Interrupt Bottom Halves: Softirqs, Tasklets, and Work Queues
Why This Matters
Every interrupt handler faces a fundamental tension: it must finish quickly (because it blocks the interrupted code and often disables other interrupts), yet the work triggered by hardware events is sometimes substantial—copying a network packet, updating block-device bookkeeping, etc. If you do everything in the handler, latency suffers and the system feels sluggish. If you do nothing, hardware events go unprocessed. Linux resolves this with deferred execution: split interrupt work into a fast top-half and a slower bottom-half that runs when things are calmer.
Top-Half vs. Bottom-Half
| Top-Half (ISR) | Bottom-Half | |
|---|---|---|
| When it runs | Immediately on interrupt | Later, when convenient |
| Interrupts | Often disabled | Always enabled |
| Blocking | Never | Depends on mechanism |
| Goal | Acknowledge hardware, save minimal state | Finish the real work |
The top-half does only what is time-critical: acknowledge the interrupt signal, reset the hardware register, and mark that bottom-half work is needed. Everything else is deferred.
A Brief History of Bottom Halves in Linux
Linux's bottom-half story reflects successive attempts to balance simplicity, performance, and concurrency:
- Old BH mechanism — a statically created list of up to 32 bottom halves, serialized globally. Simple but did not scale.
- Task queues — queues of function pointers. More flexible, but still too heavyweight for high-throughput paths like networking.
- Softirqs + Tasklets (replaced BH): low-overhead, can run on multiple CPUs.
- Work queues (replaced task queues): run in process context, so they can sleep.
Today Linux uses three mechanisms, each occupying a different point in the design space.
Today's Three Bottom-Half Mechanisms
Interrupt context? Can sleep? Same instance concurrent?
Softirq Yes No Yes (multi-CPU)
Tasklet Yes No No (serialized)
Work queue No (process ctx) Yes Yes
All three run with interrupts enabled on the local CPU.
Softirqs
How They Work
A top-half raises a softirq by setting a bit in the per-CPU pending bitmask. The kernel checks that bitmask and dispatches pending softirq handlers at three points:
- On the return path from a hardware interrupt.
- In the
ksoftirqdper-CPU kernel thread (activated when softirq load is excessive). - In any kernel code that explicitly calls
do_softirq().
Softirq Types
Linux pre-defines a small, fixed set of softirq types, ordered by priority (lower index = higher priority):
enum {
HI_SOFTIRQ=0, /* high-priority tasklets */
TIMER_SOFTIRQ, /* kernel timers */
NET_TX_SOFTIRQ, /* transmit network packets */
NET_RX_SOFTIRQ, /* receive network packets */
BLOCK_SOFTIRQ, /* block device I/O completion */
IRQ_POLL_SOFTIRQ, /* block device interrupt polling */
TASKLET_SOFTIRQ, /* normal-priority tasklets */
SCHED_SOFTIRQ, /* scheduler load-balancing */
HRTIMER_SOFTIRQ, /* (unused, kept for ABI) */
RCU_SOFTIRQ, /* RCU callbacks */
NR_SOFTIRQS
};
Adding a new softirq type directly is discouraged; use a tasklet instead.
Key Softirq Property: Scalability
The defining advantage of softirqs over tasklets is that the same softirq handler can run simultaneously on multiple CPUs. This maximizes throughput for subsystems like networking, but requires proper locking for any shared data. Most softirq handlers avoid locks entirely by using per-CPU data — each CPU operates on its own copy, so no synchronization is needed.
Softirq Handler Requirements
- Runs with interrupts enabled.
- Cannot sleep or block.
- Must protect shared data with spinlocks (or use per-CPU data).
- Registered once at compile/boot time via
open_softirq().
Tasklets
Tasklets are built on top of softirqs (they use HI_SOFTIRQ and TASKLET_SOFTIRQ), but they add an important serialization guarantee: the same tasklet instance never runs concurrently, even on a multi-CPU system. This makes tasklets much easier to use than raw softirqs.
The tasklet_struct
struct tasklet_struct {
struct tasklet_struct *next; /* next in per-CPU list */
unsigned long state; /* SCHED or RUNNING */
atomic_t count; /* disable counter; 0 = enabled */
void (*func)(unsigned long); /* handler */
unsigned long data; /* argument to handler */
};
Scheduled tasklets live in two per-CPU linked lists: tasklet_vec (normal priority) and tasklet_hi_vec (high priority). When the tasklet softirq fires, the kernel walks the list and runs each enabled, non-running tasklet.
Declaring and Initializing a Tasklet
/* Static declaration, enabled immediately */
DECLARE_TASKLET(my_tasklet, my_handler);
/* Static declaration, disabled until tasklet_enable() is called */
DECLARE_TASKLET_DISABLED(my_tasklet, my_handler);
/* Dynamic initialization */
tasklet_init(&my_tasklet, my_handler, data);
Scheduling, Disabling, Enabling
tasklet_schedule(&my_tasklet); /* queue at normal priority */
tasklet_hi_schedule(&my_tasklet); /* queue at high priority */
tasklet_disable(&my_tasklet); /* increment disable counter; waits if running */
tasklet_enable(&my_tasklet); /* decrement disable counter */
tasklet_disable() blocks until any currently-executing instance finishes, making it safe to call before freeing resources the handler uses.
Tasklet Handler Requirements
- Runs with interrupts enabled, cannot sleep.
- Two different tasklets can run concurrently on two CPUs.
- Two instances of the same tasklet are serialized.
- If data is shared with a top-half interrupt handler, you must disable interrupts or use a lock inside the tasklet handler.
ksoftirqd
If softirq (and thus tasklet) load becomes excessive—more softirqs keep being raised faster than they are drained—the kernel would either starve user space (by looping in interrupt context) or miss processing. The solution is ksoftirqd: a per-CPU kernel thread (nice value 0, normal priority) that drains the softirq backlog. This lets user-space processes continue to run via the normal scheduler, preventing starvation.
Work Queues
Work queues take a different approach: they defer work to a kernel thread running in process context. Because they are ordinary schedulable tasks, work queue handlers can sleep, acquire mutexes, call blocking I/O, etc.
Key Data Structures
worker_pool— manages a pool of worker threads, a spinlock, and a list of pendingwork_structobjects.work_struct— the unit of work: a function pointer (func) and a list entry.
Creating Work
/* Static */
DECLARE_WORK(my_work, my_work_handler);
/* Dynamic */
INIT_WORK(&my_work, my_work_handler);
/* Handler prototype */
typedef void (*work_func_t)(struct work_struct *work);
Scheduling Work
schedule_work(&my_work); /* submit to default queue (kworker/n) */
schedule_work_on(cpu, &my_work); /* submit to a specific CPU's queue */
queue_work(my_wq, &my_work); /* submit to a custom workqueue */
queue_work_on(cpu, my_wq, &my_work);
Flushing and Canceling
flush_work(&my_work); /* wait for this work to finish */
flush_workqueue(my_wq); /* drain a specific workqueue */
flush_scheduled_work(); /* drain the default kworker queue */
work_pending(&my_work); /* check if work is still queued */
Custom Work Queues
struct workqueue_struct *my_wq = create_workqueue("my_wq");
/* ... use queue_work(my_wq, ...) ... */
destroy_workqueue(my_wq);
Create a custom workqueue when you need isolated concurrency controls or don't want to share the default kworker threads with the rest of the kernel.
Choosing the Right Mechanism
| Use case | Mechanism |
|---|---|
| Performance-critical, no blocking, multi-CPU scalability | Softirq |
| Simple deferred work, no blocking, easy concurrency model | Tasklet |
| Work that might sleep, block on I/O, or hold a mutex | Work queue |
All three run with interrupts enabled. If a bottom-half handler shares data with a top-half interrupt handler, you must protect that data by disabling interrupts (local_irq_disable()) or using a spinlock with spin_lock_irqsave().
Disabling Bottom Halves Locally
local_bh_disable(); /* disable softirq and tasklet processing on this CPU */
/* ... critical section ... */
local_bh_enable(); /* re-enable (only the final call in nested disables takes effect) */
Note: these calls do not affect work queue processing.
Key Takeaways
- Interrupt work is split into a fast top-half and a deferred bottom-half to balance latency and throughput.
- Softirqs run in interrupt context, can execute the same handler on multiple CPUs simultaneously, and require careful locking. They are the building block for tasklets.
- Tasklets add serialization (same tasklet never concurrent), making them easier to use than raw softirqs, at the cost of some scalability.
- Work queues run in process context and are the only mechanism that can sleep or block.
- ksoftirqd prevents softirq overload from starving user-space processes.
- All bottom-half handlers run with interrupts enabled; use
local_bh_disable()/local_bh_enable()to protect critical sections from other bottom halves on the same CPU.