Resolving Race Conditions and Critical Sections in C#
A race condition in C# occurs when two or more threads access shared data simultaneously, and the outcome of the program depends on the unpredictable timing of these threads. This can lead to inconsistent or incorrect results, making race conditions a critical problem in multi-threaded applications.
In this article, we will learn everything in practice and will try not just to understand but also to resolve the race condition and critical section problems in .NET.
How Race Conditions Occur?
Race conditions typically occur when the following conditions are met.
- Shared Resource: Two or more threads are trying to read, write, or modify the same shared resource (e.g., a variable, object, or file).
- Concurrent Execution: The threads execute concurrently without proper synchronization mechanisms to control their access to the shared resource.
Race conditions occur when multiple threads concurrently access and modify shared data, leading to unpredictable results.
They arise due to the lack of synchronization, where threads interleave in a way that causes operations to be performed out of the expected order.
A critical section in C# refers to a block of code that must be executed by only one thread at a time to prevent data corruption or inconsistent results due to concurrent access. When multiple threads access shared resources, such as variables or objects, and at least one thread modifies those resources, a critical section ensures that only one thread can execute the code block that accesses the shared resource at a time. This is crucial for maintaining the integrity of the shared data.
Let’s directly switch to our example below and explore both worlds.
public class Transaction
{
public bool IsDone { get; set; }
public void Transfer(decimal amount)
{
if (!IsDone)//critical section
{
Console.WriteLine($"Transaction operation started in thread number = {Environment.CurrentManagedThreadId}");
TransferInternally(amount);
Console.WriteLine($"Transaction operation ended in thread number = {Environment.CurrentManagedThreadId}");
IsDone = true;
}
}
private void TransferInternally(decimal amount)
{
Console.WriteLine($"TransferInternally in thread...{Environment.CurrentManagedThreadId}, the amount = {amount}");
Console.WriteLine($"TransferInternally is done in thread...{Environment.CurrentManagedThreadId}, the amount = {amount}");
}
}
In the provided code, the critical section and the race condition problem relate to how multiple threads might interact with the Transaction class, particularly with the IsDone property and the Transfer method.
Critical Section
A critical section is a part of the code that accesses shared resources (in this case, the IsDone property) and must not be executed by more than one thread at a time. The critical section in your code is.
This block of code is critical because it checks and updates the IsDone property. If multiple threads access this code simultaneously without proper synchronization, it could lead to inconsistent or incorrect behavior.
Race Condition Problem
A race condition occurs when the outcome of a program depends on the timing or sequence of threads executing. In this code, a race condition could happen if multiple threads call the Transfer method at the same time.
Scenario Leading to a Race Condition
- Thread 1 checks the IsDone property and finds it to be false.
- Thread 1 proceeds with the transaction, outputting the start message and entering the TransferInternally method.
- Thread 2 then checks the IsDone property before Thread 1 has finished and still finds it to be false.
- Thread 2 also proceeds with the transaction, despite Thread 1 already handling it.
Because both threads find IsDone to be false before either has a chance to set it to true, both threads will execute the transaction, which is not the intended behavior. The property IsDone will only be set to true after both threads have completed their operations, leading to an incorrect situation where the transaction is performed twice.
Here is what Our main method looks like.
static void Main(string[] args)
{
Transaction2 transaction = new Transaction2();
for (int i = 0; i < 10; i++)
{
Task.Run(() =>
{
transaction.Transfer(3000);
});
}
Console.ReadLine();
}
The provided code demonstrates how a race condition problem can occur when multiple threads simultaneously attempt to execute the Transfer method of the Transaction class.
Explanation of the Code
Transaction Instance
Transaction transaction = new Transaction();
Here, a single instance of the Transaction class is created. This instance is shared among all threads that will be created in the subsequent loop.
Creating and Running Tasks
for (int i = 0; i < 10; i++)
{
Task.Run(() =>
{
transaction.Transfer(3000);
});
}
This loop runs 10 times, and each iteration starts a new task using Task. Run. Each task calls the Transfer method of the transaction object, attempting to transfer the amount of 3000.
Task
Each Task. Run spawns a new thread (or reuses one from the thread pool) to execute the code within the lambda expression. Therefore, up to 10 threads might be running the Transfer method concurrently.
Because there’s no synchronization mechanism like a lock statement, multiple threads could be executing this block simultaneously. This leads to several threads performing the transfer, outputting the start and end messages, and setting IsDone to true.
Race Condition Effect
The race condition occurs because the IsDone check and the subsequent operations are not atomic. This means that even if one thread sets IsDone to true, other threads might have already passed the check and are also executing the transfer, leading to multiple executions of what should be a one-time transaction.
The outcome of the Race Condition
As a result of the race condition, you might see output that indicates the transaction was processed multiple times, even though the logic implies it should only happen once. Each thread will print its messages to the console, showing that multiple threads have entered the critical section and completed the transaction.
The output
To prevent race condition problems and capture the critical section, we will use the lock keyword. Of course, we have multiple ways to avoid these problems but the easiest one is using the lock keyword.
public class Transaction2
{
public bool IsDone { get; set; }
private static readonly object _object = new object();
public void Transfer(decimal amount)
{
lock(_object)
{
if (!IsDone)//should act as a single atomic operation
{
Console.WriteLine($"Transaction operation started in thread number = {Environment.CurrentManagedThreadId}");
TransferInternally(amount);
Console.WriteLine($"Transaction operation ended in thread number = {Environment.CurrentManagedThreadId}");
IsDone = true;
}
}
}
private void TransferInternally(decimal amount)
{
Console.WriteLine($"TransferInternally in thread...{Environment.CurrentManagedThreadId}, the amount = {amount}");
Console.WriteLine($"TransferInternally is done in thread...{Environment.CurrentManagedThreadId}, the amount = {amount}");
}
}
Transaction2 result
Want to dive deeper?
Regularly, I share my senior-level expertise on my TuralSuleymaniTech YouTube channel, breaking down complex topics like .NET, Microservices, Apache Kafka, Javascript, Software Design, Node.js, and more into easy-to-understand explanations. Join us and level up your skills!