DEV Community

Rasul
Rasul

Posted on • Edited on

Deep Dive Into Race Condition Problem in .NET

In a multithreading environment, there are many benefits and challenges to consider. In our case, we will focus on one of the most popular challenges the Race Condition Problem.

The nature of threads, memories, operating systems and CPUs bring this problem into the table. But the problem directly touches the threads, it is because the operating system (windows) organizes threads with a time-slicing technique/algorithm.

So, we will start explaining the problem and then provide the solution on how to prevent it.

The Problem

There are many types of race condition problems. However, I will show the problem of adding a key/value pair to the Dictionary in a multithreading scenario. Also, note that this scenario will occur within the same process (A process, is a running program. In the context of a process, one or more threads are running).

So, let's think that we have a method that checks the argument (key) with the Dictionary ContainsKey method. And if the result is false, then it adds that key and value (arguments) to the dictionary instance. Otherwise the code block will do nothing.

public class Program
{
    static Dictionary<int, string> dictionaryInstance = new Dictionary<int, string>()
    {
        { 1, "Rasul" },
        { 2, "Huseynov" },
        { 3, "Rasul" },
        { 4, "Huseynov" },
        { 5, "Rasul" },
        { 6, "Huseynov" },
        { 7, "Rasul" },
        { 8, "Huseynov" },
        { 9, "Rasul" },
        { 10, "Huseynov" }
    };

    public static void Main()
    {
        for (int i = 0; i < 5; i++)
        {
            string valueStr = $"Task{i + 1}";
            ThreadPool.QueueUserWorkItem((s) => AddPair(11, valueStr));
        }

        Console.WriteLine("Main method is finished!");
        Console.Read();
    }

    static void AddPair(int key, string value)
    {  
        if (!dictionaryInstance.ContainsKey(key))
        {
            Thread.Sleep(100); // just for simulation execution time!
            dictionaryInstance.Add(key, value);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The code above throws 'An item with the same key has already been added' exception.

Let's see how the above code looks at runtime with 2 threads.

Race Condition Problem Runtime Illustration

The illustration image (runtime) explains that we have multithread environment that consists of 2 threads. And, it means two different threads are reading same code block at the same time (at the same time is an illusion).

Let's elaborate on the image above. Thread1 executes the first line and gets a false result. Then, the context switches to Thread2; it executes the first line and gets false. So, Thread1 and Thread2 enter the if block. Here is the problem, when the context switches to Thread1, it will add a key and value to the dictionary. After that, the context switches to Thread2, which will try to add a key and value to the dictionary, too. But in our case, Thread2 will get an error because the key in the dictionary should be unique.

As you can see, the result in such cases is that the code snippet does not work as it looks. So, it means this can also lead to unexpected bugs.

This is what we call the Race Condition problem because in multi-threaded environments threads are constantly chasing each other. Note that the problem occurs because multiple threads want to access a shared resource.

Thread Scheduling

Modern CPUs execute a limited number of threads in parallel. But especially operating systems like Windows use some kind of algorithm to determine which threads should be scheduled when and for how long. However, let's think that we can execute 4 threads at the same time. But the problem is our CPU's capability is limited and what if we want to execute more than 4(for example) threads?

Well, the illusion starts from here, the operating system (windows) organizes the threads scheduling with a time-slicing algorithm.

"All the threads are scheduled randomly by the operating system and each thread receives a tiny amount of time to execute. As soon as the allocated time elapses, another thread receives its portion of processor time and the context switch realizes, so it starts its own execution. This process of allocating time to threads by the operating system continues until all the threads finish their execution. This is called time-slicing algorithm. Under the hood, the threads execute sequentially, however they execute so fast that we have a sense of parallel execution, that is of course fake or simulated parallel execution. (Java Jedi)"

Because of this algorithm, our code, i.e. threads, is executed in random order, is not atomic and is similar to a car race, which creates a race condition problem.

Solutions

  1. Avoid Shared States: by keeping each thread's data separate, so they don't interfere with each other. By doing this, we ensure that each thread can work independently without causing conflicts with other threads, helping to maintain order and reliability in our programs.
  2. Use Critical Section: this involves setting up a rule that only allows one thread to access shared data at a time. When a thread wants to work with the shared data, it "locks" it, meaning it gains exclusive access. Other threads must wait until the first thread finishes and unlocks the data before they can access it. This ensures that only one thread modifies the shared data at any given time, preventing conflicts and maintaining program stability.
  3. Use Atomic Operations: it helps to solve race condition problem by ensuring that certain operations on shared data are performed as a single, indivisible unit, preventing interference from other threads. This means that atomic operations guarantee that no other thread can interrupt the operation in the middle, thus avoiding inconsistencies in the data.
  4. Use Immutability Technique: this technique involves making sure that once data is created, it cannot be changed. Instead of modifying existing data, we create new copies with the desired changes. By doing this, different threads can work on their own copies of the data without worrying about interference. Since the original data remains unchanged, there's no risk of conflicting modifications, ensuring smooth and predictable behavior in concurrent programs.

I will explain the solutions in detail in the next article. Stay tuned!

Top comments (0)