Threaded Interrupt Handlers

A threaded interrupt handler splits the work of servicing an interrupt into two pieces that run in two different execution contexts. A tiny primary handler runs in hard-interrupt (hard-IRQ) context — atomic, with the device line possibly masked, where sleeping is forbidden — does the absolute minimum, and returns the special code IRQ_WAKE_THREAD. The kernel then wakes a dedicated kernel thread (named irq/<N>-<name>) that runs the heavy thread_fn in ordinary process context, where it can sleep: take mutexes, allocate memory with GFP_KERNEL, and block on I/O. A driver asks for this split with request_threaded_irq(irq, handler, thread_fn, flags, name, dev) (per kernel/irq/manage.c, Linux v6.12). The payoff is a handler that is schedulable — it has its own task, its own priority, and can be preempted — which is why the real-time kernel (PREEMPT_RT, mainlined in 6.12) forces most interrupts to be threaded by default. This is the cleanest member of the bottom-half family because, unlike a workqueue item, the threaded handler is still part of the IRQ subsystem: it is masked/unmasked, accounted, and synchronized exactly like the hard handler it replaces.

Version pin

All kernel source in this note was read at the Linux v6.12 long-term-support (LTS) tag via raw.githubusercontent.com, and all line-level claims are against that tag. Mainline has since moved into the 7.x series; where a fact could plausibly have changed, it is dated. The empirical /proc/interrupts and ps readings later in the note come from a Fedora 44 machine running 7.1.8-200.fc44.x86_64 (32 CPUs, AMD), read on 2026-09-04 — a different kernel from the source pin, and that difference is called out where it matters.

Mental Model

The right way to think about a threaded IRQ is “a hard-IRQ handler that has been cut in half along the can-I-sleep line.” Everything that genuinely must happen in atomic context — reading a status register, acknowledging the device so it stops re-asserting the line — stays in the primary handler. Everything else — walking a long transfer, talking to a slow bus like I²C or SPI, allocating buffers — moves to the thread. The primary handler’s job shrinks to a decision: is this my interrupt, and if so, should I wake my thread? It answers by returning one of the [[Interrupt Context and Why It Cannot Sleep|irqreturn_t]] values.

sequenceDiagram
    participant DEV as Device
    participant HW as CPU / hard-IRQ
    participant PH as Primary handler<br/>(atomic, may mask line)
    participant KT as irq/N-name thread<br/>(process ctx, CAN sleep)
    DEV->>HW: asserts IRQ line
    HW->>PH: run primary handler
    Note over PH: read status, ACK device,<br/>NO sleeping
    PH-->>HW: return IRQ_WAKE_THREAD
    Note over HW: with IRQF_ONESHOT the line<br/>stays MASKED here
    HW->>KT: wake_up the kthread
    Note over KT: thread_fn runs:<br/>mutex_lock, GFP_KERNEL,<br/>block on slow bus — all legal
    KT-->>DEV: finish the work
    Note over KT: on completion the core<br/>UNMASKS the line (oneshot)

The two-context split of a threaded interrupt. What it shows: the primary handler executes in hard-IRQ context and merely acknowledges the device and returns IRQ_WAKE_THREAD; the core then wakes the per-IRQ kernel thread, which runs the real work where blocking is allowed; with IRQF_ONESHOT the interrupt line stays masked across the whole transaction and is re-enabled only when the thread finishes. The insight to take: IRQ_WAKE_THREAD is the hinge — it is the primary handler’s way of saying “the rest is too heavy for atomic context, finish it in my thread,” and the masking discipline is what makes that safe on a level-triggered line.

To see what is actually new here, it helps to draw the threaded handler against the classic bottom half it replaces. The classic top-half/bottom-half split defers work into a softirq, which is still atomic: it runs on the interrupt-return path (or in ksoftirqd), it cannot sleep, and it is not a task — you cannot see it in ps, cannot give it a priority, and cannot pin it independently of the CPU that took the interrupt. The threaded split defers into a task, and that single change is what buys sleepability, priority, and preemptibility.

sequenceDiagram
    autonumber
    participant DEV as Device
    participant TH as Hard-IRQ context<br/>(atomic, IRQs off)
    participant SI as Softirq / tasklet<br/>(atomic, IRQs ON,<br/>NOT a task)
    participant KT as irq/N-name kthread<br/>(process ctx, SCHED_FIFO 50)
    participant APP as A high-prio RT task

    rect rgb(245,238,238)
    Note over DEV,APP: CLASSIC top half / bottom half — the bottom half is still atomic
    DEV->>TH: IRQ asserted
    TH->>TH: read status, ACK
    TH->>SI: raise_softirq&#40;&#41; / tasklet_schedule&#40;&#41;
    TH-->>DEV: return IRQ_HANDLED
    SI->>SI: __do_softirq&#40;&#41; on the IRQ-return path<br/><b>cannot sleep, cannot be preempted<br/>by a task, has no priority</b>
    APP--x SI: RT task must WAIT — softirq<br/>outranks every scheduler decision
    end

    rect rgb(236,242,236)
    Note over DEV,APP: THREADED — the bottom half is a schedulable task
    DEV->>TH: IRQ asserted
    TH->>TH: read status, ACK
    TH-->>DEV: return <b>IRQ_WAKE_THREAD</b>
    TH->>KT: __irq_wake_thread&#40;&#41;:<br/>set IRQTF_RUNTHREAD,<br/>threads_oneshot |= thread_mask,<br/>threads_active++, wake_up_process&#40;&#41;
    Note over TH: hard-IRQ context ENDS here —<br/>line stays masked if IRQF_ONESHOT
    KT->>KT: thread_fn&#40;&#41; with IRQs ENABLED:<br/>mutex_lock, GFP_KERNEL, slow bus
    APP->>KT: RT task at prio > 50 <b>preempts the handler</b>
    KT->>KT: resumes, finishes
    KT-->>DEV: irq_finalize_oneshot&#40;&#41; unmasks the line
    end

The same interrupt serviced two ways. What it shows: in the classic path the deferred half is a softirq — atomic, unpreemptible by tasks, invisible to the scheduler, so a real-time task (bottom lane) simply waits for it; in the threaded path the deferred half is an ordinary SCHED_FIFO task, so a higher-priority task preempts it mid-handler and the interrupt line stays masked meanwhile. The insight to take: threading does not make the handler faster — steps 8–13 add a scheduler round-trip the softirq path does not have. It makes the handler schedulable, which converts an unbounded, priority-inverting stall into a bounded, priority-ordered one. That is the entire trade, and it is why the real-time kernel takes it and the throughput-oriented networking stack (see The Network Receive Path, which keeps its bottom half in NET_RX_SOFTIRQ) does not.

The contrast with a workqueue is worth holding in mind: a workqueue is general sleepable deferral, decoupled from any particular interrupt. A threaded IRQ is bound to the interrupt — the core knows that thread services this line, so it can mask the line until the thread is done (IRQF_ONESHOT), it can account desc->threads_active, and synchronize_irq() waits for the thread as well as the hard handler. You get sleepability and the IRQ subsystem’s bookkeeping.

Choosing the Registration Call

Before the mechanism, the decision. Driver authors face three practical shapes — request_irq(), request_threaded_irq() with a real primary handler, and request_threaded_irq() with handler == NULL — and the choice is almost entirely determined by two questions: does the deferred work need to sleep? and can I acknowledge the device from atomic context? The second question is the one people get wrong, because on a slow bus the answer is no: reading the interrupt-status register of an I²C-attached GPIO expander is itself a blocking transaction, so there is no way to stop the device asserting its line without sleeping first.

flowchart TD
    START(["Driver needs an<br/>interrupt handler"]) --> Q1{"Does any part of the work<br/>need to <b>sleep</b>?<br/><i>mutex, GFP_KERNEL,<br/>I&sup2;C/SPI transfer, wait_for_completion</i>"}

    Q1 -->|"No"| PLAIN["<b>request_irq&#40;irq, handler,<br/>flags, name, dev&#41;</b><br/>everything in hard-IRQ context"]
    PLAIN --> PLAINQ{"Is the atomic work<br/>still long-ish?"}
    PLAINQ -->|"Yes"| SOFT["Hard handler + a softirq/tasklet<br/>or napi_schedule&#40;&#41;<br/><i>see Top Halves and Bottom Halves</i>"]
    PLAINQ -->|"No"| DONE1(["Done — a plain hard handler"])

    Q1 -->|"Yes"| Q2{"Can the <b>primary</b> handler<br/>stop the device from<br/>re-asserting, in atomic context?<br/><i>i.e. an MMIO register write</i>"}

    Q2 -->|"Yes — MMIO ACK"| OWNPH["<b>request_threaded_irq&#40;irq,<br/>my_hardirq, my_thread, …&#41;</b><br/>primary returns IRQ_NONE or<br/>IRQ_WAKE_THREAD"]
    OWNPH --> Q3{"Shared line?<br/>&#40;IRQF_SHARED&#41;"}
    Q3 -->|"Yes"| SHARED["<b>Required:</b> primary must return<br/>IRQ_NONE when not ours.<br/>Add IRQF_ONESHOT if the line is<br/>level-triggered — each sharer gets<br/>its own thread_mask bit"]
    Q3 -->|"No"| ONESHOTQ{"Level-triggered?"}
    ONESHOTQ -->|"Yes"| ADDONE["Add <b>IRQF_ONESHOT</b><br/>&#40;belt and braces: the line stays<br/>masked until thread_fn returns&#41;"]
    ONESHOTQ -->|"No — edge"| DONE2(["Done — edge lines self-clear"])

    Q2 -->|"No — the ACK itself sleeps"| NULLPH["<b>request_threaded_irq&#40;irq,<br/>NULL, my_thread,<br/>flags | IRQF_ONESHOT, …&#41;</b><br/>core installs<br/>irq_default_primary_handler"]
    NULLPH --> HARD["<b>IRQF_ONESHOT is MANDATORY.</b><br/>__setup_irq&#40;&#41; returns -EINVAL and<br/>pr_err&#40;&#41;s without it, unless the<br/>chip is IRQCHIP_ONESHOT_SAFE"]
    HARD --> NOSHARE["<b>Cannot be IRQF_SHARED</b> in practice:<br/>the default primary handler always<br/>returns IRQ_WAKE_THREAD, so it can<br/>never say &quot;not mine&quot;"]

    style NULLPH fill:#eef4ff
    style HARD fill:#ffeeee
    style OWNPH fill:#eef4ff
    style PLAIN fill:#eef4ff

The registration decision tree, with the enforcement points marked. What it shows: the fork at the top is “must it sleep”; the fork in the middle is the one that actually decides whether you may omit the primary handler, and it turns on whether acknowledging the device is itself a sleeping operation. The insight to take: the red box is not advice, it is a kernel check — __setup_irq() in kernel/irq/manage.c (v6.12) hard-rejects handler == NULL without IRQF_ONESHOT on any chip that has not declared IRQCHIP_ONESHOT_SAFE. The convenient “just wake my thread” pattern and the mandatory IRQF_ONESHOT are a package deal, and the price of the package is that you give up shared lines.

The IRQF_* flags that matter to a threaded handler

The flag word is the main tuning surface. These are the definitions verbatim from include/linux/interrupt.h at v6.12, with the value and what it means specifically for a threaded handler:

FlagValueHeader descriptionRelevance to threading
IRQF_SHARED0x00000080“allow sharing the irq among several devices”Requires a real primary handler that can return IRQ_NONE; with IRQF_ONESHOT each sharer is allocated a distinct thread_mask bit
IRQF_PROBE_SHARED0x00000100“set by callers when they expect sharing mismatches to occur”Suppresses the mismatch complaint at registration
__IRQF_TIMER0x00000200“Flag to mark this interrupt as timer interrupt”See IRQF_TIMER below — never threaded
IRQF_PERCPU0x00000400“Interrupt is per cpu”Excluded from forced threading; a per-CPU line has no single thread to own it
IRQF_NOBALANCING0x00000800“Flag to exclude this interrupt from irq balancing”Pins the effective affinity, and therefore pins the thread
IRQF_IRQPOLL0x00001000“Interrupt is used for polling”Interacts with irqfixup/irqpoll spurious recovery
IRQF_ONESHOT0x00002000“Interrupt is not reenabled after the hardirq handler finished. Used by threaded interrupts which need to keep the irq line disabled until the threaded handler has been run.”The central flag of this note
IRQF_NO_SUSPEND0x00004000“Do not disable this IRQ during suspend”Orthogonal, but implied by IRQF_TIMER
IRQF_FORCE_RESUME0x00008000“Force enable it on resume even if IRQF_NO_SUSPEND is set”
IRQF_NO_THREAD0x00010000“Interrupt cannot be threaded”Opt-out from forced threading; the driver asserts it must stay in hard-IRQ context
IRQF_EARLY_RESUME0x00020000“Resume IRQ early during syscore instead of at device resume time”
IRQF_COND_SUSPEND0x00040000“If the IRQ is shared with a NO_SUSPEND user, execute this interrupt handler after suspending interrupts”
IRQF_NO_AUTOEN0x00080000“Don’t enable IRQ or NMI automatically when users request it”The driver calls enable_irq() when it is ready
IRQF_NO_DEBUG0x00100000“Exclude from runnaway detection for IPI and similar handlers, depends on IRQF_PERCPUSkips note_interrupt() accounting
IRQF_COND_ONESHOT0x00200000“Agree to do IRQF_ONESHOT if already set for a shared interrupt”Quietly added by every request_irq() call — see below

Two composites are worth spelling out because they explain behaviour that otherwise looks arbitrary:

#define IRQF_TIMER  (__IRQF_TIMER | IRQF_NO_SUSPEND | IRQF_NO_THREAD)

IRQF_TIMER is not a single bit — it is a three-bit composite that includes IRQF_NO_THREAD. So the frequently-repeated claim “the timer interrupt is never threaded” is not a special case buried in the scheduler; it falls straight out of the flag definition, and irq_setup_forced_threading() rejects the action on the ordinary IRQF_NO_THREAD test like any other opt-out. And:

static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
	    const char *name, void *dev)
{
	return request_threaded_irq(irq, handler, NULL,
	  flags | IRQF_COND_ONESHOT, name, dev);
}

Every plain request_irq() silently adds IRQF_COND_ONESHOT, meaning “I have no opinion about oneshot, but if someone else already made this shared line oneshot, I will go along with it.” Without that, a driver adding a non-oneshot handler to a line that a threaded driver had already made oneshot would fail the flag-agreement check in __setup_irq() and the second driver would simply not load.

Mechanical Walk-through

Registration. A driver calls request_threaded_irq(irq, handler, thread_fn, irqflags, devname, dev_id). Its documented contract (from the docblock in manage.c, v6.12) is precise about the two-function split:

  • handler — “Primary handler for threaded interrupts. If handler is NULL and thread_fn != NULL the default primary handler is installed.”
  • thread_fn — “Function called from the irq handler thread. If NULL, no irq thread is created.”
  • irqflags — interrupt type flags, including IRQF_SHARED, IRQF_TRIGGER_*, and IRQF_ONESHOT.
  • devname — an ASCII name for the claiming device.
  • dev_id — a cookie passed back to both functions (also the unshare key for shared lines).

The familiar request_irq() is just a wrapper that passes NULL for thread_fn, i.e. “no thread, run everything in hard-IRQ context” (per interrupt.h, v6.12):

static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
	    const char *name, void *dev)
{
	return request_threaded_irq(irq, handler, NULL,
	  flags | IRQF_COND_ONESHOT, name, dev);
}

So there is exactly one underlying registration path; “threaded vs not” is just whether thread_fn is non-NULL. The mechanics of allocation, the irqaction linked list, and shared-line handling are common to both and are covered in Requesting and Freeing IRQs and Shared Interrupt Lines — this note focuses only on what the thread adds.

The default primary handler. If a driver passes handler == NULL but a real thread_fn, it is saying “I have nothing to do in atomic context — just wake the thread on every interrupt.” The core installs a stub:

static irqreturn_t irq_default_primary_handler(int irq, void *dev_id)
{
	return IRQ_WAKE_THREAD;
}

This is the convenient case, but it is also the dangerous one, which is where IRQF_ONESHOT comes in.

Why IRQF_ONESHOT is mandatory for the handler == NULL level-triggered case. A level-triggered line stays asserted until the device is told to stop. A normal hard-IRQ handler de-asserts it inside the atomic handler. But with the default primary handler, the only thing that happens in atomic context is “wake the thread”; the device is not acknowledged until the thread eventually runs. On a level-triggered line the core would, by default, unmask the line right after the primary handler returns — but the device is still asserting it, so the CPU immediately re-enters the handler, which wakes the thread again, unmasks again, re-enters again. The manage.c comment is blunt about this:

“level interrupts this is deadly, because the default primary handler just wakes the thread, then the irq lines is reenabled, but the device still has the level irq asserted. Rinse and repeat…”

IRQF_ONESHOT is the fix: it means “do not re-enable this line after the hard handler finishes — keep it masked until the threaded handler completes.” The flag’s header definition states exactly this — “Interrupt is not reenabled after the hardirq handler finished” (interrupt.h, v6.12). The core enforces the requirement in __setup_irq(): a threaded handler with the default primary handler and no oneshot (on a chip that isn’t IRQCHIP_ONESHOT_SAFE) is rejected with -EINVAL:

} else if (new->handler == irq_default_primary_handler &&
           !(desc->irq_data.chip->flags & IRQCHIP_ONESHOT_SAFE)) {
	pr_err("Threaded irq requested with handler=NULL and !ONESHOT for %s (irq %d)\n",
	       new->name, irq);
	ret = -EINVAL;

If you do supply your own primary handler that acknowledges/masks the device itself, you don’t strictly need IRQF_ONESHOT — your handler has already stopped the line from re-asserting. IRQF_ONESHOT is the safety net for the lazy “wake the thread on everything” pattern, and it is what makes that pattern usable on shared and level-triggered lines.

Where the masking actually happens — the flow handler, not the driver. IRQF_ONESHOT is not implemented in request_threaded_irq(); it is implemented as a conditional in the per-chip flow handler. For a level-triggered line the flow handler is handle_level_irq() in kernel/irq/chip.c (v6.12), and it is short enough to read whole:

void handle_level_irq(struct irq_desc *desc)
{
	raw_spin_lock(&desc->lock);
	mask_ack_irq(desc);                 /* (1) mask FIRST, unconditionally */
	if (!irq_may_run(desc))
		goto out_unlock;
	desc->istate &= ~(IRQS_REPLAY | IRQS_WAITING);
	if (unlikely(!desc->action || irqd_irq_disabled(&desc->irq_data))) {
		desc->istate |= IRQS_PENDING;   /* stay masked, get out */
		goto out_unlock;
	}
	kstat_incr_irqs_this_cpu(desc);
	handle_irq_event(desc);             /* (2) runs the primary handler(s) */
	cond_unmask_irq(desc);              /* (3) unmask — MAYBE */
out_unlock:
	raw_spin_unlock(&desc->lock);
}

Step (1) is the important one and it surprises people: a level-triggered line is masked on every interrupt, threaded or not, because that is the only way to keep it from re-firing while the handler runs. The question is never “does the line get masked” but “when does it get unmasked”, and that is step (3):

static void cond_unmask_irq(struct irq_desc *desc)
{
	/*
	 * We need to unmask in the following cases:
	 * - Standard level irq (IRQF_ONESHOT is not set)
	 * - Oneshot irq which did not wake the thread (caused by a
	 *   spurious interrupt or a primary handler handling it
	 *   completely).
	 */
	if (!irqd_irq_disabled(&desc->irq_data) &&
	    irqd_irq_masked(&desc->irq_data) && !desc->threads_oneshot)
		unmask_irq(desc);
}

desc->threads_oneshot is the hinge. When the primary handler returned IRQ_WAKE_THREAD, __irq_wake_thread() set a bit in it before handle_irq_event() returned; cond_unmask_irq() therefore sees a non-zero mask and declines to unmask. The line stays masked all the way through the thread. When the thread finishes, irq_finalize_oneshot() clears that same bit and calls unmask_threaded_irq() — but only if every thread on the line is done (!desc->threads_oneshot), which is what makes the scheme correct on a shared oneshot line.

stateDiagram-v2
    direction TB

    UNMASKED: <b>UNMASKED & idle</b><br/>irqd_irq_masked&#40;&#41; == false<br/>desc-&gt;threads_oneshot == 0<br/><i>the device may assert at any time</i>

    MASKED_HARD: <b>MASKED — hard-IRQ running</b><br/>mask_ack_irq&#40;&#41; has run<br/>IRQD_IRQ_INPROGRESS set<br/><i>primary handler executing, atomic</i>

    MASKED_THREAD: <b>MASKED — thread running</b><br/>threads_oneshot |= action-&gt;thread_mask<br/>threads_active++<br/><i>thread_fn&#40;&#41; may sleep for milliseconds;<br/>the device is silenced the whole time</i>

    QUIESCING: <b>MASKED — finalizing</b><br/>irq_finalize_oneshot&#40;&#41; spinning on<br/>irqd_irq_inprogress&#40;&#41;<br/><i>a hard IRQ raced us on another CPU</i>

    [*] --> UNMASKED
    UNMASKED --> MASKED_HARD: device asserts;<br/>handle_level_irq&#40;&#41; runs<br/><b>mask_ack_irq&#40;&#41;</b>

    MASKED_HARD --> UNMASKED: primary returned IRQ_HANDLED<br/>&#40;or IRQ_NONE — spurious&#41;<br/><b>cond_unmask_irq&#40;&#41; sees threads_oneshot==0<br/>&rArr; unmask_irq&#40;&#41;</b>

    MASKED_HARD --> MASKED_THREAD: primary returned IRQ_WAKE_THREAD<br/>__irq_wake_thread&#40;&#41; sets the bit<br/><b>cond_unmask_irq&#40;&#41; sees threads_oneshot!=0<br/>&rArr; does NOT unmask</b>

    MASKED_THREAD --> QUIESCING: thread_fn&#40;&#41; returned;<br/>irq_finalize_oneshot&#40;&#41; takes chip_bus_lock

    QUIESCING --> QUIESCING: irqd_irq_inprogress&#40;&#41; true &rArr;<br/>drop locks, cpu_relax&#40;&#41;, retry<br/><i>never unmask under a racing hard IRQ</i>

    QUIESCING --> MASKED_THREAD: IRQTF_RUNTHREAD set again &rArr;<br/>the thread must run once more;<br/>leave the bit set

    QUIESCING --> UNMASKED: threads_oneshot &= ~thread_mask;<br/>if now zero and not disabled<br/><b>unmask_threaded_irq&#40;&#41;</b>

    note right of MASKED_THREAD
        Without IRQF_ONESHOT and with the
        default primary handler, the arrow
        from MASKED_HARD would go straight
        back to UNMASKED while the device
        is STILL asserting the level.
        The line re-fires instantly, wakes
        the thread again, unmasks again:
        an interrupt storm at line rate.
        This is why __setup_irq() rejects
        that combination with -EINVAL.
    end note

The interrupt line’s masked/unmasked state across the hardirq→thread handoff. What it shows: there are exactly two ways out of MASKED_HARD, and desc->threads_oneshot is the single variable that picks between them; the line is unmasked either by the flow handler (no thread woken) or by irq_finalize_oneshot() in the thread (thread woken), never by both. The insight to take: IRQF_ONESHOT does not add masking — level lines are always masked on entry. It removes an unmasking, extending the masked window from “end of the primary handler” to “end of the thread.” The QUIESCING self-loop is the reason irq_finalize_oneshot() cannot simply unmask: the source comment warns that if it unmasked while a hard IRQ was in progress on another CPU, “the interrupt can come in again and masks the line, leaves due to IRQS_INPROGRESS and the irq line is masked forever” — a permanently dead device, not a storm.

How the wake is actually performed. IRQ_WAKE_THREAD is interpreted in __handle_irq_event_percpu() (kernel/irq/handle.c, v6.12), which loops over every irqaction on the line, calls its handler, and switches on the return value. If a driver returns IRQ_WAKE_THREAD without having registered a thread_fn, the core does not crash — it prints once and drops the interrupt:

case IRQ_WAKE_THREAD:
	/* Catch drivers which return WAKE_THREAD but
	 * did not set up a thread function */
	if (unlikely(!action->thread_fn)) {
		warn_no_thread(irq, action);
		break;
	}
	__irq_wake_thread(desc, action);
	break;

warn_no_thread() uses a test_and_set_bit(IRQTF_WARNED, …) so the message — "IRQ %d device %s returned IRQ_WAKE_THREAD but no thread function available." — appears exactly once per action, no matter how many times the bug fires. That one-shot warning is a real diagnostic trap: a driver with this bug looks like a device whose interrupts are simply being ignored, and the single dmesg line explaining why may have scrolled away hours ago.

__irq_wake_thread() itself does four things, in this order, and all of them matter:

if (action->thread->flags & PF_EXITING)     /* (1) thread died: pretend handled */
	return;
if (test_and_set_bit(IRQTF_RUNTHREAD, &action->thread_flags))
	return;                                 /* (2) already pending: idempotent */
desc->threads_oneshot |= action->thread_mask;   /* (3) keep the line masked */
atomic_inc(&desc->threads_active);              /* (4) synchronize_irq() accounting */
wake_up_process(action->thread);

Step (2) makes the wake idempotent: a burst of interrupts that arrives faster than the thread runs collapses into a single thread invocation, not a queue of them. This is a genuine semantic difference from a workqueue, where each schedule_work() on an already-queued item is also coalesced but the coalescing rules are the workqueue’s, not the IRQ line’s. It also means a threaded handler must be written to drain all pending device state each time it runs, exactly like a NAPI poll() — it cannot assume one invocation per interrupt.

Step (1) is quietly reassuring: if the IRQ thread has been killed, the core just returns, and the comment explains why that is safe — “The hardirq handler has disabled the device interrupt, so no irq storm is lurking.”

thread_mask and the sharing limit. Step (3) needs a per-action bit, and __setup_irq() allocates one at registration by finding the first zero bit across every existing action on the line:

if (new->flags & IRQF_ONESHOT) {
	/* Unlikely to have 32 resp 64 irqs sharing one line, but who knows. */
	if (thread_mask == ~0UL) {
		ret = -EBUSY;
		goto out_unlock;
	}
	new->thread_mask = 1UL << ffz(thread_mask);
}

ffz() is “find first zero bit,” and thread_mask here is the OR of every sibling’s mask. This puts a hard, exact ceiling on sharing: at most BITS_PER_LONG — 32 on a 32-bit kernel, 64 on a 64-bit kernel — IRQF_ONESHOT actions may share one interrupt line, and the 65th gets -EBUSY. The kernel’s own comment concedes this is unlikely to bite, and in practice it does not; it is worth knowing because -EBUSY from request_threaded_irq() is otherwise a baffling return code. Note also the else branch: for non-oneshot actions thread_mask stays 0, which the comment explains is deliberate — “so we can avoid a conditional in irq_wake_thread().” OR-ing zero is free; testing a flag is not.

Thread creation. When a thread_fn is present, __setup_irq() calls setup_irq_thread(), which spins up a kernel thread with a recognizable name (this is the irq/<N>-<name> you see in ps and /proc):

t = kthread_create(irq_thread, new, "irq/%d-%s", irq, new->name);

A secondary thread (used by forced threading when both a primary and a thread function exist) gets the format "irq/%d-s-%s". The thread is created but parked until the line fires.

The thread loop. Each per-IRQ thread runs irq_thread(), an event loop that sleeps until the primary handler wakes it, runs the handler, and accounts completion:

while (!irq_wait_for_interrupt(desc, action)) {
	irqreturn_t action_ret;
	action_ret = handler_fn(desc, action);
	if (action_ret == IRQ_WAKE_THREAD)
		irq_wake_secondary(desc, action);
	wake_threads_waitq(desc);
}

irq_wait_for_interrupt() blocks until the primary handler’s IRQ_WAKE_THREAD return causes the core to wake this thread. handler_fn is one of two thin wrappers chosen at loop entry:

if (force_irqthreads() && test_bit(IRQTF_FORCED_THREAD, &action->thread_flags))
	handler_fn = irq_forced_thread_fn;
else
	handler_fn = irq_thread_fn;

irq_thread_fn() simply calls the driver’s thread_fn directly. irq_forced_thread_fn() is used when an interrupt that was a plain hard handler has been forcibly converted to a thread (see PREEMPT_RT below): because the driver’s code was written for atomic context, the wrapper re-creates that environment by disabling bottom halves (and, on non-RT kernels, local interrupts) around the call:

local_bh_disable();
if (!IS_ENABLED(CONFIG_PREEMPT_RT))
	local_irq_disable();
ret = action->thread_fn(action->irq, action->dev_id);

That IS_ENABLED(CONFIG_PREEMPT_RT) check is a quiet but central detail: on a real-time kernel the forced thread does not disable interrupts, because the entire point of RT is to keep interrupts enabled and the handler preemptible.

Completion and synchronization. When the thread finishes, wake_threads_waitq() decrements the active-thread counter and, when it hits zero, wakes anyone waiting for the IRQ to quiesce:

void wake_threads_waitq(struct irq_desc *desc)
{
	if (atomic_dec_and_test(&desc->threads_active))
		wake_up(&desc->wait_for_threads);
}

This is what makes synchronize_irq() correct for threaded handlers. The synchronize path waits for both the hard handler and any in-flight threads:

__synchronize_hardirq(desc, true);
wait_event(desc->wait_for_threads, !atomic_read(&desc->threads_active));

So when a driver calls free_irq() or synchronize_irq() before tearing down, it is guaranteed that the threaded handler is not still running — a guarantee a workqueue-based bottom half would have to arrange separately with cancel_work_sync().

Code Example — A Realistic Threaded Driver

/* Primary handler — hard-IRQ context, atomic, NO sleeping. */
static irqreturn_t mydev_hardirq(int irq, void *dev_id)
{
	struct mydev *d = dev_id;
	u32 status = readl(d->regs + STATUS);     /* MMIO read is fine */
 
	if (!(status & STATUS_OURS))
		return IRQ_NONE;                  /* not us: let others try (shared line) */
 
	writel(status, d->regs + STATUS_ACK);     /* ACK so the device de-asserts */
	d->pending_status = status;               /* hand data to the thread */
	return IRQ_WAKE_THREAD;                    /* finish the heavy work in the thread */
}
 
/* Threaded handler — process context, MAY sleep. */
static irqreturn_t mydev_thread(int irq, void *dev_id)
{
	struct mydev *d = dev_id;
 
	mutex_lock(&d->lock);                      /* legal: we can sleep */
	process_transfer(d, d->pending_status);    /* may block on a slow bus */
	mutex_unlock(&d->lock);
	return IRQ_HANDLED;
}
 
/* Registration. */
ret = request_threaded_irq(d->irq, mydev_hardirq, mydev_thread,
			   IRQF_ONESHOT | IRQF_SHARED, "mydev", d);

Line-by-line, the load-bearing choices: the primary handler returns IRQ_NONE when the status bit says the interrupt was not ours — required for shared lines so the next handler on the chain gets a turn. It ACKs the device before returning IRQ_WAKE_THREAD, so the device stops asserting; IRQF_ONESHOT then keeps the line masked across the thread regardless. The threaded function takes a mutex and may block on a slow bus — both illegal in the primary handler, both fine here. A common simpler variant passes handler == NULL and lets irq_default_primary_handler wake the thread on every interrupt — that requires IRQF_ONESHOT (the core rejects it otherwise) and cannot be used on a shared line, since there’s no way to say “not mine.”

Once registered, the thread’s priority is tunable from user space because it is a real task: chrt -f -p 50 $(pgrep -f 'irq/.*-mydev') gives the handler SCHED_FIFO priority 50, isolating its latency from ordinary work — a knob a softirq or tasklet does not offer.

The Thread Is a Real Task — Priority, Affinity, and irqbalance

Everything distinctive about a threaded handler follows from one fact: irq_thread is an ordinary struct task_struct. It appears in ps, it has a PID, it has a scheduling policy and priority, it has a CPU affinity mask, and every userspace tool that manipulates tasks works on it. This section is about what that costs and what it buys.

Priority: SCHED_FIFO 50, and why exactly 50

The very first thing irq_thread() does after marking itself ready is claim a real-time priority:

static int irq_thread(void *data)
{
	struct callback_head on_exit_work;
	struct irqaction *action = data;
	struct irq_desc *desc = irq_to_desc(action->irq);
	irqreturn_t (*handler_fn)(struct irq_desc *desc, struct irqaction *action);
 
	irq_thread_set_ready(desc, action);
	sched_set_fifo(current);
	...
}

sched_set_fifo() is not a knob — it is a fixed policy defined in kernel/sched/syscalls.c (v6.12):

void sched_set_fifo(struct task_struct *p)
{
	struct sched_param sp = { .sched_priority = MAX_RT_PRIO / 2 };
	WARN_ON_ONCE(sched_setscheduler_nocheck(p, SCHED_FIFO, &sp) != 0);
}

With MAX_RT_PRIO defined as 100 in include/linux/sched/prio.h, every IRQ thread in the kernel starts at SCHED_FIFO priority 50 — dead centre of the real-time band, above every SCHED_OTHER task and below the top half of the RT range. The comment above sched_set_fifo() explains the choice with unusual candour, and it is worth quoting because it is the honest statement of a design limitation rather than a justification:

SCHED_FIFO is a broken scheduler model; that is, it is fundamentally incapable of resource management, which is the one thing an OS really should be doing. […] Worse still; it is fundamentally impossible to compose static priority workloads. You cannot take two correctly working static prio workloads and smash them together and still expect them to work. For this reason ‘all’ FIFO tasks the kernel creates are basically at: MAX_RT_PRIO / 2. The administrator MUST configure the system, the kernel simply doesn’t know enough information to make a sensible choice.”

So 50 is not a recommendation. It is a deliberate refusal to guess, placed exactly halfway so that an administrator can put things both above and below it. If you are building a real-time system and you have not run chrt over your IRQ threads, you have not finished configuring it — the kernel is telling you so in a source comment.

Because the thread is a task, the tuning is entirely conventional:

$ ps -eo pid,class,rtprio,psr,comm | grep '^ *[0-9]* FF'   # find them
$ chrt -f -p 80 $(pgrep -x 'irq/47-nvme0q1')               # raise above the default 50
$ chrt -o -p 0  $(pgrep -x 'irq/9-acpi')                    # demote to SCHED_OTHER entirely
$ taskset -pc 4 $(pgrep -x 'irq/9-acpi')                    # pin (but read the next section first)

A real machine, read on 2026-09-04

The machine this note was edited on is a 32-CPU AMD workstation running Fedora 44 (7.1.8-200.fc44.x86_64). It is not a real-time kernel and it was not booted with threadirqs/proc/cmdline carries neither, and /boot/config-7.1.8-200.fc44.x86_64 shows # CONFIG_PREEMPT_RT is not set alongside CONFIG_IRQ_FORCED_THREADING=y and CONFIG_PREEMPT_DYNAMIC=y. In other words: forced threading is compiled in but switched off. Every IRQ thread on this box is therefore one a driver explicitly asked for. There are exactly ten:

ThreadPolicy / prioRunning onIRQChip / deviceInterrupts since boot
irq/9-acpiFF 50CPU 19IR-IO-APIC 9-fasteoi1,420
irq/26-AMD-ViFF 50CPU 226PCI-MSI 0000:00:00.2 0-edge (IOMMU)0
irq/33-pciehpFF 50CPU 333IR-PCI-MSI 0000:00:01.1 0-edge0
irq/34-pciehpFF 50CPU 934IR-PCI-MSI 0000:00:01.2 0-edge0
irq/27-ACPI:EventFF 50CPU 1927amd_gpio 61132
irq/28…32-ACPI:EventFF 50CPU 2728–32amd_gpio 62, 58, 59, 18, 00 each

Two things fall out of this table immediately. First, the FIFO-50 claim is confirmed empirically — every one of the ten shows FF and 50 in ps -eo class,rtprio, exactly MAX_RT_PRIO / 2. Second, look at the interrupt counts. The threaded lines here have handled a few thousand interrupts in total since boot. Meanwhile the same /proc/interrupts shows IRQ 41 (xhci_hcd) at 55,024,649, IRQ 47 (nvme0q1) at 200,381, and 24 further NVMe queues at ~50,000–225,000 each — none of them threaded. That is the real-world shape of the mechanism on a general-purpose kernel: threading is used for slow, rare, awkward interrupts (a hot-plug event, an ACPI notification, an IOMMU fault, a GPIO expander), and the high-rate storage and USB paths stay in hard-IRQ context with softirq bottom halves. The high-rate networking path does the same thing for the same reason — see The Network Receive Path, where the entire justification for NET_RX_SOFTIRQ is that a scheduler round-trip per packet is unaffordable.

The kernel-wide counters at the bottom of the same file frame the scale: LOC (local timer) at 9,705,942,353, CAL (function-call IPIs) at 11,987,028,094, RES (rescheduling IPIs) at 1,007,957,619, TLB (shootdowns) at 825,700,540. Threaded handlers are a rounding error on this machine’s interrupt budget, which is precisely why nobody notices their cost here — and precisely why a real-time deployment, which threads everything, has to think hard about it.

Affinity: the thread follows the line, automatically

The subtle part is CPU placement, and the mechanism is genuinely elegant. When anything changes an IRQ’s affinity — a driver call, an administrator writing to /proc/irq/N/smp_affinity, or irqbalance doing the same — irq_do_set_affinity() calls irq_set_thread_affinity(), which does not move the thread itself:

/**
 *	irq_set_thread_affinity - Notify irq threads to adjust affinity
 *	We just set IRQTF_AFFINITY and delegate the affinity setting
 *	to the interrupt thread itself. We can not call
 *	set_cpus_allowed_ptr() here as we hold desc->lock and this
 *	code can be called from hard interrupt context.
 */
void irq_set_thread_affinity(struct irq_desc *desc)
{
	for_each_action_of_desc(desc, action) {
		if (action->thread) {
			set_bit(IRQTF_AFFINITY, &action->thread_flags);
			wake_up_process(action->thread);
		}
		if (action->secondary && action->secondary->thread) { … }
	}
}

It sets a flag and kicks the thread. The thread notices on its next trip round the wait loop, inside irq_wait_for_interrupt()irq_thread_check_affinity(), where it is in ordinary process context and may allocate and sleep:

if (!test_and_clear_bit(IRQTF_AFFINITY, &action->thread_flags))
	return;
__set_current_state(TASK_RUNNING);
if (!alloc_cpumask_var(&mask, GFP_KERNEL)) {
	set_bit(IRQTF_AFFINITY, &action->thread_flags);  /* OOM: try again next time */
	return;
}
raw_spin_lock_irq(&desc->lock);
if (cpumask_available(desc->irq_common_data.affinity)) {
	m = irq_data_get_effective_affinity_mask(&desc->irq_data);
	cpumask_copy(mask, m);
	valid = true;
}
raw_spin_unlock_irq(&desc->lock);
if (valid)
	set_cpus_allowed_ptr(current, mask);

Note which mask it copies: irq_data_get_effective_affinity_mask(), the effective affinity — the single CPU (or small set) the interrupt controller actually routes the line to — not the broader “allowed” mask in smp_affinity. This is exactly right and it is the whole point. On modern x86 with MSI/MSI-X, smp_affinity is a permission set and the APIC picks one CPU out of it; putting the handler thread on the same CPU keeps the device’s data, the hard handler, and the thread all in one cache domain.

sequenceDiagram
    autonumber
    participant IB as irqbalance<br/>(userspace daemon)
    participant PROC as /proc/irq/N/smp_affinity<br/>(kernel/irq/proc.c)
    participant CORE as irq_do_set_affinity&#40;&#41;<br/>+ chip->irq_set_affinity&#40;&#41;
    participant HW as Interrupt controller<br/>(IR-IO-APIC / MSI-X)
    participant KT as irq/N-name thread

    IB->>PROC: write&#40;"00000004"&#41; — move IRQ N to CPU 2
    PROC->>CORE: irq_set_affinity&#40;&#41;
    CORE->>HW: chip->irq_set_affinity&#40;&#41;<br/>reprogram the IRTE / redirection entry
    HW-->>CORE: IRQ_SET_MASK_OK
    CORE->>CORE: irq_validate_effective_affinity&#40;&#41;<br/><i>pr_warn_once if the chip left it empty</i>
    CORE->>KT: <b>irq_set_thread_affinity&#40;&#41;</b><br/>set_bit&#40;IRQTF_AFFINITY&#41;<br/>+ wake_up_process&#40;&#41;
    Note over CORE: cannot call set_cpus_allowed_ptr&#40;&#41; here —<br/>desc->lock is held and this can run<br/>from hard-IRQ context
    KT->>KT: wakes in irq_wait_for_interrupt&#40;&#41;
    KT->>KT: irq_thread_check_affinity&#40;&#41;:<br/>alloc_cpumask_var&#40;GFP_KERNEL&#41;<br/><i>legal — we are a task now</i>
    KT->>CORE: read <b>effective</b> affinity mask under desc->lock
    KT->>KT: set_cpus_allowed_ptr&#40;current, mask&#41;
    Note over KT: thread now runs on CPU 2,<br/>same CPU the hard IRQ lands on

How an irqbalance decision propagates from userspace all the way into the handler thread’s CPU mask. What it shows: the affinity change is a two-stage handoff — the IRQ core reprograms the hardware synchronously but can only flag the thread, because it holds desc->lock and may be in hard-IRQ context where set_cpus_allowed_ptr() (which can sleep) is illegal; the thread completes the migration itself later, in process context. The insight to take: you do not have to pin IRQ threads by hand to keep them cache-local. Moving the interrupt moves the thread, automatically, and it tracks the effective mask rather than the permitted one. The corollary is the trap: if you taskset an IRQ thread yourself and irqbalance later moves the line, step 6 will silently overwrite your pin. Ban the IRQ in irqbalance (--banirq=N) or set IRQF_NOBALANCING in the driver instead of fighting it from userspace.

Measured on this machine, the correlation is exact. For each threaded IRQ, the CPU the thread is actually running on (ps -o psr) equals the IRQ’s effective_affinity_list:

IRQsmp_affinity_list (permitted)effective_affinity_listThread’s CPU (psr)
9 (acpi)0-3111
26 (AMD-Vi)0-3122
33 (pciehp)3,1933
34 (pciehp)9,2599
27–32 (amd_gpio)0-31(empty)19, 27, 27, 27, 27, 27

The last row is the informative one. amd_gpio is a demultiplexing GPIO controller whose child IRQs have no effective affinity mask at all — the chip never sets one, which is the case irq_validate_effective_affinity() exists to complain about (pr_warn_once("irq_chip %s did not update eff. affinity mask of irq %u\n", …)). With nothing to copy, irq_thread_check_affinity() never pins those threads, and the scheduler places them freely; five of the six happen to have landed on CPU 27. So the “thread follows the line” property is real but conditional on the irqchip implementing effective affinity — on hierarchical or demultiplexed controllers it silently does not apply.

Uncertain

Verify: whether irq_thread_check_affinity() is reached at all for the amd_gpio children, or whether IRQTF_AFFINITY is simply never set for them because nothing ever calls irq_set_affinity() on a demux child. Reason: the observed outcome (unpinned threads, empty effective mask) is consistent with both explanations, and I read the generic kernel/irq/ code at v6.12 but did not read drivers/pinctrl/pinctrl-amd.c or trace the hierarchical-domain affinity path. To resolve: read pinctrl-amd.c’s irq_chip at the running tag and check whether it supplies .irq_set_affinity; or enable CONFIG_GENERIC_IRQ_DEBUGFS (currently # not set on this machine) and read /sys/kernel/debug/irq/irqs/27. uncertain

irqbalance and the parts it will not touch

irqbalance is a userspace daemon (version 1.9.5 on this machine, systemctl is-activeactive) that periodically rewrites /proc/irq/N/smp_affinity to spread interrupt load across cache domains. Everything above means it is, transitively, also an IRQ-thread placement daemon. Its relevant controls, from its own manual page:

  • -i, --banirq=<irqnum> — “irqbalance will not affect the affinity of any IRQs on the banned list, allowing them to be specified manually.” This is the correct way to hand-place a latency-critical line and its thread.
  • -m, --banmod=<module_name> — the same, for every IRQ belonging to a module.
  • IRQBALANCE_BANNED_CPUS / IRQBALANCE_BANNED_CPULIST — “a mask of CPUs which irqbalance should ignore and never assign interrupts to. If not specified, irqbalance use mask of isolated and adaptive-ticks CPUs on the system as the default value” — i.e. it already respects isolcpus= and nohz_full= without being told. (The hexmask form is deprecated in favour of the CPU-list form.)
  • -l, --policyscript=<script> — runs per discovered IRQ and can emit ban=true, letting you express placement policy in a script rather than a static list.

On the kernel side the equivalent lever is IRQF_NOBALANCING (0x00000800), which marks a line as excluded from balancing at registration. The /proc interface itself is documented in Documentation/core-api/irq/irq-affinity.rst: smp_affinity is a hex bitmask and smp_affinity_list a CPU list of permitted CPUs, “It’s not allowed to turn off all CPUs, and if an IRQ controller does not support IRQ affinity then the value will not change from the default of all cpus”, with /proc/irq/default_smp_affinity supplying the initial mask for newly allocated IRQs (ffffffff on this machine). The finer points of steering — receive queues, cache domains, the difference between permitted and effective masks — belong to IRQ Affinity and irqbalance; what is specific to this note is only the propagation rule: move the line, and the thread moves with it.

The PREEMPT_RT Angle — Forced Threading

The deepest reason threaded IRQs matter is the real-time kernel. PREEMPT_RT was mainlined in Linux 6.12 (the final blocker, a printk rework, landed around 20 September 2024 and shipped in 6.12) (Phoronix, “Real-Time PREEMPT_RT Support Merged For Linux 6.12”; Wikipedia, PREEMPT_RT). A core RT technique is to make almost everything that runs in atomic context preemptible instead, and forcing interrupt handlers into threads is central to that: a handler that runs in a schedulable thread can be preempted by a higher-priority real-time task, so a long-running driver handler no longer adds unbounded latency to the most critical task.

The kernel exposes forced threading through force_irqthreads(), defined three ways (interrupt.h, v6.12):

/* CONFIG_PREEMPT_RT */
# define force_irqthreads()	(true)
 
/* CONFIG_IRQ_FORCED_THREADING, not RT */
DECLARE_STATIC_KEY_FALSE(force_irqthreads_key);
#  define force_irqthreads()	(static_branch_unlikely(&force_irqthreads_key))
 
/* neither */
#define force_irqthreads()	(false)

On a PREEMPT_RT kernel force_irqthreads() is unconditionally true — RT forces threading by default. On a non-RT kernel that has CONFIG_IRQ_FORCED_THREADING, it is controlled by a static key flipped on by the threadirqs boot parameter:

static int __init setup_forced_irqthreads(char *arg)
{
	static_branch_enable(&force_irqthreads_key);
	return 0;
}
early_param("threadirqs", setup_forced_irqthreads);

When forced threading is active, irq_setup_forced_threading() retrofits a thread onto a handler that didn’t ask for one. It adds IRQF_ONESHOT and, if the driver supplied both a primary and a thread function, creates a secondary action so the original thread_fn still runs in a thread of its own:

new->flags |= IRQF_ONESHOT;
if (new->handler && new->thread_fn) {
	new->secondary = kzalloc(sizeof(struct irqaction), GFP_KERNEL);
	new->secondary->handler = irq_forced_secondary_handler;
	new->secondary->thread_fn = new->thread_fn;
}

Crucially, not every interrupt can be forced threaded. irq_setup_forced_threading() bails out for three flag classes:

if (!force_irqthreads())
	return 0;
if (new->flags & (IRQF_NO_THREAD | IRQF_PERCPU | IRQF_ONESHOT))
	return 0;

IRQF_NO_THREAD marks interrupts the driver insists must stay in hard-IRQ context. IRQF_PERCPU interrupts (per-CPU lines) are excluded because their semantics don’t map to a single thread. IRQF_ONESHOT is excluded because such a handler is already threaded by construction — retrofitting a second layer would be nonsense.

The timer interrupt falls out of the first test rather than needing a rule of its own, and the reason is purely definitional. IRQF_TIMER is a composite that contains IRQF_NO_THREAD (include/linux/interrupt.h, v6.12):

#define __IRQF_TIMER      0x00000200
#define IRQF_NO_THREAD    0x00010000
#define IRQF_TIMER        (__IRQF_TIMER | IRQF_NO_SUSPEND | IRQF_NO_THREAD)

So any driver that registers with IRQF_TIMER has, whether it realised it or not, also set IRQF_NO_THREAD, and irq_setup_forced_threading() bails on the very first flag test. The architectural reason usually given — that the tick drives the scheduler, so the interrupt that decides which thread runs next cannot itself be deferred into a thread the scheduler must first schedule — is a sound account of why the flag is set that way, but it is a reconstruction: the v6.12 source states the mechanism (the composite #define), not the motivation.

Uncertain

Verify: that the reason IRQF_TIMER includes IRQF_NO_THREAD is the scheduler-bootstrap argument specifically, rather than (say) latency or a clockevents re-entrancy constraint. Reason: the mechanism is now fully verifiedIRQF_TIMER is literally __IRQF_TIMER | IRQF_NO_SUSPEND | IRQF_NO_THREAD in include/linux/interrupt.h at v6.12, and irq_setup_forced_threading() returns early on IRQF_NO_THREAD — so the exclusion is no longer in doubt; only the rationale is, and no in-tree comment states it. To resolve: read the commit that introduced the IRQF_NO_THREAD bit into the IRQF_TIMER composite (Thomas Gleixner, forced-threading series) via git.kernel.org’s /patch/?id=<full sha> endpoint, which serves plain curl correctly. uncertain

Putting the whole retrofit together, irq_setup_forced_threading() is a five-way decision, and one of its branches is easy to miss:

flowchart TD
    IN(["__setup_irq&#40;&#41; calls<br/>irq_setup_forced_threading&#40;new&#41;"]) --> F1{"force_irqthreads&#40;&#41;?"}
    F1 -->|"false<br/><i>non-RT, no threadirqs</i>"| OUT0(["return 0 — nothing to do<br/><b>this is the common case</b>"])
    F1 -->|"true<br/><i>PREEMPT_RT, or<br/>threadirqs boot param</i>"| F2{"flags &amp; &#40;IRQF_NO_THREAD |<br/>IRQF_PERCPU | IRQF_ONESHOT&#41;?"}

    F2 -->|"set"| OUT1(["return 0 — opted out.<br/><b>IRQF_TIMER lands here</b><br/>&#40;it contains IRQF_NO_THREAD&#41;"])
    F2 -->|"clear"| F3{"handler ==<br/>irq_default_primary_handler?"}

    F3 -->|"yes"| OUT2(["return 0 — <b>already</b> a<br/>genuine threaded handler.<br/>Leave it exactly as it is"])
    F3 -->|"no"| RETRO["<b>Retrofit:</b><br/>new-&gt;flags |= IRQF_ONESHOT"]

    RETRO --> F4{"handler &amp;&amp; thread_fn?<br/><i>driver supplied BOTH</i>"}
    F4 -->|"yes"| SEC["kzalloc a <b>secondary</b> irqaction:<br/>secondary-&gt;handler = irq_forced_secondary_handler<br/>secondary-&gt;thread_fn = original thread_fn<br/><i>runs as irq/N-s-name</i>"]
    F4 -->|"no"| SWAP
    SEC --> SWAP["set_bit&#40;IRQTF_FORCED_THREAD&#41;<br/><b>thread_fn = handler</b> &#40;the old hard handler&#41;<br/><b>handler = irq_default_primary_handler</b>"]
    SWAP --> OUT3(["return 0 — the old atomic handler<br/>now runs in irq_forced_thread_fn&#40;&#41;,<br/>which re-creates its atomic environment"])

    style OUT2 fill:#eef4ff
    style SWAP fill:#fff6e5
    style OUT1 fill:#ffeeee

What forced threading does to an irqaction. What it shows: the retrofit is a pointer swap — the driver’s original hard handler is moved into the thread_fn slot and the core’s do-nothing stub takes its place, so a driver written for atomic context is now executed by a kernel thread without the driver knowing. The insight to take: the branch that is easy to miss is the third one. A handler registered as handler == NULL, thread_fn != NULL — the idiomatic slow-bus pattern — is left completely alone by forced threading, because it is already exactly what forced threading is trying to produce. Turning on threadirqs therefore changes nothing at all for the drivers most people associate with threaded IRQs; it changes everything for the plain request_irq() drivers that never asked for it.

Because the retrofitted thread_fn is code written for hard-IRQ context — it will take spinlocks without _irqsave, touch per-CPU data assuming preemption is off, and generally assume it cannot be interrupted — irq_forced_thread_fn() rebuilds that environment around the call rather than trusting the driver to cope:

local_bh_disable();
if (!IS_ENABLED(CONFIG_PREEMPT_RT))
	local_irq_disable();
ret = action->thread_fn(action->irq, action->dev_id);
if (ret == IRQ_HANDLED)
	atomic_inc(&desc->threads_handled);
irq_finalize_oneshot(desc, action);
if (!IS_ENABLED(CONFIG_PREEMPT_RT))
	local_irq_enable();
local_bh_enable();

The source comment is precise about why: “Interrupts which are not explicitly requested as threaded interrupts rely on the implicit bh/preempt disable of the hard irq context. So we need to disable bh here to avoid deadlocks and other side effects.” Contrast this with the sibling irq_thread_fn(), used for handlers that did ask to be threaded, which just calls action->thread_fn() naked, with the comment “Interrupts explicitly requested as threaded interrupts want to be preemptible - many of them need to sleep and wait for slow busses to complete.”

The IS_ENABLED(CONFIG_PREEMPT_RT) guard is the quiet centre of the whole design. On a non-RT kernel, forced threading is a compatibility shim: it moves the handler into a task but still runs it with interrupts hard-disabled, so the latency win is limited to preemptibility between handler invocations. On an RT kernel the local_irq_disable() is skipped entirely, because on RT the spinlocks the driver takes have themselves become sleeping locks (see Raw Spinlocks and PREEMPT_RT) and disabling interrupts around them would reintroduce exactly the unbounded latency RT exists to eliminate. Forced threading and sleeping spinlocks are not two features; they are two halves of one feature, and neither is safe without the other.

Failure Modes and Common Misunderstandings

Sleeping in the primary handler. The single most common bug: calling mutex_lock(), kmalloc(GFP_KERNEL), or any blocking primitive in the primary handler. It runs in hard-IRQ context and the rules of interrupt context apply unchanged — the sleeping work belongs in thread_fn. With CONFIG_DEBUG_ATOMIC_SLEEP the kernel prints a “scheduling while atomic” splat; without it you get a hard-to-trace hang or corruption.

Forgetting IRQF_ONESHOT with handler == NULL. As shown above, the core rejects this at registration with -EINVAL and a pr_err, so it fails loudly rather than silently — but only on chips that aren’t IRQCHIP_ONESHOT_SAFE. A driver author who ignores the return value of request_threaded_irq() will simply have a non-functional interrupt.

Assuming a threaded handler is “free” latency-wise. The thread is woken, not run inline; there is a scheduler round-trip between the hard handler and thread_fn. Under load, or if the thread’s priority is low, that latency can be large. For genuinely latency-critical, short atomic work, a hard handler (or a softirq) is faster. Threading buys bounded preemptible latency, not low latency, which is exactly the RT trade.

Confusing the thread with a workqueue. People sometimes ask why you’d use a threaded IRQ instead of having the hard handler schedule_work(). The difference is the IRQ-subsystem integration: the threaded handler is masked/unmasked via IRQF_ONESHOT, is synchronized by synchronize_irq()/free_irq(), and is named and accounted per-line. A workqueue item has none of that line-level coupling — it is the right tool when the deferred work is not tightly tied to re-enabling a specific interrupt line.

Returning IRQ_WAKE_THREAD with no thread_fn. Covered above under warn_no_thread(): the interrupt is silently dropped and a single KERN_WARNING is printed once per action, ever. The usual way to arrive here is refactoring — someone converts a request_threaded_irq() back to request_irq() and forgets that the primary handler still returns IRQ_WAKE_THREAD. The symptom is a device that appears to work at registration and then never makes progress, with /proc/interrupts counting up normally (the hard handler is running) — which is exactly the evidence that misleads you into blaming the device.

The line masked forever. The nastiest failure is the one irq_finalize_oneshot()’s retry loop prevents, and understanding it is the best argument for never open-coding oneshot masking yourself. The scenario, from the source comment verbatim:

“The thread is faster done than the hard interrupt handler on the other CPU. If we unmask the irq line then the interrupt can come in again and masks the line, leaves due to IRQS_INPROGRESS and the irq line is masked forever.”

Two CPUs, one line. CPU A takes the interrupt and is inside handle_level_irq() with the line masked and IRQD_IRQ_INPROGRESS set. CPU B is the IRQ thread, which finishes early and reaches irq_finalize_oneshot(). If B unmasked now, the still-asserted device would immediately re-fire onto some CPU, handle_level_irq() would mask_ack_irq() again, irq_may_run() would see IRQS_INPROGRESS and bail without running any handler — leaving the line masked with nobody scheduled to unmask it. The device is dead until the next enable_irq(), which never comes. The fix is the spin at the top of irq_finalize_oneshot():

if (unlikely(irqd_irq_inprogress(&desc->irq_data))) {
	raw_spin_unlock_irq(&desc->lock);
	chip_bus_sync_unlock(desc);
	cpu_relax();
	goto again;
}

Spurious-interrupt detection is deferred by one interrupt for threaded handlers, and this changes what “IRQ disabled” messages mean. The kernel’s runaway-interrupt detector, note_interrupt() in kernel/irq/spurious.c (v6.12), normally decides “was this interrupt handled?” from the primary handler’s return value. With a threaded handler that return value is IRQ_WAKE_THREAD, which answers nothing — whether the interrupt was real is not known until the thread runs, long after note_interrupt() has returned. The comment states the constraint plainly: “We cannot call note_interrupt from the threaded handler because we need to look at the compound of all handlers (primary and threaded). Aside of that in the threaded shared case we have no serialization against an incoming hardware interrupt while we are dealing with a threaded result. So in case a thread is woken, we just note the fact and defer the analysis to the next hardware interrupt.”

The implementation is a small piece of bit-trickery worth reading once, because it explains an odd counter you may meet in a crash dump:

#define SPURIOUS_DEFERRED	0x80000000
...
if (action_ret == IRQ_WAKE_THREAD) {
	if (!(desc->threads_handled_last & SPURIOUS_DEFERRED)) {
		desc->threads_handled_last |= SPURIOUS_DEFERRED;
		return;                     /* first one: just arm the deferral */
	}
	handled = atomic_read(&desc->threads_handled);
	handled |= SPURIOUS_DEFERRED;
	if (handled != desc->threads_handled_last) {
		action_ret = IRQ_HANDLED;   /* a thread claimed one since last time */
		desc->threads_handled_last = handled;
	} else {
		action_ret = IRQ_NONE;      /* nobody claimed it — count as spurious */
	}
}

Bit 31 of threads_handled_last is stolen as a “deferral armed” flag (safe because, as the comment notes, “we really do not care about the high bits of the handled count. We just care about the count being different than the one we saw before”). The threaded handlers bump desc->threads_handled whenever they return IRQ_HANDLED; a change in that counter between two hardware interrupts is the evidence that somebody is doing real work.

flowchart TD
    IRQ(["Hardware interrupt N arrives<br/>on a threaded line"]) --> RET{"Compound handler result"}
    RET -->|"a primary handler returned<br/>IRQ_HANDLED"| CLR["clear SPURIOUS_DEFERRED<br/><i>threaded results don't matter —<br/>someone owned it in hardirq context</i>"] --> OK(["not spurious"])
    RET -->|"IRQ_WAKE_THREAD only"| ARMED{"SPURIOUS_DEFERRED<br/>already set?"}
    ARMED -->|"no — first time"| ARM["set SPURIOUS_DEFERRED<br/><b>return, decide nothing</b>"] --> WAIT(["verdict deferred to<br/>interrupt N+1"])
    ARMED -->|"yes"| CMP{"atomic_read&#40;threads_handled&#41;<br/>!= threads_handled_last?"}
    CMP -->|"changed"| HANDLED["a thread returned IRQ_HANDLED<br/>since the last interrupt<br/>&rArr; treat as IRQ_HANDLED"] --> OK
    CMP -->|"unchanged"| NONE["no thread claimed anything<br/>&rArr; treat as <b>IRQ_NONE</b>"] --> CNT["irqs_unhandled++<br/><i>reset to 1 if &gt; HZ/10 since<br/>the last unhandled one</i>"]
    CNT --> THRESH{"irq_count reached 100000<br/>AND irqs_unhandled &gt; 99900?"}
    THRESH -->|"no"| OK2(["keep going"])
    THRESH -->|"yes"| KILL["__report_bad_irq&#40;&#41;<br/><b>printk&#40;KERN_EMERG &quot;Disabling IRQ #%d&quot;&#41;</b><br/>IRQS_SPURIOUS_DISABLED, irq_disable&#40;&#41;<br/>+ arm poll_spurious_irq_timer"]

    style KILL fill:#ffeeee
    style ARM fill:#fff6e5

Runaway-interrupt detection on a threaded line. What it shows: for a threaded handler the verdict on interrupt N is not reached until interrupt N+1, and it is reached by comparing a counter rather than reading a return value. The insight to take: the numbers are exact and worth remembering — the kernel disables a line only after 99,901 unhandled out of a window of 100,000, and the irqs_unhandled counter is reset to 1 whenever more than HZ/10 has elapsed since the last unhandled one, so “the odd spurious IRQ caused by bus asynchronicity” never accumulates into a kill. If you ever see Disabling IRQ #N in dmesg on a threaded line, essentially every interrupt on it was unclaimed; suspect a wrong IRQF_TRIGGER_* polarity or a shared line whose real owner never loaded, not a marginal race.

Assuming one thread run per interrupt. Because __irq_wake_thread() is idempotent (test_and_set_bit(IRQTF_RUNTHREAD, …) returns early if the bit is already set), a burst of N interrupts arriving before the thread is scheduled produces one thread_fn() invocation, not N. A handler that processes exactly one event per call will fall permanently behind under load. Write thread_fn() as a drain loop.

Alternatives and When to Choose Them

A threaded IRQ is one of four deferral mechanisms; pick it when the deferred work must sleep and is tightly bound to a specific interrupt line (so you want oneshot masking and synchronize_irq() to cover it). If the work can stay atomic and you want the lowest latency, the kernel core uses a softirq (drivers don’t add new ones). If the work must sleep but isn’t coupled to re-enabling a line — deferred cleanup, a periodic poll, anything triggered from many sites — a workqueue is more flexible and doesn’t tie up a per-IRQ thread. The legacy tasklet is being phased out and should not be chosen for new code. As a rule of thumb: can it sleep and is it about this one line? → threaded IRQ; can it sleep, more general? → workqueue; must stay atomic? → softirq/(legacy) tasklet.

Laid out as a grid, the five options separate cleanly along three axes — can it sleep, is it a schedulable task, and is it coupled to the interrupt line:

MechanismContextMay sleep?Is a task?Tunable priorityBound to an IRQ lineCoalescing ruleChoose when
Hard-IRQ handlerhard-IRQ, IRQs offNoNoYes (it is the line)N/A — runs per interruptWork is a handful of register accesses
Softirq (NET_RX_SOFTIRQ, BLOCK_SOFTIRQ, …)softirq, IRQs on, preemption offNoNo (runs on the IRQ-return path, or in ksoftirqd under load)Only indirectly, via ksoftirqd’s niceNoPer-CPU pending bitmap; many raises → one runHigh-rate, atomic, throughput-critical. Core subsystems only — drivers may not add vectors
Taskletsoftirq (TASKLET_SOFTIRQ)NoNoNoNoOne pending instance, serialized against itselfLegacy — do not use in new code (Tasklets and Their Deprecation)
Threaded IRQprocess, IRQs onYesYes (irq/N-name)YesSCHED_FIFO 50 by default, chrt-ableYesIRQF_ONESHOT masking, synchronize_irq() covers it, affinity follows the lineIRQTF_RUNTHREAD bit — burst → one runWork must sleep and is about re-enabling this one line
Workqueue itemprocess, IRQs onYesYes, but a shared kworker poolPer-workqueue (WQ_HIGHPRI), not per-itemNoPer-work_struct pending flagWork must sleep and is not line-coupled: deferred cleanup, retries, polling, work raised from many sites

The two rows people actually confuse are the last two, and the distinguishing question is not “does it sleep” — both do — but “if this work is still running, does the interrupt line need to stay masked?” If yes, you want a threaded IRQ, because IRQF_ONESHOT gives you exactly that for free and free_irq() will not return until the handler has quiesced. If no, a workqueue is strictly more flexible and does not cost you a dedicated task per line. A secondary consideration: free_irq() on a threaded handler is safe by construction, whereas a hard handler that schedule_work()s must remember to cancel_work_sync() on teardown or race its own removal — a classic use-after-free.

Production Notes

The single biggest user: regmap-irq

The clearest evidence that the handler == NULL + IRQF_ONESHOT pattern is idiomatic is that it is not written per-driver at all — it is written once, in the shared regmap-irq core that hundreds of I²C and SPI chip drivers delegate their interrupt handling to. From drivers/base/regmap/regmap-irq.c (v6.12), in regmap_add_irq_chip_fwnode():

ret = request_threaded_irq(irq, NULL, regmap_irq_thread,
			   irq_flags | IRQF_ONESHOT,
			   chip->name, d);

Three things to read off it. NULL for the primary handler, because the very first thing regmap_irq_thread() does is a regmap read of the chip’s interrupt-status register — over I²C or SPI, a transaction that sleeps, and therefore the one thing that cannot happen in the primary handler. IRQF_ONESHOT is OR-ed in unconditionally, not left to the caller, because without it the registration would be rejected. And irq_flags comes from the caller, so a board can still specify trigger polarity and sharing. Every PMIC, codec, GPIO expander, and regulator that says regmap_add_irq_chip() in its probe function is a threaded-IRQ user, whether or not its author ever thought about it.

The thread also does the things only a thread can: regmap_irq_thread() calls chip->handle_pre_irq(), may take runtime-PM references, and walks a variable number of status registers with a regmap_read() per register — potentially dozens of bus transactions per interrupt, each of them a sleep. On a 100 kHz I²C bus a single 8-bit register read is on the order of 200 µs of wall time. Doing that in hard-IRQ context would not be slow; it would be a deadlock: the I²C controller driver itself waits on a completion that is signalled by its own interrupt, which cannot fire while you are inside a hard handler with interrupts disabled.

sequenceDiagram
    autonumber
    participant CHIP as PMIC / GPIO expander<br/>on an I2C bus
    participant SOC as SoC GPIO line<br/>(level-triggered)
    participant PH as irq_default_primary_handler<br/>(atomic — does NOTHING)
    participant KT as irq/N-name thread<br/>regmap_irq_thread&#40;&#41;
    participant I2C as I2C controller<br/>(its own IRQ + completion)

    CHIP->>SOC: pulls IRQ line LOW and HOLDS it<br/>(it cannot release until its<br/>status register is read)
    SOC->>PH: handle_level_irq&#40;&#41; masks the line,<br/>runs the primary handler
    Note over PH: the ONLY safe thing to do here.<br/>Reading the status register means<br/>an I2C transfer, which sleeps.
    PH-->>SOC: return IRQ_WAKE_THREAD
    Note over SOC: IRQF_ONESHOT: line stays MASKED.<br/>The device is still asserting —<br/>that is fine, nobody is listening.

    SOC->>KT: wake_up_process&#40;&#41;
    KT->>KT: chip.handle_pre_irq&#40;&#41;,<br/>runtime-PM get &#40;may sleep&#41;
    loop once per status register block
        KT->>I2C: regmap_read&#40;&#41;
        I2C->>CHIP: START, addr+W, reg, RESTART, addr+R
        Note over I2C: ~200 us on a 100 kHz bus.<br/>The thread BLOCKS on a completion<br/>signalled by the I2C controller IRQ.
        CHIP-->>I2C: status byte
        I2C-->>KT: value
    end
    KT->>KT: handle_nested_irq&#40;&#41; per set bit<br/>=> each child driver handler runs
    KT->>I2C: write ACK / clear bits
    I2C->>CHIP: clears the condition
    CHIP->>SOC: releases the IRQ line
    KT-->>SOC: irq_finalize_oneshot&#40;&#41;<br/>threads_oneshot bit cleared => unmask

Why a slow-bus interrupt controller has no choice but to be threaded. What it shows: the device holds the line asserted until its status register is read, and reading it requires an I²C transfer that blocks on the I²C controller’s own interrupt — so the acknowledgement is structurally impossible in atomic context. The insight to take: this is the case the handler == NULL + IRQF_ONESHOT pattern was designed for, and reading it this way explains both halves at once. The primary handler is NULL because there is genuinely nothing it could legally do; IRQF_ONESHOT is mandatory because the line will stay asserted for the entire hundreds-of-microseconds duration of steps 7–13, and unmasking it before step 15 would produce a re-entry storm at the speed of the interrupt controller.

Reading this machine

Everything in the A real machine, read on 2026-09-04 table above was produced with three commands, and they are the right three to reach for on any box:

$ cat /proc/interrupts                       # which lines exist, and their per-CPU counts
$ ps -eo pid,class,rtprio,psr,comm | grep irq/   # which of them are threaded, and where
$ cat /proc/irq/9/{smp_affinity_list,effective_affinity_list}

The pairing is what makes it useful: /proc/interrupts tells you the line, ps tells you whether a thread exists for it, and /proc/irq/N/effective_affinity_list tells you where both of them are actually running. A line with no matching irq/N-* task is a plain hard handler no matter what the driver’s Kconfig says.

Where they show up, and the priorities people assign them

Beyond regmap-irq, threaded handlers are the default for slow bus-attached interrupt controllers: GPIO expanders, I²C/SPI touchscreens, regulators, and codecs all use IRQF_ONESHOT threaded handlers because reading the device’s interrupt-status register requires a blocking bus transaction that cannot happen in hard-IRQ context — the bus read itself sleeps. For these, the handler == NULL + IRQF_ONESHOT pattern is idiomatic. On the latency side, real-time deployments routinely boot with everything threaded (RT) or with threadirqs (non-RT) and then assign SCHED_FIFO priorities to the specific irq/N-name threads that matter, steering and isolating IRQ latency the same way they isolate application threads — see IRQ Affinity and irqbalance for the orthogonal “which CPU” knob and Raw Spinlocks and PREEMPT_RT for why RT’s locking changes (sleeping spinlocks) are what make pervasive threading safe in the first place.

Latency, honestly

The honest summary of the latency trade, stated in terms that can be checked rather than benchmarked: threading adds one wake-up plus one context switch to every interrupt that takes the thread path, and removes an unbounded blocking window from every task with priority above 50. Neither term is a number you can quote generically — the first depends on wake-up cost and scheduler state on your CPU, the second on how long your worst driver’s handler runs. What the mechanism guarantees is the shape: the added cost is bounded and per-interrupt; the removed cost was unbounded and dependent on the worst handler in the system. That asymmetry is why an RT deployment takes a throughput hit it can measure in exchange for a tail-latency win it can bound, and why a throughput-oriented server does the opposite.

Uncertain

Verify: concrete numbers for the added wake-up-plus-context-switch cost of a threaded handler versus an inline hard handler, and for the tail-latency improvement threadirqs buys on a non-RT kernel. Reason: no primary source with reproducible measurements was retrievable during this task. The cyclictest/rt-tests numbers circulating in conference slides and vendor blogs are not primary, are hardware-specific, and are frequently quoted without stating kernel version, PREEMPT mode, or whether the load generator was running. To resolve: run rt-testscyclictest -m -p 90 -i 200 -h 400 on this machine with and without threadirqs on the kernel command line, under identical load, and record both histograms — that would be a primary measurement for this hardware, which is the only honest kind. uncertain

See Also