This article proposes considering multithreading in C#. It discusses the Thread class, creating threads, ThreadStart delegate, threads with parameters, thread synchronization, thread monitors, AutoResetEvent class and why it is needed, mutexes and semaphores.
Introduction
As we know, any block of code in C# is executed in a process called a thread, and this is the program's execution path. Typically, an application runs on a single thread. However, multithreading helps to run an application in multiple threads. To share the execution of a process between different threads, we must use multithreading. In this article, I propose to consider multithreading in C#, the Thread
class, creating threads, the ThreadStart delegate
, threads with parameters, thread synchronization, thread monitors, the AutoResetEvent
class and why it is needed, mutexes and semaphores. For our examples, I will use a simple console application.
Background
Thread is the main concept when dealing with multithreading. When a program is executed, each thread is allocated a certain time slice. To simultaneously execute several threads for their simultaneous execution, we must use multi-threading. For example, during the transfer of a large file from the client to the server without multithreading, we would block the graphical interface, but using threads, we can separate sending a file or even other resource-intensive tasks into a separate flow. And as follows from this, today client-server applications cannot exist due to multithreading.
To work with multithreading, we need the System.Threading
namespace. It defines a class that represents a separate thread - the Thread
class. The main properties of the class are as below:
ExecutionContext
: Allows you to get the context in which the thread is executing IsAlive
: Indicates if the thread is currently running IsBackground
: Indicates if the thread is in the background Name
: Contains the name of the stream ManagedThreadId
: Returns the numeric ID of the current thread
Priority: stores the priority of the thread - the value of the ThreadPriority enum
:
Lowest
BelowNormal
Normal
AboveNormal
Highest
As a default stream has a Normal
priority. However, we can change the priority while the program is running. For example, increase the importance of a thread by setting the priority to Highest
. The common language runtime will read and parse the priority values and, based on them, allocate a certain amount of time to this thread.
ThreadState
returns the state of the thread - one of the ThreadState
enum
values:
Aborted
: The thread has stopped but is not yet completely terminated. AbortRequested
: Abort was called on a thread, but the thread has not yet been terminated. Background
: The thread is running in the background. Running
: The thread is running and running (not paused). Stopped
: The thread has terminated. StopRequested
: The thread has received a request to stop. Suspended
: The thread is suspended. SuspendRequested
: The thread has received a request to be suspended. Unstarted
: The thread has not been started yet. WaitSleepJoin
: Thread blocked as a result of Sleep
or Join
methods.
For example, even before the start
method is executed, threads status is Unstarted
. However, if we start the thread, we will change its status to Running
as a result. In addition, by calling the sleep method, the status and status will change to WaitSleepJoin
. This means that during the operation of any thread, its status may change by methods.
The static
property CurrentThread
of the Thread
class allows you to get the current thread. As mentioned in C#, at least there is one thread and the Main
method is executing in it.
Let's look at a code example.
using System;
using System.Threading;
namespace Threading
{
class Program
{
static void Main(string[] args)
{
Thread currentThread = Thread.CurrentThread;
Console.WriteLine($"Thread: {currentThread.Name}");
currentThread.Name = "Main";
Console.WriteLine($"Thread name: {currentThread.Name}");
Console.WriteLine($"Thread Id: {currentThread.ManagedThreadId}");
Console.WriteLine($"Thread is Alive? : {currentThread.IsAlive}");
Console.WriteLine($"Priority of Thread: {currentThread.Priority}");
Console.WriteLine($"Status of Thread: {currentThread.ThreadState}");
Console.WriteLine($"IsBackground: {currentThread.IsBackground}");
Console.ReadKey();
}
}
}
The result is shown in image 1.
Image 1 - Thread example
As you can see from the example, in the first case, we got the Name
property as an empty string
. This happens because the Name
property of Thread
objects is not set by default. Moreover, the Thread
class defines a number of methods for managing the thread. The main ones are:
- The
static
GetDomain
method returns a reference to the application domain. - The
static
method GetDomainID
returns the id of the application domain in which the current thread is running. - The
static
method Sleep
stops the thread for a certain number of milliseconds. - The
Interrupt
method interrupts a thread that is in the WaitSleepJoin
state. - The
Join
method blocks the execution of the thread that called it until the thread for which this method was called ends. - The
Start
method starts a thread.
For example, let's use the Sleep
method to set the application's execution delay:
for (int i = 0; i < 50; i++)
{
Thread.Sleep(1000);
Console.WriteLine(i);
}
The result is shown in image 2.
Image 2 - Sleep method example
The result is especially shown as unfinished because we have 1000 milliseconds between loop iterations.
Creating Threads
As mentioned earlier, C# allows you to run an application with several threads that will be executed at the same time. Otherwise, this article would not exist. One of the constructors of the Thread
class is used to create a thread:
Thread(ThreadStart)
: takes as a parameter a ThreadStart delegate
object that represents the action to be performed on the thread Thread(ThreadStart, Int32)
: In addition to the ThreadStart delegate
, it accepts a numeric value that sets the size of the stack allocated for this thread. Thread(ParameterizedThreadStart)
: takes as a parameter a ParameterizedThreadStart delegate
object that represents the action to be performed on the thread Thread(ParameterizedThreadStart, Int32)
: Together with the ParameterizedThreadStart delegate
, it takes a numeric value that sets the stack size for this thread.
ThreadStart Delegate
This delegate represents an action that takes no parameters and returns no value:
public delegate void ThreadStart();
Let us look at the code:
Thread Thread1 = new Thread(Print);
Thread Thread2 = new Thread(new ThreadStart(Print));
Thread Thread3 = new Thread(() => Console.WriteLine("Hello from thread3"));
Thread1.Start();
Thread2.Start();
Thread3.Start();
void Print()
{
Console.WriteLine("Threads");
}
This code shows you different approaches to create a new Thread
, but the result is the same. Moreover, result of execution program is shown in Image 3, but it could be different because threads are run at the same time.
Image 3 - Multiple Threads
Let’s consider another example:
Thread MainThread = new Thread(Print);
MainThread.Start();
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Main Thread: {i}");
Thread.Sleep(300);
}
void Print()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Second Thread: {i}");
Thread.Sleep(400);
}
}
The result is shown in image 4.
Image 4 - Multiple Threads with pause
In the main thread - in the Main
method of our program, we create and launch a new thread in which the Print
method is executed and at the same time, we perform similar actions here in our thread - we print numbers from 0 to 9 to the console with a delay of 300 milliseconds. Thus, in our program, the main thread, represented by the Main
method, and the second thread, in which the Print
method is executed, will work simultaneously. As soon as all threads have completed, the program will complete its execution. This is how we can run more threads at the same time.
ParameterizedThreadStart and Threads with Parameters
So far, we've looked at how to run streams without parameters. But what if we need to pass a parameter to streams. For this purpose, the ParameterizedThreadStart delegate
is used, which is passed to the constructor of the Thread
class:
public delegate void ParameterizedThreadStart(object? obj);
ParameterizedThreadStart
is a lot like ThreadStart
, let's look at an example:
Thread Thread1 = new Thread(new ParameterizedThreadStart(Print));
Thread Thread2 = new Thread(Print);
Thread Thread3 = new Thread(message => Console.WriteLine(message));
Thread1.Start("Hi");
Thread2.Start("Hello");
Thread3.Start("Hello world");
void Print(object ? message)
{
Console.WriteLine(message);
}
The result is shown in image 5 below:
Image 5 - ParameterizedThreadStart example
When creating a thread, the constructor of the Thread
class is passed the delegate object
ParameterizedThreadStart new Thread(new ParameterizedThreadStart(Print))
, or directly the method that corresponds to this delegate (new Thread(Print))
, including in the form of a lambda expression (new Thread(message => Console.WriteLine(message)))
.
Then, when the thread is started, the Start()
method is passed the value that is passed to the parameter of the Print
method. However, we can only run in the second thread a method that takes an object of type object?
as the only parameter. We can get around this limitation using boxing. Also, we can pass several parameters of different types and moreover of its own type. Let's consider another example:
static void Main(string[] args)
{
Student student = new Student() { Id=1,Name="John"};
Thread Thread = new Thread(Print);
myThread.Start(student);
void Print(object? obj)
{
if (obj is Student person)
{
Console.WriteLine($"Id = {student.Id}");
Console.WriteLine($"Name = {student.Name}");
}
}
Console.ReadKey();
}
class Student
{
public int Id { get; set; }
public string Name { get; set; }
}
The result is shown in image 6.
Image 6 - ParameterizedThreadStart with own type
However, I highly discourage this approach, since the Thread.Start
method is not type-safe, that is, we can pass any type into it, and then we will have to cast the passed object to the type we need. I recommended to declare all the methods and variables used in a special class, and in the main program to start the thread through ThreadStart
. For example:
static void Main(string[] args)
{
Student student = new Student() { Id=1,Name="John"};
Thread myThread = new Thread(student.Print);
myThread.Start();
Console.ReadKey();
}
class Student
{
public int Id { get; set; }
public string Name { get; set; }
public void Print()
{
Console.WriteLine($"Id = {Id}");
Console.WriteLine($"Name = {Name}");
}
}
}
In this part, we considered threads with parameters and ParameterizedThreadStart
.
Threads Synchronization
In our previous parts, I considered examples without thread synchronization. As a result, we could get different sequences of program execution. In real projects, it is not uncommon for threads to use some shared resources that are common to the entire program. These can be shared variables, files, and other resources. The solution to the state problem is to synchronize threads and restrict access to shared resources while they are being used by a thread. For this, the lock
statement is used, which defines a block of code within which all code is blocked and becomes inaccessible to other threads until the current thread terminates. The rest of the threads are placed in a wait queue and wait until the current thread releases the given block of code. Consider an example (This is only available in C#8 or higher):
static void Main(string[] args)
{
int i = 0;
object locker = new();
for (int x = 1; x <= 5; x++)
{
Thread Thread = new(Print);
Thread.Name = $"Thread {x}";
Thread.Start();
}
void Print()
{
lock (locker)
{
i = 1;
for (int x = 1; x <= 5; x++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {i}");
i++;
Thread.Sleep(100);
}
}
}
Console.ReadKey();
}
The result is shown in image 7.
Image 7 - The result of threads Synchronization
Monitors
In the previous chapter, we looked at the gloss operator for thread synchronization. However, this is not the only way to synchronize threads. We can also use monitors, which are represented by the System.Threading.Monitor
class. It has the following methods:
void Enter(object obj)
: Gets exclusive ownership of the object passed as a parameter. void Enter(object obj, bool acquiredLock)
: additionally takes a second parameter - a boolean value that indicates whether ownership of the object was acquired from the first parameter void Exit(object obj)
: Releases a previously captured object
bool IsEntered(object obj)
: returns true
if the monitor has entered obj
void Pulse(object obj)
: Notifies a thread in the wait queue that the current thread has freed the object obj
void PulseAll(object obj)
: Notifies all threads in the wait queue that the current thread has released obj
. After that, one of the threads from the waiting queue captures the obj object
. bool TryEnter(object obj)
: Tries to grab object obj
. Returns true
if ownership of the object is successfully obtained bool Wait(object obj)
: Releases the object
's lock and puts the thread on the object
's wait queue. The next thread in the object
's ready queue locks the object. And all threads that have called the Wait
method remain in the wait queue until they receive a signal from the Monitor.Pulse
or Monitor.PulseAll
method sent by the owner of the lock.
The syntax for using monitors is encapsulated in the lock syntax. Based on the previous example, let's rewrite the code using monitors:
int i = 0;
object locker = new();
for (int x = 1; x <= 5; x++)
{
Thread Thread = new(Print);
Thread.Name = $"Thread {x}";
Thread.Start();
}
void Print()
{
bool Lock = false;
try
{
Monitor.Enter(locker, ref Lock);
i = 1;
for (int x = 1; i < 6; x++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {i}");
i++;
Thread.Sleep(100);
}
}
finally
{
if (Lock) Monitor.Exit(locker);
}
}
The result is shown in image 8.
Image 8 - Example of monitors
The lock
object and a bool
value are passed to the Monitor.Enter
method. The bool
value indicates the result of the lock
, and if it is true
, then the lock
was successfully completed. This method then locks the locker object
in the same way as the lock
statement does. If the lock
is successful, and it is made available to other threads using the Monitor.Exit
method in the try...finally
block.
In this part, we considered how motors works.
AutoResetEvent Class
In the previous article, we looked at how monitors work. However, there is an AutoResetEvent
class that also serves the purpose of thread synchronization. This class represents a thread synchronization event that allows you to switch this event object from a signal to a non-signaled state when a signal is received.
To manage synchronization, the AutoResetEvent
class provides a number of methods:
Reset()
: Sets the non-signaled state of an object
by blocking threads Set()
: Sets the signaled state of an object
, allowing one or more waiting threads to continue running WaitOne()
: Sets the non-signaled state and blocks the current thread until the current AutoResetEvent object
receives a signal
A sync event can be in a signaled or non-signaled state. If the event state is non-signaled, the thread that calls the WaitOne
method will block until the event state becomes signaled. The Set
method, on the contrary, sets the signaled state of the event.
Let’s take an example where we used the lock
method and replace by using AutoResetEvent
:
int i = 0;
AutoResetEvent SomeEvent = new AutoResetEvent(true);
for (int x = 1; x <= 5; x++)
{
Thread Thread = new(Print);
Thread.Name = $"Thread {x}";
Thread.Start();
}
void Print()
{
SomeEvent.WaitOne();
i = 1;
for (int x = 1; i <= 5; x++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {i}");
i++;
Thread.Sleep(100);
}
SomeEvent.Set();
}
The result is shown in image 9.
Image 9 - AutoResetEvent example
First, we create a variable of type AutoResetEvent
. By passing true
to the constructor, we indicate that the object being created will initially be in the signaled state.
Second, when a thread starts running, the call to SomeEvent.WaitOne()
fires. And then the WaitOne
method specifies that the current thread is put into the wait state until the waitHandler object
is signaled. And so all the threads are transferred to the waiting state.
Third, after the work is completed, the waitHandler.Set
method is called, which notifies all waiting threads that the waitHandler
object is again in the signaled state, and one of the threads "captures" this object, transfers it to the non-signaled state and executes its code. And the rest of the threads are waiting again.
Since we indicate in the AutoResetEvent
constructor that the object is initially in the signaled state, the first thread in the queue grabs this object and starts executing its code.
But if we wrote AutoResetEvent SomeEvent = new AutoResetEvent(false)
, then the object would initially be in a non-signaled state, and since all threads are blocked by the waitHandler.WaitOne()
method until waiting for a signal, then we would simply have a program blocking, and the program would not take any action.
If we use several AutoResetEvent
objects in our program, then we can use the static WaitAll
and WaitAny
methods to track the state of these objects, which take an array of objects of the WaitHandle
class, the base class for AutoResetEvent
, as a parameter.
Mutexes and Semaphores
In addition to the thread synchronization methods discussed in the previous articles, there are mutexes and semaphores.
The Mutex
class is also located in the System.Threading
namespace. Again, let’s take our example with lock
method and rewrite it using the Mutex
class.
int i = 0;
Mutex mutex = new();
for (int x = 1; x <= 5; x++)
{
Thread Thread = new(Print);
Thread.Name = $"Thread {x}";
Thread.Start();
}
void Print()
{
mutex.WaitOne();
i = 1;
for (int x = 1; i <= 5; x++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {i}");
i++;
Thread.Sleep(200);
}
mutex.ReleaseMutex();
}
The result is shown in image 10.
Image 10 - Example of Mutex
First, we create mutex by using Mutex mutex = new()
.
Next, the main synchronization work is done by the WaitOne()
and ReleaseMutex()
methods. The mutex.WaitOne()
method suspends the execution of the thread until the mutex mutex
is obtained.
After that, initially, the mutex
is free, so one of the threads gets it.
Next, after doing all the work, when the mutex
is no longer needed, the thread releases it using the mutex.ReleaseMutex()
method. And the mutex
is acquired by one of the waiting threads.
Finally, when execution reaches a call to mutex.WaitOne()
, the thread will wait until the mutex
is released. And after receiving it, it will continue to do its job.
Semaphores are another tool that the .NET platform offers us for managing synchronization. Semaphores allow you to limit the number of threads that have access to certain resources. In .NET, semaphores are represented by the Semaphore
class.
To create a semaphore, one of the constructors of the Semaphore
class is used:
Semaphore (int initialCount, int maximumCount)
: The initialCount
parameter specifies the initial number of threads, and maximumCount
is the maximum number of threads that have access to the shared resources. Semaphore(int initialCount, int maximumCount, string? name)
: optionally specifies the name of the semaphore Semaphore(int initialCount, int maximumCount, string? name, out bool createdNew)
: The last parameter is createdNew
, when true
, indicates that the new semaphore was successfully created. If this parameter is false
, then the semaphore with the specified name already exists.
To work with threads, the Semaphore
class has two main methods:
WaitOne()
: waits for free space in semaphore release()
: releases the space in the semaphore
Consider an example, we have a certain number of students who go to the portal with courses and watch the material. For our example, there cannot be more than five students on the portal. Although this is not a very real example from practice, it is suitable for us in order to consider the work of semaphores.
class Program
{
static void Main(string[] args)
{
for (int i = 1; i <= 5; i++)
{
Student student = new Student(i);
}
Console.ReadKey();
}
}
class Student
{
static Semaphore semahore = new Semaphore(5, 5);
Thread Thread;
int count = 5;
public Student(int x)
{
Thread = new Thread(Join);
Thread.Name = $"Student {x}";
Thread.Start();
}
public void Join()
{
while (count > 0)
{
semahore.WaitOne();
Console.WriteLine($"{Thread.CurrentThread.Name} enters in portal");
Console.WriteLine($"{Thread.CurrentThread.Name} doing something");
Thread.Sleep(1000);
Console.WriteLine($"{Thread.CurrentThread.Name} lives portal");
semahore.Release();
count--;
Thread.Sleep(5000);
}
}
}
The result is:
Student 3 enters in portal
Student 1 enters in portal
Student 1 doing something
Student 5 enters in portal
Student 5 doing something
Student 4 enters in portal
Student 2 enters in portal
Student 3 doing something
Student 4 doing something
Student 2 doing something
Student 5 lives portal
Student 1 lives portal
Student 2 lives portal
Student 4 lives portal
Student 3 lives portal
Student 5 enters in portal
Student 1 enters in portal
Student 5 doing something
Student 1 doing something
Student 3 enters in portal
Student 4 enters in portal
Student 2 enters in portal
Student 4 doing something
Student 2 doing something
Student 3 doing something
Student 5 lives portal
Student 1 lives portal
Student 4 lives portal
Student 2 lives portal
Student 3 lives portal
Student 1 enters in portal
Student 5 enters in portal
Student 5 doing something
Student 1 doing something
Student 4 enters in portal
Student 2 enters in portal
Student 4 doing something
Student 2 doing something
Student 3 enters in portal
Student 3 doing something
Student 1 lives portal
Student 5 lives portal
Student 4 lives portal
Student 2 lives portal
Student 3 lives portal
Student 1 enters in portal
Student 1 doing something
Student 5 enters in portal
Student 5 doing something
Student 2 enters in portal
Student 2 doing something
Student 4 enters in portal
Student 4 doing something
Student 3 enters in portal
Student 3 doing something
Student 5 lives portal
Student 1 lives portal
Student 4 lives portal
Student 2 lives portal
Student 3 lives portal
Student 1 enters in portal
Student 1 doing something
Student 5 enters in portal
Student 5 doing something
Student 2 enters in portal
Student 2 doing something
Student 4 enters in portal
Student 4 doing something
Student 3 enters in portal
Student 3 doing something
Student 1 lives portal
Student 5 lives portal
Student 2 lives portal
Student 4 lives portal
Student 3 lives portal
Let's consider the code.
First of all, in this program, the reader is represented by the Student
class. It encapsulates all thread-related functionality through the Thread
variable.
The semaphore
itself is defined as a static
variable sem:
static Semaphore semahore = new Semaphore(5, 5);
Second, its constructor takes two parameters: the first specifies how many objects the semaphore
will initially be available to, and the second parameter specifies the maximum number of objects that the semaphore
will use. In this case, we only have three readers that can be in the library at the same time, so the maximum number is 5
.
Next, the main functionality is concentrated in the Read
method, which is executed in the thread. First, the semahore.WaitOne()
method is used to wait for the semaphore to be received. After the space in the semaphore
becomes free, this thread fills the free space and starts to perform all further actions. After we finish reading, we release the semaphore
using the semahore.Release()
method.
After that, one place is freed in the semaphore
, which is filled by another thread.
In this part, we looked at mutexes and semaphores.
Conclusion
In conclusion, we considered multithreading in C#, the Thread
class, creating threads, the ThreadStart delegate
, threads with parameters, thread synchronization, thread monitors, the AutoResetEvent
class, mutexes and semaphores.
History
- 2nd March, 2022: Initial version