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:

  1. Old BH mechanism — a statically created list of up to 32 bottom halves, serialized globally. Simple but did not scale.
  2. Task queues — queues of function pointers. More flexible, but still too heavyweight for high-throughput paths like networking.
  3. Softirqs + Tasklets (replaced BH): low-overhead, can run on multiple CPUs.
  4. 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:

  1. On the return path from a hardware interrupt.
  2. In the ksoftirqd per-CPU kernel thread (activated when softirq load is excessive).
  3. 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


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


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

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

Practice

  1. Why must a top-half interrupt handler (ISR) execute as quickly as possible?
  2. Which statement about softirqs in Linux is correct?
  3. A developer wants to defer interrupt work that requires calling msleep(). Which bottom-half mechanism should they use?
  4. On a 4-CPU machine, my_tasklet is currently executing on CPU 0. What happens if CPU 1 tries to schedule and run my_tasklet at the same moment?
  5. What is the role of ksoftirqd?
  6. A network driver's bottom-half handler shares a ring buffer with the top-half ISR. The driver uses a tasklet for the bottom half. What is the correct way to protect the ring buffer inside the tasklet handler?
  7. Explain the key difference between softirqs and tasklets in terms of concurrency, and describe the trade-off that makes each appropriate for different use cases.
  8. Describe the lifecycle of a work queue work item from creation to completion, naming the key functions involved.
  9. Which of the following correctly describes the effect of calling local_bh_disable() followed by local_bh_enable()?
  10. A senior kernel developer tells you: 'Do not add a new softirq type; use a tasklet instead.' Explain the reasoning behind this advice.