Operating Systems · Interrupts and Timers

Softirqs and the Per-CPU Deferred Queue

How the fixed softirq vector set, the per-CPU pending mask, and the budgeted processing loop cooperate, and why running twice on one CPU is a bug rather than a race.

A softirq is the cheapest way to run code shortly after an interrupt without paying for a thread. It has no task, no stack of its own, and no scheduling decision behind it: it runs on the same CPU, in the same context, immediately before the kernel returns to whatever it interrupted. The price of that cheapness is rigidity. The number of softirqs is fixed at compile time, their handlers cannot sleep, and the mechanism that makes them fast is also the mechanism that makes them dangerous to use carelessly.

The set is small. Receive processing, transmit completion, tasklet processing, high-resolution timer expiry, RCU callbacks, block-layer completion, scheduler balancing, and a few others each have a number. Adding one requires editing the enumeration in the kernel source, which is a deliberate barrier: the mechanism exists for a handful of latency-critical paths, not as a general-purpose queue.

A pending mask per CPU

The state that drives softirq processing is a per-CPU bitmask called __softirq_pending, one bit per softirq number. Raising a softirq sets the bit for the current CPU and, if the softirq is being raised from process context rather than from an interrupt, wakes ksoftirqd.

The mask is per-CPU, not global, which is the central design decision. A softirq raised on CPU 3 is processed on CPU 3, and the same softirq number can be pending and executing simultaneously on several CPUs. That gives locality: the data the interrupt just touched is in CPU 3’s caches, and the deferred work runs there. It also means no softirq handler may assume it is the only instance running, with the single exception of the serialization that the loop itself provides on one CPU.

The loop that consumes the mask reads it, clears it, and runs the handlers for the bits that were set. Clearing before running rather than after is what allows a handler that raises its own softirq to be seen again on the next pass instead of being lost. It also means a handler that raises a different softirq during its execution will have that new bit processed in the same pass, since the loop re-reads the mask.

/* The shape of the processing loop, after kernel/softirq.c. */
static void handle_softirqs(void)
{
    unsigned long end = jiffies + MAX_SOFTIRQ_TIME;   /* time budget */
    int max_restart = MAX_SOFTIRQ_RESTART;            /* count budget */
    struct softirq_action *h;
    int softirq_bit;
    __u32 pending;

    pending = local_softirq_pending();

restart:
    set_softirq_pending(0);      /* clear before running, so a re-raise is seen */
    local_irq_enable();          /* handlers run with IRQs on */

    h = softirq_vec;
    while ((softirq_bit = ffs(pending))) {
        h += softirq_bit - 1;    /* step to the handler for this bit */
        h->action();
        h++;
        pending >>= softirq_bit; /* drop the bits already consumed */
    }

    local_irq_disable();
    pending = local_softirq_pending();

    if (pending) {
        /* Re-run only while time, restart count, and preemption all allow it. */
        if (time_before(jiffies, end) && !need_resched() && --max_restart)
            goto restart;

        wakeup_softirqd();       /* defer the remainder */
    }
}

Three details in that shape carry most of the semantics. The first is local_irq_enable inside the loop: softirq handlers run with interrupts enabled, unlike the hard handler that raised them. A softirq can therefore be interrupted by a new hardware interrupt, which will in turn raise more softirqs. The nesting is bounded by the mask-and-recheck structure rather than by disabling interrupts.

The second is that there are two budgets, not one. The restart count bounds how many times the whole pending mask is re-read, and the time limit bounds how long the loop may keep going; the loop continues only while both allow it. A handler that re-raises its own softirq on every pass would exhaust the count even though almost no time had passed, and a handler with an expensive per-item cost would exhaust the time while the count was barely touched.

The third is the condition guarding the restart. Three tests must all pass before the loop goes round again: the time budget must not be exhausted, the restart count must not be exhausted, and the scheduler must not have set the reschedule flag. That last one is the important asymmetry. A softirq run from the tail of irq_exit is about to return to the interrupted task, and if that task has been marked for rescheduling, continuing to consume its CPU in softirq context delays the switch for no reason. The check hands the remainder to ksoftirqd instead, which runs as a thread and competes for the CPU on equal terms.

Where the loop is entered from

The common entry is irq_exit, at the tail of hardware interrupt handling. After the action list has run and the descriptor’s flow handler has finished, the kernel checks whether any softirq is pending and whether it is safe to run one. If so, the loop runs before the return to the interrupted context.

The check is not merely “is the mask nonzero”. Running softirqs from an interrupt that itself interrupted a softirq would nest the mechanism, so the kernel tracks whether it is already inside softirq processing and skips the tail call in that case, leaving the work for the outer invocation. Similarly, a softirq raised while the CPU is in an RCU read-side critical section or while a local lock is held may be deferred, since the handler could deadlock against the interrupted context.

The second entry is ksoftirqd, one thread per CPU, created at boot. It calls the same loop, with the same budget, but from process context. Because it is a thread it can be preempted and scheduled, which is what makes the budget meaningful: the remaining work is no longer holding the CPU against the scheduler.

The third entry is explicit invocation. A few paths call do_softirq directly, and the networking transmit path processes its completion softirq synchronously in some circumstances. These are exceptions and each one carries a comment explaining why the latency is acceptable there.

Why the run-twice rule exists

There is a check in the softirq machinery that fires if a softirq is entered while the same CPU is already running softirqs, or if it is entered with interrupts disabled in a context where that is not allowed. The rule behind it is that the loop must not be re-entered on one CPU.

The reason is state, not performance. The loop reads and clears the pending mask, and a nested entry would read a mask that the outer invocation has already cleared while the outer invocation’s handlers are still running. The inner pass would see nothing pending and return, or would see the bits the outer pass has not yet cleared and run those handlers concurrently with itself on the same CPU. Neither outcome is recoverable by the handler.

On a multiprocessor the situation is different and legal: two CPUs running the same softirq number is normal, and handlers that touch shared data must lock. The asymmetry between “same CPU is a bug” and “different CPU is expected” is the single most misunderstood property of the mechanism.

The per-CPU data contract

The locality that makes softirqs cheap also makes them require per-CPU data. A handler that keeps its state in a per-CPU variable needs no lock at all, because only one instance of that softirq runs per CPU at a time. A handler that keeps state in a global needs a spinlock, and because it runs with interrupts enabled it needs the irqsave variant only if the same data is touched from hard interrupt context.

The allocation side has its own rule. Per-CPU data must be allocated before the softirq can run on that CPU, and CPU hotplug means “before” is not a boot-time property. A softirq registered during module load must handle a CPU that comes online later, which is why per-CPU allocation is usually done with an explicit callback rather than a static array indexed by smp_processor_id().

get_cpu_var disables preemption for the duration of the access, which is stronger than it looks: a softirq handler that is preempted and migrated would touch the wrong CPU’s variable. The disable is not about the lock; it is about pinning.

Budgets and the latency they buy

The softirq budget is expressed two ways at once, as a time limit measured in jiffies and a restart count, and neither is a fixed architectural constant. The time limit bounds wall-clock time inside the loop and therefore bounds the delay before the interrupted task runs again. The restart count bounds a different failure: a handler that raises its own softirq would be re-entered on every pass, and a time limit alone would let it spin for the whole allowance each time it was scheduled. The count stops that at a small number of passes. Both values are build-time constants rather than runtime tunables, so the trade is made when the kernel is configured, not per workload.

The trade is direct. A larger budget reduces the number of handoffs to ksoftirqd, which reduces context switches and keeps processing in the cache-warm context. A smaller budget bounds the delay before a user process runs. On a machine doing packet forwarding the larger budget is usually right; on a machine running a latency-sensitive workload alongside a busy network device the smaller one is.

The failure mode of the budget is that it does not bound total latency, only per-invocation latency. A device that continuously raises a softirq will keep the loop running, hand off to ksoftirqd, and the thread will immediately run the loop again. The CPU is then effectively dedicated to the device, and the only escape is to reduce the interrupt rate at the source. That is the problem interrupt mitigation solves, and it is covered in NAPI and Interrupt Mitigation.

Interactions with the rest of the kernel

RCU callbacks are processed in a softirq, which is why a grace period can complete without a context switch. Timer expiry for high-resolution timers is also handled in a softirq, which is why the timer subsystem’s latency depends on how promptly the softirq loop runs after the interrupt. Block-layer completion runs in a softirq for devices that complete in interrupt context, which is why a storage device’s completion latency has a floor set by the softirq path.

Each of these is a case where the mechanism was chosen because it cannot sleep and is cheap, and each has accumulated special cases. The block layer, for instance, has an option to complete requests in a task context instead, precisely because a softirq completion can delay a high-priority task.

The relationship to the split described in Top Halves and Bottom Halves is that a softirq is one of the four deferral options, and the one with the least flexibility. Work that needs to sleep cannot use it, work that needs ordering cannot use it without building ordering on top, and work whose volume is unpredictable should probably use a workqueue whose backlog is visible. The softirq survives because for the paths that do fit, nothing is faster.

The other half of the deferred-work story is the timer. A softirq runs because something raised it; a timer runs because a clock reached a value. The clock side of that pair begins with Clock Sources, Clock Events, and the Tick.

References

  1. Generic IRQ Handling — The Linux kernel documentation
  2. Linux Device Drivers, third edition, Chapter 10: Interrupt Handling — Corbet, Rubini & Kroah-Hartman, O'Reilly Media
  3. Linux kernel source, kernel/softirq.c — The Linux kernel