Knowledge Guide
HomeSystem DesignOS & Kernel Internals

Kernel Internals II — Synchronization, Thundering Herd, Zero-Copy, NUMA & Interrupts (Deep Dive)

Every mechanism on this page answers the same underlying question: what should the kernel do when two things cannot both proceed right now — two CPUs wanting the same cache line, N workers wanting the same connection, a low-priority holder standing between a high-priority waiter and a lock, a userspace buffer and an in-flight async read, a thread and the physical memory it touches. The syscalls/scheduling/paging/epoll/cgroups pages already cover the boundary crossing, the run queue, the page table, and the readiness/completion split. This page picks up the primitives underneath those mechanisms: how the kernel arbitrates contention (spinlock/mutex/futex, memory ordering, RCU), the specific pathologies that show up under load (thundering herd, priority inversion), the paths that avoid paying for data movement at all (zero-copy, NUMA-aware placement), and the two places where "it just works" quietly stops being true (interrupt routing at high packet rates, io_uring's buffer-lifetime and syscall-batching contracts).

Spinlock vs mutex vs futex: what a waiter actually does

Every lock has to decide what a thread does while it cannot get in. The three kernel-level answers trade CPU for latency in opposite directions.

Spinlock. A waiter sits in a tight loop re-testing the lock word (a ticket lock or MCS lock in modern kernels, to avoid cache-line ping-pong and give FIFO fairness) — it never gives up the CPU. Acquiring one on Linux (spin_lock, or spin_lock_irqsave if the lock is also taken from interrupt context) disables preemption on that CPU, and the irqsave variant disables local interrupts too. That is the whole point: a spinlock protects a critical section so short that busy-waiting a few dozen or hundred cycles is cheaper than the ~1–2µs round trip of sleeping and being woken, and it is the only option in a context that is not allowed to sleep — interrupt handlers, code already holding another spinlock, anywhere might_sleep() would fire. The rule that follows directly from this: never call anything that can block (a mutex, an allocation with GFP_KERNEL, I/O) while holding a spinlock — you would freeze every other CPU spinning on it with no way to make progress.

Mutex. A waiter that cannot acquire a mutex is put to sleep: it is marked TASK_UNINTERRUPTIBLE (or _INTERRUPTIBLE), linked onto the mutex’s wait list, and the scheduler picks something else to run. The holder’s mutex_unlock wakes the next waiter. This costs two context switches (park, then wake) instead of spinning, so it only pays off when the critical section might be long enough that burning CPU cycles waiting would cost more than the switch — and it is the only legal choice when the critical section itself might sleep (take another mutex, allocate, do I/O).

Futex — the fast path underneath both. A raw kernel mutex still costs a syscall on every lock/unlock if implemented naively, which is wasteful for the extremely common uncontended case (a thread locks and unlocks a mutex nobody else is touching, millions of times a second). The futex (fast userspace mutex) mechanism is how glibc’s pthread_mutex_lock avoids that: the lock word is a plain integer in user memory, and the uncontended path is a single atomic compare-and-swap done entirely in userspace — zero syscalls. Only when a thread finds the word already held does it call the futex(2) syscall (FUTEX_WAIT) to actually sleep on that address; only the unlocker, and only if it saw the "contended" state, calls FUTEX_WAKE. The classic three-state algorithm (Ulrich Drepper, Futexes Are Tricky):

// 0 = unlocked, 1 = locked/no waiters, 2 = locked/has waiters
void lock(int *f) {
    int c;
    if ((c = cmpxchg(f, 0, 1)) != 0) {       // uncontended: done, 0 syscalls
        do {
            if (c == 2 || cmpxchg(f, 1, 2) != 0)
                futex_wait(f, 2);             // sleep until value changes
        } while ((c = cmpxchg(f, 0, 2)) != 0);
    }
}
void unlock(int *f) {
    if (atomic_dec(f) != 1)                  // was 2: someone is waiting
        { *f = 0; futex_wake(f, 1); }        // only now: a syscall
}

This is also the machinery behind PI-futexes (FUTEX_LOCK_PI): the same syscall-on-contention-only design, but the kernel additionally tracks who holds the lock so it can boost that holder’s priority — the mechanism priority inheritance (below) is actually built from.

Memory barriers: reordering is a performance feature until two CPUs disagree

Both the compiler and the CPU are free to reorder memory operations, as long as the reordering is invisible to a single sequential thread — the compiler to enable optimizations (register allocation, hoisting), the CPU because a store to memory typically goes through a per-core store buffer (so the writing core sees its own store immediately, but other cores may not see it for some cycles) and because out-of-order execution lets independent operations complete before earlier, unrelated ones. Neither compiler nor CPU tracks "does another thread care about this ordering" — you have to tell it.

The failure this causes: thread A writes a payload, then writes a pointer that publishes it (data.x = 5; ready = &data;). Without a barrier, the CPU or compiler may commit the write to ready before the write to data.x is visible to other cores. Thread B, spinning on if (ready) use(ready->x), can then observe a non-NULL ready pointing at a data.x that is still the old/garbage value. A memory barrier (smp_wmb() on the writer between the two stores, matched with smp_rmb() or an acquire-load on the reader before dereferencing) forces the ordering: every store before the barrier is guaranteed visible to any CPU that has observed any load after a matching barrier. Modern code usually expresses this as acquire/release semantics (a release-store on publish, an acquire-load on read) rather than raw fences, but the underlying guarantee is the same fence. This is precisely why "it worked on my x86 dev box" can fail in production on ARM or POWER: x86’s memory model is strong enough (loads/stores mostly stay in program order already) that many missing barriers never manifest there, while ARM/POWER reorder much more aggressively and will expose the bug.

RCU: readers pay nothing, writers publish a copy and wait to reclaim

Read-Copy-Update exists for data that is read constantly and written rarely (routing tables, most lookup structures on a hot path). Its trick: readers never take a lock at all.

Net effect: readers scale linearly with core count and pay essentially zero synchronization cost; the price moves entirely onto the (rarer) writer, who pays a full copy plus a wait for the grace period. That is the opposite trade-off from a spinlock or mutex, which tax every access, reader or writer, equally.

Thundering herd on a shared listening socket

Give N worker threads or processes one shared listening socket, and have each one block waiting for a connection — in accept() directly, or in epoll_wait() on an epoll instance that has that one listen fd registered. The socket’s wait queue historically wakes every waiter when a single connection arrives (classic wake-all semantics on that queue), because the kernel has no way to know which one waiter will actually win the race. All N wake up, all N call accept(), exactly one gets the new connection, and the other N−1 get EAGAIN/EWOULDBLOCK and go back to sleep — N−1 wasted context switches per accepted connection. Adding more workers to handle more load makes this worse, not better: each additional worker is one more wasted wakeup per connection.

The three fixes, and when each applies

Priority inversion and priority inheritance

Priority inversion happens when a high-priority task is blocked waiting on a lock held by a low-priority task, and a third, medium-priority task — which needs nothing from that lock — keeps preempting the low-priority holder, so the low-priority task never gets to finish and release it. The high-priority task ends up effectively blocked by the medium-priority one, with no direct relationship between them. This is the well-documented shape of the 1997 Mars Pathfinder watchdog-reset bug: a high-priority bus-management task, a medium-priority communications task, and a low-priority meteorological data task all sharing a mutex-protected information bus.

StepHigh-pri bus task (H)Med-pri comms task (M)Low-pri met-data task (L)Running
t0not runnable yetnot runnable yetruns, acquires the shared-bus mutexL
t1becomes runnable, immediately needs the mutex → blocksnot runnable yetholds the mutex but is not scheduledH briefly, then blocked
t2blocked on mutex (not runnable)becomes runnable, preempts L (M > L in priority)preempted; still holds the mutexM
t3still blocked — nothing it can dokeeps running; nothing outranks it (H is blocked, not runnable; L is lower)never gets the CPU back to finish and release the mutexM (indefinitely)
t4misses its deadline — watchdog firessystem reset

Without any fix, the scheduler is behaving correctly at every step — always running the highest-priority runnable task — and the inversion still happens, because "runnable" is the wrong lens: H is not blocked by scheduling policy, it is blocked by a lock that M has no way of knowing matters.

Priority inheritance fixes it structurally: the instant H blocks on a lock held by L, L’s priority is temporarily boosted to H’s. Replayed with PI enabled:

t2′blocked on mutexbecomes runnable, but L is now boosted to H’s priority — M no longer outranks Lkeeps running (boosted), finishes, releases the mutex, drops back to its own priorityL (boosted)
t3′unblocks immediately, runsruns after H, as originally intendedback to low priorityH

On Linux this is implemented via rt_mutex / PI-futexes (FUTEX_LOCK_PI/FUTEX_UNLOCK_PI) — the kernel tracks the current owner of a PI-aware lock specifically so it can walk the blocking chain and boost priorities when a higher-priority waiter arrives. glibc exposes this via pthread_mutexattr_setprotocol(attr, PTHREAD_PRIO_INHERIT).

Zero-copy data path: sendfile, splice, MSG_ZEROCOPY, and mmap vs read

The naive way to serve a file over a socket — read(file_fd, buf, n) then write(socket_fd, buf, n) — copies the same bytes twice that don’t need to move through userspace at all: disk → page cache → user buffer (via read) → socket buffer (via write), plus two syscalls and two user/kernel boundary crossings. Zero-copy APIs remove the userspace hop entirely:

Why it matters: for a static file server, CDN edge node, or L7 proxy, throughput at high connection counts is CPU-per-byte bound, not disk- or network-bound — every avoided copy and every avoided context switch is CPU that goes to serving the next request instead. Nginx’s sendfile on, and Kafka relying on sendfile to ship log segments to consumers, are exactly this trade being taken deliberately.

NUMA memory placement: first-touch and the remote-access penalty

On a multi-socket machine, memory is physically attached to a particular socket (node), and a CPU accessing memory on its own node is fast; accessing memory attached to a different node crosses the inter-socket interconnect (QPI/UPI/Infinity Fabric) and costs noticeably more latency, plus consumes shared interconnect bandwidth. Linux’s default placement policy is first-touch: when you mmap or malloc memory, no physical page is allocated yet — it’s just a virtual mapping. A physical page is only allocated, on the node of whichever CPU is running, at the moment some thread writes to (or first faults on) that address. Whoever touches the memory first decides where it physically lives — not whoever called malloc.

The classic failure mode: a single "init" thread allocates a large buffer and zero-initializes all of it before handing out per-node slices to worker threads on other nodes. Because the init thread ran on (say) node 0, every page of that buffer is physically placed on node 0’s memory — regardless of how carefully the buffer is later partitioned by index range. Every worker thread on node 1, 2, 3… now pays the remote-access penalty on every single access, permanently, no matter how well the work itself was sharded. The fix is parallel first-touch: each worker thread initializes only the slice of memory it will subsequently own, so first-touch places each slice’s pages on that worker’s own node. The same problem recurs if the scheduler migrates a thread from node 0 to node 1 without moving its memory — every access that used to be local becomes remote until either the kernel’s automatic NUMA balancing migrates the pages toward the thread over time, or the thread is pinned back to its original node.

numactl is the operational lever: numactl --cpunodebind=0 --membind=0 program pins both CPU and memory allocation to node 0 (best when a workload’s working set fits on one node); numactl --interleave=all spreads pages round-robin across all nodes (useful when access truly is uniform across a large shared structure and you want to avoid any single node becoming a hotspot, at the cost of making every access pay some average remote fraction). In code, numa_alloc_onnode() (libnuma) requests an explicit per-node allocation instead of relying on first-touch timing.

Interrupt architecture: top half, bottom half, and IRQ affinity

A hardware interrupt (NIC packet arrival, disk completion, timer tick) needs an immediate, minimal acknowledgment so the device can keep working, and a much larger amount of actual processing that should not run in the tightly constrained context an interrupt fires in. The kernel splits these into a top half and a bottom half.

The top half is the registered IRQ handler: it typically runs with that IRQ line masked (so the same device can’t re-interrupt mid-handler), cannot sleep, cannot take a mutex, and must be as fast as physically possible — its entire job is to acknowledge the device (so it can raise further interrupts / continue DMA) and schedule the real work for later. Everything else is deferred to a bottom half, of which the kernel has three flavors with different trade-offs:

IRQ affinity and RSS/RPS. A multi-queue NIC hashes incoming packets in hardware across several receive queues (Receive Side Scaling), each with its own MSI-X interrupt line. If every one of those IRQ lines is routed to the same core — the out-of-the-box default on many distributions without irqbalance or manual tuning — that one core becomes the ceiling on the whole box’s packet rate: it alone does all softirq packet processing while every other core sits idle, and the box hits its throughput wall at a fraction of its real CPU/NIC capacity. The fix is to spread each queue’s IRQ across multiple cores via /proc/irq/<N>/smp_affinity (or let irqbalance do it), ideally onto cores on the same NUMA node as the NIC (check /sys/class/net/<dev>/device/numa_node) so the softirq processing itself doesn’t pay a cross-node memory penalty on top. RPS (Receive Packet Steering) is the software fallback when the NIC doesn’t have enough hardware queues: the kernel hashes packets itself, in software, after they arrive on one queue, and steers the softirq work to other cores. Getting IRQ affinity and NUMA locality right together is routine tuning for anything sustaining high packet rates — load balancers, packet capture boxes, high-throughput proxies.

io_uring’s lifetime contract, and the seccomp-bpf tax underneath every syscall

The syscalls/I/O-models page already covers the shared submission-queue/completion-queue (SQ/CQ) mechanics of io_uring. Two sharper contracts sit underneath that mechanism and are exactly where naive ports of blocking-I/O code break.

SQE and buffer lifetime. When you submit a read or write, the SQE references a buffer by pointer, and the operation is asynchronous — submission returns (or batches) immediately, while the actual I/O happens later, interleaved with whatever else is in flight. That buffer must remain valid and unchanged until the matching CQE for that specific operation is observed. A common bug: reusing a small stack buffer for "the next request" as soon as the SQE is submitted, before its CQE has arrived — the kernel may still be reading from or writing into that memory when the new request overwrites it, corrupting data silently (no error is raised; nothing looks wrong until the bytes are wrong). This is a structurally different hazard from a synchronous read(), where the buffer is guaranteed safe to reuse the instant the call returns. The fix is either to track in-flight SQEs and only recycle a buffer after its matching CQE arrives (matched via the user_data tag), size a per-request buffer pool to the maximum number of in-flight ops, or use io_uring_register_buffers() to give the kernel a pool of buffers with a well-defined, kernel-tracked lifetime.

IORING_SQ_NEED_WAKEUP. The "zero syscalls" claim for IORING_SETUP_SQPOLL (a dedicated kernel thread continuously polling the SQ ring so the app never calls io_uring_enter()) holds only while that polling thread stays busy. If submission rate drops below its idle timeout, the polling thread goes to sleep and sets IORING_SQ_NEED_WAKEUP in the shared ring’s flags. The app is responsible for checking that flag before assuming it can skip io_uring_enter(); if it’s set, one real syscall is required to wake the poll thread back up. Code that always assumes zero syscalls, without ever checking the flag, will silently stall under bursty or low-rate submission — the sleeping poll thread never sees the new SQEs.

The seccomp-bpf tax. Virtually every container runtime attaches a seccomp-bpf filter to every process by default (Docker’s default profile, Kubernetes via containerd/runc): it intercepts every syscall the process makes and runs a small BPF program against an allow/deny list before letting it through. The per-syscall overhead is normally small (roughly hundreds of nanoseconds to low microseconds, depending on filter length and whether it’s JIT-compiled) and invisible — until syscall rate is high, at which point it compounds. This is one more concrete argument for batching (io_uring, or any interface that turns N syscalls into one): fewer syscalls means fewer seccomp-bpf evaluations, not just fewer user/kernel boundary crossings, and that tax is present on essentially every container workload whether or not anyone measured it.

Pitfalls

Judgment layer: choosing among the primitives

Spinlock vs mutex vs RCU. Reach for a spinlock when the critical section is a handful of instructions and you may be in a context that cannot sleep — an interrupt handler, or code already holding another spinlock (updating a small shared counter or list head, for instance). Reach for a mutex when the critical section might be longer or might itself need to sleep (allocate, do I/O, take another sleeping lock) and you are in ordinary process context. Reach for RCU when the data is read far more often than it is written and readers can tolerate seeing a version that is correct-but-possibly-one-write-stale within a grace period (routing/config tables, most hot-path lookup structures) — it is the wrong tool when writes are frequent (every write pays a full copy plus a grace-period wait) or when readers and writers must be strictly linearizable.

SO_REUSEPORT vs EPOLLEXCLUSIVE. Use SO_REUSEPORT when your workers are separate processes (a prefork model, à la nginx/haproxy-style workers), each naturally wanting its own independent accept loop — it gives true kernel-level load balancing per socket, per-core/per-NUMA-node ownership of the accept path, and even enables hitless reload. Use EPOLLEXCLUSIVE when your workers are threads sharing one process and, therefore, naturally sharing one epoll instance (or the same fd registered across a few epoll instances) — it is the cheaper fix when re-architecting into N independent listening sockets isn’t worth it, at the cost of coarser load distribution than SO_REUSEPORT’s connection-hash balancing.

Takeaways

Recall question

Two threads run on a shared core: thread X is CPU-bound at normal priority; thread Y is also normal priority but sleeps on I/O for long stretches and runs only briefly on each wakeup. With CFS, Y gets scheduled almost instantly on wakeup despite equal priority — what mechanism gives it that, and separately: if a low-priority thread holds a lock a high-priority thread needs, why does merely raising the high-priority thread’s priority further do nothing to fix the inversion, while boosting the low-priority holder does?

Related pages


Re-authored/Deepened for this guide from: Linux kernel documentation (Documentation/locking/*, Documentation/RCU/*, Documentation/networking/scaling.rst); Ulrich Drepper, “Futexes Are Tricky”; Paul E. McKenney, “Is Parallel Programming Hard, And, If So, What Can You Do About It?” (RCU chapters); the Mars Pathfinder priority-inversion incident as documented by Glenn Reeves/Mike Jones and widely cited in real-time systems literature; Jens Axboe’s io_uring design notes and liburing documentation; Michael Kerrisk, The Linux Programming Interface (sendfile/splice/mmap chapters); Brendan Gregg, Systems Performance (NUMA and IRQ/RSS tuning chapters); LWN.net coverage of EPOLLEXCLUSIVE, SO_REUSEPORT, and MSG_ZEROCOPY.

🤖 Don't fully get this? Learn it with Claude

Stuck on Kernel Internals II — Synchronization, Thundering Herd, Zero-Copy, NUMA & Interrupts (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Kernel Internals II — Synchronization, Thundering Herd, Zero-Copy, NUMA & Interrupts (Deep Dive)** (System Design) and want to truly understand it. Explain Kernel Internals II — Synchronization, Thundering Herd, Zero-Copy, NUMA & Interrupts (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Kernel Internals II — Synchronization, Thundering Herd, Zero-Copy, NUMA & Interrupts (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Kernel Internals II — Synchronization, Thundering Herd, Zero-Copy, NUMA & Interrupts (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Kernel Internals II — Synchronization, Thundering Herd, Zero-Copy, NUMA & Interrupts (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes