Using The Lock In C# & .NET 9
[C#, .NET 9, Threading]
With the advent of computers with multiple processors and multiple cores, parallelism can be increasingly used to improve software performance.
My MacBook, for example, has 16 cores.
The challenge, of course, is that you must be very careful when writing multithreaded code that accesses common data because you can easily introduce bugs that are very difficult to detect and correct as they may sporadically appear and disappear.
Let us take an example of an Account
class that allows Withdrawal
and Deposit
and maintains a Balance
.
The code looks like this:
public sealed class Account
{
public decimal Balance { get; private set; }
public void Deposit(decimal amount)
{
Balance += amount;
}
public void Withdraw(decimal amount)
{
Balance -= amount;
}
}
We then have a simple program that Deposits 1,000 25 times and Withdraws 1,000 25 times in different threads so as to demonstrate the problems that can arise with multi-threaded code.
using Serilog;
// Create logger configuration
var config = new LoggerConfiguration()
// Enrich with thread id
.Enrich.WithThreadId()
// Write to console with specified template
.WriteTo.Console(
outputTemplate:
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff} Thread:<{ThreadId}> [{Level:u3}] {Message:lj} {NewLine}{Exception}");
// Create the logger
Log.Logger = config.CreateLogger();
// Create an account
var account = new Account();
// Create a list of tasks to deposit and withdraw money
// at the end the balance should be 0
List<Task> tasks = [];
for (var i = 0; i < 25; i++)
{
tasks.Add(Task.Run(() =>
{
account.Deposit(1000);
Log.Information("The balance after deposit is {Balance:#,0.00}", account.Balance);
}));
tasks.Add(Task.Run(() =>
{
account.Withdraw(1000);
Log.Information("The balance after withdrawal is {Balance:#,0.00}", account.Balance);
}));
}
// Execute all the tasks
await Task.WhenAll(tasks);
// Print final balance
Console.WriteLine($"The final balance is {account.Balance:#,0.00}");
To make the logging clearer, I am using the Serilog.Sinks.Console package as well as the Serilog.Erichers.Thread with a template configured to display the ThreadID
of the currently running thread.
Given we are depositing 25 times and withdrawing 25 times, the expectation is that the final balance should be 0.
When I ran this code, I got the following:
-
Is the
ThreadID
that is changing as the runtime creates and allocates threads -
Is the
Balance
after the operation. Note that in many cases, the balance changes as the log is being written; for example, the first entry.2024-12-27 00:52:00.250 Thread:<11> [INF] The balance after deposit is -1,000.00
Note that the closing Balance
is 1,000, which should be impossible.
If I run the code a second time, I get the following:
Note here the closing Balance
is -5,000.
The problem here is that multiple threads are accessing and mutating our account at the same time, leading to inconsistent and invalid state. Such code is said NOT to be thread-safe.
The solution to this problem is to introduce some sort of mechanism to inform the runtime that the Account
should only be accessed by one thread at a time.
A simple way to address this is to introduce a lock. This is an object that the runtime can be instructed to use to restrict access to a resource per thread.
The Account
can thus be updated as follows:
public sealed class Account
{
private readonly object _lock = new();
public decimal Balance { get; private set; }
public void Deposit(decimal amount)
{
lock (_lock)
{
Balance += amount;
}
}
public void Withdraw(decimal amount)
{
lock (_lock)
{
Balance -= amount;
}
}
}
If I run this a couple of times, I get a consistent closing Balance of 0, as expected.
The magic is taking place here:
The code within the statement block is called a critical section.
Before the existing thread can update the Balance
, it must first secure a Lock
. If it cannot (because another thread has succeeded), the executing thread will wait until it can secure the lock.
This mechanism has been improved in .NET 9 with the creation of an actual type called a Lock.
We can update our Account
class as follows to modify the definition of the lock from an object to a Lock
private readonly Lock _lock = new();
The rest of the code remains the same.
The new Lock
also allows you to use a new syntax for indicating code that is protected. The code below shows the Deposit
method using the lock
syntax and the Withdraw
method using the new EnterScope method.
public sealed class Account
{
private readonly Lock _lock = new();
public decimal Balance { get; private set; }
public void Deposit(decimal amount)
{
lock (_lock)
{
Balance += amount;
}
}
public void Withdraw(decimal amount)
{
using (_lock.EnterScope())
{
Balance -= amount;
}
}
}
My preference would be to use the EnterScope()
method because if someone were to change the Lock back to an object mistakenly, the code would not be able to compile.
There are a couple be benefits to this:
- Clearer syntax and explicity types to communicate meaning to anyone reading the code
- Under the hood, the compiler generates different code from the old approach that is more performant
- The lock uses Dispose semantics that can prevent bugs from developers forgetting to release the Lock.
TLDR
The new Lock
class makes it easier to write thread-safe code that communicates intent and has better performance.
The code is in my GitHub.
Happy hacking!