Skip to content

8. Execution Threads

This chapter introduces the simultaneous execution of multiple pieces of code within a single program: threads (execution threads) and the tools used to synchronize them. In 2008, most machines had only one processor, and parallelism was largely an illusion maintained by the operating system. Today, even the most basic laptop has multiple cores: multiple threads actually run at the same time, making the synchronization issues discussed here even more prevalent.

The examples are single-file applications ([dotnet run threads-01.cs]) located in the [08-threads] folder. Note that the implicit directives [using] for these applications include [System.Threading] and [System.Threading.Tasks]: the classes [Thread], [Lock], [Mutex], [Monitor], [Interlocked], [Task]… can be used without the [using] directive.

A word of caution before you begin: the results of a multithreaded program vary from one execution to the next. The order in which threads are assigned the processor depends on the operating system, the number of processors, and the machine’s load. The results shown in this chapter were obtained on a dual-processor Linux machine shared with other programs. If you run the examples, you will get similar results, but they will rarely be identical.

8.1. The Thread Class

When an application is launched, it runs in an execution flow called a thread. The .NET class that models a thread is the [System.Threading.Thread] class. Its main constructors, properties, and methods are as follows:

Member
Role
[Thread(ThreadStart start)]
creates a thread that will execute the [start] method. The [ThreadStart] delegate refers to a method with no parameters and no return value: [void M()]
[Thread(ParameterizedThreadStart start)]
same as above, but with a method that has a parameter of type [object?]: [void M(object? obj)]
[static Thread CurrentThread]
the thread executing the code that requested this property
[string? Name]
the thread name, useful for tracking
[int ManagedThreadId]
A unique numeric identifier for the thread
[bool IsBackground]
Indicates whether the thread is a background thread (see below)
[bool IsAlive]
indicates whether the thread is currently running
[void Start()], [void Start(object? obj)]
starts the thread's execution, optionally passing it information of type [object]
[void Join()], [bool Join(TimeSpan timeout)]
The thread T1, which executes [T2.Join()], is blocked until the thread T2 has finished. The second form limits the duration of the wait
[static void Sleep(int ms)]
The thread executing the method is suspended for [ms] milliseconds. It loses the CPU, which is given to another thread

The 2008 course also covered the [Abort] method, which forcibly terminated a thread. It no longer exists in practice: since .NET Core, it throws the [PlatformNotSupportedException] exception. Abruptly stopping a thread could leave the data it was handling in an inconsistent state. Today, a thread is asked to stop cooperatively, using a cancellation token ([CancellationToken]) that the thread checks regularly: we’ll see this in the next chapter.

Here is an initial application demonstrating the existence of a main execution thread—the one that executes the program’s high-level instructions (the equivalent of the [Main] method). The [threads-01.cs] program is as follows:

// the main thread of a program

// the thread executing this code
Thread main = Thread.CurrentThread;
// at the start, it has no name
Console.WriteLine($"Name of the current thread: [{main.Name}]");
// we give it one
main.Name = "Main";
Console.WriteLine($"Name of the current thread: [{main.Name}]");
// a few properties
Console.WriteLine($"Identifier (ManagedThreadId): {main.ManagedThreadId}");
Console.WriteLine($"Background thread (IsBackground): {main.IsBackground}");
Console.WriteLine($"Pool thread (IsThreadPoolThread): {main.IsThreadPoolThread}");
Console.WriteLine($"Priority (Priority): {main.Priority}");
Console.WriteLine($"State (ThreadState): {main.ThreadState}");
Console.WriteLine($"Number of processors: {Environment.ProcessorCount}");

// a display every second
for (int i = 0; i < 5; i++)
{
    Console.WriteLine($"{main.Name}: {DateTime.Now:HH:mm:ss}");
    // the thread is suspended for 1 second
    Thread.Sleep(1000);
}

Results:

> dotnet run threads-01.cs
Name of the current thread: []
Name of the current thread: [Main]
Identifier (ManagedThreadId): 1
Background thread (IsBackground): False
Pool thread (IsThreadPoolThread): False
Priority (Priority): Normal
State (ThreadState): Running
Number of processors: 2
Main: 11:35:54
Main: 11:35:55
Main: 11:35:56
Main: 11:35:57
Main: 11:35:58

Comments:

  • Line 4: A reference to the thread executing the program is retrieved;
  • Lines 5–9: We display its name, modify it, and then display it again. Initially, the main thread has no name (line 2 of the results);
  • lines 11–15: some thread properties. The main thread has the ID 1; it is a foreground thread ([IsBackground] equals [False]) that does not belong to the thread pool (we’ll discuss these two concepts later); it is currently running ([Running]);
  • Line 16: The machine that ran the example has 2 processors;
  • lines 19–23: a loop that displays a message every second. [Thread.Sleep(1000)] suspends the main thread for one second. The 2008 course used an infinite loop here that was terminated by Ctrl-C; we are limiting ourselves to five displays. Note the format [HH:mm:ss] (hours from 0 to 23): the 2008 course used [hh], which displays hours from 1 to 12.

8.2. Creating Execution Threads

It is possible to have applications where pieces of code execute “simultaneously” in different execution threads. If the machine has fewer processors than there are threads ready to execute, the threads share the processors: each one takes turns using a processor for a short time (a few milliseconds). On a multiprocessor machine, multiple threads actually execute at the same time. The amount of time allocated to a thread depends on various factors, including its priority, which has a default value but can be set programmatically (property [Priority]). A thread that has the processor may release it early:

  • by waiting for an event (completion of another thread with [Join], waiting for a signal);
  • by entering a sleep state for a specified duration ([Sleep]).

A thread T is first created using one of the constructors presented above, for example:

Thread t = new Thread(Display);

where [Display] is a method with one of the following two signatures:

void Display();
void Display(object? obj);

Creating a thread does not start it:

  • the execution of thread T is initiated by [T.Start()]: the method passed to the constructor of T is then executed by thread T. The code that executes the instruction [T.Start()] does not wait for thread T to finish; it immediately moves on to the next instruction. We then have two threads running in parallel. They often need to communicate with each other to track the progress of the shared work: this is the problem of thread synchronization;
  • Once started, thread T runs autonomously. It stops when the method it is executing is complete;
  • we can wait for it to finish executing using [T.Join()]. This is a blocking instruction: the thread executing it is blocked until thread T has finished its work. This is a basic method of synchronization.

The program [threads-02.cs] is as follows:

// creation of execution threads

// we name the main thread
Thread.CurrentThread.Name = "Main";

// creation of 5 threads
var tasks = new Thread[5];
for (int i = 0; i < tasks.Length; i++)
{
    // thread no. i will execute the Display method
    tasks[i] = new Thread(Display);
    // we give it a name
    tasks[i].Name = i.ToString();
    // we start its execution (non-blocking operation)
    tasks[i].Start();
}

// end of the main thread
Console.WriteLine($"End of thread {Thread.CurrentThread.Name} at {DateTime.Now:HH:mm:ss.fff}");

// the method executed by the threads
static void Display()
{
    // the thread executing the method
    Thread current = Thread.CurrentThread;
    Console.WriteLine($"Start of execution of the Display method in thread {current.Name} (id {current.ManagedThreadId}): {DateTime.Now:HH:mm:ss.fff}");
    // sleep for 1 s
    Thread.Sleep(1000);
    Console.WriteLine($"End of execution of the Display method in thread {current.Name}: {DateTime.Now:HH:mm:ss.fff}");
}

Comments:

  • line 4: we give the main thread a name;
  • lines 7–15: 5 threads are created and started. Their references are stored in an array so they can be retrieved later. Each thread executes the [Display] method on lines 22–29;
  • line 11: the name of the method [Display] is automatically converted to a delegate of type [ThreadStart] (delegates were introduced in the previous chapter);
  • line 15: thread No. i is launched. This operation is non-blocking: thread No. i runs in parallel with the main thread that launched it;
  • line 19: the main thread reaches the end of the program;
  • lines 22–29: the method [Display] displays the name and ID of the thread executing it, as well as the start and end times (to the millisecond) of execution;
  • line 28: any thread executing the [Display] method pauses for 1 second. The processor is then handed over to another thread waiting for the processor. At the end of the 1-second pause, the paused thread becomes eligible for the processor again. It will obtain it when its turn comes.

Results:

> dotnet run threads-02.cs
Start of execution of the Display method in thread 2 (id 6): 11:36:01.725
Start of execution of the Display method in thread 4 (id 8): 11:36:01.725
End of thread Main at 11:36:01.725
Start of execution of the Display method in thread 0 (id 4): 11:36:01.725
Start of execution of the Display method in thread 1 (id 5): 11:36:01.725
Start of execution of the Display method in thread 3 (id 7): 11:36:01.725
End of execution of the Display method in thread 1: 11:36:02.779
End of execution of the Display method in thread 0: 11:36:02.778
End of execution of the Display method in thread 4: 11:36:02.779
End of execution of the Display method in thread 2: 11:36:02.779
End of execution of the Display method in thread 3: 11:36:02.779

These results are very informative:

  • First, we see that launching a thread is not blocking. The main thread launched 5 threads and reached the end of the program (line 4) even though three of the launched threads had not yet displayed their first message;
  • the order in which the threads execute is unpredictable: in this run, threads 2 and 4 displayed their start messages before thread 0. The operating system has 6 threads and 2 processors here; it allocates the processors to these threads according to its own rules;
  • We also note that the order of the displays does not always follow the order of the displayed times: the time is calculated before it is displayed, and in the meantime, another thread may have written to the console. [Console.WriteLine] is thread-safe: two lines written by two threads do not get mixed up, but their order is not guaranteed;
  • each thread pauses for one second with [Sleep], then completes execution of the [Display] method;
  • Finally, the program did not stop when the main thread finished its work: the process waited for all 5 threads to finish. This is because these threads are foreground threads: a .NET process terminates when all of its foreground threads have finished.

Conversely, a thread can be declared a background thread by setting its [IsBackground] property to [true] before launching it. Background threads do not prevent the process from terminating: when the last foreground thread terminates, they are stopped. The program [threads-03.cs] is as follows:

// background threads

// we name the main thread
Thread.CurrentThread.Name = "Main";

// creation of 5 background threads
var tasks = new Thread[5];
for (int i = 0; i < tasks.Length; i++)
{
    tasks[i] = new Thread(Display)
    {
        Name = i.ToString(),
        // a background thread does not prevent the process from ending
        IsBackground = true
    };
    tasks[i].Start();
}

// we give the threads time to start
Thread.Sleep(100);
// end of the main thread, hence of the process
Console.WriteLine($"End of thread {Thread.CurrentThread.Name} at {DateTime.Now:HH:mm:ss.fff}");

// the method executed by the threads
static void Display()
{
    Thread current = Thread.CurrentThread;
    Console.WriteLine($"Start of execution of the Display method in thread {current.Name}: {DateTime.Now:HH:mm:ss.fff}");
    // sleep for 1 s
    Thread.Sleep(1000);
    Console.WriteLine($"End of execution of the Display method in thread {current.Name}: {DateTime.Now:HH:mm:ss.fff}");
}

Comments:

  • line 14: the 5 threads are background threads. The properties [Name] and [IsBackground] are set using an object initializer;
  • line 20: the main thread waits 100 ms to give the threads time to start, then it terminates.

Results:

1
2
3
4
5
6
7
> dotnet run threads-03.cs
Start of execution of the Display method in thread 4: 11:36:04.770
Start of execution of the Display method in thread 3: 11:36:04.764
Start of execution of the Display method in thread 1: 11:36:04.766
Start of execution of the Display method in thread 0: 11:36:04.764
Start of execution of the Display method in thread 2: 11:36:04.766
End of thread Main at 11:36:04.872

All 5 threads began execution, then the main thread terminated (line 7). The background threads were then stopped during their one-second sleep: no end message is displayed. The 2008 course achieved the same result by ending [Main] with [Environment.Exit(0)], an instruction that stops all threads in the application, not just the thread executing it. The threads in the thread pool, which we’ll discuss later, are background threads.

If the main thread wants to wait for the threads it created to finish, it uses the [Join] method. The program [threads-04.cs] also demonstrates another way to specify the code a thread should execute: a lambda.

// waiting for the end of a thread with Join - threads created with a lambda

// we name the main thread
Thread.CurrentThread.Name = "Main";

// creation of 5 threads
var tasks = new Thread[5];
for (int i = 0; i < tasks.Length; i++)
{
    // local copy of i, captured by the lambda
    int number = i;
    // the thread executes the lambda, which calls Display with a typed parameter
    tasks[i] = new Thread(() => Display(number, 1000 + 100 * number)) { Name = i.ToString() };
    tasks[i].Start();
}

// we wait for the end of all the threads
foreach (Thread task in tasks)
{
    // the main thread is blocked until the end of the thread [task]
    task.Join();
}
// end of the main thread
Console.WriteLine($"End of thread {Thread.CurrentThread.Name} at {DateTime.Now:HH:mm:ss.fff}");

// the method executed by the threads
static void Display(int number, int duration)
{
    Console.WriteLine($"Start of execution of task {number} in thread {Thread.CurrentThread.Name}: {DateTime.Now:HH:mm:ss.fff}");
    // sleep for [duration] milliseconds
    Thread.Sleep(duration);
    Console.WriteLine($"End of execution of task {number} in thread {Thread.CurrentThread.Name}: {DateTime.Now:HH:mm:ss.fff}");
}

Comments:

  • Line 13: The thread executes the lambda [() => Display(number, 1000 + 100 * number)]. A lambda with no parameters and no return value is compatible with the delegate [ThreadStart]. This syntax allows you to call a method with typed parameters (in this case, two [int]) instead of the imposed signature [void M(object? obj)];
  • Line 11: The lambda uses a copy of the loop variable [i]. This is essential: a lambda captures the variable, not its value. In a [for] loop, the variable [i] is unique across all iterations; if the lambda used [i], a thread that starts late could read the value of [i] from a subsequent iteration, or even the final value 5. The variable [number], declared within the loop body, is, on the other hand, a new variable for each iteration (a [foreach] loop variable is also new for each iteration, as of C# 5);
  • lines 18–21: the main thread waits for each of the threads. It is first blocked waiting for thread 0, then for thread 1, and so on. When it exits the loop, the 5 threads it launched have finished;
  • line 31: task #i sleeps for 1,000 + 100 × i milliseconds, so that the threads finish in a known order.

Results:

> dotnet run threads-04.cs
Start of execution of task 1 in thread 1: 11:36:06.779
Start of execution of task 3 in thread 3: 11:36:06.779
Start of execution of task 0 in thread 0: 11:36:06.779
Start of execution of task 4 in thread 4: 11:36:06.779
Start of execution of task 2 in thread 2: 11:36:06.779
End of execution of task 0 in thread 0: 11:36:07.828
End of execution of task 1 in thread 1: 11:36:07.928
End of execution of task 2 in thread 2: 11:36:08.029
End of execution of task 3 in thread 3: 11:36:08.128
End of execution of task 4 in thread 4: 11:36:08.228
End of thread Main at 11:36:08.229

The last line shows that the main thread finished after the threads it had launched, at the moment the last of them finished.

8.3. The Benefits of Threads

Now that we’ve highlighted the existence of a default thread—the one that executes the main program—and we know how to create additional ones, let’s consider the benefits of threads. They have two main uses:

  • to utilize all the machine’s processors for a time-consuming calculation: the work is divided into chunks assigned to different threads. We’ll do this using the [Parallel] library at the end of this chapter;
  • to perform multiple tasks simultaneously, particularly when some of them primarily involve waiting (for a network response, a disk read, or user input). This is the case with client/server Internet applications, which we’ll cover in a later chapter.

In a client-server Internet application, a server located on a machine named S1 responds to requests from clients located on remote machines C1, C2, ..., Cn:

Image

We use Internet applications that follow this pattern every day: web services, email, file transfers, and so on. In the diagram above, the server S1 must serve clients Ci simultaneously. If we take the example of a file server, we know that a transfer can sometimes take several minutes. It is out of the question for a single client to monopolize the server for such a long period of time. A common solution is for the server to create as many execution threads as there are clients. Each thread is then responsible for handling a specific client:

Image

In practice, the server uses a thread pool with a limited number of threads, as described later. Modern servers go a step further: while a client is waiting for a response from the network, no thread is reserved for it. This is made possible by asynchronous programming, which is the subject of the next chapter.

8.4. Information Exchange Between Threads

In the previous examples, the threads executed a method with no parameters. The [Thread(ParameterizedThreadStart)] constructor accepts a method with the signature [void M(object? obj)]. This allows information to be passed to the launched thread:

t.Start(obj1);

launches the thread [t], which then executes the method associated with it by construction, passing it the actual parameter [obj1]. The program [threads-05.cs] is as follows:

// exchange of information between threads

// we name the main thread
Thread.CurrentThread.Name = "Main";

// creation of 5 threads
var tasks = new Thread[5];
var data = new Data[5];
for (int i = 0; i < tasks.Length; i++)
{
    // thread no. i will execute the method Sleep(object?)
    tasks[i] = new Thread(Sleep) { Name = i.ToString() };
    // the object exchanged with the thread: the launching thread sets Start and Duration
    data[i] = new Data { Start = DateTime.Now, Duration = i + 1 };
    // we start the thread passing it this object
    tasks[i].Start(data[i]);
}

// we wait for the threads one after the other
for (int i = 0; i < tasks.Length; i++)
{
    // waiting for the end of thread no. i
    tasks[i].Join();
    // thread no. i has filled in the End property of its object
    Data d = data[i];
    Console.WriteLine($"Thread {tasks[i].Name} finished: start {d.Start:HH:mm:ss}, scheduled duration {d.Duration} s, end {d.End:HH:mm:ss}, actual duration {(d.End - d.Start).TotalSeconds:F3} s");
}
// end of the main thread
Console.WriteLine($"End of thread {Thread.CurrentThread.Name} at {DateTime.Now:HH:mm:ss}");

// the method executed by the threads: signature void (object?)
static void Sleep(object? state)
{
    // we retrieve the parameter with its actual type
    if (state is not Data info)
    {
        return;
    }
    // sleep for Duration seconds
    Thread.Sleep(info.Duration * 1000);
    // the launched thread returns a result to the launching thread
    info.End = DateTime.Now;
}

// the information exchanged between the launching thread and the launched thread
class Data
{
    // set by the launching thread
    public DateTime Start { get; init; }
    public int Duration { get; init; }
    // set by the launched thread
    public DateTime End { get; set; }
}

Comments:

  • Lines 46–52: The information passed to the threads is a [Data] object. There is a two-way exchange of information between the launching thread and the launched thread. [Start] and [Duration] are [init] properties: they can only be set when the object is created:
    • [Start]: the thread’s execution start time, set by the launching thread;
    • [Duration]: duration in seconds of the launched thread’s sleep, set by the launching thread;
    • [End]: thread execution end time, set by the launched thread;
  • line 12: each thread is associated with the [Sleep] method in lines 32–42, whose signature [void Sleep(object? state)] corresponds to the delegate [ParameterizedThreadStart];
  • lines 14–16: a [Data] object is created, stored in the [data] array, and then passed to the [Start] method, which launches the thread;
  • lines 20–26: The main thread waits for each of the threads it has launched to finish, then retrieves the [data[i]] object from thread No. i and displays its contents;
  • line 35: the received parameter is of type [object?]; the pattern filter [state is not Data info] checks its actual type and stores it in the typed variable [info]. The 2008 course performed a type cast to [(Data)state], which throws an exception if the object is not of the expected type;
  • line 40: the parameter’s property [Duration] sets the sleep duration;
  • line 42: the [End] property of the parameter is set.

Results:

1
2
3
4
5
6
7
> dotnet run threads-05.cs
Thread 0 finished: start 11:36:10, scheduled duration 1 s, end 11:36:11, actual duration 1.044 s
Thread 1 finished: start 11:36:10, scheduled duration 2 s, end 11:36:12, actual duration 2.000 s
Thread 2 finished: start 11:36:10, scheduled duration 3 s, end 11:36:13, actual duration 3.000 s
Thread 3 finished: start 11:36:10, scheduled duration 4 s, end 11:36:14, actual duration 4.000 s
Thread 4 finished: start 11:36:10, scheduled duration 5 s, end 11:36:15, actual duration 5.000 s
End of thread Main at 11:36:15

This example shows that two threads can exchange information:

  • the calling thread can control the execution of the called thread by providing it with information;
  • the launched thread can return results to the launching thread.

For the calling thread to know when the results it is expecting are available, it must be notified when the called thread has finished. Here, it waited for the called thread to finish using the [Join] method. [Join] also ensures that any writes made by the called thread are visible to the thread waiting for it. We’ll look at other ways to synchronize later on.

With a lambda, it is no longer necessary to use the type [object]: the lambda directly captures typed variables. Lines 12–16 could be written as follows:

1
2
3
4
Data d = new() { Start = DateTime.Now, Duration = i + 1 };
data[i] = d;
tasks[i] = new Thread(() => Sleep(d)) { Name = i.ToString() };
tasks[i].Start();

with a method [static void Sleep(Data data)] that no longer needs to check the type of its parameter. This is the most common approach today.

8.5. Concurrent Access to Shared Resources

8.5.1. Unsynchronized concurrent access

In the previous paragraph, information was exchanged only between two threads and at very specific times: this was a classic example of parameter passing. There are other cases where information is shared by multiple threads that may want to read or update it at the same time. This raises the issue of the integrity of this information. Suppose the shared information is a structure S containing various pieces of information I1, I2, ..., In:

  • a thread T1 begins updating structure S: it modifies the field I1 and is interrupted before completing the full update of structure S;
  • a thread T2 that acquires the processor then reads structure S to make decisions. It reads a structure in an unstable state: some fields are up to date, others are not.

This situation is called concurrent access to a shared resource—in this case, structure S. It is often difficult to manage. Let’s consider the following example to illustrate the problems that can arise:

  • an application will generate n threads, where n is passed as a parameter;
  • the shared resource is a counter that must be incremented by each generated thread;
  • at the end of the application, the counter’s value is displayed. It should therefore equal n.

The program [threads-06.cs] is as follows:

// unsynchronized concurrent access to a shared resource

// checking the argument: the number of threads to create
const int nbMaxThreads = 100;
if (args.Length != 1 || !int.TryParse(args[0], out int nbThreads) || nbThreads is < 1 or > nbMaxThreads)
{
    Console.Error.WriteLine($"syntax: dotnet run threads-06.cs -- nbThreads (nbThreads between 1 and {nbMaxThreads})");
    return 1;
}

// creation and start of the threads
var threads = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++)
{
    threads[i] = new Thread(Counter.Increment) { Name = i.ToString() };
    threads[i].Start();
}
// waiting for the end of the threads
foreach (Thread thread in threads)
{
    thread.Join();
}
// display of the counter
Console.WriteLine($"Number of threads generated: {Counter.Value}");
return 0;

// the shared resource
static class Counter
{
    // the thread counter, shared by all the threads
    public static int Value { get; private set; }

    // the method executed by each thread
    public static void Increment()
    {
        // reading the counter
        int value = Value;
        Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {Thread.CurrentThread.Name} read the counter value: {value}");
        // waiting: the thread loses the processor
        Thread.Sleep(1000);
        // incrementing the counter
        Value = value + 1;
        Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {Thread.CurrentThread.Name} wrote the counter value: {Value}");
    }
}

Comments:

  • Lines 4–8: The number of threads is passed as an argument. The condition on line 5 combines all the checks: an argument is required; it is an integer ([int.TryParse]); and it is between 1 and 100 (relational pattern [nbThreads is < 1 or > nbMaxThreads]). The program returns an exit code of 1 if an error occurs, 0 otherwise (line 25);
  • lines 28–43: the shared resource is the static property [Counter.Value]. The counter is encapsulated in a static class, with the method [Increment] executed by each thread. A high-level statement cannot declare a field: therefore, the data shared by the threads are static members of a class;
  • line 37: the counter is read;
  • line 40: the thread pauses for 1 second. It therefore loses the CPU;
  • line 42: the counter is incremented.

The one-second pause is only there to force the thread to lose the processor between reading and writing the counter. In practice, there is no guarantee that a thread will not be interrupted between these two moments, and on a multiprocessor machine, two threads may even read the counter at exactly the same time. The one-second pause merely systematizes this risk.

To pass arguments to a single-file application, place them after [--]:

> dotnet run threads-06.cs -- 5
At 11:36:17.169, thread 3 read the counter value: 0
At 11:36:17.169, thread 4 read the counter value: 0
At 11:36:17.169, thread 2 read the counter value: 0
At 11:36:17.169, thread 0 read the counter value: 0
At 11:36:17.169, thread 1 read the counter value: 0
At 11:36:18.216, thread 3 wrote the counter value: 1
At 11:36:18.216, thread 4 wrote the counter value: 1
At 11:36:18.217, thread 2 wrote the counter value: 1
At 11:36:18.217, thread 1 wrote the counter value: 1
At 11:36:18.217, thread 0 wrote the counter value: 1
Number of threads generated: 1

Looking at these results, it’s clear what’s happening:

  • A first thread reads the counter. It finds 0. It pauses for 1 second and thus loses the CPU;
  • a second thread also reads the counter value. It is still 0 since the previous thread has not yet incremented it. It also pauses for 1 second;
  • in less than a second, all 5 threads have time to read the value 0;
  • when they wake up, they increment the value 0 they read and all write the value 1 to the counter, as confirmed by the last line (line 12).

One might object that the example is artificial. So let’s remove the [Sleep] and use the [++] operator, which gives the illusion of a single instruction. The [threads-07.cs] program is as follows:

// lost updates without Sleep: counter++ is not atomic

// parameters: number of threads, number of increments per thread
int nbThreads = args.Length > 0 && int.TryParse(args[0], out int n) ? n : 4;
int nbIncrements = args.Length > 1 && int.TryParse(args[1], out int m) ? m : 50_000_000;

// we repeat the experiment three times
for (int attempt = 1; attempt <= 3; attempt++)
{
    Counter.Value = 0;
    // creation and start of the threads
    var threads = new Thread[nbThreads];
    for (int i = 0; i < nbThreads; i++)
    {
        threads[i] = new Thread(() =>
        {
            for (int j = 0; j < nbIncrements; j++)
            {
                // read, increment, write: three operations
                Counter.Value++;
            }
        });
        threads[i].Start();
    }
    // waiting for the end of the threads
    foreach (Thread thread in threads)
    {
        thread.Join();
    }
    // result
    int expected = nbThreads * nbIncrements;
    Console.WriteLine($"Attempt {attempt}: expected value {expected:N0}, obtained value {Counter.Value:N0}, lost updates {expected - Counter.Value:N0}");
}

// the shared resource
static class Counter
{
    public static int Value;
}

Comments:

  • Lines 4–5: The number of threads (4 by default) and the number of increments performed by each thread (50 million by default) can be passed as arguments. The expression [args.Length > 0 && int.TryParse(args[0], out int n) ? n : 4] returns the argument if it exists and is valid; otherwise, it returns the default value. The separator [_] makes the constant [50_000_000] readable;
  • line 20: each thread increments the shared counter without any pause;
  • line 38: this time, the counter is a field;
  • line 32: the format [N0] displays an integer with a thousands separator.

Results:

1
2
3
4
> dotnet run threads-07.cs
Attempt 1: expected value 200,000,000, obtained value 120,818,197, lost updates 79,181,803
Attempt 2: expected value 200,000,000, obtained value 121,419,329, lost updates 78,580,671
Attempt 3: expected value 200,000,000, obtained value 114,583,348, lost updates 85,416,652

Tens of millions of updates were lost, and the number varies from one test to another. The high-level operation [Counter.Value++] is in fact translated into several basic processor instructions: read the value from memory, increment it in a register, and write the result to memory. If two threads read the same value before one of them has written its own, an increment is lost, exactly as in the previous example. It takes many iterations to observe this: with one million increments per thread, each thread has often finished its work before the next one has started, and the result is correct. This is what makes these errors so dangerous: a program can run for months and then fail on the very day the system is under heavier load.

Where does the problem come from? One thread read an outdated value because another thread had been interrupted—or was working simultaneously on another processor—before it had finished its task of updating the counter. This brings us to the concepts of critical resources and critical sections in a program:

  • a critical resource is a resource that can be held by only one thread at a time. Here, the critical resource is the counter;
  • a critical section of a program is a sequence of instructions in a thread’s execution flow during which it accesses a critical resource. We must ensure that, during this critical section, the thread is the only one with access to the resource.

In the program [threads-06.cs], the critical section is the code between reading the counter and writing its new value:

1
2
3
4
5
6
// reading the counter
int value = Value;
// waiting: the thread loses the processor
Thread.Sleep(1000);
// incrementing the counter
Value = value + 1;

To execute this code, a thread must be guaranteed to be the only one running. It may be interrupted, but during that interruption, no other thread must be able to execute this same code. The .NET platform offers various tools to ensure exclusive access to critical sections of code. We’ll look at a few of them now.

8.5.2. The [lock] clause and the [Lock] type

The [lock] clause delimits a critical section as follows:

1
2
3
4
lock (padlock)
{
    // critical section
}

[padlock] is an object visible to all threads executing the critical section. The [lock] clause ensures that only one thread at a time executes the critical section: a thread that encounters a lock held by another thread is blocked until the lock is released.

Starting with C# 13 and .NET 9, the synchronization object should preferably be an instance of the [System.Threading.Lock] type, which is designed for this purpose. The previous example is rewritten as follows in [threads-08.cs]:

// critical section protected by the lock clause and the System.Threading.Lock type (C# 13 / .NET 9)

// checking the argument: the number of threads to create
const int nbMaxThreads = 100;
if (args.Length != 1 || !int.TryParse(args[0], out int nbThreads) || nbThreads is < 1 or > nbMaxThreads)
{
    Console.Error.WriteLine($"syntax: dotnet run threads-08.cs -- nbThreads (nbThreads between 1 and {nbMaxThreads})");
    return 1;
}

// creation and start of the threads
var threads = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++)
{
    threads[i] = new Thread(Counter.Increment) { Name = i.ToString() };
    threads[i].Start();
}
// waiting for the end of the threads, in the reverse order of their creation
Thread.CurrentThread.Name = "Main";
for (int i = nbThreads - 1; i >= 0; i--)
{
    Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread Main waits for the end of thread {threads[i].Name}");
    threads[i].Join();
    Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread Main was notified of the end of thread {threads[i].Name}");
}
// display of the counter
Console.WriteLine($"Number of threads generated: {Counter.Value}");
return 0;

// the shared resource
static class Counter
{
    // the thread counter
    public static int Value { get; private set; }
    // the synchronization object, visible to all threads
    private static readonly Lock padlock = new();

    // the method executed by each thread
    public static void Increment()
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} waits for permission to enter the critical section");
        // only one thread at a time executes the following block
        lock (padlock)
        {
            // reading the counter
            int value = Value;
            Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} read the counter value: {value}");
            // waiting: the thread loses the processor but keeps the lock
            Thread.Sleep(1000);
            // incrementing the counter
            Value = value + 1;
            Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} wrote the counter value: {Value}");
        }
        Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} left the critical section");
    }
}

Comments:

  • line 36: [padlock] is the object that will enable synchronization of all threads. It is [readonly]: the reference must never change; otherwise, two threads could lock two different objects;
  • Lines 44–53: The critical section of the [Increment] method is enclosed by the [lock] clause. The thread holds the lock while it sleeps for one second: the other threads cannot enter;
  • Lines 20–24: The main thread waits for the threads in the reverse order of their creation.

Results with 3 threads:

> dotnet run threads-08.cs -- 3
At 11:36:23.809, thread Main waits for the end of thread 2
At 11:36:23.809, thread 0 waits for permission to enter the critical section
At 11:36:23.809, thread 1 waits for permission to enter the critical section
At 11:36:23.809, thread 2 waits for permission to enter the critical section
At 11:36:23.855, thread 0 read the counter value: 0
At 11:36:24.855, thread 0 wrote the counter value: 1
At 11:36:24.856, thread 1 read the counter value: 1
At 11:36:24.856, thread 0 left the critical section
At 11:36:25.856, thread 1 wrote the counter value: 2
At 11:36:25.856, thread 1 left the critical section
At 11:36:25.856, thread 2 read the counter value: 2
At 11:36:26.856, thread 2 wrote the counter value: 3
At 11:36:26.856, thread 2 left the critical section
At 11:36:26.857, thread Main was notified of the end of thread 2
At 11:36:26.857, thread Main waits for the end of thread 1
At 11:36:26.857, thread Main was notified of the end of thread 1
At 11:36:26.857, thread Main waits for the end of thread 0
At 11:36:26.857, thread Main was notified of the end of thread 0
Number of threads generated: 3
  • All three threads request entry into the critical section at nearly the same time;
  • only one enters it—in this case, thread 0 (the order of entry is not guaranteed: it is not necessarily the first to arrive that enters first). It reads the counter, sleeps for one second, writes the new value, and exits the critical section;
  • the other two threads are blocked until the first one exits the critical section, then proceed one after the other. Each reads the value written by the previous one;
  • the main thread was waiting for thread 2 to finish. When it is notified, it waits for thread 1 to finish, which has already completed: it is notified immediately. The same applies to thread 0;
  • the number of threads is correct (line 20).

The compiler translates the clause [lock (padlock) { ... }] on a [Lock] object as:

1
2
3
4
using (padlock.EnterScope())
{
    // critical section
}

[EnterScope] acquires the lock and returns a structure whose method [Dispose]—called upon exiting the [using] block—releases the lock, even if the critical section throws an exception. The type [Lock] also provides the methods [Enter], [TryEnter], and [Exit] for cases where the lock cannot be managed by a block.

Prior to .NET 9, and in much existing code, the lock was a generic object, typically of type [object]:

1
2
3
4
5
6
private static readonly object synchro = new();
...
lock (synchro)
{
    // critical section
}

This form remains valid: any .NET object can serve as a lock. The compiler then translates it into calls to the [Monitor] class, which we will examine later:

bool lockTaken = false;
try
{
    Monitor.Enter(synchro, ref lockTaken);
    // critical section
}
finally
{
    if (lockTaken) Monitor.Exit(synchro);
}

The type [Lock] is more efficient and makes the intent explicit. We always lock a private object: locking an externally visible object ([this], a type with [typeof(...)], a string) would allow external code to acquire the same lock and block ours.

8.5.3. The Mutex Class

The [System.Threading.Mutex] class also allows you to delimit critical sections. It differs from the [lock] clause in terms of visibility:

  • the [lock] clause synchronizes threads within the same application;
  • the [Mutex] class can synchronize threads from different applications (or processes) if given a name.

A [Mutex] is an operating system object: its use is much more resource-intensive than that of a [lock]. For threads within the same process, we therefore prefer to use [lock]; [Mutex] is primarily used between processes. We will use the following constructors and methods:

Member
Role
[Mutex()]
creates an anonymous Mutex M, usable by the process’s threads
[Mutex(bool initiallyOwned, string name)]
creates or opens the named mutex [name], shared by all processes that use this name
[bool WaitOne()]
The thread T1, which is executing [M.WaitOne()], requests ownership of the mutex M. If M is not held by any thread, it is granted to T1. If a thread T2 then attempts the same operation, it is blocked: a mutex can belong to only one thread. T2 will be unblocked when T1 releases M. Thus, multiple threads may be blocked while waiting for M
[bool WaitOne(TimeSpan timeout)]
Same as above, but the wait is limited to [timeout]. The method returns [false] if the mutex could not be acquired
[void ReleaseMutex()]
The thread holding M releases it. One of the threads waiting for M then acquires it

Synchronization of a critical section is done as follows:

1
2
3
4
5
6
7
8
9
M.WaitOne();
try
{
    // the thread is the only one to enter here: critical section
}
finally
{
    M.ReleaseMutex();
}

Don't forget to release a mutex; otherwise, the threads waiting on it will remain blocked indefinitely. That is why the release is performed in a [finally] statement, which is executed regardless of whether an exception occurred. The program [threads-09.cs] uses the previous example with a mutex:

// critical section protected by a Mutex

// checking the argument: the number of threads to create
const int nbMaxThreads = 100;
if (args.Length != 1 || !int.TryParse(args[0], out int nbThreads) || nbThreads is < 1 or > nbMaxThreads)
{
    Console.Error.WriteLine($"syntax: dotnet run threads-09.cs -- nbThreads (nbThreads between 1 and {nbMaxThreads})");
    return 1;
}

// creation and start of the threads
var threads = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++)
{
    threads[i] = new Thread(Counter.Increment) { Name = i.ToString() };
    threads[i].Start();
}
// waiting for the end of the threads
foreach (Thread thread in threads)
{
    thread.Join();
}
// display of the counter
Console.WriteLine($"Number of threads generated: {Counter.Value}");
return 0;

// the shared resource
static class Counter
{
    // the thread counter
    public static int Value { get; private set; }
    // the synchronization object: a Mutex
    private static readonly Mutex synchro = new();

    // the method executed by each thread
    public static void Increment()
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} waits for permission to enter the critical section");
        // we request ownership of the Mutex
        synchro.WaitOne();
        try
        {
            // reading the counter
            int value = Value;
            Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} read the counter value: {value}");
            // waiting
            Thread.Sleep(1000);
            // incrementing the counter
            Value = value + 1;
            Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} wrote the counter value: {Value}");
        }
        finally
        {
            // we release the Mutex, exception or not
            Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} leaves the critical section");
            synchro.ReleaseMutex();
        }
    }
}

Comments:

  • line 33: the thread synchronization object is now a mutex;
  • line 41: start of the critical section. The thread is blocked until the mutex is free;
  • lines 42–57: Because a mutex must always be released, regardless of whether an exception occurs, the critical section is placed within a [try], whose [finally] clause releases the mutex (line 57).

Results:

> dotnet run threads-09.cs -- 3
At 11:36:28.858, thread 2 waits for permission to enter the critical section
At 11:36:28.906, thread 2 read the counter value: 0
At 11:36:28.858, thread 0 waits for permission to enter the critical section
At 11:36:28.858, thread 1 waits for permission to enter the critical section
At 11:36:29.907, thread 2 wrote the counter value: 1
At 11:36:29.907, thread 2 leaves the critical section
At 11:36:29.907, thread 0 read the counter value: 1
At 11:36:30.907, thread 0 wrote the counter value: 2
At 11:36:30.907, thread 0 leaves the critical section
At 11:36:30.907, thread 1 read the counter value: 2
At 11:36:31.908, thread 1 wrote the counter value: 3
At 11:36:31.908, thread 1 leaves the critical section
Number of threads generated: 3

The results are similar to the previous ones: the threads pass through the critical section one by one, and the final counter value is correct.

The true value of the mutex becomes apparent with a named mutex. The program [threads-10.cs] is run twice, in two different processes, which share a critical section:

// a named Mutex synchronizes threads of different processes

// the name of the Mutex is known to all the processes using it
const string mutexName = @"Global\csharp-course-threads-10";
// the name of the process is passed as an argument
string process = args.Length > 0 ? args[0] : "P";

// we open the named Mutex (it is created if it does not exist yet)
using var mutex = new Mutex(false, mutexName);
Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: process {process} requests the Mutex");
// attempt without waiting
if (!mutex.WaitOne(TimeSpan.Zero))
{
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: the Mutex is held by another process, {process} waits");
    // wait without time limit
    mutex.WaitOne();
}
try
{
    // critical section between processes
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: process {process} obtained the Mutex, it works for 3 s");
    Thread.Sleep(3000);
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: process {process} releases the Mutex");
}
finally
{
    mutex.ReleaseMutex();
}

Comments:

  • Line 4: the mutex name. All processes that open a mutex with this name use the same system object. The prefix [Global] specifies that the mutex is visible to all sessions on the machine (the default prefix [Local] limits it to the user’s session);
  • Line 9: The named mutex is created if it does not yet exist; otherwise, it is opened. The first parameter, [false], indicates that the process does not request ownership of it upon creation. The [using] declaration releases the [Mutex] object at the end of the program;
  • line 12: the process attempts to acquire the mutex immediately ([TimeSpan.Zero]). If it fails, it reports the failure and then waits for it indefinitely (line 16);
  • Lines 18–27: the critical section, which lasts 3 seconds, followed by the release of the mutex.

The capture script launches process A, waits 1.5 seconds, and then launches process B. Results for process A:

1
2
3
4
> dotnet run threads-10.cs -- A
11:36:35.438: process A requests the Mutex
11:36:35.486: process A obtained the Mutex, it works for 3 s
11:36:38.487: process A releases the Mutex

Results of process B:

1
2
3
4
5
> dotnet run threads-10.cs -- B
11:36:36.908: process B requests the Mutex
11:36:36.957: the Mutex is held by another process, B waits
11:36:38.487: process B obtained the Mutex, it works for 3 s
11:36:41.487: process B releases the Mutex

Process B found the mutex held by process A and waited. It acquired it the very moment A released it. A common use of a named mutex is to prevent two instances of the same application from being launched: the second instance, unable to acquire the mutex, terminates immediately. If a process terminates without releasing a mutex it holds, the mutex is said to be abandoned: the next process to call [WaitOne] acquires it but throws the exception [AbandonedMutexException] to signal that the protected resource may be in an inconsistent state.

8.5.4. The AutoResetEvent class

An [AutoResetEvent] object is a barrier that allows only one thread to pass at a time. It is constructed as follows:

AutoResetEvent barrier = new AutoResetEvent(state);

The boolean [state] indicates whether the barrier is closed ([false]) or open ([true]). A thread wishing to pass through the barrier indicates this as follows:

barrier.WaitOne();
  • If the barrier is open, the thread passes through and the barrier automatically closes behind it (hence the class name). If multiple threads are waiting, only one passes through;
  • if the barrier is closed, the thread is blocked. Another thread will open it when the time comes. That time depends entirely on the problem being solved. The barrier is opened by the operation:
barrier.Set();

A thread can also close a barrier using [barrier.Reset()]. Unlike a mutex, a barrier does not belong to any specific thread: any thread can open it. If, in the previous example, we replace the mutex with an [AutoResetEvent], the code becomes the following [threads-11.cs]:

// critical section protected by an AutoResetEvent barrier

// checking the argument: the number of threads to create
const int nbMaxThreads = 100;
if (args.Length != 1 || !int.TryParse(args[0], out int nbThreads) || nbThreads is < 1 or > nbMaxThreads)
{
    Console.Error.WriteLine($"syntax: dotnet run threads-11.cs -- nbThreads (nbThreads between 1 and {nbMaxThreads})");
    return 1;
}

// creation and start of the threads
var threads = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++)
{
    threads[i] = new Thread(Counter.Increment) { Name = i.ToString() };
    threads[i].Start();
}
// we let the threads arrive at the closed barrier
Thread.Sleep(500);
// we open the barrier of the critical section
Thread.CurrentThread.Name = "Main";
Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread Main opens the barrier of the critical section");
Counter.Synchro.Set();
// waiting for the end of the threads
foreach (Thread thread in threads)
{
    thread.Join();
}
// display of the counter
Console.WriteLine($"Number of threads generated: {Counter.Value}");
return 0;

// the shared resource
static class Counter
{
    // the thread counter
    public static int Value { get; private set; }
    // the barrier, created closed (false)
    public static readonly AutoResetEvent Synchro = new(false);

    // the method executed by each thread
    public static void Increment()
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} waits for permission to enter the critical section");
        // passing the barrier: it closes again behind the thread
        Synchro.WaitOne();
        try
        {
            // reading the counter
            int value = Value;
            Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} read the counter value: {value}");
            // waiting
            Thread.Sleep(1000);
            // incrementing the counter
            Value = value + 1;
            Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} wrote the counter value: {Value}");
        }
        finally
        {
            // we reopen the barrier for another thread
            Console.WriteLine($"At {DateTime.Now:HH:mm:ss.fff}, thread {name} leaves the critical section");
            Synchro.Set();
        }
    }
}

Comments:

  • line 39: the barrier is created in a closed state. It will be opened by the main thread on line 23;
  • line 19: the main thread waits half a second to allow the threads to arrive at the barrier;
  • line 47: the thread responsible for incrementing the counter requests permission to enter the critical section. The various threads accumulate in front of the closed barrier. When the main thread opens it, one of the waiting threads passes through, and the barrier closes again;
  • line 63: when it has finished its work, the thread reopens the barrier, allowing another thread to enter.

Results:

> dotnet run threads-11.cs -- 3
At 11:36:43.413, thread 2 waits for permission to enter the critical section
At 11:36:43.407, thread 0 waits for permission to enter the critical section
At 11:36:43.411, thread 1 waits for permission to enter the critical section
At 11:36:43.914, thread Main opens the barrier of the critical section
At 11:36:43.915, thread 2 read the counter value: 0
At 11:36:44.915, thread 2 wrote the counter value: 1
At 11:36:44.915, thread 2 leaves the critical section
At 11:36:44.915, thread 0 read the counter value: 1
At 11:36:45.916, thread 0 wrote the counter value: 2
At 11:36:45.916, thread 0 leaves the critical section
At 11:36:45.916, thread 1 read the counter value: 2
At 11:36:46.916, thread 1 wrote the counter value: 3
At 11:36:46.916, thread 1 leaves the critical section
Number of threads generated: 3

The results are similar to the previous ones. We see that the three threads wait in front of the barrier until the main thread opens it (line 5).

8.5.5. The classes ManualResetEventSlim and SemaphoreSlim

Two other synchronization tools complement the previous ones.

An object [ManualResetEventSlim] is a barrier that, once opened by [Set], remains open and allows all threads to pass through until it is explicitly closed by [Reset]. This is the “starting signal” for a race. The suffix [Slim] denotes the lightweight versions of the synchronization tools, introduced with .NET 4: they only call upon the operating system if the wait is prolonged and operate only within a process. The program [threads-12.cs] is as follows:

// ManualResetEventSlim: a barrier that lets all the waiting threads through

// the barrier, created closed
using var start = new ManualResetEventSlim(false);

// creation and start of 4 threads
var threads = new Thread[4];
for (int i = 0; i < threads.Length; i++)
{
    threads[i] = new Thread(() =>
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: runner {name} waits for the start signal");
        // waiting for the barrier to open
        start.Wait();
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: runner {name} has started");
    })
    { Name = i.ToString() };
    threads[i].Start();
}

// the main thread waits 1 s then gives the start signal
Thread.Sleep(1000);
Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: start signal");
// the barrier opens and stays open: all the threads go through
start.Set();
// waiting for the end of the threads
foreach (Thread thread in threads)
{
    thread.Join();
}
Console.WriteLine($"Is the barrier open? {start.IsSet}");

Comments:

  • line 4: the barrier is created in the closed state;
  • line 15: each thread waits for the barrier to open;
  • line 26: the main thread opens the barrier after one second;
  • line 32: The [IsSet] property indicates whether the barrier is open.

Results:

> dotnet run threads-12.cs
11:36:48.866: runner 3 waits for the start signal
11:36:48.865: runner 0 waits for the start signal
11:36:48.866: runner 2 waits for the start signal
11:36:48.865: runner 1 waits for the start signal
11:36:49.865: start signal
11:36:49.865: runner 0 has started
11:36:49.865: runner 1 has started
11:36:49.865: runner 2 has started
11:36:49.865: runner 3 has started
Is the barrier open? True

All four runners started together, to the millisecond, after the starting signal. With an [AutoResetEvent], only one would have started.

A semaphore is a barrier equipped with a counter: it allows at most N threads to enter a section of code at a time. This is useful for limiting the use of a resource available in limited quantities (database connections, concurrent requests to a server, etc.). The program [threads-13.cs] is as follows:

// SemaphoreSlim: at most N threads at a time in a section of code

// a semaphore with 2 slots
using var semaphore = new SemaphoreSlim(2);

// creation and start of 5 threads
var threads = new Thread[5];
for (int i = 0; i < threads.Length; i++)
{
    threads[i] = new Thread(() =>
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: thread {name} waits for a slot");
        // we request a slot: blocking if there are none left
        semaphore.Wait();
        try
        {
            Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: thread {name} obtained a slot (free slots: {semaphore.CurrentCount})");
            // work
            Thread.Sleep(1000);
        }
        finally
        {
            // we give the slot back
            Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: thread {name} releases its slot");
            semaphore.Release();
        }
    })
    { Name = i.ToString() };
    threads[i].Start();
}
// waiting for the end of the threads
foreach (Thread thread in threads)
{
    thread.Join();
}

Comments:

  • line 4: the semaphore has 2 slots;
  • line 15: a thread requests a slot. If one is available, it takes it and continues; otherwise, it is blocked until a slot becomes available;
  • line 18: the number of slots still available;
  • line 26: the thread returns its slot, in a [finally] clause.

Results:

> dotnet run threads-13.cs
11:36:51.985: thread 3 waits for a slot
11:36:51.985: thread 1 waits for a slot
11:36:51.985: thread 0 waits for a slot
11:36:51.985: thread 4 waits for a slot
11:36:51.985: thread 2 waits for a slot
11:36:52.035: thread 3 obtained a slot (free slots: 0)
11:36:52.035: thread 2 obtained a slot (free slots: 0)
11:36:53.035: thread 2 releases its slot
11:36:53.037: thread 3 releases its slot
11:36:53.037: thread 1 obtained a slot (free slots: 0)
11:36:53.037: thread 4 obtained a slot (free slots: 0)
11:36:54.037: thread 1 releases its slot
11:36:54.037: thread 4 releases its slot
11:36:54.037: thread 0 obtained a slot (free slots: 1)
11:36:55.037: thread 0 releases its slot

All five threads request a slot; two obtain one immediately, while the other three wait. Each time a thread releases its slot, a waiting thread obtains it. There are never more than two threads in the code section. A single-slot semaphore acts as a lock. The class [SemaphoreSlim] also has a method [WaitAsync] that can be used with [await], which we will encounter in the next chapter; the [Semaphore] class (without [Slim]), which is more resource-intensive, can—like [Mutex]—be named and shared across processes, but only on Windows.

8.5.6. The Interlocked class

The [Interlocked] class makes simple operations on a variable atomic: the operation is executed entirely by the thread, without any other thread being able to observe or modify the variable in the middle of the operation. The previous tools ([lock], [Mutex], [AutoResetEvent]) are all designed to make a group of operations atomic, at the cost of blocking other threads. For simple but frequent operations, the [Interlocked] class avoids this blocking by using special processor instructions. Its main static methods are as follows:

Method
Role
[int Increment(ref int x)]
Increments [x] by 1 and returns the new value
[int Decrement(ref int x)]
decrements [x] by 1 and returns the new value
[int Add(ref int x, int value)]
adds [value] to [x] and returns the new value
[int Exchange(ref int x, int value)]
assigns [value] to [x] and returns the old value
[int CompareExchange(ref int x, int value, int comparand)]
assigns [value] to [x] only if [x] is equal to [comparand]; returns the previous value of [x]
[long Read(ref long x)]
atomically reads a 64-bit value (useful on a 32-bit processor)

These methods are available for the types [int], [long], [uint], [ulong], and—for [Exchange] and [CompareExchange]—for other types. The methods [And] and [Or] (.NET 5) atomically perform a bitwise “and” or “or” operation. The parameter is passed by reference ([ref]): [Interlocked] must operate on the variable itself, not on a copy. For this reason, the variable must be a field or a local variable; a property cannot be passed by reference.

The program [threads-14.cs] builds on the experiment from the program [threads-07.cs] and compares three ways to increment the shared counter:

// the Interlocked class: atomic operations without a lock

using System.Diagnostics;

// parameters: number of threads, number of increments per thread
int nbThreads = args.Length > 0 && int.TryParse(args[0], out int n) ? n : 4;
int nbIncrements = args.Length > 1 && int.TryParse(args[1], out int m) ? m : 10_000_000;
Console.WriteLine($"{nbThreads} threads, {nbIncrements:N0} increments per thread, expected value {nbThreads * nbIncrements:N0}");

// three ways to increment the shared counter
Measure("without synchronization", Counter.WithoutSynchronization);
Measure("with lock", Counter.WithLock);
Measure("with Interlocked", Counter.WithInterlocked);

// starts the threads that all execute [increment] nbIncrements times
void Measure(string method, Action increment)
{
    Counter.Value = 0;
    // a barrier so that all the threads start at the same time
    using var start = new ManualResetEventSlim(false);
    var threads = new Thread[nbThreads];
    for (int i = 0; i < nbThreads; i++)
    {
        threads[i] = new Thread(() =>
        {
            start.Wait();
            for (int j = 0; j < nbIncrements; j++)
            {
                increment();
            }
        });
        threads[i].Start();
    }
    // go!
    var stopwatch = Stopwatch.StartNew();
    start.Set();
    foreach (Thread thread in threads)
    {
        thread.Join();
    }
    stopwatch.Stop();
    Console.WriteLine($"{method,-23}: obtained value {Counter.Value,12:N0} in {stopwatch.ElapsedMilliseconds,5} ms");
}

// the shared resource
static class Counter
{
    // a field (and not a property) so that it can be passed by reference
    public static int Value;
    private static readonly Lock padlock = new();

    // read, increment, write: not atomic
    public static void WithoutSynchronization() => Value++;

    // critical section protected by a lock
    public static void WithLock()
    {
        lock (padlock)
        {
            Value++;
        }
    }

    // atomic increment, without a lock
    public static void WithInterlocked() => Interlocked.Increment(ref Value);
}

Comments:

  • line 3: The class [Stopwatch], which measures durations, is in the namespace [System.Diagnostics];
  • lines 11–13: The local function [Measure] is called with three different increment methods, passed as a delegate [Action];
  • lines 16–42: the function [Measure] launches [nbThreads] threads, each of which calls the received method [nbIncrements] times, and measures the total time with a [Stopwatch]. The format [{Counter.Value,12:N0}] right-aligns the value to 12 characters;
  • lines 20–26 and 36: the threads all wait behind a [ManualResetEventSlim] barrier, which opens once they have all been launched. This allows them to begin their work simultaneously, making concurrent access much more likely than with threads launched one after another;
  • line 49: the counter is a field, not a property, so that it can be passed by reference to [Interlocked.Increment];
  • line 53: the unsynchronized increment;
  • lines 56–60: incrementing within a critical section protected by a [Lock];
  • line 65: atomic increment.

Results:

1
2
3
4
5
> dotnet run threads-14.cs
4 threads, 10,000,000 increments per thread, expected value 40,000,000
without synchronization: obtained value   26,108,241 in   289 ms
with lock              : obtained value   40,000,000 in  2484 ms
with Interlocked       : obtained value   40,000,000 in   754 ms
  • Without synchronization, the result is incorrect, once again;
  • the other two methods yield the correct result;
  • [Interlocked] is significantly faster than [lock] in this run. The lock is costly here because four threads are constantly competing for the same lock for a tiny operation. The measured times depend heavily on the machine and its load: only their order of magnitude is significant.

The [CompareExchange] method is the building block of “lock-free” algorithms: read the value, calculate the new one, then write it only if the variable has not changed in the meantime; otherwise, start over. The concurrent collections presented later make extensive use of it.

8.6. Concurrent Access to Multiple Shared Resources

8.6.1. Deadlock

In our previous examples, a single resource was shared among the various threads. The situation becomes more complicated if there are multiple resources and they are dependent on one another. In particular, a deadlock can occur: two threads are waiting for each other. Consider the following actions, which occur sequentially:

  • a thread T1 acquires the lock V1 to access a shared resource R1;
  • a thread T2 acquires the lock V2 to access a shared resource R2;
  • Thread T1 requests lock V2. It is blocked;
  • the thread T2 requests the lock V1. It is blocked.

The threads T1 and T2 are waiting for each other indefinitely. Several solutions are possible:

  • request both resources at the same time using a single lock. This isn’t always possible if it results in a long lock on a costly resource;
  • always acquire locks in the same order: if all threads acquire V1 before V2, deadlock is impossible. This is the most commonly used rule;
  • A thread holding V1 that cannot acquire V2 releases V1 to avoid deadlock.

The program [threads-15.cs] causes a deadlock and implements the final solution:

// deadlock: two threads take two locks in a different order

// the two shared resources and their locks
Lock lockR1 = new();
Lock lockR2 = new();

// T1 takes R1 then requests R2; it gives up after 2 s
var t1 = new Thread(() => Work(lockR1, "R1", lockR2, "R2", TimeSpan.FromSeconds(2))) { Name = "T1" };
// T2 takes R2 then requests R1; it waits up to 5 s
var t2 = new Thread(() => Work(lockR2, "R2", lockR1, "R1", TimeSpan.FromSeconds(5))) { Name = "T2" };
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("End of Main");

// takes the 1st resource, then tries to take the 2nd during [timeout]
static void Work(Lock first, string firstName, Lock second, string secondName, TimeSpan timeout)
{
    string name = Thread.CurrentThread.Name ?? "?";
    lock (first)
    {
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: {name} obtained {firstName}");
        // we give the other thread time to take its first resource
        Thread.Sleep(500);
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: {name} requests {secondName}");
        // request with a maximum waiting time, instead of a lock that would wait indefinitely
        if (second.TryEnter(timeout))
        {
            try
            {
                Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: {name} obtained {secondName} and works with {firstName} and {secondName}");
            }
            finally
            {
                second.Exit();
            }
        }
        else
        {
            Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: {name} did not obtain {secondName} within {timeout.TotalSeconds} s: it releases {firstName} and gives up");
        }
    }
}

Comments:

  • lines 4–5: the locks on the two resources. These are local variables here, captured by the lambdas in lines 8–10;
  • line 8: the T1 thread acquires R1 and then requests R2; it waits for a maximum of 2 seconds;
  • Line 10: The thread T2 takes R2 and then requests R1—that is, in reverse order; it waits for a maximum of 5 seconds;
  • Line 21: The thread acquires its first resource;
  • line 25: it waits half a second, which gives the other thread time to acquire its own first resource: deadlock is then certain;
  • line 28: instead of a [lock] that would wait indefinitely, the thread requests the second lock with a maximum wait time. [TryEnter] returns [true] if the lock was acquired, or [false] if the timeout expired;
  • line 36: a lock acquired by [TryEnter] must be released by [Exit], within a [finally] clause;
  • Lines 39–41: If the second lock could not be acquired, the thread aborts. Upon exiting the [lock] block, it releases its first resource.

Results:

1
2
3
4
5
6
7
8
> dotnet run threads-15.cs
11:37:01.001: T1 obtained R1
11:37:01.001: T2 obtained R2
11:37:01.549: T1 requests R2
11:37:01.549: T2 requests R1
11:37:03.550: T1 did not obtain R2 within 2 s: it releases R1 and gives up
11:37:03.550: T2 obtained R1 and works with R2 and R1
End of Main

Each of the two threads acquired its first resource and then requested the second: they are in a deadlock. After 2 seconds, T1 gives up and releases R1; T2, which was still waiting, immediately obtains R1 and can do its work. Without a wait time, the program would never have finished.

8.6.2. An example: readers and writers

Consider the following example:

  • we have an array into which some threads write data (the writers) and others read it (the readers);
  • the writers are equal to one another but exclusive: only one writer at a time can deposit data into the array;
  • readers are equal to one another but exclusive: only one reader at a time can read the data stored in the array;
  • a reader can only read data from the array when a writer has placed some there, and a writer can only place new data in the array when the data already there has been read by a reader.

There are two types of shared resources:

  • the array being written: only one writer at a time may access it;
  • the array being read: only one reader at a time may access it.

and an order for using these resources:

  • a reader must always follow a writer;
  • a writer must always follow a reader, except the first time.

Access to these two resources can be controlled using two barriers of type [AutoResetEvent]:

  • The [canWrite] barrier controls writers’ access to the array;
  • the barrier [canRead] controls readers’ access to the array;
  • the [canWrite] barrier is created open, allowing the first writer to pass and blocking all others;
  • The [canRead] barrier is created in the closed state, blocking all readers;
  • when a writer has finished their work, they open barrier [canRead] to let a reader in;
  • when a reader has finished their work, they open the barrier [canWrite] to let a writer in.

The program [threads-16.cs] illustrating this synchronization is as follows:

// readers and writers synchronized by two AutoResetEvent barriers

// the number of threads of each kind
const int nbThreads = 2;

// creation and start of the reader threads
for (int i = 0; i < nbThreads; i++)
{
    new Thread(Board.Read) { Name = $"R{i}" }.Start();
}
// creation and start of the writer threads
for (int i = 0; i < nbThreads; i++)
{
    new Thread(Board.Write) { Name = $"W{i}" }.Start();
}
// end of Main: the other threads continue their work
Console.WriteLine("End of Main...");

// the shared resource and its synchronization
static class Board
{
    // the array shared between readers and writers
    private static readonly int[] data = new int[3];
    // signals that the content of data can be read: closed at the start
    private static readonly AutoResetEvent canRead = new(false);
    // signals that data can be written: open at the start
    private static readonly AutoResetEvent canWrite = new(true);

    // read the content of the array
    public static void Read()
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"Method [Read] started by thread {name}");
        // we wait for read permission
        canRead.WaitOne();
        // reading the array
        for (int i = 0; i < data.Length; i++)
        {
            // waiting 0.5 s: the thread loses the processor
            Thread.Sleep(500);
            Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: reader {name} read the number {data[i]}");
        }
        // we allow a writer
        canWrite.Set();
        Console.WriteLine($"Method [Read] finished by thread {name}");
    }

    // write into the array
    public static void Write()
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"Method [Write] started by thread {name}");
        // we wait for write permission
        canWrite.WaitOne();
        // writing the array
        for (int i = 0; i < data.Length; i++)
        {
            // waiting 0.5 s
            Thread.Sleep(500);
            // Random.Shared can be used by several threads
            data[i] = Random.Shared.Next(0, 1000);
            Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: writer {name} wrote the number {data[i]}");
        }
        // we allow a reader
        canRead.Set();
        Console.WriteLine($"Method [Write] finished by thread {name}");
    }
}

Comments:

  • lines 7–14: creation and launch of two reader threads, followed by two writer threads. No references are kept to the threads: the main thread does not wait for them;
  • line 17: the main thread terminates immediately. The process continues until all four threads—which are foreground threads—have finished;
  • line 23: the array [data] is the shared resource between the reader and writer threads;
  • line 25: The barrier [canRead] signals to the readers that they can read the array. It is opened by the writer who has filled the array. It is created in a closed state: a writer must first fill the array;
  • line 27: The barrier [canWrite] notifies writers that they can write to the array. It is opened by the reader who has read the entire array. It is created open: the array is writable from the start;
  • line 35: a reader waits for a writer to signal that the array has been filled. When this signal arrives, only one of the waiting readers proceeds;
  • lines 37–41: the array is read, with a half-second pause at each element to force the thread to yield the CPU;
  • line 44: the reader notifies the writers that the array has been read and can be filled again;
  • the method [Write] is symmetric to the method [Read];
  • line 61: the written numbers are randomly generated. The property [Random.Shared] (.NET 6) provides a shared generator that can be safely used by multiple threads. The 2008 course shared a regular [Random] object among threads, whose methods are not designed to be called simultaneously by multiple threads.

Results:

> dotnet run threads-16.cs
Method [Read] started by thread R1
Method [Read] started by thread R0
Method [Write] started by thread W0
Method [Write] started by thread W1
End of Main...
11:37:06.185: writer W0 wrote the number 359
11:37:06.719: writer W0 wrote the number 226
11:37:07.219: writer W0 wrote the number 693
Method [Write] finished by thread W0
11:37:07.719: reader R0 read the number 359
11:37:08.220: reader R0 read the number 226
11:37:08.720: reader R0 read the number 693
Method [Read] finished by thread R0
11:37:09.220: writer W1 wrote the number 325
11:37:09.720: writer W1 wrote the number 935
11:37:10.221: writer W1 wrote the number 96
Method [Write] finished by thread W1
11:37:10.721: reader R1 read the number 325
11:37:11.221: reader R1 read the number 935
11:37:11.722: reader R1 read the number 96
Method [Read] finished by thread R1

The following points are worth noting:

  • There is only one reader at a time, although that reader loses the processor in the critical section [Read];
  • there is only one writer at a time, although it loses the processor in the critical section [Write];
  • a reader only reads when there is something to read in the array;
  • a writer writes only when the array has been fully read;
  • however, the order of execution cannot be controlled: there was no reason why writer W0 should come before writer W1, nor why reader R0 should come before reader R1. A different execution may result in a different order.

8.6.3. The Monitor class

In the previous example, when the writers are blocked on the instruction [canWrite.WaitOne()], one of them—any one—is unblocked by the operation [canWrite.Set()]. If you want to open the barrier for a specific writer, things become more complicated.

Consider the analogy of a public service office with service counters. When a customer arrives, they take a numbered ticket from the dispenser and then go sit down. Customers are called by their number over a loudspeaker. While waiting, the customer can do whatever they want: they can read or doze off. They are woken up every time the loudspeaker announces a number. If it’s their number, the customer gets up and goes to the counter; otherwise, they go back to what they were doing. We can operate in a similar way with threads. Let’s take the example of writers:

At the counter
With threads
several writers are waiting for the same counter
their threads are blocked
the counter becomes available and the next writer’s number is called
the thread that was reading from the array notifies the writers that the array is available. Either that thread or another thread has determined which writer should proceed
Each writer checks its number; only the one with the called number proceeds to the counter; the others return to the waiting queue
Each thread checks to see if it is the one selected. If so, it passes through the barrier; otherwise, it returns to the waiting queue

The [Monitor] class implements this scenario. Its static methods are as follows:

Method
Role
[Enter(object token)]
Requests the token: the thread obtains it if it is free; otherwise, it is blocked until it becomes free
[bool TryEnter(object token, TimeSpan timeout)]
Same as above, with a maximum timeout
[Exit(object token)]
returns the token
[bool Wait(object token)]
returns the token and blocks the thread until another thread signals a change via [Pulse] or [PulseAll]; the thread must then retrieve the token before continuing
[Pulse(object token)]
wakes up one of the threads blocked by [Wait] on this token
[PulseAll(object token)]
wakes up all threads blocked by [Wait] on this token

We will now describe a standard pattern, proposed in the “Threading” chapter of the book by J. and B. Albahari, [C# in a Nutshell], capable of resolving barrier problems with an entry condition.

Threads that share a resource (the counter) access it via an object we’ll call a token. To cross the barrier leading to the counter, you must have the token, and there is only one. The threads must therefore pass the token to one another:

object token = new();

To go to the counter, a thread first requests the token using [Monitor.Enter(token)], or more simply with a [lock] clause. If it obtains the token, it checks that it meets the condition to go to the counter:

1
2
3
4
5
6
7
8
9
lock (token)
{
    while (!iAmTheOneExpected)
    {
        Monitor.Wait(token);
    }
    // going to the counter
    ...
}

If the thread is not the one expected at the counter, it yields its turn by returning the token via [Monitor.Wait(token)] and enters a blocked state. It will be woken up when another thread signals a change in the access condition; it will then retrieve the token and check the condition again. This is why the check is a loop ([while]) rather than a simple [if]. The operation [Monitor.Wait(token)] can only be performed by a thread that holds the token; otherwise, a [SynchronizationLockException] exception is thrown. In this scenario, the token is released only after the operation is completed.

The thread that modifies the access condition for the counter must also hold the token and notify the waiting threads:

1
2
3
4
5
6
7
lock (token)
{
    // modify the condition for accessing the counter
    ...
    // wake up the threads waiting for the token
    Monitor.PulseAll(token);
}

Waking up the threads means that the waiting threads become candidates for the token; they do not receive it immediately. The token is only available at the end of the [lock] block. All awakened threads will then receive the token in turn, in an indeterminate order, giving them the opportunity to check the access condition again. The first one to satisfy the condition keeps the token and proceeds to the counter.

An important note: the methods [Wait], [Pulse], and [PulseAll] from [Monitor] apply to an object locked by [lock (obj)] or [Monitor.Enter(obj)]. They do not work with the [Lock] type of .NET 9, which has no equivalent. The tokens in this paragraph are therefore objects of type [object].

Armed with this information, we can rewrite the reader/writer application by specifying the order in which readers and writers access their respective counters [threads-17.cs]:

// readers and writers going to the counter in an imposed order: Monitor.Wait / Monitor.PulseAll

// the number of threads of each kind
const int nbThreads = 3;

// the order of the readers: the reverse of the creation order
Board.ReadOrder = [.. Enumerable.Range(0, nbThreads).Select(i => $"R{nbThreads - 1 - i}")];
// the order of the writers: the creation order
Board.WriteOrder = [.. Enumerable.Range(0, nbThreads).Select(i => $"W{i}")];
for (int i = 0; i < nbThreads; i++)
{
    Console.WriteLine($"Reader {Board.ReadOrder[i]} is in position {i}, writer {Board.WriteOrder[i]} is in position {i}");
}

// creation and start of the reader threads then the writer threads
for (int i = 0; i < nbThreads; i++)
{
    new Thread(Board.Read) { Name = $"R{i}" }.Start();
}
for (int i = 0; i < nbThreads; i++)
{
    new Thread(Board.Write) { Name = $"W{i}" }.Start();
}

// write permission
Board.AllowWriting();
// end of Main
Console.WriteLine("End of Main...");

// the shared resource and its synchronization
static class Board
{
    // the array shared between readers and writers
    private static readonly int[] data = new int[3];
    // the access tokens of the read and write counters
    private static readonly object canRead = new();
    private static readonly object canWrite = new();
    // the access conditions of the counters
    private static bool readingAllowed = false;
    private static bool writingAllowed = false;
    private static int nextReader = 0;
    private static int nextWriter = 0;
    // the order of the readers and of the writers
    public static string[] ReadOrder { get; set; } = [];
    public static string[] WriteOrder { get; set; } = [];

    // opens the write counter
    public static void AllowWriting()
    {
        lock (canWrite)
        {
            writingAllowed = true;
            // we wake up all the writers waiting for the token
            Monitor.PulseAll(canWrite);
        }
    }

    // read the content of the array
    public static void Read()
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"Method [Read] started by thread {name}");
        // we request the token of the read counter
        lock (canRead)
        {
            // as long as it is not our turn, we give the token back and wait
            while (!readingAllowed || ReadOrder[nextReader] != name)
            {
                Monitor.Wait(canRead);
            }
            // at the counter: reading the array
            for (int i = 0; i < data.Length; i++)
            {
                Thread.Sleep(500);
                Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: reader {name} read the number {data[i]}");
            }
            // next reader
            readingAllowed = false;
            nextReader++;
            // we notify the writers that they can write
            lock (canWrite)
            {
                writingAllowed = true;
                Monitor.PulseAll(canWrite);
            }
            Console.WriteLine($"Method [Read] finished by thread {name}");
        }
    }

    // write into the array
    public static void Write()
    {
        string name = Thread.CurrentThread.Name ?? "?";
        Console.WriteLine($"Method [Write] started by thread {name}");
        // we request the token of the write counter
        lock (canWrite)
        {
            // as long as it is not our turn, we give the token back and wait
            while (!writingAllowed || WriteOrder[nextWriter] != name)
            {
                Monitor.Wait(canWrite);
            }
            // at the counter: writing the array
            for (int i = 0; i < data.Length; i++)
            {
                Thread.Sleep(500);
                data[i] = Random.Shared.Next(0, 1000);
                Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: writer {name} wrote the number {data[i]}");
            }
            // next writer
            writingAllowed = false;
            nextWriter++;
            // we notify the readers that they can read
            lock (canRead)
            {
                readingAllowed = true;
                Monitor.PulseAll(canRead);
            }
            Console.WriteLine($"Method [Write] finished by thread {name}");
        }
    }
}

Access to the read counter is conditioned by the following elements:

  • line 36: the token [canRead];
  • line 39: the Boolean [readingAllowed];
  • line 44: the sorted array of reader names. Readers proceed to the reading station in the order specified by this array;
  • line 41: [nextReader] is the index in this array of the next reader authorized to proceed to the counter.

Access to the write counter is similarly controlled by the token [canWrite], the Boolean [writingAllowed], the array [WriteOrder], and the index [nextWriter].

The main program:

  • lines 7–9: readers will proceed to the counter in reverse order of their creation (R2, R1, R0), and writers in the order they were created (W0, W1, W2). The arrays are constructed using a collection expression [.. sequence], which creates an array from a LINQ query;
  • lines 18–22: creation and initialization of readers, followed by writers. They will all be blocked, since neither reading nor writing is permitted;
  • line 26: writing is enabled, and the writers are notified that something has changed (method [AllowWriting], lines 48–54).

The [Read] method:

  • lines 64–86: the entire process at the reading station is controlled by [lock] on token [canRead]. The reader who obtains it keeps it throughout their time at the station;
  • lines 67–69: A reader who has acquired the token returns it if reading is not authorized or if it is not their turn;
  • lines 72–75: passing through the station (reading the table);
  • lines 78–79: the reader changes the access conditions for the read counter. It still holds the read token, and these changes prevent any other reader from accessing the counter;
  • lines 81–84: the reader changes the access conditions for the write counter and notifies all waiting writers that something has changed. To do this, it takes the token [canWrite]: here we see that the thread holds two tokens at the same time. The writers do the same in reverse order (they hold [canWrite] and request [canRead]), which is the deadlock situation described above. However, there is no risk here: a thread requests the second token only at the end of its visit to the counter; the two counters are never occupied at the same time; and the other threads hold a token only long enough to check the access condition, without requesting the other token.

The method [Write] is symmetric to the method [Read]. The 2008 course used, in the main program, [Monitor.Pulse], which wakes up only a single thread: if that thread was not the expected writer, it would go back to sleep, and the expected writer might never be woken up. When using an access condition that specifies a particular thread, you must use [PulseAll].

Here is an example of execution:

> dotnet run threads-17.cs
Reader R2 is in position 0, writer W0 is in position 0
Reader R1 is in position 1, writer W1 is in position 1
Reader R0 is in position 2, writer W2 is in position 2
Method [Read] started by thread R0
Method [Read] started by thread R1
Method [Read] started by thread R2
End of Main...
Method [Write] started by thread W0
Method [Write] started by thread W2
Method [Write] started by thread W1
11:37:14.240: writer W0 wrote the number 550
11:37:14.789: writer W0 wrote the number 13
11:37:15.290: writer W0 wrote the number 5
Method [Write] finished by thread W0
11:37:15.790: reader R2 read the number 550
11:37:16.290: reader R2 read the number 13
11:37:16.791: reader R2 read the number 5
Method [Read] finished by thread R2
11:37:17.291: writer W1 wrote the number 962
11:37:17.791: writer W1 wrote the number 475
11:37:18.292: writer W1 wrote the number 167
Method [Write] finished by thread W1
11:37:18.792: reader R1 read the number 962
11:37:19.292: reader R1 read the number 475
11:37:19.793: reader R1 read the number 167
Method [Read] finished by thread R1
11:37:20.293: writer W2 wrote the number 849
11:37:20.793: writer W2 wrote the number 31
11:37:21.294: writer W2 wrote the number 817
Method [Write] finished by thread W2
11:37:21.794: reader R0 read the number 849
11:37:22.295: reader R0 read the number 31
11:37:22.795: reader R0 read the number 817
Method [Read] finished by thread R0

Writers and readers take turns at their respective counters, each in the specified order: W0, R2, W1, R1, W2, R0.

8.7. Concurrent Collections

The previous examples protected very simple shared data (a counter, an array). When threads share a collection (list, dictionary, queue), one could protect each access with a [lock]. But ordinary collections ([List<T>], [Dictionary<TKey,TValue>], [Queue<T>]) are not designed to be modified by multiple threads simultaneously: unprotected use can result in the loss of elements, trigger unexpected exceptions, or even corrupt the collection’s internal structure. Starting with .NET 4, the [System.Collections.Concurrent] namespace offers collections designed to be shared between threads without explicit locking:

Collection
Role
[ConcurrentDictionary<TKey,TValue>]
a dictionary whose operations, including conditional updates, are safe
[ConcurrentQueue<T>]
a queue (first-in, first-out)
[ConcurrentStack<T>]
a stack (last-in, first-out)
[ConcurrentBag<T>]
a bag: an unordered collection
[BlockingCollection<T>]
a wrapper around the previous collections that blocks the consumer when the collection is empty and, possibly, the producer when it is full

8.7.1. ConcurrentDictionary and ConcurrentQueue

In the program [threads-18.cs], four threads generate random numbers and count the number of times each number appears in a shared dictionary:

// concurrent collections: ConcurrentDictionary and ConcurrentQueue

using System.Collections.Concurrent;

// the shared dictionary: digit -> number of draws
var draws = new ConcurrentDictionary<int, int>();
// the shared queue of the thread reports
var reports = new ConcurrentQueue<string>();

// 4 threads each draw 100,000 random digits
const int nbDraws = 100_000;
var threads = new Thread[4];
for (int i = 0; i < threads.Length; i++)
{
    threads[i] = new Thread(() =>
    {
        for (int j = 0; j < nbDraws; j++)
        {
            int digit = Random.Shared.Next(0, 10);
            // adding the key with the value 1 or atomic update of the existing value
            draws.AddOrUpdate(digit, 1, (key, oldValue) => oldValue + 1);
        }
        // report put in the queue
        reports.Enqueue($"thread {Thread.CurrentThread.Name} has finished its {nbDraws:N0} draws");
    })
    { Name = $"T{i}" };
    threads[i].Start();
}
foreach (Thread thread in threads)
{
    thread.Join();
}

// the reports, in their order of arrival
while (reports.TryDequeue(out string? report))
{
    Console.WriteLine(report);
}
// the results of the draws
foreach (var (digit, count) in draws.OrderBy(pair => pair.Key))
{
    Console.WriteLine($"digit {digit}: {count,6:N0} draws");
}
Console.WriteLine($"total: {draws.Values.Sum():N0} draws (expected: {threads.Length * nbDraws:N0})");
// GetOrAdd: returns the value associated with the key, after adding it if it did not exist
Console.WriteLine($"GetOrAdd(10, 0) = {draws.GetOrAdd(10, 0)}, number of keys: {draws.Count}");

Comments:

  • line 3: the namespace for concurrent collections;
  • line 6: the shared dictionary associates each digit with its number of occurrences;
  • line 8: a queue where each thread deposits a report at the end of its work;
  • line 21: the method [AddOrUpdate] adds the key [digit] with the value 1 if it does not exist, and otherwise replaces its value with the one calculated by the function [(key, oldValue) => oldValue + 1]. The operation is atomic. We could not write [draws[digit]++]: this expression reads the value and then writes the new one, in two operations, and two threads could lose an update as in the example [threads-07.cs];
  • line 24: each thread places its report in the queue;
  • lines 35–37: the main thread empties the queue. [TryDequeue] returns [false] when the queue is empty. Concurrent collections only offer “Try…” methods that combine the check and the operation: a check for [Count > 0] followed by a removal would not be safe, since another thread could empty the queue in between;
  • line 40: the dictionary is traversed in key order; each key/value pair is split into two variables;
  • line 46: the [GetOrAdd] method returns the value associated with the key, after adding the key with the provided value if it did not exist.

Results:

> dotnet run threads-18.cs
thread T0 has finished its 100,000 draws
thread T2 has finished its 100,000 draws
thread T1 has finished its 100,000 draws
thread T3 has finished its 100,000 draws
digit 0: 40,168 draws
digit 1: 39,980 draws
digit 2: 39,982 draws
digit 3: 39,856 draws
digit 4: 40,170 draws
digit 5: 40,271 draws
digit 6: 40,081 draws
digit 7: 39,704 draws
digit 8: 39,722 draws
digit 9: 40,066 draws
total: 400,000 draws (expected: 400,000)
GetOrAdd(10, 0) = 0, number of keys: 11

The total number of draws is correct: no updates were lost. The reports appear in the order in which the threads completed their work.

8.7.2. Producers and Consumers: BlockingCollection

The reader-writer problem is a variant of the classic producer-consumer problem: threads produce data that other threads consume. The [BlockingCollection<T>] class solves it without any explicit synchronization tools:

  • [Add(element)] adds an element. If the collection has a limited capacity and is full, the producer is blocked until a spot becomes available;
  • [CompleteAdding()] signals that no more elements will be added;
  • [GetConsumingEnumerable()] returns an enumeration that removes elements one by one. If the collection is empty, the consumer is blocked until an element arrives; the enumeration ends when the collection is empty and [CompleteAdding] has been called.

The program [threads-19.cs] is as follows:

// producer / consumers with BlockingCollection<T>

using System.Collections.Concurrent;

// a queue of at most 3 elements
using var queue = new BlockingCollection<int>(boundedCapacity: 3);

// the producer puts 8 numbers then signals that it has finished
var producer = new Thread(() =>
{
    for (int i = 1; i <= 8; i++)
    {
        // blocking if the queue is full
        queue.Add(i);
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: P put {i} (waiting elements: {queue.Count})");
        Thread.Sleep(100);
    }
    // no more elements will be added
    queue.CompleteAdding();
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: P has finished");
});

// two consumers take the numbers as long as there are some
var consumers = new Thread[2];
for (int i = 0; i < consumers.Length; i++)
{
    consumers[i] = new Thread(() =>
    {
        string name = Thread.CurrentThread.Name ?? "?";
        // the enumeration waits for the elements and stops when the queue is empty and completed
        foreach (int number in queue.GetConsumingEnumerable())
        {
            Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: {name} took {number}");
            // processing of the number
            Thread.Sleep(600);
        }
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: {name} has finished");
    })
    { Name = $"C{i + 1}" };
}

// start and wait of all the threads
producer.Start();
foreach (Thread consumer in consumers)
{
    consumer.Start();
}
producer.Join();
foreach (Thread consumer in consumers)
{
    consumer.Join();
}

Comments:

  • line 6: a queue (the default underlying collection is a [ConcurrentQueue<int>]) containing at most 3 elements. The parameter is passed by name, which makes the call more readable;
  • Lines 9–20: The producer deposits the numbers 1 through 8, one every tenth of a second, then signals that it is finished;
  • lines 24–37: two consumers retrieve the numbers and spend 0.6 seconds processing each one. They consume the numbers more slowly than the producer generates them;
  • line 31: the blocking enumeration. Each number is retrieved by only one of the two consumers.

Results:

> dotnet run threads-19.cs
11:37:27.216: C2 took 1
11:37:27.213: P put 1 (waiting elements: 0)
11:37:27.366: P put 2 (waiting elements: 1)
11:37:27.366: C1 took 2
11:37:27.466: P put 3 (waiting elements: 1)
11:37:27.566: P put 4 (waiting elements: 2)
11:37:27.666: P put 5 (waiting elements: 3)
11:37:27.866: C2 took 3
11:37:27.866: P put 6 (waiting elements: 3)
11:37:27.966: C1 took 4
11:37:27.966: P put 7 (waiting elements: 3)
11:37:28.466: C2 took 5
11:37:28.466: P put 8 (waiting elements: 3)
11:37:28.566: C1 took 6
11:37:28.567: P has finished
11:37:29.066: C2 took 7
11:37:29.167: C1 took 8
11:37:29.667: C2 has finished
11:37:29.767: C1 has finished

We see the producer quickly enqueue the first numbers, then—as soon as the queue contains 3 elements—only enqueue a number when a consumer dequeues one: it is blocked by [Add] when the queue is full. The consumers share the numbers and stop when the queue is empty and the process is complete. Note that the consumer C2 displayed the retrieval of the number 1 before the producer displayed its insertion: the insertion did indeed occur earlier, but the producer was interrupted between [Add] and its display.

The class [Channel<T>] in the namespace [System.Threading.Channels] provides the same service for asynchronous programming: a consumer waiting for an element does not block any threads. We’ll cover this in the next chapter.

8.8. The thread pool

Until now, to manage threads, we have created them using [new Thread(...)] and then executed them using [Start()]. Creating a thread is a relatively costly operation: in particular, the system must allocate a stack for it, and each thread occupies memory as long as it exists. We’ll see in the chapter on databases that connection pools exist: open connections are kept in reserve and lent to threads that request them, since opening a connection is costly. A similar system exists for threads: the thread pool (ThreadPool) of .NET.

  • As long as the number of threads in the pool is below a minimum threshold, the pool immediately creates a new thread for each task that cannot find a free thread. By default, this minimum is the number of processors; it can be read using [ThreadPool.GetMinThreads(out min1, out min2)] and changed using [ThreadPool.SetMinThreads(min1, min2)]. The pool manages two types of threads: worker threads (min1), which execute the jobs assigned to them, and asynchronous I/O threads (min2);
  • if this number is insufficient, the pool creates additional threads incrementally, up to a maximum that can be read using [ThreadPool.GetMaxThreads(out max1, out max2)] and changed using [ThreadPool.SetMaxThreads(max1, max2)]. When the maximum is reached, the jobs assigned to the pool are queued until a thread becomes available;
  • A thread that has completed a task is not destroyed: it is returned to the pool and used for the next task.

The thread pool offers two advantages: it saves time spent creating threads (a few threads can handle hundreds of requests), and by limiting the number of threads, it prevents the application from becoming overwhelmed by too many requests.

To assign a task to the pool, use the [ThreadPool.QueueUserWorkItem(WaitCallback callback, object? state)] method, where [WaitCallback] is a delegate of signature [void M(object? state)]. The task will be executed by a thread in the pool, with the parameter [state]. The program [threads-20.cs] is as follows:

// the thread pool

// the main thread
Thread.CurrentThread.Name = "Main";

// minimum number of threads in the pool
ThreadPool.GetMinThreads(out int min1, out int min2);
Console.WriteLine($"Minimum number of worker threads in the pool: {min1}");
Console.WriteLine($"Minimum number of asynchronous I/O threads in the pool: {min2}");
ThreadPool.SetMinThreads(3, min2);
ThreadPool.GetMinThreads(out min1, out _);
Console.WriteLine($"Minimum number of worker threads after change: {min1}");
// maximum number of threads in the pool
ThreadPool.GetMaxThreads(out int max1, out int max2);
Console.WriteLine($"Maximum number of worker threads in the pool: {max1}");
Console.WriteLine($"Maximum number of asynchronous I/O threads in the pool: {max2}");
bool ok = ThreadPool.SetMaxThreads(5, max2);
ThreadPool.GetMaxThreads(out max1, out _);
Console.WriteLine($"Maximum number of worker threads after change: {max1} (SetMaxThreads returned {ok})");

// we hand 7 jobs to the pool; the countdown lets us wait for their end
using var remainingJobs = new CountdownEvent(7);
for (int i = 0; i < 7; i++)
{
    var data = new Data { Number = i, Start = DateTime.Now, Duration = 3 };
    ThreadPool.QueueUserWorkItem(Sleep, data);
}
Console.WriteLine($"At {DateTime.Now:HH:mm:ss.ff}, the Main thread has handed the 7 jobs to the pool and waits for their end");
// wait for the end of the 7 jobs
remainingJobs.Wait();
Console.WriteLine($"At {DateTime.Now:HH:mm:ss.ff}, end of the Main thread");

// the job executed by a pool thread
void Sleep(object? state)
{
    if (state is not Data data)
    {
        return;
    }
    // state of the pool
    ThreadPool.GetAvailableThreads(out int available, out _);
    Console.WriteLine($"At {DateTime.Now:HH:mm:ss.ff}, job no. {data.Number} starts in thread {Thread.CurrentThread.ManagedThreadId} for {data.Duration} s (available worker threads: {available})");
    // sleep for Duration seconds
    Thread.Sleep(data.Duration * 1000);
    DateTime end = DateTime.Now;
    Console.WriteLine($"At {end:HH:mm:ss.ff}, job no. {data.Number} ends: scheduled for {data.Duration} s, it lasted {(end - data.Start).TotalSeconds:F2} s since its request");
    // one job less
    remainingJobs.Signal();
}

// the information passed to each job
class Data
{
    public int Number { get; init; }
    public DateTime Start { get; init; }
    public int Duration { get; init; }
}

Comments:

  • lines 7–12: We read and display the current minimum number of both types of threads in the pool, then set the minimum number of worker threads to 3. The parameter [out _] is an “ignored parameter”: we are not interested in the returned value;
  • lines 14–19: We do the same to set the maximum number of worker threads to 5. [SetMaxThreads] returns [false] if the requested value is rejected, for example, if it is less than the number of processors;
  • line 22: A [CountdownEvent] object acts as a countdown timer: each call to its [Signal] method decrements it, and its [Wait] method blocks the calling thread until it reaches 0. Here, it allows the main thread to wait for the 7 jobs to finish: you cannot call [Join] on a thread from the pool, since you do not own it. The 2008 course asked the user to press [Enter] once the jobs were completed;
  • lines 23–26: We assign 7 tasks, each lasting 3 seconds, to the pool, even though the pool will never have more than 5 worker threads. Each task receives a [Data] object;
  • line 30: the main thread waits for the 7 jobs to finish;
  • lines 34–48: the task executed by a thread in the pool. This is a non-static local function because it uses the local variable [remainingJobs];
  • line 41: the number of worker threads still available (the maximum minus the number of busy threads);
  • line 48: the counter is decremented at the end of each job.

Results:

> dotnet run threads-20.cs
Minimum number of worker threads in the pool: 2
Minimum number of asynchronous I/O threads in the pool: 1
Minimum number of worker threads after change: 3
Maximum number of worker threads in the pool: 32767
Maximum number of asynchronous I/O threads in the pool: 1000
Maximum number of worker threads after change: 5 (SetMaxThreads returned True)
At 11:37:31.85, the Main thread has handed the 7 jobs to the pool and waits for their end
At 11:37:31.85, job no. 1 starts in thread 6 for 3 s (available worker threads: 2)
At 11:37:31.85, job no. 0 starts in thread 4 for 3 s (available worker threads: 2)
At 11:37:31.85, job no. 2 starts in thread 7 for 3 s (available worker threads: 2)
At 11:37:32.85, job no. 3 starts in thread 8 for 3 s (available worker threads: 1)
At 11:37:33.85, job no. 4 starts in thread 9 for 3 s (available worker threads: 0)
At 11:37:34.85, job no. 0 ends: scheduled for 3 s, it lasted 3.04 s since its request
At 11:37:34.85, job no. 1 ends: scheduled for 3 s, it lasted 3.00 s since its request
At 11:37:34.85, job no. 2 ends: scheduled for 3 s, it lasted 3.00 s since its request
At 11:37:34.85, job no. 5 starts in thread 7 for 3 s (available worker threads: 0)
At 11:37:34.85, job no. 6 starts in thread 4 for 3 s (available worker threads: 0)
At 11:37:35.85, job no. 3 ends: scheduled for 3 s, it lasted 4.00 s since its request
At 11:37:36.85, job no. 4 ends: scheduled for 3 s, it lasted 5.00 s since its request
At 11:37:37.85, job no. 6 ends: scheduled for 3 s, it lasted 6.00 s since its request
At 11:37:37.85, job no. 5 ends: scheduled for 3 s, it lasted 6.00 s since its request
At 11:37:37.85, end of the Main thread
  • On this dual-processor machine, the pool initially had a minimum of 2 worker threads and a maximum of 32,767;
  • jobs 0, 1, and 2 started immediately: the pool has a minimum of 3 threads;
  • jobs 3 and 4 started with delays of one and two seconds, respectively: there were no free threads left, and the pool—once it exceeds its minimum—creates new threads only gradually as long as jobs are waiting. The maximum of 5 threads is then reached: no threads remain available;
  • Jobs 5 and 6 had to wait for a thread to become available after jobs 0, 1, and 2 finished. We can see that they reused threads 7 and 4, which had executed the first jobs;
  • In total, jobs 5 and 6 took 6 seconds from the time they were requested: 3 seconds of waiting and 3 seconds of execution.

It is rarely necessary to change the pool limits: its default values are suitable for most applications. In fact, [QueueUserWorkItem] is rarely used directly: we work through tasks, which are described below.

8.9. Tasks and the Parallel Library

8.9.1. Task.Run

Since .NET 4, the [System.Threading.Tasks.Task] class represents a job (a task) that runs asynchronously. [Task.Run(action)] assigns a job to the thread pool, just like [QueueUserWorkItem], but returns a [Task] object that allows you to track this job: check if it’s finished, wait for it, retrieve its result, or the exception it threw. A task that returns a result of type T is of type [Task<T>]. The program [threads-21.cs] is as follows:

// Task.Run: hand a job to the thread pool and get its result

Console.WriteLine($"Main thread: id {Thread.CurrentThread.ManagedThreadId}, pool thread: {Thread.CurrentThread.IsThreadPoolThread}");

// three tasks that each return a result of type int
var tasks = new Task<int>[3];
for (int i = 0; i < tasks.Length; i++)
{
    int number = i;
    tasks[i] = Task.Run(() =>
    {
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: task {number} runs in thread {Thread.CurrentThread.ManagedThreadId} (pool thread: {Thread.CurrentThread.IsThreadPoolThread})");
        // work
        Thread.Sleep(500 * (number + 1));
        // the result of the task
        return number * 10;
    });
}
// wait for the end of the three tasks (blocking)
Task.WaitAll(tasks);
// the results
for (int i = 0; i < tasks.Length; i++)
{
    Console.WriteLine($"task {i}: status {tasks[i].Status}, result {tasks[i].Result}");
}

// a task that throws an exception
Task failingTask = Task.Run(Fail);
try
{
    // the exception of the task is rethrown by Wait, wrapped in an AggregateException
    failingTask.Wait();
}
catch (AggregateException e)
{
    Console.WriteLine($"{e.GetType().Name} containing {e.InnerException?.GetType().Name}: {e.InnerException?.Message}");
}
Console.WriteLine($"failed task: status {failingTask.Status}");

// the work of the failing task
static void Fail() => throw new InvalidOperationException("test....");

Comments:

  • Lines 10–16: Each task executes a lambda that displays the thread executing it, sleeps for 0.5, 1, or 1.5 seconds, and then returns a result of type [int]. [Task.Run] therefore returns a [Task<int>];
  • line 20: the main thread waits for the three tasks to finish. This is a blocking wait, like [Join];
  • line 24: the property [Status] indicates the task’s status; [RanToCompletion] means it completed normally;
  • line 24: the property [Result] is the result of the task. If the task had not completed, reading [Result] would block the thread until it finished;
  • line 28: a task that executes the [Fail] method, which throws an exception;
  • line 32: the exception thrown in the task is not lost: it is stored in the [Task] object, then rethrown in the thread waiting for the task. [Wait] wraps it in an [AggregateException] (a task can group several of these together), whose [InnerException] property is the original exception;
  • line 41: the method that throws the exception. We could have passed the lambda [() => throw new InvalidOperationException(...)] directly to [Task.Run].

Results:

> dotnet run threads-21.cs
Main thread: id 1, pool thread: False
11:37:39.970: task 0 runs in thread 4 (pool thread: True)
11:37:39.969: task 1 runs in thread 6 (pool thread: True)
11:37:40.516: task 2 runs in thread 4 (pool thread: True)
task 0: status RanToCompletion, result 0
task 1: status RanToCompletion, result 10
task 2: status RanToCompletion, result 20
AggregateException containing InvalidOperationException: test....
failed task: status Faulted
  • The tasks run in threads from the pool (line 3);
  • Task 2 did not start until Task 0 had finished, in the thread that Task 0 had freed up: the pool had only 2 threads (the default minimum on this machine) and did not create a third one in time;
  • the results are retrieved after the tasks have finished;
  • the exception from the task that failed was caught by the main thread, and the task’s status is [Faulted].

This example also replaces the one for the [BackgroundWorker] class from the 2008 course, which demonstrated how to run a job in the background, retrieve its result or exception, and cancel it. [BackgroundWorker] was designed for Windows Forms graphical user interfaces; it has now been replaced by tasks, with cancellation handled by [CancellationToken] and progress tracking by the [IProgress<T>] interface, which are presented in the following chapter along with [async] / [await], which allow you to wait for a task without blocking a thread.

8.9.2. Parallel.For and Parallel.ForEach

The [System.Threading.Tasks.Parallel] class (.NET 4) automatically distributes the iterations of a loop among multiple threads in the pool:

  • [Parallel.For(start, end, i => ...)] executes the loop body for i ranging from [start] to [end] - 1;
  • [Parallel.ForEach(collection, element => ...)] executes the loop body for each element in the collection;
  • a parameter [ParallelOptions] allows you to limit the number of iterations executed simultaneously (property [MaxDegreeOfParallelism]);
  • both methods do not yield control until all iterations are complete. The thread that calls them participates in the work itself.

These loops are only suitable if the iterations are independent: their order of execution is undefined, and if they update shared data, that data must be protected. The program [threads-22.cs] is as follows:

// the parallel library: Parallel.For and Parallel.ForEach

using System.Collections.Concurrent;
using System.Diagnostics;

// 1 - count the prime numbers lower than N
const int N = 5_000_000;
// sequential version
var stopwatch = Stopwatch.StartNew();
int nbSequential = 0;
for (int i = 2; i < N; i++)
{
    if (IsPrime(i))
    {
        nbSequential++;
    }
}
Console.WriteLine($"sequential for    : {nbSequential:N0} prime numbers in {stopwatch.ElapsedMilliseconds} ms");

// parallel version no. 1: the iterations are spread over several threads
stopwatch.Restart();
int nbParallel1 = 0;
Parallel.For(2, N, i =>
{
    if (IsPrime(i))
    {
        // the counter is shared: atomic increment
        Interlocked.Increment(ref nbParallel1);
    }
});
Console.WriteLine($"Parallel.For no. 1: {nbParallel1:N0} prime numbers in {stopwatch.ElapsedMilliseconds} ms");

// parallel version no. 2: each thread counts in a local variable,
// which is added to the shared total only once, at the end
stopwatch.Restart();
int nbParallel2 = 0;
// the threads that worked and the number of prime numbers found by each
var threads = new ConcurrentDictionary<int, int>();
Parallel.For(2, N,
    // initialization of the local counter
    () => 0,
    // the loop body returns the new value of the local counter
    (i, state, local) => IsPrime(i) ? local + 1 : local,
    // end of a thread's work: we add its local counter to the total
    local =>
    {
        Interlocked.Add(ref nbParallel2, local);
        threads.AddOrUpdate(Environment.CurrentManagedThreadId, local, (_, n) => n + local);
    });
Console.WriteLine($"Parallel.For no. 2: {nbParallel2:N0} prime numbers in {stopwatch.ElapsedMilliseconds} ms");
foreach (var (id, nb) in threads.OrderBy(pair => pair.Key))
{
    Console.WriteLine($"  thread {id}: {nb:N0} prime numbers found");
}

// 2 - process a collection with at most 2 simultaneous processings
string[] files = ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"];
var options = new ParallelOptions { MaxDegreeOfParallelism = 2 };
Parallel.ForEach(files, options, file =>
{
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}: processing of {file} in thread {Environment.CurrentManagedThreadId}");
    // simulated processing
    Thread.Sleep(500);
});
Console.WriteLine("Parallel.ForEach finished");

// primality test (deliberately simple)
static bool IsPrime(int n)
{
    if (n < 2)
    {
        return false;
    }
    for (int d = 2; d * d <= n; d++)
    {
        if (n % d == 0)
        {
            return false;
        }
    }
    return true;
}

Comments:

  • lines 7–18: We count the prime numbers less than 5,000,000 using a standard [for] loop. The function [IsPrime] (lines 68–81) tests divisors up to the square root of the number: this is a time-consuming process, and the iterations are independent;
  • lines 22–31: the same loop using [Parallel.For]. The counter [nbParallel1] is shared by all threads: it is incremented by [Interlocked.Increment] (a local variable captured by a lambda can be passed by reference);
  • Lines 36–50: An overloaded version of [Parallel.For] allows you to work with a local variable: [Parallel.For] breaks the loop down into tasks, each executed by a single thread, and each task has its own counter. The first lambda (41) initializes a task’s counter; the second (43) is the body of the loop, which takes the counter and returns its new value; the third (45) is executed once per task, at the end of its work: it adds the task’s counter to the shared total. This significantly reduces the number of atomic operations;
  • line 48: for each thread that participated, we record the number of prime numbers it found;
  • lines 57–65: [Parallel.ForEach] processes a collection of filenames (the processing is simulated by a half-second pause), with at most 2 simultaneous processes.

Results:

> dotnet run threads-22.cs
sequential for    : 348,513 prime numbers in 2061 ms
Parallel.For no. 1: 348,513 prime numbers in 2161 ms
Parallel.For no. 2: 348,513 prime numbers in 1928 ms
  thread 1: 112,640 prime numbers found
  thread 4: 118,169 prime numbers found
  thread 5: 36,754 prime numbers found
  thread 7: 80,950 prime numbers found
11:37:50.239: processing of c.txt in thread 7
11:37:50.239: processing of a.txt in thread 1
11:37:50.776: processing of d.txt in thread 7
11:37:50.776: processing of b.txt in thread 1
11:37:51.277: processing of e.txt in thread 7
Parallel.ForEach finished
  • All three loops find the same number of prime numbers;
  • several threads participated in the parallel loop, including the main thread (ID 1);
  • the processing of [Parallel.ForEach] occurs in pairs, half a second apart, and in an order that does not match that of the list;
  • The time savings in this run are disappointing: there are only 2 processors, which our program’s threads were sharing with other very active programs at the time of the capture. On a lightly loaded machine with n processors, a parallel loop like this one can be nearly n times faster than the sequential loop. You should always measure before drawing conclusions: parallelization comes at a cost (work distribution, synchronization) that is only worthwhile if each iteration involves enough computation.

8.10. Data Local to a Thread

8.10.1. The Principle

Consider a three-layer application:

Image

Suppose the application is multi-user, such as a web application. Each user request is handled by a thread. The thread’s lifecycle is as follows:

  • the thread is created or retrieved from the thread pool to fulfill a user’s request;
  • if this request requires data, the thread executes a method in the [ui] layer, which calls a method in the [business] layer, which in turn calls a method in the [dao] layer;
  • the thread returns the response to the user. It then either terminates or is returned to the thread pool.

During the second step, it may be useful for the thread to have its own data—that is, data not shared with other threads. This data could, for example, belong to the specific user the thread is serving and be used in the various [ui, business, dao] layers without being passed as a parameter to each method.

The 2008 course used the methods [Thread.SetData] and [Thread.GetData] for this purpose, which manage a sort of private dictionary for the thread. They still exist but are rarely used; two simpler and more efficient tools are preferred:

  • the [[ThreadStatic]] attribute applied to a static field: each thread has its own instance of the field;
  • the [ThreadLocal<T>] class: an object whose [Value] property has a different value for each thread. A function passed to the constructor provides the initial value for each thread.

The program [threads-23.cs] compares a regular static field with these two tools:

// data local to a thread: [ThreadStatic] and ThreadLocal<T>

Thread.CurrentThread.Name = "Main";
// the main thread writes its own values
Context.Shared = 100;
Context.PerThread = 100;
Context.User.Value = "admin";

// three threads write then reread the three data
var threads = new Thread[3];
for (int i = 0; i < threads.Length; i++)
{
    int number = i;
    threads[i] = new Thread(() =>
    {
        // writing
        Context.Shared = number;
        Context.PerThread = number;
        // thread T2 does not set a user: it will get the default value
        if (number != 2)
        {
            Context.User.Value = $"user{number}";
        }
        // we let the other threads write in their turn
        Thread.Sleep(200);
        // rereading
        Display();
    })
    { Name = $"T{i}" };
    threads[i].Start();
}
foreach (Thread thread in threads)
{
    thread.Join();
}
// rereading by the main thread
Display();

// displays the data seen by the current thread
static void Display()
{
    Console.WriteLine($"{Thread.CurrentThread.Name,-4} : Shared={Context.Shared,-3} PerThread={Context.PerThread,-3} WithInitializer={Context.WithInitializer,-2} User={Context.User.Value}");
}

// the data of the program
static class Context
{
    // an ordinary static field: a single copy, shared by all the threads
    public static int Shared;
    // one copy per thread
    [ThreadStatic]
    public static int PerThread;
    // trap: the initializer is only executed for the first thread that uses the class
    [ThreadStatic]
    public static int WithInitializer = 5;
    // one copy per thread, initialized for each thread by the factory passed to the constructor
    public static readonly ThreadLocal<string> User = new(() => $"anonymous (thread {Thread.CurrentThread.Name})");
}

Comments:

  • line 49: a regular static field. There is only one instance of it, shared by all threads;
  • lines 51–52: the attribute [[ThreadStatic]] gives each thread an instance of the field;
  • lines 54–55: a classic pitfall. The initializer [= 5] is executed by the class’s static constructor, just once, in the thread that uses the class first. For the other threads, the field has the default value of its type, which is 0 in this case;
  • line 57: a [ThreadLocal<string>] object. The lambda passed to the constructor is executed the first time a thread reads [Value] without having written to it; it provides the initial value for that thread;
  • Lines 5–7: The main thread writes its own values;
  • lines 17–22: Each thread writes its own values, except for the thread T2, which does not set a user;
  • line 25: each thread waits for the others to write before reading the values again.

Results:

1
2
3
4
5
> dotnet run threads-23.cs
T0   : Shared=2   PerThread=0   WithInitializer=0  User=user0
T2   : Shared=2   PerThread=2   WithInitializer=0  User=anonymous (thread T2)
T1   : Shared=2   PerThread=1   WithInitializer=0  User=user1
Main : Shared=2   PerThread=100 WithInitializer=5  User=admin
  • the ordinary field [Shared] has the same value for all threads: the last value written (in this execution, that of thread T2);
  • the field [PerThread] retained, in each thread, the value that thread had written to it;
  • the field [WithInitializer] is 5 for the main thread and 0 for the others;
  • the property [User.Value] has the value written by each thread; the thread T2, which had not written to it, received the initial value provided by the constructor’s lambda.

Thread-local data has a limitation: it is scoped to the thread, not the process. With asynchronous programming, as discussed in the next chapter, a single process can start in one thread and continue in another after a wait. In this case, we use the [AsyncLocal<T>] class, whose value persists across threads.

8.10.2. Application: A Transaction Shared Across Layers

Consider the following three-tier application, in which the [dao] layer manages a table of items:

Image

Suppose that the interface of the [dao] layer is initially as follows:

1
2
3
4
5
6
interface IDao
{
    int InsertArticle(Article article);
    List<Article> GetAllArticles();
    void DeleteAllArticles();
}

Later, the need arises to insert an array of items within a transaction, because the system must operate on an all-or-nothing basis: either all items are inserted, or none are. A method named [InsertArticles(Article[] articles)] can be added to the interface. Then, for another application, the need arises to delete a list of items, again within a transaction… To meet different business requirements, the [dao] layer will grow. We can take a different approach:

  • include only the basic operations (insert, update, delete, read an item) in the [dao] layer;
  • move operations that simultaneously update multiple items to the [business] layer. These operations use the basic operations from the [dao] layer.

The advantage of this solution is that the same [dao] layer can be used without modification with different [business] layers. It introduces a challenge in managing the transaction that groups the updates to be performed atomically:

  • the transaction must be started by the [business] layer before it calls the methods of the [dao] layer;
  • the methods in the [dao] layer must be aware of the transaction’s existence in order to participate in it;
  • the transaction must be completed (committed or rolled back) by the [business] layer.

To ensure that the methods of the [dao] layer are aware of any ongoing transaction, the transaction could be added as a parameter to each method of the [dao] layer. This parameter would then appear in the signature of the interface’s methods, which would bind the interface to a specific data source. Thread-local data provides a more elegant solution: the [business] layer places the transaction in the thread-local data, and that is where the [dao] layer retrieves it. The method signatures of the [dao] layer do not need to be changed. Multiple threads (multiple users) can work simultaneously: each has its own transaction.

The 2008 course used a SQL Server Compact database, a product that has since been discontinued. Databases will be covered in the chapters on ADO.NET and Entity Framework Core. To keep the focus on threads, we’ll use an in-memory table and a simulated transaction here. The [threads-24.cs] program combines all the layers into a single file. The entities are as follows:

// an article
record Article(int Id, string Name, decimal Price, int CurrentStock, int MinimumStock);

// a simulated transaction: it records how to undo each update
class Transaction
{
    private static int counter;
    private readonly Stack<Action> undos = new();
    public int Number { get; } = Interlocked.Increment(ref counter);

    // records the operation that undoes an update
    public void Log(Action undo) => undos.Push(undo);
    // commits the transaction: the updates are kept
    public void Commit() => undos.Clear();
    // rolls back the transaction: the updates are undone, from the last to the first
    public void Rollback()
    {
        while (undos.TryPop(out Action? undo))
        {
            undo();
        }
    }
}

// the thread-local data shared by the layers
static class ThreadContext
{
    // the current transaction of the thread (null if there is none)
    public static readonly ThreadLocal<Transaction?> Transaction = new();
}
  • line 60: an article is a [record] whose identifier [Id] is set by the [dao] layer;
  • lines 63–78: a simulated transaction. Each update made within the transaction records the operation needed to undo it (method [Log]); [Commit] commits the transaction while ignoring these operations; [Rollback] rolls it back by executing the rollback operations, from the last to the first (hence the stack [Stack<Action>]). A real database operates in a similar manner, using a transaction log;
  • line 67: Each transaction is assigned a unique number. Since transactions can be created by multiple threads simultaneously, the counter is incremented atomically;
  • lines 84–87: the thread-local data shared by the layers: the thread’s current transaction, or [null] if there is none.

The [dao] layer is as follows:

// the [dao] layer: an in-memory table of articles with a uniqueness constraint on the name
interface IDao
{
    int InsertArticle(Article article);
    List<Article> GetAllArticles();
    void DeleteAllArticles();
}

class Dao : IDao
{
    // the table, shared by all the threads, and its lock
    private readonly List<Article> table = [];
    private readonly Lock padlock = new();
    private int nextId = 1;

    public int InsertArticle(Article article)
    {
        // is there a transaction in progress in the current thread?
        Transaction? transaction = ThreadContext.Transaction.Value;
        lock (padlock)
        {
            if (table.Any(a => a.Name == article.Name))
            {
                throw new InvalidOperationException($"an article named [{article.Name}] already exists");
            }
            Article inserted = article with { Id = nextId++ };
            table.Add(inserted);
            // inside a transaction, we record how to undo the insertion
            transaction?.Log(() =>
            {
                lock (padlock)
                {
                    table.Remove(inserted);
                }
            });
            string mode = transaction is null ? "outside a transaction" : $"in transaction {transaction.Number}";
            Console.WriteLine($"[{Thread.CurrentThread.Name}] [dao] insertion of {inserted.Name} (id {inserted.Id}) {mode}");
            return 1;
        }
    }

    public List<Article> GetAllArticles()
    {
        lock (padlock)
        {
            return [.. table];
        }
    }

    public void DeleteAllArticles()
    {
        lock (padlock)
        {
            table.Clear();
        }
    }
}
  • line 101: the item table, shared by all threads. Access to it is protected by the lock on line 102;
  • line 108: the method [InsertArticle] retrieves any transaction that the [business] layer has placed in the current thread’s local data;
  • Lines 111–113: The table has a unique constraint on item names: inserting an item with a name that already exists throws an exception;
  • Line 115: The inserted item is a copy of the received item, with an identifier assigned by the [dao] layer. The expression [with] creates a copy of a [record] by modifying certain properties;
  • Lines 118–122: If a transaction is in progress, the [dao] layer participates by recording how to undo the insertion. The operator [?.] calls [Log] only if [transaction] is not [null];
  • line 125: a trace message displays the thread, the inserted item, and the transaction, if any;
  • lines 131–135: The list of items returned is a copy of the table (collection expression [.. table]), so that the caller does not iterate over it while another thread is modifying it.

The [business] layer is as follows:

// the [business] layer
interface IBusiness
{
    void InsertArticlesInTransaction(Article[] articles);
    void InsertArticlesOutOfTransaction(Article[] articles);
    List<Article> GetAllArticles();
    void DeleteAllArticles();
}

class Business(IDao dao) : IBusiness
{
    // insertion of an array of articles: all or nothing
    public void InsertArticlesInTransaction(Article[] articles)
    {
        // start of the transaction, stored in the thread-local data
        var transaction = new Transaction();
        ThreadContext.Transaction.Value = transaction;
        try
        {
            foreach (Article article in articles)
            {
                // the [dao] layer will find the transaction in the thread
                dao.InsertArticle(article);
                // simulated processing, which gives other threads time to step in
                Thread.Sleep(200);
            }
            transaction.Commit();
            Console.WriteLine($"[{Thread.CurrentThread.Name}] [business] transaction {transaction.Number} committed");
        }
        catch
        {
            transaction.Rollback();
            Console.WriteLine($"[{Thread.CurrentThread.Name}] [business] transaction {transaction.Number} rolled back");
            throw;
        }
        finally
        {
            // the thread no longer has a transaction in progress
            ThreadContext.Transaction.Value = null;
        }
    }

    // insertion of an array of articles without a transaction
    public void InsertArticlesOutOfTransaction(Article[] articles)
    {
        foreach (Article article in articles)
        {
            dao.InsertArticle(article);
        }
    }

    public List<Article> GetAllArticles() => dao.GetAllArticles();
    public void DeleteAllArticles() => dao.DeleteAllArticles();
}
  • line 157: the [dao] layer is injected by the primary constructor;
  • lines 163–164: the [InsertArticlesInTransaction] method starts a transaction and saves it to the thread’s local data;
  • line 170: the insertion method of the [dao] layer is called for each item, without passing the transaction to it;
  • line 172: a pause simulates processing, in order to give another thread time to intervene during the transaction (third part of the test program);
  • line 174: if the program reaches this point, no exception has occurred: the transaction is committed;
  • lines 177–181: if an exception occurs, the transaction is rolled back and the exception is rethrown to the caller;
  • line 186: In any case, the thread no longer has an active transaction. This is important if the thread is reused (pooled thread);
  • line 191: the same insertion, without a transaction.

The test program is as follows:

// data local to a thread: the [business] layer puts the transaction there, the [dao] layer finds it there

// the layers of the application
IDao dao = new Dao();
IBusiness business = new Business(dao);
Thread.CurrentThread.Name = "Main";

// two articles with the same name: the second violates the uniqueness constraint on the name
Article[] articles = [new(0, "article", 100, 10, 1), new(0, "article", 100, 10, 1)];

// 1 - insertion outside a transaction
Console.WriteLine("Inserting the articles outside a transaction...");
Execute(() => business.InsertArticlesOutOfTransaction(articles));
DisplayArticles(business);

// 2 - insertion inside a transaction
business.DeleteAllArticles();
Console.WriteLine("Inserting the articles inside a transaction...");
Execute(() => business.InsertArticlesInTransaction(articles));
DisplayArticles(business);

// 3 - two simultaneous threads: the transaction of thread A is not seen by thread B
business.DeleteAllArticles();
Console.WriteLine("Two simultaneous threads...");
Article[] articlesA = [new(0, "keyboard", 20, 5, 1), new(0, "mouse", 10, 5, 1), new(0, "keyboard", 20, 5, 1)];
Article[] articlesB = [new(0, "screen", 150, 2, 1)];
var threadA = new Thread(() => Execute(() => business.InsertArticlesInTransaction(articlesA))) { Name = "A" };
var threadB = new Thread(() => Execute(() => business.InsertArticlesOutOfTransaction(articlesB))) { Name = "B" };
threadA.Start();
Thread.Sleep(100);
threadB.Start();
threadA.Join();
threadB.Join();
DisplayArticles(business);

// executes an action, displaying the possible exception
static void Execute(Action action)
{
    try
    {
        action();
    }
    catch (Exception e)
    {
        Console.WriteLine($"[{Thread.CurrentThread.Name}] {e.GetType().Name} : {e.Message}");
    }
}

// displays the list of articles
static void DisplayArticles(IBusiness business)
{
    Console.WriteLine("List of articles:");
    foreach (Article article in business.GetAllArticles())
    {
        Console.WriteLine($"  {article}");
    }
}
  • Line 9: We create an array of two items with the same name. The insertion of the second item will be rejected. If the insertion is performed outside a transaction, the first item is inserted and remains in the array. If it is performed within a transaction, the first item is inserted and then removed when the transaction is rolled back;
  • Lines 11–14: Insert the array outside a transaction, then display the table;
  • Lines 16–20: Same process within a transaction;
  • lines 22–34: Two threads are working simultaneously. Thread A inserts three items within a transaction (the third violates the uniqueness constraint); meanwhile, Thread B inserts an item outside a transaction;
  • lines 37–45: The function [Execute] performs an action and displays any exception, preceded by the thread name.

Results:

> dotnet run threads-24.cs
Inserting the articles outside a transaction...
[Main] [dao] insertion of article (id 1) outside a transaction
[Main] InvalidOperationException : an article named [article] already exists
List of articles:
  Article { Id = 1, Name = article, Price = 100, CurrentStock = 10, MinimumStock = 1 }
Inserting the articles inside a transaction...
[Main] [dao] insertion of article (id 2) in transaction 1
[Main] [business] transaction 1 rolled back
[Main] InvalidOperationException : an article named [article] already exists
List of articles:
Two simultaneous threads...
[A] [dao] insertion of keyboard (id 3) in transaction 2
[B] [dao] insertion of screen (id 4) outside a transaction
[A] [dao] insertion of mouse (id 5) in transaction 2
[A] [business] transaction 2 rolled back
[A] InvalidOperationException : an article named [keyboard] already exists
List of articles:
  Article { Id = 4, Name = screen, Price = 150, CurrentStock = 2, MinimumStock = 1 }
  • The insertion outside the transaction left the first record in the table (line 6);
  • the insertion made within a transaction left no records in the table: the record with ID 2 was removed by the rollback;
  • In the third part, thread B inserted the screen while thread A’s transaction 2 was in progress: it did so outside the transaction, since the transaction is a local variable of thread A. The rollback of transaction 2 removed the keyboard and mouse inserted by A, but not the screen inserted by B.

8.10.3. Conclusion

The previous example demonstrated the value of thread-local data for transaction management. It should not be replicated as-is. .NET provides this mechanism out of the box with the [System.Transactions.TransactionScope] class: an “ambient” transaction, accessible via [Transaction.Current], in which database connections automatically participate. Frameworks such as Entity Framework Core also manage transactions without the [dao] layer code having to handle them. And in today’s web applications, which are asynchronous, this context information is carried by [AsyncLocal<T>] rather than by thread-local data.

8.11. Further Reading

Thread synchronization is a challenging field. This chapter has introduced the main tools; to learn more:

  • Microsoft’s documentation, chapter “Threading in .NET”: Managed threading, particularly the “Overview of synchronization primitives” page;
  • Microsoft’s documentation, chapter “Parallel Programming”: Parallel Programming in .NET, which covers the [Parallel] library, tasks, and concurrent collections;
  • the “Threading” chapter from Joseph Albahari’s book, [C# in a Nutshell], for which the 2008 course already recommended the C# 3.0 edition. An older but still informative version of this chapter is available online: Threading in C#.

In modern code, [Thread] objects are rarely manipulated directly. Instead, tasks ([Task]) are preferred, as they run in the thread pool, and especially asynchronous programming using the keywords [async] and [await], which allows you to wait for a long-running operation (network, database, or file access) without blocking a thread. This is the subject of the next chapter. Nevertheless, the concepts covered in this chapter remain essential: tasks run in threads, and the data they share must be protected using the same tools ([lock], [Interlocked], concurrent collections).