Choosing a Deferral Mechanism

When a hard interrupt handler runs, it is in interrupt context — it cannot sleep, it should not run long, and it must ACK the device and get out. Everything else is deferred work, and Linux offers four mechanisms to run it: softirqs, tasklets, threaded IRQ handlers, and workqueues. They are four points on a single trade-off curve between latency and the freedom to sleep. The decision is almost mechanical once you ask three questions in order: Does the work need to sleep? Who is allowed to add this work? How sensitive is it to latency? Get this right and the system stays responsive under load; get it wrong — the classic bug is calling a sleeping function from a softirq or tasklet — and you crash with a scheduling while atomic splat or, worse, livelock the machine. This note is the decision framework that ties together §D of the Interrupts MOC. It pins to Linux 6.12 LTS (released 2024-11-17, verified against kernel.org releases).

Mental Model — The Latency / Sleepability Trade-off

Picture the four mechanisms on a line. At one end, softirqs run the soonest after the interrupt, in atomic context, but you cannot sleep and only the kernel core may define one. At the other end, workqueues let you do anything a normal kernel thread can — sleep, take mutexes, allocate with GFP_KERNEL, fault on user memory — but pay a scheduler round-trip in latency. Tasklets and threaded IRQs sit between, and tasklets are on their way out.

flowchart LR
  TOP["Top half<br/>(hard IRQ)<br/>atomic, ACK only"] --> Q{"Must the work<br/>SLEEP?"}
  Q -->|"No · atomic OK"| ATOMIC["Atomic deferral"]
  Q -->|"Yes · blocks/mutex/GFP_KERNEL"| SLEEP["Sleepable deferral"]
  ATOMIC --> SIRQ["softirq<br/>kernel-core only · lowest latency<br/>concurrent per-CPU"]
  ATOMIC --> TASK["tasklet<br/>(DEPRECATED)<br/>serialized vs itself"]
  SLEEP --> THR["threaded IRQ<br/>one kthread per IRQ<br/>device handler that blocks"]
  SLEEP --> WQ["workqueue<br/>pooled kworkers<br/>most flexible, general work"]

The decision tree. What it shows: the first and most important fork is “does this work need to sleep?” — it splits the four mechanisms into an atomic pair (softirq, tasklet) and a sleepable pair (threaded IRQ, workqueue). The insight: sleepability is a hard wall, not a preference. A softirq/tasklet that sleeps is a kernel bug; a workqueue used where microsecond latency matters is a performance bug. Pick the side of the wall first, then optimize within it.

The Comparison — Five Axes

The four mechanisms differ along five axes that matter in practice. Read this table as the spine, then read the prose that justifies each cell.

AxisSoftirqTasklet (deprecated)Threaded IRQWorkqueue
Can it sleep?No (atomic)No (atomic)Yes (process context)Yes (process context)
LatencyLowest — runs on return-from-IRQLow — built on softirqMedium — wake one kthreadMedium/high — wake a pooled kworker
Frequency suited forVery high (NIC RX/TX, timers)ModeratePer-device, moderateGeneral, bursty, low–moderate
SerializationSame vector runs concurrently on different CPUsSame tasklet never runs on two CPUs at onceOne kthread per IRQ action; serialized per handlerPer-pwq concurrency mgmt; configurable max_active
Who may add itKernel core only (fixed compile-time vector)Any module (dynamic)Any driver via request_threaded_irq()Any code via queue_work()
Runs in__do_softirq / ksoftirqdsoftirq (TASKLET_SOFTIRQ)dedicated irq/N-name kthreadkworker/... (or softirq for WQ_BH)

Softirq — atomic, lowest latency, kernel-core only

A softirq is an entry in a fixed, compile-time array softirq_vec[NR_SOFTIRQS] (kernel/softirq.c, v6.12), registered with open_softirq(nr, action). The set is closed and small — HI, TIMER, NET_TX, NET_RX, BLOCK, IRQ_POLL, TASKLET, SCHED, HRTIMER, RCU. You cannot add a softirq from a module; it is reserved for kernel subsystems with extreme throughput needs (networking, timers, block I/O, RCU). Softirqs run with the lowest latency of any bottom half: after a hard IRQ handler returns, irq_exit() checks for pending softirqs and runs them right there (__do_softirq); only under sustained load does the work spill over to the per-CPU [[ksoftirqd and Softirq Load|ksoftirqd]] thread. The crucial property for correctness is that the same softirq vector can run concurrently on different CPUsNET_RX can be servicing packets on CPU 0 and CPU 1 simultaneously. That is what makes softirqs fast and scalable, but it also means a softirq handler’s per-CPU and shared data must be locked accordingly. Softirqs run in atomic context and must not sleep. Full mechanism in Softirqs and the Softirq Vector.

Tasklet — atomic, serialized against itself, but deprecated

A tasklet is a dynamically created deferral built on top of the TASKLET_SOFTIRQ (and HI_SOFTIRQ) vector — open_softirq(TASKLET_SOFTIRQ, tasklet_action) wires it up. Its historic appeal was a guarantee softirqs do not give: the same tasklet never runs on two CPUs at once (it is serialized against itself), so a tasklet handler needs no locking against itself. Any module could create one. It still runs in atomic context and still cannot sleep.

Tasklets are deprecated and new code should not use them. The reasons, per Corbet’s “The end of tasklets” (LWN, 2024-02-05): the API is error-prone — “the tasklet subsystem could end up writing to a structure that has been freed and allocated for another use, with predictably unpleasant consequences” — and tasklets “can create surprising latencies in the kernel.” The kernel’s strategy is to convert the 500-plus existing tasklet users to WQ_BH workqueues (which give the same softirq-context, low-latency, same-CPU execution but through the cleaner workqueue API) or to threaded handlers, gradually. See Tasklets and Their Deprecation.

Uncertain

Verify: the exact version WQ_BH (the tasklet replacement) was merged, and whether tasklets are formally removed in any released kernel as of mid-2026. Reason: the LWN article (2024-02-05) describes the WQ_BH series as targeting the 6.9 merge window — a forecast, not a confirmed release — and describes removal as a long, gradual effort, not a dated event. The v6.12 source confirms WQ_BH/system_bh_wq exist, so the replacement landed by 6.12; tasklets themselves were still present in v6.12. To resolve: check the WQ_BH merge commit and a recent git grep tasklet_schedule count. uncertain

Threaded IRQ — sleepable, one kthread per IRQ

A threaded interrupt handler moves the handler itself into a dedicated kernel thread. You register it with request_threaded_irq(irq, handler, thread_fn, flags, name, dev) (kernel/irq/manage.c, v6.12): the handler is a small primary handler that runs in hard-IRQ context and returns IRQ_WAKE_THREAD; the thread_fn then runs in a kthread where it can sleep. The kernel spawns one kthread per IRQ action, named irq/<irq>-<name> (a “secondary” handler gets irq/<irq>-s-<name>) via setup_irq_thread(). The defining flag is IRQF_ONESHOT, which keeps the interrupt line masked from the moment the primary handler returns until the thread finishes — essential for level-triggered lines, where leaving the line unmasked would re-fire the interrupt immediately. If a driver passes no primary handler, the kernel installs a default one (irq_default_primary_handler) that just returns IRQ_WAKE_THREAD.

The right shape for a threaded IRQ is a device handler that must block — talk to an I²C/SPI peripheral, take a sleeping lock, do a GFP_KERNEL allocation — in direct response to the device’s interrupt, where you want the handler tied to that specific IRQ (affinity, naming, masking). It is more device-coupled than a workqueue: the threading is part of the IRQ registration. PREEMPT_RT (mainlined in 6.12) leans heavily on this — it forces most handlers threaded by default. See Threaded Interrupt Handlers.

Workqueue — sleepable, pooled kworkers, most flexible

A workqueue is the general-purpose sleepable deferral: you queue_work() a function and a pooled [[Workqueue Internals kworker and Worker Pools|kworker]] runs it in process context, where anything goes — sleep, mutexes, GFP_KERNEL, faulting on user memory, calling other blocking kernel APIs. Unlike a threaded IRQ, a workqueue is not tied to any interrupt; it is the place for “deferred work that needs to sleep but isn’t the IRQ handler itself” — a driver’s slow-path completion, a periodic maintenance job, work kicked off from many call sites. The Concurrency-Managed Workqueue keeps thread count minimal (one runnable worker per CPU pool, more only when work blocks), so it scales to thousands of queued items without spawning a thread per item. The cost is latency: queueing wakes a kworker, which is a scheduler round-trip — fine for milliseconds-class work, wrong for the hot packet path. The full backend (pools, pwqs, concurrency management, rescuers) is in Workqueue Internals kworker and Worker Pools; the API and flags in Workqueues and the Concurrency-Managed Workqueue.

The Decision Procedure

Ask the questions in this order; the first hard constraint usually decides it.

  1. Does the work need to sleep? If it takes a mutex, allocates with GFP_KERNEL/GFP_NOFS, does I/O, or touches user memory — it must run in process context. That immediately rules out softirqs and tasklets and leaves threaded IRQ or workqueue. If it is purely computational and short (advance a state machine, push a packet up the stack, fire a completion), an atomic mechanism is fine.

  2. Who is allowed to add it? Only the kernel core can add a softirq — the vector is a fixed compile-time array. A driver or module therefore cannot choose a softirq even if it wants the latency; its atomic options are a tasklet (deprecated → prefer WQ_BH) and its sleepable options are a threaded IRQ or workqueue.

  3. Is it the IRQ handler itself, or follow-on work? If the sleepable work is the device’s interrupt response and benefits from being bound to that IRQ (masking via IRQF_ONESHOT, per-IRQ affinity, a named irq/N thread), use a threaded IRQ. If it is general follow-on work decoupled from the IRQ, use a workqueue.

  4. How latency-sensitive and how frequent? For very high frequency, lowest-latency atomic work that the core owns (NIC RX/TX, timer expiry), it is a softirq — and that is why those subsystems are core, not modules. For everything sleepable, a workqueue is the default; reach for a threaded IRQ when device coupling argues for it.

A compact restatement: kernel core + atomic + hot path → softirq. Module + atomic → historically a tasklet, now WQ_BH. Must sleep + is the device handler → threaded IRQ. Must sleep + general work → workqueue.

Classic Bugs

Calling a sleeping function from a softirq or tasklet. This is the canonical deferral bug. Anything in atomic context — softirq, tasklet, hard IRQ, or under a spinlock — must not call mutex_lock(), kmalloc(GFP_KERNEL), msleep(), copy_from_user(), or any function annotated might_sleep(). The kernel catches it: BUG: scheduling while atomic or BUG: sleeping function called from invalid context. The fix is to move that work to a workqueue or threaded handler — i.e. you chose the wrong side of the sleepability wall. (in_atomic() / in_interrupt() are the runtime predicates; see Interrupt Context and Why It Cannot Sleep.)

Using a workqueue where latency matters. Queueing to a workqueue costs a wakeup and a scheduler round-trip — typically microseconds to low milliseconds depending on load and where the kworker lands. On a high-rate path (per-packet, per-block-completion) that overhead dominates and you fall behind, manifesting as throughput collapse or growing backlog. The fix is to keep the hot path in a softirq (if you are the core) or restructure so the workqueue handles only the genuinely slow, bursty tail.

Choosing a tasklet in new code. It works today but you are writing on deprecated infrastructure with known use-after-free hazards and latency surprises; reviewers will (correctly) push back. Use a WQ_BH workqueue for the same atomic-same-CPU semantics, or a threaded handler/regular workqueue if you can sleep.

Assuming a softirq is serialized. Unlike a tasklet, the same softirq vector runs on multiple CPUs at once. Code that ports a tasklet to a softirq and drops the self-locking on the assumption of serialization races against itself across CPUs.

Alternatives Within the Choice

The four are not strictly ranked — they overlap, and the right answer is contextual:

  • Softirq vs. workqueue is the cleanest contrast: pure atomic-vs-sleepable plus core-vs-anyone. The network stack uses both — NET_RX softirq for the per-packet fast path, workqueues for slow control-plane operations.
  • Tasklet vs. WQ_BH workqueue is the deprecation migration: same softirq-context, same-CPU, low-latency execution, cleaner API. New atomic deferrals from drivers should be WQ_BH.
  • Threaded IRQ vs. workqueue is “is this the IRQ handler?” Both sleep; the threaded IRQ is coupled to a specific interrupt (masking, affinity, naming), the workqueue is free-floating general work. A driver often uses both: a threaded handler for the immediate sleepable response, then queue_work() for slower follow-up.
  • NAPI vs. raw softirq for floods: a device drowning in interrupts should switch to polling (NAPI) rather than taking one softirq per packet — that is interrupt mitigation, covered in Interrupt Storms and Livelock, and it is itself implemented on top of NET_RX_SOFTIRQ.

Production Notes

In real drivers the pattern is layered, not single-choice. A typical modern device driver registers a threaded IRQ (request_threaded_irq with IRQF_ONESHOT) so its handler can talk to the bus, and inside that handler may queue_work() slower maintenance onto a workqueue — using WQ_MEM_RECLAIM if the work sits on a reclaim path so a rescuer guarantees forward progress (see Workqueue Internals kworker and Worker Pools). PREEMPT_RT (mainlined in Linux 6.12) shifts the default: it forces most interrupt handlers threaded, so the “atomic” mechanisms shrink in scope and the sleepable ones dominate — a reason new code increasingly defaults to threaded handlers and workqueues over softirqs/tasklets. The kernel-wide tasklet conversion to WQ_BH is the visible end of this trend: the deferral landscape is consolidating toward workqueues for everything that can use them, with softirqs reserved for the genuine hot core paths.

See Also