Tasklets and Their Deprecation

A tasklet is a dynamically-created bottom half — a deferred-work item a driver allocates at runtime and schedules from an interrupt — built directly on top of the softirq layer, specifically the TASKLET_SOFTIRQ and HI_SOFTIRQ softirq vectors. Its one defining property, the reason it was ever attractive, is serialization against itself: a given tasklet is “strictly serialized wrt itself” and “is running only on one CPU simultaneously” — even if it is scheduled on two CPUs at once, the kernel guarantees only one instance runs at a time (per the in-tree comment in include/linux/interrupt.h, Linux v6.12). That made tasklets easier to write than a raw softirq, whose handler can run concurrently on every CPU and must therefore be fully reentrant. Like all softirq-based work, a tasklet runs in atomic context and cannot sleep. The catch — and the reason this note carries the word “deprecation” in its title — is that the tasklet API is now officially discouraged: the kernel header itself says “This API is deprecated. Please consider using threaded IRQs instead,” and as of the 6.9 kernel a replacement, the BH (bottom-half) workqueue, exists specifically so that the remaining tasklet users can be converted and the API eventually removed (LWN, “The end of the tasklet”; the BH-workqueue patch series).

This note pins to Linux 6.12 LTS (released 2024-11-17, verified at kernel.org/releases); the structures and APIs below were read from the v6.12 tree, where tasklets very much still exist.

Uncertain

Verify: that tasklets are being phased out (an ongoing migration), not removed. As of v6.12 the entire tasklet API (tasklet_struct, tasklet_schedule, DECLARE_TASKLET, tasklet_action/tasklet_hi_action, TASKLET_SOFTIRQ/HI_SOFTIRQ) is still present and exported — confirmed by direct reads of include/linux/interrupt.h and kernel/softirq.c at the v6.12 tag. The deprecation is a direction of travel, not a dated removal: the header comment says “deprecated, consider threaded IRQs,” and the BH-workqueue mechanism (merged for 6.9) is the conversion target. Reason for flag: “deprecation” status is an in-progress effort with no announced removal release, and the canonical lore deprecation thread (linutronix, 2020) was blocked (Anubis anti-bot) when fetched for this note, so its exact wording is cited from the header’s reference to it rather than read directly. To resolve: re-fetch the lore thread when accessible, and check the latest tasklet-removal series on LWN/lore at reading time for the current count of remaining users and any removal target. uncertain

Mental Model — A Serialized Wrapper Over a Softirq

The right way to picture a tasklet is as a convenience layer: the softirq subsystem provides exactly two general-purpose softirq vectors, TASKLET_SOFTIRQ (normal priority) and HI_SOFTIRQ (high priority), whose handlers walk a per-CPU linked list of tasklet_structs and run each one — but with a lock that ensures any single tasklet executes on only one CPU at a time. So when you schedule a tasklet, you are not getting a new kernel mechanism; you are appending your callback to the list that one of these two softirqs drains, and inheriting the softirq’s timing, its atomic context, and its ksoftirqd throttling — plus a self-serialization guarantee that raw softirqs do not give you.

flowchart TB
  DRV["Driver top half<br/>tasklet_schedule(&t)"] --> SET{"TASKLET_STATE_SCHED<br/>already set?"}
  SET -->|"yes (already queued)"| DROP["do nothing<br/>(coalesced — runs once)"]
  SET -->|"no"| ADD["__tasklet_schedule()<br/>append t to per-CPU tasklet_vec<br/>raise_softirq_irqoff(TASKLET_SOFTIRQ)"]
  ADD --> SIRQ["TASKLET_SOFTIRQ runs<br/>(inline at irq_exit, or ksoftirqd)"]
  SIRQ --> ACT["tasklet_action_common()<br/>walks the list"]
  ACT --> TRY{"tasklet_trylock(t)?<br/>(TASKLET_STATE_RUN)"}
  TRY -->|"fail: running on another CPU"| REQ["requeue t,<br/>re-raise TASKLET_SOFTIRQ"]
  TRY -->|"ok"| CNT{"atomic count == 0?<br/>(not disabled)"}
  CNT -->|"no (disabled)"| UNL["unlock, leave queued"]
  CNT -->|"yes"| RUN["clear SCHED, run callback<br/>t->callback(t) / t->func(data)"]
  RUN --> ULK["tasklet_unlock(t)<br/>clear RUN"]

The life of one tasklet from scheduling to execution. What it shows: scheduling sets a SCHED bit (so a double-schedule coalesces into one run), appends the tasklet to a per-CPU list, and raises the softirq. When the softirq runs, tasklet_action_common() walks the list and uses tasklet_trylock() on a RUN bit to enforce that the same tasklet is never entered twice concurrently — if it is already running on another CPU, the tasklet is requeued instead of run. The insight to take: the two bits — SCHED (queued) and RUN (executing) — together implement both coalescing and the famous cross-CPU self-serialization, and the atomic count provides the disable/enable refcount. Everything else is just a softirq.

The Data Structure and the Two State Bits

The whole mechanism lives in struct tasklet_struct (from include/linux/interrupt.h, v6.12):

struct tasklet_struct
{
	struct tasklet_struct *next;   /* singly-linked list of pending tasklets */
	unsigned long state;           /* TASKLET_STATE_SCHED / _RUN bits */
	atomic_t count;                /* disable refcount: !=0 means disabled */
	bool use_callback;             /* new (callback) vs old (func) calling convention */
	union {
		void (*func)(unsigned long data);          /* old signature */
		void (*callback)(struct tasklet_struct *t); /* new signature */
	};
	unsigned long data;            /* opaque arg for the old func() form */
};

Two bits in state carry the entire concurrency contract (enum { TASKLET_STATE_SCHED, TASKLET_STATE_RUN }):

  • TASKLET_STATE_SCHED — set when the tasklet is on a CPU’s pending list, cleared just before it runs. It is what makes scheduling idempotent: tasklet_schedule() only actually enqueues if it can transition this bit from clear to set, so scheduling the same tasklet ten times before it runs results in it running once.
  • TASKLET_STATE_RUN — set (on SMP) while the callback is executing, via tasklet_trylock(). It is what enforces never-concurrent-with-itself across CPUs: before running a tasklet, the executor must win this bit; if another CPU holds it, the tasklet is requeued for later instead of run in parallel.

The atomic_t count is a disable refcount, separate from the two bits. Zero means enabled; any positive value means disabled, and the executor skips (but leaves queued) a tasklet whose count is non-zero. tasklet_disable() increments it, tasklet_enable() decrements it — this is how you temporarily pause a tasklet without destroying it.

Scheduling — How the SCHED Bit Coalesces

tasklet_schedule() is the entry point a top half calls:

static inline void tasklet_schedule(struct tasklet_struct *t)
{
	if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
		__tasklet_schedule(t);
}

test_and_set_bit() atomically sets TASKLET_STATE_SCHED and returns its old value. If it was already set (tasklet already queued and not yet started), the if is false and nothing happens — the redundant schedule is dropped. Only on the clear→set transition does it call __tasklet_schedule(), which appends the tasklet to the per-CPU tasklet_vec list and raises TASKLET_SOFTIRQ via raise_softirq_irqoff() (__tasklet_schedule_common(), v6.12 kernel/softirq.c). tasklet_hi_schedule() is identical except it uses the tasklet_hi_vec list and raises HI_SOFTIRQ — and HI_SOFTIRQ is softirq index 0, the very first softirq drained, so high-priority tasklets run ahead of timers, networking, and everything else. (Reserve tasklet_hi_schedule() for genuinely latency-critical work — e.g. some sound drivers use it — because it preempts the entire rest of the softirq set.)

You create a tasklet either statically with the DECLARE_TASKLET macro or at runtime with tasklet_setup():

/* static, enabled at boot, new callback convention */
#define DECLARE_TASKLET(name, _callback)	\
struct tasklet_struct name = {			\
	.count = ATOMIC_INIT(0),		\
	.callback = _callback,			\
	.use_callback = true,			\
}
 
/* runtime init, new convention */
void tasklet_setup(struct tasklet_struct *t,
		   void (*callback)(struct tasklet_struct *));

Note DECLARE_TASKLET sets count = 0 (enabled); the sibling DECLARE_TASKLET_DISABLED sets count = 1 so the tasklet starts disabled until you call tasklet_enable(). tasklet_setup() is significant beyond convenience: it was introduced as part of the cleanup that gave tasklets a typed callback signature — void callback(struct tasklet_struct *t) — replacing the old void func(unsigned long data) form (DECLARE_TASKLET_OLD/tasklet_init(), still present for legacy users). The new form lets a callback recover its own structure with from_tasklet() (a container_of) instead of casting an unsigned long, eliminating a whole class of type-confusion bugs. This tasklet_setup() conversion was a deliberate first step in modernising tasklets — tidying the API even as the longer-term plan became to remove it (v6.12 interrupt.h).

Execution — Where the Serialization Actually Happens

When TASKLET_SOFTIRQ runs, its handler tasklet_action() calls tasklet_action_common(), which is where the cross-CPU guarantee is enforced (kernel/softirq.c, v6.12):

static void tasklet_action_common(struct tasklet_head *tl_head,
				  unsigned int softirq_nr)
{
	struct tasklet_struct *list;
 
	local_irq_disable();
	list = tl_head->head;          /* detach the whole pending list */
	tl_head->head = NULL;
	tl_head->tail = &tl_head->head;
	local_irq_enable();
 
	while (list) {
		struct tasklet_struct *t = list;
		list = list->next;
 
		if (tasklet_trylock(t)) {              /* win TASKLET_STATE_RUN ... */
			if (!atomic_read(&t->count)) {     /* ... and not disabled */
				if (tasklet_clear_sched(t)) {  /* clear SCHED before running */
					if (t->use_callback)
						t->callback(t);
					else
						t->func(t->data);
				}
				tasklet_unlock(t);
				continue;
			}
			tasklet_unlock(t);
		}
		/* couldn't run it (running elsewhere, or disabled): requeue */
		local_irq_disable();
		t->next = NULL;
		*tl_head->tail = t;
		tl_head->tail = &t->next;
		__raise_softirq_irqoff(softirq_nr);
		local_irq_enable();
	}
}

Walking it: the handler first detaches the entire pending list under a brief IRQ-off section, then processes each entry. For each tasklet t it calls tasklet_trylock(t), which does test_and_set_bit(TASKLET_STATE_RUN, ...). If that fails, some other CPU is already running this exact tasklet — so this CPU does not run it; it falls through to the requeue path, puts t back on the list, and re-raises the softirq so t gets another chance later. That is the cross-CPU serialization, in code: the RUN bit is a one-holder lock on the tasklet’s body. If trylock succeeds, it then checks count — if non-zero the tasklet is disabled, so it just unlocks and leaves it queued; if zero, it clears the SCHED bit (so the tasklet can be re-scheduled while it runs) and invokes the callback. Because the body runs inside a softirq, it runs in atomic context: it cannot call any function that might sleep — no mutexes, no GFP_KERNEL allocations, no copy_to_user(), no msleep(). Violating this is the classic tasklet bug, surfaced by the kernel as a “scheduling while atomic” or “sleeping function called from invalid context” splat.

The tasklet_disable() Sleeping-vs-Atomic Gotcha

tasklet_disable() hides a context trap that trips real drivers. It is built from two steps:

static inline void tasklet_disable_nosync(struct tasklet_struct *t)
{
	atomic_inc(&t->count);          /* mark disabled */
	smp_mb__after_atomic();
}
 
static inline void tasklet_disable(struct tasklet_struct *t)
{
	tasklet_disable_nosync(t);
	tasklet_unlock_wait(t);         /* wait until it is not RUNNING */
	smp_mb();
}

tasklet_disable_nosync() just bumps the refcount and returns — it guarantees the tasklet won’t start again, but says nothing about an instance already in flight. The full tasklet_disable() adds tasklet_unlock_wait(), which blocks until TASKLET_STATE_RUN clears — i.e. until any currently-executing instance finishes. The gotcha is that tasklet_unlock_wait() uses wait_var_event(), which can sleep. So tasklet_disable() must not be called from atomic context (from an interrupt handler, a softirq, or while holding a spinlock) — if the tasklet happens to be running on another CPU, you would block in a context where blocking is forbidden, or worse, spin-wait while holding the very thing that prevents the tasklet from finishing, deadlocking. The kernel even ships a separate tasklet_disable_in_atomic() for the rare caller that genuinely must disable from atomic context (it busy-waits via tasklet_unlock_spin_wait() instead), and its comment is blunt: “Do not use in new code. Disabling tasklets from atomic contexts is error prone and should be avoided” (v6.12 interrupt.h). The safe rule: call tasklet_disable()/tasklet_kill() only from process context. tasklet_kill() likewise sleeps (it wait_var_event()s on the SCHED bit) and explicitly warns if called from interrupt context.

The Deprecation — Why, and What Replaces It

Tasklets have been “on the chopping block for many years, but no such effort has succeeded to date” (LWN 2024). Several flaws drove the latest, finally-credible removal effort:

  • A use-after-free hazard for one-shot tasklets. The tasklet executor may touch the tasklet_struct after the callback returns (e.g. to manipulate the list/state). If a one-shot tasklet’s callback frees its own structure — a natural pattern — the subsystem then writes to freed memory. Mikulas Patocka identified this class of bug; it is hard to use the API correctly for self-freeing work (LWN 2024).
  • Latency and the “random victim” problem inherited from softirqs. Because tasklets ride on softirqs, they run in whatever task happens to be interrupted, hurting that task’s latency unpredictably, and they are awkward for the preemptible, real-time kernel.
  • Interface mistakes. Linus Torvalds summed it up while proposing the replacement: “I think if we introduced a workqueue that worked more like a tasklet — in that it’s run in softirq context — but doesn’t have the interface mistakes of tasklets, a number of existing workqueue users might decide that that is exactly what they want” (LWN 2024).

That idea became the BH workqueue (“BH” = bottom half), implemented by workqueue maintainer Tejun Heo and merged for the 6.9 kernel. A BH workqueue (WQ_BH) has the full, well-designed workqueue API — proper flush/cancel, no self-touch-after-return hazard — but its work items run “in softirq context, on the same CPU,” keeping the low-latency atomic-context behaviour that made tasklets useful (BH-workqueue series). The kernel ships two ready-made system instances so most callers need not create their own: system_bh_wq (normal) and system_bh_highpri_wq (high priority). The conversion is mechanical, roughly:

TaskletBH workqueue replacement
tasklet_setup() / tasklet_init()INIT_WORK()
tasklet_schedule(&t)queue_work(system_bh_wq, &w)
tasklet_hi_schedule(&t)queue_work(system_bh_highpri_wq, &w)

Tejun Heo’s own series began converting real drivers (for example the r8152 USB Ethernet driver) as proof (r8152 conversion patch). The endgame, as LWN puts it: “once that conversion is complete… it will be possible to run WQ_BH workqueues directly from a software interrupt and remove the tasklet API entirely” — at which point TASKLET_SOFTIRQ/HI_SOFTIRQ themselves can be retired (LWN 2024). For most drivers, though, the simplest migration is not even to a BH workqueue but to a threaded IRQ, which is exactly what the in-tree header recommends: “This API is deprecated. Please consider using threaded IRQs instead.”

What To Use Instead — A Decision Sketch

For new code, do not reach for a tasklet. If the work can sleep, use a threaded IRQ (request_threaded_irq) or an ordinary workqueue — both run in a schedulable thread where blocking is allowed. If the work genuinely must run in low-latency atomic (softirq) context and you want self-serialization and proper cancel/flush, use a BH workqueue (system_bh_wq / WQ_BH), the purpose-built tasklet replacement. The only reason to touch the tasklet API now is to maintain or convert existing users. See Choosing a Deferral Mechanism for the full comparison across softirqs, BH workqueues, workqueues, and threaded IRQs.

Common Misunderstandings

  • “A tasklet is a thread / a kind of task.” No. Despite the name, a tasklet is not a task_struct and has nothing to do with the scheduler directly. It is a callback on a softirq list. The name is a historical accident.
  • “Tasklets can sleep because they’re ‘lighter’ than softirqs.” No — they are heavier wrappers over softirqs and run in the same atomic context; they cannot sleep.
  • tasklet_disable() is safe to call anywhere.” No — it can sleep and must run in process context (covered above).
  • “Two different tasklets are serialized against each other.” No. The serialization guarantee is each tasklet against itself only. Distinct tasklets can run concurrently on different CPUs; if they share data they need their own spinlock — the header says exactly this: “Tasklet is strictly serialized wrt itself, but not wrt another tasklets.”
  • “Tasklets are gone in modern kernels.” Not in 6.12 — they are deprecated and being migrated away, but the API is fully present (see the uncertainty flag at the top).

See Also