Interrupt Handling in the Linux Kernel — Part 5
Interrupt Handling in the Linux Kernel — Part 5: Tasklets
Part 4 covers top halves, bottom halves, softirqs, ksoftirqd,
local_bh_disable/local_bh_enable, and spinlock variants in interrupt context:part3_doc_part4.md
Tasklets are the kernel’s dynamic, per-module deferred execution mechanism — built on top of the softirq framework covered in Part 4. Where softirqs are statically compiled into the kernel with a fixed set of ten vectors, tasklets give any driver or module the ability to defer work into softirq context without touching the core kernel. A tasklet runs with hardware interrupts enabled but in softirq context, meaning it cannot sleep. Its defining property is automatic serialization: the kernel guarantees that a given tasklet instance will never run on more than one CPU at the same time, eliminating the concurrency headaches that make raw softirqs difficult to use correctly. This simplicity comes with a tradeoff — whereas the same softirq handler can execute simultaneously on every CPU in the system (giving maximum throughput), a tasklet is strictly single-threaded per instance. For the vast majority of drivers that need a fast, non-sleeping bottom half, tasklets provide the right balance of performance and safety. That said, the tasklet API is officially deprecated in favor of threaded IRQs (request_threaded_irq()) for new code — but understanding tasklets remains essential for reading and maintaining the enormous body of existing kernel and driver code that uses them.
Table of Contents
- What Is a Tasklet and Why Does It Exist?
- HI_SOFTIRQ vs TASKLET_SOFTIRQ
- The tasklet_struct Structure
- The state Field: TASKLET_STATE_SCHED and TASKLET_STATE_RUN
- The count Field: Enable and Disable Reference Counter
- Declaring a Tasklet: Static vs Dynamic
- Scheduling a Tasklet: tasklet_schedule and tasklet_hi_schedule
- The Internal Scheduling Path: __tasklet_schedule_common
- How the Bitmask Is Used by tasklet_schedule and tasklet_hi_schedule
- What Happens If tasklet_schedule Is Called Twice?
- Which Softirq Does tasklet_hi_schedule Use?
- When to Use tasklet_hi_schedule
- How the Tasklet Softirq Handler Executes Tasklets: tasklet_action_common
- Preventing Concurrent Execution: tasklet_trylock and tasklet_unlock
- Can the Same Softirq Run on Multiple Processors?
- Same Softirq Running on Multiple CPUs: Race Conditions and Why Tasklets Are Different
- Which Lock to Use in Tasklet Context
- Sleeping in a Tasklet Handler
- Interrupt State While a Tasklet Runs
- Execution Context of Tasklets and Softirqs
- Detecting Hard vs Soft IRQ Context in a Tasklet
- CPU Affinity of Tasklets
- Enabling and Disabling Tasklets
- What Happens If a Tasklet Is Scheduled and You Call tasklet_kill?
- Softirqs vs Tasklets: A Detailed Comparison
- Tasklet Deprecation: History, Discussion, and the Future
- Why the Tasklet API Is Deprecated
- Callback-Based vs func + data API: What Changed and Why
- Complete Driver Examples: New API and Old API
- End-to-End Flow: From Driver Registration to Tasklet Execution
- Re-Queuing a Tasklet: Which CPU Gets It?
- Is TASKLET_STATE_RUN a Global State?
- Summary
What Is a Tasklet and Why Does It Exist?
A tasklet is a dynamically allocatable bottom-half mechanism that runs in softirq context. It was introduced to solve a specific problem: softirqs are extremely high-performance but extremely difficult to use correctly because the same softirq handler can run simultaneously on every CPU. Any shared data must be protected with per-CPU variables or fine-grained locking, which is error-prone and complex.
Most drivers do not need that level of concurrency. A typical device driver just needs a fast way to defer work from its hardirq handler, with the guarantee that its deferred function will not be re-entered concurrently. Tasklets provide exactly this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌─────────────────────────────────────────────────────────────────────────────────┐
│ WHY TASKLETS EXIST │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ THE PROBLEM WITH RAW SOFTIRQS: │
│ ────────────────────────────── │
│ ● Only 10 vectors — statically compiled, cannot add from a module │
│ ● Same handler runs on multiple CPUs simultaneously │
│ ● Handler must use per-CPU data or fine-grained locking │
│ ● Complex, error-prone for driver authors │
│ │
│ WHAT TASKLETS PROVIDE: │
│ ────────────────────── │
│ ● Unlimited instances — any module can create them │
│ ● Serialized per-instance — never runs on two CPUs at once │
│ ● Still runs in softirq context — fast, interrupts enabled │
│ ● Simple API: declare, schedule, done │
│ ● Built on top of two existing softirq vectors (HI_SOFTIRQ, TASKLET_SOFTIRQ) │
│ │
│ THE ARCHITECTURE: │
│ ───────────────── │
│ │
│ Tasklets are NOT a separate mechanism — they are implemented AS │
│ softirq handlers for HI_SOFTIRQ (index 0) and TASKLET_SOFTIRQ (index 6). │
│ Each CPU maintains a per-CPU linked list of pending tasklets. When the │
│ softirq fires, the handler (tasklet_action or tasklet_hi_action) walks │
│ the list and executes each tasklet. │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
The kernel comment above the tasklet API in include/linux/interrupt.h, lines 668–689 summarizes these properties:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Tasklets --- multithreaded analogue of BHs.
This API is deprecated. Please consider using threaded IRQs instead:
https://lore.kernel.org/lkml/20200716081538.2sivhkj4hcyrusem@linutronix.de
Main feature differing them of generic softirqs: tasklet
is running only on one CPU simultaneously.
Main feature differing them of BHs: different tasklets
may be run simultaneously on different CPUs.
Properties:
* If tasklet_schedule() is called, then tasklet is guaranteed
to be executed on some cpu at least once after this.
* If the tasklet is already scheduled, but its execution is still not
started, it will be executed only once.
* If this tasklet is already running on another CPU (or schedule is called
from tasklet itself), it is rescheduled for later.
* Tasklet is strictly serialized wrt itself, but not
wrt another tasklets. If client needs some intertask synchronization,
he makes it with spinlocks.
*/
HI_SOFTIRQ vs TASKLET_SOFTIRQ
Tasklets are served by two softirq vectors, defined in the softirq enum at include/linux/interrupt.h, lines 550–563:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum
{
HI_SOFTIRQ=0, /* index 0 — highest priority softirq */
TIMER_SOFTIRQ, /* index 1 */
NET_TX_SOFTIRQ, /* index 2 */
NET_RX_SOFTIRQ, /* index 3 */
BLOCK_SOFTIRQ, /* index 4 */
IRQ_POLL_SOFTIRQ, /* index 5 */
TASKLET_SOFTIRQ, /* index 6 — normal priority tasklet */
SCHED_SOFTIRQ, /* index 7 */
HRTIMER_SOFTIRQ, /* index 8 */
RCU_SOFTIRQ, /* index 9 */
NR_SOFTIRQS
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌─────────────────────────────────────────────────────────────────────────────────┐
│ HI_SOFTIRQ vs TASKLET_SOFTIRQ │
├────────────────────────────────┬────────────────────────────────────────────────┤
│ HI_SOFTIRQ (index 0) │ TASKLET_SOFTIRQ (index 6) │
├────────────────────────────────┼────────────────────────────────────────────────┤
│ Highest priority softirq │ Normal priority — runs after TIMER, │
│ Runs BEFORE all others │ NET_TX, NET_RX, BLOCK, IRQ_POLL │
│ including TIMER and NET_RX │ │
├────────────────────────────────┼────────────────────────────────────────────────┤
│ Handler: tasklet_hi_action() │ Handler: tasklet_action() │
├────────────────────────────────┼────────────────────────────────────────────────┤
│ Per-CPU list: tasklet_hi_vec │ Per-CPU list: tasklet_vec │
├────────────────────────────────┼────────────────────────────────────────────────┤
│ Scheduled via: │ Scheduled via: │
│ tasklet_hi_schedule() │ tasklet_schedule() │
├────────────────────────────────┼────────────────────────────────────────────────┤
│ Use case: latency-critical │ Use case: normal driver bottom halves │
│ work that must run before │ (the vast majority of tasklets) │
│ timers and networking │ │
└────────────────────────────────┴────────────────────────────────────────────────┘
Processing order in handle_softirqs():
ffs() finds lowest set bit first
HI(0) → TIMER(1) → NET_TX(2) → NET_RX(3) → BLOCK(4) → IRQ_POLL(5) → TASKLET(6) → ...
▲ ▲
│ │
tasklet_hi_schedule() tasklet_schedule()
runs here — before runs here — after
everything else networking
Both HI_SOFTIRQ and TASKLET_SOFTIRQ are registered during boot by softirq_init() at kernel/softirq.c, lines 1048–1061:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void __init softirq_init(void)
{
int cpu;
for_each_possible_cpu(cpu) {
per_cpu(tasklet_vec, cpu).tail =
&per_cpu(tasklet_vec, cpu).head;
per_cpu(tasklet_hi_vec, cpu).tail =
&per_cpu(tasklet_hi_vec, cpu).head;
}
open_softirq(TASKLET_SOFTIRQ, tasklet_action);
open_softirq(HI_SOFTIRQ, tasklet_hi_action);
}
This function initializes the per-CPU linked lists for both normal and high-priority tasklets, then registers their softirq handlers. The tail pointer is initialized to point to the head field, forming an empty list (head == NULL, tail points to &head).
The tasklet_struct Structure
Each tasklet is represented by a struct tasklet_struct, defined at include/linux/interrupt.h, lines 691–702:
1
2
3
4
5
6
7
8
9
10
11
12
struct tasklet_struct
{
struct tasklet_struct *next;
unsigned long state;
atomic_t count;
bool use_callback;
union {
void (*func)(unsigned long data);
void (*callback)(struct tasklet_struct *t);
};
unsigned long data;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌─────────────────────────────────────────────────────────────────────────────────┐
│ struct tasklet_struct │
├───────────────────┬─────────────────────────────────────────────────────────────┤
│ Field │ Purpose │
├───────────────────┼─────────────────────────────────────────────────────────────┤
│ next │ Pointer to the next tasklet in the per-CPU linked list. │
│ │ The per-CPU tasklet_vec / tasklet_hi_vec list chains │
│ │ tasklets through this pointer. NULL if this is the last │
│ │ tasklet in the list or the tasklet is not on any list. │
├───────────────────┼─────────────────────────────────────────────────────────────┤
│ state │ Bitmask tracking the tasklet's lifecycle state. │
│ │ Two bits defined: TASKLET_STATE_SCHED (bit 0) and │
│ │ TASKLET_STATE_RUN (bit 1). Used to prevent double │
│ │ scheduling and concurrent execution. │
├───────────────────┼─────────────────────────────────────────────────────────────┤
│ count │ Atomic reference counter for enabling/disabling. │
│ │ count == 0 means the tasklet is enabled. │
│ │ count > 0 means the tasklet is disabled. │
│ │ A disabled tasklet can be scheduled but will NOT execute. │
├───────────────────┼─────────────────────────────────────────────────────────────┤
│ use_callback │ Boolean flag distinguishing old API (func) from new API │
│ │ (callback). New code uses callback; old code uses func. │
├───────────────────┼─────────────────────────────────────────────────────────────┤
│ func / callback │ Union of two function pointer styles: │
│ (union) │ • func(unsigned long data) — old API, receives opaque data │
│ │ • callback(struct tasklet_struct *t) — new API, receives │
│ │ the tasklet itself (use container_of / from_tasklet │
│ │ to get the enclosing structure) │
├───────────────────┼─────────────────────────────────────────────────────────────┤
│ data │ Opaque data passed to func() in the old API. │
│ │ Unused in the new callback-based API. │
└───────────────────┴─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Memory Layout (64-bit AArch64)
══════════════════════════════
Offset Size Field
────── ──── ──────────────
0x00 8 next (struct tasklet_struct *)
0x08 8 state (unsigned long)
0x10 4 count (atomic_t → int)
0x14 1 use_callback (bool)
0x15 3 (padding)
0x18 8 func/callback (union of function pointers)
0x20 8 data (unsigned long)
──────
Total: 0x28 = 40 bytes
The state Field: TASKLET_STATE_SCHED and TASKLET_STATE_RUN
The state field is a bitmask with two defined bits, declared as an enum at include/linux/interrupt.h, lines 733–737:
1
2
3
4
5
enum
{
TASKLET_STATE_SCHED, /* Tasklet is scheduled for execution */
TASKLET_STATE_RUN /* Tasklet is running (SMP only) */
};
These translate to:
TASKLET_STATE_SCHED= bit 0 (value 1)TASKLET_STATE_RUN= bit 1 (value 2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
state Bitmask
═════════════
Bit: 1 0
┌────────────────────┬────────────────────┐
│ TASKLET_STATE_RUN │ TASKLET_STATE_SCHED│
│ (bit 1, value 2) │ (bit 0, value 1) │
└────────────────────┴────────────────────┘
Possible states:
┌────────────┬────────────┬──────────────────────────────────────────────────┐
│ RUN (b1) │ SCHED (b0) │ Meaning │
├────────────┼────────────┼──────────────────────────────────────────────────┤
│ 0 │ 0 │ Idle — not scheduled, not running │
│ 0 │ 1 │ Scheduled — on a per-CPU list, waiting to run │
│ 1 │ 0 │ Running — handler is currently executing │
│ 1 │ 1 │ Running AND re-scheduled — will run again │
│ │ │ after current execution completes │
└────────────┴────────────┴──────────────────────────────────────────────────┘
TASKLET_STATE_SCHED (bit 0)
This bit is the scheduling guard. It is set atomically by tasklet_schedule() using test_and_set_bit(). If the bit was already set, the tasklet is already on a per-CPU list and the schedule call is a no-op — this prevents the same tasklet from being added to the list twice.
The bit is cleared by tasklet_clear_sched() after the tasklet handler has finished executing, via test_and_clear_wake_up_bit() at kernel/softirq.c, lines 852–862:
1
2
3
4
5
6
7
8
9
10
11
static bool tasklet_clear_sched(struct tasklet_struct *t)
{
if (test_and_clear_wake_up_bit(TASKLET_STATE_SCHED, &t->state))
return true;
WARN_ONCE(1, "tasklet SCHED state not set: %s %pS\n",
t->use_callback ? "callback" : "func",
t->use_callback ? (void *)t->callback : (void *)t->func);
return false;
}
TASKLET_STATE_RUN (bit 1)
This bit is the execution guard, used on SMP systems to prevent the same tasklet from running on two CPUs simultaneously. It is set by tasklet_trylock() before executing the handler and cleared by tasklet_unlock() after the handler returns. If tasklet_trylock() finds the bit already set, the tasklet is currently running on another CPU and must be deferred.
On uniprocessor systems, TASKLET_STATE_RUN is never used — there is no risk of concurrent execution — so tasklet_trylock() always returns 1.
The count Field: Enable and Disable Reference Counter
The count field is an atomic_t that acts as a disable reference counter. Its semantics are:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────────────────────────────┐
│ count Field Semantics │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ count == 0 → Tasklet is ENABLED │
│ The handler will execute when the softirq processes it. │
│ │
│ count > 0 → Tasklet is DISABLED │
│ The tasklet CAN still be scheduled (added to the list), │
│ but the handler will NOT execute. When the softirq handler │
│ finds count > 0, it puts the tasklet back on the list and │
│ re-raises the softirq. │
│ │
│ Increment count → disable (tasklet_disable / tasklet_disable_nosync) │
│ Decrement count → enable (tasklet_enable) │
│ Calls nest: if disabled twice, must be enabled twice. │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
This is checked in the execution path at kernel/softirq.c, line 934:
1
2
3
4
if (!atomic_read(&t->count)) {
/* count == 0, tasklet is enabled — execute the handler */
...
}
If count is nonzero (tasklet disabled), the handler is skipped and the tasklet is re-queued.
Declaring a Tasklet: Static vs Dynamic
Static Declaration
For tasklets known at compile time (typically module-level global tasklets), the kernel provides macros at include/linux/interrupt.h, lines 704–731:
New API (callback-based):
1
2
3
4
5
6
7
8
9
10
11
12
13
#define DECLARE_TASKLET(name, _callback) \
struct tasklet_struct name = { \
.count = ATOMIC_INIT(0), \
.callback = _callback, \
.use_callback = true, \
}
#define DECLARE_TASKLET_DISABLED(name, _callback) \
struct tasklet_struct name = { \
.count = ATOMIC_INIT(1), \
.callback = _callback, \
.use_callback = true, \
}
Old API (func + data):
1
2
3
4
5
6
7
8
9
10
11
#define DECLARE_TASKLET_OLD(name, _func) \
struct tasklet_struct name = { \
.count = ATOMIC_INIT(0), \
.func = _func, \
}
#define DECLARE_TASKLET_DISABLED_OLD(name, _func) \
struct tasklet_struct name = { \
.count = ATOMIC_INIT(1), \
.func = _func, \
}
The key difference between DECLARE_TASKLET and DECLARE_TASKLET_DISABLED:
DECLARE_TASKLETsets.count = ATOMIC_INIT(0)— the tasklet is enabled immediately and will execute when scheduled.DECLARE_TASKLET_DISABLEDsets.count = ATOMIC_INIT(1)— the tasklet is disabled and will not execute untiltasklet_enable()is called.
Fields not explicitly initialized (.next, .state, .data) are zero-initialized by C’s struct initializer rules — so state = 0 (neither scheduled nor running) and next = NULL.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Static Declaration — New API Example
═════════════════════════════════════
/* In the module's global scope */
static void my_tasklet_handler(struct tasklet_struct *t);
DECLARE_TASKLET(my_tasklet, my_tasklet_handler);
/* Expands to: */
struct tasklet_struct my_tasklet = {
.count = ATOMIC_INIT(0), /* enabled */
.callback = my_tasklet_handler,
.use_callback = true,
/* .next = NULL, .state = 0, .data = 0 — implicit zero init */
};
Experiment: Verifying Static Declaration Initial Values
This module demonstrates the difference between DECLARE_TASKLET (enabled, count=0) and DECLARE_TASKLET_DISABLED (disabled, count=1). Source: day37/4.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/interrupt.h>
MODULE_LICENSE("GPL");
static void my_handler(struct tasklet_struct *t)
{
pr_info("%s: tasklet handler executed\n", __func__);
}
DECLARE_TASKLET(my_tasklet, my_handler);
DECLARE_TASKLET_DISABLED(my_tasklet_disabled, my_handler);
static int __init test_tasklet_init(void)
{
pr_info("--- DECLARE_TASKLET (enabled) ---\n");
pr_info(" state: %ld\n", my_tasklet.state); /* 0 */
pr_info(" count: %d\n", atomic_read(&my_tasklet.count)); /* 0 = enabled */
pr_info("--- DECLARE_TASKLET_DISABLED ---\n");
pr_info(" state: %ld\n", my_tasklet_disabled.state); /* 0 */
pr_info(" count: %d\n", atomic_read(&my_tasklet_disabled.count)); /* 1 = disabled */
return 0;
}
static void __exit test_tasklet_exit(void)
{
pr_info("module unloaded\n");
}
module_init(test_tasklet_init);
module_exit(test_tasklet_exit);
1
2
3
4
5
6
7
8
Expected dmesg output:
──────────────────────
--- DECLARE_TASKLET (enabled) ---
state: 0 ← neither SCHED nor RUN
count: 0 ← enabled (will execute when scheduled)
--- DECLARE_TASKLET_DISABLED ---
state: 0 ← neither SCHED nor RUN
count: 1 ← disabled (will NOT execute until tasklet_enable)
Dynamic Declaration
For tasklets created at runtime (e.g., allocated as part of a larger per-device structure), use tasklet_setup() (new API) or tasklet_init() (old API).
tasklet_setup() at kernel/softirq.c, lines 975–985:
1
2
3
4
5
6
7
8
9
10
11
void tasklet_setup(struct tasklet_struct *t,
void (*callback)(struct tasklet_struct *))
{
t->next = NULL;
t->state = 0;
atomic_set(&t->count, 0);
t->callback = callback;
t->use_callback = true;
t->data = 0;
}
EXPORT_SYMBOL(tasklet_setup);
tasklet_init() at kernel/softirq.c, lines 987–997:
1
2
3
4
5
6
7
8
9
10
11
void tasklet_init(struct tasklet_struct *t,
void (*func)(unsigned long), unsigned long data)
{
t->next = NULL;
t->state = 0;
atomic_set(&t->count, 0);
t->func = func;
t->use_callback = false;
t->data = data;
}
EXPORT_SYMBOL(tasklet_init);
The from_tasklet() macro at include/linux/interrupt.h, line 718 provides a container_of() wrapper for the new callback API:
1
2
#define from_tasklet(var, callback_tasklet, tasklet_fieldname) \
container_of(callback_tasklet, typeof(*var), tasklet_fieldname)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Dynamic Declaration — New API Example
══════════════════════════════════════
struct my_device {
struct net_device *netdev;
struct tasklet_struct rx_tasklet; /* embedded tasklet */
u8 *rx_buffer;
int rx_len;
};
static void my_rx_handler(struct tasklet_struct *t)
{
struct my_device *dev = from_tasklet(dev, t, rx_tasklet);
/* from_tasklet() expands to:
* container_of(t, struct my_device, rx_tasklet)
*
* This gives us a pointer to the enclosing my_device structure.
*/
process_rx_data(dev->rx_buffer, dev->rx_len);
}
static int my_probe(struct platform_device *pdev)
{
struct my_device *dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);
tasklet_setup(&dev->rx_tasklet, my_rx_handler);
/* dev->rx_tasklet is now ready to be scheduled */
...
}
Dynamic Declaration — Old API Example
══════════════════════════════════════
static void my_old_handler(unsigned long data)
{
struct my_device *dev = (struct my_device *)data;
process_rx_data(dev->rx_buffer, dev->rx_len);
}
static int my_probe(struct platform_device *pdev)
{
struct my_device *dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);
tasklet_init(&dev->rx_tasklet, my_old_handler, (unsigned long)dev);
/* Passes dev pointer as the data argument to the handler */
...
}
Experiment: Dynamic Allocation and State Transitions
This module shows the state and count fields at every stage: after kzalloc (zero-initialized), after tasklet_setup (initialized), and after tasklet_schedule (scheduled). Source: day37/7.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/interrupt.h>
#include <linux/slab.h>
MODULE_LICENSE("GPL");
struct my_data {
struct tasklet_struct tasklet;
int value;
};
static struct my_data *dev;
static void my_handler(struct tasklet_struct *t)
{
struct my_data *d = from_tasklet(d, t, tasklet);
pr_info("handler: value = %d\n", d->value);
}
static int __init test_init(void)
{
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev)
return -ENOMEM;
dev->value = 42;
pr_info("--- After kzalloc (zero-initialized) ---\n");
pr_info(" state: %ld\n", dev->tasklet.state);
pr_info(" count: %d\n", atomic_read(&dev->tasklet.count));
tasklet_setup(&dev->tasklet, my_handler);
pr_info("--- After tasklet_setup ---\n");
pr_info(" state: %ld\n", dev->tasklet.state); /* 0 */
pr_info(" count: %d\n", atomic_read(&dev->tasklet.count)); /* 0 = enabled */
tasklet_schedule(&dev->tasklet);
pr_info("--- After tasklet_schedule ---\n");
pr_info(" state: %ld\n", dev->tasklet.state); /* 1 = SCHED */
pr_info(" count: %d\n", atomic_read(&dev->tasklet.count)); /* 0 = enabled */
return 0;
}
static void __exit test_exit(void)
{
tasklet_kill(&dev->tasklet);
kfree(dev);
pr_info("module unloaded\n");
}
module_init(test_init);
module_exit(test_exit);
1
2
3
4
5
6
7
8
9
10
11
12
Expected dmesg output:
──────────────────────
--- After kzalloc (zero-initialized) ---
state: 0 ← not scheduled, not running
count: 0 ← kzalloc zeroed it
--- After tasklet_setup ---
state: 0 ← tasklet_setup explicitly sets state = 0
count: 0 ← enabled
--- After tasklet_schedule ---
state: 1 ← TASKLET_STATE_SCHED (bit 0) is set
count: 0 ← still enabled
handler: value = 42 ← handler runs in softirq context after init returns
Scheduling a Tasklet: tasklet_schedule and tasklet_hi_schedule
To request that a tasklet be executed, the driver calls tasklet_schedule() (for normal priority) or tasklet_hi_schedule() (for high priority).
tasklet_schedule()
Defined at include/linux/interrupt.h, lines 758–762:
1
2
3
4
5
static inline void tasklet_schedule(struct tasklet_struct *t)
{
if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
__tasklet_schedule(t);
}
tasklet_hi_schedule()
Defined at include/linux/interrupt.h, lines 766–770:
1
2
3
4
5
static inline void tasklet_hi_schedule(struct tasklet_struct *t)
{
if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
__tasklet_hi_schedule(t);
}
Both functions use the same pattern:
- Atomically test and set
TASKLET_STATE_SCHED(bit 0) in the tasklet’sstate. - If the bit was already set (the tasklet is already scheduled),
test_and_set_bit()returns 1 → theifis false → nothing happens. The tasklet will not be added to the list a second time. - If the bit was clear (the tasklet was not scheduled),
test_and_set_bit()returns 0 → theifis true → call__tasklet_schedule()or__tasklet_hi_schedule()to actually add the tasklet to the per-CPU list and raise the softirq.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
tasklet_schedule() Flow
═══════════════════════
tasklet_schedule(t)
│
▼
test_and_set_bit(TASKLET_STATE_SCHED, &t->state)
│
├── Returns 1 (bit was already set)
│ └── Tasklet already scheduled — do nothing
│ (prevents double-linking on the per-CPU list)
│
└── Returns 0 (bit was clear, now set)
└── __tasklet_schedule(t)
└── __tasklet_schedule_common(t, &tasklet_vec, TASKLET_SOFTIRQ)
│
├── local_irq_save(flags) — disable IRQs
├── head = this_cpu_ptr(headp) — get per-CPU list
├── t->next = NULL — tasklet is last
├── *head->tail = t — append to list tail
├── head->tail = &(t->next) — update tail pointer
├── raise_softirq_irqoff(TASKLET_SOFTIRQ) — set bit 6
└── local_irq_restore(flags) — restore IRQs
The Internal Scheduling Path: __tasklet_schedule_common
The actual work of adding a tasklet to the per-CPU list and raising the softirq is done by __tasklet_schedule_common() at kernel/softirq.c, lines 822–836:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void __tasklet_schedule_common(struct tasklet_struct *t,
struct tasklet_head __percpu *headp,
unsigned int softirq_nr)
{
struct tasklet_head *head;
unsigned long flags;
local_irq_save(flags);
head = this_cpu_ptr(headp);
t->next = NULL;
*head->tail = t;
head->tail = &(t->next);
raise_softirq_irqoff(softirq_nr);
local_irq_restore(flags);
}
This function is called by both __tasklet_schedule() and __tasklet_hi_schedule() — the only difference is which per-CPU list and which softirq number:
At kernel/softirq.c, lines 838–850:
1
2
3
4
5
6
7
8
9
void __tasklet_schedule(struct tasklet_struct *t)
{
__tasklet_schedule_common(t, &tasklet_vec, TASKLET_SOFTIRQ);
}
void __tasklet_hi_schedule(struct tasklet_struct *t)
{
__tasklet_schedule_common(t, &tasklet_hi_vec, HI_SOFTIRQ);
}
The Per-CPU Tasklet Lists
The per-CPU lists are defined as struct tasklet_head at kernel/softirq.c, lines 814–820:
1
2
3
4
5
6
7
struct tasklet_head {
struct tasklet_struct *head;
struct tasklet_struct **tail;
};
static DEFINE_PER_CPU(struct tasklet_head, tasklet_vec);
static DEFINE_PER_CPU(struct tasklet_head, tasklet_hi_vec);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Per-CPU Tasklet List — Linked List via next pointer
════════════════════════════════════════════════════
Initial state (empty list — after softirq_init):
┌─────────────────────────────────────────┐
│ tasklet_head │
│ head = NULL │
│ tail = &head ──► points to head field│
└─────────────────────────────────────────┘
After scheduling tasklet A:
┌─────────────────────────────────────────┐
│ tasklet_head │
│ head ──► ┌──────────┐ │
│ │ tasklet A │ │
│ │ next=NULL │ │
│ └──────────┘ │
│ tail = &A.next │
└─────────────────────────────────────────┘
After scheduling tasklet B:
┌─────────────────────────────────────────────────────┐
│ tasklet_head │
│ head ──► ┌──────────┐ ┌──────────┐ │
│ │ tasklet A │──►│ tasklet B│ │
│ │ next ─────┘ │ next=NULL│ │
│ └──────────┘ └──────────┘ │
│ tail = &B.next │
└─────────────────────────────────────────────────────┘
After scheduling tasklet C:
┌─────────────────────────────────────────────────────────────────┐
│ tasklet_head │
│ head ──► ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ tasklet A │──►│ tasklet B │──►│ tasklet C│ │
│ │ next ─────┘ │ next ─────┘ │ next=NULL│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ tail = &C.next │
└─────────────────────────────────────────────────────────────────┘
The tail pointer always points to the next field of the LAST
tasklet (or &head if empty), enabling O(1) append without
traversing the list.
The local_irq_save(flags) / local_irq_restore(flags) pair around the list manipulation is essential. Without it, a hardirq could fire mid-update and tasklet_schedule() from the hardirq handler could corrupt the list. By disabling interrupts, the function guarantees atomicity of the list insertion on the local CPU.
After appending the tasklet, raise_softirq_irqoff(softirq_nr) sets the corresponding bit in the per-CPU __softirq_pending bitmask. For tasklet_schedule(), this sets bit 6 (TASKLET_SOFTIRQ). For tasklet_hi_schedule(), this sets bit 0 (HI_SOFTIRQ).
How the Bitmask Is Used by tasklet_schedule and tasklet_hi_schedule
The per-CPU __softirq_pending bitmask connects tasklet scheduling to the softirq processing loop:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Bitmask Interaction — tasklet_schedule
═══════════════════════════════════════
1. Driver calls tasklet_schedule(t)
└── test_and_set_bit(TASKLET_STATE_SCHED, &t->state)
└── If SCHED bit was clear:
__tasklet_schedule(t)
└── __tasklet_schedule_common(t, &tasklet_vec, TASKLET_SOFTIRQ)
│
├── Append t to this CPU's tasklet_vec list
└── raise_softirq_irqoff(TASKLET_SOFTIRQ)
└── __raise_softirq_irqoff(6)
└── or_softirq_pending(1UL << 6)
__softirq_pending on this CPU:
Before: 0b ... 0000 0000
OR: 0b ... 0100 0000 (bit 6)
After: 0b ... 0100 0000
2. At irq_exit() or ksoftirqd:
handle_softirqs() reads __softirq_pending
ffs(0b0100_0000) = 7 → vec_nr = 6 = TASKLET_SOFTIRQ
└── softirq_vec[6].action = tasklet_action
└── tasklet_action()
└── tasklet_action_common(tasklet_vec, TASKLET_SOFTIRQ)
└── walk the list, execute tasklets
Bitmask Interaction — tasklet_hi_schedule
══════════════════════════════════════════
1. Driver calls tasklet_hi_schedule(t)
└── __tasklet_hi_schedule(t)
└── __tasklet_schedule_common(t, &tasklet_hi_vec, HI_SOFTIRQ)
│
├── Append t to this CPU's tasklet_hi_vec list
└── raise_softirq_irqoff(HI_SOFTIRQ)
└── or_softirq_pending(1UL << 0)
__softirq_pending on this CPU:
Before: 0b ... 0000 0000
OR: 0b ... 0000 0001 (bit 0)
After: 0b ... 0000 0001
2. ffs(0b0000_0001) = 1 → vec_nr = 0 = HI_SOFTIRQ
└── softirq_vec[0].action = tasklet_hi_action
└── processes tasklet_hi_vec list
What Happens If tasklet_schedule Is Called Twice?
The tasklet will execute only once. The test_and_set_bit(TASKLET_STATE_SCHED, &t->state) call in tasklet_schedule() is the guard. If the SCHED bit is already set (from the first call), the second call’s test_and_set_bit returns 1, and __tasklet_schedule() is never called:
1
2
3
4
5
static inline void tasklet_schedule(struct tasklet_struct *t)
{
if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
__tasklet_schedule(t); /* only reached if SCHED was clear */
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Double-Schedule Scenario
════════════════════════
Time ──────────────────────────────────────────────────────────►
Call 1: tasklet_schedule(t)
test_and_set_bit(SCHED) → was 0, now 1 → __tasklet_schedule(t) called
t added to per-CPU list
TASKLET_SOFTIRQ raised
Call 2: tasklet_schedule(t) (before tasklet has executed)
test_and_set_bit(SCHED) → was 1, stays 1 → returns 1
if (!1) → false → __tasklet_schedule(t) NOT called
NO-OP: tasklet is not added a second time
Softirq fires: tasklet executes once
tasklet_clear_sched(t) clears SCHED bit
t->callback(t) runs
Now SCHED=0 — a new tasklet_schedule(t) call would succeed
This is a deliberate design choice. If a device generates interrupts faster than the tasklet can process them, calling tasklet_schedule() on every interrupt just ensures the tasklet runs “at least once” — it does not queue up multiple executions. The driver is responsible for checking the device state when the tasklet actually runs (e.g., draining all available data from the hardware FIFO, not just one item).
Experiment: Observing the Bitmask and Double-Schedule Behavior
This module shows local_softirq_pending() before scheduling, after scheduling (bit 6 set), after a second schedule (no change), and during handler execution. Source: day37/18, day37/19.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/interrupt.h>
MODULE_LICENSE("GPL");
static struct tasklet_struct my_tasklet;
static void my_handler(struct tasklet_struct *t)
{
pr_info("handler: local_softirq_pending = 0x%02x\n",
local_softirq_pending());
pr_info("handler: tasklet executed\n");
}
static int __init test_init(void)
{
tasklet_setup(&my_tasklet, my_handler);
pr_info("before schedule: pending = 0x%02x\n",
local_softirq_pending());
tasklet_schedule(&my_tasklet);
pr_info("after 1st schedule: pending = 0x%02x\n",
local_softirq_pending());
tasklet_schedule(&my_tasklet);
pr_info("after 2nd schedule: pending = 0x%02x\n",
local_softirq_pending());
return 0;
}
static void __exit test_exit(void)
{
tasklet_kill(&my_tasklet);
}
module_init(test_init);
module_exit(test_exit);
1
2
3
4
5
6
7
8
9
10
11
Expected dmesg output:
──────────────────────
before schedule: pending = 0x00 ← no softirqs pending
after 1st schedule: pending = 0x40 ← bit 6 (TASKLET_SOFTIRQ) set
after 2nd schedule: pending = 0x40 ← unchanged — second schedule was a NO-OP
handler: local_softirq_pending = 0x00 ← bit cleared before handler runs
handler: tasklet executed ← runs ONCE despite two schedule calls
The second tasklet_schedule() call found TASKLET_STATE_SCHED already set
(from the first call), so test_and_set_bit returned 1 and the function
returned without adding the tasklet to the list again.
Which Softirq Does tasklet_hi_schedule Use?
tasklet_hi_schedule() uses HI_SOFTIRQ (index 0), not TASKLET_SOFTIRQ (index 6).
This is visible in the implementation at kernel/softirq.c, lines 845–850:
1
2
3
4
void __tasklet_hi_schedule(struct tasklet_struct *t)
{
__tasklet_schedule_common(t, &tasklet_hi_vec, HI_SOFTIRQ);
}
And the handler for HI_SOFTIRQ is tasklet_hi_action(), registered in softirq_init() at kernel/softirq.c, line 1060:
1
open_softirq(HI_SOFTIRQ, tasklet_hi_action);
Since HI_SOFTIRQ is at index 0 — the lowest bit position — it is processed first by handle_softirqs()’s ffs() loop. This means high-priority tasklets run before all other softirqs: before timers, before networking, before block I/O, before normal tasklets.
1
2
3
4
5
6
7
8
9
10
Processing Order in handle_softirqs()
═════════════════════════════════════
pending = 0b ... 0100 0001 (bit 0 = HI_SOFTIRQ, bit 6 = TASKLET_SOFTIRQ)
ffs(pending) iteration:
1st: ffs(0b0100_0001) = 1 → vec_nr 0 → HI_SOFTIRQ → tasklet_hi_action()
2nd: ffs(0b0010_0000) = 6 → vec_nr 6 → TASKLET_SOFTIRQ → tasklet_action()
The high-priority tasklet (HI_SOFTIRQ) always runs first.
When to Use tasklet_hi_schedule
Rarely. tasklet_hi_schedule() should only be used when the bottom-half work is so latency-critical that it must execute before the timer tick, before network packet processing, and before all other softirqs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────────────────────────┐
│ WHEN TO USE tasklet_hi_schedule │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ USE tasklet_hi_schedule() WHEN: │
│ ─────────────────────────────── │
│ ● The work is so time-sensitive that delaying it by even a few │
│ microseconds (the time to process TIMER, NET_TX, NET_RX, BLOCK, │
│ IRQ_POLL softirqs) is unacceptable │
│ ● Audio devices where jitter causes audible artifacts │
│ ● Hardware with very small FIFOs that will overflow if not serviced │
│ immediately │
│ │
│ USE tasklet_schedule() (NORMAL PRIORITY) IN ALL OTHER CASES: │
│ ───────────────────────────────────────────────────────────── │
│ ● Normal driver bottom halves │
│ ● Data processing that can tolerate microsecond-level delays │
│ ● Anything that does not have hard sub-microsecond deadlines │
│ │
│ WARNING: Overusing HI_SOFTIRQ degrades system performance by │
│ delaying timer processing (which affects scheduler tick, watchdog, │
│ timeout handling) and network processing. │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
Experiment: HI_SOFTIRQ Runs Before TASKLET_SOFTIRQ
This module schedules a normal-priority tasklet first, then a high-priority tasklet second — yet the high-priority one runs first because HI_SOFTIRQ (bit 0) is processed before TASKLET_SOFTIRQ (bit 6) by handle_softirqs()’s ffs() loop. Source: day37/24.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/interrupt.h>
MODULE_LICENSE("GPL");
static void normal_handler(struct tasklet_struct *t)
{
pr_info("[TASKLET_SOFTIRQ] normal-priority tasklet ran\n");
}
static void hi_handler(struct tasklet_struct *t)
{
pr_info("[HI_SOFTIRQ] high-priority tasklet ran\n");
}
DECLARE_TASKLET(normal_tasklet, normal_handler);
DECLARE_TASKLET(hi_tasklet, hi_handler);
static int __init test_init(void)
{
pr_info("scheduling normal tasklet FIRST...\n");
tasklet_schedule(&normal_tasklet);
pr_info("scheduling hi tasklet SECOND...\n");
tasklet_hi_schedule(&hi_tasklet);
return 0;
}
static void __exit test_exit(void)
{
tasklet_kill(&normal_tasklet);
tasklet_kill(&hi_tasklet);
}
module_init(test_init);
module_exit(test_exit);
1
2
3
4
5
6
7
8
9
10
11
Expected dmesg output:
──────────────────────
scheduling normal tasklet FIRST...
scheduling hi tasklet SECOND...
[HI_SOFTIRQ] high-priority tasklet ran ← runs FIRST (bit 0)
[TASKLET_SOFTIRQ] normal-priority tasklet ran ← runs SECOND (bit 6)
Even though tasklet_schedule() was called before tasklet_hi_schedule(),
the HI_SOFTIRQ handler runs first because handle_softirqs() processes
pending bits in order: ffs(0x41) finds bit 0 (HI_SOFTIRQ) before
bit 6 (TASKLET_SOFTIRQ).
How the Tasklet Softirq Handler Executes Tasklets: tasklet_action_common
When the softirq processing loop in handle_softirqs() finds TASKLET_SOFTIRQ (or HI_SOFTIRQ) pending, it calls tasklet_action() (or tasklet_hi_action()). Both are thin wrappers around tasklet_action_common().
The wrappers at kernel/softirq.c, lines 963–973:
1
2
3
4
5
6
7
8
9
10
11
static __latent_entropy void tasklet_action(void)
{
workqueue_softirq_action(false);
tasklet_action_common(this_cpu_ptr(&tasklet_vec), TASKLET_SOFTIRQ);
}
static __latent_entropy void tasklet_hi_action(void)
{
workqueue_softirq_action(true);
tasklet_action_common(this_cpu_ptr(&tasklet_hi_vec), HI_SOFTIRQ);
}
The core function tasklet_action_common() at kernel/softirq.c, lines 916–961:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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;
tl_head->head = NULL;
tl_head->tail = &tl_head->head;
local_irq_enable();
tasklet_lock_callback();
while (list) {
struct tasklet_struct *t = list;
list = list->next;
if (tasklet_trylock(t)) {
if (!atomic_read(&t->count)) {
if (tasklet_clear_sched(t)) {
if (t->use_callback) {
trace_tasklet_entry(t, t->callback);
t->callback(t);
trace_tasklet_exit(t, t->callback);
} else {
trace_tasklet_entry(t, t->func);
t->func(t->data);
trace_tasklet_exit(t, t->func);
}
}
tasklet_unlock(t);
tasklet_callback_sync_wait_running();
continue;
}
tasklet_unlock(t);
}
local_irq_disable();
t->next = NULL;
*tl_head->tail = t;
tl_head->tail = &t->next;
__raise_softirq_irqoff(softirq_nr);
local_irq_enable();
}
tasklet_unlock_callback();
}
Step-by-Step Flow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
tasklet_action_common() Execution Flow
═══════════════════════════════════════
┌─────────────────────────────────────────────────────────────────────────────────┐
│ STEP 1: Detach the entire per-CPU list │
│ ────────────────────────────────────── │
│ │
│ local_irq_disable(); │
│ list = tl_head->head; ← grab the list head │
│ tl_head->head = NULL; ← reset to empty │
│ tl_head->tail = &tl_head->head; ← tail points back to head (empty) │
│ local_irq_enable(); │
│ │
│ Why? New tasklet_schedule() calls during processing will build a NEW │
│ list. The current batch is processed independently. │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ STEP 2: Walk the detached list │
│ ────────────────────────────── │
│ │
│ For each tasklet t in the list: │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 2a. tasklet_trylock(t) │ │
│ │ test_and_set_bit(TASKLET_STATE_RUN, &t->state) │ │
│ │ │ │
│ │ ├── FAILS (bit already set) — tasklet running on another CPU │ │
│ │ │ └── goto step 2d (re-queue) │ │
│ │ │ │ │
│ │ └── SUCCEEDS (bit was clear, now set) — we own execution │ │
│ │ │ │
│ │ 2b. Check count: atomic_read(&t->count) │ │
│ │ │ │
│ │ ├── count > 0 — tasklet is DISABLED │ │
│ │ │ └── tasklet_unlock(t) │ │
│ │ │ goto step 2d (re-queue) │ │
│ │ │ │ │
│ │ └── count == 0 — tasklet is ENABLED │ │
│ │ │ │
│ │ 2c. Execute the handler │ │
│ │ tasklet_clear_sched(t) ← clear TASKLET_STATE_SCHED │ │
│ │ if (use_callback) │ │
│ │ t->callback(t); ← call new-style handler │ │
│ │ else │ │
│ │ t->func(t->data); ← call old-style handler │ │
│ │ tasklet_unlock(t) ← clear TASKLET_STATE_RUN │ │
│ │ continue to next tasklet │ │
│ │ │ │
│ │ 2d. Re-queue (tasklet could not execute) │ │
│ │ local_irq_disable(); │ │
│ │ t->next = NULL; │ │
│ │ *tl_head->tail = t; ← put back on the list │ │
│ │ tl_head->tail = &t->next; │ │
│ │ __raise_softirq_irqoff(softirq_nr); ← re-raise the softirq │ │
│ │ local_irq_enable(); │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
The critical insight is step 2d: if a tasklet cannot execute — either because it is running on another CPU (tasklet_trylock fails) or because it is disabled (count > 0) — it is re-queued on the same per-CPU list and the softirq is re-raised. This means the tasklet will be retried on the next softirq processing pass on this CPU.
The TASKLET_STATE_SCHED Clear Ordering
Notice that tasklet_clear_sched(t) is called before the handler executes. This means that while the handler is running, TASKLET_STATE_SCHED is clear — so another call to tasklet_schedule() during handler execution will succeed and schedule the tasklet for another run after the current one completes. This is how re-scheduling from within a tasklet handler works.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Re-scheduling Timeline
═══════════════════════
CPU 0 CPU 1
────── ──────
tasklet_action_common():
tasklet_trylock(t) → set RUN bit
tasklet_clear_sched(t) → clear SCHED bit
t->callback(t) ───────┐
│
┌──────────▼──────────────────────┐
│ Inside handler: │
│ SCHED=0, RUN=1 │
│ │
│ tasklet_schedule(t) │
│ test_and_set_bit(SCHED) │ tasklet_schedule(t)
│ SCHED was 0 → set to 1 │ test_and_set_bit(SCHED)
│ → __tasklet_schedule(t) │ SCHED is 1 → already set
│ → adds to per-CPU list │ → NO-OP (not double-scheduled)
│ → raises TASKLET_SOFTIRQ │
└─────────────────────────────────┘
tasklet_unlock(t) → clear RUN bit
Next softirq pass: tasklet executes again
Preventing Concurrent Execution: tasklet_trylock and tasklet_unlock
The kernel guarantees that a given tasklet never runs on more than one CPU simultaneously. This is enforced by the tasklet_trylock() / tasklet_unlock() pair using the TASKLET_STATE_RUN bit.
tasklet_trylock()
At include/linux/interrupt.h, lines 739–753:
1
2
3
4
5
6
7
8
#if defined(CONFIG_SMP) || defined(CONFIG_PREEMPT_RT)
static inline int tasklet_trylock(struct tasklet_struct *t)
{
return !test_and_set_bit(TASKLET_STATE_RUN, &(t)->state);
}
#else
static inline int tasklet_trylock(struct tasklet_struct *t) { return 1; }
#endif
On SMP systems, tasklet_trylock() atomically tests and sets TASKLET_STATE_RUN (bit 1). If the bit was already set (another CPU is running this tasklet), it returns 0 (lock failed). If the bit was clear, it is set and the function returns 1 (lock acquired).
On uniprocessor systems, tasklet_trylock() always returns 1 — there is no other CPU that could be running the tasklet.
tasklet_unlock()
At kernel/softirq.c, lines 1035–1039:
1
2
3
4
void tasklet_unlock(struct tasklet_struct *t)
{
clear_and_wake_up_bit(TASKLET_STATE_RUN, &t->state);
}
This clears the TASKLET_STATE_RUN bit and wakes up any waiters (used by tasklet_unlock_wait() and tasklet_disable()).
How This Prevents Concurrent Execution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Concurrent Execution Prevention — SMP Scenario
════════════════════════════════════════════════
CPU 0 CPU 1
───── ─────
Tasklet A scheduled on CPU 0 Tasklet A scheduled on CPU 1
(e.g., re-queued from a (shouldn't happen — same tasklet)
previous failed trylock)
tasklet_action_common(): tasklet_action_common():
tasklet_trylock(A) tasklet_trylock(A)
test_and_set_bit(RUN, &A->state) test_and_set_bit(RUN, &A->state)
│ │
├── CPU 0 wins the race ├── CPU 1 loses
│ bit was 0, now 1 │ bit was already 1
│ returns 1 (success) │ returns 0 (fail)
│ │
▼ ▼
Execute A->callback(A) Re-queue A on CPU 1's list
... __raise_softirq_irqoff()
tasklet_unlock(A) (A will be retried later)
clear RUN bit
This is fundamentally different from softirqs. A softirq handler like net_rx_action() can run on CPU 0 and CPU 3 simultaneously — it relies on per-CPU data structures and fine-grained locking to handle this. A tasklet handler is guaranteed to run on only one CPU at a time, so it does not need any internal locking for its own state.
Can the Same Softirq Run on Multiple Processors?
Yes. The same softirq type can — and regularly does — execute simultaneously on different CPUs. This is by design for maximum throughput.
Each CPU has its own __softirq_pending bitmask and its own handle_softirqs() processing loop. When multiple CPUs have the same softirq bit set (e.g., NET_RX_SOFTIRQ on CPU 0 and CPU 2), each CPU independently calls the same handler (net_rx_action()).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌─────────────────────────────────────────────────────────────────────────────────┐
│ SOFTIRQ CONCURRENCY vs TASKLET SERIALIZATION │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ SOFTIRQ (e.g., NET_RX_SOFTIRQ): │
│ │
│ CPU 0 CPU 1 CPU 2 CPU 3 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │net_rx_ │ │ │ │net_rx_ │ │ │ │
│ │action() │ │ (idle) │ │action() │ │ (idle) │ │
│ │RUNNING │ │ │ │RUNNING │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ Same handler, same time, different CPUs — ALLOWED │
│ Handler must use per-CPU data or locking for shared data │
│ │
│ TASKLET (e.g., my_tasklet): │
│ │
│ CPU 0 CPU 1 CPU 2 CPU 3 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │my_tasklet │ │ │ │my_tasklet │ │ │ │
│ │callback() │ │ (idle) │ │BLOCKED by │ │ (idle) │ │
│ │RUNNING │ │ │ │trylock — │ │ │ │
│ │(RUN bit=1) │ │ │ │re-queued │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ Same tasklet instance on two CPUs — PREVENTED by TASKLET_STATE_RUN │
│ │
│ NOTE: DIFFERENT tasklet instances CAN run on different CPUs simultaneously. │
│ The serialization is per-instance, not global. │
└─────────────────────────────────────────────────────────────────────────────────┘
This is the fundamental scalability tradeoff: softirqs allow full per-CPU parallelism but require careful synchronization; tasklets sacrifice parallelism for the simplicity of per-instance serialization.
Same Softirq Running on Multiple CPUs: Race Conditions and Why Tasklets Are Different
What “Same Softirq Runs on Multiple CPUs” Actually Means
When we say the same softirq can run on multiple CPUs simultaneously, we mean the exact same function — with the exact same memory address — executes on two CPUs at the same time. The softirq_vec array at kernel/softirq.c, line 60 is a global array, not per-CPU:
1
static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;
Every CPU reads the same softirq_vec[3].action function pointer (e.g., net_rx_action) and calls the same function. The function’s code lives at one address in kernel text — it is the same instruction sequence on every CPU.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Same Softirq Action Function — Same Code Address on Every CPU
═════════════════════════════════════════════════════════════
Global (shared by all CPUs):
softirq_vec[3].action = 0xFFFF800080A12340 (net_rx_action)
│
│ same function pointer
├────────────────────────────────────┐
│ │
▼ ▼
CPU 1 CPU 2
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ handle_softirqs(): │ │ handle_softirqs(): │
│ h = &softirq_vec[3] │ │ h = &softirq_vec[3] │
│ h->action() │ │ h->action() │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ net_rx_action() at │ │ net_rx_action() at │
│ 0xFFFF800080A12340 │ │ 0xFFFF800080A12340 │
│ SAME CODE, SAME ADDRESS │ │ SAME CODE, SAME ADDRESS │
│ │ │ │
│ But different: │ │ But different: │
│ - stack (each CPU has own) │ │ - stack (each CPU has own) │
│ - local variables (on stack) │ │ - local variables (on stack) │
│ - per-CPU data (different) │ │ - per-CPU data (different) │
│ - registers (CPU-private) │ │ - registers (CPU-private) │
└──────────────────────────────────┘ └──────────────────────────────────┘
The Race Condition Scenario
Consider a softirq handler with a global variable:
1
2
3
4
5
6
static unsigned long global_counter = 0; /* GLOBAL — shared across CPUs */
static void my_softirq_handler(void)
{
global_counter++; /* READ-MODIFY-WRITE — NOT ATOMIC */
}
When this handler runs on CPU 1 and CPU 2 simultaneously:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Race Condition on a Global Variable
════════════════════════════════════
global_counter starts at 100.
CPU 1 CPU 2
──────── ────────
1. LDR x0, [global_counter] → x0 = 100
2. LDR x0, [global_counter] → x0 = 100
3. ADD x0, x0, #1 → x0 = 101
4. ADD x0, x0, #1 → x0 = 101
5. STR x0, [global_counter] → memory = 101
6. STR x0, [global_counter] → memory = 101
RESULT: global_counter = 101 (WRONG — should be 102)
Two increments happened, but one was lost because both CPUs read the
same value (100) before either wrote back.
On AArch64, global_counter++ compiles to a non-atomic LDR/ADD/STR
sequence. Without explicit synchronization (atomic_inc, spinlock,
or per-CPU variables), this is a DATA RACE.
This is exactly why softirq handlers must use per-CPU data or explicit locking for any shared state. The net_rx_action() handler, for example, uses per-CPU softnet_data structures — each CPU processes its own receive queue independently, so there is no sharing and no locking needed on the hot path.
Why This Problem Does Not Exist for Tasklets
Tasklets solve this problem fundamentally differently. A tasklet does not have a global handler shared across CPUs in the same way. Each tasklet is an instance — a specific tasklet_struct in memory — and the kernel guarantees that a given instance never runs on two CPUs simultaneously.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Why Tasklets Are Different — Per-Instance Serialization
═══════════════════════════════════════════════════════
SOFTIRQ:
────────
ONE global function pointer: softirq_vec[6].action = tasklet_action
This function IS called on multiple CPUs simultaneously.
But the function itself then walks a PER-CPU list of tasklets.
TASKLET:
────────
Each tasklet_struct is a SEPARATE INSTANCE with its own state field.
struct my_nic nic_A: struct my_nic nic_B:
┌──────────────────────┐ ┌──────────────────────┐
│ rx_tasklet: │ │ rx_tasklet: │
│ state = ... │ │ state = ... │
│ callback = my_rx_fn │ │ callback = my_rx_fn │
│ address: 0x8800100 │ │ address: 0x8800200 │
└──────────────────────┘ └──────────────────────┘
│ │
│ DIFFERENT memory address │ DIFFERENT memory address
│ DIFFERENT state field │ DIFFERENT state field
│ SAME callback function │ SAME callback function
│ │
▼ ▼
my_rx_fn(t) where my_rx_fn(t) where
t = 0x8800100 t = 0x8800200
from_tasklet → nic_A from_tasklet → nic_B
These CAN run on different CPUs simultaneously — they are
DIFFERENT instances pointing to DIFFERENT data.
But if CPU 1 and CPU 2 both try to run nic_A's tasklet:
CPU 1 CPU 2
───── ─────
tasklet_trylock(nic_A->rx_tasklet) tasklet_trylock(nic_A->rx_tasklet)
test_and_set_bit(RUN, &state) test_and_set_bit(RUN, &state)
├── state at 0x8800108 ├── state at 0x8800108
│ (SAME address — same instance) │ (SAME address — same instance)
│ │
├── CPU 1 wins atomically ├── CPU 2 loses
│ bit was 0, now 1, returns 1 │ bit was 1, returns 0
▼ ▼
EXECUTES my_rx_fn(nic_A) RE-QUEUES nic_A, does NOT execute
The state field at 0x8800108 is the SAME memory location for both CPUs.
test_and_set_bit is an ATOMIC operation (LDXR/STXR on AArch64).
Only one CPU can win the race — guaranteed by hardware atomics.
The key insight: the state field inside a tasklet_struct is not per-CPU — it is part of the instance, shared across all CPUs. The TASKLET_STATE_RUN bit acts as a spinlock-like mutual exclusion mechanism using hardware atomic instructions. Because test_and_set_bit() compiles to AArch64 LDXR/STXR (Load-Exclusive / Store-Exclusive) instructions, only one CPU can atomically transition the bit from 0 to 1.
So: different tasklet instances (e.g., nic_A and nic_B) can run on different CPUs in parallel — they have different state fields at different memory addresses. But the same tasklet instance is serialized — because both CPUs compete for the same atomic state bit at the same memory address.
Which Lock to Use in Tasklet Context
Choosing the right spinlock variant when a tasklet shares data with other kernel contexts is a direct consequence of the preemption hierarchy — which context can interrupt which. Get this wrong and you get a deadlock that freezes the CPU with no error message and no stack trace.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Preemption Hierarchy — Who Can Interrupt Whom
══════════════════════════════════════════════
Hardirq (IRQs disabled, HARDIRQ bits in preempt_count)
│
│ CAN preempt ──────────────────────────────────────────┐
│ │
▼ ▼
Softirq / Tasklet (IRQs enabled, SOFTIRQ_OFFSET) Process Context
│ ▲
│ CAN preempt ───────────────────────────────────────────┘
│
▼
Process Context (preempt_count = 0, can sleep)
KEY RULES:
● Hardirq CAN preempt a tasklet — IRQs are enabled during tasklet execution
● Softirq CANNOT nest on the same CPU — handle_softirqs() sets SOFTIRQ_OFFSET
● Tasklet CAN preempt process context — via softirq on irq_exit path
● Same tasklet instance CANNOT run on two CPUs — TASKLET_STATE_RUN bit
● Different tasklet instances CAN run on different CPUs simultaneously
The spinlock variant you pick must block the exact preemption path that the hierarchy allows. If a hardirq can interrupt your tasklet while it holds the lock, and the hardirq handler tries to acquire the same lock — deadlock. If a tasklet can fire on the same CPU while process context holds the lock — deadlock. The table below tells you which variant blocks which path.
Quick Reference Table
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──────────────────────────────────────┬───────────────────────────┬───────────────────────────┐
│ Data Shared Between │ Lock in Tasklet │ Lock in Other Context │
├──────────────────────────────────────┼───────────────────────────┼───────────────────────────┤
│ Same tasklet instance only │ NONE │ — │
├──────────────────────────────────────┼───────────────────────────┼───────────────────────────┤
│ Two different tasklet instances │ spin_lock() │ spin_lock() │
├──────────────────────────────────────┼───────────────────────────┼───────────────────────────┤
│ Tasklet ↔ hardirq handler │ spin_lock_irqsave() │ spin_lock() │
├──────────────────────────────────────┼───────────────────────────┼───────────────────────────┤
│ Tasklet ↔ process context │ spin_lock() │ spin_lock_bh() │
├──────────────────────────────────────┼───────────────────────────┼───────────────────────────┤
│ Tasklet ↔ softirq timer callback │ spin_lock() │ spin_lock() │
├──────────────────────────────────────┼───────────────────────────┼───────────────────────────┤
│ Tasklet ↔ hardirq ↔ process (three) │ spin_lock_irqsave() │ See per-context below │
└──────────────────────────────────────┴───────────────────────────┴───────────────────────────┘
Scenario 1: Data Private to a Single Tasklet Instance — No Lock Needed
The kernel guarantees that a given tasklet instance never runs on two CPUs simultaneously. The TASKLET_STATE_RUN bit in tasklet_trylock() at include/linux/interrupt.h, lines 739–753 enforces this. If your data is accessed only inside one tasklet’s handler function and nowhere else, no locking is required.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct my_device {
struct tasklet_struct tasklet;
unsigned long packets_processed; /* accessed ONLY in the tasklet handler */
u8 hw_status; /* accessed ONLY in the tasklet handler */
};
static void my_tasklet_handler(struct tasklet_struct *t)
{
struct my_device *dev = from_tasklet(dev, t, tasklet);
dev->packets_processed++; /* safe — no other CPU can run this instance */
dev->hw_status = ioread8(dev->base + STATUS_REG);
}
This is the whole point of tasklets — they give you single-threaded execution per instance without explicit locking. If all your deferred work can be encapsulated in one tasklet’s private data, you are done.
Scenario 2: Data Shared Between Two Different Tasklet Instances — spin_lock()
Two different tasklet instances (e.g., nic_A->tasklet and nic_B->tasklet) can run on different CPUs simultaneously because they have different TASKLET_STATE_RUN bits. If they share a global or subsystem-level data structure, you need a lock.
A plain spin_lock() is sufficient because softirqs cannot nest on the same CPU. One tasklet cannot preempt another tasklet on the same CPU — handle_softirqs() walks the pending list sequentially with SOFTIRQ_OFFSET set in preempt_count. The only concern is cross-CPU concurrency, and spin_lock() handles that.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static DEFINE_SPINLOCK(stats_lock);
static unsigned long global_rx_count;
static void nic_a_handler(struct tasklet_struct *t)
{
spin_lock(&stats_lock);
global_rx_count += local_count_a;
spin_unlock(&stats_lock);
}
static void nic_b_handler(struct tasklet_struct *t)
{
spin_lock(&stats_lock);
global_rx_count += local_count_b;
spin_unlock(&stats_lock);
}
The same analysis applies to data shared between a tasklet and a timer callback (TIMER_SOFTIRQ) or between a tasklet and a networking softirq (NET_RX_SOFTIRQ). All of these run in softirq context, cannot nest on the same CPU, and spin_lock() protects against cross-CPU access.
Scenario 3: Data Shared Between a Tasklet and Its Hardirq Handler — spin_lock_irqsave()
This is where most drivers get it wrong. A hardirq can fire at any point during tasklet execution because hardware interrupts are enabled in softirq context (see kernel/softirq.c, line 606 — local_irq_enable() before the handler loop). If the hardirq handler tries to acquire the same lock the tasklet is holding — deadlock on that CPU.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
DEADLOCK: Tasklet uses spin_lock(), hardirq tries same lock
════════════════════════════════════════════════════════════
CPU 0
─────
1. tasklet_handler() running
spin_lock(&dev->lock) ← acquires lock
... processing data ...
2. ──── HARDIRQ fires (same device) ────
│ IRQs are enabled, so hardware interrupt arrives
│
▼
my_irq_handler()
spin_lock(&dev->lock) ← SPINS FOREVER
tasklet holds the lock
hardirq can't be preempted
tasklet can't resume
═══ CPU HANGS ═══
The fix: spin_lock_irqsave() in the tasklet, which disables hardware interrupts while the lock is held. The hardirq physically cannot fire on this CPU during the critical section. In the hardirq handler, plain spin_lock() is sufficient because hardware interrupts are already disabled when the hardirq handler runs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct my_device {
spinlock_t lock;
struct tasklet_struct tasklet;
u32 ring_head; /* written by hardirq, read by tasklet */
u32 ring_tail; /* written by tasklet */
};
/* runs in hardirq context — IRQs already disabled on this CPU */
static irqreturn_t my_irq_handler(int irq, void *data)
{
struct my_device *dev = data;
spin_lock(&dev->lock); /* NOT _irqsave — IRQs already off */
dev->ring_head = ioread32(dev->base + HEAD_REG);
spin_unlock(&dev->lock);
tasklet_schedule(&dev->tasklet);
return IRQ_HANDLED;
}
/* runs in softirq context — IRQs ENABLED */
static void my_tasklet_handler(struct tasklet_struct *t)
{
struct my_device *dev = from_tasklet(dev, t, tasklet);
unsigned long flags;
spin_lock_irqsave(&dev->lock, flags); /* disables IRQs — prevents deadlock */
while (dev->ring_tail != dev->ring_head) {
process_packet(dev, dev->ring_tail);
dev->ring_tail++;
}
spin_unlock_irqrestore(&dev->lock, flags);
}
On AArch64, spin_lock_irqsave() compiles to mrs x0, daif (save DAIF register) → msr daifset, #2 (set the I bit in PSTATE to mask IRQs) → then the lock acquisition sequence. The hardirq literally cannot reach the GIC’s CPU interface while the I bit is masked.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
AArch64: What spin_lock_irqsave() Does
═══════════════════════════════════════
spin_lock_irqsave(&lock, flags):
│
├── local_irq_save(flags)
│ ├── MRS x0, DAIF ← save current interrupt mask
│ └── MSR DAIFSet, #2 ← set I bit → IRQs MASKED on this CPU
│
├── preempt_disable()
│ └── preempt_count += 1 ← prevent scheduling
│
└── do_raw_spin_lock()
└── LDAXR/STXR spin loop ← acquire the lock
spin_unlock_irqrestore(&lock, flags):
│
├── do_raw_spin_unlock()
│ └── STLR to release ← release the lock
│
├── preempt_enable()
│
└── local_irq_restore(flags)
└── MSR DAIF, x0 ← restore saved mask → IRQs UNMASKED
Why not spin_lock_irq() instead? In a tasklet, spin_lock_irq() / spin_unlock_irq() would technically work — IRQs are always enabled when a tasklet handler is entered, so spin_unlock_irq() unconditionally re-enabling them is correct. But spin_lock_irqsave() is the standard idiom because it is safe regardless of calling context. If you later refactor the critical section into a helper function called from both tasklet and hardirq context, spin_unlock_irq() in the hardirq path would incorrectly re-enable IRQs. Use _irqsave by default.
Scenario 4: Data Shared Between a Tasklet and Process Context — spin_lock_bh()
Process context (syscalls, workqueues, kernel threads) runs with preempt_count = 0. A softirq — including your tasklet — can fire on the same CPU during irq_exit() after any hardware interrupt, preempting the process context code. If process context holds a plain spin_lock() and the tasklet fires on that CPU and tries to acquire the same lock — deadlock.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DEADLOCK: Process uses spin_lock(), tasklet fires on same CPU
═════════════════════════════════════════════════════════════
CPU 0
─────
1. syscall / workqueue handler running
spin_lock(&dev->data_lock) ← acquires lock
2. ──── HARDIRQ fires (any device) ────
│ hardirq handler runs, returns
│
└── irq_exit() → handle_softirqs()
│
└── tasklet_action_common()
my_tasklet_handler()
spin_lock(&dev->data_lock) ← SPINS FOREVER
process holds the lock
softirq can't be preempted
process can't resume
═══ CPU HANGS ═══
The fix: process context must use spin_lock_bh(), which calls __local_bh_disable_ip() to increment preempt_count by SOFTIRQ_LOCK_OFFSET before acquiring the lock. This prevents softirq processing (and therefore all tasklets) from running on this CPU while the lock is held.
Inside the tasklet, plain spin_lock() is sufficient — you are already in softirq context, so no other softirq can preempt you on this CPU.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct my_device {
spinlock_t data_lock;
struct tasklet_struct tasklet;
struct list_head pending_list; /* modified by both tasklet and ioctl */
};
/* runs in softirq context */
static void my_tasklet_handler(struct tasklet_struct *t)
{
struct my_device *dev = from_tasklet(dev, t, tasklet);
spin_lock(&dev->data_lock); /* plain spin_lock — already in softirq */
process_pending(dev);
spin_unlock(&dev->data_lock);
}
/* runs in process context — e.g., ioctl handler */
static long my_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
struct my_device *dev = f->private_data;
spin_lock_bh(&dev->data_lock); /* disables bottom halves — prevents deadlock */
add_to_pending(dev, arg);
spin_unlock_bh(&dev->data_lock);
return 0;
}
spin_lock_bh() at include/linux/spinlock.h, lines 345–349 calls raw_spin_lock_bh() which invokes __local_bh_disable_ip() at include/linux/spinlock_api_smp.h, line 149. This adds SOFTIRQ_LOCK_OFFSET to preempt_count, so even if a hardirq fires, the irq_exit() path sees in_interrupt() == true and skips softirq processing.
Scenario 5: Data Shared Between Tasklet, Hardirq, and Process Context — All Three
When all three contexts access the same data, every path must block the strongest preemptor that could hit it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Three-Way Sharing: Lock Requirements
═════════════════════════════════════
┌─────────────────────────────┬──────────────────────────────────┬──────────────────────────┐
│ Context │ Lock Variant │ Why │
├─────────────────────────────┼──────────────────────────────────┼──────────────────────────┤
│ Hardirq handler │ spin_lock(&lock) │ IRQs already disabled │
│ │ spin_unlock(&lock) │ │
├─────────────────────────────┼──────────────────────────────────┼──────────────────────────┤
│ Tasklet handler │ spin_lock_irqsave(&lock, flags) │ Hardirq can preempt │
│ │ spin_unlock_irqrestore(&lock, f) │ │
├─────────────────────────────┼──────────────────────────────────┼──────────────────────────┤
│ Process context │ spin_lock_irqsave(&lock, flags) │ Both hardirq and tasklet │
│ (syscall, workqueue, kthrd) │ spin_unlock_irqrestore(&lock, f) │ can preempt │
└─────────────────────────────┴──────────────────────────────────┴──────────────────────────┘
Note: process context uses spin_lock_irqsave(), NOT spin_lock_bh().
spin_lock_bh() only blocks softirqs — it does not block hardirqs.
Since the hardirq handler also accesses this data, process context
must disable IRQs entirely to prevent a hardirq → deadlock.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
struct my_device {
spinlock_t lock;
struct tasklet_struct tasklet;
u32 hw_status; /* written by hardirq */
u32 processed; /* written by tasklet */
u32 user_config; /* written by process context (ioctl) */
};
static irqreturn_t my_irq_handler(int irq, void *data)
{
struct my_device *dev = data;
spin_lock(&dev->lock);
dev->hw_status = ioread32(dev->base + STATUS_REG);
spin_unlock(&dev->lock);
tasklet_schedule(&dev->tasklet);
return IRQ_HANDLED;
}
static void my_tasklet_handler(struct tasklet_struct *t)
{
struct my_device *dev = from_tasklet(dev, t, tasklet);
unsigned long flags;
spin_lock_irqsave(&dev->lock, flags);
dev->processed += process(dev->hw_status, dev->user_config);
spin_unlock_irqrestore(&dev->lock, flags);
}
static long my_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
struct my_device *dev = f->private_data;
unsigned long flags;
spin_lock_irqsave(&dev->lock, flags); /* NOT _bh — hardirq also accesses */
dev->user_config = arg;
spin_unlock_irqrestore(&dev->lock, flags);
return 0;
}
Scenario 6: Using atomic_t Instead of Spinlocks
For simple counters or flags, atomic_t operations avoid spinlocks entirely. They compile to single AArch64 atomic instructions (LDADD, STADD, LDXR/STXR loops) and are safe from any context — hardirq, softirq, or process.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct my_device {
struct tasklet_struct tasklet;
atomic_t packets_total; /* updated by both hardirq and tasklet */
atomic_t error_count;
};
static irqreturn_t my_irq_handler(int irq, void *data)
{
struct my_device *dev = data;
atomic_inc(&dev->packets_total); /* single LDADD instruction on AArch64 */
tasklet_schedule(&dev->tasklet);
return IRQ_HANDLED;
}
static void my_tasklet_handler(struct tasklet_struct *t)
{
struct my_device *dev = from_tasklet(dev, t, tasklet);
if (check_error(dev))
atomic_inc(&dev->error_count); /* no spinlock needed */
}
Use atomic_t when the shared operation is a single increment, decrement, test-and-set, or compare-and-swap. If you need to read-modify-write multiple fields atomically (e.g., update both ring_head and ring_count together), atomic_t is not enough — use a spinlock.
Common Mistakes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
┌─────────────────────────────────────────────────────────────────────────────────┐
│ MISTAKES TO AVOID │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ MISTAKE 1: spin_lock_bh() inside a tasklet │
│ ────────────────────────────────────────── │
│ You are ALREADY in softirq context. spin_lock_bh() calls │
│ __local_bh_disable_ip() which increments preempt_count by │
│ SOFTIRQ_LOCK_OFFSET — this is redundant because softirqs │
│ already cannot nest on this CPU. It wastes cycles and │
│ confuses readers. Use spin_lock() instead. │
│ │
│ MISTAKE 2: spin_lock() when hardirq accesses the same data │
│ ──────────────────────────────────────────────────────────── │
│ The hardirq CAN fire during tasklet execution (IRQs enabled). │
│ spin_lock() does not disable IRQs → deadlock. │
│ Use spin_lock_irqsave(). │
│ │
│ MISTAKE 3: spin_lock_bh() in process context when hardirq also shares data │
│ ────────────────────────────────────────────────────────────────────── │
│ spin_lock_bh() blocks softirqs but NOT hardirqs. If the hardirq │
│ handler tries to acquire the same lock → deadlock. When a hardirq │
│ is in the picture, process context must use spin_lock_irqsave(). │
│ │
│ MISTAKE 4: Forgetting locking between different tasklet instances │
│ ──────────────────────────────────────────────────────────────── │
│ TASKLET_STATE_RUN serializes ONE instance. Two different instances │
│ of the same tasklet callback (e.g., two NICs using the same driver) │
│ CAN run on different CPUs simultaneously. Shared subsystem data │
│ must be protected with spin_lock(). │
│ │
│ MISTAKE 5: mutex_lock() or any sleeping lock in a tasklet │
│ ──────────────────────────────────────────────────────── │
│ Tasklets run in softirq context where sleeping is forbidden. │
│ mutex_lock() can sleep. Use spin_lock() or defer to a workqueue. │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
Decision Flowchart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Which Lock Variant Do I Need?
═════════════════════════════
START: Your tasklet shares data with another context
│
├── Does a hardirq handler also access this data?
│ │
│ ├── YES ──► In tasklet: spin_lock_irqsave()
│ │ In hardirq: spin_lock()
│ │ In process: spin_lock_irqsave()
│ │
│ └── NO ──► Does process context access this data?
│ │
│ ├── YES ──► In tasklet: spin_lock()
│ │ In process: spin_lock_bh()
│ │
│ └── NO ──► Does another tasklet/softirq access this data?
│ │
│ ├── YES ──► In both: spin_lock()
│ │
│ └── NO ──► No lock needed
│ (same instance = serialized)
SHORTCUT: Is it just a counter or flag?
│
└── YES ──► Use atomic_t / atomic_long_t — no spinlock needed
Sleeping in a Tasklet Handler
Sleeping is absolutely forbidden in a tasklet handler. Tasklets run inside softirq context, where preempt_count has the SOFTIRQ_OFFSET bit set. Any attempt to call schedule(), mutex_lock(), msleep(), wait_event(), kmalloc(GFP_KERNEL), or any other function that might sleep will trigger a kernel BUG:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
┌─────────────────────────────────────────────────────────────────────────────────┐
│ WHY SLEEPING IS FORBIDDEN │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Tasklet runs in softirq context: │
│ │
│ preempt_count = 0x00000100 (SOFTIRQ_OFFSET bit 8 set) │
│ │
│ If the handler calls schedule(): │
│ schedule() → __schedule() → schedule_debug() │
│ → in_atomic_preempt_off() checks preempt_count │
│ → preempt_count = 0x100 ≠ 0 │
│ → __schedule_bug() │
│ → "BUG: scheduling while atomic: <comm>/<pid>/0x00000100" │
│ │
│ FORBIDDEN OPERATIONS IN TASKLET HANDLERS: │
│ ───────────────────────────────────────── │
│ ✗ schedule() │
│ ✗ mutex_lock() (calls schedule() internally) │
│ ✗ down() / down_interruptible() (semaphore acquire — may sleep) │
│ ✗ msleep() / ssleep() (calls schedule_timeout()) │
│ ✗ wait_event() (may call schedule()) │
│ ✗ kmalloc(GFP_KERNEL) (may sleep for memory reclaim) │
│ ✗ copy_from_user/copy_to_user (may page fault and sleep) │
│ ✗ usleep_range() (calls schedule() internally) │
│ │
│ ALLOWED OPERATIONS: │
│ ────────────────── │
│ ✓ spin_lock() / spin_unlock() │
│ ✓ spin_lock_irqsave() / spin_unlock_irqrestore() │
│ ✓ kmalloc(GFP_ATOMIC) │
│ ✓ kfree() │
│ ✓ tasklet_schedule() │
│ ✓ queue_work() / schedule_work() │
│ ✓ atomic_inc() / atomic_dec() / etc. │
│ ✓ pr_info() / printk() │
│ ✓ del_timer() / mod_timer() │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
If a tasklet needs to perform work that requires sleeping (e.g., allocating memory with GFP_KERNEL, acquiring a mutex, or performing I/O that blocks), the correct approach is to schedule a workqueue item from within the tasklet handler. The workqueue runs in process context where sleeping is permitted.
Interrupt State While a Tasklet Runs
Hardware interrupts are enabled while a tasklet handler executes. Tasklets run inside the softirq processing loop (handle_softirqs()), which explicitly re-enables interrupts with local_irq_enable() at kernel/softirq.c, line 606 before entering the handler loop.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Interrupt State During Tasklet Execution
═════════════════════════════════════════
handle_softirqs()
│
├── local_irq_enable() ← IRQs ENABLED (PSTATE.I = 0 on AArch64)
│
├── ffs(pending) finds TASKLET_SOFTIRQ (bit 6)
│ └── tasklet_action()
│ └── tasklet_action_common()
│ └── while (list):
│ tasklet_trylock(t)
│ t->callback(t) ← HANDLER RUNS WITH IRQs ENABLED
│ tasklet_unlock(t)
│
└── local_irq_disable() ← IRQs DISABLED again
Consequence: a hardirq CAN preempt a tasklet handler at any point.
If the tasklet accesses data shared with hardirq handlers,
it MUST use spin_lock_irqsave().
This is the same for softirqs in general: hardware interrupts are enabled during handler execution. It is one of the key reasons that softirq/tasklet context is more suitable for longer work than hardirq context.
Execution Context of Tasklets and Softirqs
Both tasklets and softirqs run in softirq context — also called “interrupt context” in the broader sense (as opposed to process context), but specifically the softirq sub-level of interrupt context.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌─────────────────────────────────────────────────────────────────────────────────┐
│ EXECUTION CONTEXT HIERARCHY │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ NMI Context (level 3) │
│ │ preempt_count: NMI_MASK set │
│ │ Cannot be interrupted by anything except machine check │
│ │ │
│ ▼ │
│ Hardirq Context (level 2) │
│ │ preempt_count: HARDIRQ_MASK set │
│ │ IRQs disabled (PSTATE.I = 1) │
│ │ Cannot be preempted by softirqs or scheduling │
│ │ │
│ ▼ │
│ Softirq Context (level 1) ◄── TASKLETS AND SOFTIRQS RUN HERE │
│ │ preempt_count: SOFTIRQ_OFFSET set (bit 8) │
│ │ IRQs ENABLED (PSTATE.I = 0) │
│ │ CAN be preempted by hardirqs │
│ │ CANNOT be preempted by other softirqs on same CPU │
│ │ CANNOT sleep │
│ │ │
│ ▼ │
│ Process Context (level 0) │
│ │ preempt_count: 0 (or only PREEMPT bits) │
│ │ IRQs enabled │
│ │ Can be preempted, can sleep │
│ │ in_task() = true │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
Detecting Hard vs Soft IRQ Context in a Tasklet
A tasklet runs in softirq context, not hardirq context. To verify this from within a tasklet handler, use the context detection macros defined in include/linux/preempt.h, lines 108–141:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void my_tasklet_handler(struct tasklet_struct *t)
{
pr_info("in_hardirq() = %d\n", !!in_hardirq());
pr_info("in_serving_softirq() = %d\n", !!in_serving_softirq());
pr_info("in_softirq() = %d\n", !!in_softirq());
pr_info("in_task() = %d\n", !!in_task());
pr_info("in_interrupt() = %d\n", !!in_interrupt());
pr_info("irqs_disabled() = %d\n", irqs_disabled());
/*
* Expected output:
* in_hardirq() = 0 ← NOT in hardirq
* in_serving_softirq() = 1 ← YES, in softirq handler
* in_softirq() = 1 ← YES (broader check)
* in_task() = 0 ← NOT in process context
* in_interrupt() = 1 ← YES, in interrupt context
* irqs_disabled() = 0 ← IRQs are ENABLED
*/
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
preempt_count Inside a Tasklet Handler
═══════════════════════════════════════
preempt_count = 0x00000100
Bits: 19 18 17 16 | 15 ... 9 | 8 | 7 ... 0
┌───┬───┬───┬───┼────────────┼─────┼────────────┐
│ 0 0 0 0 │ 0 ... 0 │ 1 │ 0 ... 0 │
│ HARDIRQ │ SOFTIRQ │ │ PREEMPT │
└───────────────┴──── MASK ──┴─────┴────────────┘
▲
│
SOFTIRQ_OFFSET (0x100)
Set by softirq_handle_begin()
in_hardirq() → (0x100 & HARDIRQ_MASK) = 0 → false
in_serving_softirq() → (0x100 & SOFTIRQ_MASK) & 0x100 → true (0x100)
in_softirq() → (0x100 & SOFTIRQ_MASK) → true (0x100)
in_task() → !(0x100 & (NMI|HARDIRQ|SOFTIRQ_OFFSET)) → false
Experiment: Verifying Execution Context Inside a Tasklet
This module checks all context macros from within a tasklet handler to confirm it runs in softirq context. Source: day37/12, day37/13, day37/16, day37/17.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/interrupt.h>
MODULE_LICENSE("GPL");
static struct tasklet_struct my_tasklet;
static void context_check_handler(struct tasklet_struct *t)
{
pr_info("--- Context inside tasklet handler ---\n");
pr_info(" irqs_disabled() = %d\n", irqs_disabled());
pr_info(" in_hardirq() = %d\n", !!in_hardirq());
pr_info(" in_serving_softirq() = %d\n", !!in_serving_softirq());
pr_info(" in_softirq() = %d\n", !!in_softirq());
pr_info(" in_interrupt() = %d\n", !!in_interrupt());
pr_info(" in_task() = %d\n", !!in_task());
pr_info(" preempt_count() = 0x%08x\n", preempt_count());
pr_info(" current->pid = %d\n", current->pid);
pr_info(" current->comm = %s\n", current->comm);
}
static int __init test_init(void)
{
tasklet_setup(&my_tasklet, context_check_handler);
tasklet_schedule(&my_tasklet);
return 0;
}
static void __exit test_exit(void)
{
tasklet_kill(&my_tasklet);
}
module_init(test_init);
module_exit(test_exit);
1
2
3
4
5
6
7
8
9
10
11
12
Expected dmesg output:
──────────────────────
--- Context inside tasklet handler ---
irqs_disabled() = 0 ← IRQs ENABLED (tasklets run with IRQs on)
in_hardirq() = 0 ← NOT in hard IRQ context
in_serving_softirq() = 1 ← YES, in softirq handler
in_softirq() = 1 ← YES (broader check)
in_interrupt() = 1 ← YES, in interrupt context (soft)
in_task() = 0 ← NOT in process context
preempt_count() = 0x00000100 ← SOFTIRQ_OFFSET set
current->pid = 0 ← ksoftirqd or interrupted process
current->comm = ksoftirqd/N or swapper/N
This single module consolidates four separate experiments and adds preempt_count() for direct verification of the SOFTIRQ_OFFSET bit.
CPU Affinity of Tasklets
Tasklets are NOT guaranteed to run on the same CPU that first scheduled them. While in the common case (inline processing at irq_exit()), the tasklet runs on the same CPU, this is not a hard guarantee.
Here is why: when a hardirq handler on CPU 2 calls tasklet_schedule(t), the tasklet is appended to CPU 2’s tasklet_vec list, and TASKLET_SOFTIRQ is raised on CPU 2. If the softirq is processed inline at irq_exit(), the tasklet runs on CPU 2.
But there are scenarios where this does not hold:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌─────────────────────────────────────────────────────────────────────────────────┐
│ TASKLET CPU AFFINITY — NOT GUARANTEED │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ SCENARIO 1: ksoftirqd processing │
│ ────────────────────────────────── │
│ If handle_softirqs() exceeds its budget (10 restarts / 2 ms), the │
│ remaining work is deferred to ksoftirqd/N. ksoftirqd IS per-CPU, │
│ so the tasklet still runs on the same CPU — but via a different │
│ code path (as a schedulable kernel thread). │
│ │
│ SCENARIO 2: Re-queue after trylock failure │
│ ────────────────────────────────────────── │
│ If the tasklet is already running on CPU 0 when CPU 2 processes it, │
│ tasklet_trylock() fails and the tasklet is re-queued on CPU 2's list. │
│ It will be retried on CPU 2, not CPU 0. The tasklet may eventually │
│ execute on CPU 2 (after CPU 0 finishes). │
│ │
│ SCENARIO 3: Scheduled from process context │
│ ─────────────────────────────────────────── │
│ If tasklet_schedule() is called from process context (not from a │
│ hardirq handler), the tasklet is added to the current CPU's list. │
│ If the process then migrates to another CPU, the tasklet still runs │
│ on the CPU where it was scheduled (since the list is per-CPU). │
│ │
│ BOTTOM LINE: │
│ A tasklet is queued on the CPU where tasklet_schedule() was called. │
│ It will execute on that CPU. But if the handler re-schedules itself │
│ or if different invocations happen on different CPUs, there is no │
│ guarantee of CPU consistency across invocations. │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
The practical implication: do not rely on a tasklet executing on a specific CPU. If CPU affinity matters, use a workqueue with explicit CPU binding or a threaded IRQ.
Enabling and Disabling Tasklets
tasklet_disable()
At include/linux/interrupt.h, lines 789–794:
1
2
3
4
5
6
static inline void tasklet_disable(struct tasklet_struct *t)
{
tasklet_disable_nosync(t);
tasklet_unlock_wait(t);
smp_mb();
}
tasklet_disable() increments count (making it nonzero → disabled) and then waits for any currently executing instance to finish (tasklet_unlock_wait() blocks until TASKLET_STATE_RUN is cleared). After this call returns, you are guaranteed that the tasklet is not running and will not run until re-enabled. This function may sleep (because tasklet_unlock_wait() calls wait_on_bit() with TASK_UNINTERRUPTIBLE) and therefore must not be called from atomic context.
tasklet_disable_nosync()
At include/linux/interrupt.h, lines 772–776:
1
2
3
4
5
static inline void tasklet_disable_nosync(struct tasklet_struct *t)
{
atomic_inc(&t->count);
smp_mb__after_atomic();
}
tasklet_disable_nosync() increments count but does not wait for a currently running instance to finish. The tasklet is disabled (will not start a new execution), but it may still be in the middle of executing on another CPU when this function returns. This is safe to call from any context, including hardirq.
tasklet_disable_in_atomic()
At include/linux/interrupt.h, lines 782–787:
1
2
3
4
5
6
static inline void tasklet_disable_in_atomic(struct tasklet_struct *t)
{
tasklet_disable_nosync(t);
tasklet_unlock_spin_wait(t);
smp_mb();
}
This variant disables the tasklet and spin-waits (busy-loops) for any running instance to complete. It is usable from atomic context but wastes CPU cycles while waiting. Marked as “do not use in new code.”
tasklet_enable()
At include/linux/interrupt.h, lines 796–800:
1
2
3
4
5
static inline void tasklet_enable(struct tasklet_struct *t)
{
smp_mb__before_atomic();
atomic_dec(&t->count);
}
tasklet_enable() decrements count. If this brings count back to 0, the tasklet is re-enabled and will execute on the next softirq processing pass (if it is scheduled).
tasklet_kill()
At kernel/softirq.c, lines 1022–1032:
1
2
3
4
5
6
7
8
9
10
11
void tasklet_kill(struct tasklet_struct *t)
{
if (in_interrupt())
pr_notice("Attempt to kill tasklet from interrupt\n");
wait_on_bit_lock(&t->state, TASKLET_STATE_SCHED, TASK_UNINTERRUPTIBLE);
tasklet_unlock_wait(t);
tasklet_clear_sched(t);
}
EXPORT_SYMBOL(tasklet_kill);
tasklet_kill() permanently removes a tasklet from the scheduling system. It:
- Waits for
TASKLET_STATE_SCHEDto become clear (meaning the tasklet’s handler has completed andtasklet_clear_sched()was called), then sets it to prevent re-scheduling. - Waits for
TASKLET_STATE_RUNto become clear (ensuring the handler is not currently executing). - Clears
TASKLET_STATE_SCHED.
This function may sleep and must be called from process context. It is typically called during module cleanup (module_exit).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────────────────────────┐
│ TASKLET ENABLE/DISABLE vs local_bh_disable │
├────────────────────────────┬────────────────────────────────────────────────────┤
│ tasklet_disable(t) │ local_bh_disable() │
├────────────────────────────┼────────────────────────────────────────────────────┤
│ Disables ONE specific │ Disables ALL bottom halves (all softirqs, │
│ tasklet instance │ all tasklets) on the local CPU │
├────────────────────────────┼────────────────────────────────────────────────────┤
│ Other tasklets on this │ No softirq or tasklet can run on this CPU │
│ CPU are unaffected │ until local_bh_enable() │
├────────────────────────────┼────────────────────────────────────────────────────┤
│ Increments t->count │ Adds SOFTIRQ_DISABLE_OFFSET (0x200) to │
│ (per-tasklet atomic) │ preempt_count (per-CPU) │
├────────────────────────────┼────────────────────────────────────────────────────┤
│ Other CPUs can still run │ Other CPUs are unaffected — their softirqs │
│ this tasklet (until it │ continue normally │
│ finishes, due to trylock) │ │
├────────────────────────────┼────────────────────────────────────────────────────┤
│ Use case: prevent a │ Use case: protect per-CPU data shared with │
│ specific tasklet from │ softirq handlers from concurrent access │
│ running, e.g., before │ on the same CPU │
│ modifying its state or │ │
│ during module teardown │ │
└────────────────────────────┴────────────────────────────────────────────────────┘
Experiment: local_bh_disable Defers Tasklet Execution
This module schedules a tasklet, then immediately disables bottom halves. The tasklet cannot execute until local_bh_enable() is called — proving that local_bh_disable() blocks all softirqs on the local CPU, including tasklets. Source: day37/25.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/interrupt.h>
#include <linux/delay.h>
MODULE_LICENSE("GPL");
static struct tasklet_struct my_tasklet;
static void my_handler(struct tasklet_struct *t)
{
pr_info("tasklet handler executed\n");
}
static int __init test_init(void)
{
tasklet_setup(&my_tasklet, my_handler);
tasklet_schedule(&my_tasklet);
pr_info("tasklet scheduled, now disabling BH\n");
local_bh_disable();
pr_info("BH disabled — tasklet CANNOT run here\n");
mdelay(100);
pr_info("re-enabling BH\n");
local_bh_enable();
pr_info("BH enabled — tasklet should have run by now\n");
return 0;
}
static void __exit test_exit(void)
{
tasklet_kill(&my_tasklet);
}
module_init(test_init);
module_exit(test_exit);
1
2
3
4
5
6
7
8
9
10
11
Expected dmesg output:
──────────────────────
tasklet scheduled, now disabling BH
BH disabled — tasklet CANNOT run here
re-enabling BH ← tasklet still blocked
tasklet handler executed ← runs inside local_bh_enable() path
BH enabled — tasklet should have run by now
local_bh_enable() calls do_softirq() on the way out if softirqs
are pending, so the tasklet handler runs INSIDE the local_bh_enable()
call — before the next pr_info prints.
What Happens If a Tasklet Is Scheduled and You Call tasklet_kill?
If a tasklet has been scheduled (i.e., TASKLET_STATE_SCHED is set and the tasklet is on a per-CPU list waiting to execute), calling tasklet_kill() will block until the tasklet’s handler has executed and completed.
Here is the sequence:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
tasklet_kill() When Tasklet Is Scheduled
═════════════════════════════════════════
State at call: SCHED=1, RUN=0 (tasklet is on the per-CPU list, waiting)
tasklet_kill(t):
│
├── wait_on_bit_lock(&t->state, TASKLET_STATE_SCHED, TASK_UNINTERRUPTIBLE)
│ │
│ └── SCHED bit is 1 → BLOCKS (sleeps)
│ Waits until SCHED bit becomes 0 (cleared by tasklet_clear_sched
│ after the handler finishes), then sets it again to prevent
│ re-scheduling.
│
│ Meanwhile, on the CPU where the tasklet is queued:
│ tasklet_action_common() runs:
│ tasklet_trylock(t) → sets RUN=1
│ tasklet_clear_sched(t) → clears SCHED → wakes up tasklet_kill()
│ t->callback(t) → handler executes
│ tasklet_unlock(t) → clears RUN=0
│
├── wait_on_bit_lock returns (SCHED is now locked by us)
│
├── tasklet_unlock_wait(t)
│ └── RUN is already 0 → returns immediately
│
└── tasklet_clear_sched(t)
└── Clears SCHED bit — tasklet is now fully dead
After return:
- The handler has completed
- The tasklet will not run again (SCHED bit is clear, and we hold it)
- Caller can safely free resources
Because tasklet_kill() may sleep waiting for the handler to complete, it must not be called from interrupt context (hardirq or softirq). The in_interrupt() check at the top of the function prints a notice (not a BUG) if this rule is violated, but the subsequent wait_on_bit_lock() will still attempt to sleep, likely causing a kernel crash.
Experiment: Observing tasklet_kill State Transitions
This module schedules a tasklet, then immediately calls tasklet_kill(). The tasklet_kill() call blocks until the handler finishes, then clears the SCHED bit. Source: day37/23.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/interrupt.h>
MODULE_LICENSE("GPL");
static struct tasklet_struct my_tasklet;
static void my_handler(struct tasklet_struct *t)
{
pr_info("handler: starting\n");
pr_info("handler: state = %ld (expect RUN=1, SCHED cleared)\n", t->state);
pr_info("handler: finishing\n");
}
static int __init test_init(void)
{
tasklet_setup(&my_tasklet, my_handler);
pr_info("before schedule: state = %ld, count = %d\n",
my_tasklet.state, atomic_read(&my_tasklet.count));
tasklet_schedule(&my_tasklet);
pr_info("after schedule: state = %ld, count = %d\n",
my_tasklet.state, atomic_read(&my_tasklet.count));
pr_info("calling tasklet_kill (will block until handler completes)...\n");
tasklet_kill(&my_tasklet);
pr_info("after kill: state = %ld, count = %d\n",
my_tasklet.state, atomic_read(&my_tasklet.count));
return 0;
}
static void __exit test_exit(void)
{
pr_info("module unloaded\n");
}
module_init(test_init);
module_exit(test_exit);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Expected dmesg output:
──────────────────────
before schedule: state = 0, count = 0
after schedule: state = 1, count = 0 ← SCHED=1
calling tasklet_kill (will block until handler completes)...
handler: starting
handler: state = 2 (expect RUN=1, SCHED cleared) ← RUN=1, SCHED cleared by tasklet_clear_sched
handler: finishing
after kill: state = 0, count = 0 ← both bits cleared, tasklet is dead
Timeline:
1. tasklet_schedule() sets SCHED=1 → state=1
2. tasklet_kill() calls wait_on_bit_lock(SCHED) → blocks
3. Softirq fires: tasklet_trylock sets RUN=1, tasklet_clear_sched clears SCHED
4. Handler runs with state=2 (RUN only)
5. tasklet_unlock clears RUN → wakes tasklet_kill
6. tasklet_kill clears SCHED → state=0
Softirqs vs Tasklets: A Detailed Comparison
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
┌───────────────────────────┬────────────────────────────┬────────────────────────────┐
│ Property │ Softirq │ Tasklet │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Allocation │ Static — 10 compile-time │ Dynamic — any module can │
│ │ vectors only │ create unlimited tasklets│
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Registration │ open_softirq() at boot │ DECLARE_TASKLET or │
│ │ (kernel-only) │ tasklet_setup() (module) │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Execution context │ Softirq context │ Softirq context (runs │
│ │ (SOFTIRQ_OFFSET in │ inside TASKLET_SOFTIRQ │
│ │ preempt_count) │ or HI_SOFTIRQ handler) │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Concurrency │ Same handler runs on │ Same tasklet instance │
│ │ multiple CPUs at once │ runs on ONE CPU at a time │
│ │ (fully parallel) │ (serialized per-instance) │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Locking requirement │ Handler MUST use per-CPU │ Handler needs no locking │
│ │ data or fine-grained │ for its own state (but │
│ │ locking for shared data │ still needs locks for │
│ │ │ data shared with other │
│ │ │ tasklets or contexts) │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ IRQ state │ Hardware IRQs ENABLED │ Hardware IRQs ENABLED │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Can sleep? │ NO │ NO │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Recompile kernel to add? │ YES │ NO │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Performance │ Maximum throughput — │ Good throughput — │
│ │ all CPUs can process in │ serialization limits to │
│ │ parallel │ one CPU at a time │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Complexity │ High — handler must be │ Low — serialization │
│ │ fully reentrant │ provided automatically │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Scheduling │ raise_softirq() sets a │ tasklet_schedule() sets │
│ │ bit in __softirq_pending │ SCHED bit, appends to │
│ │ │ per-CPU list, then raises │
│ │ │ TASKLET_SOFTIRQ bit │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Double-schedule behavior │ Idempotent — OR on a │ Idempotent — SCHED bit │
│ │ bitmask is idempotent │ prevents double-linking │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Disable mechanism │ local_bh_disable() │ tasklet_disable(t) for │
│ │ disables ALL softirqs on │ one specific tasklet │
│ │ the local CPU │ │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ Users │ NET_RX, NET_TX, TIMER, │ Legacy driver BHs, │
│ │ BLOCK, RCU, SCHED, │ various subsystem │
│ │ HRTIMER │ callbacks │
├───────────────────────────┼────────────────────────────┼────────────────────────────┤
│ API status │ Active — used by core │ DEPRECATED — new code │
│ │ kernel subsystems │ should use threaded IRQs │
└───────────────────────────┴────────────────────────────┴────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Relationship: Tasklets Are Built ON TOP OF Softirqs
════════════════════════════════════════════════════
┌─────────────────────────────────────────────────────────────────┐
│ SOFTIRQ FRAMEWORK │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ softirq_vec[10] ← registered via open_softirq() │ │
│ │ │ │
│ │ [0] HI_SOFTIRQ → tasklet_hi_action() │ │
│ │ [1] TIMER_SOFTIRQ → run_timer_softirq() │ │
│ │ [2] NET_TX_SOFTIRQ → net_tx_action() │ │
│ │ [3] NET_RX_SOFTIRQ → net_rx_action() │ │
│ │ [4] BLOCK_SOFTIRQ → blk_done_softirq() │ │
│ │ [5] IRQ_POLL_SOFTIRQ → irq_poll_softirq() │ │
│ │ [6] TASKLET_SOFTIRQ → tasklet_action() ───────────┐ │ │
│ │ [7] SCHED_SOFTIRQ → sched_balance_softirq() │ │ │
│ │ [8] HRTIMER_SOFTIRQ → hrtimer_run_softirq() │ │ │
│ │ [9] RCU_SOFTIRQ → rcu_core_si() │ │ │
│ └──────────────────────────────────────────────────────┼────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────▼────┐ │
│ │ TASKLET LAYER │ │
│ │ │ │
│ │ Per-CPU lists: tasklet_vec (for TASKLET_SOFTIRQ) │ │
│ │ tasklet_hi_vec (for HI_SOFTIRQ) │ │
│ │ │ │
│ │ Each list contains tasklet_struct instances: │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ tasklet │──►│ tasklet │──►│ tasklet │──► NULL │ │
│ │ │ A │ │ B │ │ C │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ │ │ │
│ │ API: tasklet_schedule(), tasklet_disable(), │ │
│ │ tasklet_kill(), tasklet_setup() │ │
│ │ │ │
│ │ Serialization: TASKLET_STATE_RUN bit prevents │ │
│ │ concurrent execution of the same tasklet │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
The tasklet layer is purely a software construct built on top of two existing softirq vectors. From the softirq framework’s perspective, tasklet_action() and tasklet_hi_action() are just softirq handlers like any other — they happen to implement a linked-list-based scheduler for dynamically registered callback functions with per-instance serialization.
Tasklet Deprecation: History, Discussion, and the Future
The tasklet API is officially marked as deprecated in the kernel source. The deprecation notice in include/linux/interrupt.h, lines 668–671 reads:
1
2
3
4
/* Tasklets --- multithreaded analogue of BHs.
This API is deprecated. Please consider using threaded IRQs instead:
https://lore.kernel.org/lkml/20200716081538.2sivhkj4hcyrusem@linutronix.de
The referenced LKML thread is a July 2020 message from Thomas Gleixner (maintainer of the interrupt subsystem and PREEMPT_RT) proposing a plan to phase out tasklets. The core argument: tasklets are a legacy mechanism from the BH (bottom half) era of Linux 2.2, and they create fundamental problems for the PREEMPT_RT patchset.
Key LWN.net Discussions
The deprecation discussion spanned several years and multiple LWN articles:
“Eliminating tasklets” (July 2007) — Steven Rostedt proposed removing tasklets entirely, arguing that workqueues and other mechanisms had superseded them. The effort stalled due to the sheer number of in-tree users. LWN: https://lwn.net/Articles/239633/
“Modernize the tasklet API” (September 2019) — Romain Perier posted a 16-patch series to convert
tasklet_init()totasklet_setup()across crypto, mmc, networking, ISDN, and SCSI subsystems — modeled on the earliertimer_listcallback conversion. LWN: https://lwn.net/Articles/800928/“Modernizing the tasklet API” (September 2020) — The main article. Kees Cook posted a security-inspired patch set (co-developed with Romain Perier) changing the callback signature from
void (*func)(unsigned long)tovoid (*callback)(struct tasklet_struct *). Motivation: eliminate unsafeunsigned long-to-pointer casts (same rationale as thetimer_structconversion), reducing the attack surface during memory corruption. The discussion also raised the broader question of removing tasklets entirely in favor of threaded IRQs or workqueues. LWN: https://lwn.net/Articles/830964/“The end of tasklets” (February 2024) — Covers the introduction of BH workqueues (
WQ_BH) as the ultimate tasklet replacement. BH workqueues execute in bottom-half context (like tasklets) but use the workqueue infrastructure. The old tasklet API has design flaws including potential use-after-free (the execution code accesses the tasklet item after completion). The plan: convert 500+ tasklet users one subsystem at a time, then remove the API entirely. LWN: https://lwn.net/Articles/960041/“The bottom half of the kernel” (March 2012) — An older but foundational article explaining the historical evolution from BHs → tasklets → softirqs → workqueues → threaded IRQs. Provides essential context for why each mechanism was introduced and what problems it solved. LWN: https://lwn.net/Articles/493991/
Relevant Kernel Commit History
The callback-based API was introduced by commit 12cc923f1ccc — “tasklet: Introduce new initialization API” by Romain Perier (co-developed with Allen Pais), dated 2019-09-29. The commit message highlights:
- The old API passes
unsigned long— no type checking, forces explicit casts - Keeps a redundant
.datafield, bloatingtasklet_struct - Buffer overflows can overwrite
.funcand.data, letting attackers control both the function pointer and its argument — a trivial privilege escalation - The new API uses
callback(struct tasklet_struct *t)+container_of()pattern (following the same model astimer_setup()/from_timer()) - The
use_callbackmember was added to allow coexistence during the transition;.dataanduse_callbackwill be removed once all old callers are converted - On 64-bit architectures,
use_callbackfills the padding hole afteratomic_t count, so struct size does not grow
The ongoing conversion effort has produced hundreds of driver commits matching patterns like “Convert from tasklet to BH workqueue” across crypto, networking, mmc, media, mailbox, IPMI, dm-verity, and many other subsystems — the kernel is actively migrating away from tasklets.
Why the Tasklet API Is Deprecated
The tasklet API has three fundamental problems that led to its deprecation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌─────────────────────────────────────────────────────────────────────────────────┐
│ WHY TASKLETS ARE DEPRECATED │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ PROBLEM 1: PREEMPT_RT INCOMPATIBILITY │
│ ────────────────────────────────────── │
│ Tasklets run in softirq context with preemption disabled. On PREEMPT_RT │
│ kernels, the goal is to make ALL non-NMI code preemptible — but tasklets │
│ cannot be preempted by design (SOFTIRQ_OFFSET in preempt_count). This │
│ creates unbounded latency spikes in RT systems. │
│ │
│ PREEMPT_RT had to add special hacks to serialize tasklets through a per-CPU │
│ spinlock, but this is a workaround, not a solution. │
│ │
│ PROBLEM 2: TYPE SAFETY AND CFI │
│ ────────────────────────────── │
│ The old API uses void (*func)(unsigned long data) where "data" is │
│ typically a cast pointer: (unsigned long)my_struct_ptr. This is: │
│ • A C type-safety violation (casting pointer to integer and back) │
│ • Incompatible with Control Flow Integrity (CFI/KCFI) — the compiler │
│ cannot verify that the function signature matches the call site │
│ • Error-prone — wrong casts cause silent corruption, not compile errors │
│ │
│ PROBLEM 3: POOR EXECUTION MODEL │
│ ────────────────────────────── │
│ Tasklets cannot sleep, cannot be prioritized, cannot be CPU-affinity- │
│ controlled, and have no scheduler visibility. Threaded IRQs and workqueues │
│ provide ALL of these capabilities while being simpler to reason about. │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
Is Threaded IRQ the Replacement? Then Why Introduce a New Tasklet API?
Threaded IRQs ARE the recommended replacement for new code. When a driver uses request_threaded_irq(), the bottom-half work runs in a dedicated kernel thread (irq/N-name) in process context at SCHED_FIFO priority 50. It can sleep, has scheduler visibility, works perfectly with PREEMPT_RT, and supports IRQF_ONESHOT for level-triggered interrupts.
But there is a practical problem: hundreds of existing drivers use tasklets, and converting them all to threaded IRQs is a multi-year effort requiring individual per-driver review and testing. The new callback-based tasklet API (tasklet_setup / from_tasklet) was introduced not to encourage new tasklet usage, but as an intermediate step in the migration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────────────────────────┐
│ THE MIGRATION STRATEGY │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: Fix type safety (COMPLETED) │
│ ───────────────────────────────────── │
│ Convert void (*func)(unsigned long) → void (*callback)(struct tasklet_struct *)│
│ This is what tasklet_setup() and from_tasklet() accomplish. │
│ Benefit: type-safe, CFI-compatible, uses container_of pattern │
│ │
│ Phase 2: Convert drivers to threaded IRQs/workqueues (ONGOING) │
│ ────────────────────────────────────────────────────────────── │
│ Each driver's tasklet is replaced with request_threaded_irq() or │
│ a workqueue, depending on the latency requirements. │
│ This requires per-driver analysis — cannot be automated. │
│ │
│ Phase 3: Remove tasklet API entirely (FUTURE) │
│ ────────────────────────────────────────────── │
│ Once all users are converted, remove struct tasklet_struct, │
│ tasklet_schedule(), and the TASKLET_SOFTIRQ/HI_SOFTIRQ vectors. │
│ │
│ THE NEW API EXISTS TO FACILITATE PHASE 1, NOT TO ENCOURAGE NEW USAGE. │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
Callback-Based vs func + data API: What Changed and Why
The old and new tasklet APIs differ in their function pointer signature and how the handler accesses its associated data:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
┌─────────────────────────────────────────────────────────────────────────────────┐
│ OLD API (func + data) NEW API (callback) │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Function signature: Function signature: │
│ void (*func)(unsigned long data); void (*callback)( │
│ struct tasklet_struct *t); │
│ │
│ Initialization: Initialization: │
│ tasklet_init(&t, my_func, tasklet_setup(&t, my_callback); │
│ (unsigned long)my_ptr); │
│ │
│ Static declaration: Static declaration: │
│ DECLARE_TASKLET_OLD(name, func); DECLARE_TASKLET(name, callback); │
│ │
│ Accessing data: Accessing data: │
│ void my_func(unsigned long data) { void my_callback( │
│ struct my_dev *dev = struct tasklet_struct *t) { │
│ (struct my_dev *)data; struct my_dev *dev = │
│ /* unsafe cast! */ from_tasklet(dev, t, tlet); │
│ } /* type-safe container_of */ │
│ } │
│ │
│ use_callback field: false use_callback field: true │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ PROBLEMS WITH THE OLD API: │
│ │
│ 1. Type erasure: casting struct pointer → unsigned long → struct pointer │
│ loses all type information. The compiler cannot verify correctness. │
│ │
│ 2. CFI violation: Control Flow Integrity checks verify that function │
│ pointer call sites match the actual function signature. With │
│ the old API, the indirect call through func has signature │
│ void (*)(unsigned long), but CFI cannot verify that "data" │
│ is actually a valid pointer to the expected structure. │
│ │
│ 3. No self-reference: the handler does not receive a pointer to │
│ the tasklet_struct itself, so it cannot use container_of() — │
│ the idiomatic kernel pattern for accessing enclosing structures. │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ HOW THE NEW API FIXES THIS: │
│ │
│ 1. The callback receives struct tasklet_struct * — a typed pointer. │
│ No casts needed. │
│ │
│ 2. from_tasklet() uses container_of() to get the enclosing structure. │
│ This is type-checked at compile time. Wrong types → compile error. │
│ │
│ 3. Follows the same pattern as from_timer() in the timer API — │
│ consistent, idiomatic, well-understood by kernel developers. │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
Inside tasklet_action_common(), the use_callback flag determines which call style is used at kernel/softirq.c, lines 936–944:
1
2
3
4
5
6
7
8
9
if (t->use_callback) {
trace_tasklet_entry(t, t->callback);
t->callback(t); /* new API: pass tasklet_struct pointer */
trace_tasklet_exit(t, t->callback);
} else {
trace_tasklet_entry(t, t->func);
t->func(t->data); /* old API: pass opaque unsigned long */
trace_tasklet_exit(t, t->func);
}
Complete Driver Examples: New API and Old API
New Callback API — Complete Working Example
This example shows a simplified network driver that uses request_irq() for the hardirq handler and tasklet_setup() for deferred RX processing. Based on the pattern used by the JME driver at drivers/net/ethernet/jme.c.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#include <linux/module.h>
#include <linux/pci.h>
#include <linux/netdevice.h>
#include <linux/interrupt.h>
struct my_nic {
struct pci_dev *pdev;
struct net_device *netdev;
void __iomem *regs;
struct tasklet_struct rx_tasklet; /* tasklet embedded in device struct */
struct tasklet_struct tx_tasklet;
spinlock_t lock;
u8 *rx_buffer;
int rx_len;
};
/*
* Tasklet handler — runs in softirq context, IRQs enabled, cannot sleep.
* Receives the tasklet_struct pointer; use from_tasklet() to get the
* enclosing my_nic structure.
*/
static void my_rx_tasklet(struct tasklet_struct *t)
{
struct my_nic *nic = from_tasklet(nic, t, rx_tasklet);
/*
* from_tasklet(nic, t, rx_tasklet) expands to:
* container_of(t, struct my_nic, rx_tasklet)
*
* This is type-safe: if 'rx_tasklet' is not a field of struct my_nic,
* or if its type is not struct tasklet_struct, the compiler errors.
*/
/* Process all pending RX packets (drain the ring) */
spin_lock(&nic->lock);
process_received_packets(nic);
spin_unlock(&nic->lock);
/* If the hardware has more data, re-schedule ourselves */
if (hw_has_pending_rx(nic))
tasklet_schedule(&nic->rx_tasklet);
}
static void my_tx_tasklet(struct tasklet_struct *t)
{
struct my_nic *nic = from_tasklet(nic, t, tx_tasklet);
spin_lock(&nic->lock);
reclaim_tx_buffers(nic);
spin_unlock(&nic->lock);
if (netif_queue_stopped(nic->netdev))
netif_wake_queue(nic->netdev);
}
/*
* Hardirq handler — runs with IRQs disabled on local CPU.
* Must be fast: read interrupt status, acknowledge, schedule tasklets.
*/
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
struct net_device *netdev = dev_id;
struct my_nic *nic = netdev_priv(netdev);
u32 status;
status = ioread32(nic->regs + IRQ_STATUS_REG);
if (!status)
return IRQ_NONE; /* not our interrupt (shared IRQ line) */
/* Acknowledge the interrupt at the hardware level */
iowrite32(status, nic->regs + IRQ_ACK_REG);
/* Schedule the appropriate tasklet for deferred processing */
if (status & RX_DONE_BIT)
tasklet_schedule(&nic->rx_tasklet);
if (status & TX_DONE_BIT)
tasklet_schedule(&nic->tx_tasklet);
return IRQ_HANDLED;
}
/*
* Device open — called when the network interface is brought up.
* Sets up tasklets and registers the IRQ handler.
*/
static int my_open(struct net_device *netdev)
{
struct my_nic *nic = netdev_priv(netdev);
int err;
/* Initialize tasklets — must be done BEFORE requesting the IRQ,
* because the IRQ handler may schedule them immediately */
tasklet_setup(&nic->rx_tasklet, my_rx_tasklet);
tasklet_setup(&nic->tx_tasklet, my_tx_tasklet);
/* Register hardirq handler */
err = request_irq(nic->pdev->irq, my_irq_handler,
IRQF_SHARED, netdev->name, netdev);
if (err) {
dev_err(&nic->pdev->dev, "Failed to request IRQ %d\n",
nic->pdev->irq);
return err;
}
netif_start_queue(netdev);
return 0;
}
/*
* Device close — called when the network interface is brought down.
* Must kill tasklets AFTER freeing the IRQ to prevent use-after-free.
*/
static int my_close(struct net_device *netdev)
{
struct my_nic *nic = netdev_priv(netdev);
netif_stop_queue(netdev);
/* Free the IRQ first — no more hardirq handler calls,
* so no new tasklet_schedule() calls */
free_irq(nic->pdev->irq, netdev);
/* Now kill tasklets — waits for any running handler to finish,
* then prevents future scheduling. tasklet_kill() may sleep. */
tasklet_kill(&nic->rx_tasklet);
tasklet_kill(&nic->tx_tasklet);
return 0;
}
Old func + data API — Complete Working Example
The same driver using the old API for comparison:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/*
* Old-style tasklet handler.
* Receives an unsigned long — must cast it back to a pointer.
* NO compile-time type checking on the cast.
*/
static void my_rx_tasklet_old(unsigned long data)
{
struct my_nic *nic = (struct my_nic *)data;
/* ^^^ UNSAFE CAST: if 'data' is wrong, silent corruption.
* The compiler cannot help you here. */
spin_lock(&nic->lock);
process_received_packets(nic);
spin_unlock(&nic->lock);
if (hw_has_pending_rx(nic))
tasklet_schedule(&nic->rx_tasklet);
}
static void my_tx_tasklet_old(unsigned long data)
{
struct my_nic *nic = (struct my_nic *)data;
spin_lock(&nic->lock);
reclaim_tx_buffers(nic);
spin_unlock(&nic->lock);
if (netif_queue_stopped(nic->netdev))
netif_wake_queue(nic->netdev);
}
static int my_open_old(struct net_device *netdev)
{
struct my_nic *nic = netdev_priv(netdev);
int err;
/* Old-style init: pass function pointer AND data as unsigned long */
tasklet_init(&nic->rx_tasklet, my_rx_tasklet_old, (unsigned long)nic);
tasklet_init(&nic->tx_tasklet, my_tx_tasklet_old, (unsigned long)nic);
/* ^^^ (unsigned long)nic — type information erased here */
err = request_irq(nic->pdev->irq, my_irq_handler,
IRQF_SHARED, netdev->name, netdev);
if (err)
return err;
netif_start_queue(netdev);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌─────────────────────────────────────────────────────────────────────────────────┐
│ NEW API vs OLD API — SIDE BY SIDE │
├─────────────────────────────────────┬───────────────────────────────────────────┤
│ NEW (callback-based) │ OLD (func + data) │
├─────────────────────────────────────┼───────────────────────────────────────────┤
│ tasklet_setup(&nic->rx_tasklet, │ tasklet_init(&nic->rx_tasklet, │
│ my_rx_tasklet); │ my_rx_tasklet_old, │
│ │ (unsigned long)nic); │
│ /* no data parameter needed */ │ /* type info ERASED in cast */ │
├─────────────────────────────────────┼───────────────────────────────────────────┤
│ void my_rx_tasklet( │ void my_rx_tasklet_old( │
│ struct tasklet_struct *t) { │ unsigned long data) { │
│ struct my_nic *nic = │ struct my_nic *nic = │
│ from_tasklet(nic, t, │ (struct my_nic *)data; │
│ rx_tasklet); │ /* UNSAFE cast — no type check */ │
│ /* TYPE-SAFE container_of */ │ } │
│ } │ │
├─────────────────────────────────────┼───────────────────────────────────────────┤
│ Compile error if rx_tasklet is │ Compiles even if data is wrong — │
│ not a tasklet_struct field in │ silent corruption at runtime │
│ struct my_nic │ │
├─────────────────────────────────────┼───────────────────────────────────────────┤
│ use_callback = true │ use_callback = false │
│ Internally: t->callback(t) │ Internally: t->func(t->data) │
└─────────────────────────────────────┴───────────────────────────────────────────┘
End-to-End Flow: From Driver Registration to Tasklet Execution
This diagram traces the complete lifecycle using the JME network driver (drivers/net/ethernet/jme.c) as a real-world example. The JME adapter struct embeds four tasklets for TX cleanup, RX cleanup, RX empty handling, and PCC (Packet Completion Coalescing) at drivers/net/ethernet/jme.h, lines 409–413.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
End-to-End Flow: JME Network Driver — From Probe to Tasklet Execution
══════════════════════════════════════════════════════════════════════
┌─────────────────────────────────────────────────────────────────────────────────┐
│ PHASE 1: KERNEL BOOT — softirq_init() │
│ ────────────────────────────────────── │
│ │
│ start_kernel() │
│ └── softirq_init() [kernel/softirq.c:1048] │
│ ├── Initialize per-CPU tasklet_vec and tasklet_hi_vec lists │
│ ├── open_softirq(TASKLET_SOFTIRQ, tasklet_action) │
│ └── open_softirq(HI_SOFTIRQ, tasklet_hi_action) │
│ │
│ Result: softirq_vec[0].action = tasklet_hi_action │
│ softirq_vec[6].action = tasklet_action │
│ Per-CPU lists: all empty (head=NULL, tail=&head) │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ PHASE 2: PCI PROBE — jme_init_one() (module load / PCI enumeration) │
│ ──────────────────────────────────────────────────────────────────── │
│ │
│ jme_init_one() [drivers/net/ethernet/jme.c:3021] │
│ ├── alloc_etherdev(sizeof(struct jme_adapter)) │
│ │ └── jme_adapter allocated with embedded tasklet_structs │
│ │ │
│ ├── tasklet_setup(&jme->pcc_task, jme_pcc_tasklet) │
│ │ ├── jme->pcc_task.state = 0 │
│ │ ├── jme->pcc_task.count = 0 (enabled) │
│ │ ├── jme->pcc_task.callback = jme_pcc_tasklet │
│ │ └── jme->pcc_task.use_callback = true │
│ │ │
│ └── register_netdev(netdev) │
│ │
│ At this point: pcc_task is initialized but NOT scheduled. │
│ No IRQ handler registered yet. Tasklets are dormant. │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ PHASE 3: INTERFACE UP — jme_open() (ifconfig eth0 up) │
│ ───────────────────────────────────────────────────── │
│ │
│ jme_open() [drivers/net/ethernet/jme.c:1818] │
│ ├── tasklet_setup(&jme->txclean_task, jme_tx_clean_tasklet) │
│ ├── tasklet_setup(&jme->rxclean_task, jme_rx_clean_tasklet) │
│ ├── tasklet_setup(&jme->rxempty_task, jme_rx_empty_tasklet) │
│ │ │
│ ├── jme_request_irq(jme) [jme.c:1611] │
│ │ ├── pci_enable_msi(jme->pdev) │
│ │ └── request_irq(jme->pdev->irq, jme_msi, 0, ...) │
│ │ │ │
│ │ └── Kernel registers jme_msi() as hardirq handler │
│ │ for this IRQ line. GIC configured to deliver │
│ │ this interrupt to the target CPU. │
│ │ │
│ └── jme_start_irq(jme) │
│ └── Enable interrupts at the hardware level │
│ │
│ At this point: 4 tasklets initialized, IRQ handler registered. │
│ System is ready to receive and process interrupts. │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ PHASE 4: PACKET ARRIVES — Hardware Interrupt Fires │
│ ────────────────────────────────────────────────── │
│ │
│ NIC hardware receives a packet │
│ │ │
│ ▼ │
│ NIC asserts MSI interrupt → GIC delivers to CPU 2 │
│ │ │
│ ▼ │
│ CPU 2 takes exception to EL1 │
│ ├── PSTATE.I = 1 (IRQs disabled) │
│ ├── preempt_count += HARDIRQ_OFFSET │
│ └── → gic_handle_irq() → generic_handle_irq() → jme_msi() │
│ │
│ jme_msi(irq, dev_id) [jme.c:1578] │
│ ├── intrstat = jread32(jme, JME_IEVE) ← read interrupt status │
│ └── jme_intr_msi(jme, intrstat) [jme.c:1492] │
│ │ │
│ ├── jwrite32f(jme, JME_IENC, INTR_ENABLE) ← disable device IRQs │
│ │ │
│ ├── if (intrstat & INTR_TMINTR) │
│ │ └── tasklet_schedule(&jme->pcc_task) │
│ │ │
│ ├── if (intrstat & (INTR_PCCTXTO | INTR_PCCTX)) │
│ │ └── tasklet_schedule(&jme->txclean_task) │
│ │ │
│ ├── if (intrstat & INTR_RX0EMP) │
│ │ └── tasklet_hi_schedule(&jme->rxempty_task) ← HIGH PRIORITY │
│ │ │
│ ├── if (intrstat & (INTR_PCCRX0TO | INTR_PCCRX0)) │
│ │ └── tasklet_hi_schedule(&jme->rxclean_task) ← HIGH PRIORITY │
│ │ │
│ └── jwrite32f(jme, JME_IENS, INTR_ENABLE) ← re-enable device IRQs │
│ │
│ return IRQ_HANDLED │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ PHASE 5: TASKLET SCHEDULING — Inside tasklet_schedule() │
│ ─────────────────────────────────────────────────────── │
│ │
│ For each tasklet_schedule(&jme->txclean_task): │
│ │
│ tasklet_schedule() [interrupt.h:758] │
│ └── test_and_set_bit(TASKLET_STATE_SCHED, &t->state) │
│ └── SCHED bit was 0 → set to 1 → __tasklet_schedule(t) │
│ └── __tasklet_schedule_common(t, &tasklet_vec, TASKLET_SOFTIRQ) │
│ ├── local_irq_save(flags) │
│ ├── head = this_cpu_ptr(&tasklet_vec) ← CPU 2's list │
│ ├── Append t to the tail of CPU 2's list │
│ ├── raise_softirq_irqoff(TASKLET_SOFTIRQ) │
│ │ └── or_softirq_pending(1UL << 6) ← set bit 6 │
│ └── local_irq_restore(flags) │
│ │
│ For each tasklet_hi_schedule(&jme->rxclean_task): │
│ Same flow but uses tasklet_hi_vec and HI_SOFTIRQ (bit 0) │
│ │
│ CPU 2's state after hardirq handler returns: │
│ __softirq_pending = 0b ... 0100 0001 (bit 0: HI, bit 6: TASKLET) │
│ tasklet_hi_vec: rxclean_task → rxempty_task → NULL │
│ tasklet_vec: txclean_task → NULL │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ PHASE 6: IRQ EXIT — irq_exit() Triggers Softirq Processing │
│ ───────────────────────────────────────────────────────── │
│ │
│ __irq_exit_rcu() [kernel/softirq.c:720] │
│ ├── preempt_count -= HARDIRQ_OFFSET │
│ ├── !in_interrupt() → true (no longer in hardirq) │
│ ├── local_softirq_pending() → 0x41 (bits 0 and 6 set) │
│ └── invoke_softirq() │
│ └── do_softirq_own_stack() │
│ └── call_on_irq_stack(NULL, ____do_softirq) │
│ └── __do_softirq() → handle_softirqs(false) │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ PHASE 7: SOFTIRQ PROCESSING — handle_softirqs() on CPU 2 │
│ ───────────────────────────────────────────────────────── │
│ │
│ handle_softirqs(false) [kernel/softirq.c:579] │
│ ├── pending = 0x41 │
│ ├── softirq_handle_begin() ← preempt_count += SOFTIRQ_OFFSET │
│ ├── set_softirq_pending(0) ← clear bitmask │
│ ├── local_irq_enable() ← IRQs RE-ENABLED │
│ │ │
│ ├── ffs(0x41) = 1 → vec_nr = 0 → HI_SOFTIRQ │
│ │ └── tasklet_hi_action() [kernel/softirq.c:969] │
│ │ └── tasklet_action_common(tasklet_hi_vec, HI_SOFTIRQ) │
│ │ ├── Detach list: rxclean_task → rxempty_task │
│ │ │ │
│ │ ├── tasklet_trylock(rxclean_task) → RUN=1 │
│ │ ├── count == 0 → enabled │
│ │ ├── tasklet_clear_sched() → SCHED=0 │
│ │ ├── rxclean_task.callback(t) │
│ │ │ └── jme_rx_clean_tasklet() runs │
│ │ │ └── jme_process_receive(jme, ...) │
│ │ ├── tasklet_unlock(rxclean_task) → RUN=0 │
│ │ │ │
│ │ ├── tasklet_trylock(rxempty_task) → RUN=1 │
│ │ ├── ...same flow... │
│ │ └── tasklet_unlock(rxempty_task) → RUN=0 │
│ │ │
│ ├── ffs(remaining) → vec_nr = 6 → TASKLET_SOFTIRQ │
│ │ └── tasklet_action() [kernel/softirq.c:963] │
│ │ └── tasklet_action_common(tasklet_vec, TASKLET_SOFTIRQ) │
│ │ ├── Detach list: txclean_task │
│ │ ├── tasklet_trylock(txclean_task) → RUN=1 │
│ │ ├── tasklet_clear_sched() → SCHED=0 │
│ │ ├── txclean_task.callback(t) │
│ │ │ └── jme_tx_clean_tasklet() runs │
│ │ │ └── reclaim TX buffers, wake queue │
│ │ └── tasklet_unlock(txclean_task) → RUN=0 │
│ │ │
│ ├── local_irq_disable() │
│ ├── Check for new pending softirqs (any re-raised during handlers) │
│ └── softirq_handle_end() ← preempt_count -= SOFTIRQ_OFFSET │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ PHASE 8: INTERFACE DOWN — jme_close() (ifconfig eth0 down) │
│ ────────────────────────────────────────────────────────── │
│ │
│ jme_close() [jme.c:1901] │
│ ├── netif_stop_queue(netdev) │
│ ├── jme_stop_irq(jme) ← disable device interrupts │
│ ├── jme_free_irq(jme) ← free_irq() — no more hardirq calls │
│ ├── tasklet_kill(&jme->txclean_task) ← wait for completion, prevent resched │
│ ├── tasklet_kill(&jme->rxclean_task) │
│ └── tasklet_kill(&jme->rxempty_task) │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
Re-Queuing a Tasklet: Which CPU Gets It?
When tasklet_action_common() cannot execute a tasklet (either because tasklet_trylock() failed or because count > 0), it re-queues the tasklet on the CURRENT CPU’s list — the CPU that is currently running tasklet_action_common(), not the CPU where the tasklet might be running.
The re-queuing code at kernel/softirq.c, lines 952–958:
1
2
3
4
5
6
local_irq_disable();
t->next = NULL;
*tl_head->tail = t; /* tl_head is the LOCAL CPU's list */
tl_head->tail = &t->next;
__raise_softirq_irqoff(softirq_nr);
local_irq_enable();
The tl_head parameter was obtained via this_cpu_ptr() at the call site (kernel/softirq.c, line 966):
1
tasklet_action_common(this_cpu_ptr(&tasklet_vec), TASKLET_SOFTIRQ);
So tl_head always refers to the local CPU’s per-CPU list.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Re-Queuing Scenario: Tasklet Running on CPU 0, Re-queued on CPU 2
═════════════════════════════════════════════════════════════════
Initial state: tasklet T is on CPU 2's list (scheduled from CPU 2's hardirq)
tasklet T is also running on CPU 0 (from a previous schedule)
CPU 0 CPU 2
───── ─────
tasklet_action_common(): tasklet_action_common():
T->callback(T) running │
(RUN bit = 1) ▼
tasklet_trylock(T)
test_and_set_bit(RUN, &T->state)
→ RUN bit is 1 (CPU 0 holds it)
→ returns 0 (FAIL)
│
▼
RE-QUEUE on CPU 2's list:
local_irq_disable();
*tl_head->tail = T; ← CPU 2's list
tl_head->tail = &T->next;
__raise_softirq_irqoff(TASKLET_SOFTIRQ);
local_irq_enable();
│
▼
CPU 2's TASKLET_SOFTIRQ re-raised
T will be retried on CPU 2's next
softirq pass
Later: CPU 0 finishes T->callback()
tasklet_unlock(T) → clears RUN bit
Even later: CPU 2's next softirq pass
tasklet_trylock(T) → succeeds (RUN bit now 0)
T->callback(T) runs on CPU 2
The consequence: a tasklet can “migrate” between CPUs if it is re-queued. It was originally scheduled on the CPU where tasklet_schedule() was called. If that CPU’s processing finds it locked (running on another CPU), the tasklet stays on the current CPU’s list and will be retried there — potentially executing on a different CPU than where it was originally scheduled.
Is TASKLET_STATE_RUN a Global State?
Yes — TASKLET_STATE_RUN is a global state, not per-CPU. It is a bit in the state field of the tasklet_struct instance itself, and each tasklet_struct has exactly one state field at one memory address, shared across all CPUs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
TASKLET_STATE_RUN — One Bit, One Memory Address, All CPUs
═════════════════════════════════════════════════════════
struct jme_adapter (allocated once, lives in kernel heap):
┌────────────────────────────────────────────────────────────────────┐
│ │
│ rxclean_task (struct tasklet_struct): │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ next: 0x0000000000000000 │ │
│ │ state: 0x0000000000000002 ← address: 0xFFFF0000A108│ │
│ │ bit 1 (RUN) = 1 │ │
│ │ bit 0 (SCHED) = 0 │ │
│ │ count: 0 │ │
│ │ use_callback: true │ │
│ │ callback: jme_rx_clean_tasklet │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
The state field at address 0xFFFF0000A108 is visible to ALL CPUs.
There is NO per-CPU copy of this field.
When CPU 0 does:
test_and_set_bit(TASKLET_STATE_RUN, &t->state)
→ LDXR x0, [0xFFFF0000A108] ← Load-Exclusive from this address
→ ORR x0, x0, #2 ← Set bit 1
→ STXR w1, x0, [0xFFFF0000A108] ← Store-Exclusive to this address
→ If w1 == 0: success (we own the bit)
→ If w1 != 0: retry (another CPU modified it between LDXR and STXR)
When CPU 2 does the same:
test_and_set_bit(TASKLET_STATE_RUN, &t->state)
→ LDXR x0, [0xFFFF0000A108] ← SAME address
→ Sees bit 1 already set (CPU 0 set it)
→ Returns 1 (bit was already set → trylock FAILED)
The AArch64 exclusive monitor ensures that LDXR/STXR pairs are
atomic across CPUs. If two CPUs issue LDXR on the same address,
only one will succeed with STXR — the other's store will fail
and it will see the bit already set.
This is fundamentally different from the per-CPU __softirq_pending bitmask, which is per-CPU and requires no cross-CPU synchronization. The state field is intentionally global because its purpose is cross-CPU synchronization — preventing the same tasklet from running on two CPUs at the same time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────────────────────────────┐
│ PER-CPU vs GLOBAL STATE IN THE TASKLET SYSTEM │
├─────────────────────────────┬───────────────────────────────────────────────────┤
│ Per-CPU (no cross-CPU │ Global (shared across all CPUs, │
│ synchronization needed) │ requires atomic operations) │
├─────────────────────────────┼───────────────────────────────────────────────────┤
│ __softirq_pending bitmask │ tasklet_struct.state (SCHED and RUN bits) │
│ tasklet_vec linked list │ tasklet_struct.count (disable counter) │
│ tasklet_hi_vec linked list │ tasklet_struct.next (only modified under │
│ │ local_irq_disable, but the tasklet_struct │
│ │ itself is global) │
├─────────────────────────────┼───────────────────────────────────────────────────┤
│ Accessed via: │ Accessed via: │
│ __this_cpu_read/write/or │ test_and_set_bit (LDXR/STXR on AArch64) │
│ this_cpu_ptr │ atomic_read/atomic_inc/atomic_dec │
│ (no atomics, no barriers) │ clear_and_wake_up_bit │
│ │ (hardware-enforced atomicity) │
└─────────────────────────────┴───────────────────────────────────────────────────┘
The TASKLET_STATE_SCHED bit is also global — tasklet_schedule() from any CPU tests and sets this bit on the same instance. The atomicity of test_and_set_bit() guarantees that if two CPUs call tasklet_schedule() simultaneously on the same tasklet, only one will succeed in adding it to a per-CPU list. The other will see the bit already set and return without adding the tasklet — preventing double-linking.
Summary
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
┌─────────────────────────────────────────────────────────────────────────────┐
│ What is a tasklet? │
│ │
│ A dynamically allocatable bottom-half mechanism built on top of softirqs. │
│ Runs in softirq context (cannot sleep, IRQs enabled). │
│ Per-instance serialization — never runs on two CPUs simultaneously. │
│ Built on HI_SOFTIRQ (index 0) and TASKLET_SOFTIRQ (index 6). │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ struct tasklet_struct — key fields │
│ │
│ state: TASKLET_STATE_SCHED (bit 0) — prevents double-scheduling │
│ TASKLET_STATE_RUN (bit 1) — prevents concurrent execution (SMP) │
│ count: 0 = enabled, >0 = disabled (reference-counted) │
│ callback / func: handler function (new API vs old API) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Lifecycle │
│ │
│ Declare: DECLARE_TASKLET(name, callback) or tasklet_setup(&t, fn) │
│ Schedule: tasklet_schedule(&t) → appends to per-CPU list, raises softirq│
│ Execute: tasklet_action_common() walks list, trylock, run, unlock │
│ Disable: tasklet_disable(&t) → increments count, waits for completion │
│ Kill: tasklet_kill(&t) → waits for handler, prevents reschedule │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Execution context │
│ │
│ Context: softirq (SOFTIRQ_OFFSET in preempt_count) │
│ IRQs: ENABLED (hardirq can preempt) │
│ Sleeping: FORBIDDEN (schedule() will BUG) │
│ in_hardirq(): false │
│ in_softirq(): true │
│ in_task(): false │
│ current: borrowed (interrupted task or ksoftirqd) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Concurrency model │
│ │
│ Same softirq handler CAN run on multiple CPUs simultaneously. │
│ Same tasklet instance CANNOT — TASKLET_STATE_RUN enforces exclusion. │
│ Different tasklet instances CAN run in parallel (different state fields). │
│ TASKLET_STATE_RUN is per-instance (global), not per-CPU. │
│ Atomicity via LDXR/STXR (AArch64) on the shared state field. │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Priority │
│ │
│ tasklet_schedule() → TASKLET_SOFTIRQ (bit 6) — normal priority │
│ tasklet_hi_schedule() → HI_SOFTIRQ (bit 0) — runs before all softirqs │
│ Use tasklet_hi_schedule only for sub-microsecond deadlines. │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Softirqs vs Tasklets │
│ │
│ Softirqs: static (10 vectors), fully parallel, requires per-CPU locking │
│ Tasklets: dynamic (unlimited), serialized per-instance, no self-locking │
│ Both run in softirq context — cannot sleep, IRQs enabled. │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Deprecation │
│ │
│ Status: DEPRECATED — new code should use threaded IRQs or workqueues │
│ Why: PREEMPT_RT incompatible, type-unsafe (old API), poor model │
│ New API: tasklet_setup() + from_tasklet() — type-safe, CFI-compatible │
│ Old API: tasklet_init() + (unsigned long) cast — no type checking │
│ Migration: Phase 1 (callback API) done → Phase 2 (remove tasklets) WIP │
│ Key commit: 12cc923f1ccc — "tasklet: Introduce new initialization API" │
│ Replacement: request_threaded_irq() for new drivers │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Key source files (v7.2-rc5) │
│ │
│ include/linux/interrupt.h — tasklet_struct, state enum, schedule/disable │
│ kernel/softirq.c — tasklet_action_common, setup, init, kill │
│ include/linux/preempt.h — preempt_count layout, context macros │
│ arch/arm64/kernel/irq.c — do_softirq_own_stack (AArch64 stack switch) │
└─────────────────────────────────────────────────────────────────────────────┘