When multiple threads modify shared state, the typical solution is a lock. But locks have costs: contention, context switches, and the risk of deadlocks. For simple atomic operations — incrementing counters, swapping references, compare-and-set patterns — the Interlocked class provides lock-free alternatives that map directly to CPU instructions.

Why Lock-Free?

A lock statement acquires a monitor, executes the protected code, then releases it. If another thread tries to acquire the same lock, it blocks. Under high contention, threads queue up, wasting CPU time on context switches. Interlocked operations avoid all of this by using hardware-level atomic instructions (like LOCK CMPXCHG on x86) that complete in a single uninterruptible step.

Basic Operations

The Interlocked class in System.Threading provides several atomic operations:

Example.cs
private int _requestCount;
private long _totalBytes;

public void RecordRequest(int bytes)
{
    Interlocked.Increment(ref _requestCount);
    Interlocked.Add(ref _totalBytes, bytes);
}

public (int Count, long Bytes) GetStats()
{
    return (
        Interlocked.CompareExchange(ref _requestCount, 0, 0), // Atomic read
        Interlocked.Read(ref _totalBytes) // Atomic 64-bit read
    );
}

Interlocked.Increment atomically adds 1 and returns the new value. Interlocked.Add atomically adds any amount. Interlocked.Read performs an atomic 64-bit read — necessary on 32-bit platforms where reading a long is not naturally atomic.

Compare-And-Swap (CAS)

Interlocked.CompareExchange is the most powerful primitive. It atomically compares a variable to an expected value and, only if they match, replaces it with a new value:

Example.cs
// Atomically update a maximum value
private long _maxLatency;

public void RecordLatency(long latencyMs)
{
    long current;
    do
    {
        current = Interlocked.Read(ref _maxLatency);
        if (latencyMs <= current)
            return; // Current max is already higher
    }
    while (Interlocked.CompareExchange(ref _maxLatency, latencyMs, current) != current);
}

This is the CAS loop pattern. If another thread modifies _maxLatency between the read and the compare-exchange, the operation detects the mismatch and retries. No lock is needed, and the retry is extremely fast under low contention.

Atomic Reference Swaps

Interlocked.Exchange and Interlocked.CompareExchange work with reference types too, enabling lock-free immutable state updates:

Example.cs
public class MetricsSnapshot
{
    private ImmutableDictionary<string, int> _counters =
        ImmutableDictionary<string, int>.Empty;

    public void IncrementCounter(string name)
    {
        ImmutableDictionary<string, int> current, updated;
        do
        {
            current = _counters;
            updated = current.SetItem(
                name,
                current.GetValueOrDefault(name) + 1);
        }
        while (Interlocked.CompareExchange(ref _counters, updated, current) != current);
    }

    public ImmutableDictionary<string, int> GetSnapshot()
    {
        return Volatile.Read(ref _counters);
    }
}

By using immutable data structures, the CAS loop never corrupts state. Either the swap succeeds (replacing the entire dictionary atomically) or it retries with the latest version.

Volatile vs Interlocked

Volatile.Read and Volatile.Write ensure memory ordering — they prevent the CPU and compiler from reordering reads and writes across the volatile access. But they do not provide atomicity for compound operations:

Example.cs
// Volatile ensures you see the latest value but does not make ++ atomic
Volatile.Write(ref _count, Volatile.Read(ref _count) + 1); // STILL A RACE!

// Use Interlocked for atomic increment
Interlocked.Increment(ref _count); // Correct

Use Volatile when you need a simple read or write with memory barriers (e.g., a flag that one thread sets and another reads). Use Interlocked when you need the operation itself to be atomic.

Practical Example: Lock-Free Counter with Reset

Example.cs
public class ResettableCounter
{
    private long _count;

    public void Increment() => Interlocked.Increment(ref _count);

    public long GetAndReset() => Interlocked.Exchange(ref _count, 0);

    public long Current => Interlocked.Read(ref _count);
}

GetAndReset atomically reads the current value and sets it to zero in one operation. No lock, no race condition.

When to Use Locks Instead

Lock-free programming is not always the right choice:

Interlocked shines for hot-path counters, statistics, flags, and simple state transitions where every nanosecond matters. For everything else, reach for a lock and keep your code readable.