Skip to content

What is Parallelism?

Parallelism means doing more than one task at exactly the same instant, using more than one worker. In a computer, the workers are CPU cores.

In the previous lesson one chef switched between three dishes (concurrency). Now imagine three chefs, each cooking one dish at the same time. That is parallelism: real work happening at the same moment on different hands.

Why parallelism needs multiple cores

A CPU core can execute only one instruction stream at a time. Most modern laptops have 4, 8 or more cores, so a program can run several pieces of work truly side by side. You can check how many cores your machine has:

Console.WriteLine($"This machine has {Environment.ProcessorCount} logical cores");
Output
This machine has 8 logical cores

On a machine with only one core, there can be concurrency (switching), but there cannot be real parallelism.

What kind of work benefits from parallelism?

Parallelism helps with CPU-bound work: work where the CPU is busy calculating the whole time, for example:

  • Processing thousands of images (resize, compress)
  • Big calculations: reports, statistics, encryption, prime numbers
  • Parsing and transforming large files in memory

It does not help much with I/O work like database calls or web requests, because there the CPU is mostly waiting, not calculating. For waiting work we use async/await.

Parallel.For and Parallel.ForEach

The easiest way to use parallelism in C# is the Parallel class in System.Threading.Tasks. It splits a loop across the available cores for you.

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        string[] files = { "a.jpg", "b.jpg", "c.jpg", "d.jpg" };

        Parallel.ForEach(files, file =>
        {
            Console.WriteLine($"Processing {file} on thread {Environment.CurrentManagedThreadId}");
        });
    }
}
Output (order and thread numbers change on every run)
Processing a.jpg on thread 1
Processing c.jpg on thread 6
Processing b.jpg on thread 4
Processing d.jpg on thread 7

Different thread numbers prove that the items were processed on different threads at the same time. The order is not fixed, because the threads race each other.

Measuring the speed-up

Let's count prime numbers, a pure CPU job, first with a normal loop and then with Parallel.For.

using System;
using System.Diagnostics;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        int[] limits = { 2_000_000, 2_000_000, 2_000_000, 2_000_000 };

        Stopwatch watch = Stopwatch.StartNew();
        for (int i = 0; i < limits.Length; i++)
        {
            CountPrimes(limits[i]);
        }
        Console.WriteLine($"Normal for loop  : {watch.ElapsedMilliseconds} ms");

        watch.Restart();
        Parallel.For(0, limits.Length, i =>
        {
            CountPrimes(limits[i]);
        });
        Console.WriteLine($"Parallel.For loop: {watch.ElapsedMilliseconds} ms");
    }

    static int CountPrimes(int max)
    {
        int count = 0;
        for (int n = 2; n <= max; n++)
        {
            bool isPrime = true;
            for (int d = 2; d * d <= n; d++)
            {
                if (n % d == 0) { isPrime = false; break; }
            }
            if (isPrime) count++;
        }
        return count;
    }
}
Output (on an 8-core laptop; your numbers will be different)
Normal for loop  : 2480 ms
Parallel.For loop: 690 ms

Four equal jobs ran on four cores at the same time, so the parallel loop was roughly 3.5 times faster. It is rarely exactly 4 times, because splitting the work and combining the results also takes a little time.

Other ways to run code in parallel

// 1. Task.Run: run a piece of CPU work on a thread-pool thread
Task<int> t1 = Task.Run(() => CountPrimes(2_000_000));
Task<int> t2 = Task.Run(() => CountPrimes(3_000_000));
int[] counts = await Task.WhenAll(t1, t2);   // both counted at the same time

// 2. PLINQ: add AsParallel() to a LINQ query
var bigNumbers = Enumerable.Range(1, 10_000_000)
                           .AsParallel()
                           .Where(n => n % 7 == 0)
                           .ToList();

Things to be careful about

  • Small work gets slower, not faster. If each item takes microseconds, the cost of splitting work across threads is bigger than the gain.
  • Shared data needs protection. Many threads writing to the same List<T> or variable at once will corrupt it. Use ConcurrentBag<T>, ConcurrentDictionary, lock or Interlocked.
  • Order is not guaranteed. Don't rely on items being processed in sequence.
  • Don't use it for I/O. Calling a web API inside Parallel.ForEach blocks many threads while they wait. Use Task.WhenAll or Parallel.ForEachAsync (.NET 6+) instead.

Summary

  • Parallelism means doing several tasks at exactly the same instant on different CPU cores.
  • It needs more than one core and is meant for CPU-bound work.
  • Use Parallel.For, Parallel.ForEach, Task.Run or PLINQ (AsParallel).
  • The speed-up depends on the number of cores and the size of the work.
  • Protect shared data and don't expect results in a fixed order.