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 1515277
Code 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 2000000
Code 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 alock
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 thelock
block. Also, it ensures other threads are not blocked indefinitely. - Consider using alternative synchronization mechanisms like
SemaphoreSlim
andReaderWriterLockSlim
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.