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
The classic trap: wrapping 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
Rule of thumb: optimistic by default, pessimistic under proven contention, in-process primitives only for in-process state, distributed locks only when the resource is work rather than data. Each part of the series covers one row of this table in detail.

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.

Part 1

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.

lock · SemaphoreSlim · keyed locks · scale-out failure
Coming soon
Part 2

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.

rowversion · concurrency tokens · retry · EF Core
Coming soon
Part 3

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.

UPDLOCK · deadlocks · isolation levels · SQL Server
Coming soon
Part 4

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.

sp_getapplock · Redis · leases · fencing tokens
Coming soon
Part 5

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.

Interlocked · CAS · Channel<T> · single-writer

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.


More posts
← All posts  ·  RSS © 2026 DotNET Leet