C# & .NET (C# 14, .NET 10 LTS) Module 16 – Threads, parallelism and synchronisation
Locks, Interlocked and the .NET memory model
Fix C# data races with lock and the .NET 9 Lock type, count with Interlocked, and see what volatile and the .NET memory model promise.
What you will learn
- Explain why value++ loses updates when threads share a field, and reproduce it
- Fix a data race with the lock statement on a System.Threading.Lock, or with Interlocked
- Reproduce a deadlock between two locks, detect it with TryEnter and remove its cause
- Explain what volatile, Volatile.Read and Lazy<T> guarantee under the .NET memory model
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why value++ loses updates when threads share a field, and reproduce it
- Fix a data race with the lock statement on a System.Threading.Lock, or with Interlocked
- Reproduce a deadlock between two locks, detect it with TryEnter and remove its cause
- Explain what volatile, Volatile.Read and Lazy<T> guarantee under the .NET memory model
Before you start
On this page
Four threads each added 1 to the same counter a million times, and the counter ended at 1,328,193 instead of
4,000,000: 2,671,807 additions vanished without an error. value++ reads the field, adds 1 and writes it back, and
another thread can run between those steps.
You will reproduce that race, fix it with locks and with Interlocked, and see what the .NET memory model lets the compiler and the processor do with reads and writes that nothing protects.
value++ is three steps, and another thread can come between them
race.cs starts four threads at the same moment with a barrier, and each adds 1 to one counter a million times: with
no lock, inside a lock, and with Interlocked.Increment.
using System.Diagnostics;
// Four threads each add 1 to the same counter a million times, in three ways.
const int Threads = 4;
const int PerThread = 1_000_000;
Console.WriteLine($"{Threads} threads x {PerThread:N0} additions: {Threads * PerThread:N0} expected");
Run("value++, no lock", c => c.AddUnsafe());
Run("lock on a Lock", c => c.AddLocked());
Run("Interlocked.Increment", c => c.AddAtomic());
void Run(string name, Action<Counter> add)
{
var counter = new Counter();
using var start = new Barrier(Threads + 1);
var workers = new Thread[Threads];
for (int t = 0; t < Threads; t++)
{
workers[t] = new Thread(() =>
{
start.SignalAndWait(); // every thread starts at the same moment
for (int i = 0; i < PerThread; i++)
{
add(counter);
}
});
workers[t].Start();
}
start.SignalAndWait();
var watch = Stopwatch.StartNew();
foreach (Thread worker in workers)
{
worker.Join();
}
int lost = Threads * PerThread - counter.Value;
Console.WriteLine($"{name,-22} counted {counter.Value,9:N0}, lost {lost,9:N0}, in {watch.ElapsedMilliseconds} ms");
}
sealed class Counter
{
private readonly Lock gate = new();
private int value;
public int Value => Volatile.Read(ref value);
public void AddUnsafe() => value++; // read, add, write: three steps another thread can come between
public void AddLocked()
{
lock (gate)
{
value++;
}
}
public void AddAtomic() => Interlocked.Increment(ref value);
} Output
4 threads x 1,000,000 additions: 4,000,000 expected value++, no lock counted 1,328,193, lost 2,671,807, in 7 ms lock on a Lock counted 4,000,000, lost 0, in 74 ms Interlocked.Increment counted 4,000,000, lost 0, in 90 ms
This output changes from run to run: how many additions the race loses, and the times, change from run to run
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run race.cs
The version with no lock finished in 7 ms and lost 2,671,807 additions. That is a race condition: A bug whose result depends on which of two or more threads reaches a piece of code first, so runs of the same program give different results. Two threads running value++ on one field is the classic case: an update can be lost.: the result depends on which thread reaches the code first. Step through one lost update:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The counter holds 41. Both threads are about to run value++ on the same field, which holds 41. Each ++ is three steps: read the value, add 1, write it back.
- A reads 41. Thread A reads 41 into a register of its own processor core.
- B reads 41 too. Before A has written anything back, thread B, on another core, reads the same 41.
- A writes 42. Thread A writes 42. One addition is done.
- B writes 42. Thread B writes its own result, also 42, over A's. Two additions ran, and the counter went up by one: A's update is lost, and nothing reports it.
- With a lock or Interlocked. A lock lets only one thread run the three steps at a time, and Interlocked.Increment does all three as one atomic operation. Either way the counter ends at 43, as the two fixed lines of race.cs did with 4,000,000.
Both threads read 41 before either wrote, so both wrote 42. Nothing failed and nothing reported the loss, and its size changes from run to run, so a test on one thread never sees it. The two fixed versions counted all 4,000,000, and took tens of milliseconds where the race took 7: being correct costs time.
Checkpoint
Check: Why value++ loses updates
A lock runs the steps one thread at a time, and Interlocked makes one step atomic
The lock statement lets one thread at a time run its block. On .NET 9 and later, Microsoft’s guidance is to lock a
dedicated System.Threading.Lock: the compiler turns lock (gate) on a Lock into using (gate.EnterScope()), and
on any other object into Monitor.Enter and Monitor.Exit in a try and finally, so the lock is released even
when the block throws. Never lock this, a Type or a string, which other code can lock too. Locks are held by
threads, so an await inside one does not compile; async code uses SemaphoreSlim instead, the tool of the lesson on
throttling.
Interlocked turns one operation on one variable into a single atomic operation: An operation that other threads see either not at all or completely, never half done. The methods of Interlocked, such as Increment and CompareExchange, are atomic operations on one variable; value++ is not.:
Increment, Add, Exchange, and CompareExchange, which writes a new value only if the variable still holds the
value you expect. Locks protect any number of steps and fields together; Interlocked protects exactly one.
The third piece is the .NET memory model: The rules for which reads and writes of shared memory one thread may see from another, and in which order. Ordinary reads and writes may be reordered or kept in registers; locks, Interlocked and Volatile.Read and Volatile.Write limit that.. Without a lock, the compiler and the processor
may keep a value in a register, merge reads or reorder them, as long as one thread cannot tell. volatile and the
Volatile.Read and Volatile.Write methods limit that reordering, but never make ++ atomic, while a lock and
every Interlocked method bring the ordering they need. The .NET memory model is why “it worked on my machine” proves
nothing about code that shares data without them. Side by side, with what race.cs counted:
| Tool | Protects | value++ from four threads |
|---|---|---|
lock on a Lock |
any steps and fields, one thread at a time | 4,000,000 in race.cs |
Interlocked.Increment |
one operation on one variable | 4,000,000 in race.cs |
volatile, Volatile.Read |
the order of reads and writes | still loses updates |
| nothing | nothing | 1,328,193 in race.cs |
The parts below go deeper. A deadlock: A state in which pieces of work each wait for another to go first, so none of them can go on. Two threads that each hold a lock the other needs are one case; a thread blocked on a task whose continuation needs that same thread is another. More in “SynchronizationContext, ConfigureAwait and deadlocks” is two threads that each hold a lock the other
waits for. Lock.TryEnter with a time limit gives up instead of waiting for ever. Lazy<T> builds a shared object
once, on first use. Double-checked locking tests a field before and after taking a lock.
Key idea
A lock runs several steps on one thread at a time and Interlocked makes one step atomic; volatile only orders reads and writes, so it never fixes a lost update.
Checkpoint
Check: lock, Lock and Interlocked
Deadlocks from lock order, reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
Two threads that take the same two locks in opposite orders can wait for each other for ever. This part reproduces that deadlock on purpose, turns the hang into an answer with a time limit, removes its cause, and shows why an await never goes inside a lock.
In this part:
- Two transfers, two locks, and a wait that never ends
- One order for every pair of locks
1 diagram · 3 runnable examples (C#, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
The .NET memory model: reordering, volatile and safe publication Pro: opens with a Pro, Premium or Ultimate pass
Without a lock, the compiler and the processor may cache, merge and reorder ordinary reads and writes, as long as one thread cannot tell. This part reproduces a loop that never sees its stop flag, sets out the rules of the .NET memory model, and measures four ways to build a shared object once.
In this part:
- A stop flag the loop never sees
- What the runtime promises: acquire, release and full fences
- Building a shared object once: Lazy and double-checked locking, measured
2 runnable examples (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
A lab: transfers between accounts without races or deadlocks Pro: opens with a Pro, Premium or Ultimate pass
A transfer changes two accounts, so it needs two locks, and two locks bring back the deadlock of the first part. This lab gives you a transfer method that loses money under load, and four checks to make pass.
In this part:
- The four checks, and the one that fails
- Two locks in one order, and the checks that catch each wrong fix
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
The self-check covers the first two sections: why value++ races, and which tool fixes which problem. It starts where
your answers so far were least sure:
Practice that adapts to you
Practise this lesson
7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why is count++ not thread-safe in C#?
count++ on a field is three steps: read the value, add 1, write the result back. Two threads can both read the same
value before either writes, and then both write the same result, so one addition is lost without any error. How many
are lost changes with timing, so the bug shows under load and rarely in tests. Interlocked.Increment(ref count)
makes the three steps one atomic operation; a lock around the update lets one thread at a time run it, which you
need when several fields change together. Marking the field volatile does not help: it affects the order of reads
and writes, not whether ++ is atomic.
-
Mid to senior
A payment service hangs a few times a week: two threads are stuck, and every later request waits behind them. Transfer locks the payer's account, then the payee's. Explain the hang and fix it so that it cannot happen again.
-
Mid to senior
A teammate fixed a lost-update bug by marking a counter field volatile. The nightly test with eight threads still counts too few. Explain why volatile did not help, and what each correct fix costs.
-
Mid to senior
At start-up a service logs that it loaded its pricing settings 8 times, and some requests used other settings than others. The property is written as if (_settings == null) _settings = Load(). Explain it and replace it.
Key takeaways
value++is a read, an add and a write, so two threads can read the same value and lose an update: race.cs lost 2,671,807 of 4,000,000 additions.- A
lockon a dedicatedSystem.Threading.Lockruns its block on one thread at a time, andInterlockedmakes one operation on one variable atomic. - Under the .NET memory model ordinary reads and writes may be reordered or kept in registers, and
volatilelimits that without making++atomic.
Terms in this lesson
- .NET memory model
- The rules for which reads and writes of shared memory one thread may see from another, and in which order. Ordinary reads and writes may be reordered or kept in registers; locks, Interlocked and Volatile.Read and Volatile.Write limit that.
- atomic operation
- An operation that other threads see either not at all or completely, never half done. The methods of Interlocked, such as Increment and CompareExchange, are atomic operations on one variable; value++ is not.
- race condition
- A bug whose result depends on which of two or more threads reaches a piece of code first, so runs of the same program give different results. Two threads running value++ on one field is the classic case: an update can be lost.
References
- The lock statement (Microsoft Learn)
- Lock class (Microsoft Learn)
- Interlocked class (Microsoft Learn)
- Managed threading best practices (Microsoft Learn)
- Overview of synchronization primitives (Microsoft Learn)
- volatile keyword (Microsoft Learn)
- Memory model (dotnet/runtime design specification) (.NET Foundation)
- LazyThreadSafetyMode enum (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress