Choose polling or interrupts from an event budget
Compare polling and interrupt completion times with an executable event budget, then identify overload and hybrid-driver boundaries.

You are connecting a device event stream to a software worker. Polling every ten ticks sounds inexpensive, while an interrupt sounds immediate. Neither description tells you when the work finishes. An interrupt handler can wait behind earlier handlers, and one polling pass can collect several events while paying its checking cost only once. You need an arrival pattern, a service budget and a definition of completion before choosing between them.
This lesson uses invented integer ticks and CPU costs. It does not measure a microcontroller, Linux interrupt latency or network throughput. The worked model compares a fixed polling schedule with an ideal interrupt queue, then uses a real Linux mechanism to explain why production designs often combine notification and polling.
Define the work before choosing the notification
Suppose events arrive during the closed interval [0, H]. Each event needs Cs ticks of CPU service. The polling worker checks at P, 2P, ... H, paying Cp ticks per check even when there is no event. There is no initial check at time zero. At each check, it samples all uncollected events that have already arrived and services that batch in arrival order. An event timestamped exactly at the sampling tick is visible to that check. Events arriving during the check’s subsequent CPU work wait for the next sample.
The interrupt alternative pays Ci ticks of overhead per event, followed by the same Cs service. Notifications are instantaneous, lossless and queued in arrival order. A single nonpreemptive worker processes them. These are deliberately strong assumptions: no interrupt masking, coalescing, dropped events, finite queue, priority interference or unrelated CPU work appears in this model.
The polling case has one further admission limit: every batch must satisfy Cp + batch_size * Cs <= P. This also applies to empty batches and the final batch. A check therefore finishes before or exactly when the next sample would occur. An overloaded batch is rejected; the program does not silently move sampling times or claim to model a saturated queue. At an exact tie, the preceding batch finishes before the next sample. A real design may queue, discard, throttle or change its polling policy instead.
The observation interval selects arrivals, not an absolute deadline for all processing. The last poll at H may finish after H, and the interrupt queue is also drained after the last selected arrival. Report that closing work. Dividing these complete work totals by H would not, by itself, give CPU utilization inside the finite observation interval.
Run two schedules with identical event counts
Use H = 40, P = 10, Cp = 1, Ci = 3 and Cs = 2. Four spaced arrivals occur at ticks 1, 11, 21 and 31. The first poll samples at 10, spends one tick checking and two serving, and finishes the first event at 13. The same reasoning gives completions 23, 33 and 43. Each event waits twelve ticks from arrival to completion.
An idle interrupt worker starts the first event at tick 1 and completes at 6. Its five ticks of work finish before the next spaced arrival. All four completion delays are five ticks. That improves this latency measure while using more CPU work: polling spends four checking ticks plus eight service ticks, whereas the interrupt worker spends twelve overhead ticks plus eight service ticks.
Now move all four arrivals to tick 1. The event count and total CPU costs are unchanged. Polling completes them at 13, 15, 17 and 19; the interrupt queue completes them at 6, 11, 16 and 21. Interrupts finish the first event sooner, but polling finishes the last event sooner. The result follows from paying one check for the whole batch rather than an overhead for each event. It does not establish a general latency advantage for polling. It establishes that average event rate alone cannot reconstruct a burst’s completion times.
The following Python program stores a finite arrival iterable once. It validates each original timestamp as a built-in integer, preserves simultaneous arrivals and rejects decreasing input. Its returned polling records contain the sampling tick, batch size and final completion time of that check. All results are simulated.
def compare(arrivals, horizon=40, period=10, poll_cost=1,
irq_cost=3, service=2):
values = (horizon, period, poll_cost, irq_cost, service)
if any(type(x) is not int for x in values):
raise ValueError('all parameters must be built-in integers')
if horizon <= 0 or period <= 0 or horizon % period:
raise ValueError('horizon must be a positive multiple of period')
if poll_cost < 0 or irq_cost < 0 or service <= 0:
raise ValueError('invalid CPU costs')
times = tuple(arrivals)
if any(type(t) is not int or not 0 <= t <= horizon for t in times):
raise ValueError('arrival outside the closed observation interval')
if any(a > b for a, b in zip(times, times[1:])):
raise ValueError('arrivals must be nondecreasing')
groups = {tick: [] for tick in range(period, horizon+1, period)}
for index, arrival in enumerate(times):
tick = max(period, ((arrival+period-1)//period)*period)
groups[tick].append(index)
if any(poll_cost+len(group)*service > period
for group in groups.values()):
raise ValueError('poll batch does not fit the declared period')
polling, polls = [None]*len(times), []
for tick, group in groups.items():
finish = tick + poll_cost
for index in group:
finish += service
polling[index] = finish
polls.append((tick, len(group), finish))
interrupt, finish = [], 0
for arrival in times:
finish = max(arrival, finish) + irq_cost + service
interrupt.append(finish)
return dict(arrivals=times, polling=tuple(polling),
interrupt=tuple(interrupt), polls=tuple(polls),
polling_work=len(groups)*poll_cost+len(times)*service,
interrupt_work=len(times)*(irq_cost+service))
spaced = (1, 11, 21, 31)
burst = (1, 1, 1, 1)
for name, times in (('spaced', spaced), ('burst', burst)):
result = compare(times)
print(name, result['polling'], result['interrupt'],
result['polling_work'], result['interrupt_work'])
print('completion delay:',
tuple(end-start for start, end in zip(times, result['polling'])),
tuple(end-start for start, end in zip(times, result['interrupt'])))
assert compare(())['polling_work'] == 4
assert compare(())['interrupt_work'] == 0
assert compare((1,))['polling_work'] == 6
assert compare((1,))['interrupt_work'] == 5
assert compare(spaced, period=5)['polling'] == (8, 18, 28, 38)
assert compare(spaced, period=5)['polling_work'] == 16
The printed results are:
spaced (13, 23, 33, 43) (6, 16, 26, 36) 12 20
completion delay: (12, 12, 12, 12) (5, 5, 5, 5)
burst (13, 15, 17, 19) (6, 11, 16, 21) 12 20
completion delay: (12, 14, 16, 18) (5, 10, 15, 20)
For the interrupt queue, each finish is max(arrival, previous_finish) + Ci + Cs. The maximum is essential. Using only arrival instead of max(arrival, previous_finish) would let simultaneous events occupy the same CPU at the same time. For polling, the rounded sampling tick is at least P; this handles an event at time zero without inventing a check that the contract did not schedule.
Find the CPU-work crossover
Let N be the number of events and K = H / P the number of checks. Under this model, polling work is K * Cp + N * Cs; interrupt work is N * (Ci + Cs). Subtracting cancels the shared service cost. Polling uses less CPU work when K * Cp < N * Ci.
With four checks, one checking tick and three overhead ticks per interrupt, the crossover is between one and two events. With no events, polling spends four ticks and the ideal interrupt worker spends none. With one event, their totals are six and five. With two events, their totals are eight and ten. Equality can occur for other parameter choices; it means equal work, not equal completion schedules.
The admission condition still matters. A high event count does not automatically make the polling trace valid. Five simultaneous arrivals in the example would require eleven ticks at one check, exceeding its ten-tick period. The function rejects that case. You cannot use the cost formula alone to claim that a rejected schedule meets a response deadline.
Shorten the period without hiding its price
For the four spaced arrivals, changing P from ten to five gives completions 8, 18, 28 and 38. Completion delay falls from twelve to seven ticks, while checking work doubles from four to eight. Total polling work rises from twelve to sixteen ticks. Interrupt results stay unchanged because their model does not use P.
This particular comparison remains admissible because each occupied check uses three ticks. Changing P to two does not: even one event needs three ticks of checking and service. The model refuses that input. A continuously spinning loop or a worker that reschedules its next check after finishing would need a different contract; neither is represented by reducing this period below the batch cost.
Detection delay and completion delay must also stay separate. An event at tick 10 is sampled immediately by that poll but still needs checking and service. In a multi-event batch, later events wait behind earlier service. If your requirement says a device must be acknowledged within a deadline, completing an application task may be the wrong endpoint entirely. Define which observable action counts before collecting measurements.
Why real interrupt paths often lead back to polling
The Linux generic IRQ documentation separates interrupt-controller details, interrupt flow handling and driver actions. Its different flow types are already a warning against interpreting every interrupt as one completed device operation. The model above represents none of the controller’s acknowledgement or masking behavior.
Linux networking provides a concrete combined design. The NAPI documentation describes interrupt notification scheduling a poll method, which processes available work subject to a receive budget. The associated interrupts should remain masked while polling is active and be unmasked after polling completes. It also describes busy polling that can invoke a poll method without a preceding interrupt. NAPI’s receive budget is not a universal bound on every unit of transmit-completion work, and a zero receive budget has specific restrictions. Those details matter when translating a conceptual batch into an actual driver.
Consequently, one interrupt per packet is an assumption to test, not a fact to copy into an estimate. Notification coalescing can reduce interrupt overhead; longer waiting can increase latency; budget exhaustion can require more polling. The two ideal schedules here are useful baselines, but neither implements NAPI or predicts a driver’s measured tail latency.
For a follow-up exercise, hold all public costs fixed and compare arrivals at ticks 9, 10 and 11, first separately and then together. Explain which poll samples each event and why equal detection times need not produce equal completion times. Then place five events at tick 1 and explain the rejection. A useful next experiment would record arrival, detection and completion separately, count empty checks and interrupt notifications, and include overload outcomes. Its conclusions should apply to the tested queue, scheduling policy and workload, rather than to the words “polling” and “interrupt” alone.