Concurrency & Locking in .NET
A five-part series on race conditions in .NET and how to fix them at the right layer.
The Problem Selling the last item twice
Most race conditions hide in code that looks correct and passes every unit test. Here is a typical example: a checkout that must not oversell:
public async Task<bool> TryPurchaseAsync(int productId)
{
var product = await _db.Products.SingleAsync(p => p.Id == productId);
if (product.Stock <= 0)
return false; // 1. CHECK
product.Stock--; // 2. ACT
await _db.SaveChangesAsync(); // 3. PERSIST
return true;
}
This is a textbook TOCTOU race (time-of-check to time-of-use). The check on line 6 and the write on line 9 are two separate operations with a gap between them, and nothing stops another request from squeezing into that gap. With one item left in stock and two concurrent requests:
| T | Request A | Request B | Stock in DB |
|---|---|---|---|
| t1 | reads product, Stock == 1 |
1 | |
| t2 | reads product, Stock == 1 |
1 | |
| t3 | check passes (1 > 0) | check passes (1 > 0) | 1 |
| t4 | writes Stock = 0, returns true |
0 | |
| t5 | writes Stock = 0, returns true | 0 - but 2 items sold |
Both customers get a confirmation email, but only one item exists. Request B did not write a wrong number into the database - it caused a lost update: B's write was based on a stale read, so A's decrement was silently overwritten and nothing in the database looks broken afterwards.
The same bug appears with wallet balances, booking slots, invoice sequence numbers and API rate-limit counters. This page gives an overview of the ways to fix it, and each part of the series covers one of them in detail with runnable, load-tested .NET examples.
The Insight A lock is only as wide as your deployment
The reason this topic produces so many bad fixes is that the race exists on three different layers, and a lock taken on one layer is invisible to the layers above it:
| Layer | Who is racing | What can synchronize them |
|---|---|---|
| In-process | Threads inside one process - parallel requests on the ASP.NET Core thread pool | lock, Monitor, SemaphoreSlim, Interlocked, Channel<T> |
| Cross-process | Multiple app instances - scale-out, rolling deploys, IIS overlapped recycle | Database locks & transactions, optimistic concurrency, OS mutexes (same machine only) |
| Cross-system | Different services touching the same resource - API + background worker + cron job | Distributed locks (sp_getapplock, Redis), leases, message-queue ordering |
TryPurchaseAsync in a
SemaphoreSlim fixes the demo on your laptop and does absolutely nothing in
production, because production runs two instances behind a load balancer. The lock is real -
it just guards one process's memory, while the fight is over a row in a shared database.
An in-process lock can never fix a cross-process race. Before choosing a mechanism,
answer one question: what is the widest boundary from which this resource is mutated?
The Framework Choosing the right mechanism
Once you know the boundary, the choice narrows down quickly. This is the decision table the series is built around:
| Situation | Reach for | Why |
|---|---|---|
| Shared state in one process only (cache, counters, singleton init) | lock / Interlocked / SemaphoreSlim |
Nanosecond-scale cost, no external dependency - but worthless across instances |
| Database row, conflicts are rare | Optimistic concurrency (rowversion token + retry) |
No blocking, no deadlocks, scales with instance count; you pay only when a conflict actually happens |
| Database row, conflicts are constant (hot row, flash sale) | Pessimistic locking (UPDLOCK, atomic UPDATE, isolation levels) |
Retry storms make optimistic collapse under contention; serialize at the row and keep the queue in the database |
| A job that must run on exactly one instance (migrations, outbox processor, nightly batch) | Distributed lock / lease (sp_getapplock, Redis) |
The resource is not a row - it is the right to do work; needs a lock all instances can see, with an expiry for crashed holders |
| Extreme throughput, lock cost itself is the bottleneck | Lock-free & single-writer designs (Interlocked, Channel<T>, immutability) |
Stop fighting over the data; make contention structurally impossible instead |
The Series One problem, five mechanisms
Every part applies a different mechanism to the same overselling scenario, under a real concurrent load test, so you can see the failure, the fix, and the limits of the fix in measured numbers.
In-Process Synchronization and Where It Stops Working
A LIMS case study: two technicians dispatch material from the same aliquot.
lock vs SemaphoreSlim under async; why you cannot await
inside lock; the .NET 9+ System.Threading.Lock type; per-key and
striped locking. Ends with a demo of the same code on two instances, where the locks stop helping.
Optimistic Concurrency in EF Core: The Default Choice
rowversion tokens and how EF Core turns them into WHERE predicates;
handling DbUpdateConcurrencyException without corrupting the change tracker;
retry policies that do not stampede; where optimistic breaks down under contention - with numbers.
Pessimistic Locking & Isolation Levels - When You Must Hold the Row
What UPDLOCK actually acquires and the S/U/X story behind deadlock 1205;
isolation levels as implicit locking policies; the atomic conditional UPDATE that beats
both; lock escalation, intent locks and reading sys.dm_tran_locks when things go wrong.
Distributed Locks - When the Resource Is the Work Itself
sp_getapplock as the distributed lock you already own; Redis locks, leases and
fencing tokens; why every distributed lock is a trade against clock drift and process pauses;
designing jobs so a lost lock degrades safely instead of double-executing.
Lock-Free .NET - Making Contention Structurally Impossible
Interlocked and compare-and-swap loops; Channel<T> and the
single-writer principle; immutable snapshots over guarded mutation; when lock-free is
genuinely faster and when it is just harder to read - benchmarked with BenchmarkDotNet.
Cheat Sheet The whole series in one table
| Mechanism | Scope | Blocks? | Typical cost | Breaks down when |
|---|---|---|---|---|
lock / Monitor |
One process | Yes (thread) | ~20 ns uncontended | You scale past one instance; you need await inside |
SemaphoreSlim |
One process | Yes (async-friendly) | ~100 ns uncontended | You forget the finally around Release(); you scale out |
Interlocked |
One process | No (lock-free) | ~5-10 ns | The invariant spans more than one memory location |
Optimistic (rowversion) |
Everything sharing the DB | No - detect & retry | Free until a conflict | Hot-row contention turns retries into a storm |
Pessimistic (UPDLOCK, isolation) |
Everything sharing the DB | Yes (row/range) | A blocked connection per waiter | Long transactions; lock escalation; deadlocks under mixed order |
Distributed (sp_getapplock, Redis) |
Everything you point at it | Yes (lease) | A network round-trip | Clock drift, GC pauses, forgetting fencing tokens |
Part 1 (in-process synchronization) is live and linked above. The remaining parts will be linked here as they are published.
- In-Process Synchronization in .NET and Where It Stops Working
- Start With a Loosely Coupled Monolith
- When a DTO Is Worth Writing: Composition, Boundaries and CQRS with MediatR