Polling versus Interrupts
Being told costs a fixed amount per event; asking costs a fixed amount per unit of time. Which is cheaper is arithmetic, and above a crossover rate the "wasteful" busy loop wins decisively.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
Two ways to learn that something happened
Interrupts and polling answer the same question with opposite cost structures. An interrupt is *push*: the device pays nothing until it has news, then the CPU pays a fixed, fairly large price to receive it. Polling is *pull*: the CPU pays continuously whether or not anything happened, but each discovery is nearly free — a load and a branch.
That difference in shape, not in magnitude, is what decides the winner. Interrupt cost is rate × per_event_cost. Polling cost is duration × poll_overhead, independent of rate. Below the crossover, interrupts win and the idle core can do other work or sleep. Above it, the machine spends more time processing interrupt delivery than it would have spent spinning.
The other axis is latency, and here polling wins at every rate. A polled event is noticed within one loop iteration. An interrupt-delivered event waits for delivery, the mode transition and possibly a scheduler wakeup. For control paths where microseconds matter, that gap is the entire argument.
| Interrupts | Polling | |
|---|---|---|
| Cost scales with | Event rate | Wall-clock time |
| Cost when idle | Nothing — the core can sleep or work | Full — a core is consumed regardless |
| Cost per event | High and fixed: drain, transition, cache displacement | Near zero: a load and a predictable branch |
| Latency to notice | Delivery + transition + possible wakeup | Within one loop iteration |
| Effect on other threads | Displaces their cache on whatever core takes it | None — the polling core is dedicated |
| Behaviour under overload | Degrades: delivery itself consumes the core | Stable: the loop rate does not change |
The crossover is arithmetic, not ideology
Because the two costs have different shapes, there is a rate at which they cross. Below it, interrupts; above it, polling. The crossover is not a matter of taste and it is not fixed — it moves with per-event interrupt cost (which depends on the machine and on how much the handler touches) and with how much useful work the core could otherwise be doing.
This is why high-rate networking and NVMe stacks poll. A NIC receiving millions of packets per second, taking an interrupt per packet, would spend most of the core on delivery overhead and never reach line rate. Dedicating a core to a spin loop looks profligate on a utilisation graph and is dramatically cheaper in reality.
The comparison below is the shape of that arithmetic, not a measurement of any machine. What transfers is that one line is flat and the other has slope — and therefore that they cross.
1per_event_cost = drain + transition + cache_displacement // large, fixed2total = event_rate x per_event_cost3 4// 2,000,000 events/sec x large fixed cost5// -> delivery overhead consumes most of the core6// -> throughput ceiling well below what the device can do7// -> and every other thread on that core pays for the cache churn1per_event_cost = load + predictable_branch // near zero2total = duration x loop_overhead // flat in rate3 4// 2,000,000 events/sec on a dedicated core5// -> per-event cost stays negligible6// -> latency bounded by one loop iteration7// -> one core fully consumed, and that is the priceInterrupt cost has slope in the event rate; polling cost is flat. Below the crossing point the flat line is above and interrupts win; above it, polling wins and keeps winning. Neither is "the efficient one" in general — the event rate decides.
Nobody actually picks one
Real systems refuse the binary. The standard shape is adaptive: take an interrupt when idle, and once an event arrives switch to polling for as long as events keep coming, only re-arming the interrupt when the queue goes quiet. This gets interrupt behaviour's zero idle cost and polling's per-event cost, at the price of substantially more complexity in the driver.
The device side has its own version. Coalescing makes an interrupt-driven path behave more like a polled one by amortising delivery across a batch — one interrupt per N events or per T microseconds, whichever comes first. It is the same trade in a different place: latency for throughput.
From application code all of this is invisible. What is visible is the consequence: a service can be latency-bound by an interrupt configuration nobody in the team chose, on a device nobody in the team configured. Which is the practical lesson — when tail latency has no explanation in your code, the I/O path's delivery mode is worth checking before the code is rewritten.
idle -> interrupts armed, core free to sleep or do other work
event arrives -> take ONE interrupt
disable further interrupts for this queue
poll the queue until it drains (or a work budget is hit)
queue empty -> re-arm interrupts, go idle
low rate : behaves like interrupts (one interrupt per event, no spin)
high rate : behaves like polling (one interrupt per burst, then spin)
overload : work budget bounds the loop so one queue cannot starve othersKey points
- Interrupt cost scales with event rate; polling cost scales with time. That difference in shape is the whole argument.
- Above a crossover rate, polling is cheaper in total cycles despite looking wasteful on a utilisation graph.
- Polling wins on latency at every rate, because there is no delivery or transition to wait through.
- Real drivers are adaptive: interrupt when idle, poll under load, re-arm when the queue drains.
- Coalescing is the device-side version of the same trade — batch delivery, exchanging latency for throughput.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1Device → controller → core (interrupt path): each event costs a delivery, a privilege transition and displaced cache, whether the core was busy or not.
- 2Core → device queue (polling path): a load of a descriptor status field and a branch, costing a few cycles and no transition.
- 3Rate → total cost: interrupt total grows linearly with event rate; polling total is flat, so there is a crossing point.
- 4Driver → device: an adaptive driver disables the queue's interrupts on the first event and polls until the queue drains, then re-arms.
- 5Device → delivery policy: coalescing settings decide how many events, or how much time, one interrupt represents.
- • "Busy-waiting is always wasteful." It is wasteful per unit time and free per event, which makes it the cheaper option above the crossover rate.
- • "The polling core is at 100%, so it is the bottleneck." A dedicated polling core is *designed* to be at 100%; utilisation is not the right signal for it.
- • "Interrupts are more efficient because the core can sleep." True only while the event rate is low enough that delivery cost stays small.
- • "We enabled coalescing and throughput improved, so it is strictly better." It improved throughput by adding latency to every event in the batch.
Consequences, controls and cost
- • High-rate devices on per-event interrupts hit a throughput ceiling well below the hardware's capability.
- • Latency-critical paths inherit delivery and wakeup latency they cannot see or control from application code.
- • A polling core shows 100% utilisation while doing exactly the right thing, which routinely triggers the wrong alarm.
- • Coalescing set for throughput silently raises the tail latency of every request on that path.
- • Match the mechanism to the event rate rather than to instinct: measure the rate first, then choose.
- • For high-rate paths, prefer a driver or framework that polls adaptively rather than one that takes an interrupt per event.
- • Tune coalescing explicitly for the workload — the default is a compromise chosen for neither your latency nor your throughput.
- • Where a core is dedicated to polling, exclude it from general scheduling so the spin loop is not itself preempted.
- • Interrupts per second per queue against events per second — a ratio near 1:1 at high rates is the signature of an untuned path.
- • Compare achieved throughput against the device's rated capability; a large gap with a busy core suggests delivery overhead.
- • Measure end-to-end latency with coalescing off and on to see exactly what the batching is costing.
- • Watch whether a "100% busy" core is spinning in a poll loop or doing work — utilisation alone cannot tell you.
- • Polling consumes a core completely, whether or not events arrive; that core is unavailable for anything else and burns power while idle-spinning.
- • Adaptive designs get most of both but are meaningfully harder to implement and to reason about under overload.
- • Coalescing improves throughput and worsens every individual event's latency, and the right setting differs per workload on the same hardware.
Scope
§224 — what these claims are specific to.
- GENERALThe cost-shape argument (per-event versus per-time) holds for any device and any OS. The crossover point does not transfer.
- PLATFORM-SPECIFICAdaptive polling exists under different names and with different work-budget rules per OS and driver framework; coalescing knobs are device- and driver-specific.