Skip to main content

Inside Interlocked: Memory Ordering, Fences, and Cache Lines

Follow a shared counter across threads and CPU cores, from atomic updates and memory ordering to contention and false sharing.

Updated 38 min readMarkdown
Inside Interlocked: Memory Ordering, Fences, and Cache Lines

About a month ago I started digging into how Interlocked.Increment works and what can go wrong with it. That led me to memory barriers, instruction reordering, cache coherency and friends. Quite a lot of machinery for adding one to a number.

How two threads lose an increment

Suppose we're processing a pile of jobs and want to count how many are done. With one worker, there's not much to discuss: finish a job, add one to the counter, move on. With several workers running in parallel, we want all of them to add to the same count.

That makes the counter shared mutable state. The workers do most of their work on their own, but they all come back to this one variable to record it. When they compete for the same shared resource, we have contention.

Before we worry about how expensive that competition is, let's make sure the counter actually counts: every finished job should add one. A simple class looks like enough:

public sealed class Counter
{
    private int _count;

    public int Increment() => ++_count;
    public int Value => _count;
}

That works on one thread. With several threads, ++_count is three steps: a read, a calculation and a write, and another thread can get in between them. Start at 10 and let two workers finish at the same time:

Step Thread A Thread B
1 Reads 10
2 Reads 10
3 Calculates 11
4 Calculates 11
5 Writes 11
6 Writes 11

Two increments happened, but the counter only went up by one. Each thread calculated a correct answer from the value it read, and the second write simply overwrote the first.

Reading or writing a normally aligned int is atomic on its own: another thread never sees half of the old integer glued to half of the new one. That covers each access separately, not the gaps between them.

So our first requirement is that the whole read-modify-write happens as one step that no other thread can split.

Fixing it with Interlocked.Increment

Interlocked.Increment gives us exactly that. Let's replace ++_count with it:

using System.Threading;

public sealed class Counter
{
    private int _count;

    public int Increment() => Interlocked.Increment(ref _count);
    public int Value => _count;
}

Now two workers that finish together on a counter at 10 always leave it at 12, no matter how their calls overlap. The increments are safe, but Value is still an ordinary field read: Interlocked only protects its own operation. That becomes a problem when another thread keeps checking the count while the workers update it.

A polling loop that never sees the update

Suppose a reader waits until the workers have finished a certain number of jobs. It can keep checking Value in a loop, which is called polling. The workers call Increment on the same counter while the reader runs this method:

public static class CounterReader
{
    public static void WaitUntil(Counter counter, int target)
    {
        while (counter.Value < target)
        {
            // Keep checking while the workers run.
        }
    }
}

In the source code, every pass through the loop checks the property again. If the count is 10 and we're waiting for 100, we expect every new check to be another chance to see the workers' progress. Because the property reads an ordinary field, the compiler has more freedom here than is safe for this loop.

The loop can keep checking an old value

.NET's JIT optimizes methods while it compiles them to machine code. A small property getter can be inlined: its body is copied straight into the caller's code. Here, that leaves a plain read of _count inside the loop.

The JIT doesn't have to keep every ordinary field read exactly where we wrote it. As long as the current thread still behaves the same, it can read a value once and reuse it for later checks. In a polling loop, that can mean moving the read before the loop. Microsoft's article on compiler optimizations shows this exact problem.

Here's a C# sketch of what the JIT is allowed to turn our method into:

public static class CounterReader
{
    public static void WaitUntil(Counter counter, int target)
    {
        int observed = counter.Value;

        while (observed < target)
        {
            // The comparison keeps using the first value.
        }
    }
}

If observed starts at 10 and target is 100, the workers can take _count all the way to 100 without losing a single update. The reader still compares its saved 10 with 100, long after the work is done.

Keeping every read with volatile

The reader needs every check to be a real read, not a reused result of an earlier one. C# has the volatile field modifier for this. A field declared as private volatile int _count; is still read and written with normal-looking C#, but the JIT can't replace a loop's reads of it with a single read before the loop.

Volatile.Read does the same for a single read. The field can stay an ordinary int, and only the read in Value becomes volatile:

using System.Threading;

public sealed class Counter
{
    private int _count;

    public int Increment() => Interlocked.Increment(ref _count);
    public int Value => Volatile.Read(ref _count);
}

Now every check in while (counter.Value < target) is a volatile read. A volatile read doesn't promise to return the newest value right away, but the loop can't get stuck on one saved value anymore.

Making the field volatile wouldn't fix our original ++_count, though. Both callers could still read 10 and write 11: keeping each read and write in place doesn't join them into one atomic update. The C# documentation on volatile points out this difference.

Reads and writes can change order

Besides keeping repeated reads of a changing field, volatile has a second use: ordering. It makes some accesses to related data happen before others.

Suppose a writer prepares some data in _value, then sets _ready to true. A reader checks _ready before it uses _value. The flag makes a promise about the other field: “the data is ready; you can use it now.”

For that promise to hold, preparing the data must stay before setting the flag. On the reader's side, checking the flag must stay before using the data. These are rules about memory ordering: the order of accesses to different memory locations. Reading a value from memory is also called a load, and writing one is a store.

Ordinary accesses don't always stay in source order: the C# execution-order rules let the JIT move or combine some of them. In our example, the reader could read the payload, _value, before checking the flag, and then use that early result once the check passes. With _value starting at 0 and _ready at false, this can happen:

Step Writer Reader
1 Fetches _value early: gets 0
2 Stores 42 in _value
3 Stores true in _ready
4 Reads _ready: gets true, then uses the earlier 0

The writer didn't even have to reorder its stores; the reader's early load was enough.

The CPU reorders too. Within the rules of its architecture, it can run instructions out of order and do other work while an earlier instruction waits. A write can also sit in a store buffer, where pending stores wait before other cores can see them. Microsoft's memory model article covers both the compiler side and the hardware side.

Fences: release stores and acquire loads

A memory barrier, also called a fence, limits this freedom for both the compiler and the CPU: it keeps certain memory accesses on the correct side of a boundary.

Volatile reads and writes give two kinds of ordering guarantees:

  • A release store stops memory accesses that come before it from moving after it. For our writer, preparing _value must stay before setting _ready.
  • An acquire load stops memory accesses that come after it from moving before it. For our reader, using _value must stay after checking _ready.

These are the write/release and read/acquire rules from the C# specification for volatile fields. Volatile.Read gives us the acquire load we already used. Its partner, Volatile.Write, gives us a release store for a single write.

Each guarantee works in one direction only. In general, an acquire doesn't stop an earlier access from moving after it, and a release doesn't stop a later access from moving before it.

Publish the data through the flag

In this example, one thread calls Publish exactly once, and other threads can check whether the value is ready:

using System.Threading;

public sealed class OneTimeValue
{
    private int _value;
    private bool _ready;

    public void Publish(int value)
    {
        _value = value;
        Volatile.Write(ref _ready, true);
    }

    public bool TryRead(out int value)
    {
        if (!Volatile.Read(ref _ready))
        {
            value = default;
            return false;
        }

        value = _value;
        return true;
    }
}

If the acquire read in TryRead sees the true written by the release store in Publish, the reader also sees everything the writer did before that store:

Writer                          Reader
_value = 42
    | must stay before
release: _ready = true --------> acquire: read _ready == true
                                    | must stay before
                                read _value: gets 42

The Volatile documentation describes these guarantees and their limits: they tell you what the reader can rely on once it sees the flag, not how soon it will see it.

The payload doesn't need a volatile read: the flag already gives us the ordering, and nobody changes the payload after it's published. The example depends on that last part. A later write to the payload wouldn't be covered by the earlier release.

The volatile cache myth

Now prepare for the biggest misunderstanding about volatile, one that even many senior .NET developers have.

CPUs keep copies of data in caches close to the cores, so they don't have to fetch it from main memory every time. Many people think volatile skips those caches and reads straight from RAM, but that's horribly wrong! Ordinary field reads and writes always go through the CPU caches, volatile or not. Putting volatile on a field doesn't switch caching off or flush anything. All it does is the two things we just covered: it stops the JIT from merging repeated reads, and it orders accesses with acquire and release.

I don't know where this myth comes from, but it's very persistent. I even remember an interviewer giving me exactly that explanation, and I'm frustrated that I bought into it too, at least a little.

The hardware keeps cached copies in sync through cache coherency, which we'll look at when we get to contention. Coherency alone doesn't order accesses to different fields, though. For our payload and flag, the order comes from the acquire and release rules.

Now let's go back to the counter and give it a new rule that an increment alone can't handle.

Stopping at 100 with compare-and-swap

When we only count finished jobs, it's enough that no increment gets lost. Now suppose we use the counter to accept at most 100 jobs. A caller should succeed only if there's still room, and each successful call takes one place.

We already have a counter with safe reads and increments. It's tempting to just wrap a limit check around it:

var counter = new Counter();
const int limit = 100;

// Several threads execute this against the same counter.
if (counter.Value < limit)
{
    counter.Increment();
}

Two threads can both see 99, both enter the if, and take the counter to 101. Every read and increment did what it promised. The problem is bigger than any single operation: the decision and the update have to be based on the same value.

Seeing a free place doesn't reserve it. We need to apply the update only if the value we based our decision on is still there, and decide again if someone has changed it.

Retry if the count changed

Interlocked.CompareExchange lets us say: store current + 1, but only if the counter still equals current. The comparison and the swap happen as one atomic operation. This is usually called compare-and-swap, or CAS.

It returns the value it found, so getting current back means our swap happened. Any other value means another caller changed the count first, and our attempt changed nothing.

That gives us a loop: read the count, check the limit, then try to swap in the new value. If the comparison fails, we read again and decide again, instead of acting on an old answer.

If the losing callers retried right away, they'd keep hammering the same memory location, so we space out the attempts with SpinWait.

using System.Threading;

public sealed class LimitedCounter(int limit)
{
    private int _count;

    public bool TryIncrement()
    {
        SpinWait spin = default;

        while (true)
        {
            int current = Volatile.Read(ref _count);
            if (current >= limit)
                return false;

            if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
                return true;

            spin.SpinOnce();
        }
    }
}

In the 99-to-100 race, both callers can still read 99 and decide there's room. One CAS replaces 99 with 100, and at that moment its job takes the last place. The other CAS expects 99 but finds 100, so it changes nothing. After SpinOnce, that caller reads again, sees the limit is reached and returns false.

Anything calculated inside a retry loop may run several times. A failed CAS doesn't undo the work done before it, so that work must be safe to repeat or throw away. Charging a card or sending a message in there would be a rather exciting mistake.

This works nicely because the whole state is one integer. Once the state we need to keep consistent grows bigger than that, retrying the calculation gets awkward. But an atomic update can still decide which caller gets to work on the larger state, and that caller can then make several related changes to it.

Building a mutex from one bool

Suppose accepting a job now has to update both a count and a separate collection of job records. If we make each update atomic on its own, there's still a gap between them, and another caller could see only part of the work.

We can protect the whole region of code by letting only one caller run it at a time. That's mutual exclusion, and the region is a critical section. A mutex gives callers a simple ownership rule: acquire it, use the protected state, then release it for someone else.

Let's represent ownership with one bool: false means free and true means held. To enter, a caller must change false to true atomically. CAS can do exactly that: several callers may see false, but only one of them can replace it with true.

There's one more requirement. When the owner releases the mutex, the next owner must see every change the previous owner made to the protected data.

The fences a mutex needs

This is the same problem we solved with the ready flag. Reads and writes of the protected data must stay after we acquire the mutex and before we release it: an acquire on the way in and a release on the way out.

Interlocked operations give us even more. The .NET memory model treats them as full fences: no memory access can move across them in either direction. Acquire and release each block only one direction, which is why they're often called half fences.

So a successful CAS already gives us the acquire we need to enter the critical section. To leave, a release store of false is enough: the owner's changes can't move past the store that frees the mutex. Leaving doesn't need CAS, because only the owner releases the mutex, and it always writes the same value.

Claim ownership, do the work, release it

While the flag is true, there's nothing for a waiter to claim. So a waiter can check it with Volatile.Read first and try CAS only when the mutex looks free. That read just saves useless attempts; the CAS still decides who gets in. Add the release store on exit, and we have a tiny mutex:

using System.Threading;

public sealed class SpinMutex
{
    private bool _held;

    public void Enter()
    {
        SpinWait spin = default;

        while (true)
        {
            if (!Volatile.Read(ref _held) &&
                !Interlocked.CompareExchange(ref _held, true, false))
            {
                return;
            }

            spin.SpinOnce();
        }
    }

    public void Exit() => Volatile.Write(ref _held, false);
}

CompareExchange returns the value it found, so getting false back means we're the caller that set true. A caller that loses waits and tries again. A plain read followed by _held = true would bring back our original lost-update problem: both callers could see false, store true and enter the region that was supposed to be exclusive.

Putting the mutex around ++_count

Let's use the mutex to protect our original ++_count. The increment stays ordinary C#, but now it runs between Enter and Exit:

public sealed class LockedCounter
{
    private readonly SpinMutex _mutex = new();
    private int _count;

    public int Increment()
    {
        _mutex.Enter();
        try
        {
            return ++_count;
        }
        finally
        {
            _mutex.Exit();
        }
    }
}

Now no two callers can run the increment at the same time, as long as every other access to the protected state takes the same mutex too. For a single counter, Interlocked.Increment is simpler. A mutex is worth it when the region grows to hold several related changes.

The .NET SpinLock implementation uses the same building blocks, plus a lot more code for owner tracking, timeouts and contention. Ours doesn't track an owner at all, so if you enter it twice on the same thread, you'll deadlock. So, please, for the love of god, do not use my example above in real production code and use production ready primitives supplied by runtime team :)

Side note: Interlocked.CompareExchange accepts a bool only since .NET 9. Older versions have no bool overload, and their generic overload only works with reference types. That's why you'll find tons of code that keeps a flag like this in an int and uses 0 and 1.

All of these approaches keep the work correct, but every successful update still goes through one shared location, and so does every caller that takes the mutex. Let's see what that means for the CPU cores running those callers.

Why cores wait in line for one counter

So far we've pictured memory as separate variables: _count here, _held over there. At that level, an increment touches four bytes, but to let a core write those four bytes, the CPU has to coordinate a bigger piece of memory.

The counter has roommates

The CPU caches from the volatile myth work with blocks called cache lines, not with single variables. If a line isn't in the cache yet, the CPU loads the whole block around the requested address, neighboring bytes included. On the x64 processors we're talking about here, a line is 64 bytes. So our four-byte int gets a few roommates.

Loading neighboring bytes is useful because programs often use nearby data next. After reading one array element, a loop will often read the next one, which is then already in the same cached block. This is called spatial locality, and it's one of the reasons caches work with lines, as Intel's explanation of memory locality describes.

Different cores can have copies of the same line in their caches, and cache coherency is how the hardware keeps those copies in sync. In the simplified write-invalidate model we'll use here, a core needs exclusive write ownership of a line before it can change it. When needed, the other cores' copies are invalidated, and those cores can't use their copies until they fetch valid data again. Ownership covers the whole line, even when an instruction changes only four bytes of it.

From Interlocked.Increment to an instruction

So how does a real CPU keep an increment atomic? Interlocked.Increment ends up in a method the JIT treats as an intrinsic (see the .NET 10 CoreCLR source): instead of calling it, the JIT puts the matching CPU instruction right into our code. On .NET 10 x64, when the result isn't used, that's a single instruction:

lock inc dword ptr [rcx]

[rcx] is the counter in memory: rcx holds its address. A plain inc would also read the value and write it back, and another core could get in between. The lock prefix closes that gap and makes the whole read-modify-write atomic. When the result is used, like in our Counter, the JIT emits lock xadd instead, which also gives back the old value. The API doesn't promise any specific instruction; Arm64, for example, has its own atomic instructions.

For an ordinary aligned field, a modern Intel CPU doesn't lock all of memory to do this. It uses the cache-line ownership we just described: the core holds on to the line until the update is done. Intel's documentation of locked operations lists the exceptions, such as an access that crosses a cache-line boundary.

A correct counter can still make cores wait

Whichever instruction the JIT picks, a core needs write ownership of the counter's cache line before it can update the count.

Imagine two cores repeatedly incrementing the count, starting at 10. In the simplified model from earlier, it goes like this:

Step Core A Core B Line owner
1 Takes the line A
2 Increments 10 to 11 Wants the line, waits A
3 Loses its copy Takes the line B
4 Wants the line, waits Increments 11 to 12 B
5 Takes the line Loses its copy A
6 Increments 12 to 13 Wants the line, waits A

Only one core can own the line at a time, so the cores take turns, and the one without the line waits. Real timing is messier: one core may get several increments in a row before the line moves. The line can travel straight from one core's cache to the other's, so not every increment needs a trip to main memory, but every move still costs time.

A polling reader joins in too: it can reuse a valid cached copy across several reads. When an increment on another core invalidates that copy, the reader has to fetch the line again on its next read.

More cores can process more jobs in parallel, but their updates to the one shared counter still happen one after another.

When the useful work between increments is tiny, moving the line between cores and paying for the ordering can take a large part of the time. A CAS loop adds failed attempts on top. Our mutex skips CAS while _held reads as true, so its waiters mostly just read and call SpinWait. Every new owner still has to claim the same field, though.

At low contention, Interlocked.Increment can be a very good choice. When many cores keep coming back to it, we can check how much of the work really needs to touch the shared counter.

Keep a subtotal per worker

Let's go back to the original job count. Since then, we've added a reader that polls the count and a counter with a limit, and they need different things from the number. The limit has to check every job before it's accepted. The polling reader can often live with a delay, and the final total isn't needed until every worker is done.

If nobody needs the count while the work is running, publishing every increment makes the workers coordinate for a number nobody reads. Instead, each worker can keep a subtotal and add it to the shared total once, when it's done. Far fewer updates reach the shared location that way.

The Parallel.For overload with local state does exactly this. Each subtotal starts at zero, each iteration adds to it, and a final callback adds it to the shared total:

using System.Threading;
using System.Threading.Tasks;

const int itemCount = 1_000_000;
long total = 0;

Parallel.For<long>(
    0,
    itemCount,
    localInit: static () => 0,
    body: static (i, state, subtotal) =>
    {
        // Process item i here.
        return subtotal + 1;
    },
    localFinally: subtotal => Interlocked.Add(ref total, subtotal));

Console.WriteLine(total); // 1000000

Those final callbacks can run at the same time, which is why the merge still uses Interlocked.Add. Microsoft shows the same approach in its parallel-loop example with local state.

For a long-running worker, the same idea becomes batching: count locally, add the batch to the shared total from time to time, and flush whatever is left when the worker finishes. A polling reader then sees the total as of the last flush.

The runtime makes the same tradeoff. Its profiling counters could use interlocked updates to be exact, but under heavy contention those updates could become a bottleneck. The .NET 8 performance article describes the approximate counters used instead.

Counting locally means workers update the shared number less often. Another option is to keep progress in several counters, so the workers don't all update the same number. That takes us back to the counter's roommates.

Separate counters can still share a cache line

Suppose each worker has a counter of its own, and we add them together when we need a total. That's striping: each counter is a stripe. You might also know it as sharding the counter.

Now the workers update different locations, but different locations can still sit in the same cache line. An array makes that easy to see: neighboring long elements are eight bytes apart, which is much less than the 64-byte line in our hardware model.

using System.Threading;
using System.Threading.Tasks;

const int workerCount = 4;
const int iterationsPerWorker = 100_000;
long[] counters = new long[workerCount];

Parallel.For(0, workerCount, worker =>
{
    for (int i = 0; i < iterationsPerWorker; i++)
    {
        Interlocked.Increment(ref counters[worker]);
    }
});

The four values take only 32 bytes of element storage. Depending on where the array data starts within a line, several of them can end up in the same line, like this:

One possible layout of four adjacent eight-byte long counters inside a single 64-byte cache line. Cores A, B, C and D each update a different counter, but all need writable ownership of the same line.

Core A needs write ownership of this line to increment counters[0]. If core B has a cached copy of the line for counters[1], that copy gets invalidated. When B increments its own counter, it needs write ownership of the same line, and now it gets in A's way.

Neither worker touched the other's number, so in C# their work is independent. In hardware, they still compete for write access to one line. This is false sharing, as opposed to the true sharing of our single counter, where every worker really did need the same value. Oracle's explanation shows why it happens: coherency works on whole cache lines, not on single variables.

False sharing can also happen between a writer and a reader of different fields on the same line. Two cores that only read a line don't invalidate each other's copies, so being neighbors isn't a problem by itself. It becomes one when something writes to that line often.

Side note: we don't need atomic increments here for correctness: each element has one writer, and we can read the results after Parallel.For finishes. They make every memory update explicit and keep the code comparable with the shared counter. Ordinary writes can cause false sharing too, although the JIT may optimize some repeated accesses away.

Give the stripes some space

We already know one way to reduce this traffic: write less often. If workers have to keep writing to separate fields, we can also move those fields farther apart. The unused space between the hot fields is called padding.

Striping spreads updates across several values, and padding keeps those values apart in memory. OpenJDK's Striped64 does both: it spreads updates across cells and pads the cells to reduce false sharing.

For our fixed set of workers, an array of structs lets us control the distance between counter fields, because the structs are stored right inside the array. To picture the spacing, let's start with a smaller example: a four-byte int at the start of a 64-byte struct, followed by 60 bytes of padding.

Two possible layouts of four 64-byte structs, each containing a four-byte int followed by 60 bytes of padding. The first layout starts on a cache-line boundary; the second starts 16 bytes into a line. In both, neighboring int fields occupy separate cache lines.

The cache-line boundaries stay where they are, but the array's starting position can vary. In the second layout, the padding spills into the next line, but the int fields still sit in separate lines. Reserving 64 bytes controls the spacing between elements; it doesn't promise that each element starts at the beginning of a cache line.

A 128-byte PaddedCounter

So far we've measured everything in 64-byte lines, the size on the x64 processors we've been talking about. Cache line size depends on the CPU, though. On Apple's M-series chips, macOS reports 128-byte lines, and IBM's POWER processors use 128 bytes too. IBM Z mainframes go up to 256 bytes. On those machines, 64-byte spacing can put two counters in one line again.

Even on x64, a core doesn't always load only the line it asked for. CPUs try to load data before the program needs it, by guessing from recent accesses. This is called prefetching. Some Intel processors have a prefetcher that works with 128-byte pairs of lines: when it loads one 64-byte line into the L2 cache, it also fetches the other line of the pair. So a core working on its own counter can end up with a copy of its neighbor's line too, and the neighbor's next write has to invalidate it.

That's why padding code often uses 128 bytes, even on x64. The .NET runtime's internal padding constant is 128 bytes, picked to cover the most common cache lines, and Rust's Crossbeam chooses 128 bytes on x64 because of that prefetcher.

There's also one more neighbor to think about: the array's length, which sits right in front of the first element. Bounds checks may need to read it from memory, so if the first counter shares its line, every increment of that counter invalidates the copy other workers use for their bounds checks.

Our PaddedCounter reserves 128 bytes per element and puts the field at offset 64:

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit, Size = 128)]
public struct PaddedCounter
{
    [FieldOffset(64)]
    public long Value;
}

In a PaddedCounter[], neighboring Value fields are now 128 bytes apart, so they can't share a 128-byte line or one of the prefetcher's pairs. The 64 bytes in front of the field keep the first counter out of the 64-byte line that holds the array's length. That doesn't make the element any bigger: the field just moves from the start of its slot to the middle.

One possible layout of two 128-byte PaddedCounter elements on a machine with 64-byte cache lines. Each element has 64 bytes of padding, an eight-byte Value and 56 more bytes of padding. Core A updates a Value in line 1, core B updates a Value in line 3, and the array length is in line 0.

Another way to keep the length away is to allocate one extra element and leave it unused: worker i takes element i + 1. The empty element puts a whole slot between the length and the first counter, at the price of one more slot of memory. That's handy with 64-byte slots, which have no room for 64 bytes of padding in front of the field. With our 128-byte slots, it also gives enough distance for 128-byte lines, which the 64-byte offset alone doesn't.

So we have a classic tradeoff between memory and speed here: we use more memory per counter so the cores spend less time waiting for cache-line ownership. Four long counters need 32 bytes of element storage; four PaddedCounter elements need 512 bytes, not counting the array's own overhead. Adding up the padded counters also touches more cache lines, so the extra space has to earn its keep.

Now we can repeat the earlier per-worker loop with this layout. Each worker still owns the stripe at its index, and we add up the result only after all workers finish:

using System.Threading;
using System.Threading.Tasks;

const int workerCount = 4;
const int iterationsPerWorker = 100_000;
PaddedCounter[] stripes = new PaddedCounter[workerCount];

Parallel.For(0, workerCount, worker =>
{
    for (int i = 0; i < iterationsPerWorker; i++)
    {
        Interlocked.Increment(ref stripes[worker].Value);
    }
});

long total = 0;
for (int i = 0; i < stripes.Length; i++)
    total += stripes[i].Value;

Console.WriteLine(total); // 400000

Each iteration still does one atomic increment; only the location of the updated fields changed. Padding the original shared _count wouldn't help, since every worker would still change the same number.

The total above is exact because we add it up after the workers finish. A reader that sums the stripes while the workers still run gets no atomic snapshot: the workers keep updating while it walks through the array. Frequent polling can also bring the cache traffic back.

One stripe per core

In our striped examples, each worker got an index from Parallel.For and used it to find its stripe. The job counter from the beginning doesn't have that: whichever thread finishes a job calls Increment, and there can be many more such threads than cores.

We don't need a stripe per thread, though. At any moment, only as many threads can run as the machine has cores, and threads that take turns on the same core don't compete for a cache line: the line just stays in that core's cache. So the usual design is one stripe per core, and each update goes to the stripe of the core the calling thread runs on. Choosing the stripe by thread ID would spread the updates too, but two threads that run at the same moment could still get the same stripe. Two running threads are always on different cores, so choosing by core avoids that.

We create Environment.ProcessorCount stripes. Here, a core means a logical processor, which is what that property counts: a CPU that runs two threads per physical core reports both of them. The PaddedCounter from the previous section works as a stripe, and Thread.GetCurrentProcessorId() tells us which core the calling thread is on:

using System.Threading;

public sealed class StripedCounter
{
    private readonly PaddedCounter[] _stripes = new PaddedCounter[Environment.ProcessorCount];

    public void Increment()
    {
        int stripe = Thread.GetCurrentProcessorId() % _stripes.Length;
        Interlocked.Increment(ref _stripes[stripe].Value);
    }

    public long Value
    {
        get
        {
            long total = 0;
            for (int i = 0; i < _stripes.Length; i++)
                total += Volatile.Read(ref _stripes[i].Value);

            return total;
        }
    }
}

Callers don't need an index anymore: any thread can call Increment. It doesn't return the new count, though, since a stripe only knows its own part of it. Value adds up the stripes with the same catch as before: while workers run, the stripes keep changing under it.

The % is there because the ID doesn't have to fit our array. The GetCurrentProcessorId documentation says it isn't guaranteed to be a zero-based processor number. And Environment.ProcessorCount counts only the processors the process may use: limit a process to processors 4 and 5, and it reports 2, while GetCurrentProcessorId keeps returning 4 and 5. A container's CPU limit lowers the count too, even though the threads may still run on any core of the machine.

Interlocked.Increment is still needed, even though each core has its own stripe. The ID is only a hint: the runtime may cache it per thread and refresh it from time to time, and the OS can move the thread to another core right after the call anyway. So two threads can share a stripe for a while, and unlike the per-worker stripes, a stripe can have more than one writer.

Memory grows with the machine now: with 128-byte stripes, a counter takes 2 KB on a 16-processor machine and 8 KB on a 64-processor one. For one job counter that's nothing, but it adds up when a process has lots of counters. The runtime's CounterAggregator, which adds up Counter<T> and UpDownCounter<T> measurements when a tool like dotnet-counters listens, picks its stripe exactly like ours, with Thread.GetCurrentProcessorId() % deltas.Length, but caps the array at eight stripes to save memory.

Its stripes hold a double, which Interlocked.Add doesn't support, so it updates them with a CAS loop. The PR that added the stripes replaced a lock with that loop. It notes that without the per-core split, the loop could be measurably slower than the lock under heavy contention.

When and how to measure contention

We now have several designs to compare, but a plain Interlocked.Increment is often good enough. Measure when a profiler points at the shared counter as a hot spot, or when adding workers stops making the job faster. Do it before you switch to subtotals, stripes or padding, because each of them comes with the costs we saw earlier.

A useful measurement has to create the contention we're studying. A single-threaded benchmark of Interlocked.Increment tells you about an increment with no contention, not about how one counter behaves when eight cores want it.

It also needs to put real work back between the increments. A tiny counter loop may show a bottleneck that disappears when each update comes after a millisecond of job processing. So look at how much work gets done as you add workers.

A lighter fence in System.Text.Json

To finish, let's go back to memory barriers with a case from the runtime.

Once acquire and release made sense to me, I remembered a thread-safety bug reported against .NET 6: concurrent reads of a JsonNode could fail during lazy initialization. I went back to the runtime team's fix and noticed that, for one part of the logic, its full fences were stronger than needed.

A parsed JsonArray starts with a JsonElement, a view of the parsed JSON, and builds a List<JsonNode?> of child nodes only when it first needs them. That's lazy initialization. So even a read can switch the array from the element to the list while another thread is reading it. JsonObject does the same with a dictionary.

Full fences on both sides

The original fix puts a full fence on each side, and the two sides mirror each other. The thread that builds the list publishes it first and clears the element after that. This is the end of JsonArray.InitializeList:

// Ensure _jsonElement is written to after _list
_list = list;
Interlocked.MemoryBarrier();
_jsonElement = null;

Readers go the other way: element first, then list. Callers use the element only if the list is still null:

private void GetUnderlyingRepresentation(
    out List<JsonNode?>? list, out JsonElement? jsonElement)
{
    // Because JsonElement cannot be read atomically there might be torn reads,
    // however the order of read/write operations guarantees that that's only
    // possible if the value of _list is non-null.
    jsonElement = _jsonElement;
    Interlocked.MemoryBarrier();
    list = _list;
}

If a reader checked _list first, it could see null, and the writer could then publish the list and clear the element before the reader copied it. The reader would miss both. The comment covers the other risk: JsonElement? is a struct, so the copy takes several memory accesses and can come out torn, with some parts from before the clear and some from after it. That can only happen once the list is published, so the _list read after the fence finds the list, and the reader ignores the element.

Replacing the reader's fence with Volatile.ReadBarrier

The reader only needs the element read to stay before the list read. A full fence also stops earlier writes from moving after it, which the reader doesn't need. .NET 10 added Volatile.ReadBarrier, which keeps earlier reads before later memory accesses. PR #136156 uses it in the reader of both types, and the writer keeps its full fence:

 jsonElement = _jsonElement;
-Interlocked.MemoryBarrier();
+Volatile.ReadBarrier();
 list = _list;

The barrier has to sit between the two reads. Turning the later _list read into an acquire load wouldn't do the same job: an acquire only holds back accesses that come after it, and here we need to keep an earlier read ahead of it. It's the same direction question as in the flag example. An acquire on the element read would point the right way, but that read is a struct copy, and Volatile.Read has no overload for a struct like JsonElement?.

No fence instruction on x64

In the x64 JIT output I posted on the PR, the lock or instruction used for the full barrier is gone. A fence doesn't always turn into a CPU instruction: both the JIT and the hardware must respect its ordering, and what that takes depends on what the architecture already guarantees. x64 hardware keeps more accesses in order than Arm64 does, so on x64 this read barrier needs no fence instruction, and only the JIT has to keep the accesses on the correct side.

The benchmark showed no measurable difference on my machine, though, so there's no speedup number to celebrate here. Tiny PR, quite a lot of reading behind it.

Two rabbit holes for later

Two topics sit right next to this one, and each could fill a post of its own: one on the algorithm side, one on the hardware side.

CAS compares a value, not its history

Our limited counter commits whenever the value still matches what it expected. That's enough for a count that only goes up until it hits the limit. More complex algorithms may base their decision on other state linked to the value they read, and that opens the door to the ABA problem.

CAS only checks that the location holds A at the moment it runs. Another thread can change A to B and back to A while we're paused, and the comparison still succeeds, even though what we assumed when we first read A is no longer true.

Imagine a linked stack whose nodes can be removed and reused. The head points to the top node, and each node points to the one below it. Popping a node means moving the head to the next node:

  1. Thread 1 reads the head node A and remembers its next node, B. It plans to pop A by making B the head.
  2. Thread 2 removes A, removes B, then puts the same node A back with a different next node.
  3. Thread 1 tries to replace head A with the B it remembered. CAS succeeds because the head is A again, even though B is no longer in the stack.

The atomic comparison did exactly what it promised. What went stale was the assumption that B still came after A, and thread 1 has just made a removed node the head again.

One fix is to pair the value with a version counter, so that CAS compares and updates both together. Coming back to A with a different version then fails the comparison. Version wraparound and safe node reuse need care too, so we'll leave that world for another day.

Cache coherency has protocols of its own

Finding out that there are whole protocols behind this was the exciting part for me. MESI, snooping, directories, hybrid approaches... suddenly “the cache” turned into a much bigger world.

The simplified ownership model we used to explain contention sits on top of real hardware messages and state changes. These terms lead into different parts of that machinery:

  • MESI cache coherence: Modified, Exclusive, Shared and Invalid are the states a cache line can be in. They say whether a cached line has been changed, whether other caches may have valid copies, and whether the line can be read or written.
  • Snooping versus directory-based coherence: in a snooping design, caches watch coherence requests and respond when a request concerns them. A directory keeps track of which caches may hold a line, so requests can go only to those caches.
  • Hybrid coherence protocols: some designs combine directory information with snooping to narrow down which caches need to be asked.

MESI describes line states, while snooping and directories describe how caches coordinate, so one design can use both MESI states and a directory. We don't need to follow every message to understand why updating one line again and again makes cores get in each other's way, but there's plenty more down there :D

About the author

  • Hi, I'm Azat. I write code, mostly in C#, and I tend to go down rabbit holes. This blog is where I write down what comes out of them: things I've built, problems I've solved (or haven't yet), useful snippets, and ideas I want to think through properly. Some posts are polished, some are closer to notes. Either way, if something here saves you an hour of searching, it was worth posting.

No comments yet. Be the first to comment.