Parallel.ForEach doesn't have a method which returns results for each process as a list aggregate. You need to synchronize with some local variable defined outside the loop.
Microsoft has an example where they use a ConcurrentBag and add elements to this within each process, https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/how-to-write-a-simple-parallel-foreach-loop.
Alternatives to use Parallel.ForEach is AsParallel() from PLINQ. Parallel compared to PLINQ does take into account the current work on the CPU, where PLINQ just uses as much resources as it can - from Concurrency in C# Cookbook, 2nd Edition
I implemented several different ways to solve the issue below using Parallel with and without local list, PLINQ and Tasks.
When using Parallel I also tried using different kind of lists.
public record struct Person(int Age);
public static class Question {
public static IList<Person> PLinq(IList<Person> input)
{
return input.AsParallel().Select(p =>
{
p.Age += 1;
return p;
}).ToList();
}
// Example from Microsoft uses ConcurrentBag
// https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/how-to-write-a-simple-parallel-foreach-loop
public static IList<Person> ParallelForEachConcurrentBag(IList<Person> input)
{
var persons = new ConcurrentBag<Person>();
Parallel.ForEach(input, (p) =>
{
p.Age += 1;
persons.Add(p);
});
return persons.ToList();
}
public static IList<Person> ParallelForEachLock(IList<Person> input)
{
object mutex = new();
var persons = new List<Person>(input.Count);
Parallel.ForEach(input, (p) =>
{
p.Age += 1;
lock (mutex)
{
persons.Add(p);
}
});
return persons;
}
public static IList<Person> ParallelForEachLocalInitLock(IList<Person> input)
{
object mutex = new();
var persons = new List<Person>(input.Count);
Parallel.ForEach(
source: input,
localInit: () => new List<Person>(),
body: (p, state, local) => {
p.Age += 1;
local.Add(p);
return local;
},
local =>
{
lock (mutex)
{
persons.AddRange(local);
}
});
return persons;
}
public static IList<Person> ParallelForEachImmutable(IList<Person> input)
{
var persons = ImmutableList<Person>.Empty;
Parallel.ForEach(input, (p) =>
{
p.Age += 1;
ImmutableInterlocked.Update(ref persons, l => l.Add(p));
});
return persons;
}
public static IList<Person> ParallelForEachLocalInitImmutable(IList<Person> input)
{
var persons = ImmutableList<Person>.Empty;
Parallel.ForEach(
source: input,
localInit: () => new List<Person>(),
body: (p, state, local) => {
p.Age += 1;
local.Add(p);
return local;
},
local =>
{
ImmutableInterlocked.Update(ref persons, l => l.AddRange(local));
});
return persons;
}
public static async Task<IList<Person>> TaskAsync(IList<Person> input)
{
var tasks = new List<Task<Person>>();
foreach (var p in input)
{
tasks.Add(Task.Run(() => UpdateAge(p)));
}
return await Task.WhenAll(tasks);
}
private static Person UpdateAge(Person p)
{
p.Age += 1;
return p;
}
}
To test the speed I ran a benchmark with input list sizes of 10, 100, 1.000 and 10.000.
The results are given below and on my computer the best result are to use Parallel with a local list for each process, and then in the end append lists together using a mutex (lock).
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1889 (21H1/May2021Update)
Intel Core i7-8565U CPU 1.80GHz (Whiskey Lake), 1 CPU, 8 logical and 4 physical cores
.NET SDK=6.0.302
[Host] : .NET 6.0.7 (6.0.722.32202), X64 RyuJIT
DefaultJob : .NET 6.0.7 (6.0.722.32202), X64 RyuJIT
| Method |
Size |
Mean |
Error |
StdDev |
Median |
Gen 0 |
Gen 1 |
Gen 2 |
Allocated |
| PLinq |
10 |
10.143 μs |
0.2028 μs |
0.5236 μs |
10.325 μs |
1.1902 |
- |
- |
5 KB |
| ParallelForEachConcurrentBag |
10 |
8.424 μs |
0.1659 μs |
0.1470 μs |
8.436 μs |
0.5646 |
0.2747 |
- |
3 KB |
| ParallelForEachLock |
10 |
5.266 μs |
0.1011 μs |
0.1315 μs |
5.251 μs |
0.6180 |
- |
- |
3 KB |
| ParallelForEachLocalInitLock |
10 |
5.097 μs |
0.0759 μs |
0.0634 μs |
5.112 μs |
0.6866 |
- |
- |
3 KB |
| ParallelForEachImmutable |
10 |
7.604 μs |
0.0536 μs |
0.0501 μs |
7.597 μs |
1.5411 |
- |
- |
6 KB |
| ParallelForEachLocalInitImmutable |
10 |
5.484 μs |
0.1075 μs |
0.1320 μs |
5.444 μs |
0.8774 |
- |
- |
4 KB |
| TaskAsync |
10 |
6.690 μs |
0.0400 μs |
0.0334 μs |
6.688 μs |
0.5722 |
- |
- |
2 KB |
| PLinq |
100 |
14.823 μs |
0.2029 μs |
0.1898 μs |
14.871 μs |
1.7090 |
- |
- |
7 KB |
| ParallelForEachConcurrentBag |
100 |
17.026 μs |
0.1636 μs |
0.1531 μs |
16.983 μs |
1.0376 |
0.5188 |
- |
6 KB |
| ParallelForEachLock |
100 |
16.346 μs |
0.2262 μs |
0.2116 μs |
16.333 μs |
0.8545 |
- |
- |
4 KB |
| ParallelForEachLocalInitLock |
100 |
7.651 μs |
0.1108 μs |
0.0983 μs |
7.679 μs |
1.1444 |
- |
- |
5 KB |
| ParallelForEachImmutable |
100 |
78.945 μs |
0.5810 μs |
0.5434 μs |
78.954 μs |
49.0723 |
- |
- |
199 KB |
| ParallelForEachLocalInitImmutable |
100 |
19.134 μs |
0.3173 μs |
0.2968 μs |
19.054 μs |
3.8452 |
- |
- |
16 KB |
| TaskAsync |
100 |
45.111 μs |
0.8115 μs |
0.7591 μs |
45.291 μs |
4.6997 |
- |
- |
19 KB |
| PLinq |
1000 |
57.280 μs |
0.8254 μs |
0.7720 μs |
57.241 μs |
5.5542 |
- |
- |
22 KB |
| ParallelForEachConcurrentBag |
1000 |
49.310 μs |
0.9353 μs |
1.0008 μs |
48.897 μs |
4.5776 |
2.2583 |
0.0610 |
27 KB |
| ParallelForEachLock |
1000 |
180.092 μs |
3.1611 μs |
2.8022 μs |
180.082 μs |
1.7090 |
- |
- |
8 KB |
| ParallelForEachLocalInitLock |
1000 |
18.550 μs |
0.1255 μs |
0.1113 μs |
18.532 μs |
5.1575 |
- |
- |
20 KB |
| ParallelForEachImmutable |
1000 |
1,162.524 μs |
9.1828 μs |
7.6681 μs |
1,162.008 μs |
814.4531 |
3.9063 |
- |
3,314 KB |
| ParallelForEachLocalInitImmutable |
1000 |
76.731 μs |
0.8213 μs |
0.7281 μs |
76.789 μs |
27.9541 |
- |
- |
113 KB |
| TaskAsync |
1000 |
444.637 μs |
8.0363 μs |
7.5171 μs |
445.054 μs |
44.9219 |
0.4883 |
- |
184 KB |
| PLinq |
10000 |
321.782 μs |
4.2552 μs |
3.9803 μs |
320.797 μs |
77.1484 |
19.0430 |
- |
262 KB |
| ParallelForEachConcurrentBag |
10000 |
326.800 μs |
6.4293 μs |
7.4039 μs |
324.472 μs |
54.6875 |
27.3438 |
6.8359 |
287 KB |
| ParallelForEachLock |
10000 |
1,026.265 μs |
49.5168 μs |
146.0014 μs |
1,048.246 μs |
11.7188 |
0.9766 |
- |
48 KB |
| ParallelForEachLocalInitLock |
10000 |
75.856 μs |
0.3989 μs |
0.3536 μs |
75.814 μs |
52.7344 |
0.2441 |
- |
165 KB |
| ParallelForEachImmutable |
10000 |
17,307.585 μs |
319.1086 μs |
282.8814 μs |
17,335.630 μs |
10937.5000 |
250.0000 |
93.7500 |
46,350 KB |
| ParallelForEachLocalInitImmutable |
10000 |
523.030 μs |
10.0367 μs |
11.9479 μs |
521.513 μs |
183.5938 |
81.0547 |
- |
985 KB |
| TaskAsync |
10000 |
5,353.178 μs |
103.1810 μs |
137.7437 μs |
5,356.836 μs |
312.5000 |
109.3750 |
54.6875 |
1,936 KB |
The results depends on the hardware and how many parallel processes are started. You can run the benchmark yourself by running the benchmark code below on your own machine.
using BenchmarkDotNet.Attributes;
namespace Benchmark
{
[MemoryDiagnoser]
public class QuestionBenchmark
{
private IList<Person>? input;
[Params(10, 100, 1_000, 10_000)]
public int Size { get; set; }
[GlobalSetup]
public void Setup()
{
input = new List<Person>(Size);
for (int i = 0; i < Size; i++)
{
input.Add(new Person(Age: Random.Shared.Next(10,100)));
}
}
[Benchmark]
public void PLinq()
{
_ = Question.PLinq(input!);
}
[Benchmark]
public void ParallelForEachConcurrentBag()
{
_ = Question.ParallelForEachConcurrentBag(input!);
}
[Benchmark]
public void ParallelForEachLock()
{
_ = Question.ParallelForEachLock(input!);
}
[Benchmark]
public void ParallelForEachLocalInitLock()
{
_ = Question.ParallelForEachLocalInitLock(input!);
}
[Benchmark]
public void ParallelForEachImmutable()
{
_ = Question.ParallelForEachImmutable(input!);
}
[Benchmark]
public void ParallelForEachLocalInitImmutable()
{
_ = Question.ParallelForEachLocalInitImmutable(input!);
}
[Benchmark]
public async Task TaskAsync()
{
_ = await Question.TaskAsync(input!);
}
}
}
I made some test to validate all solutions returns correct results - they are given below.
public class QuestionTests
{
private readonly IList<Person> expected = new List<Person>()
{
new Person{Age=2},new Person{Age=4},new Person{Age=6}
};
private readonly IList<Person> input = new List<Person>()
{
new Person{Age=1},new Person{Age=3},new Person{Age=5}
};
[Fact]
public void PLinqTest() {
// Act
var actual = Question.PLinq(input);
// Assert
Assert.True(actual.All(expected.Contains));
}
#region ConcurrentBag
[Fact]
public void ParallelForEachConcurrentBagTest()
{
// Act
var actual = Question.ParallelForEachConcurrentBag(input);
// Assert
Assert.True(actual.All(expected.Contains));
}
#endregion
#region Lock
[Fact]
public void ParallelForEachLockTest()
{
// Act
var actual = Question.ParallelForEachLock(input);
// Assert
Assert.True(actual.All(expected.Contains));
}
[Fact]
public void ParallelForEachLocalInitLockTest()
{
// Act
var actual = Question.ParallelForEachLocalInitLock(input);
// Assert
Assert.True(actual.All(expected.Contains));
}
#endregion
#region Immutable
[Fact]
public void ParallelForEachImmutableTest()
{
// Act
var actual = Question.ParallelForEachImmutable(input);
// Assert
Assert.True(actual.All(expected.Contains));
}
[Fact]
public void ParallelForEachLocalInitImmutableTest()
{
// Act
var actual = Question.ParallelForEachLocalInitImmutable(input);
// Assert
Assert.True(actual.All(expected.Contains));
}
#endregion
[Fact]
public async Task TaskTest()
{
// Act
var actual = await Question.TaskAsync(input);
// Assert
Assert.True(actual.All(expected.Contains));
}
}