📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is SpinLock, and when (if ever) should you use it instead of lock?

SpinLock and SpinWait, busy-waiting versus blocking, struct copying pitfalls, owner tracking and why most code should not use it.

Senior Theory Advanced performance spinlock spinwait synchronization

🎯 Key points

  • SpinLock busy-waits instead of blocking
  • Only for tiny critical sections on hot paths, proven by profiling
  • It is a mutable struct: never readonly, never copied
  • Not re-entrant; owner tracking throws LockRecursionException
  • Wastes CPU if the owner is descheduled or the section is long
  • Monitor and Lock already spin briefly before blocking
  • SpinWait is useful for custom lock-free loops

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"SpinLock is a struct lock that busy-waits: a waiting thread keeps retrying an atomic compare-and-swap (backing off with SpinWait) 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 a readonly field 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 (both Monitor and the .NET 9 Lock type) 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 with Interlocked, per-thread state or batching."


1. Spinning vs blocking

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

2. The API

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)

3. The pitfalls

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

4. Better alternatives, in order

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)

Production example

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.

🔍 What the interviewer is checking

Checks depth on low-level primitives and engineering judgement.

Green flags

  • Explains spinning versus blocking trade-off.
  • Knows the struct copy and re-entrancy pitfalls.
  • Says lock already spins and measurement is required.

Red flags

  • "SpinLock is faster, so use it."
  • Stores it in a readonly field.

📚 Learn in depth

1. How SpinLock.Enter works

Enter(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.

2. lock already spins

Monitor.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.

3. The copy problem, concretely

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.

4. Why removing contention usually wins

// 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.

5. Measuring correctly

  • BenchmarkDotNet with realistic thread counts (including more threads than cores).
  • Record CPU time, not just throughput or latency.
  • Use [ThreadingDiagnoser] for lock contention counts and work-item counts.
  • Test on the production core count; spin locks behave very differently on 4 vs 64 cores.

6. Version notes

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

7. Try it yourself

  1. Run the code example; then make _spin readonly and see the total come out wrong.
  2. Benchmark lock, Lock, SpinLock and Interlocked.Add for 8 threads incrementing a counter.
  3. Add Thread.Sleep(0) inside the critical section and compare CPU usage between lock and SpinLock.
  4. Run with 4× more threads than cores and observe SpinLock degrade.
  5. Enable owner tracking and re-enter to see LockRecursionException.
  6. Implement the striped counter and compare with all of the above.

💻 Code

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.

⚠ Common mistakes

1. readonly SpinLock or by-value copies

What 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.

2. Long or blocking work inside the critical section

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.

3. Re-entering

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.

4. Choosing it without measurement

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.

5. Using it with more threads than cores

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.

6. Forgetting the lockTaken pattern

What 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(); }.

7. Using spin locks in async code

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.

8. Ignoring false sharing

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.

9. Using SpinWait loops without back-off limits

What 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.

10. Treating CPU% as irrelevant

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says lock already spins Why: Checks lock implementation
    How to answer

    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.

  2. When: Candidate suggests Interlocked instead Why: Probes lock-free updates
    How to answer

    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.

  3. When: Candidate mentions cache lines and cores Why: Checks false sharing
    How to answer

    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).

🛠 Real-world problems: how you handle them

SpinLock increased CPU in a metrics aggregator
Situation
A [metrics aggregator] on a [32-core] host replaced `lock` with `SpinLock` to reduce latency.
Signals
CPU rose [about 15%] under load with no throughput gain.
Investigation
A CPU profile showed time in `SpinLock.ContinueTryEnter`; the protected section included a logging call that sometimes blocked.
Resolution
Reverted to `lock`, then redesigned with per-thread counters merged every second.
Prevention
Required benchmarks for any change to low-level synchronisation.

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.

↩ Usually asked after

More in Synchronization

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me