Home > Enterprise >  What is the meaning of the MaxDegreeOfParallelism = -1 in Parallel operations in .NET 6?
What is the meaning of the MaxDegreeOfParallelism = -1 in Parallel operations in .NET 6?

Time:03-28

The documentation of the ParallelOptions.MaxDegreeOfParallelism property states that:

The MaxDegreeOfParallelism property affects the number of concurrent operations run by Parallel method calls that are passed this ParallelOptions instance. A positive property value limits the number of concurrent operations to the set value. If it is -1, there is no limit on the number of concurrently running operations.

By default, For and ForEach will utilize however many threads the underlying scheduler provides, so changing MaxDegreeOfParallelism from the default only limits how many concurrent tasks will be used.

I am trying to understand what "no limit" means in this context. Based on the above excerpt from the docs, my expectation was that a Parallel.Invoke operation configured with MaxDegreeOfParallelism = -1 would start executing immediately in parallel all the supplied actions. But this is not what happening. Here is an experiment with 12 actions:

int concurrency = 0;
Action action = new Action(() =>
{
    var current = Interlocked.Increment(ref concurrency);
    Console.WriteLine(@$"Started an action at {DateTime
        .Now:HH:mm:ss.fff} on thread #{Thread
        .CurrentThread.ManagedThreadId} with concurrency {current}");
    Thread.Sleep(1000);
    Interlocked.Decrement(ref concurrency);
});
Action[] actions = Enumerable.Repeat(action, 12).ToArray();
var options = new ParallelOptions() { MaxDegreeOfParallelism = -1 };
Parallel.Invoke(options, actions);

Output:

Started an action at 11:04:42.636 on thread #6 with concurrency 4
Started an action at 11:04:42.636 on thread #7 with concurrency 5
Started an action at 11:04:42.629 on thread #1 with concurrency 1
Started an action at 11:04:42.636 on thread #8 with concurrency 3
Started an action at 11:04:42.630 on thread #4 with concurrency 2
Started an action at 11:04:43.629 on thread #9 with concurrency 6
Started an action at 11:04:43.648 on thread #6 with concurrency 6
Started an action at 11:04:43.648 on thread #8 with concurrency 6
Started an action at 11:04:43.648 on thread #4 with concurrency 6
Started an action at 11:04:43.648 on thread #7 with concurrency 6
Started an action at 11:04:43.648 on thread #1 with concurrency 6
Started an action at 11:04:44.629 on thread #9 with concurrency 6

(Live demo)

The result of this experiment does not match my expectations. Not all actions were invoked immediately. The maximum concurrency recorded is 6, and sometimes 7, but not 12. So the "no limit" does not mean what I think it means. My question is: what does the MaxDegreeOfParallelism = -1 configuration means exactly, with all four Parallel methods (For, ForEach, ForEachAsync and Invoke)? I want to know in details what's the behavior of these methods, when configured this way. In case there are behavioral differences between .NET versions, I am interested about the current .NET version (.NET 6), which also introduced the new Parallel.ForEachAsync API.

Secondary question: Is the MaxDegreeOfParallelism = -1 exactly the same with omitting the optional parallelOptions argument in these methods?


Clarification: I am interested about the behavior of the Parallel methods when configured with the default TaskScheduler. I am not interested about any complications that might arise by using specialized or custom schedulers.

CodePudding user response:

The definition is deliberately states as -1 means that the number of number of concurrent operations will not be artificially limited. and it doesn't say that all actions will start immediately.

The thread pool manager normally keeps the number of available threads at the number of cores (or logical processor which are 2x number of cores) and this is considered the optimum number of threads (I think this number is [number of cores/logical processor 1]) . This means that when you start executing your actions the number of available threads to immediately start work is this number.

Thread pool manager runs periodically (twice a second) and a if none of the threads have completed a new one is added (or removed in the reverse situation when there are too many threads).

A good experiment to see this in action is too run your experiment twice in quick succession. In the first instance the number of concurrent jobs at the beginning should be around number of cores/logical processor 1 and in 2nd run it should be the number of jobs run (because these threads were created to service the first run:

Here's a modified version of your code:

using System.Diagnostics;

Stopwatch sw = Stopwatch.StartNew();
int concurrency = 0;
Action action = new Action(() =>
{
    var current = Interlocked.Increment(ref concurrency);
    Console.WriteLine(@$"Started at {sw.ElapsedMilliseconds} with concurrency {current}");
    Thread.Sleep(10_000);
    current = Interlocked.Decrement(ref concurrency);
});


Action[] actions = Enumerable.Repeat(action, 12).ToArray();
var options = new ParallelOptions() { MaxDegreeOfParallelism = -1 };
Parallel.Invoke(options, actions);

Parallel.Invoke(options, actions);

Output:

Started at 114 with concurrency 8
Started at 114 with concurrency 1
Started at 114 with concurrency 2
Started at 114 with concurrency 3
Started at 114 with concurrency 4
Started at 114 with concurrency 6
Started at 114 with concurrency 5
Started at 114 with concurrency 7
Started at 114 with concurrency 9
Started at 1100 with concurrency 10
Started at 2097 with concurrency 11
Started at 3100 with concurrency 12
Started at 13110 with concurrency 1
Started at 13110 with concurrency 2
Started at 13110 with concurrency 3
Started at 13110 with concurrency 5
Started at 13110 with concurrency 7
Started at 13110 with concurrency 9
Started at 13110 with concurrency 10
Started at 13110 with concurrency 11
Started at 13110 with concurrency 4
Started at 13110 with concurrency 12
Started at 13110 with concurrency 6
Started at 13110 with concurrency 8

My computer has 4 cores (8 logical processors) and we when the jobs run on a "cold" TaskScheduler.Default at first 8 1 of them are started immediately and after that a new thread is added periodically.

Then, when running the second batch "hot" then all jobs start at the same time.

Parallel.ForEachAsync

When a similar example is run with Parallel.ForEachAsync the behaviour is different. The work is done at a constant level of paralellism. Please not that this is not about threads because if you await Task.Delay (so not blocking the thread`) the number of parallel jobs stays the same.

If wee peek at the source code for the version taking ParallelOptions it passes parallelOptions.EffectiveMaxConcurrencyLevel as dop to the private method which does the real work.

public static Task ForEachAsync<TSource>(IEnumerable<TSource> source!!, ParallelOptions parallelOptions!!, Func<TSource, CancellationToken, ValueTask> body!!)
{
     return ForEachAsync(source, parallelOptions.EffectiveMaxConcurrencyLevel, ...);
}

If we peek further we can see that:

  • "dop" is documented as 'A integer indicating how many operations to allow to run in parallel.'.
  • the actual level of parallelism is DefaultDegreeOfParallelism.
/// <param name="dop">A integer indicating how many operations to allow to run in parallel.</param>
(...)
private static Task ForEachAsync<TSource>(IEnumerable<TSource> source, int dop,
{
    ...

    if (dop < 0)
    {
        dop = DefaultDegreeOfParallelism;
    }

One last peek, and we can see the final value is Environment.ProcessorCount.

private static int DefaultDegreeOfParallelism => Environment.ProcessorCount;

This is what it is now and I am not sure if this will stay like this in .NET 7.

  • Related