Synchronous & Asynchronous and MultiThread Programming -3 — Semaphore — SemaphoreSlim
Race Condition & Synchronization Techniques -2 Semaphore — SemaphoreSlim
CancellationTokenSource
- CancellationTokenSource is an object that allows us to gracefully terminate asynchronous processes.
- Typically, it is used when a thread needs to be canceled due to a user or system event occurring during its execution.
Thread thread = new((cancellationToken) =>
{
var cancel = (CancellationTokenSource)cancellationToken;
while (true)
{
if (cancel.IsCancellationRequested) break;
Console.WriteLine("worked..");
}
Console.WriteLine("Thread finished.");
});
CancellationTokenSource cancellationToken = new();
thread.Start(cancellationToken);
Thread.Sleep(5000);
cancellationToken.Cancel();
- In the example above, a cancellationToken object is created, and it is passed as a parameter when the relevant thread is started. After sleeping the thread for 5 seconds, the information to stop the thread is sent with cancellationToken.Cancel();
Spinning
- Spinning in C# thread synchronization refers to the continuous checking of whether a thread can use a locked resource (such as a shared variable) by another thread.
- Spinning threads represent a behavior where threads are kept waiting in a loop based on a specific condition, effectively blocking them.
- This approach ensures that the thread remains actively running until the expected condition is met and does not yield to other threads. This behavior is termed as
spinning
orbusy-waiting.
bool threadCondition = true; //common variable
Thread thread1 = new(() =>
{
while (true)
{
if (!threadCondition)
{
for (int i = 1; i <= 10; i++)
Console.WriteLine($"Thread 1 {i}.");
threadCondition = false;
break;
}
}
});
Thread thread2 = new(() =>
{
while (true)
{
if (threadCondition)
{
for (int i = 10; i > 0; i--)
Console.WriteLine($"Thread 2 {i}.");
threadCondition = false;
break;
}
}
});
- As seen in the example above, the thread operations within the infinite loop are controlled in such a way that they do not block each other or create a race condition between the first and second threads based on the shared threadCondition value.
- However, the spinning method, due to its infinite loop logic, can sometimes lead to excessive CPU usage.
- Therefore, spinning behavior is not recommended for long waits or high-demand situations in order to effectively utilize CPU resources.
Monitor.Enter & Monitor.Exit
- The Monitor.Enter and Monitor.Exit methods are functional versions of the locking mechanism.
- Monitor.Enter attempts to acquire a lock on a specific object, similar to the lock mechanism. If another thread has acquired this lock, it will be placed in a waiting state.
- Monitor.Exit, on the other hand, releases the object locked by Monitor.Enter.
In essence, the locking mechanism also utilizes the Monitor.Enter and Monitor.Exit methods behind the scenes.
object locking = new(); //locking object
int i = 0;
Thread thread1 = new(() =>
{
try
{
Monitor.Enter(locking);
for (i = 0; i < 10; i++)
Console.WriteLine($"Thread 1 {i}");
}
finally
{
Monitor.Exit(locking);
}
});
Thread thread2 = new(() =>
{
try
{
Monitor.Enter(locking);
for (i = 0; i < 10; i++)
Console.WriteLine($"Thread 2 {i}");
}
finally
{
Monitor.Exit(locking);
}
});
thread1.Start();
thread2.Start();
- Örnekte de dikkat edeceğiniz üzere Monitor metotları kullanılırken thread içerisindeki işlemin uzun sürebilmesi ve diğer thread’leri de bu uzun süren işlemin etkilemesine önlemek için Monitor.Exit metodunu try-catch mekanizmasının finally bloğu içerisine almak kritik önem arz eder.
LockTaken
- Rarely, the Monitor.Enter method may fail to acquire the lock. In such cases, we can use the LockTaken parameter to understand and control this situation.
- LockTaken allows us to check whether the initial lock has been successfully acquired. This is crucial for ensuring the safety of locking operations in critical sections.
object locking = new();
int i = 0;
Thread thread1 = new(() =>
{
try
{
bool lockTaken = false;
Monitor.Enter(locking, ref lockTaken);
if (lockTaken)
for (i = 0; i < 10; i++)
Console.WriteLine($"Thread 1 {i}");
}
finally
{
Monitor.Exit(locking);
}
});
Thread thread2 = new(() =>
{
try
{
bool lockTaken = false;
Monitor.Enter(locking, ref lockTaken);
if (lockTaken)
for (i = 0; i < 10; i++)
Console.WriteLine($"Thread 2 {i}");
}
finally
{
Monitor.Exit(locking);
}
});
thread1.Start();
thread2.Start();
- As seen in the example, the Monitor.Enter function will return True to the lockTaken reference if it successfully acquires the lock. Then, in the subsequent if block, we can use this reference value to perform our checks.
Monitor.TryEnter
- Monitor.TryEnter is a synchronization method that attempts to acquire a lock on an object and checks whether it has been acquired.
- This method allows us to control the risks associated with competing threads while trying to lock a piece of code, thanks to the
millisecondTimeout
parameter. - A locking request is attempted within the specified time frame in milliseconds, and if the locking is successful within this time, it returns a successful (true) result. Otherwise, the result will be false.
object locking = new();
int i = 0;
Thread thread1 = new(() =>
{
var result = Monitor.TryEnter(locking, 100);
if (result)
try
{
for (i = 0; i < 10; i++)
Console.WriteLine($"Thread 1 {i}");
}
finally
{
Monitor.Exit(locking);
}
});
Thread thread2 = new(() =>
{
var result = Monitor.TryEnter(locking, 10);
if (result)
try
{
for (i = 0; i < 10; i++)
Console.WriteLine($"Thread 2 {i}");
}
finally
{
Monitor.Exit(locking);
}
});
thread1.Start();
thread2.Start();
- As seen above, both threads have specified the timeout parameter in milliseconds in Monitor.TryEnter method. For instance, if the locking operation in thread2 takes more than 10 milliseconds, the result value will be false, indicating that the locking operation has failed.
- TryEnter provides us with the ability to immediately continue with different operations instead of waiting when the lock cannot be acquired.
In Monitor.TryEnter method, there is also a third parameter, as mentioned earlier, which is the lockTaken parameter.
Mutex Class
- Mutex class, similar to the lock mechanism, is a synchronization structure capable of performing inter-process locking at the computer level.
- Its name originates from “Mutual Exclusion”.
Mutex mutex = new(); //Mutex object
Thread thread1 = new(() =>
{
mutex.WaitOne();
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Thread 1 {i}");
}
mutex.ReleaseMutex();
});
Thread thread2 = new(() =>
{
mutex.WaitOne();
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Thread 2 {i}");
}
mutex.ReleaseMutex();
});
thread1.Start();
thread2.Start();
Semaphore & Semaphore Slim
- Semaphore and SemaphoreSlim are important synchronization techniques used in controlling access to resources.
- Similar to the previously mentioned synchronization techniques, the aim with Semaphore and SemaphoreSlim is to control the access of threads entering a specific critical section simultaneously.
- However, Semaphore and SemaphoreSlim have a more flexible structure compared to other synchronization techniques.
In previous synchronization techniques, typically only one thread’s access to the critical section is allowed at a time. However, with Semaphore and SemaphoreSlim, it is possible to allow one or more threads to enter a critical section parametrically.
- In Semaphore and SemaphoreSlim synchronization technique, the number of threads allowed to access the critical section is determined parametrically. Other threads in line are put on hold. After a thread finishes its job in the critical section and exits, a new thread is allowed into the section.
- We can illustrate this process with an example: Imagine a car with 4 seats, all occupied. Now, there are more people waiting outside the car to get in. For someone outside to get in, one of the seated passengers must leave and vacate a seat.
- While both Semaphore and SemaphoreSlim are used to control a certain number of threads accessing a particular resource simultaneously, they have some differences:
Semaphore
- Semaphore is an older synchronization tool compared to SemaphoreSlim.
- It was introduced with .NET 2.0.
- It is dependent on operating system resources and is executed by the operating system.
- It exhibits synchronous behavior.
SemaphoreSlim
- SemaphoreSlim is newer and has a lighter structure compared to Semaphore.
- It was introduced with .NET 4.0.
- It typically works faster and has lower memory usage compared to Semaphore. Thus, it is more cost-effective.
- It is not dependent on the operating system, making it faster and more agile.
- It can exhibit both synchronous and asynchronous behavior.
The reason SemaphoreSlim can be more cost-effective and performant compared to Semaphore is that it is not dependent on the operating system and has its execution mechanism at the .NET level.
While SemaphoreSlim might seem superior to Semaphoe on paper, Semaphore synchronization technique should be used when needed according to the requirements.
List<int> numbers = new();
Semaphore semaphore = new(2, 3); //1.parameter=> allowed thread count 2.parametre => allowed thread for reach to the critical section
Thread thread1 = new(() =>
{
semaphore.WaitOne(); //thread requests access permission and gets 1 of 2 permissions.
int i = 0;
while (i < 10)
{
Console.WriteLine($"Thread1 {++i}");
numbers.Add(i);
Thread.Sleep(1000);
}
semaphore.Release(); //The thread that completes its operation frees up its space again..
});
Thread thread2 = new(() =>
{
semaphore.WaitOne();
int i = 10;
while (i < 20)
{
Console.WriteLine($"Thread2 {++i}");
numbers.Add(i);
Thread.Sleep(1500);
}
semaphore.Release();
});
Thread thread3 = new(() =>
{
semaphore.WaitOne();
int i = 20;
while (i < 30)
{
Console.WriteLine($"Thread3 {++i}");
numbers.Add(i);
Thread.Sleep(2000);
}
semaphore.Release();
});
thread1.Start();
thread2.Start();
thread3.Start();
//output
Thread2 11
Thread1 1
Thread1 2
Thread2 12
Thread1 3
Thread2 13
Thread1 4
Thread1 5
Thread2 14
Thread1 6
Thread2 15
Thread1 7
Thread1 8
Thread2 16
Thread1 9
Thread2 17
Thread1 10
Thread3 21
Thread2 18
Thread2 19
Thread3 22
Thread2 20
Thread3 23
Thread3 24
Thread3 25
Thread3 26
Thread3 27
Thread3 28
Thread3 29
Thread3 30
- In the example above, an example of using a Semaphore with three different threads is provided.
- When creating a Semaphore instance, the first parameter specifies the maximum number of threads allowed, while the second parameter indicates the maximum number of threads allowed simultaneous access to critical sections.
- As seen in the output, the Semaphore is initialized with a parameter of 3 for access to the critical section and a parameter of 2 for the maximum allowed threads. Although the maximum access parameter for thread2 and thread1 is 3, only two of them start processing because only two threads are allowed. After thread1 finishes its process, the third thread begins.
Here, if the first parameter, which specifies the maximum permission, is greater than the second parameter, it would be nonsensical. Because logically, attempting to grant access permission to more threads than the maximum allowed thread count for the critical section would be meaningless.
List<int> numbers = new();
using SemaphoreSlim semaphoreSlim = new(3, 3);
Thread thread1 = new(() =>
{
semaphoreSlim.Wait(100);
int i = 0;
while (i < 10)
{
Console.WriteLine($"Thread1 {++i}");
numbers.Add(i);
Thread.Sleep(100);
}
semaphoreSlim.Release();
});
Thread thread2 = new(async () =>
{
await semaphoreSlim.WaitAsync(1000);
int i = 10;
while (i < 20)
{
Console.WriteLine($"Thread2 {++i}");
numbers.Add(i);
Thread.Sleep(100);
}
semaphoreSlim.Release();
});
Thread thread3 = new(() =>
{
semaphoreSlim.Wait(5000);
int i = 20;
while (i < 30)
{
Console.WriteLine($"Thread3 {++i}");
numbers.Add(i);
Thread.Sleep(100);
}
semaphoreSlim.Release();
});
thread1.Start();
thread2.Start();
thread3.Start();
//output
Thread3 21
Thread1 1
Thread2 11
Thread1 2
Thread2 12
Thread3 22
Thread1 3
Thread2 13
Thread1 4
Thread3 23
Thread1 5
Thread2 14
Thread1 6
Thread3 24
Thread2 15
Thread1 7
Thread1 8
Thread2 16
Thread3 25
Thread1 9
Thread2 17
Thread1 10
Thread3 26
Thread2 18
Thread3 27
Thread2 19
Thread2 20
Thread3 28
Thread3 29
Thread3 30
- In this example, an example of using SemaphoreSlim is also provided.
- By specifying initialCount as 3 and maxCount parameters, it is observed that three threads simultaneously obtain permission from the semaphore to perform their operations.
- Here, as observed in thread2, SemaphoreSlim also allows an asynchronous approach.
By providing a parameter in milliseconds to the Wait action, we can assign a maximum waiting time for a thread to obtain a maximum permission. The thread waits for the specified time, and if it still cannot obtain permission from the semaphore, it accesses the critical section and performs its operation.
semaphoreSlim.Wait(1000);
Regardless of whether Semaphore or SemaphoreSlim is used, in both cases, once the work is done, the consumed resources should be released and memory management ensured by either releasing the semaphore with
semaphore.Dispose();
or using the semaphore & semaphoreSlim operation within ausing
block.