Spinlock irqsave and bh Variants

A plain spinlock (spin_lock()) gives you mutual exclusion across CPUs, but it does nothing about the lock being re-entered on the same CPU by code that interrupts the holder. If an interrupt handler — or a softirq/tasklet — takes the very lock a process-context thread is already holding, and that interrupt fires on the CPU that holds the lock, the handler spins forever waiting for a lock that can never be released, because the only code that could release it is the interrupted thread, which can’t run until the handler returns. This is a hard, single-CPU self-deadlock. The interrupt-aware variants — spin_lock_irqsave()/spin_unlock_irqrestore(), spin_lock_irq(), and spin_lock_bh() — fix it by disabling the offending context on the local CPU while the lock is held, so the interrupting code simply cannot run until the critical section finishes (per the canonical explanation by Linus Torvalds in Documentation/locking/spinlocks.rst, v6.12). The decision of which variant to use is dictated entirely by which context the lock is also taken from — not by performance.

This note pins to Linux 6.12 LTS (released 2024-11-17), the long-term-support branch current in the 2026 kernel era. Mechanism and source line references below are from the v6.12 tree.


Mental Model

The right way to think about an interrupt-aware spinlock is as two independent locks fused into one operation: a cross-CPU spinlock (the actual lock word, which serializes other CPUs) plus a local-CPU “do-not-interrupt-me” flag (which serializes the holder against re-entrancy from interrupt or softirq context on this very CPU). The plain spin_lock() only provides the first. The hazard is entirely about the second: a holder that gets preempted by an interrupt that wants the same lock.

The key asymmetry is local versus remote. If the interrupt handler that wants the lock runs on a different CPU than the holder, there is no deadlock — the handler spins, but the holder, running undisturbed on its own CPU, will finish its critical section and release the lock, at which point the handler acquires it. Spinning briefly is the whole point of a spinlock; that is fine. The catastrophe is only when the interrupt fires on the same CPU as the holder: now the holder is frozen mid-critical-section (the CPU is executing the handler instead), and the handler spins on a lock that will never be freed. The fix therefore only needs to disable interrupts locally, never globally — and that is exactly what these variants do.

flowchart TD
  H["Process-context thread<br/>holds spin_lock(L) on CPU0"]
  H -->|"IRQ fires on CPU0"| SELF["Handler on CPU0<br/>spin_lock(L)"]
  H -->|"IRQ fires on CPU1"| REMOTE["Handler on CPU1<br/>spin_lock(L)"]
  SELF --> DEAD["DEADLOCK<br/>handler spins forever;<br/>holder can't run to release"]
  REMOTE --> OK["Safe<br/>handler spins briefly;<br/>holder on CPU0 finishes<br/>and releases L"]
  H -.->|"fix: spin_lock_irqsave(L)<br/>disables IRQs on CPU0"| NOIRQ["IRQ can't fire on CPU0<br/>while L is held → no self-deadlock"]

What it shows: the same lock taken by an interrupt handler is only dangerous when the interrupt preempts the holder on the same CPU; a remote-CPU handler merely spins harmlessly. What to take from it: the cure is to suppress interrupts on the local CPU for the duration of the critical section — never a global interrupt disable — which is precisely why the irqsave variant disables interrupts _locally (per spinlocks.rst).


Why the Plain Spinlock Deadlocks Against an Interrupt

Linus’s own worked example in Documentation/locking/spinlocks.rst (Lesson 3) lays out the failure exactly:

spin_lock(&lock);
...
    <- interrupt comes in:
        spin_lock(&lock);

“where 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 (because the interrupt is waiting for the lock, and the lock-holder is interrupted by the interrupt and will not continue until the interrupt has been processed).”

And the rationale for the local-only disable, quoted from the same document:

“(This is also the reason why the irq-versions of the spinlocks only need to disable the local interrupts - it’s ok to use spinlocks in interrupts on other CPU’s, because an interrupt on another CPU doesn’t interrupt the CPU that holds the lock, so the lock-holder can continue and eventually releases the lock).”

This is the entire justification for the irqsave family. A spinlock is not a sleeping lock: a CPU that fails to acquire it does not yield, it busy-waits. Busy-waiting is only acceptable if the holder can make forward progress. An interrupt handler that preempts the holder on the same CPU removes that guarantee, so the only safe move is to make sure the interrupt cannot fire while we hold the lock.


Mechanical Walk-through

spin_lock_irqsave() / spin_unlock_irqrestore()

spin_lock_irqsave(lock, flags) is a macro (not a function) because it must write the saved interrupt state back into the caller’s flags variable. In include/linux/spinlock.h it expands (for PREEMPT_RT=n) to raw_spin_lock_irqsave(spinlock_check(lock), flags), which on SMP builds becomes:

#define raw_spin_lock_irqsave(lock, flags)        \
    do {                                          \
        typecheck(unsigned long, flags);          \
        flags = _raw_spin_lock_irqsave(lock);     \
    } while (0)

The typecheck(unsigned long, flags) line is a compile-time assertion that flags is exactly an unsigned long — interrupt flags are a machine word, and passing the wrong type is a common bug the macro catches early. The real work is in _raw_spin_lock_irqsave(), whose inline definition is in include/linux/spinlock_api_smp.h:

static inline unsigned long __raw_spin_lock_irqsave(raw_spinlock_t *lock)
{
    unsigned long flags;
 
    local_irq_save(flags);
    preempt_disable();
    spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
    LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
    return flags;
}

Reading it line by line:

  1. local_irq_save(flags) — saves the current hardware-interrupt state of this CPU into flags, then disables interrupts locally. Per include/linux/irqflags.h it wraps raw_local_irq_save(flags), which calls the architecture’s arch_local_irq_save() to read the flags register (e.g. on x86, the IF bit of EFLAGS) into flags and then clear it. The crucial property is that it saves prior state rather than assuming interrupts were on.
  2. preempt_disable() — increments this task’s preempt_count so the scheduler cannot preempt it while it holds the lock (see Preemption Disabling and preempt_count). All spinlock acquisitions disable preemption, irqsave or not; a sleeping holder of a spinlock would be a disaster.
  3. spin_acquire(...) — feeds the acquisition to lockdep (a no-op unless CONFIG_DEBUG_LOCK_ALLOC is set) so the lock-ordering validator can track this acquire.
  4. LOCK_CONTENDED(...) — the actual spin: try the fast path (do_raw_spin_trylock), and on failure fall into do_raw_spin_lock, which busy-waits on the architecture lock word (a qspinlock on most modern arches).
  5. return flags — hands the saved interrupt state back so the matching unlock can restore it.

The release path, spin_unlock_irqrestore(lock, flags), runs the steps in reverse in __raw_spin_unlock_irqrestore():

static inline void __raw_spin_unlock_irqrestore(raw_spinlock_t *lock,
                                                 unsigned long flags)
{
    spin_release(&lock->dep_map, _RET_IP_);
    do_raw_spin_unlock(lock);
    local_irq_restore(flags);
    preempt_enable();
}

do_raw_spin_unlock() releases the lock word; local_irq_restore(flags) restores interrupts to exactly the state captured at lock time — not unconditionally on. This is the defining feature of the _irqsave/_irqrestore pair: if interrupts were already disabled when you acquired (because, say, you are nested inside another irqsave region), restore leaves them disabled. Without save/restore, an unconditional re-enable would silently re-enable interrupts in a context where the outer code expected them off — a classic and dangerous bug.

spin_lock_irq() / spin_unlock_irq()

spin_lock_irq() is the cheaper, less-defensive sibling. Its inline (__raw_spin_lock_irq) calls local_irq_disable() instead of local_irq_save(), and the unlock calls local_irq_enable() instead of local_irq_restore():

static inline void __raw_spin_lock_irq(raw_spinlock_t *lock)
{
    local_irq_disable();
    preempt_disable();
    spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
    LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}

local_irq_disable() unconditionally turns interrupts off, and local_irq_enable() unconditionally turns them back on. There is no saved state. This is correct only when you already know, by construction, that interrupts were enabled when you took the lock — typically the top of a process-context function that you know was entered with interrupts on. If you used spin_lock_irq() while interrupts happened to be off, the matching spin_unlock_irq() would wrongly enable them. The win is a couple fewer instructions (no flags read, no register save). In practice the kernel uses spin_lock_irq() in code paths where the interrupt state is statically known; everywhere else, the safe default is spin_lock_irqsave(). The Linus document opens with spin_lock_irqsave precisely because “The above is always safe.”

spin_lock_bh() / spin_unlock_bh()

spin_lock_bh() does not disable hardware interrupts. It disables bottom halves — the term covers softirqs, and therefore the tasklets and (via softirq) the timer/network/block-completion processing built on them (see Linux Interrupts and Deferred Work MOC). Use it when the lock is shared between process context and softirq context, but is never taken by a hardware interrupt handler. The classic example is a network-stack data structure touched by both a syscall path and the NET_RX softirq.

The mechanism is subtler than it looks. Looking at __raw_spin_lock_bh() in spinlock_api_smp.h:

static inline void __raw_spin_lock_bh(raw_spinlock_t *lock)
{
    __local_bh_disable_ip(_RET_IP_, SOFTIRQ_LOCK_OFFSET);
    spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
    LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}

__local_bh_disable_ip() (from include/linux/bottom_half.h) bumps the softirq portion of preempt_count. On a non-debug build it is simply preempt_count_add(cnt); barrier();. Because softirqs only run when preempt_count’s softirq field is zero, incrementing it prevents softirq processing on this CPU until the matching __local_bh_enable_ip() in spin_unlock_bh(). Note that spin_lock_bh() does not call preempt_disable() separately — the bh-disable count is a preempt-count contribution, so preemption is held off by the same mechanism. Disabling bh is strictly weaker (and cheaper) than disabling hardware interrupts: hardware IRQs keep flowing, only the deferred softirq work is held back.

There is also a deeper-machinery path. When the kernel builds the out-of-line lock functions in kernel/locking/spinlock.c, the generic BUILD_LOCK_OPS macro implements _lock_bh by temporarily disabling hardware interrupts too, with this comment:

/* Careful: we must exclude softirqs too, hence the   */
/* irq-disabling. We use the generic preemption-aware  */
/* function:                                           */
flags = _raw_##op##_lock_irqsave(lock);
local_bh_disable();
local_irq_restore(flags);

It acquires under a full irqsave, then raises the bh-disable count, then restores interrupts — leaving only bottom halves disabled while it spins. The takeaway: the kernel goes to real trouble to make _bh correct against softirqs even on the contended slow path.


The Decision Rule

The variant is chosen by the most aggressive context that also takes this lock, evaluated per-lock across the whole kernel:

The lock is also taken from…UseWhy
Hardware interrupt handler (hardirq)spin_lock_irqsave() (or spin_lock_irq() if IRQ state is statically known on)Must block the hardirq locally or it self-deadlocks the holder
Softirq / tasklet (bottom half) only — never hardirqspin_lock_bh()Must block softirq locally; hardirq can’t take this lock so no need to disable it
Process context only — never any interruptplain spin_lock()No interrupting context wants the lock; disabling interrupts would be pure overhead

The rule is asymmetric and global: if the lock is taken in any hardirq handler anywhere, then every acquirer — including all the process-context ones — must use spin_lock_irqsave() (or _irq). It is not enough for the handler to disable interrupts; the process-context holder is the one that gets interrupted, so it is the one that must disable interrupts while holding the lock. Conversely, a lock genuinely confined to process context must not use the irqsave variant, because disabling interrupts has a real cost. Linus is blunt about it in spinlocks.rst: the safe _irqsave forms “are also fairly slow … because they do have to disable interrupts (which is just a single instruction on a x86, but it’s an expensive one - and on other architectures it can be worse).”


Failure Modes and Diagnosis

  • Same-CPU self-deadlock (the headline bug). A process-context path takes spin_lock() on a lock that some IRQ handler also takes; the IRQ fires on the same CPU; the box hangs hard (often the whole CPU, sometimes detected as a hard lockup by the NMI watchdog → BUG: NMI watchdog: ...). lockdep will normally catch this before it ever deadlocks in production, emitting an inconsistent {IN-HARDIRQ-W} -> {HARDIRQ-ON-W} report: it observes that a lock was once taken in hardirq context and later taken with interrupts enabled, and warns even if the bad interleaving has not yet happened. Treat that report as a real bug, not noise.
  • Restoring interrupts wrongly with _irq instead of _irqsave. Using spin_lock_irq() in code that may run with interrupts already disabled means the spin_unlock_irq() re-enables them prematurely, opening a window for an interrupt the outer code intended to keep masked. Symptoms are subtle and timing-dependent. The fix is to default to _irqsave whenever you are unsure of the entry interrupt state.
  • Sleeping while holding any of these. All three variants disable preemption (and irqsave/irq also disable interrupts). Calling anything that may sleep — kmalloc(GFP_KERNEL), mutex_lock(), copy_to_user() — inside the critical section is illegal. With CONFIG_DEBUG_ATOMIC_SLEEP you get BUG: sleeping function called from invalid context.
  • Holding interrupts off too long. spin_lock_irqsave() increases interrupt latency for the entire time the lock is held, because that CPU services no interrupts. Long irqsave critical sections show up as latency spikes (visible to ftrace’s irqsoff tracer or cyclictest). Keep irqsave critical sections extremely short; if they cannot be short, the data probably wants a sleeping lock and a redesign that moves it out of interrupt context.
  • _bh does not protect against hardirqs. A frequent mistake is using spin_lock_bh() for a lock that is also touched by a hardware IRQ handler. Bottom-half disabling leaves hardware interrupts fully enabled, so the self-deadlock returns. If a hardirq handler touches the lock, you need _irqsave, full stop.

Alternatives and When to Choose Them

  • Plain [[Kernel Spinlocks|spin_lock()]] — the right answer whenever the lock never meets an interrupt handler. Cheapest of the spinning locks; do not pay for interrupt disabling you don’t need.
  • Sleeping locks (mutex, rw_semaphore). If the critical section is long or may block, you should not be in an atomic context at all — use a mutex in process context. You cannot take a mutex from interrupt context, which is itself a signal that interrupt-shared state must stay tiny and use a spinlock.
  • Per-CPU Variables / local_lock. If the shared state is naturally per-CPU, local_lock expresses “protect this CPU’s data against preemption and/or interrupts” directly, and on PREEMPT_RT it cleanly maps to a per-CPU sleeping lock. Per the kernel’s locktypes.rst, local_lock_irq() maps to local_irq_disable() on non-RT — i.e. it is the named, lockdep-tracked way to do what an ad-hoc spin_lock_irqsave over per-CPU data would do.
  • seqlock / RCU. For read-mostly data where the readers might run in interrupt context, lock-free readers sidestep the whole irqsave question on the read side.

Production Notes — PREEMPT_RT Changes the Meaning

On a PREEMPT_RT kernel (the real-time preemption model, fully mainline since 6.12), the semantics shift in a way every driver author should know. Per the kernel’s Documentation/locking/locktypes.rst: on PREEMPT_RT, spinlock_t becomes a sleeping lock (a priority-inheritance rtmutex), preemption is not disabled, and crucially “the hard interrupt related suffixes for spin_lock / spin_unlock operations (_irq, _irqsave / _irqrestore) do not affect the CPU’s interrupt disabled state.” In other words, on RT, spin_lock_irqsave() does not actually disable hardware interrupts — interrupt handlers run as threads anyway, so the self-deadlock hazard is handled differently. Code that genuinely must spin with interrupts off on RT (true low-level critical sections) must use [[Raw Spinlocks and PREEMPT_RT|raw_spinlock_t]], whose _irqsave forms keep the classic hard-disabling behavior on all kernels. The _bh() suffix still disables softirq handlers on RT, but via a per-CPU lock that keeps preemption enabled. This is a subtle trap: the same source line means different things depending on the config, which is exactly why raw_spinlock_t exists as the escape hatch for code that needs the old guarantees.

A second practical point from real driver code: the irqsave form is what you reach for in any handler-shared path even when you think you know interrupts are on, because future callers might invoke your function from a context you didn’t anticipate. Defensive use of _irqsave is cheap insurance; the few-instruction savings of _irq are only worth claiming in hot, well-audited paths where the interrupt state is provably fixed.


See Also