How to populate and return List<T> from Parallel.ForEach using partitioning

Viewed 320

I am trying to learn usage of Parallel.ForEach loop with partitioning. I encounter a code and try to modify it but getting error.

See one example code:

 namespace TaskPartitionExample
 {
     public partial class Form1 : Form
     {
         public Form1()
         {
             InitializeComponent();
         }
    
         private void button1_Click(object sender, EventArgs e)
         {
             List<Person> persons = GetPerson();
             int ageTotal = 0;
    
             Parallel.ForEach
             (
                 persons,
                 () => 0,
                 (person, loopState, subtotal) => subtotal + person.Age,
                 (subtotal) => Interlocked.Add(ref ageTotal, subtotal)
             );
    
             MessageBox.Show(ageTotal.ToString());
         }
    
         static List<Person> GetPerson()
         {
             List<Person> p = new List<Person>
             {
                 new Person() { Id = 0, Name = "Artur", Age = 5 },
                 new Person() { Id = 1, Name = "Edward", Age = 10 },
                 new Person() { Id = 2, Name = "Krzysiek", Age = 20 },
                 new Person() { Id = 3, Name = "Piotr", Age = 15 },
                 new Person() { Id = 4, Name = "Adam", Age = 10 }
             };
    
             return p;
         }
     }
    
     class Person
     {
         public int Id { get; set; }
         public string Name { get; set; }
         public int Age { get; set; }
     }
 }

I tried to change the above code to create new List<T> instance from localInit section of Parallel.ForEach and from body populate List<T> with data and from localFinally return the new List<T> but facing problem.

List<Person> persons1 = new List<Person>();
Parallel.ForEach(persons, new Person(), drow =>
    {
    },
    (persons1) => lock{}
    );

Please help me to do it. I want to populate a list from Parallel.ForEach with data from another list. I want to create a local List<T> which I like to populate with data from my global List<T>. How it will be possible? How can I declare a local list with in Parallel.ForEach localInit section? from body section I want to populate that local list with data from global List<T> and from localFinally block that want to return my local List<T> to outside. Please guide me to achieve this.

2 Answers

As an exercise you can do something like this:

var persons1 = new List<Person>();
var locker = new object();
Parallel.ForEach(
    persons,
    () => new List<Person>(), // initialize aggregate per thread 
    (person, loopState, subtotal) =>
    {
        subtotal.Add(person); // add current thread element to aggregate 
        return subtotal; // return current thread aggregate
    },
    p => // action to combine all threads results
    {
        lock (locker) // lock, cause List<T> is not a thread safe collection
        {
            persons1.AddRange(p);
        }
    }
);

This should not be used in production code though. What you should do usually depends on actual task you want to perform.

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));
        }
    }
Related