Multithreading in C# Introduction and Best practices

Bob Code
17 min readMay 31, 2024

--

Bob Code Originals

Agenda

I/ Introduction

Threads in a Computer CPU
Scheduler & Time slices
Processes & Threads
Concurrency and parallelism
Asynchrony vs Multithreading
Benefits of using multithreading in C#

II Threads In C#

Thread Lifecycle
Creating, starting and pausing threads
Join
Abort
Interrupt
Thread cancellation: a better way to stop threads

III/ Issues with Threads

Deadlocks and Race condition
Preventing race conditions and deadlocks using Join and Locks
AutoResetEvent
Threads performance issues

IV/ The ThreadPool

V/ Synchronisation Mechanisms

Mutex
Semaphore
Monitor (lock)

VI/ Managing threads

Foreground vs background thread
Thread context
Passing data to thread
Thread Priority
Thread-local Storage
Debugging Threads

VII/ Recommended way to work with threads in .Net

I/ Introduction

Threads in a Computer CPU

Before understanding threads and parallelism, it’s important to get a good grasp on how the underlying hardware works.

The CPU (Central processing Unit) is the brain of a computer, responsible for executing all instructions needed to run applications.

Modern computer often have multiple cores, each of which can be divided into logical processors.

stock.adobe.com

Each processor in turn can be divided into two logical processors

https://www.linkedin.com/pulse/understanding-physical-logical-cpus-akshay-deshpande

Each logical core can now process multiple threads simultaneously! For instance a 4 core processor can handle 8 threads in parallel thanks to hyper-threading technology.

To find-out about how many cores and logical cores your computer has, go to Task Manager > Performance

Alternatively, you can run the following code in your application:

Console.WriteLine("Cores count: " + Environment.ProcessorCount);

Scheduler & Time slices

Scheduler

In real life, each core runs many instructions concurrently.

To prevent one task blocking the others, it allocates a fixed amount of time on each tasks.

Switching between all tasks at very high speed, it feels that all are being run at the same time!

In Windows, a special program called the scheduler determines the order and time frames (known as processor time slices) in which the processor will execute instructions.

Its job is to decide the order and time frames in which the processor will consume the instructions.

https://www.scaler.com/topics/time-sharing-operating-system/

Processor time slices

Those time frames are also known as the processor time slices.

These time slices are the periods during which the processor handles specific instructions. To prevent long instructions from blocking the entire computer, each instruction is given a specific time slice.

Processes & Threads

Let’s now look at the difference between processes and threads.

https://medium.com/@metehanakbaba/processes-vs-threads-6320fb59bbed

A process is an executing program. An operating system uses processes to separate the applications that are being executed. A thread is the basic unit to which an operating system allocates processor time.

Process

  • Basically the instance that executes your programme
  • Has its own memory space and resources
  • Operates independently of other processes
  • Can contain one to many threads

A typical system might have hundreds of processes running simultaneously.

Each process acts as a container for threads

https://www.backblaze.com/blog/whats-the-diff-programs-processes-and-threads/

When a process starts it gets allocated its own memory and resources, which in turn will be shared amongst the threads.

Thread

  • An execution unit within a process
  • Each has its own stack
  • Shares heap memory with other threads within the same process
Currently 269 processes and 3506 threads running

In multithreaded processes, threads share the heap memory.

https://www.backblaze.com/blog/whats-the-diff-programs-processes-and-threads/

Multithreading is the ability for a programme to execute multiple threads allowing for efficient utilisation of the system resources.

https://www.backblaze.com/blog/whats-the-diff-programs-processes-and-threads/

Concurrency and parallelism

https://www.linkedin.com/pulse/concurrency-vs-parallelism-2-sides-same-coin-khaja-shaik-

Concurrency

  • When two or more tasks can start, run and complete in overlapping time periods
  • Enables a single thread to handle multiple tasks by switching rapidely between them, giving the illusion of simultaneous execution

Parallelism

  • When two or more tasks run at the same time on different threads
  • Two tasks executed by two threads
https://www.linkedin.com/pulse/concurrency-vs-parallelism-2-sides-same-coin-khaja-shaik-

Asynchrony vs Multithreading

Synchronous

  • Each task must complete before the next one starts.
  • Can lead to inefficiencies if one task takes too long, therefore freezing or blocking the app until the task is complete.

Tasks are executed one after the other

Asynchronous (single thread)

  • A single thread handles multiple tasks by switching between them
  • Allows tasks to progress concurrently without blocking the flow

Both tasks are started within the same thread and progress concurrently

Asynchronous (multiple threads)

  • Multiple threads handle different tasks simultaneously
  • Allows both tasks to be completed more quickly and efficiently

Two threads allow both tasks to progress independently and simultaneously

https://blog.devgenius.io/multi-threading-vs-asynchronous-programming-what-is-the-difference-3ebfe1179a5

Benefits of using multithreading in C#

Performance

The first obvious advantage is to make use of the hardware capability and speed-up the task at hand by executing tasks in parallel.

Responsiveness

By having multiple processes running at the same time, when a user clicks to retrieve data, the whole app remains responsive even though on thread is busy fetching the data

Scalability

Handling more and more requests is made possible by processing them simultaneously using different threads for each task.

II/ Threads In C#

As introduced, threads are the lowest unit of work of a CPU.

C# provides an easy to use library to work with threads: the Thread Class, it enables to manage the entire thread lifecycle!

Bob Code Originals

Thread Lifecycle

A typical thread goes through these stages

  • Thread created
  • Thread Starts
  • Thread completes method
  • Thread automatically ends

Creating, starting and pausing threads

Creating a thread can be done in multiple ways

// Create a new thread
var thread = new Thread(new ThreadStart(Operation));

// Or in a more concise way
var thread = new Thread(Operation);

// Or using a lambda
var thread = new Thread(() => { Operation(); });

As you can see a thread must have a method delegate to be instantiated

// A thread needs an operation (method delegate) to be instantiated
var thread = new Thread(Operation);

// Operation to be completed is passed into the thread
private void Operation()
{
Console.WriteLine("Hello from thread");
}

Although the thread has been created it needs to be explicitly started

// start the thread 
thread.Start();

Once started it automatically carries-out the task and ends once completed.

Once ended the thread cannot restart

A thread can however be paused by using the .Sleep method.

It will automatically restart after the time has elapsed.

// Pause and interrupt threads
Thread.Sleep(2000); // takes milliseconds or a TimeSpan
sleepingThread.Interrupt();

There are several ways of stopping threads, each with its own advantages and disadvantages:

  • Join
  • Abort
  • Interrupt

Join

The Thread.Join will stop “gracefully” the thread, meaning the code will wait until the thread has stopped.

Thread thread = new Thread(Work);
thread.Start();

// Waits until the thread stops "gracefully"
thread.Join();

Console.WriteLine("Thread has ended.");

It is also possible to pass a timeout to avoid waiting indefinitely for a thread to complete

Thread thread = new Thread(Work);
thread.Start();

// Waits until the thread stops or the timeout interval has elapsed
bool didComplete = thread.Join(1000);

This will basically make the main thead wait for 1 second and see if the thread has finished.

Blocking nature of Join

Thread.Join is a blocking call, which means it does not return until the thread has stopped executing or the optional timeout interval has elapsed.

Which means that the main thread will have to wait for the thread to complete.

To illustrate, you should never call Join from its own thread

Calling Thread.Join on the current thread from the current thread will cause the application to become unresponsive because the current thread will wait upon itself indefinitely

Thread thread = Thread.CurrentThread;

// This will cause a deadlock and make the application unresponsive
// thread.Join(); // Avoid this

Console.WriteLine("Avoid calling Join on the current thread.");

Join in multithreaded environments

Since Join is a blocking call it defeats the multithreading (and parallel) objective!

It could be however useful in race conditions when other threads should wait for one thread to manipulate an object (more on this later).

Abort

Immediately throws a ThreadAbortException which forces the thread to prematurely stop

It could however introduce a memory or resource leak by abruptely stopping the thread without closing a stream connection.

Additionally, it is unclear in what state the thread or the objects manipulated are when the thread is interrupted.

This could lead to deadlocks, resources or memory leaks!

Abruptly aborting threads

However, interrupt is not supported anymore in .Net core so it will throw a PlatformNotSupportedException.

Interrupt

Throws a ThreadInterruptedException only when the interrupted thread calls Thread.Join or Thread.Sleep.

It can be used to break a thread out of blocking operations, such as waiting for access to a synchronized region of code or during Thread.Sleep.

        Thread thread = new Thread(Work);
thread.Start();

// Give the thread some time to start
Thread.Sleep(500);

// Interrupt the thread, causing a ThreadInterruptedException
thread.Interrupt();

// Wait for the thread to handle the interruption and finish
thread.Join();

Thread.Interrupt will interrupt the thread only if it is in a blocked state. It does not inherently abort third-party code unless that code is currently in a blocking call like Thread.Sleep.

While Thread.Interrupt is still supported, cooperative cancellation using CancellationToken is often preferred for more predictable and manageable thread interruptions.

Thread cancellation: a better way to stop threads

However since .Net 5+, following the issues related with thread abort, Microsoft now recommends using thread cancellations rather than Thread.Abort or Thread.Interrupt.

This approach avoids the unpredictability and potential resource leaks associated with abruptly terminating threads.

// Instantiate cancellation token
CancellationTokenSource cts = new CancellationTokenSource();

// Pass token to thread
Thread thread = new Thread(() => Work(cts.Token));
Thread.Start();

// Simulate some other work in main thread
Thread.Sleep(1000);

// Cancel the thread work after 1 second
cts.Cancel();

// Wait for the thread to end gracefully
thread.Join();

CancellationTokenSource: This class provides a mechanism for signaling cancellation. It creates a CancellationToken that can be passed to the thread.

Pass the cancellation token in the Work method

static void Work(CancellationToken cancellationToken)
{
try
{
while (true)
{
// Check for cancellation request
cancellationToken.ThrowIfCancellationRequested();

// Simulate work
Thread.Sleep(500);
Console.WriteLine("Working...");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancellation requested, ending work.");
}
finally
{
Console.WriteLine("Cleanup code here.");
}
}
}

ThrowIfCancellationRequested: This method throws an OperationCanceledException if cancellation has been requested, allowing the thread to exit gracefully.

Benefits of Using CancellationToken

  • Graceful Exit: Threads can finish their current work and clean up resources properly before exiting.
  • Predictability: The thread exits in a controlled manner, avoiding the risks of abrupt termination.
  • Cooperative Cancellation: The thread periodically checks the token to see if it should stop, allowing for a more cooperative approach to thread management.

Using CancellationToken is the preferred method for managing thread lifecycles in modern .NET applications, ensuring that threads can be cancelled predictably and safely.

III/ Issues with Threads

Deadlocks and Race condition

As introduced earlie, each thread has its own stack, however each shares the heap memory.

https://www.baeldung.com/cs/threads-sharing-resources

Which means that multiple threads can access and modify one shared value.

This can lead to what is called a race condition, basically two threads changing the same value at the same time.

https://medium.com/kpmg-uk-engineering/race-conditions-and-entity-framework-core-5f4ea8b308f6
Bob Originals

Let’s see a code example

// Shared variable
public static int i = 0;

public static void ExecuteWork()
{
// thread executes loop
var t = new Thread(DoWork);
t.Start();
// another thread executes loop leading to race condition
DoWork();
}
// Two threads execute this method
public static void DoWork()
{
for(i = 0;i < 5; i++)
{
Console.WriteLine("*");
}
}
// Results in "******" => 6 * being printed instead of 5

Another issue is a deadlock, which occurs when two or more threads are blocked forever, each waiting on the other to release a resource.

Deadlocks can happen when multiple threads need the same set of resources and acquire them in different orders.

Preventing race conditions and deadlocks using Join and Locks

To prevent race condition one can either:

  • Have threads wait on each other
  • Lock threads

Waiting on threads can be done with the Thead.Join as we saw earlier

// suspends the main thread
// wait till thread is finished
// resumes main thread
thread.Join();

// Also takes a TimeSpan or int Milleseconds
bool Thread.Join(TimeSpan timeout);

Another way is to use the Thread.Lock statement

What it does is basically “locks” the shared object so that only the executing thread has access to it.

Once the thread is done it releases the object.

using System;
using System.Threading;

class Program
{
// these objects are shared
private static readonly object lock1 = new object();
private static readonly object lock2 = new object();

public static void Main()
{
// Thread 1
var t1 = new Thread(Thread1);
t1.Start();

// Thread 2
var t2 = new Thread(Thread2);
t2.Start();

t1.Join();
t2.Join();
}

public static void Thread1()
{
lock (lock1)
{
Thread.Sleep(100); // Simulate some work
lock (lock2)
{
Console.WriteLine("Thread 1 acquired both locks");
}
}
}

public static void Thread2()
{
lock (lock2)
{
Thread.Sleep(100); // Simulate some work
lock (lock1)
{
Console.WriteLine("Thread 2 acquired both locks");
}
}
}
}

AutoResetEvent

An AutoResetEvent can be used to sync communication between threads.

Here is how it works:

  • Create a AutoResetEvent => event created
  • 1st thread calls WaitOne() on event => thread1 waits for event to be released
  • 2nd thread does its work and calls set() on event => thread1 can perform its work
   static AutoResetEvent autoResetEvent = new AutoResetEvent(false);

static void Thread1()
{
autoResetEvent.WaitOne(); // Wait for the event to be signaled
// Perform work here
}

static void Thread2()
{
// Simulate some work with a sleep
Thread.Sleep(2000);
autoResetEvent.Set(); // Signal the event to release one waiting thread
}

Now we have communication between threads and they can signal to each other whenever the other can carry its work.

For robust communication, it is best to work with two AutoResetEvent to avoid locking. This way both threads can signal Set and Wait when needed.

https://www.dotnetinterviewquestions.in/article_c-threading-interview-questions:-what-is-the-difference-between-%25E2%2580%259Cautoresetevent%25E2%2580%259D-and-%25E2%2580%259Cmanualresetevent%25E2%2580%259D_118.html

Threads performance issues

Starting new threads is costly in terms of performance for several reasons:

  • Memory allocation

When a new thread is created, the system allocates memory for its stack and thread control block (TCB).

Allocating and initialising these resources consumes both memory and time.

  • Operating System (OS) overhead

The OS must manage each thread’s lifecycle. These operations require CPU cycles and contribute to the overhead of starting a thread.

  • Thread initialisation

Creating a thread is not instantaneous, it requires allocating resources, setting up the execution environment, and notifying the scheduler which takes time.

The solution is to use a thread pool instead.

Bob Originals

IV/ The ThreadPool

The System.Threading.ThreadPool class provides a pool of worker threads. You can also use thread pool threads.

Using a thread pool instead of creating new threads each time improves performance by reusing existing threads.

Here#s the process:

  • Threadpool receives a task
  • Threadpool allocates a thread
  • Thread executes task
  • Thread returns to pool
Threadpool flow

The .NET framework provides a built-in ThreadPool class that makes it easy to use thread pooling without having to manually manage the threads.

So instead of creating a thread like we did earlier, we would just queue our thread to the threadpool


ThreadPool.QueueUserWorkItem(Worker);

void Worker()
{
Console.WriteLine("Task executed.");
}

The threads in the managed thread pool are background threads.

We can see at any time how threads are available, the max and min threads in the pool, but also set them!

// get available threads
ThreadPool.GetAvailableThreads(out int workerThreads, out int completionPortThreads);

// get max threads
ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads);

// get min threads
ThreadPool.GetMinThreads(out int minWorkerThreads, out int minCompletionPortThreads);

// set max threads
ThreadPool.SetMaxThreads(8, 8);

// set min threads
ThreadPool.SetMinThreads(4, 4);

Even with using a threadpool, threads still should be synchronised when using shared data.

V/ Synchronisation Mechanisms

The Thread class works seamlessly with .NET's synchronization primitives (Mutex, Semaphore, Monitor).

These mechanisms help manage access to shared resources, ensuring data consistency and preventing race conditions.

Mutex

A Mutex (short for mutual exclusion) is a synchronisation primitive that ensures only one thread can acquire a lock at a time.


Mutex mutex = new Mutex();

for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(EnterCriticalSection);
thread.Start(i);
}


void EnterCriticalSection(object threadId)
{
mutex.WaitOne(); // Acquire the mutex lock

try
{
Thread.Sleep(1000); // Simulate work
}

finally
{
mutex.ReleaseMutex(); // Release the mutex lock
}
}

Semaphore

A Semaphore is a synchronisation primitive that limits the number of threads that can simultaneously access a resource.

It maintains a count of available resources and blocks threads when the count reaches zero.

Semaphore semaphore = new Semaphore(2, 2); // Allow 2 threads at a time


for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(EnterCriticalSection);
thread.Start(i);
}


static void EnterCriticalSection(object threadId)
{
semaphore.WaitOne(); // Acquire the semaphore
try
{
// Critical section: Access shared resource
Thread.Sleep(1000); // Simulate work
}
finally
{
semaphore.Release(); // Release the semaphore
}
}

Monitor (lock)

The Monitor class provides a mechanism for exclusive access to a resource, similar to using the lock keyword in C#.

It ensures that only one thread can execute a critical section of code at a time.

static object lockObject = new object();


for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(EnterCriticalSection);
thread.Start(i);
}
}

static void EnterCriticalSection(object threadId)
{
lock (lockObject) // Acquire the lock
{
Thread.Sleep(1000); // Simulate work
}
}

VI/ Managing threads

Foreground vs background thread

By default, threads created in .NET are foreground threads, meaning they keep the application alive until they complete.

However, you can explicitly set a thread to be a background thread, which will terminate automatically when all foreground threads have finished executing.

// Explicitly sets the thread as background
thread.IsBackground = true;
https://www.linkedin.com/pulse/tasks-vs-threads-c-mojtaba-shojajou-g8dic

Thread context

The thread context includes all the information necessary for the thread to resume execution seamlessly. This includes CPU registers, stack, and other relevant data.

// To find-out about the current state of a thread (running, background, stopped, aborted...)
var threadState = thread.ThreadState;

Passing data to thread

Lambda expressions are commonly used to initialize threads and pass data to them.

// only one argument can be passed
var thread = new Thread(() => Operation("Hello"));
private void Operation(string name)
{
Console.WriteLine("Hello from thread" + name);
}

However, be cautious when passing shared variables to threads to avoid race conditions. It’s best to use constants or local variables.

const string greeting = "Hello";
var thread = new Thread(() => Operation(greeting));

Thread Priority

Threads can have different priorities, which determine their order of execution.

Higher priority threads receive more CPU time. The default priority is Normal.

thread.Priority = ThreadPriority.Highest;

// Possible options are:
Lowest
BelowNormal
Normal
AboveNormal
Highest

Thread-local Storage

The Thread class supports thread-local storage using the ThreadLocal<T> class.

This allows each thread to have its own unique data, ensuring thread safety and preventing data corruption.

ThreadLocal<int> threadLocalValue = new ThreadLocal<int>(() => 0);

Debugging Threads

A thread can be given a name for easy debugging

thread.Name = "Bob Thread";
Handy for debugging

You can get the information of the thread in use by using:

ConsoleWriteLine("Main thread's ID: " + Thread.CurrentThread.ManagedThreadId);

Alternatively, when debugging you can also see what thread is doing what

In Visual Studio when debugging you can select to view threads

This will give the following window

https://learn.microsoft.com/en-us/visualstudio/debugger/walkthrough-debugging-a-multithreaded-application?view=vs-2022

VII/ Recommended way to work with threads in .Net

As it forcefully terminates the thread similar to throwing an exception on that thread. Instead use cancellation tokens.

  • Do use multiple threads for tasks that require different resources and avoid assigning multiple threads to a single resource.

Tasks involving I/O operations benefit from having their own threads to prevent blocking and improve overall throughput.

Similarly, tasks such as user input processing are best handled by dedicated threads.

ThreadPool.QueueUserWorkItem(PerformIOOperation);
ThreadPool.QueueUserWorkItem(ProcessUserInput);
  • Do handle exceptions in threads.

Unhandled exceptions in threads generally terminate the process.

ThreadPool.QueueUserWorkItem(DoWork);

void DoWork(object state)
{
try
{
// Perform work here
}
catch (Exception ex)
{
// Handle exception
}
}

Use the System.Threading.ThreadPool class to initialize and manage threads, especially for short-lived tasks and asynchronous operations.

The thread pool efficiently manages a pool of worker threads, reducing the overhead of thread creation and destruction.

ThreadPool.QueueUserWorkItem(DoWork);
  • Use tasks instead of threads!

Starting with .NET Framework 4, the recommended way to utilize multithreading is to use Task Parallel Library (TPL) and Parallel LINQ (PLINQ). For more information, see Parallel programming. (Microsoft)

https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/

I will see you in my next blog about tasks! :)

When to use threads?

Threads offer a level of control and customisation that is not always achievable with higher-level abstractions like tasks.

Threads provide developers with direct control over the execution of code at a lower level.

This allows for precise management of resources, scheduling, and synchronization, which can be crucial in certain performance-critical or specialised scenarios.

While threads offer greater control and flexibility, they also come with added complexity and potential pitfalls, such as race conditions, deadlocks, and synchronization issues.

Therefore, it’s essential to carefully consider the trade-offs and choose the appropriate concurrency model based on your application’s requirements

Sources

Async

https://www.udemy.com/course/ultimate-csharp-masterclass/

Threads

https://www.udemy.com/course/how-to-write-multi-threaded-csharp-code

Parallel Programming in .Net

--

--

Bob Code

All things related to Memes, .Net/C#, Azure, DevOps and Microservices