We’ve talked about Interlocked.Increment
before,
but let’s be honest, “thread-safe integer increments” sound like quite a specific
concern, don’t they? Is this just something for the .NET Core team at Microsoft to
worry about, or could it actually affect your business application? Let’s find out!
An Ordinary Day at the Burger Bar
Imagine we have kiosks for ordering burgers, all connected to the same server
in the kitchen. We want each order to have a unique integer number. To implement this,
we will reuse BurgerOrderService
from the previous post, making just one slight modification:
removing the static
modifier from the orderNumber
, since we no longer need it to be global
for our experiment.
class BurgerOrderService
{
private int orderNumber = 0;
public int GetOrderNumber()
{
return ++orderNumber;
}
public int GetOrderNumberButThreadSafe()
{
return Interlocked.Increment(ref orderNumber);
}
}
The experiment idea is simple: we generate 1,000 order numbers asynchronously,
and all of them should be unique. We can’t use HashSet<int>
to verify uniqueness
because it’s not thread-safe, which could affect our results. A thread-safe
option would be ConcurrentDictionary<int, byte>
, where the order number
is the key, and the value can be anything. But we’ll use ConcurrentBag<int>
instead. While it doesn’t check for duplicates, we’ll turn this into an advantage
by verifying that exactly 1,000 executions occurred.
Console.WriteLine("Total order numbers: {0}", bag.Count);
Console.WriteLine("Unique order numbers: {0}", bag.Distinct().Count());
The experiment
First, we want to verify our setup and make 1,000 order numbers synchronously:
var orderService = new BurgerOrderService();
var bag = new ConcurrentBag<int>();
for (var i = 0; i < 1000; i++)
{
bag.Add(orderService.GetOrderNumber());
}
The result is great, 1,000 executions and 1,000 unique order numbers:
Now, it’s time to answer our main question: can we see a race condition
with the integer increment operator using a simple code snippet? Let’s find out.
We want to execute BurgerOrderService
methods asynchronously, so we need
an auxiliary function, GetOrderNumberAsync
. And using of Task.Delay
is a little
trick to force the method to be asynchronous and prevent the compiler from optimizing
or inlining it:
async Task GetOrderNumberAsync(BurgerOrderService service, ConcurrentBag<int> bag)
{
await Task.Delay(1); // This forces the method to be async
bag.Add(service.GetOrderNumber());
}
var tasks = new List<Task>();
for (var i = 0; i < 1000; i++)
{
tasks.Add(GetOrderNumberAsync(orderService, bag));
}
await Task.WhenAll(tasks);
And the output is…
Wow. Quite far away from 1,000. The number of unique orders will vary with each run, because race conditions can be different each time. It may even reach 1,000 on some executions, but the exact number can never be guaranteed.
As we can see, even with a simple code snippet and without diving deep into the system assemblies, we can encounter a race condition on the integer increment operator.
Can Interlocked fix it?
Now, the Interlocked.Increment
enters the stage:
async Task GetOrderNumberThreadSafeAsync(BurgerOrderService service, ConcurrentBag<int> bag)
{
await Task.Delay(1); // This forces the method to be async
bag.Add(service.GetOrderNumberButThreadSafe());
}
for (var i = 0; i < 1000; i++)
{
tasks.Add(GetOrderNumberThreadSafeAsync(orderService, bag));
}
And we get a solid, thread-safe result:
Exactly as expected.
The complete source code for this experiment is available here.
***
Our experiment shows that even a simple integer increment can lead to race conditions in a multi-threaded environment. So while we are using the power of asynchronous executions in .NET, we have to be careful even to these small details.
Last modified on 2025-02-09