C# Lock

Summary: in this tutorial, you’ll learn how to use the C# lock statement to prevent race conditions and ensure thread safety when multiple threads access shared resources.

Race conditions

Let’s start with a simple program:

int counter = 0;
void Increase()
{
    for (int i = 0; i < 1000000; i++)
    {
        counter++;
    }
    Console.WriteLine("The counter is " + counter);
}

Task.Run(() => Increase());
Task.Run(() => Increase());

Console.ReadLine();Code language: C# (cs)

How it works.

First, define a variable counter and initialize it to zero.

Second, define the Increase() method that adds one to the counter variable 1 million times and displays the final value of the counter to the console.

Third, create two tasks that run in separate threads. Each task executes the Increase() method to increase the counter variable.

Because both tasks modify the counter variable concurrently, it has a risk of race conditions and synchronization problems.

When the program increases the variable counter, it carries three steps:

  • Read the value of the counter variable.
  • Increase it by one.
  • Write the new value back to the counter variable.

In other words, the increase of the counter variable is not an atomic operation.

Therefore, one task may read the value of the counter before the other task has completed updating it, resulting in either an incorrect value or a lost update.

Here’s the output of the program:

The counter is 1113815
The counter is 1515277Code language: C# (cs)

Note that you may see different counter values when you run the program several times because of the race conditions between two tasks (or threads).

That’s why the lock statement comes to the rescue.

Introduction to the C# lock statement

The lock statement prevents race conditions and ensures thread safety when multiple threads access the same shared variable.

To use the lock statement you create a new object that serves as a lock, which is also known as the mutex. The mutex stands for mutual exclusion.

lock(lockObject)
{
   // access the shared resources
}Code language: C# (cs)

When a thread enters a lock block, it will try to acquire the lock on the specified lockObject.

If the lock is already acquired by another thread, the current thread is blocked until the lock is released.

Once the lock is released, the current thread can acquire it and execute the code in the lock block which often reads or writes the shared resources.

The following program demonstrates how to use a lock statement to prevent a race condition between two threads:

int counter = 0;

object lockCounter = new();
void Increase()
{
    for (int i = 0; i < 1000000; i++)
    {
        lock (lockCounter) 
        { 
            counter++; 
        }

    }
    Console.WriteLine("The counter is " + counter);
}

Task.Run(() => Increase());
Task.Run(() => Increase());

Console.ReadLine();Code language: C# (cs)

This program is similar to the previous program but uses a lock statement to synchronize access to the counter variable.

The lock statement ensures that both tasks access the counter variable in a mutually exclusive way.

This means that the final value of the counter is predictable and always equal to the sum of increments carried by both tasks, which is 2,000,000.

Here’s the output of the program:

The counter is 1992352
The counter is 2000000Code language: C# (cs)

Note that the final value of the counter is always 2,000,000. However, the immediate value (1,992,352) may vary because the program is running concurrently, with two threads racing to increment the counter variable.

C# lock best practices

The following are some best practices when using the C# lock statement:

  • Keep the lock block as small as possible to minimize the time that other threads have to wait for the lock. If a lock block takes a long time to execute, it may cause contention among threads and reduces the application’s performance.
  • Avoid nested locks because they may cause a deadlock. Deadlocks occur when multiple threads try to acquire locks in a different order.
  • Use try...finally block to release the lock properly when an exception occurs in the lock block. Also, it ensures other threads are not blocked indefinitely.
  • Consider using alternative synchronization mechanisms like SemaphoreSlim and ReaderWriterLockSlim because they can provide better performance and more fine-grained control over concurrency.

Summary

  • Use the C# lock statement to prevent race conditions and synchronization issues when multiple threads access the same shared resources.
Was this tutorial helpful ?