DEV Community

Cover image for Resolving Race Conditions and Critical Sections in C#
Tural Suleymani
Tural Suleymani

Posted on

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}");
    }
}
Enter fullscreen mode Exit fullscreen mode

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 code block 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 a program's outcome 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.
static void Main(string[] args)
{
    Transaction2 transaction = new Transaction2();
    for (int i = 0; i < 10; i++)
    {
        Task.Run(() =>
        {
            transaction.Transfer(3000);
        });
    }
    Console.ReadLine();
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.

for (int i = 0; i < 10; i++)
{
    Task.Run(() =>
    {
        transaction.Transfer(3000);
    });
}
Enter fullscreen mode Exit fullscreen mode

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

race condition 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}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Transaction2 result

resolving race condition

Top comments (0)