One semaphore counter fits in four bytes. The library in this article requires the full semaphore to occupy two cache lines, 128 bytes on a target with 64-byte lines, and fails the build if that layout changes.
// two hot counters that two cores write, forced onto separate lines
struct Padded {
alignas(64) std::atomic<uint64_t> a; // core 0's counter, its own line
alignas(64) std::atomic<uint64_t> b; // core 1's flag, its own line
};
static_assert(offsetof(Padded, b) == 64, "b must sit on its own cache line");
The padding separates two groups of writers so they stop invalidating the same cache line. The smaller semaphore has the same API and passes the same functional tests, but loses a double-digit percentage of throughput under contention.
The semaphore and event measurements below come from cross-platform game-runtime primitives. The ring measurement comes from Astral in May 2026.
The cost of a cache line
Cores exchange memory in cache lines, usually 64 bytes. Before a core writes to a line, it must hold that line exclusively. Taking ownership invalidates every other core's copy. The next access from another core can then cost many times more than a hit in its own cache.
Reads are the cheap direction. Several cores can hold read-only copies of the same line at once without invalidating one another. Those reads can still pay for the first fetch, eviction, migration, and movement through the cache hierarchy. A writer adds coherence traffic because it must obtain exclusive ownership and invalidate the other copies.
The cost belongs to the line, not the variable. Put a counter and an unrelated flag in the same 64 bytes. One core increments the counter while another polls the flag. Each write to the counter invalidates the polling core's copy, even though that core never reads the counter. The line can move back and forth on every iteration.
This is false sharing. The source-level operations are independent, but the layout is not. A profiler reports the loads that stalled, not the struct definition that put the fields together.
The direct fix is to align each field to its own line. A four-byte flag then occupies 64 bytes, but it no longer incurs a cross-core miss on every poll.
To choose a layout, list who writes each line and which other cores touch it. The cache misses often appear in code that never mentions the field causing them.
A semaphore in two halves
A semaphore has a counter that acquire decrements and release increments. Acquire may block, and release may wake a blocked thread.
Under contention, every acquirer modifies the same counter and needs its cache line exclusively. The line moves between cores. That traffic is required because all acquirers genuinely share the counter.
Release also needs waiter bookkeeping. If that state shares the counter's line, the release path invalidates every acquirer's copy while updating data those acquirers never read. Their next attempt starts with another cross-core miss.
So the library splits the semaphore's counter from its waiter and wake bookkeeping. Acquire and release both modify the counter. The second line is touched only when the operation has to enter the waiting protocol. The split does not remove the contention required by the counter. It keeps slow-path wake bookkeeping from sharing that counter's line.
An uncontended release still modifies the same counter as acquire. Only the sleeping and waking state moves to the second line.
Under contention, the split improved throughput by a double-digit percentage in this benchmark. It removed the extra sharing from waiter bookkeeping. Contention on the counter remained.
I compiled the uncontended acquire and release on both test machines. Acquire is one atomic decrement. Release is one atomic add. Neither reads the wake word, and the operand places the counter 64 bytes into the struct on its own line.
; x86-64 (clang-18 -O2), Intel Core Ultra 7 265
sem_acquire: mov eax, -1
lock xadd dword ptr [rdi + 64], eax ; counter is +64, on its own line
test eax, eax
setg al ; got a token iff previous count > 0
sem_release: lock xadd dword ptr [rdi + 64], eax ; release: the SAME lock xadd
; arm64 (Apple M5 Max, LSE): the ordering is one letter of the opcode
sem_acquire: ldadda w9, w8, [x8] ; atomic add, ACQUIRE (the 'a')
sem_release: ldaddl w1, w8, [x8] ; atomic add, RELEASE (the 'l')
On x86, both operations use lock xadd. The locked instruction already provides the requested ordering. On ARM, the ordering appears in the opcode: ldadda for acquire and ldaddl for release. Both fast paths perform one atomic operation on the counter. They touch the wake word only when a thread must sleep.
Making the layout fail the build
The split works only while the fields stay on separate lines. A later refactor can add or reorder members and move both hot words onto the same line. The semaphore still counts correctly and functional tests still pass. Only throughput regresses.
Compile-time assertions pin the struct to exactly two cache lines and check each hot field's byte offset. Size alone is insufficient because fields can move while the total remains 128 bytes. Any layout drift is a compile error.
The destructor checks a separate rule. Destroying a semaphore while a thread waits on it causes a use-after-free when that waiter wakes. The destructor reads the wait state and asserts that no waiter remains, so the failure occurs at destruction.
Destruction requires external quiescence: no thread may begin or continue a wait once destruction starts. Under that lifecycle rule, the destructor checks the final waiter state. A seq_cst read cannot make destruction safe if waiter creation is still racing with it.
The unlock that skips the kernel
Putting a thread to sleep or waking it requires a system call and scheduler work. That costs far more than a cache miss, so the uncontended path must stay out of the kernel.
The place this bites is unlock. This lock is optimized for its intended common case: acquisition finds it free, and unlock has nobody to wake. A lock word with only two states, locked and unlocked, leaves unlock guessing about that case. It can skip the wake and risk a lost wakeup. It can wake every time and pay a syscall when no waiter exists. Or it can keep waiter state somewhere else, which creates another coordination problem.
Uncontended acquire and release each need one atomic operation. A contender marks the word as locked with waiters before sleeping, so release enters the kernel only when that state was present.
The arriving waiter pays for the third state just before it sleeps. The fast path remains one swap to acquire and one swap to release.
Unlock learns whether anyone is waiting from the word it already writes. The common case is one atomic operation and no kernel call.
I compiled both operations on both machines to check the description against the instructions. Acquire is one compare-exchange from unlocked to locked. Release is one exchange back to unlocked, then a test of the value it displaced.
; x86-64 (clang-18 -O2), Intel Core Ultra 7 265
lock_acquire: mov ecx, 1
xor eax, eax ; expected = unlocked (0)
lock cmpxchg dword ptr [rdi], ecx ; one CAS: unlocked -> locked
sete al
lock_release: xor eax, eax
xchg dword ptr [rdi], eax ; one exchange back to unlocked
cmp eax, 2 ; was it locked-with-waiters?
sete al ; wake a thread only then
; arm64 (Apple M5 Max, LSE)
lock_acquire: casa w8, w9, [x0] ; compare-and-swap, acquire: unlocked -> locked
cmp w8, #0
lock_release: swpl wzr, w8, [x0] ; swap-release: store unlocked (wzr), old -> w8
cmp w8, #2 ; locked-with-waiters?
The final compare controls the futex wake. It runs only when the displaced value is 2, meaning locked with waiters. Otherwise release returns after the single xchg or swpl.
An event in one word
The semaphore separates fields that different threads update. The event goes the other way because the kernel must re-check the complete sleep decision in one atomic comparison. Threads sleep until someone sets it, and a broadcast wakes them all. Two flag bits and a 30-bit generation counter therefore share one 32-bit word.
The source layout uses the high two bits for state and the low thirty bits for the generation. A set that observes waiting threads increments that low field and publishes the new generation with the set state in one compare-exchange. When nobody is waiting, set changes only the state and skips both the generation increment and the kernel notification.
// one 32-bit word: high 2 bits = state, low 30 bits = generation
constexpr uint32_t SET = 1u << 30;
constexpr uint32_t RESET = 2u << 30;
constexpr uint32_t GEN_MASK = (1u << 30) - 1;
uint32_t generation(uint32_t w) { return w & GEN_MASK; }
// waiting path: advance the generation and set the event together
uint32_t next_set_state(uint32_t current) {
return ((generation(current) + 1) & GEN_MASK) | SET;
}
A waiter reads the state, finds the event clear, and prepares to sleep. A broadcast can occur between that read and the sleep. Without another check, the waiter could sleep immediately after its wakeup. Wait-on-address avoids this by sleeping only if the word still matches the value the waiter read. If a broadcast changed it, the comparison fails and the waiter checks again.
The layout follows from the wait protocol. The state used to decide whether this wait should sleep must fit in the word the kernel compares. State elsewhere may exist, but it cannot participate in that decision unless another synchronization step covers it. Two flags and a 30-bit generation counter share 32 bits so this decision is captured and re-checked in one atomic snapshot.
The flag bits alone are insufficient. Set and then reset the event, and they return to the value a waiter may have read before the cycle. The generation changes during that cycle, so the full word no longer matches the stale snapshot.
The counter is finite and wraps after 230 generation changes. The argument assumes that no waiter can retain one snapshot across a complete wrap. The code enforces that lifetime separately from the bit layout.
Suppose a waiter reads clear at generation five. A broadcast changes the word before the sleep begins. The kernel compares the stale snapshot with the live word, refuses to sleep, and makes the waiter recheck. It then sees the set flag and returns.
With no waiters, set and reset need one atomic write and no kernel call. The generation advances only when waiters need to observe the transition.
Broadcasting to 8 waiters took about 4.2 microseconds for the futex-based version, 8.1 microseconds for the standard condition variable, and roughly 42 microseconds for a semaphore-based fallback used where no native wait-on-address facility exists. The comparison includes each implementation's mutex work and wake behavior, so it measures the complete broadcast rather than one isolated kernel operation.
The comparison measures a broadcast with a full set of waiters, when kernel work is highest. The one-word design still ran in about half the time of the condition variable. The no-waiter case does not appear because it is only one atomic write with no kernel call.
Both layouts are pinned by compile-time assertions. The semaphore must stay at exactly two lines, while the event state must remain in the one word passed to the kernel.
The same layout in a ring
Astral's single-producer single-consumer ring applies the same split: each cursor has one writer and its own cache line. Each side also keeps a stale local copy of the other cursor so the common path does not read across cores. That post contains the safety argument, assembly, and measurements.
The memory cost
The cost is mostly memory. Two cache lines per semaphore is 128 bytes on a target with 64-byte lines, for state a textbook fits in four bytes. A thousand semaphores uses 128 KB, most of it padding. The double-digit gain appeared under contention. An uncontended semaphore gains nothing from the separation, so callers have to measure whether the extra memory is worthwhile.
The asserts also fail on intended changes. Cache-line size is a per-target constant, so a port with a different line size stays broken until someone re-derives the size and offsets for that target. Every port pays that review cost.