The List<T>
class is thread-safe only for multiple readers. As long as you add a writer in the mix, you must synchronize all interactions with the collection, otherwise its behavior is undefined. The simplest synchronization tool is the lock
statement. This statement requires a locker object
, which can be any reference type, and usually is either a dedicated new object()
or the list itself. The locker should not "leak" to the outside world, so using the list itself as the locker is only viable if the list is internal, and you don't expose it to unknown code.
For demonstration purposes I'll show an example with a simpler list than your lstDataSetCdTasks
, a list that contains value tuples with just two members. I am showing three approaches of incrementing the NumProcessed
member . Take a look at them, and I'll explain them below:
List<(string DataSetCode, int NumProcessed)> list
= new() { ("A", 0), ("B", 0), ("C", 0) };
int index = 1;
Console.WriteLine($"Before: {String.Join(", ", list)}");
lock (list)
{
(string DataSetCode, int NumProcessed) temp = list[index];
temp.NumProcessed++;
list[index] = temp;
}
Console.WriteLine($"After1: {String.Join(", ", list)}");
lock (list)
{
Span<(string DataSetCode, int NumProcessed)> span = CollectionsMarshal
.AsSpan(list);
span[index].NumProcessed++;
}
Console.WriteLine($"After2: {String.Join(", ", list)}");
// Incorrect code, for educational purposes only
{
Span<(string DataSetCode, int NumProcessed)> span = CollectionsMarshal
.AsSpan(list);
Interlocked.Increment(ref span[index].NumProcessed);
}
Console.WriteLine($"After3: {String.Join(", ", list)}");
Output:
Before: (A, 0), (B, 0), (C, 0)
After1: (A, 0), (B, 1), (C, 0)
After2: (A, 0), (B, 2), (C, 0)
After3: (A, 0), (B, 3), (C, 0)
Online demo.
The first approach locks on the list
, creating a protected region that only one thread can enter at a time¹. Inside the protected region we store a copy of a tuple in a temp
variable, we mutate the copy, and then we replace the existing tuple in the list with the mutated copy. This is the simplest way to mutate a value-type stored in a List<T>
.
The second approach again locks on the list
, and then uses the advanced CollectionsMarshal.AsSpan
to get a Span<T>
representation of the list. With the Span<T>
you gain direct access to the backing array of the list, and so you can mutate the stored value-tuples in-place, without using temporary variables. This is the most efficient way of mutating value-types stored in a List<T>
.
The third approach doesn't use the lock
statement, and instead attempts to grab the Span<T>
and then mutate an entry with the Interlocked.Increment
method. This is valid C# code, but it is not thread-safe and it has undefined behavior. The problem is that another thread might perform concurrently an action that will replace the backing array of the list, in which case the mutation performed by the current thread will be lost. This would be a valid approach though if instead of a list you stored your tuples in an array ((string DataSetCode, int NumProcessed)[]
). The arrays have fixed length, so they are less versatile than lists. But they open some opportunities for lock-free multithreading, opportunities that lists totally lack. I am not advising you to pursue these opportunities though. As a beginner in multithreading, it's much safer to stick with the lock
. As long as you don't do anything heavy inside the protected regions, the lock
s are cheap and won't slow down your application.
¹ Provided that all other interactions with the list are performed in lock
regions protected with the same locker. Caution: even a single unprotected interaction with the list, even reading the Count
property, renders your program invalid and it's behavior undefined. Enumerating the list should also be enclosed in a protected region. Interacting with the List<T>.Enumerator
counts as interacting with the list itself.