The Tasklet: Why Some Softirqs Run on the CPU That Scheduled Them

2026-06-30

Softirqs are great for throughput but terrible for code that isn't reentrant. A network softirq can run on every CPU simultaneously, so the handler must be SMP-safe down to every variable it touches. For driver writers in the 1990s, that bar was too high — most legacy drivers assumed a single bottom-half ran at a time. The tasklet exists as the compromise: it rides the softirq infrastructure but adds a per-tasklet lock so the same tasklet never runs on two CPUs at once.

Mechanically, tasklets are built on top of two specific softirq vectors: TASKLET_SOFTIRQ and HI_SOFTIRQ. When you call tasklet_schedule(), the kernel appends your tasklet to a per-CPU linked list (tasklet_vec) on the current CPU and raises the softirq locally. That's the surprising part: the tasklet will fire on whichever CPU scheduled it, not whichever CPU is idle. There's no migration, no load balancing. If your interrupt always lands on CPU 3, every tasklet it schedules executes on CPU 3 too.

The serialization comes from a TASKLET_STATE_RUN bit in the tasklet struct. Before executing, the softirq handler does a test_and_set_bit. If another CPU somehow already holds it (rare, but possible if the tasklet was rescheduled mid-flight), the current CPU skips it and re-raises the softirq. The same tasklet can therefore queue while running, but it cannot run while running.

Real example: the legacy floppy.c driver in Linux uses a tasklet (floppy_tq) to process command completion. The floppy hardware is gloriously non-reentrant — there's literally one controller — so serializing the bottom-half via tasklet semantics means the driver never had to be rewritten for SMP. Same story for many older sound and serial drivers. Newer code prefers workqueues (sleepable, schedulable) or raw softirqs (parallel), but tasklets persist exactly because rewriting working drivers is a bad trade.

Rule of thumb: if your bottom-half work is under ~10 µs, non-blocking, and touches a shared resource you can't lock cheaply, use a tasklet. If it's longer or might sleep, use a workqueue. If it's truly parallel-safe, write a softirq (but you almost certainly shouldn't — softirqs are a fixed enum, not a registration API).

One footgun: tasklet_disable() spins until any in-flight execution finishes. Calling it from the same context that runs the tasklet deadlocks instantly. The 2021 tasklet_setup() API change (replacing data with a typed callback) was driven by Spectre v1 mitigations on the indirect call — another reason new code avoids them.

See it in action: Check out IRQs: the Hard, the Soft, the Threaded and the Preemptible by The Linux Foundation to see this theory applied.
Key Takeaway: Tasklets are softirqs with a per-instance lock and CPU affinity to the scheduler — same throughput class as softirqs, but the kernel guarantees only one CPU runs a given tasklet at a time.

All newsletters