Synchronous & Asynchronous and MultiThread Programming -2 — Concept of Thread & Race Condition & Synchronization Techniques
Concept of Thread & Race Condition & Synchronization Techniques
Thread Class in C#, Configurations and Features
- We have said that threads are the smallest execution units that can work independently within a process, that while a program is running, there is a thread called the main thread, and that we can also create auxiliary threads in addition to this thread, which we call worker threads.
- In C#, the System.Threading namespace is utilized to showcase the multithreaded approach.
- How to create a worker thread in C# and its different overloads are given in the example below.
class Program
private static void Main(string[] args) //main thread
{
Thread thread1 = new (()⇒ //creating worker thread 1. method
{ //ThreadStart overload'ı...
});
Thread thread2 = new ((o)⇒ //creating worker thread.
{ //w/ ParameterizedThreadStart overload.
});
Thread thread3 = new (new ThreadStart(()=> //creating worker thread 2. method
{
}));
}
- Creating a Thread in this way does not mean that the thread we created is active. We make this thread operational by running it as thread.Start().
Thread Id
- Each thread created in C# is given an Id by the operating system. Thanks to this id, operations performed can be distinguished from each other. Trace and debug operations can also be carried out more easily via this ID.
- ThreadId can also be used in resource management and communication between threads.
System.Environment.CurrentManagedThreadId; //1. method for thread Id
Thread.CurrentThread.ManagedThreadId; //2. method for thread Id
IsBackGround Field
- The IsBackground field determines whether a thread will run in the background. A thread marked as background, meaning its IsBackground property is set to true, behaves in relation to the main thread. When the main thread terminates, the background thread will also automatically terminate, regardless of whether its task has finished or not.
- On the other hand, a thread with IsBackground property set to false will continue to run even if the main thread terminates. This thread must wait until it completes its task before the main thread can terminate the application.
- This feature is useful for controlling the behavior of applications in scenarios such as background services or long-running tasks.
Race Condition & Synchronization Techniques
- Although asynchronous and multithreaded programming techniques are beneficial and necessary for developing fast and efficient programs, they can sometimes lead to problems.
- When striving to develop fast and performant programs, it’s essential to ensure that these programs handle data sharing between threads in a thread-safe manner.
A race condition is a situation encountered in multithreaded environments where multiple threads are allowed to access a shared resource simultaneously, resulting in undesired outcomes.
- For example, when performing read, write, or modify operations on a variable, if multiple threads perform similar operations on the same variable concurrently, race conditions may occur. In such cases, it becomes uncertain which thread will complete the operation first, leading to unexpected results or errors.
- Additionally, one thread may interfere with the unfinished work of another thread, also leading to a race condition.
- Race conditions are resolved using synchronization techniques. These techniques enable multiple threads to access a resource in a controlled manner, ensuring that only the relevant thread operates on critical resources.
- In summary, when a resource is accessed by multiple threads, synchronization logic must be applied.
The C# ecosystem offers various synchronization techniques to choose from, allowing developers to select the most suitable technique for the specific need and eliminate race condition situations.
Synchronization Techniques
Thread Locking
- The locking mechanism is one of the synchronization techniques that allow us to control access to a shared resource by multiple threads simultaneously.
- The lock mechanism ensures that only one thread accesses the critical section at a time, preventing race conditions and ensuring data integrity.
- Below, an example code snippet and its outputs are provided both without the lock mechanism, where race conditions occur, and with the lock mechanism, where race conditions are prevented.
Excessive use of the Lock mechanism and similar synchronization techniques can lead to performance problems, so it is important to keep the critical region as small as possible.
int i = 1;
Thread thread1 = new(() =>
{
while (i < 10)
{
i++;
Console.WriteLine($"Thread 1 : {i}");
}
});
Thread thread2 = new(() =>
{
while (i > 0)
{
i--;
Console.WriteLine($"Thread 2 : {i}");
}
});
thread1.Start();
thread2.Start();
//Output
//Thread 1: 1
//Thread 2: 1
//Thread 2: 1
//Thread 2: 0
//Thread 1: 2
//Thread 1: 1
//Thread 1: 2
//Thread 1: 3
//Thread 1: 4
//Thread 1: 5
//Thread 1: 6
//Thread 1: 7
//Thread 1: 8
//Thread 1: 9
//Thread 1: 10
//Thread 1: 11
- As seen in the output above, in the example given, due to the lack of synchronization control in both thread1 and thread2 operating on the resource i, inconsistency occurred in the value of the i object.
object locking = new(); //referance object
int i = 1;
Thread thread1 = new(() =>
{
lock (locking)
{
while (i < 10)
{
i++;
Console.WriteLine($"Thread 1 : {i}");
}
}
});
Thread thread2 = new(() =>
{
lock (locking)
{
while (i > 0)
{
i--;
Console.WriteLine($"Thread 2 : {i}");
}
}
});
thread1.Start();
thread2.Start();
//Output
//Thread 1: 2
//Thread 1: 3
//Thread 1: 4
//Thread 1: 5
//Thread 1: 6
//Thread 1: 7
//Thread 1: 8
//Thread 1: 9
//Thread 1: 10
//Thread 2: 9
//Thread 2: 8
//Thread 2: 7
//Thread 2: 6
//Thread 2: 5
//Thread 2: 4
//Thread 2: 3
//Thread 2: 2
//Thread 2: 1
//Thread 2: 0
- As seen in the example above, a reference object accessible to both threads has been created, and a lock mechanism has been established on critical points in the code via this reference. This ensures that the thread that starts its operation on the i object first completes its operation before the other thread starts and finishes its operation on the same object. Threads accessed the information whether the code block was locked or available for operation on the same reference object.
- As seen from the code output, the code blocks executed by different threads on the i object completed their operations without any data inconsistency.
Starting from .NET 9, instead of creating an instance of type object as in
“object locking = new();”,
we can directly create an instance of type Lock class like
“Lock locking = new();”.
Thread Sleep
- The Thread.Sleep() method is a method that allows a thread to sleep (wait) for a specified period of time. This method pauses the execution of the thread for the specified duration, and after the waiting period elapses, the thread becomes active again.
- It is commonly used to handle situations where a thread needs to be inactive for a certain period of time.
- For example, if a thread needs to wait for a certain amount of time after completing a task, or if you want to create a delay within a loop, you can use the Thread.Sleep() method.
Thread thread = new(() =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine(i);
Thread.Sleep(1000); //parameter in MS type...
}
});
thread.Start();
Thread Join
- The Thread.Join() method is a synchronization technique designed for a main thread to wait for the completion of a specified secondary thread. In other words, the main thread waits for the other thread, on which the Join() method is called, to finish before proceeding.
- This method is used for synchronizing and coordinating different threads in multithreaded applications.
During this waiting period, the main thread will also be blocked.
Thread thread1 = new(() =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Thread 1 {i}");
}
});
Thread thread2 = new(() =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Thread 2 {i}");
}
});
thread1.Start();
thread1.Join();
thread2.Start();
//output
//Thread 1: 2
//Thread 1: 3
//Thread 1: 4
//Thread 1: 5
//Thread 1: 6
//Thread 1: 7
//Thread 1: 8
//Thread 1: 9
//Thread 2: 1
//Thread 2: 2
//Thread 2: 3
//Thread 2: 4
//Thread 2: 5
//Thread 2: 6
//Thread 2: 7
//Thread 2: 8
//Thread 2: 9
- As seen in the example and output above, thread2 has to wait for the thread joined before it to finish its operation.