Kernel Spinlocks
A spinlock is the Linux kernel’s primitive for mutual exclusion over a critical section that is short and runs in a context that must not sleep. A CPU that wants the lock and finds it held does not block and yield the processor; it busy-waits — it “spins” in a tight loop reading the lock word until the holder releases it. Because the waiter burns a whole CPU while spinning, spinlocks are only correct and only efficient when the hold time is tiny (a handful of cache-line accesses, never an I/O wait or a memory allocation that can reclaim). The defining constraint is captured in one iron rule: you must never sleep while holding a spinlock. On a non-
PREEMPT_RTkernel, taking aspinlock_talso disables kernel preemption on the holding CPU, so the holder cannot be scheduled out mid-critical-section. The generic type isspinlock_t, manipulated throughspin_lock(),spin_unlock(), andspin_trylock()(per the kernel’s own locking documentation).
This note covers the contract and API of spinlocks: what they guarantee, why the no-sleep rule exists, which of the four acquire variants you actually need in a given context, and how spinlock_t differs from raw_spinlock_t. It also reads the real lock word, because a description of a Linux spinlock that stops at “a bit you test and set” is a staleness bug, not a simplification — since v4.2 the thing under spin_lock() on every major SMP architecture has been a queued spinlock with an embedded MCS wait queue, and its structure is what explains both its performance and its API. The deeper machinery of that queue is in Queued Spinlocks and qspinlock; the full treatment of the interrupt- and bottom-half-disabling variants is in Spinlock irqsave and bh Variants; the preemption counter that makes the no-sleep guarantee enforceable is in Preemption Disabling and preempt_count. The memory-ordering vocabulary this note leans on — acquire, release, and why they are not a full barrier — belongs to Acquire Release and Fence Semantics, and the hardware reason a contended lock is expensive at all belongs to Cache Coherence and the Store Buffer; both are cited here rather than re-derived.
Version pin
Every source line, macro value, and configuration default in this note was read from the v6.12 tag of the mainline tree on 2026-09-04. v6.12 is a maintained long-term-support (LTS) release, not the newest kernel — mainline is on the 7.x series by now, so any claim below is “true as of v6.12” and should be re-checked against a newer tag before being treated as current. v6.12 is a deliberate choice for this topic for one reason beyond its LTS status: it is the release in which
PREEMPT_RTbecame selectable on mainline x86-64, which changes whatspin_lock()means (see thespinlock_tversusraw_spinlock_tsection below).
Mental Model
The right way to think about a spinlock is as a doorway that one CPU can occupy at a time, where everyone else queues at the threshold without sitting down. Contrast it with a mutex, which is a doorway with a waiting room: if the door is occupied, you take a seat (the scheduler puts your task to sleep and runs someone else) and a bell wakes you when it’s your turn. The spinlock has no waiting room. If the door is occupied you stand at the threshold tapping your foot — consuming a CPU — until it opens. That is only sensible if the person inside will be out in microseconds. If they might go take a nap inside (sleep), everyone queued outside is stuck burning fuel indefinitely, and on a single-CPU machine the napper can never even be woken because the only CPU is busy spinning.
flowchart TB A["CPU wants spinlock"] --> B{"lock word == 0<br/>(free)?"} B -->|"yes"| C["atomic cmpxchg<br/>0 -> locked<br/>(fast path)"] C --> D["preemption disabled<br/>on this CPU<br/>enter critical section"] B -->|"no (held)"| E["busy-wait / spin<br/>(slow path:<br/>queued, see qspinlock)"] E --> B D --> F["spin_unlock():<br/>store-release lock = 0<br/>preemption re-enabled"] F --> G["next waiter proceeds"]
The spinlock lifecycle. What it shows: acquisition first tries an atomic compare-and-swap on the lock word (the uncontended fast path); failure drops into a busy-wait loop. Crucially, between acquire and release, preemption is disabled on the holding CPU — the holder runs to completion of the critical section and cannot be scheduled away. The insight to take: the spin is pure wasted CPU, so the entire design only pays off when the critical section is short enough that spinning is cheaper than the context-switch cost a sleeping lock would incur. The moment a holder could sleep, this model collapses: the spinning waiters would burn CPU for the holder’s entire sleep duration.
How the Lock Got Here: Test-and-Set, Then Tickets, Then a Queue
The word “spinlock” describes a behaviour (busy-wait until free), not an algorithm, and Linux has shipped three materially different algorithms under that name. Knowing which one you are actually running matters, because each generation fixed a defect that its predecessor’s mental model still carries.
Generation one — test-and-set. Through Linux 2.6.24 an x86 spinlock was a single integer, 1 meaning free. spin_lock() atomically decremented it and checked the sign: a non-negative result meant you had won, a negative result meant somebody else held it and you spun until the value went positive again, then retried (LWN, Ticket spinlocks, Feb 2008). It is about as cheap as mutual exclusion gets in the uncontended case, and it has a diagnostic charm — the more negative the word, the more CPUs are fighting over it. Its defect is unfairness: when the lock is released, whichever CPU manages to decrement first wins, and the CPU that just released it has an unfair advantage because it already owns that cache line exclusively. Nick Piggin measured the consequence on an 8-core (2-socket) Opteron: “a difference of up to 2x runtime per thread, and some threads are starved or ‘unfairly’ granted the lock up to 1 000 000 (!) times.”
Generation two — ticket locks. Merged for 2.6.25 in 2008, a ticket lock splits the lock word into two counters, next and owner, exactly like the paper-ticket dispenser at a deli counter: spin_lock() atomically reads the word and increments next, then spins until owner reaches the ticket it drew; spin_unlock() just increments owner. Arrival order is now service order, so the starvation and the latency tail both vanish. The initial patch used one byte per counter, capping the machine at 256 CPUs; a companion “big ticket” patch widened them to 16 bits each for a 65,536-CPU ceiling. What ticket locks do not fix is the memory traffic: every waiter is spinning on owner, which lives in the same cache line as the lock, so each release invalidates that line in every waiting CPU’s cache and every waiter re-fetches it. With n waiters, one handoff costs O(n) coherence transactions. This is cacheline bouncing, and it is the thing that makes contended locks scale badly rather than merely slowly — see Cache Coherence and the Store Buffer for why an exclusive-state transfer is expensive in the first place.
Generation three — queued spinlocks (qspinlock). Landed in v4.2, based on Waiman Long’s implementation with substantial rework by Peter Zijlstra (the copyright block in kernel/locking/qspinlock.c credits Hewlett-Packard 2013–2015, Red Hat, and Intel). The dating is verifiable by existence check: kernel/locking/qspinlock.c returns HTTP 404 at tags v3.15 through v4.1 and HTTP 200 at v4.2, and arch/x86/Kconfig first contains select ARCH_USE_QUEUED_SPINLOCKS at v4.2. It is still what x86 selects at v6.12. The core idea is the MCS lock of Mellor-Crummey and Scott: instead of every waiter spinning on the shared lock word, each waiter enqueues a small node of its own and spins on a flag inside that node, which lives in its own CPU’s cache and is written exactly once, by its immediate predecessor. Handoff cost drops from O(n) coherence transactions to O(1).
timeline title Linux x86 spinlock implementations, and what each generation fixed 2.6.24 and earlier : "Test-and-set: lock is an int, 1 = free" : "spin_lock() atomically decrements; negative result means held" : "Defect: unfair — up to 2x runtime skew measured on an 8-core Opteron" 2.6.25 (2008) : "Ticket lock: word split into next / owner counters" : "Take a ticket, spin until now-serving matches it" : "Fixes fairness. Every waiter still spins on the SAME cache line" 4.2 (2015) : "qspinlock: 32-bit word plus per-CPU MCS queue nodes" : "Only the queue head spins on the lock word itself" : "Fixes cacheline bouncing AND keeps the 4-byte size" 6.12 LTS (2024) : "Algorithm unchanged; PREEMPT_RT becomes selectable on x86-64" : "spinlock_t may now be a sleeping rt_mutex instead of a spinner"
The three spinlock algorithms Linux has shipped on x86, and the specific defect each one removed. What it shows: every generation kept the previous one’s API (spin_lock/spin_unlock never changed) while replacing the algorithm underneath, and each move traded implementation complexity for a scaling property — first fairness, then coherence traffic. The insight to take: a description of a Linux spinlock as “a bit you test and set” has been wrong since 2008 and doubly wrong since 2015. The reason qspinlock exists is not raw speed on one CPU — it is that the ticket lock’s contended handoff cost grows with the number of waiters, and qspinlock’s does not.
| Property | Test-and-set (≤2.6.24) | Ticket lock (2.6.25–4.1) | qspinlock (4.2 → v6.12) |
|---|---|---|---|
| Lock word size | 4 bytes | 2 or 4 bytes | 4 bytes (_Q_TAIL_CPU_BITS caps CONFIG_NR_CPUS) |
| Uncontended acquire | 1 atomic RMW | 1 atomic RMW | 1 atomic cmpxchg |
| Uncontended release | plain store | atomic increment of owner | plain smp_store_release of one byte |
| FIFO fair? | no | yes | yes |
| Waiters spin on | the shared lock word | the shared lock word | their own per-CPU MCS node (except the queue head) |
| Coherence traffic per handoff, n waiters | O(n) | O(n) | O(1) |
| Extra state | none | none | per-CPU array of 4 struct qnode |
Comparing the three implementations on the properties that actually differ. What it shows: qspinlock is not merely “the fast one” — it is the only one whose contended handoff cost is independent of the number of waiters, and, less obviously, it is also cheaper to release than a ticket lock, because a ticket unlock needs a read-modify-write on owner while a qspinlock unlock is a single byte store. The insight to take: the uncontended-path win is small and universal; the contended-path win is large and only shows up on big machines. That is why LWN’s coverage of the merge reported most workloads moving 1–2% while an AIM7 disk workload — dominated by VFS and ext4 lock contention — moved by as much as 116% (LWN, MCS locks and qspinlocks, Mar 2014).
Uncertain
Verify: the “+116% on AIM7 disk” and “1–2% on other workloads” figures. Reason: these come from LWN’s March 2014 write-up summarising Waiman Long’s posted benchmark set, which predates the pending-bit optimisation and was measured on 2013-era hardware against a ticket-lock baseline; the numbers were never re-run for v6.12 and the original posting is on
lore.kernel.org, which is behind an Anubis proof-of-work challenge and could not be fetched during this task (plaincurlHTTP 403; a browserUser-Agentreturns a JS bot-check page). To resolve: locate the[PATCH] qspinlockcover letter through a mirror that is fetchable, or re-benchmark on current hardware. uncertain
The 32-Bit Lock Word, Field by Field
The constraint that shapes qspinlock is size. A textbook MCS lock is a tail pointer (8 bytes) plus a next pointer in each node, and spinlock_t is embedded in structures — struct page is the canonical example — that cannot afford to grow. The header comment in kernel/locking/qspinlock.c (v6.12) states the compression trick outright: “where the traditional MCS lock consists of a tail pointer (8 bytes) and needs the next pointer (another 8 bytes) of its own node to unlock the next pending (next→locked), we compress both these: {tail, next→locked} into a single u32 value.”
Two observations make the compression work. First, a tail can be identified by CPU number plus a small index rather than by a pointer, because the queue nodes are statically allocated per-CPU. Second, the index needs only two bits: “Since a spinlock disables recursion of its own context and there is a limit to the contexts that can nest; namely: task, softirq, hardirq, nmi. As there are at most 4 nesting levels, it can be encoded by a 2-bit number.” So each CPU owns a four-element array, static DEFINE_PER_CPU_ALIGNED(struct qnode, qnodes[MAX_NODES]) with #define MAX_NODES 4, sized so that on a 64-bit machine “[e]xactly fits one 64-byte cacheline.”
packet-beta 0-7: "locked byte (0 or _Q_LOCKED_VAL = 1)" 8-15: "pending (full byte when CONFIG_NR_CPUS < 16384)" 16-17: "tail index: nesting depth 0-3 into this CPU qnode array" 18-31: "tail cpu + 1 (0 means no tail)"
The qspinlock word on a machine with CONFIG_NR_CPUS < 16384, drawn from _Q_LOCKED_OFFSET/_Q_PENDING_OFFSET/_Q_TAIL_IDX_OFFSET/_Q_TAIL_CPU_OFFSET in include/asm-generic/qspinlock_types.h (v6.12). What it shows: all four pieces of state — is it held, is there a first waiter, which of that CPU’s four queue nodes is at the tail, and on which CPU — live in one 32-bit word that can be manipulated with a single atomic operation. The insight to take: “the lock is only one bit” is true of the semantics and false of the word. Note the deliberate waste: only bit 0 of the locked byte is ever set, but the field is a full byte “to achieve better performance for architectures that support atomic byte write” — which is exactly what makes queued_spin_unlock() a plain one-byte smp_store_release instead of a read-modify-write. The pending field is likewise widened to a byte, “[b]y using the whole 2nd least significant byte for the pending bit, we can allow better optimization of the lock acquisition for the pending bit holder” — with locked and pending adjacent bytes, the clear_pending_set_locked() transition is a single 16-bit store to locked_pending.
packet-beta 0-7: "locked byte" 8: "pending (single bit)" 9-10: "tail index" 11-31: "tail cpu + 1 (21 bits)"
The same word when CONFIG_NR_CPUS >= 16384. What it shows: the pending field collapses from a byte to a single bit so the tail-CPU field can grow from 14 to 21 bits, raising the CPU ceiling from 16,383 to 2,097,151. The insight to take: the layout is not fixed — it is chosen at build time by #if CONFIG_NR_CPUS < (1U << 14), and _Q_TAIL_CPU_BITS is computed as (32 - _Q_TAIL_CPU_OFFSET) so the word is always exactly filled. queued_spin_lock_slowpath() opens with BUILD_BUG_ON(CONFIG_NR_CPUS >= (1U << _Q_TAIL_CPU_BITS)), so a configuration that would overflow the field fails to compile rather than corrupting the queue at runtime.
The two byte-level views coexist with an integer view through an anonymous union, which is how the code can treat the same four bytes as an atomic_t for the compare-and-swaps and as a plain u8 locked for the release store:
typedef struct qspinlock {
union {
atomic_t val;
#ifdef __LITTLE_ENDIAN
struct { u8 locked; u8 pending; };
struct { u16 locked_pending; u16 tail; };
#else
struct { u16 tail; u16 locked_pending; };
struct { u8 reserved[2]; u8 pending; u8 locked; };
#endif
};
} arch_spinlock_t;The #ifdef __LITTLE_ENDIAN split is not cosmetic: the bit numbering in _Q_LOCKED_OFFSET and friends is defined on the integer value, so on a big-endian machine the byte that holds bit 0 sits at the high address and the struct members must be reordered to name the same physical bytes. Getting this wrong would put the release store on the wrong byte.
Three Stages: Fast Path, Pending Bit, and the MCS Queue
The whole algorithm is a graded response to how much contention actually exists, and it is worth internalising as three stages rather than as “fast path and slow path.” The kernel’s own state chart, reproduced verbatim in the comment above queued_spin_lock_slowpath(), uses the triple (queue tail, pending bit, lock value) for the word’s state — (0,0,1) means “no queue, no pending waiter, held.”
Stage 1 — the uncontended fast path. queued_spin_lock() in include/asm-generic/qspinlock.h is the entire happy path:
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
int val = 0;
if (likely(atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL)))
return;
queued_spin_lock_slowpath(lock, val);
}One atomic compare-and-swap against zero, with acquire ordering, and you are done: (0,0,0) -> (0,0,1). No queue node is touched, no per-CPU data is read, no cache line beyond the lock itself is brought in. This is the case that runs essentially always — the point of the elaborate machinery below is that it costs nothing when it is not needed. val is passed by reference, so on failure it carries the observed word into the slow path, saving a re-read. The mirror-image release is equally minimal:
static __always_inline void queued_spin_unlock(struct qspinlock *lock)
{
smp_store_release(&lock->locked, 0);
}A single store of one byte with release ordering. Not an atomic. This asymmetry is fundamental: acquiring must win a race against other CPUs and therefore needs a read-modify-write, while releasing only has to publish a byte that the next waiter is already watching.
Stage 2 — the pending bit. If the compare-and-swap failed but the word shows only locked set (no queue, no existing pending waiter), you are the first contender. Building an MCS queue for a single waiter would be pure overhead — you would touch a cold per-CPU cache line, publish a tail, and then have to tear it all down. Instead the slow path sets the pending bit and spins directly on the lock byte:
val = queued_fetch_set_pending_acquire(lock); /* 0,0,* -> 0,1,* */
if (unlikely(val & ~_Q_LOCKED_MASK)) { /* raced with someone */
if (!(val & _Q_PENDING_MASK))
clear_pending(lock); /* undo, then queue */
goto queue;
}
if (val & _Q_LOCKED_MASK)
smp_cond_load_acquire(&lock->locked, !VAL); /* 0,1,1 -> *,1,0 */
clear_pending_set_locked(lock); /* 0,1,0 -> 0,0,1 */The comment on the smp_cond_load_acquire is the memory-model subtlety worth reading twice: “this wait loop must be a load-acquire such that we match the store-release that clears the locked bit and create lock sequentiality; this is because not all clear_pending_set_locked() implementations imply full barriers.” On x86 queued_fetch_set_pending_acquire() is a LOCK btsl on _Q_PENDING_OFFSET — a locked bit-test-and-set — chosen because “x86 cannot (cheaply) do” the generic atomic_fetch_or_acquire().
There is one more guard in front of all of this. If the caller arrives and observes the word to be exactly _Q_PENDING_VAL — pending set but locked clear, i.e. a handover is in flight — it waits a bounded number of iterations for that handover to complete rather than piling on:
if (val == _Q_PENDING_VAL) {
int cnt = _Q_PENDING_LOOPS;
val = atomic_cond_read_relaxed(&lock->val,
(VAL != _Q_PENDING_VAL) || !cnt--);
}_Q_PENDING_LOOPS defaults to 1 in the generic code and is overridden to (1 << 9) — 512 iterations — in arch/x86/include/asm/qspinlock.h. The bound is not an optimisation but a liveness requirement: “We don’t spin indefinitely because there’s no guarantee that we’ll make forward progress.”
Stage 3 — the MCS queue. Any further contention goes to queue:. The CPU grabs qnodes[idx] for its current nesting context, encodes (cpu + 1, idx) into a tail value, and publishes it with old = xchg_tail(lock, tail). If old already held a tail, that CPU is the predecessor, so this node links itself in with WRITE_ONCE(prev->next, node) and then spins on its own node->locked via arch_mcs_spin_lock_contended(). Only the CPU at the head of the queue spins on the lock word itself, with atomic_cond_read_acquire(&lock->val, !(VAL & _Q_LOCKED_PENDING_MASK)). That split is the entire scalability argument: n queued waiters generate n private spin loops and exactly one shared one.
Three details in this path are easy to miss and each is defensive. The +1 in encode_tail exists because “[w]e must be able to distinguish between no-tail and the tail at 0:0” — CPU 0 with index 0 would otherwise encode as an all-zero tail, indistinguishable from “queue empty.” A smp_wmb() sits between initialising the node and publishing the tail, so a predecessor can never observe a half-built node. And if all four nesting slots are somehow in use, the code does not corrupt anything — it degenerates to a plain test-and-set loop: if (unlikely(idx >= MAX_NODES)) { while (!queued_spin_trylock(lock)) cpu_relax(); }, described in the source as “not the most elegant solution, but is simple enough.”
stateDiagram-v2 direction LR [*] --> Free Free: (0,0,0)<br/>free Locked: (0,0,1)<br/>held, no waiters Pending: (0,1,1)<br/>held + 1 pending waiter Handover: (0,1,0)<br/>released, pending claims it Queued: (n,x,y)<br/>MCS queue, tail = CPU n QHead: (n,0,0)<br/>queue head, lock free Free --> Locked: STAGE 1<br/>atomic_try_cmpxchg_acquire(0 -> 1) Locked --> Pending: STAGE 2<br/>queued_fetch_set_pending_acquire() Pending --> Handover: holder does<br/>smp_store_release(locked, 0) Handover --> Locked: clear_pending_set_locked()<br/>one 16-bit store Locked --> Queued: STAGE 3<br/>xchg_tail(), link MCS node Pending --> Queued: any later contender Queued --> QHead: predecessor sets<br/>node->locked (private line) QHead --> Locked: cmpxchg tail -> 0<br/>or set_locked() Locked --> Free: smp_store_release(&lock->locked, 0)
The qspinlock state machine, using the kernel’s own (queue tail, pending bit, lock value) notation from the comment above queued_spin_lock_slowpath() in v6.12. What it shows: three escalating regimes on one 32-bit word — a single atomic when nobody else wants the lock, a bit flip and a local spin when exactly one other CPU wants it, and a full MCS queue only when three or more are involved. The insight to take: the pending bit is not a micro-optimisation, it is what keeps the two-CPU case — overwhelmingly the most common contended case in practice — from paying the cost of touching a cold per-CPU cache line and publishing a tail. Note also that the machine never returns to (0,0,0) through the queue path directly: the queue head either compare-and-swaps the tail away when it is the last waiter, or calls set_locked() and leaves the tail in place for whoever is behind it.
sequenceDiagram autonumber participant W as 32-bit lock word participant C0 as CPU 0 (first) participant C1 as CPU 1 (pending) participant C2 as CPU 2 (queued) Note over W: (tail, pending, locked) = (0,0,0) C0->>W: atomic_try_cmpxchg_acquire(0 -> _Q_LOCKED_VAL) Note over W: (0,0,1) — ONE atomic, total cost of the common case C1->>W: cmpxchg fails, so it enters queued_spin_lock_slowpath C1->>W: queued_fetch_set_pending_acquire() (x86: LOCK btsl) Note over W: (0,1,1) C1-->>W: smp_cond_load_acquire(&lock->locked, !VAL) C2->>W: reads val, sees val & ~_Q_LOCKED_MASK -> skip pending, queue C2->>W: xchg_tail(encode_tail(2, idx)) Note over W: (cpu2,1,1) C2-->>C2: spins on its OWN per-CPU node->locked — no shared line C0->>W: smp_store_release(&lock->locked, 0) Note over W: (cpu2,1,0) C1->>W: clear_pending_set_locked() — one 16-bit store Note over W: (cpu2,0,1) — CPU 1 holds it C1->>W: smp_store_release(&lock->locked, 0) C2->>W: atomic_cond_read_acquire sees locked|pending clear C2->>W: (val & _Q_TAIL_MASK) == tail, so cmpxchg -> _Q_LOCKED_VAL Note over W: (0,0,1) — CPU 2 holds it, queue empty again
Three CPUs contending for one qspinlock, traced against the actual v6.12 code path. What it shows: each CPU takes a different route through the same word — CPU 0 never leaves the fast path, CPU 1 uses the pending bit and spins on the shared lock byte, CPU 2 publishes a tail and spins on private per-CPU memory. The insight to take: count the accesses to the shared cache line. CPU 2 touches it three times total (publish tail, poll at the head, claim) regardless of how many CPUs are queued behind it, because everyone behind CPU 2 is spinning on their own node. Under a ticket lock, every one of those CPUs would be polling this one line, and every release would invalidate it in all of their caches — that difference is the whole reason the algorithm was changed.
Where this note stops and the sibling begins
The above is the shape of the algorithm and the layout of its word, which you need in order to reason about
spin_lock()’s cost and its API. The queue-node lifecycle in full —grab_mcs_node, theprefetchwof the successor,pv_wait_node/pv_kick_nodeparavirtualisation for guests whose vCPUs can be descheduled mid-spin, and thevirt_spin_lock()fallback that reverts to plain test-and-set inside a VM because “fair locks have horrible lock ‘holder’ preemption issues” — belongs to Queued Spinlocks and qspinlock.
The Iron Rule: You Cannot Sleep While Holding a Spinlock
This is the single most important fact about spinlocks, and it is not a stylistic preference — it is a correctness requirement that the kernel actively enforces in debug builds. Two independent reasons make it absolute.
Reason one: deadlock by self-starvation. Suppose a task holds a spinlock and then calls something that sleeps — kmalloc(GFP_KERNEL) (which may block on memory reclaim), mutex_lock(), copy_to_user() (which may fault and wait on disk), or msleep(). The scheduler picks another task on that CPU. If that other task tries to take the same spinlock, it spins. But the original holder is asleep and will only release the lock when it is scheduled back in — and it can only be scheduled back in if the spinning task yields the CPU, which it never will, because a spinning task does not yield. On a uniprocessor (CONFIG_SMP=n) build this is an instant hard hang of that CPU. On SMP it is a latent deadlock that fires whenever the contending task lands on the holder’s CPU.
Reason two: preemption is disabled, so sleeping is already forbidden. Taking a spinlock_t calls preempt_disable() as part of the acquire path. The implementation is explicit: in include/linux/spinlock_api_smp.h, __raw_spin_lock() begins with preempt_disable(); before it ever touches the lock word (spinlock_api_smp.h, v6.12). Sleeping (calling schedule() to yield) inside a preempt_disable() region is itself illegal: the scheduler’s __schedule() path and the might_sleep() debug check assume preemption is enabled when a voluntary sleep happens. A CONFIG_DEBUG_ATOMIC_SLEEP kernel catches a sleep inside any spinlock with a loud BUG: sleeping function called from invalid context splat that names the offending file and line.
sequenceDiagram autonumber participant S as Scheduler (1 CPU) participant A as Task A (holder) participant B as Task B participant L as spinlock L A->>L: spin_lock(&L) — succeeds, preempt_disable() Note over A,L: A owns L. preempt_count > 0 on this CPU. A->>A: kmalloc(size, GFP_KERNEL) Note over A: reclaim needed, so the allocator blocks A->>S: schedule() — A goes to sleep still holding L S->>B: context switch to Task B B->>L: spin_lock(&L) L-->>B: held, so busy-wait loop forever B->>L: read lock word, cpu_relax() end Note over S: B never yields. A is runnable but never scheduled.<br/>A cannot release L. Hard hang of this CPU.
The uniprocessor deadlock that the no-sleep rule exists to prevent. What it shows: sleeping while holding a spinlock breaks the mutual dependency that a spinlock silently assumes — that the holder will keep running. Task A cannot release the lock until it is scheduled, and it cannot be scheduled because Task B is spinning and a spinning task never yields. The insight to take: this is not a probabilistic race that shows up under load — on a single-CPU system it is a guaranteed hang the moment the two tasks meet. On SMP the same deadlock is latent, waiting for the contending task to be placed on the holder’s CPU, which is exactly the kind of bug that passes testing and fires in production. Note that step 1 is what makes step 5 illegal in the first place: preempt_disable() in the acquire path means the sleep in step 4 is already a rule violation before the deadlock has a chance to form.
The practical corollary, stated in the kernel’s lock-types documentation: “Sleeping lock types cannot nest inside CPU local and spinning lock types.” You may take a spinlock while holding a mutex, but never a mutex while holding a spinlock — the ordering is one-directional. Any allocation inside a spinlock must use GFP_ATOMIC (which never reclaims and never sleeps); any user-memory copy must be hoisted outside the locked region.
Reading the v6.12 Documentation/locking/locktypes.rst directly rather than the rendered latest-docs page, the rule generalises into a strict three-level hierarchy that lockdep enforces:
flowchart TB subgraph L1["1. Sleeping locks — LD_WAIT_SLEEP"] A["mutex · rt_mutex · semaphore<br/>rw_semaphore · ww_mutex<br/>percpu_rw_semaphore"] end subgraph L2["2. spinlock_t · rwlock_t · local_lock — LD_WAIT_CONFIG"] B["spinning on !PREEMPT_RT<br/>SLEEPING (rt_mutex) on PREEMPT_RT"] end subgraph L3["3. raw_spinlock_t · bit spinlocks — LD_WAIT_SPIN"] C["always a true busy-wait spinner,<br/>in every kernel configuration"] end L1 -->|"may nest inside"| L2 L2 -->|"may nest inside"| L3 L3 -.->|"FORBIDDEN:<br/>a sleeping or spinlock_t lock<br/>inside a raw_spinlock_t"| L1 N["Rule: any lock may be taken<br/>while holding a lock from a<br/>LOWER-numbered level.<br/>Never the reverse."]
The lock-category nesting hierarchy from Documentation/locking/locktypes.rst (v6.12), annotated with the lockdep wait-type constants from include/linux/lockdep_types.h. What it shows: the three categories form a total order — you may acquire a level-3 lock while holding a level-1 or level-2 lock, but never the reverse. The insight to take: this ordering exists because of PREEMPT_RT, not in spite of it. On a non-RT kernel levels 2 and 3 are the same thing (LD_WAIT_CONFIG is literally #defined to LD_WAIT_SPIN), so the distinction looks pedantic; on an RT kernel level 2 can sleep, which makes taking a spinlock_t inside a raw_spinlock_t a real deadlock. Writing code that respects the hierarchy on a non-RT kernel is what makes it portable to an RT one.
The practical consequences follow mechanically. Any allocation inside a spinlock must use GFP_ATOMIC, which never reclaims and never sleeps; any user-memory copy must be hoisted outside the locked region. And the raw_spinlock_t case carries a trap that catches people who assume GFP_ATOMIC is a universal escape hatch — locktypes.rst spells it out with paired examples:
raw_spin_lock(&lock);
p = kmalloc(sizeof(*p), GFP_ATOMIC); /* WORKS on !RT, FAILS on PREEMPT_RT */
spin_lock(&lock);
p = kmalloc(sizeof(*p), GFP_ATOMIC); /* fine on both */The reason is that on PREEMPT_RT “the memory allocator is fully preemptible and therefore cannot be invoked from truly atomic contexts,” and raw_spin_lock() still creates a truly atomic context there while spin_lock() no longer does. This is a concrete example of the general point: raw_spinlock_t is a stronger constraint on what you may do inside the critical section, not merely a faster lock.
The API Surface
The generic, preemption-disabling lock is spinlock_t. You define one statically with DEFINE_SPINLOCK(my_lock) or initialize an embedded one at runtime with spin_lock_init(&obj->lock). The core operations, all declared in include/linux/spinlock.h, are:
spinlock_t my_lock = __SPIN_LOCK_UNLOCKED(my_lock); /* or DEFINE_SPINLOCK(my_lock) */
spin_lock(&my_lock); /* acquire; spins if held; disables preemption */
/* ... short critical section, no sleeping ... */
spin_unlock(&my_lock); /* release; store-release; re-enables preemption */
if (spin_trylock(&my_lock)) { /* non-blocking attempt */
/* got it */
spin_unlock(&my_lock);
} else {
/* lock was held; do something else, do NOT spin */
}Walking the call chain shows there is almost no overhead in the common case. On a non-PREEMPT_RT SMP kernel, spin_lock() is a static __always_inline wrapper that forwards to raw_spin_lock(&lock->rlock) (spinlock.h, v6.12). That expands through _raw_spin_lock() to __raw_spin_lock(), which does exactly three things: preempt_disable(), a lockdep annotation (spin_acquire(), compiled out unless CONFIG_DEBUG_LOCK_ALLOC is on), and LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock). do_raw_spin_lock() finally calls arch_spin_lock(&lock->raw_lock), which on every modern SMP architecture is queued_spin_lock() — the qspinlock fast path, a single atomic_try_cmpxchg_acquire() (asm-generic/qspinlock.h, v6.12). So an uncontended acquire is: disable preemption, one atomic compare-and-swap with acquire ordering, done.
flowchart TB A["spin_lock(&lock)<br/><i>include/linux/spinlock.h</i>"] --> B["raw_spin_lock(&lock->rlock)"] B --> C["_raw_spin_lock()"] C --> D["__raw_spin_lock()<br/><i>spinlock_api_smp.h</i>"] D --> D1["preempt_disable()"] D --> D2["spin_acquire(&lock->dep_map, ...)<br/>lockdep hook — compiled out<br/>unless CONFIG_DEBUG_LOCK_ALLOC"] D --> D3["LOCK_CONTENDED(lock,<br/>do_raw_spin_trylock,<br/>do_raw_spin_lock)"] D3 --> E["do_raw_spin_lock()"] E --> E1["arch_spin_lock(&lock->raw_lock)"] E --> E2["mmiowb_spin_lock()"] E1 --> F["queued_spin_lock()<br/><i>asm-generic/qspinlock.h</i>"] F --> G{"atomic_try_cmpxchg_acquire<br/>(&lock->val, &0, _Q_LOCKED_VAL)"} G -->|"success — the whole cost"| H["HELD"] G -->|"fail"| I["queued_spin_lock_slowpath(lock, val)<br/>pending bit, then MCS queue"] I --> H
The full call chain from spin_lock() down to the atomic, on a CONFIG_SMP, non-PREEMPT_RT, non-debug x86 kernel. What it shows: every box above queued_spin_lock() is a static __always_inline wrapper that the compiler collapses; the lockdep hook and the LOCK_CONTENDED contention accounting vanish entirely unless CONFIG_DEBUG_LOCK_ALLOC / CONFIG_LOCK_STAT are enabled. The insight to take: the shipped fast path is exactly two operations — an increment of preempt_count and one LOCK cmpxchg — and everything else in the chain is either a naming layer or a debug feature you compiled out. This is also why spin_lock() shows up in profiles attributed to its caller rather than to itself. mmiowb_spin_lock() is a no-op on x86 and a real barrier on architectures where MMIO writes can be reordered across a lock release.
The LOCK_CONTENDED(lock, try, lock) macro is worth naming because it is the seam through which measurement happens: with CONFIG_LOCK_STAT off it expands to a bare call to do_raw_spin_lock, and with it on it first attempts the trylock, and if that fails records a contention event, takes the lock, and records the acquisition — which is what fills in /proc/lock_stat.
spin_unlock() mirrors this: it calls arch_spin_unlock() → queued_spin_unlock(), which is smp_store_release(&lock->locked, 0) — a plain store with release ordering, not an expensive atomic — followed by preempt_enable(). The asymmetry is deliberate: acquiring a lock needs an atomic read-modify-write to win a race against other CPUs, but releasing it only needs to publish a single byte that the next waiter is already watching.
spin_trylock() forwards to raw_spin_trylock() → do_raw_spin_trylock() → arch_spin_trylock(), i.e. queued_spin_trylock(): it reads the lock word, and if it is zero, attempts the cmpxchg; if the word is non-zero (held or contended) it returns 0 immediately without spinning. spin_trylock() is what you reach for when you can do useful work elsewhere rather than wait, or to acquire locks out of your normal order without risking a deadlock (try the second lock; if it fails, drop the first and restart).
Read-side query helpers exist too: spin_is_locked(&lock) (is anyone holding it — racy, for assertions only), spin_is_contended(&lock) (is someone waiting behind the holder), and assert_spin_locked(&lock) (a debug assertion that the current path holds the lock). These do not acquire anything; they only inspect the word.
Scope-Based Acquisition with guard()
Since the linux/cleanup.h infrastructure arrived, v6.12 also offers scope-bound spinlock acquisition built on the compiler’s __attribute__((cleanup)). include/linux/spinlock.h declares a guard class for every variant:
DEFINE_LOCK_GUARD_1(spinlock, spinlock_t,
spin_lock(_T->lock),
spin_unlock(_T->lock))
DEFINE_LOCK_GUARD_1_COND(spinlock, _try, spin_trylock(_T->lock))
DEFINE_LOCK_GUARD_1(spinlock_irqsave, spinlock_t,
spin_lock_irqsave(_T->lock, _T->flags),
spin_unlock_irqrestore(_T->lock, _T->flags),
unsigned long flags)which lets a function be written as:
static int frob(struct dev *d)
{
guard(spinlock_irqsave)(&d->lock); /* released at end of scope */
if (d->broken)
return -EIO; /* early return still unlocks */
d->count++;
return 0;
}The flags word is stored inside the guard object rather than in a caller-declared local, which removes the most common mechanical bug in _irqsave code — declaring flags in one scope and restoring it in another. scoped_guard(spinlock, &d->lock) { ... } bounds the critical section to a block rather than the whole function. This is a readability and correctness aid, not a performance one: the generated code is the same lock/unlock pair. It does not change any of the rules below — a guard(spinlock) region is still a no-sleeping region.
Choosing the Variant: spin_lock vs _bh vs _irq vs _irqsave
This is where the real bugs live. The plain spin_lock() closes exactly one race: two CPUs entering the critical section at once. It does not close the race between a critical section and an interrupt on the same CPU that takes the same lock — and that race is a guaranteed self-deadlock, not a rare one, because the interrupted CPU cannot make progress while the interrupt handler is spinning on a lock the interrupted code holds. Linus states the mechanism plainly in Documentation/locking/spinlocks.rst (v6.12): “an interrupt tries to lock an already locked variable. This is ok if the other interrupt happens on another CPU, but it is not ok if the interrupt happens on the same CPU that already holds the lock, because the lock will obviously never be released.”
sequenceDiagram autonumber participant P as Process context (CPU 3) participant L as spinlock L participant H as IRQ handler (CPU 3) P->>L: spin_lock(&L) — plain variant, IRQs still enabled Note over P,L: P holds L. preempt_count > 0, but IF flag is still set. Note over H: device raises an interrupt on CPU 3 P->>H: CPU traps into the handler mid-critical-section H->>L: spin_lock(&L) L-->>H: already held, so busy-wait loop forever H->>L: read lock word, cpu_relax() end Note over P: P cannot resume until the handler returns.<br/>The handler cannot return until P releases L.<br/>CPU 3 is dead. Other CPUs are unaffected — which is<br/>why this reproduces only sometimes.
The interrupt self-deadlock that spin_lock_irqsave() exists to prevent, on a lock that an interrupt handler also takes. What it shows: the deadlock is entirely CPU-local — no second CPU is involved — and it is created by the interrupt arriving, not by any concurrency between CPUs. The insight to take: preempt_disable() does not save you here, because a hardware interrupt is not a preemption; it is a trap that runs regardless of preempt_count. Only masking the interrupt on the local CPU closes the window, which is precisely what the _irq and _irqsave variants add. And note the failure mode: it needs the interrupt to land inside a critical section that is typically nanoseconds long, on the specific CPU holding the lock — so it can survive months of testing before firing in production.
The four variants are the four answers to “what else, on this CPU, could take this lock while I hold it?” Their implementations in include/linux/spinlock_api_smp.h (v6.12) are almost self-documenting — each is the same lockdep-plus-LOCK_CONTENDED body with a different prologue:
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{ preempt_disable(); spin_acquire(...); LOCK_CONTENDED(...); }
static inline void __raw_spin_lock_bh(raw_spinlock_t *lock)
{ __local_bh_disable_ip(_RET_IP_, SOFTIRQ_LOCK_OFFSET); spin_acquire(...); LOCK_CONTENDED(...); }
static inline void __raw_spin_lock_irq(raw_spinlock_t *lock)
{ local_irq_disable(); preempt_disable(); spin_acquire(...); LOCK_CONTENDED(...); }
static inline unsigned long __raw_spin_lock_irqsave(raw_spinlock_t *lock)
{ unsigned long flags; local_irq_save(flags); preempt_disable(); spin_acquire(...); LOCK_CONTENDED(...); return flags; }Note that _bh does not call preempt_disable() separately — __local_bh_disable_ip(..., SOFTIRQ_LOCK_OFFSET) adds to the same preempt_count word in a way that disables both softirqs and preemption at once. See Preemption Disabling and preempt_count for how the one counter encodes all of these.
flowchart TB Q1{"Can any HARDWARE interrupt handler<br/>on this CPU take this same lock?"} Q1 -->|"yes"| Q2{"Might this code itself run<br/>with interrupts already disabled?<br/>(i.e. is it callable from<br/>an IRQ handler or from another<br/>_irqsave region?)"} Q2 -->|"yes, or you are not sure"| IRQSAVE["spin_lock_irqsave(&l, flags)<br/>spin_unlock_irqrestore(&l, flags)<br/><b>the always-correct answer</b>"] Q2 -->|"no — always called with IRQs on"| IRQ["spin_lock_irq(&l)<br/>spin_unlock_irq(&l)<br/>marginally cheaper: no flags word"] Q1 -->|"no"| Q3{"Can a SOFTIRQ, tasklet, or<br/>timer callback take this lock?"} Q3 -->|"yes"| BH["spin_lock_bh(&l)<br/>spin_unlock_bh(&l)"] Q3 -->|"no"| Q4{"Are you already inside a softirq,<br/>tasklet, timer, or IRQ handler?"} Q4 -->|"yes"| PLAIN["spin_lock(&l)<br/>the context is already closed"] Q4 -->|"no — pure process context"| Q5{"Might the critical section<br/>need to sleep? (GFP_KERNEL,<br/>copy_to_user, I/O)"} Q5 -->|"yes"| MUTEX["Not a spinlock at all —<br/>use a mutex"] Q5 -->|"no"| PLAIN
The decision tree for picking a spinlock variant, derived from Documentation/kernel-hacking/locking.rst and Documentation/locking/spinlocks.rst (v6.12). What it shows: the question is never “how fast is this variant” but “which local execution context could re-enter this lock.” The insight to take: every branch that leads away from spin_lock_irqsave is an optimisation you must earn by proving a negative about your lock’s callers. Linus’s own guidance is to start from the safe end: “The above is always safe… you can potentially use cheaper versions of the spinlocks. IFF you know that the spinlocks are never used in interrupt handlers.” Pete Zaitcev’s cheat sheet in the kernel-hacking guide compresses the whole tree to two lines: process context and want to lock other processes out, use a mutex; “[o]therwise (== data can be touched in an interrupt), use spin_lock_irqsave() and spin_unlock_irqrestore().”
The kernel ships the exhaustive version of that decision as a matrix. Documentation/kernel-hacking/locking.rst (v6.12) gives the minimum primitive needed to protect data shared between each pair of contexts:
| Shared between ↓ / ↓ | IRQ handler A | IRQ handler B | Softirq A | Softirq B | Tasklet A | Tasklet B | Timer A | Timer B | User ctx A | User ctx B |
|---|---|---|---|---|---|---|---|---|---|---|
| IRQ handler A | none | |||||||||
| IRQ handler B | SLIS | none | ||||||||
| Softirq A | SLI | SLI | SL | |||||||
| Softirq B | SLI | SLI | SL | SL | ||||||
| Tasklet A | SLI | SLI | SL | SL | none | |||||
| Tasklet B | SLI | SLI | SL | SL | SL | none | ||||
| Timer A | SLI | SLI | SL | SL | SL | SL | none | |||
| Timer B | SLI | SLI | SL | SL | SL | SL | SL | none | ||
| User ctx A | SLI | SLI | SLBH | SLBH | SLBH | SLBH | SLBH | SLBH | none | |
| User ctx B | SLI | SLI | SLBH | SLBH | SLBH | SLBH | SLBH | SLBH | MLI | none |
Legend: SLIS = spin_lock_irqsave, SLI = spin_lock_irq, SL = spin_lock, SLBH = spin_lock_bh, MLI = mutex_lock_interruptible. (Table of Minimum Requirements, Documentation/kernel-hacking/locking.rst, v6.12.)
The authoritative “which primitive do I need” matrix. What it shows: the required strength is determined entirely by the pair of contexts sharing the data, and it is monotone — the further from process context either side is, the more you must disable. Read a cell as “the minimum that is correct,” never as “the only thing that works”: spin_lock_irqsave is a superset of every other cell and is never wrong, only sometimes wasteful. The insight to take: three cells deserve a second look. Two hard IRQ handlers need spin_lock_irqsave rather than _irq because “it is architecture-specific whether all interrupts are disabled inside irq handlers themselves.” An IRQ handler sharing with a softirq needs only plain spin_lock() on the handler side — “the softirq cannot run while the irq handler is running” — while the softirq side needs _irq. And the diagonal “same tasklet” cell is none, because a tasklet never runs on two CPUs at once; that is a property of tasklets, not of locking.
Two further points that the matrix does not show. First, _bh is a historical name: “The ‘_bh’ suffix is a historical reference to ‘Bottom Halves’, the old name for software interrupts. It should really be called spin_lock_softirq() in a perfect world.” Second, spin_lock_irq() also stops softirqs, “[n]ote that softirqs (and hence tasklets and timers) are run on return from hardware interrupts” — so _irq/_irqsave subsume _bh, which is why the matrix never needs a combined variant. The full treatment of these variants, including the local_irq_save flags dance and why spin_unlock_irq() inside a handler is a bug, is in Spinlock irqsave and bh Variants.
The Memory-Ordering Guarantee
A spinlock is not only mutual exclusion — it is also a memory barrier. The acquire uses acquire semantics (atomic_try_cmpxchg_acquire) and the release uses release semantics (smp_store_release). This means writes inside the critical section by the holder are guaranteed visible to the next CPU that acquires the same lock, in program order. The header comment in spinlock.h works through the subtle cases — notably that spin_unlock() followed by spin_lock() on the same lock provides an ordering strong enough that a third CPU observing the second lock’s effects also observes the first’s. The one trap the kernel calls out: a lock acquire is an ACQUIRE and a release is a RELEASE, but the two together do not form a full barrier; code that needs store-after-unlock to be ordered against load-after-lock must reach for smp_mb__after_unlock_lock(). See Memory Barriers in the Linux Kernel and Acquire Release and Fence Semantics for the full model; Compare-and-Swap and cmpxchg in the Kernel for the atomic the fast path rests on.
flowchart TB subgraph OUT1["before the lock"] A["load / store X"] end ACQ["spin_lock() — ACQUIRE<br/>atomic_try_cmpxchg_acquire"] subgraph CS["critical section"] B["load / store Y"] end REL["spin_unlock() — RELEASE<br/>smp_store_release(&lock->locked, 0)"] subgraph OUT2["after the unlock"] C["load / store Z"] end OUT1 --> ACQ --> CS --> REL --> OUT2 ACQ -.->|"BLOCKED: an access inside<br/>cannot move out above"| OUT1 REL -.->|"BLOCKED: an access inside<br/>cannot move out below"| OUT2 OUT1 -->|"ALLOWED: X may sink<br/>into the critical section"| CS OUT2 -->|"ALLOWED: Z may rise<br/>into the critical section"| CS
What a lock’s ACQUIRE and RELEASE actually order — often called the “roach motel” model: accesses check in, they don’t check out. What it shows: the two one-way barriers keep the critical section’s own accesses inside it, but they explicitly permit outside accesses to migrate in from either direction. The insight to take: this is why an ACQUIRE followed by a RELEASE is not equivalent to a full barrier — a store before the lock and a load after the unlock may still be reordered with respect to each other, because neither one-way barrier forbids it. That is the exact hole smp_mb__after_spinlock() plugs. The full semantics, including the difference between RCpc and RCsc and why architectures differ here, belong to Acquire Release and Fence Semantics; the store-buffer hardware that makes reordering observable in the first place belongs to Cache Coherence and the Store Buffer.
The header comment in include/linux/spinlock.h (v6.12) makes the guarantee precise with two litmus tests. The first defines what smp_mb__after_spinlock() buys you:
{ X = 0; Y = 0; }
CPU0 CPU1
WRITE_ONCE(X, 1); WRITE_ONCE(Y, 1);
spin_lock(S); smp_mb();
smp_mb__after_spinlock(); r1 = READ_ONCE(X);
r0 = READ_ONCE(Y);
spin_unlock(S);
“[I]t is forbidden that CPU0 does not observe CPU1’s store to Y (r0 = 0) and CPU1 does not observe CPU0’s store to X (r1 = 0)” — i.e. the classic store-buffering outcome is ruled out. Without the smp_mb__after_spinlock(), it is not: the ACQUIRE alone does not stop CPU0’s earlier store to X from being buffered past its later load of Y. The second litmus test in the same comment shows the three-CPU case and concludes: “Property (2) upgrades the lock to an RCsc lock.” Both of these are real call sites, not hypotheticals — the comment points at __schedule() and try_to_wake_up(), where the scheduler needs exactly this strengthening to reason about task state across a wakeup.
On x86 and on other total-store-order machines smp_mb__after_spinlock() is free — “all our TSO architectures imply an smp_mb() for each atomic instruction and equally don’t need more” — and its generic definition is kcsan_mb(), which is an annotation for the Kernel Concurrency Sanitizer (KCSAN) data-race detector rather than a machine instruction. It is only on architectures “that can implement ACQUIRE better” that it costs anything. This is a recurring trap in kernel memory-ordering work: a missing barrier is invisible on the machine most developers test on.
One more ordering fact specific to qspinlock is worth knowing because it constrains which architectures may use it at all. The header of include/asm-generic/qspinlock.h warns that “qspinlock relies on atomic_*_release()/atomic_*_acquire() to be RCsc (or no weaker than RCtso if you’re power), where regular code only expects atomic_t to be RCpc,” and that it “heavily relies on mixed size atomic operations, in specific it requires architectures to have xchg16.” The file’s own advice to a porter is to “first consider ticket-lock.h and only come looking here when you’ve considered all the constraints below and can show your hardware does actually perform better with qspinlock.” So qspinlock is not universal even at v6.12 — it is what the big SMP architectures select, and a generic ticket lock remains the fallback.
spinlock_t versus raw_spinlock_t — The PREEMPT_RT Split
This distinction looks pedantic on a normal kernel and becomes load-bearing on a real-time one. On a standard (!CONFIG_PREEMPT_RT) kernel the two are nearly identical: include/linux/spinlock_types.h literally defines spinlock_t as a wrapper around a raw_spinlock_t — “Non PREEMPT_RT kernels map spinlock to raw_spinlock” (spinlock_types.h, v6.12). Both spin; both disable preemption; the only practical difference is debug/lockdep wait-type annotation (LD_WAIT_CONFIG for spinlock_t, LD_WAIT_SPIN for raw_spinlock_t).
On a PREEMPT_RT kernel the semantics diverge sharply, and this is the whole point of the type distinction. Under PREEMPT_RT, spinlock_t is redefined entirely — it becomes a struct rt_mutex_base, i.e. a sleeping, priority-inheriting mutex: “PREEMPT_RT kernels map spinlock to rt_mutex.” A spin_lock() on RT calls rt_spin_lock(), which can block the calling task and let the scheduler run something else; preemption is not disabled (spinlock_rt.h, v6.12; locktypes doc). This is how PREEMPT_RT achieves bounded latency: almost all spinlocks stop disabling preemption, so a high-priority task can preempt a low-priority lock holder, and priority inheritance prevents the classic priority-inversion stall.
raw_spinlock_t, by contrast, stays a true busy-wait spinning lock in every configuration, including PREEMPT_RT: “raw_spinlock_t is a strict spinning lock implementation in all kernels.” It always disables preemption (and, with _irqsave, interrupts). It is reserved for the genuinely atomic core of the kernel — the scheduler’s own run-queue lock, the low-level timer code, the parts of interrupt entry/exit and the architecture code that cannot tolerate being preempted or made to sleep even on RT. Converting a hot path from spinlock_t to raw_spinlock_t is not an optimization; it is a contract that the critical section is genuinely non-preemptible and you accept the RT latency cost. See Raw Spinlocks and PREEMPT_RT for the full treatment.
This stopped being a hypothetical in exactly the release this note is pinned to. CONFIG_PREEMPT_RT first became selectable on mainline x86-64 in v6.12, verified by existence check on arch/*/Kconfig: the string ARCH_SUPPORTS_RT — which config PREEMPT_RT depends on, per kernel/Kconfig.preempt — is absent from arch/x86/Kconfig, arch/arm64/Kconfig, and arch/riscv/Kconfig at tag v6.11 and present in all three at v6.12. Before v6.12 you needed the out-of-tree -rt patch set to get an RT kernel on x86; from v6.12 a plain mainline menuconfig offers it (gated behind EXPERT). The practical consequence is that “spinlocks always spin” moved from almost always true in practice to a build-time question about the kernel you are running on, and the folklore built on the old answer is now unsafe.
flowchart TB SRC["Source code, unchanged:<br/>spin_lock(&my_lock);<br/>...<br/>spin_unlock(&my_lock);"] SRC --> K{"CONFIG_PREEMPT_RT ?"} K -->|"n"| N1["spinlock_t = struct raw_spinlock<br/><i>spinlock_types.h</i>"] N1 --> N2["preempt_disable()"] N2 --> N3["queued_spin_lock() —<br/>BUSY-WAIT, never sleeps"] N3 --> N4["holder is non-preemptible<br/>latency = holder's critical section"] K -->|"y"| R1["spinlock_t = struct rt_mutex_base<br/><i>spinlock_types.h</i>"] R1 --> R2["rt_spin_lock() —<br/>preemption stays ENABLED"] R2 --> R3{"free?"} R3 -->|"yes"| R4["take it, record owner"] R3 -->|"no"| R5["BLOCK on the rt_mutex,<br/>boost the holder's priority<br/>(priority inheritance)"] R5 --> R6["scheduler runs something else"] R6 --> R4 R4 --> R7["holder IS preemptible<br/>latency bounded by PI, not by<br/>the critical section's length"]
The same two lines of driver code compiled two ways. What it shows: CONFIG_PREEMPT_RT does not change the API, the type name, or the call site — it changes what spinlock_t is, from a qspinlock word to an rt_mutex_base, and therefore whether spin_lock() can block. The insight to take: the right-hand path is why RT can promise bounded latency. On the left, a high-priority task that becomes runnable while a low-priority task holds a spinlock must wait for the entire critical section, because preemption is off. On the right it preempts the holder immediately, and priority inheritance then boosts the holder so it finishes and releases quickly rather than being starved. The cost is that spin_lock() is now a scheduler operation, which is exactly why the genuinely atomic core of the kernel must use raw_spinlock_t instead.
What actually changes on RT
The most surprising item is the interrupt suffixes. include/linux/spinlock_rt.h (v6.12) defines them like this:
static __always_inline void spin_lock_irq(spinlock_t *lock)
{
rt_spin_lock(lock);
}
#define spin_lock_irqsave(lock, flags) \
do { \
typecheck(unsigned long, flags); \
flags = 0; \
spin_lock(lock); \
} while (0)spin_lock_irqsave() on a PREEMPT_RT kernel sets your flags word to zero and does not touch the interrupt-enable state at all. spin_unlock_irqrestore() correspondingly ignores flags and just calls rt_spin_unlock(). locktypes.rst states the rule directly: “The hard interrupt related suffixes for spin_lock / spin_unlock operations (_irq, _irqsave / _irqrestore) do not affect the CPU’s interrupt disabled state.” The _bh suffix is the exception that survives — “[t]he soft interrupt related suffix (_bh()) still disables softirq handlers,” implemented on RT with a per-CPU lock rather than by disabling preemption. Any code that took spin_lock_irqsave() in order to disable interrupts, rather than in order to close a race with an IRQ handler over that lock, is silently broken on RT. This is the single most important piece of pre-RT folklore to unlearn.
Two other semantics are deliberately preserved, and knowing which are preserved is as important as knowing which change. First, a task holding a spinlock_t still cannot migrate: “Non-PREEMPT_RT kernels avoid migration by disabling preemption. PREEMPT_RT kernels instead disable migration, which ensures that pointers to per-CPU variables remain valid even if the task is preempted.” Second, task state survives acquisition. On RT a task blocking on a spinlock_t must be set to TASK_UNINTERRUPTIBLE, which would ordinarily destroy an in-progress TASK_INTERRUPTIBLE sleep, so the RT implementation saves and restores it:
task->state = TASK_INTERRUPTIBLE
lock()
block()
task->saved_state = task->state
task->state = TASK_UNINTERRUPTIBLE
schedule()
lock wakeup
task->state = task->saved_state
and a non-lock wakeup arriving during that window writes task->saved_state = TASK_RUNNING instead, “[t]his ensures that the real wakeup cannot be lost.”
!PREEMPT_RT | PREEMPT_RT | |
|---|---|---|
spinlock_t underlying type | struct raw_spinlock (a qspinlock) | struct rt_mutex_base |
spin_lock() can sleep? | no | yes — blocks on an rt_mutex |
spin_lock() disables preemption? | yes | no |
| Priority inheritance? | no | yes |
spin_lock_irq() / _irqsave() mask IRQs? | yes | no — flags is set to 0 |
spin_lock_bh() blocks softirqs? | yes (via preempt_count) | yes (via a per-CPU lock) |
| Task may migrate while holding? | no (preemption off) | no (migrate_disable()) |
raw_spinlock_t behaviour | spins, preemption off | identical — spins, preemption off |
kmalloc(GFP_ATOMIC) inside raw_spin_lock? | allowed | forbidden — allocator is preemptible |
| lockdep wait type | LD_WAIT_CONFIG == LD_WAIT_SPIN | LD_WAIT_CONFIG distinct from LD_WAIT_SPIN |
What PREEMPT_RT changes about spinlock_t, from Documentation/locking/locktypes.rst, include/linux/spinlock_types.h, and include/linux/spinlock_rt.h at v6.12. What it shows: the type name and the API are identical across the two configurations while the semantics differ on almost every row that matters. The insight to take: the last four rows are the ones that turn working code into broken code. Everything in the raw_spinlock_t row is unchanged, which is exactly why the two types must be distinct: raw_spinlock_t is the escape hatch that says “I really do mean a busy-wait, non-preemptible section,” and it costs RT latency every time it is used, so it is rationed.
The one documented exception to the blanket conversion is bit spinlocks: “PREEMPT_RT cannot substitute bit spinlocks because a single bit is too small to accommodate an RT-mutex. Therefore, the semantics of bit spinlocks are preserved on PREEMPT_RT kernels, so that the raw_spinlock_t caveats also apply to bit spinlocks.” Where a bit spinlock’s atomicity is unacceptable on RT, the fix is a conditional change at the usage site — #ifdef’d code that swaps in a real spinlock_t — in contrast to the spinlock_t substitution itself, which “conditionals in header files and the core locking implementation enable the compiler to do… transparently.”
Failure Modes and How They Present
Sleeping in atomic context. The most common bug. You call mutex_lock(), kmalloc(GFP_KERNEL), copy_from_user(), vmalloc(), msleep(), or any function that might sleep, while holding a spinlock. On a CONFIG_DEBUG_ATOMIC_SLEEP=y kernel the symptom is a BUG: sleeping function called from invalid context at <file>:<line> followed by a stack trace and the value of preempt_count. On a production kernel without that debug option, the symptom is far worse and rarer to reproduce: an intermittent hang or a soft-lockup warning when the contending task happens to spin against a sleeping holder.
Double-acquire / recursion. Spinlocks are not recursive. If a function holds a spinlock and calls (directly or via an interrupt) another function that takes the same lock, the second acquire spins forever on a lock its own CPU already holds — an instant self-deadlock. This is exactly why the irqsave/bh variants exist: if an interrupt handler takes lock L, then any process-context code that takes L must disable that interrupt first (spin_lock_irqsave), or the interrupt can fire mid-critical-section and self-deadlock. That whole family is in Spinlock irqsave and bh Variants.
Holding too long. Even when correct, a spinlock held across a long computation hurts: every contending CPU is burning cycles, and on a !PREEMPT_RT kernel preemption is off so even unrelated high-priority work on the holder’s CPU is stalled. spin_needbreak() exists for loops that legitimately must hold a lock across many iterations — it lets the holder voluntarily drop and re-take the lock at a safe point if a higher-priority task is waiting.
Forgetting _irqsave against an IRQ handler. Symptom: rare deadlocks under load that lockdep flags as an inconsistent {IN-HARDIRQ-W} -> {HARDIRQ-ON-W} usage. The fix is the irqsave variant; see lockdep Runtime Lock Validator.
How lockdep Catches These Before They Deadlock
None of the failure modes above are meant to be found by staring at code. CONFIG_PROVE_LOCKING — the runtime lock validator, lockdep — is what catches them, and it does so on the first occurrence of a dangerous pattern rather than on the first actual deadlock. Its model, from Documentation/locking/lockdep-design.rst (v6.12), is worth stating because it explains why lockdep finds bugs that never fired: lockdep operates on lock classes, not lock instances. “A class of locks is a group of locks that are logically the same with respect to locking rules, even if the locks may have multiple (possibly tens of thousands of) instantiations. For example a lock in the inode struct is one class, while each inode has its own instantiation of that lock class.” So the ordering L1 -> L2 observed once on any pair of inodes becomes a permanent recorded fact about the class, and the reverse order observed later on completely different inodes is reported as a deadlock — even though those two acquisitions could never have raced.
For a spinlock the most valuable thing lockdep tracks is IRQ usage state. It records, per class, whether the lock was ever taken in hardirq context, ever taken in softirq context, and whether it was ever taken with those interrupts enabled, and it enforces mutual exclusion between those facts. The four-character {....} annotation in every lockdep report encodes it:
| Character | Meaning |
|---|---|
. | acquired while IRQs disabled and not in IRQ context |
- | acquired in IRQ context |
+ | acquired with IRQs enabled |
? | acquired in IRQ context with IRQs enabled |
and the four positions, left to right, are: acquired in hardirq context / hardirq disabled and not in hardirq context / acquired in softirq context / softirq disabled and not in softirq context. So a report naming (&dev->lock){+.?.} is telling you that this lock class has been taken both with hardirqs enabled and from softirq context with softirqs enabled — the precise spin_lock()-where-you-needed-spin_lock_bh() bug.
The rules lockdep enforces map one-to-one onto the failure modes above: “The same lock-class must not be acquired twice” (recursion); “two locks can not be taken in inverse order” (lock inversion, found “in arbitrary complexity” by searching the dependency graph for cycles); and, the one specific to interrupt variants, the forbidden dependencies <hardirq-safe> -> <hardirq-unsafe> and <softirq-safe> -> <softirq-unsafe>. That last rule is why lockdep can flag a missing _irqsave on a code path where the interrupt has never actually arrived at the wrong moment: it only needs to see the lock taken in an IRQ handler somewhere and taken with IRQs enabled somewhere else.
A v6.12-specific default worth knowing
The check that enforces the nesting hierarchy above —
spinlock_tmust never be taken insideraw_spinlock_t— is a separate, opt-in config at v6.12.lib/Kconfig.debugatv6.12declaresconfig PROVE_RAW_LOCK_NESTINGwithdefault n, and warns “[t]here are known nesting problems. So if you enable this option expect lockdep splats until these problems have been fully addressed which is work in progress.” Atv6.13the same symbol isdefault ywith no prompt, and atv6.17it readsbool "Enable raw_spinlock - spinlock nesting checks" if !ARCH_SUPPORTS_RT/default y if ARCH_SUPPORTS_RT. So on a v6.12 kernel you must turn this on deliberately to get RT-nesting violations reported; from v6.13 you get them by default withPROVE_LOCKING. If you are validating a driver for RT-readiness on v6.12, set it.
The mechanics of the validator itself — the dependency graph, the chain cache, the lockdep_assert_held() family, and how to read a full splat — are in lockdep Runtime Lock Validator.
Measuring Contention: lock_stat, perf lock, and What the Numbers Mean
“This spinlock is contended” is a claim that should be measured, not asserted, and the kernel ships two independent tools for it.
CONFIG_LOCK_STAT and /proc/lock_stat. Lock statistics hook the same points lockdep already instruments. Documentation/locking/lockstat.rst (v6.12) draws the state machine each acquisition passes through:
__acquire
|
lock _____
| \
| __contended
| |
| <wait>
| _______/
|/
|
__acquired
|
.
<hold>
.
|
__release
|
unlock
(Reproduced from the kernel documentation. This is an ASCII flow rather than a mermaid diagram because it is quoted verbatim from the source doc, where the hook names and the state labels are the authoritative spelling.)
From those hooks it derives, per lock class: contentions (acquisitions that had to wait), con-bounces and acq-bounces (how many of those involved cross-CPU data — i.e. cacheline bouncing, measured directly), and min/max/total/average for both wait time and hold time, with the integer part in microseconds. It also keeps the four hottest contention points per class, as instruction pointers you can symbolise. You enable collection with echo 1 > /proc/sys/kernel/lock_stat and read /proc/lock_stat; echo 0 > /proc/lock_stat clears the counters.
The documentation’s worked example is worth reading for shape rather than for absolute values, because it profiles a specific machine and workload that nobody can reproduce today:
| class | contentions | wait avg (µs) | acquisitions | hold avg (µs) |
|---|---|---|---|---|
&rq->lock (scheduler run queue) | 13,128 | 7.91 | 3,453,404 | 3.82 |
unix_table_lock | 112 | 1.46 | 66,312 | 0.48 |
&mm->mmap_sem-W (write side) | 84 | 194.90 | 2,922,365 | 5,975.99 |
&mm->mmap_sem-R (read side) | 100 | 3,256.30 | 34,316,685 | 2.77 |
Excerpt from the annotated /proc/lock_stat sample in Documentation/locking/lockstat.rst (v6.12). What it shows: the contention rate is what matters, not the raw contention count — &rq->lock was contended 13,128 times out of 3.45 million acquisitions (0.38%) with a 3.82 µs average hold, while &mm->mmap_sem’s write side shows a 5.98 millisecond average hold, three orders of magnitude longer, which is exactly the signature of a sleeping lock held across page-fault work. The insight to take: a spinlock whose average hold time is in the multi-microsecond range is already a design smell, because every contending CPU burns that entire time; the mmap_sem row is what a lock that must be a sleeping lock looks like. Treat these figures as illustrative of the shape of real data — the sample is undated, taken on unspecified hardware, and the doc gives no provenance.
Uncertain
Verify: absolute lock-hold and wait-time figures for any specific lock on modern hardware. Reason: the numbers above are the example output embedded in
lockstat.rst, not a benchmark — the doc names neither the machine nor the kernel version nor the workload, and the sample has clearly been in tree for many years (it refers to&mm->mmap_sem, which was renamed tommap_lockin v5.8). To resolve: runCONFIG_LOCK_STATon the actual machine and workload in question; there is no substitute and no portable answer. uncertain
perf lock contention. The newer and generally more practical tool, because it needs no CONFIG_LOCK_STAT rebuild when run in BPF mode. perf lock contention -a -b collects system-wide contention through a BPF program, and -Y/--type-filter restricts the report to a lock type — the accepted values at v6.12 include spinlock, rwlock, rwlock:R, rwlock:W, mutex, rwsem, rtmutex, rwlock-rt, semaphore, and pcpu-sem (tools/perf/Documentation/perf-lock.txt, v6.12). -l/--lock-addr aggregates by lock instance rather than by call site, which is how you distinguish “one hot lock” from “a hot lock class spread over many objects” — the distinction lockdep deliberately erases. -o/--lock-owner attributes contention to the holder rather than the waiter and requires --use-bpf.
What a lock operation costs. The kernel-hacking guide gives an order-of-magnitude breakdown, and its provenance must be stated because it is old: on a 700 MHz Pentium III, “an instruction takes about 0.7ns, an atomic increment takes about 58ns, a lock which is cache-hot on this CPU takes 160ns, and a cacheline transfer from another CPU takes an additional 170 to 360ns,” attributed to Paul McKenney’s Linux Journal RCU article. The absolute nanoseconds are obsolete by roughly two decades of hardware; the ratios are the durable content and have if anything widened:
| Operation | Cost, in units of one simple instruction |
|---|---|
| one instruction | 1× |
| atomic increment (cache-hot) | ~83× |
| uncontended lock acquire (cache-hot) | ~230× |
| plus a cacheline transfer from another CPU | ~+240× to +510× |
Relative costs derived from the figures in Documentation/kernel-hacking/locking.rst (v6.12), which itself cites measurements on a 700 MHz Pentium III. What it shows: an uncontended lock is already two orders of magnitude more expensive than ordinary work, and moving the lock’s cache line between CPUs can more than triple that again. The insight to take: this ratio is the whole justification for qspinlock’s per-CPU queue nodes and for RCU’s zero-cost read side. It is also the arithmetic behind the advice to split locks carefully rather than reflexively: splitting one lock into many reduces contention but multiplies the number of acquisitions, and the doc warns “the results are often slower than having a single lock.”
Uncertain
Verify: current per-operation costs on modern x86-64 or arm64. Reason: the cited figures are from a 700 MHz Pentium III via a Linux Journal article whose URL in the v6.12 doc (
linuxjournal.com/article.php?sid=6993) was not fetched during this task, and cache-coherence latencies have changed shape substantially with on-die interconnects, multi-socket NUMA, and much deeper store buffers. To resolve: measure on the target machine with a microbenchmark, or cite a recent published characterisation. The ratios are presented here as the durable takeaway precisely because the absolutes are not. uncertain
Alternatives and When to Choose Them
The decision is almost never about performance first — it is about context, which is the cross-cutting theme of the Linux Kernel Synchronization MOC. If the critical section can sleep (waits on I/O, allocates with GFP_KERNEL, copies user memory), you cannot use a spinlock and must use a mutex or semaphore. If you are in hardirq/softirq context or already hold another spinlock, you cannot sleep, so a spinlock (or its irqsave variant) is mandatory. Within the spinlock family: use plain spin_lock() when no interrupt handler touches the lock; spin_lock_bh() when a softirq does; spin_lock_irqsave() when a hardirq does (Spinlock irqsave and bh Variants). If the data is read-mostly, a seqlock or RCU beats any spinlock by removing reader-side contention entirely. If the data is a single word, an atomic removes the lock altogether. rwlocks exist but usually lose to a plain spinlock because of their own overhead unless the read/write asymmetry is extreme.
| Primitive | Sleeps when contended? | Legal in hardirq / softirq? | Concurrent readers? | Uncontended cost | Size on x86-64 | Reach for it when |
|---|---|---|---|---|---|---|
raw_spinlock_t | never, in any config | yes | no | 1 atomic cmpxchg | 4 bytes (non-debug SMP) | genuinely atomic core code; must stay non-preemptible even on RT |
spinlock_t | no on !RT, yes on RT | yes on !RT | no | 1 atomic cmpxchg | 4 bytes (non-debug, !RT) | the default for short critical sections touchable from interrupt context |
rwlock_t | as spinlock_t | yes on !RT | yes | more atomics than a spinlock | 4 bytes | almost never — see below |
struct mutex | yes | no | no | 1 atomic cmpxchg on owner | 32 bytes | process context, critical section may sleep or is long |
struct semaphore | yes | no (see below) | counting | atomic + internal spinlock | 24 bytes | you need N holders, or lock-by-A / unlock-by-B |
struct rw_semaphore | yes | no | yes | atomic on count | 40 bytes | read-mostly and readers may sleep |
seqlock_t | writers no | yes | unlimited, retry-based | reader: 2 loads + a barrier | 8 bytes | tiny read-mostly data, readers must never block writers |
| RCU | readers never | yes | unlimited, wait-free | reader: effectively zero | 0 (per-object) | read-mostly pointer-based data structures |
atomic_t | n/a | yes | n/a | 1 atomic op | 4 bytes | the shared state genuinely is one word |
Choosing among the kernel’s synchronisation primitives on the axes that actually decide the answer. What it shows: the first two columns are the constraints and the rest are the trade-offs — you do not get to weigh cost against a sleeping-in-atomic-context violation, because that combination is simply illegal. The insight to take: read this table top-down as a filter, not left-to-right as a scoreboard. Context eliminates most rows before performance is even discussed, which is why “which lock is fastest” is almost never the right question. The struct sizes come from Documentation/locking/mutex-design.rst (v6.12), which states them as prose rather than deriving them from a build, so treat them as approximate.
Two rows deserve elaboration because they are commonly mis-selected. rwlock_t usually loses to a plain spinlock, and the kernel says so twice: “reader-writer locks require more atomic memory operations than simple spinlocks. Unless the reader critical section is long, you are better off just using spinlocks,” and more bluntly, “[w]e are working hard to remove reader-writer spinlocks in most cases, so please don’t add a new one without consensus.” A read lock is not free — it is an atomic read-modify-write on the shared word, so n concurrent readers still bounce that cache line n times. You also cannot upgrade a read lock to a write lock, so “if you at any time need to do any changes (even if you don’t do it every time), you have to get the write-lock at the very beginning.” Semaphores are similarly deprecated for new locking use: locktypes.rst says “[s]emaphores are often used for both serialization and waiting, but new use cases should instead use separate serialization and wait mechanisms, such as mutexes and completions,” and notes that because a counting semaphore has no owner, PREEMPT_RT cannot give it priority inheritance — “blocking on semaphores can result in priority inversion.”
flowchart TB START["I need to protect shared data"] --> C1{"Is the data a single<br/>machine word?"} C1 -->|"yes"| AT["atomic_t / atomic_long_t<br/>no lock at all"] C1 -->|"no"| C2{"Is it read-mostly and<br/>pointer-reachable?"} C2 -->|"yes, and readers must<br/>never block or be blocked"| RCU["RCU — zero-cost read side"] C2 -->|"yes, and it is tiny and<br/>readers can tolerate a retry"| SEQ["seqlock_t"] C2 -->|"no"| C3{"Can any code path that<br/>takes this lock run in<br/>hardirq or softirq context?"} C3 -->|"yes"| C4["MUST be a spinning lock.<br/>Pick the variant from the<br/>context matrix above."] C3 -->|"no — process context only"| C5{"Might the critical section<br/>sleep, or run longer than<br/>a couple of microseconds?"} C5 -->|"yes"| MTX["struct mutex<br/>(rw_semaphore if read-mostly)"] C5 -->|"no"| C6{"Is this genuinely-atomic<br/>core kernel code that must<br/>stay non-preemptible on RT?"} C6 -->|"yes"| RAW["raw_spinlock_t"] C6 -->|"no"| SPIN["spinlock_t"]
The decision procedure, ordered so that the hard constraints are asked first. What it shows: the sequence matters — asking “will this run in interrupt context?” before “how long is the critical section?” is what keeps you from designing a mutex into a path that can never sleep. The insight to take: the first two branches are the ones people skip. A lock you can delete by making the state an atomic_t, or by restructuring around RCU, beats any lock you can choose; the in-tree guide’s first piece of advice is “keep it simple” and “be reluctant to introduce new locks.”
Production Notes
In real kernel code, spinlocks are everywhere short critical sections guard shared state touchable from interrupt context: device-driver registers, the per-CPU run queue (rq->lock, a raw_spinlock_t), wait-queue heads, the slab allocator’s per-node lists, network sk_buff queue locks. The discipline that experienced kernel developers internalize: acquire late, release early, never sleep in between, and pick the narrowest variant that closes the relevant interrupt race. Lockdep (lockdep Runtime Lock Validator) runs in debug kernels and CI to catch ordering violations and bad nesting before they reach production; CONFIG_DEBUG_ATOMIC_SLEEP catches the no-sleep-rule violations. The combination is why a vanishingly small fraction of spinlock bugs survive to a shipped kernel — the rules are mechanically enforced, not merely documented.
Reading real code: two things the tree itself will mislead you about
In-tree comments go stale; the #include is the truth. arch/x86/include/asm/spinlock.h at v6.12 still opens with “These are fair FIFO ticket locks, which support up to 2^16 CPUs” — a comment describing the 2008 implementation that was replaced in 2015. Six lines below it, the file does #include <asm/qspinlock.h>, which is what actually defines arch_spin_lock. A reader who trusted the comment would carry a nine-year-old model of the lock. This is the general rule for kernel research: verify documentation and comments against code, and when they disagree, the code wins.
Even the citations rot. kernel/locking/qspinlock.c cites the MCS paper as https://bugzilla.kernel.org/show_bug.cgi?id=206115, which is not a paper — it is a bug report titled “locking/qspinlock: MCS paper URL inaccessible,” filed because the original URL died, with the paper attached to it. The kernel’s own reference to the algorithm’s source is a workaround for link rot.
Practical discipline
The habits that experienced kernel developers apply, each traceable to something above:
- Acquire late, release early. Build objects and do computation outside the lock; take it only to publish. The in-tree guide’s rule of thumb is quantitative: “[a]void holding spinlock for more than 5 lines of code and across any function call (except accessors like
readb()).” - Pick the narrowest variant that closes the relevant interrupt race, using the context matrix — but start from
spin_lock_irqsave()and narrow only when you can prove the interrupt cannot take this lock. - Never call anything that might sleep, and remember that “might sleep” includes
kmalloc(GFP_KERNEL),copy_to_user(),vmalloc(),mutex_lock(), and any function whose implementation you have not read. - Use
spin_needbreak()for loops that legitimately hold a lock across many iterations. At v6.12 it ispreempt_model_preemptible() ? spin_is_contended(lock) : 0— it returns true only on a preemptible kernel and only when someone is actually waiting, which is the signal to drop the lock at a consistent point, let the waiter through, and re-acquire. - Run
CONFIG_PROVE_LOCKINGandCONFIG_DEBUG_ATOMIC_SLEEPin CI, not just locally. Between them they mechanically enforce every rule in this note; without them the same bugs become intermittent production hangs. - Do not assume you are on bare metal. Inside a virtual machine without paravirtualised spinlock support, x86’s
virt_spin_lock()deliberately abandons the queue and reverts to plain test-and-set, because “fair locks have horrible lock ‘holder’ preemption issues” when the hypervisor can deschedule a vCPU mid-critical-section. Fairness and cacheline behaviour you measured on metal may not be what a guest gets.
See Also
- Queued Spinlocks and qspinlock — the MCS-based implementation that backs
arch_spin_lock()and makes contended spinning scalable - Spinlock irqsave and bh Variants —
spin_lock_irqsave/spin_lock_bhfor closing interrupt and softirq races - Raw Spinlocks and PREEMPT_RT — why
raw_spinlock_tstays a real spinlock whilespinlock_tbecomes a sleeping mutex on RT - Preemption Disabling and preempt_count — the counter that makes “no sleeping while holding a spinlock” enforceable
- Reader-Writer Spinlocks — the read/write-asymmetric cousin
- Kernel Mutexes — the sleeping alternative for process-context, possibly-blocking critical sections
- Compare-and-Swap and cmpxchg in the Kernel — the atomic the acquire fast path rests on
- Memory Barriers in the Linux Kernel, Acquire Release and Fence Semantics — the ordering a lock provides
- Cache Coherence and the Store Buffer — why moving a contended lock’s cache line between CPUs is the dominant cost, and what makes reordering observable
- Lock Statistics and Contention Analysis — reading
/proc/lock_statandperf lock contentionin anger - Sequence Locks and seqlock, Read-Copy-Update Fundamentals — the read-mostly alternatives that remove reader-side contention entirely
- Kernel Atomic Operations and atomic_t — when the shared state is one word and no lock is needed
- Priority Inversion and Priority Inheritance — the latency problem
PREEMPT_RT’s rt_mutex-backedspinlock_texists to bound - Linux Kernel Synchronization MOC — parent map (section B, Spinning Locks)