Polling is simple: ask the device “anything yet?” in a loop. It’s also how you turn a CPU into a space heater that checks an empty mailbox a billion times a second. Interrupts invert control — the device signals, the CPU responds — and that inversion is load-bearing for everything: your keyboard, your NVMe drive, your 100 Gbit NIC, and the scheduler tick that makes multitasking preemptive rather than cooperative. It also creates one of the kernel’s hardest constraint environments, which is why interrupt handling is split into halves.
The Mechanism
A device raises an interrupt (legacy IRQ line, or — for anything modern — an MSI-X message write, which is how one NVMe drive gets a separate vector per queue per CPU). The interrupt controller (APIC on x86) routes it to a CPU, which finishes the current instruction, saves state, and vectors through the IDT to the registered handler. Whatever was running — your process, another interrupt’s tail — is suspended mid-thought.
NIC ──MSI-X──▶ APIC ──▶ CPU7: save state
└─▶ IDT[vector] ─▶ driver ISR "top half"
└─ raise NET_RX softirq "bottom half, later"
┌─▶ restore state
interrupted task resumes ◀──┘/proc/interrupts shows the whole routing table live — one row per vector,
one column per CPU. A single column growing much faster than the rest is the
classic “all my packets interrupt CPU0” smell; irqbalance or manual
smp_affinity masks are the knob.
Why Handlers Are Split in Half
Interrupt context is a hostile place to run code. You can’t sleep (there’s no task to put to sleep — you’re borrowing someone’s stack), so no mutexes, no kmalloc(GFP_KERNEL), no page faults. And while you run, you delay everything — including other interrupts and any latency-sensitive task on that CPU. The rule is: do almost nothing.
So Linux splits the work. The top half (the ISR proper) does only what
must happen at interrupt priority: acknowledge the device, grab the perishable
data (read a status register, note which queue has work), schedule the rest,
return. Microseconds. The bottom half does everything else — TCP
processing, block completion — in softirq context, which runs with
interrupts enabled, either on the tail of interrupt exit or, under sustained
load, in the per-CPU ksoftirqd thread. That name is worth recognizing:
ksoftirqd/3 eating CPU in top means “CPU 3 is drowning in deferred interrupt
work” — usually network — and it’s the scheduler’s way of making that load
visible and preemptible instead of invisible and latency-destroying. (Threaded
IRQs and workqueues extend the same idea further for drivers that need to
sleep.)
NAPI: Knowing When to Stop Listening
Interrupts have a failure mode at the high end: livelock. At a million packets per second, a per-packet interrupt means the CPU does nothing but enter and exit handlers — 100% busy, zero packets actually delivered to userspace. The fix, NAPI, is beautifully self-tuning: on the first packet, the driver’s top half disables RX interrupts and schedules a poll; the softirq then harvests packets in batches (budgeted, ~64 at a time) for as long as they keep coming; when a poll drains the queue, re-enable interrupts.
Light load: behaves like pure interrupts, minimum latency. Heavy load: behaves
like pure polling, minimum overhead — one interrupt per burst, not per
packet. The system slides between modes automatically, driven by the traffic
itself. Hardware interrupt coalescing (ethtool -c) stacks on top of this,
trading microseconds of latency for fewer vector deliveries; storage went the
same direction, and io_uring’s polled mode drops interrupts entirely for
latency-critical NVMe.
The last mile is placement: RSS spreads flows across NIC queues, each with its own MSI-X vector pinned to a CPU, so softirq work lands where the consuming application runs (RPS/RFS do it in software). Getting a 100 Gbit NIC to line rate is largely the craft of arranging which CPU gets tapped on the shoulder, and how often.
Takeaways
- Interrupts preempt anything, anywhere; top halves therefore ack, stash, and defer — never sleep, never dawdle.
- Bottom halves (softirqs) do the real protocol/completion work preemptibly; ksoftirqd burning CPU means deferred-interrupt overload.
- NAPI flips interrupt→poll under load and back, defeating receive livelock with one elegant rule.
- MSI-X gives modern devices per-queue vectors; /proc/interrupts + IRQ affinity decide which cores pay the tax.
- Latency tuning is interrupt placement: coalescing, RSS/RPS, pinning — the shoulder-tap schedule is the performance model.