SpinLock and SpinWait, busy-waiting versus blocking, struct copying pitfalls, owner tracking and why most code should not use it.
SpinLock is a struct lock that busy-waits (spins) instead of blocking the thread, which avoids a context switch when the lock is held for only a few instructions. It is not re-entrant, it must never be copied (store it in a non-readonly field and pass it by ref), and it wastes CPU if held longer than a tiny moment or if the owner is descheduled. Monitor and Lock already spin briefly before blocking, so SpinLock only helps in very hot, very short critical sections proven by profiling. Most code should use lock.
"
SpinLockis a struct lock that busy-waits: a waiting thread keeps retrying an atomic compare-and-swap (backing off withSpinWait) instead of going to sleep in the kernel. When a critical section is only a few instructions, that avoids the cost of a context switch. But it comes with sharp edges. It's a mutable struct, so areadonlyfield or a by-value copy silently breaks it: each copy is a separate lock. It's not re-entrant. And if the holder is slow or gets preempted, every waiter burns CPU for nothing. Also,lock(bothMonitorand the .NET 9Locktype) already spins adaptively before blocking, which captures most of the benefit. So my answer to 'when should I use it' is: almost never in application code; only for a proven, tiny, hot critical section on many cores, after profiling. And usually the better fix is to remove the contention entirely withInterlocked, per-thread state or batching."
Spinning (SpinLock, SpinWait) |
Blocking (lock after its spin phase) |
|
|---|---|---|
| While waiting | Thread stays on the CPU, retrying | Thread sleeps; OS schedules something else |
| Cost when hold time is tiny | Lowest: no context switch | Context switch to sleep + wake (microseconds) |
| Cost when hold time is long | Wasted CPU across all waiters | Low CPU |
| Owner preempted by the OS | Waiters spin uselessly for a time slice | Waiters sleep |
| Best when | Hold time < cost of a context switch, few waiters, many cores | Hold time long or unknown |
| Member | Notes |
|---|---|
new SpinLock(enableThreadOwnerTracking) |
Tracking gives better errors (and IsHeldByCurrentThread) at a small cost |
Enter(ref bool lockTaken) / TryEnter(timeout, ref lockTaken) |
Use the lockTaken pattern in try/finally |
Exit() / Exit(useMemoryBarrier) |
Release |
IsHeld, IsHeldByCurrentThread |
Diagnostics (the latter needs tracking) |
| Pitfall | What goes wrong |
|---|---|
readonly SpinLock field |
Every call operates on a copy: no mutual exclusion at all |
| Passing by value / via a property | Same: copies |
| Re-entry | With tracking: LockRecursionException; without: deadlock (spins forever) |
| Long or blocking work inside | Waiters burn CPU: logging, allocation, I/O all qualify as "long" |
| Async code | Can't await while holding it |
| Oversubscription (more threads than cores) | Spinners delay the owner from running → even more spinning |
| Situation | Choose |
|---|---|
| Normal critical sections | lock / System.Threading.Lock |
| Single-value updates | Interlocked |
| Hot counters/aggregates | Per-thread or per-core state merged periodically (ThreadLocal<T>, striped arrays) |
| Read-mostly data | Immutable snapshots swapped atomically |
| Custom lock-free loops | SpinWait for back-off |
| Proven tiny, hot section on many cores | SpinLock (with a benchmark proving it) |
A teammate switched the lock in our [metrics aggregator], running on [32-core] hosts, from lock to SpinLock "for speed". CPU rose [about 15%] under load with no throughput gain. A CPU profile showed many threads in SpinLock.ContinueTryEnter: the protected section included a debug-logging call that occasionally blocked, so waiters spun for long periods. I reverted to lock, then redesigned the aggregator to keep counters per thread and merge them once a second, which removed contention entirely and beat both previous versions on CPU and throughput. We now require a BenchmarkDotNet comparison, including CPU, for any change to low-level synchronisation.
Checks depth on low-level primitives and engineering judgement.
Green flags
lock already spins and measurement is required.Red flags
SpinLock.Enter worksEnter(ref taken):
try CompareExchange(owner, me, free) -> success: taken = true, return
loop:
SpinWait.SpinOnce() -> early: spin with CPU pause instructions
-> later: Thread.Yield / Sleep(0) / Sleep(1)
retry CompareExchange
SpinWait adapts: it spins a few iterations, then starts yielding the time slice, and eventually sleeps, so a long wait degrades gracefully, but you've still paid for the spinning.
lock already spinsMonitor.Enter and Lock.Enter both spin adaptively for a short time before blocking on a kernel wait. The runtime tunes the spin count based on processor count and recent success. For most code this hybrid gives the same uncontended speed as a spin lock and far better behaviour under real contention.
private readonly SpinLock _s = new(false); // BUG
public void Add()
{
bool taken = false;
_s.Enter(ref taken); // compiler makes a defensive copy of the readonly struct -> locks the copy
_count++; // unprotected
_s.Exit(); // exits another copy
}
Correct:
private SpinLock _s = new(enableThreadOwnerTracking: false); // mutable field, accessed directly
Analyzers/IDE warnings can flag defensive copies; code review should too.
// Striped counters: one slot per CPU-ish index, padded to avoid false sharing
private readonly PaddedLong[] _slots = new PaddedLong[Environment.ProcessorCount];
public void Increment()
{
int i = Thread.GetCurrentProcessorId() % _slots.Length;
Interlocked.Increment(ref _slots[i].Value);
}
public long Total() => _slots.Sum(s => Volatile.Read(ref s.Value));
[StructLayout(LayoutKind.Explicit, Size = 128)]
struct PaddedLong { [FieldOffset(64)] public long Value; }
No lock, little sharing between cores, and no false sharing thanks to padding.
[ThreadingDiagnoser] for lock contention counts and work-item counts.| Version | Change |
|---|---|
| .NET Framework 4.0 | SpinLock, SpinWait |
| .NET Core 3.0 | Thread.GetCurrentProcessorId() (useful for striping) |
| .NET 9 | System.Threading.Lock with its own adaptive spinning |
| .NET 10 | Guidance unchanged: prefer lock |
_spin readonly and see the total come out wrong.lock, Lock, SpinLock and Interlocked.Add for 8 threads incrementing a counter.Thread.Sleep(0) inside the critical section and compare CPU usage between lock and SpinLock.SpinLock degrade.LockRecursionException.using System.Threading;
var counter = new HotCounter();
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
{
for (int i = 0; i < 100_000; i++) counter.Add(1);
})));
Console.WriteLine(counter.Total); // 800000
public sealed class HotCounter
{
// NOT readonly: a readonly struct field would be copied on each call.
private SpinLock _spin = new(enableThreadOwnerTracking: false);
private long _total;
public long Total => Interlocked.Read(ref _total);
public void Add(long value)
{
bool taken = false;
try
{
_spin.Enter(ref taken);
_total += value; // a few instructions only
}
finally
{
if (taken) _spin.Exit(useMemoryBarrier: false);
}
}
}
// In real code, Interlocked.Add(ref _total, value) is simpler and faster here.
readonly SpinLock or by-value copiesWhat happens: the field is readonly or the lock is passed to a helper by value.
Why it is wrong: each call locks a copy; no mutual exclusion.
Do instead: mutable field, accessed directly or by ref.
What happens: logging, allocation, I/O or locks inside.
Why it is wrong: waiters burn CPU for the whole hold time.
Do instead: lock, which blocks after a short spin.
What happens: a method holding the spin lock calls another that takes it. Why it is wrong: not re-entrant: exception with tracking, deadlock without. Do instead: take the lock once; restructure helpers.
What happens: SpinLock adopted because it "sounds faster".
Why it is wrong: lock already spins; under real contention SpinLock is often worse.
Do instead: benchmark with realistic contention and CPU measurements.
What happens: thread pool threads oversubscribe the CPUs. Why it is wrong: spinners can prevent the owner from being scheduled. Do instead: blocking locks or contention-free designs.
lockTaken patternWhat happens: Enter() outside try, Exit() in finally unconditionally.
Why it is wrong: an exception during Enter leads to exiting a lock you don't hold.
Do instead: bool taken = false; try { s.Enter(ref taken); ... } finally { if (taken) s.Exit(); }.
What happens: a spin lock guards state touched around await.
Why it is wrong: can't hold it across await, and spinning on pool threads wastes capacity.
Do instead: SemaphoreSlim or lock-free designs.
What happens: replacing a lock with per-thread counters stored in adjacent array slots. Why it is wrong: cores fight over the same cache line; it's slow anyway. Do instead: pad slots to separate cache lines.
SpinWait loops without back-off limitsWhat happens: a custom while (!flag) SpinWait.SpinOnce() waits for seconds.
Why it is wrong: wastes CPU and delays other work.
Do instead: switch to a blocking/async primitive for unbounded waits.
What happens: only latency is measured after adopting a spin lock. Why it is wrong: spinning can increase cost and reduce headroom without changing p50. Do instead: measure CPU, throughput and tail latency together.
Knowing when not to use SpinLock is the senior answer. I have used it only in infrastructure code after benchmarks, and even then removing contention (per-thread data, Interlocked) usually won.
| Option | When |
|---|---|
lock |
Default |
Interlocked |
Single-value updates |
| Per-thread or per-core state | Hot counters and aggregates |
SpinLock |
Proven tiny hot section, many cores |
SpinWait |
Custom lock-free wait loops |
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.
Cores keep their caches coherent: when one core writes to a cache line, copies of that whole 64-byte line in other cores are invalidated. If thread A updates counters[0] and thread B updates counters[1], they're logically independent but sit on the same line, so the line ping-pongs between cores on every write. That's false sharing: more threads, more invalidations, and the loop gets slower. Symptoms are high CPU with poor scaling and no locks in sight. The best fix is to accumulate in a local variable per thread and combine once at the end (Parallel.For with localInit/localFinally). Where per-thread state must live in shared memory, pad each slot to its own cache line (128 bytes is safe across x64 and Apple Arm64).
How to tell it (STAR)
Someone switched our [aggregator] to `SpinLock` and CPU went up. The profile showed threads spinning while the owner was in a slow log call. I reverted it and moved to per-thread counters merged periodically, which beat both versions. We now require a benchmark for these changes.
Last updated 31 Oct 2026.