Read, write and upgradeable read locks, recursion policy, thread affinity, performance reality and lock-free alternatives for read-mostly data.
ReaderWriterLockSlim lets many readers hold the lock at once but only one writer, which helps when reads are frequent, take a while, and writes are rare. It also has an upgradeable read mode (only one at a time) for read-then-maybe-write. Its pitfalls: it costs more than lock per operation, so for short sections it is often slower; it is thread-affine, so no awaits; recursion is off by default; it must be disposed; and writer starvation or upgrade deadlocks appear if misused. For read-mostly data, an immutable snapshot swapped with Volatile.Write or Interlocked.Exchange is usually simpler and faster.
"
ReaderWriterLockSlimallows many concurrent readers or one writer. It also has an upgradeable read mode, held by one thread at a time, for 'read, and maybe write' logic. It helps when read sections are long, there are many concurrent readers, and writes are rare: for example scanning a large in-memory index. Its pitfalls are real, though. Per operation it costs more thanlock, so for short sections it's often slower. It's thread-affine, so noawaitwhile it's held. Recursion is off by default. You can't upgrade a plain read lock. It must be disposed. And once a writer is waiting, new readers queue behind it, which can cause latency spikes. For read-mostly data like configuration, rules or reference data, I prefer an immutable snapshot: readers just read a reference and never block, and writers build a new object, often aFrozenDictionary, and publish it atomically withVolatile.WriteorInterlocked.Exchange."
| Mode | Concurrency | Can coexist with | Notes |
|---|---|---|---|
Read (EnterReadLock) |
Many | Other readers, one upgradeable reader | Can't upgrade to write |
Upgradeable read (EnterUpgradeableReadLock) |
One at a time | Readers | Can call EnterWriteLock without releasing |
Write (EnterWriteLock) |
One | Nothing | Waits for readers to drain |
TryEnter...(timeout) |
β | β | Bounded waits |
| Pitfall | Consequence | Fix |
|---|---|---|
| Short read sections | Overhead > benefit; often slower than lock |
Measure; use lock or a snapshot |
| Upgrading a plain read lock | LockRecursionException (or deadlock with recursion + two upgraders) |
Upgradeable read, or release β write β recheck |
| Recursion | Default NoRecursion: re-entering throws |
Restructure; SupportsRecursion only if unavoidable |
await while held |
Exit on another thread β SynchronizationLockException |
Snapshot pattern, or an async-aware library lock |
Missing finally |
Lock held forever | Always try/finally |
| Not disposing | Leaks internal wait handles | Dispose with the owner |
| Writer waiting blocks new readers | Reader latency spikes during long reads | Keep reads short; or snapshots |
| Approach | Reader cost | Writer cost | Readers ever block? | Best for |
|---|---|---|---|---|
lock |
Low, exclusive | Low | Yes | Small, simple state |
ReaderWriterLockSlim |
Medium | Medium | During writes | Long reads over large mutable structures |
Immutable snapshot (Volatile.Write swap) |
A reference read | Rebuild + swap | Never | Config, rules, reference data |
FrozenDictionary (.NET 8+) snapshot |
Very fast lookups | Expensive build | Never | Built once, read millions of times |
ImmutableDictionary + ImmutableInterlocked |
Moderate | Structural sharing | Never | Frequent small updates with snapshot semantics |
ConcurrentDictionary |
Low (lock-free reads) | Low per key | No | Independent per-key updates |
Our [pricing service] looked up tax and discount rules [about 50,000 times per second] under a ReaderWriterLockSlim. A CPU profile showed [about 30%] of lookup time inside lock enter/exit, even though each read section was a tiny dictionary lookup and the rules changed only [a few times a day]. I replaced the lock with an immutable snapshot: on reload we build a FrozenDictionary<string, Rule> and publish it with Volatile.Write; readers do Volatile.Read(ref _rules).TryGetValue(...) and never block. Lookups got [about 60%] faster, the lock disappeared, and reload no longer stalled readers. I documented the snapshot pattern for all reference data and added a BenchmarkDotNet comparison to CI.
Checks nuanced performance judgement.
Green flags
FrozenDictionary.Red flags
state: reader count, writer flag, upgrader flag, waiting writers/readers/upgraders
EnterReadLock: if no writer and no waiting writer -> readers++ ; else wait on read event
EnterWriteLock: mark writer waiting; wait until readers == 0 and no writer -> writer = me
ExitWriteLock: wake waiting writer first (writer preference), else release readers
EnterUpgradeable: only one upgrader; coexists with readers; can later EnterWriteLock (waits for other readers)
It tracks per-thread lock counts (for recursion policy and ownership checks), which is part of why it costs more than lock.
_rw.EnterUpgradeableReadLock();
try
{
if (!_index.TryGetValue(key, out var value))
{
_rw.EnterWriteLock();
try { _index[key] = value = Build(key); }
finally { _rw.ExitWriteLock(); }
}
return value;
}
finally { _rw.ExitUpgradeableReadLock(); }
Because only one thread can be in upgradeable mode, this path is serialised, even for cache hits. Often it's better to take a plain read lock for the hit path and fall back to a write lock (with a recheck) on a miss.
T1 holds read, wants write -> waits for readers == 0
T2 holds read, wants write -> waits for readers == 0
both wait for each other forever
That's why only one upgradeable reader is allowed, and why NoRecursion rejects readβwrite upgrades outright.
public sealed class RuleTable
{
private FrozenDictionary<string, Rule> _rules = FrozenDictionary<string, Rule>.Empty;
public Rule? Find(string key) =>
Volatile.Read(ref _rules).GetValueOrDefault(key); // grab the reference once, use it
public void Reload(IEnumerable<Rule> rules)
{
var fresh = rules.ToFrozenDictionary(r => r.Key); // build off to the side
Volatile.Write(ref _rules, fresh); // publish atomically
}
public void Upsert(Rule rule) // several concurrent writers? use CAS
{
while (true)
{
var current = Volatile.Read(ref _rules);
var copy = new Dictionary<string, Rule>(current) { [rule.Key] = rule };
var next = copy.ToFrozenDictionary();
if (Interlocked.CompareExchange(ref _rules, next, current) == current) return;
}
}
}
Rules: readers capture the reference once per operation; the published object is never mutated; multiple concurrent writers use Interlocked.CompareExchange (or a writer-only lock). Rebuilding a frozen dictionary is expensive, so this suits data that changes rarely; for frequent small updates, ImmutableDictionary with ImmutableInterlocked.AddOrUpdate shares structure instead.
The BCL has none. Options: the snapshot pattern (usually best), AsyncReaderWriterLock from Microsoft.VisualStudio.Threading, or AsyncReaderWriterLock in Nito.AsyncEx. They're more complex; prefer designs that don't need them.
| Version | Change |
|---|---|
| .NET Framework 2.0 | ReaderWriterLock (legacy, avoid) |
| .NET Framework 3.5 | ReaderWriterLockSlim |
| .NET Framework 4.5 | System.Collections.Immutable (NuGet), ImmutableInterlocked |
| .NET 8 | FrozenDictionary, FrozenSet |
| .NET 10 | No changes to ReaderWriterLockSlim |
lock, ReaderWriterLockSlim, a FrozenDictionary snapshot and ConcurrentDictionary.NoRecursion and observe the exception.Reload and confirm readers are never blocked during reloads.using System.Collections.Frozen;
using System.Threading;
var rules = new RuleTable(new Dictionary<string, decimal> { ["AU"] = 0.10m });
Console.WriteLine(rules.TaxFor("AU"));
rules.Reload(new Dictionary<string, decimal> { ["AU"] = 0.10m, ["NZ"] = 0.15m });
Console.WriteLine(rules.TaxFor("NZ"));
var legacy = new LegacyRuleTable();
legacy.Set("AU", 0.10m);
Console.WriteLine(legacy.TryGet("AU", out var t) ? t : -1);
// Preferred: immutable snapshot, readers never block.
public sealed class RuleTable(IDictionary<string, decimal> initial)
{
private FrozenDictionary<string, decimal> _rules = initial.ToFrozenDictionary();
public decimal TaxFor(string country) =>
Volatile.Read(ref _rules).TryGetValue(country, out var rate) ? rate : 0m;
public void Reload(IDictionary<string, decimal> fresh) =>
Volatile.Write(ref _rules, fresh.ToFrozenDictionary());
}
// ReaderWriterLockSlim version, for comparison.
public sealed class LegacyRuleTable : IDisposable
{
private readonly ReaderWriterLockSlim _rw = new(LockRecursionPolicy.NoRecursion);
private readonly Dictionary<string, decimal> _rules = new();
public bool TryGet(string key, out decimal value)
{
_rw.EnterReadLock();
try { return _rules.TryGetValue(key, out value); }
finally { _rw.ExitReadLock(); }
}
public void Set(string key, decimal value)
{
_rw.EnterWriteLock();
try { _rules[key] = value; }
finally { _rw.ExitWriteLock(); }
}
public void Dispose() => _rw.Dispose();
}
What happens: a tiny dictionary lookup is wrapped in EnterReadLock/ExitReadLock.
Why it is wrong: overhead exceeds the benefit; usually slower than lock.
Do instead: measure; prefer a snapshot or lock.
What happens: EnterReadLock() then EnterWriteLock().
Why it is wrong: LockRecursionException (or deadlock with recursion allowed and two upgraders).
Do instead: upgradeable read, or release β write lock β recheck.
What happens: await inside a read or write section.
Why it is wrong: thread-affine: exit on another thread throws; the lock may be held forever.
Do instead: snapshot pattern or an async-aware lock library.
What happens: per-object ReaderWriterLockSlim instances never disposed.
Why it is wrong: leaks internal wait handles.
Do instead: dispose with the owner; prefer long-lived instances.
What happens: readers get the current dictionary while a writer updates it in place. Why it is wrong: destroys the safety of the snapshot pattern; data races. Do instead: build a new object and swap the reference.
What happens: _rules[a] then _rules[b] in the same method.
Why it is wrong: the two reads may see different snapshots.
Do instead: read the reference once into a local.
SupportsRecursion to hide design problemsWhat happens: recursion enabled to stop exceptions. Why it is wrong: adds overhead and enables deadlocks on upgrade. Do instead: restructure so each operation takes the lock once.
ReaderWriterLockWhat happens: old code uses System.Threading.ReaderWriterLock.
Why it is wrong: slower and with known pitfalls.
Do instead: ReaderWriterLockSlim or, better, snapshots.
What happens: a writer rebuilds a large index while holding the write lock. Why it is wrong: all readers stall for the duration. Do instead: build the new version off to the side, then swap under a brief lock (or atomically).
What happens: "many readers, so it'll scale linearly". Why it is wrong: the shared reader count is itself a contended cache line. Do instead: benchmark at production core counts; snapshots scale better.
Reader/writer locks look like the obvious answer for read-mostly data, but they rarely win in practice. I default to immutable snapshots for configuration and reference data, and use ReaderWriterLockSlim only for long reads over large mutable structures.
| Data shape | Choice |
|---|---|
| Config, rules, reference data | Immutable snapshot (FrozenDictionary) |
| Frequent per-key updates | ConcurrentDictionary |
| Large mutable index with long scans | ReaderWriterLockSlim |
| Small and simple | lock |
lock (x) { ... } gives mutual exclusion: only one thread at a time runs the block for the same lock object, and entering and leaving act as memory barriers. For a normal object the compiler emits Monitor.Enter(x, ref lockTaken) in a try/finally with Monitor.Exit. Since .NET 9 and C# 13, if x is a System.Threading.Lock, the compiler instead uses x.EnterScope() and disposes the scope, which is a dedicated, slightly faster lock type. Locks are re-entrant on the same thread, you cannot await inside one (CS1996), and you should lock on a private, readonly object and keep the block short.
Interlocked provides atomic operations on shared variables: Increment, Decrement, Add, Exchange, CompareExchange, Read (for 64-bit values on 32-bit platforms), and And/Or (.NET 5+). Each is a single indivisible hardware operation and a full memory fence. CompareExchange(ref x, newValue, expected) writes only if x still equals expected and returns the original value, which is the building block for lock-free algorithms: read, compute, try to swap, and retry if another thread changed it. Use it for counters, flags and reference swaps; use a lock when several values must change together.
System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.
How to tell it (STAR)
In our [pricing service] the profiler showed lots of time in a reader/writer lock around tiny lookups, while rules changed only a few times a day. I replaced it with an immutable frozen dictionary swapped on reload. Lookups got [about 60%] faster and the lock disappeared.
Last updated 31 Oct 2026.