1

Consider the following code:

private static void IncrementList(List<int> items)
{
    Enumerable.Range(0, items.Count).AsParallel().ForAll(i =>
    {
        items[i] += 1;
    });
}

Is this "safe"?

I believe it is safe, because there is no adding or removing which would resize the backing array. However, after learning about the volatile keyword and why it exists, I am wondering if there could be issues with cached cpu values since there is no locking.

var numbers = Enumerable.Range(0, 100).Select(_ => 1).ToList();
IncrementList(numbers);
foreach (var number in numbers) {
    Console.WriteLine(number); // could this write a stale "1" value since IncrementList has no locking?
}

The practical usage for this is I have an API where the request supplies a list of Ids, I need to request the resources for each Id (which I would like to do in parallel because each requires a network request), and I need to return the response in the same order as the requested list of Ids.

MattHH
  • 57
  • 4
  • 2
    I think it is safe, because each core will demand the cache line when it needs it, and the cache remains coherent between cores due to MESI. `volatile` is an instruction to the JIT to force an actual main memory read/write (rather than using a register), it is not necessary here as no other core touches that exact location. Whether all this is performant compared to doing it on one core: in this instance probably not. – Charlieface Mar 31 '21 at 00:13
  • Be aware you are depending on implementation details of `List` (the fact that it uses an array internally). – Theraot Mar 31 '21 at 00:53
  • "thread safe" is not a well-defined term. There are lots of different kinds of thread safety. For `List`, the only thread safety guaranteed by the class is that you may _read_ from the list from any number of threads concurrently without corrupting the data in the list. That's all. Anything else is _not safe_. For the current implementation of the class, you can get away with modifying individual elements from different threads, but the class doesn't guarantee this is safe, and you should not write code that assumes it. Use an array if you want that. See duplicates. – Peter Duniho Mar 31 '21 at 00:59
  • as a note, you'd be better off using ```Parallel.For``` – Keith Nicholas Mar 31 '21 at 01:08
  • 1
    Related: [1](https://stackoverflow.com/questions/28871878/are-you-there-asynchronously-written-value "Are you there, asynchronously written value?"), [2](https://stackoverflow.com/questions/6581848/memory-barrier-generators "Memory barrier generators"). But you can achieve what you want (return the responses in the same order as the requested Ids) by using the `Select` operator, instead of relying on the (not well documented) visibility guarantees offered by `ForAll` and similar side-effect-inducing operations: `int[] responses = items.AsParallel().AsOrdered().Select(i => i + 1).ToArray();` – Theodor Zoulias Mar 31 '21 at 01:10
  • @KeithNicholas the `Parallel.For`/`Parallel.ForEach` methods are more primitive than PLINQ, and require greater care when used. For example if you don't specify explicitly the `MaxDegreeOfParallelism` option, their default behavior is to [saturate the `ThreadPool`](https://stackoverflow.com/questions/66261010/multiple-parallel-foreach-loops-in-net/66263583#66263583), which is rarely a desirable behavior. Personally I would strongly recommend PLINQ over the `Parallel` class, whenever possible. – Theodor Zoulias Mar 31 '21 at 01:19
  • @TheodorZoulias it can saturate the threadpool, but depends what you are doing in parallel, in general it shouldn't, but you have a lot of control over it, the advice in https://learn.microsoft.com/en-us/dotnet/api/System.Threading.Tasks.ParallelOptions.MaxDegreeOfParallelism?view=net-5.0&viewFallbackFrom=netcore-5.0 is pretty good – Keith Nicholas Mar 31 '21 at 01:47
  • @KeithNicholas from the documentation of `ParallelOptions.MaxDegreeOfParallelism`: *"Generally, you do not need to modify this setting"*. I highly disagree with this advice! It probably comes from an era where the `ThreadPool` was underutilized. In the era of async-await this is not true any more, and saturating the `ThreadPool` is quite likely to have consequences. But even in the pre-async-await era, it's beyond me why anyone would consider saturating the `ThreadPool` to be a good idea. – Theodor Zoulias Mar 31 '21 at 03:03
  • @KeithNicholas if saturating the `ThreadPool` was a good idea, as the advice of the documentation implies, then the (relatively modern) PLINQ would have the same default. But it doesn't. The PLINQ's default degree of parallelism is `Environment.ProcessorCount`, not unbounded. – Theodor Zoulias Mar 31 '21 at 03:12
  • @TheodorZoulias you talk like it's a guarantee, but from my experience it tends to ~= ProcessorCount, with waits and blocks maybe up to ~= 2-3x ProcessorCount. So it adapts nicely to blocks while keeping the CPU loaded. Not sure what you'd actually have to do saturate the thread pool, maybe if you were using Parallel as some kind of weird way to acquire a bunch of threads to do random tasks. – Keith Nicholas Mar 31 '21 at 03:48
  • @KeithNicholas you are experiencing the effects of a saturated `ThreadPool`. AFAIK the number of threads that the `ThreadPool` creates instantly on demand (by default) is `Environment.ProcessorCount`, and then one extra thread is injected every 500 msec until the saturation incident ends. Effectively the `ThreadPool` availability becomes the limiting factor of the parallel processing. You could try giving it for example 100 initial threads (`ThreadPool.SetMinThreads(100, 100);`), and see how this affects the degree of parallelism. – Theodor Zoulias Mar 31 '21 at 04:03
  • 1
    @TheodorZoulias thank you! You got to the heart of the issue. It is probably my mistake for phrasing the question in such a way - I should have emphasized the volatile part. Am I correct to interpret those related links that, when using TPL, appropriate memory barriers are constructed so that I do not have to worry? – MattHH Mar 31 '21 at 15:49
  • To expand, it seems that when using TPL, when the tasks are joined there should be memory barriers, so when the "main" execution continues after the TPL call it will see the latest values. – MattHH Mar 31 '21 at 18:24
  • 1
    @MattHH yes, I believe that your code is OK, regarding the visibility of the `numbers` list after the completion of the PLINQ query. But although it's probably OK, I would avoid having code like that in my application code. The more [lock-free](https://stackoverflow.com/a/2529773/11178549) code you have to write and maintain, the more chances for [haisenbugs](https://en.wikipedia.org/wiki/Heisenbug) to creep in and haunt your project for months and years. – Theodor Zoulias Mar 31 '21 at 22:07

1 Answers1

2

Update

On 2nd look your question seems to mostly about // could this write a stale "1" value since IncrementList has no locking?.

The answer is no and all values will be incremented.

Your code has 101 "writes", 1 creation of the list and 100 updates after creation. Let's assume the very unlikely scenario that each of the writes is performed by a new thread. Even in this case there is not logical possibility for a thread to see a stale value.

Please note that in case multiple threads would update the same item[k] locking, not volatile should be used. Answers to volatile for reference type in .net 4.0 are a good read.


Old, somewhat still relevant answer.

Your context

It seems that in your example each worker will visit items[i] once - to assign the resource it obtained (or set error, etc.).

Multiple workers (threads) updating a list where each item is only visited once with no locking is safe.

More generally

If different threads are doing incrementing for a given index then it isn't safe.

Here's an example:

var a = new int[]{0,0,0,0,0};

Enumerable.Range(0, 1000).AsParallel().ForAll(i =>
{
    a[i % a.Length] += 1;
});

Console.WriteLine(string.Join(",", a));

The output is:

189,192,188,185,189

One way to address this is to use Interlocked.Increment Method:

Interlocked.Increment(ref a[i % a.Length]);

The output now is:

200,200,200,200,200
tymtam
  • 31,798
  • 8
  • 86
  • 126
  • 1
    I doubt OP is that much concerned about thread safety, providing unique index to every thread will work well, he's more worried about CPU level caching of data which can be managed using volatile keyword for a variable, which is a memory barrier – Mrinal Kamboj Mar 31 '21 at 04:20
  • Yeah @mrinal-kamboj, on 2nd look the key question seems to be mostly about: `// could this write a stale "1" value since IncrementList has no locking` – tymtam Mar 31 '21 at 07:35
  • Exactly correct @MrinalKamboj – MattHH Mar 31 '21 at 15:46
  • @tymtam thank you for your answer! The part I do not fully understand is "Even in this case there is not logical possibility for a thread to see a stale value". I believe that it is correct, but why? – MattHH Mar 31 '21 at 18:36
  • 1
    What I mean is that all threads start when all "cells" of the list have 1 in them. Each update targets a different index so they all do "read index that no one else touches, add 1, write index that on one else touches". No combination of events leads to a possibility of multiple actors accessing the same value. – tymtam Mar 31 '21 at 22:30
  • Yes that is true. I was more worried about the cpu which performs the 'main' execution after the PLINQ seeing stale values. – MattHH Mar 31 '21 at 22:37