Click here to Skip to main content
15,881,173 members
Articles / Programming Languages / C#

Multithreading in C#

Rate me:
Please Sign up or sign in to vote.
4.51/5 (13 votes)
1 Mar 2022CPOL14 min read 16.1K   28   5
An overview of multithreading in C#
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.

C#
using System;
using System.Threading;

namespace Threading
{
    class Program
    {
        static void Main(string[] args)
        {
            //Creating instance of Thread
            Thread currentThread = Thread.CurrentThread;

            //Get name of Thread
            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:

C#
for (int i = 0; i < 50; i++)
           {
               Thread.Sleep(1000);      // here, we have delay execution by 1000 milliseconds
               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:

C#
public delegate void ThreadStart();

Let us look at the code:

C#
// Create a new Thread
            Thread Thread1 = new Thread(Print);
            Thread Thread2 = new Thread(new ThreadStart(Print));
            Thread Thread3 = new Thread(() => Console.WriteLine("Hello from thread3"));

            Thread1.Start();  // Thread1 starts
            Thread2.Start();  // Thread1 starts
            Thread3.Start();  // Thread1 starts

            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:

C#
// Create a new Thread
            Thread MainThread = new Thread(Print);
            // Run thread
            MainThread.Start();

            // Actions that we make in the Main Thread
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine($"Main Thread: {i}");
                //Pause thread
                Thread.Sleep(300);
            }

            // Actions from second thread
            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:

C#
public delegate void ParameterizedThreadStart(object? obj);

ParameterizedThreadStart is a lot like ThreadStart, let's look at an example:

C#
// Create new Threads
           Thread Thread1 = new Thread(new ParameterizedThreadStart(Print));
           Thread Thread2 = new Thread(Print);
           Thread Thread3 = new Thread(message => Console.WriteLine(message));

           // Run the threads
           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:

C#
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:

C#
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):

C#
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:

C#
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:

C#
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.

C#
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();     // Wait for mutex obj
    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.

C#
class Program
    {
        static void Main(string[] args)
        {          
            for (int i = 1; i <= 5; i++)
            {
                Student student = new Student(i);
            }

            Console.ReadKey();
        }
    }

    class Student
    {
        // Create semahore
        static Semaphore semahore = new Semaphore(5, 5);
        Thread Thread;
        int count = 5;// counter

        public Student(int x)
        {
            Thread = new Thread(Join);
            Thread.Name = $"Student {x}";
            Thread.Start();
        }

        public void Join()
        {
            while (count > 0)
            {
                semahore.WaitOne();  // wait

                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();  // clean place

                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:

C#
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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
GeneralMy vote of 5 Pin
keary_keller10-Mar-22 0:48
keary_keller10-Mar-22 0:48 
QuestionGood article on Threading Pin
MSBassSinger3-Mar-22 8:27
professionalMSBassSinger3-Mar-22 8:27 
QuestionTask Pin
per593-Mar-22 2:47
per593-Mar-22 2:47 
QuestionTask Pin
Сергій Ярошко1-Mar-22 21:45
professionalСергій Ярошко1-Mar-22 21:45 
AnswerRe: Task Pin
Uladzislau Baryshchyk2-Mar-22 14:03
Uladzislau Baryshchyk2-Mar-22 14:03 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.