Thread Info Flags and Syscall Exit Work

Each kernel thread carries a tiny, cache-line-sized struct thread_info that the lowest-level entry and exit assembly can reach instantly. Its most consequential members are two bitmask words: flags, holding the TIF_* (“thread information flag”) bits such as TIF_SIGPENDING, TIF_NEED_RESCHED, and TIF_NOTIFY_RESUME; and syscall_work, holding the SYSCALL_WORK_* bits that gate per-syscall tracing, auditing, seccomp, and ptrace. The entry/exit layer never asks a string of separate questions — “is a signal pending? is a reschedule needed? is anyone tracing?” Instead it reads one word and AND-masks it against a precomputed constant (SYSCALL_WORK_ENTER, SYSCALL_WORK_EXIT, or EXIT_TO_USER_MODE_WORK); if the result is zero, no deferred work exists and the boundary is crossed with a single test. Only when the masked test is non-zero does the kernel pay for the slow path. This single-bit-test fast path is the reason a no-op syscall return is a handful of instructions despite the dozen distinct kinds of work that could be pending (per include/linux/thread_info.h and include/linux/entry-common.h at v6.12).

The single most important idea: thread_info flags are how the kernel turns “deferred work” into an O(1) check. Any subsystem that needs a thread to do something before it next runs in userspace — deliver a signal, reschedule, run a task_work callback, apply a live patch — sets a flag. The exit path’s job is to drain those flags. Bundling all of them into one or two words means the common “nothing to do” case is decided by a single AND-and-branch, which is exactly what you want on a path executed billions of times a second across a busy machine.

Mental Model

Picture thread_info as a small index card stapled to the front of each thread, holding two rows of checkboxes. The flags row (TIF bits) is checked on every return to userspace — from syscalls, interrupts, and exceptions alike. The syscall_work row (SYSCALL_WORK bits) is checked only on syscall entry and syscall exit, and only governs the observability and confinement hooks (ptrace, seccomp, audit, tracepoints) that most threads never enable. Two rows, two masks, two fast paths.

flowchart TD
  subgraph TI["struct thread_info (one cache line)"]
    F["flags (TIF_* bits)<br/>SIGPENDING, NEED_RESCHED,<br/>NOTIFY_RESUME, NOTIFY_SIGNAL,<br/>UPROBE, PATCH_PENDING..."]
    SW["syscall_work (SYSCALL_WORK_* bits)<br/>SECCOMP, SYSCALL_TRACE,<br/>SYSCALL_AUDIT, TRACEPOINT,<br/>SYSCALL_EMU, USER_DISPATCH..."]
  end
  ENTRY["syscall entry"] --> ME{"work & SYSCALL_WORK_ENTER ?"}
  ME -->|"== 0 (common)"| DISPATCH["dispatch syscall directly"]
  ME -->|"!= 0"| TRACE_E["syscall_trace_enter():<br/>user-dispatch, ptrace,<br/>seccomp, tracepoint, audit"]
  TRACE_E --> DISPATCH
  DISPATCH --> MX{"work & SYSCALL_WORK_EXIT ?"}
  MX -->|"== 0 (common)"| EXITUM
  MX -->|"!= 0"| TRACE_X["syscall_exit_work():<br/>audit, tracepoint,<br/>ptrace single-step"]
  TRACE_X --> EXITUM["exit_to_user_mode_prepare()"]
  EXITUM --> MF{"flags & EXIT_TO_USER_MODE_WORK ?"}
  MF -->|"== 0 (common)"| RET["return to ring 3"]
  MF -->|"!= 0"| LOOP["exit_to_user_mode_loop():<br/>resched, signals, task_work..."]
  LOOP --> RET
  F -.read by.-> MF
  SW -.read by.-> ME
  SW -.read by.-> MX

The two flag words and the three masked tests that gate the slow paths. What it shows: syscall_work is consulted twice (entry and exit) against SYSCALL_WORK_ENTER/SYSCALL_WORK_EXIT to decide whether to run tracing/seccomp/audit; flags is consulted once on the way out against EXIT_TO_USER_MODE_WORK to decide whether to run signals/reschedule/task-work. Each diamond is a single AND-and-branch. The insight to take: separating the rarely-set observability bits (syscall_work) from the more-frequently-set scheduling/signal bits (flags) lets each fast path test exactly the bits that matter to it, so enabling strace on one process never slows the no-work path of every other.

Why two words, not one — the historical split

Originally all of these were TIF_* bits in the single flags word, and the x86 syscall entry path tested a mask called _TIF_WORK_SYSCALL_ENTRY — defined in arch/x86/include/asm/thread_info.h and read in arch/x86/entry/common.c (both verified at the v5.4 tag, pre-generic-entry). That had a subtle cost: setting any syscall-tracing bit lived in the same word as TIF_NEED_RESCHED and TIF_SIGPENDING, so the bits were mixed and the masks overlapped awkwardly. The generic entry rework (CONFIG_GENERIC_ENTRY) moved the syscall-observability bits into a separate unsigned long syscall_work field of thread_info and gave them their own enum (per include/linux/thread_info.h @ v6.12):

#ifdef CONFIG_GENERIC_ENTRY
enum syscall_work_bit {
	SYSCALL_WORK_BIT_SECCOMP,
	SYSCALL_WORK_BIT_SYSCALL_TRACEPOINT,
	SYSCALL_WORK_BIT_SYSCALL_TRACE,
	SYSCALL_WORK_BIT_SYSCALL_EMU,
	SYSCALL_WORK_BIT_SYSCALL_AUDIT,
	SYSCALL_WORK_BIT_SYSCALL_USER_DISPATCH,
	SYSCALL_WORK_BIT_SYSCALL_EXIT_TRAP,
};
#define SYSCALL_WORK_SECCOMP		BIT(SYSCALL_WORK_BIT_SECCOMP)
/* ...one BIT() macro per bit... */
#endif

The thread_info struct on x86 (v6.12) is therefore:

struct thread_info {
	unsigned long	flags;		/* low level flags (TIF_*) */
	unsigned long	syscall_work;	/* SYSCALL_WORK_ flags */
	u32		status;		/* thread synchronous flags */
#ifdef CONFIG_SMP
	u32		cpu;		/* current CPU */
#endif
};

Note the comment in the source that this struct “should fit entirely inside of one cache line” and “shares the supervisor stack pages” — it sits where the entry assembly can reach it with a single load, no pointer chase. (Under CONFIG_THREAD_INFO_IN_TASK, current_thread_info() is just a cast of current, the per-CPU task_struct pointer.)

The third member, status, is a thread-synchronous word — “nobody else ever touches our thread-synchronous status, so we don’t have to worry about atomic accesses,” per the source comment. Its main flag is TS_COMPAT (0x0002), set when a 32-bit syscall is active so the kernel knows to use the compat ABI; see The Compat Syscall Layer for 32-bit Binaries. Because flags can be set by other threads/CPUs (a remote kill sets TIF_SIGPENDING; the scheduler sets TIF_NEED_RESCHED from a timer IRQ), it must be accessed with atomic bit ops, whereas status need not.

The TIF bits and the EXIT_TO_USER_MODE_WORK mask

On x86 (v6.12) the relevant flags bits and their numeric positions are defined in arch/x86/include/asm/thread_info.h:

  • TIF_NOTIFY_RESUME (bit 1) — a generic “run resume_user_mode_work() before returning” callback; carries task_work (e.g. fput deferral, io_uring completions, keyring updates).
  • TIF_SIGPENDING (bit 2) — a signal is queued for this thread; triggers arch_do_signal_or_restart().
  • TIF_NEED_RESCHED (bit 3) — the scheduler wants this thread to yield; triggers schedule(). See System Calls and the Scheduler.
  • TIF_NOTIFY_SIGNAL (bit 17) — a “signal-like” notification (e.g. task_work that wants signal-style wakeup semantics); also routed to arch_do_signal_or_restart().
  • TIF_UPROBE (bit 12) — a userspace probe needs servicing; triggers uprobe_notify_resume().
  • TIF_PATCH_PENDING (bit 13) — a live-patch transition is in flight; triggers klp_update_patch_state().

Each TIF_x integer has a paired _TIF_x bit-mask macro (#define _TIF_SIGPENDING (1 << TIF_SIGPENDING)). The exit-work loop’s gate mask combines exactly the ones it knows how to service (include/linux/entry-common.h @ v6.12):

#define EXIT_TO_USER_MODE_WORK						\
	(_TIF_SIGPENDING | _TIF_NOTIFY_RESUME | _TIF_UPROBE |		\
	 _TIF_NEED_RESCHED | _TIF_PATCH_PENDING | _TIF_NOTIFY_SIGNAL |	\
	 ARCH_EXIT_TO_USER_MODE_WORK)

The actual gate, in exit_to_user_mode_prepare(), is one masked test:

ti_work = read_thread_flags();
if (unlikely(ti_work & EXIT_TO_USER_MODE_WORK))
	ti_work = exit_to_user_mode_loop(regs, ti_work);

read_thread_flags() is READ_ONCE(current_thread_info()->flags) — a single relaxed load. The unlikely() marks the no-work branch as hot. Many TIF bits (e.g. TIF_NEED_FPU_LOAD, TIF_IO_BITMAP, the TIF_SPEC_* family) are not in EXIT_TO_USER_MODE_WORK because they are handled elsewhere — for example _TIF_WORK_CTXSW is the context-switch mask checked in __switch_to(), a different consumer of the same flags word. The same word feeds multiple fast paths, each with its own mask. See Returning to Userspace and exit_to_user_mode for how the loop drains these.

The syscall_work bits and the ENTER/EXIT masks

The syscall_work word gates the observability/confinement hooks. The two precomputed masks (v6.12) are:

#define SYSCALL_WORK_ENTER	(SYSCALL_WORK_SECCOMP |			\
				 SYSCALL_WORK_SYSCALL_TRACEPOINT |	\
				 SYSCALL_WORK_SYSCALL_TRACE |		\
				 SYSCALL_WORK_SYSCALL_EMU |		\
				 SYSCALL_WORK_SYSCALL_AUDIT |		\
				 SYSCALL_WORK_SYSCALL_USER_DISPATCH |	\
				 ARCH_SYSCALL_WORK_ENTER)
#define SYSCALL_WORK_EXIT	(SYSCALL_WORK_SYSCALL_TRACEPOINT |	\
				 SYSCALL_WORK_SYSCALL_TRACE |		\
				 SYSCALL_WORK_SYSCALL_AUDIT |		\
				 SYSCALL_WORK_SYSCALL_USER_DISPATCH |	\
				 SYSCALL_WORK_SYSCALL_EXIT_TRAP	|	\
				 ARCH_SYSCALL_WORK_EXIT)

On the entry side, syscall_enter_from_user_mode_work() does:

unsigned long work = READ_ONCE(current_thread_info()->syscall_work);
if (work & SYSCALL_WORK_ENTER)
	syscall = syscall_trace_enter(regs, syscall, work);

If work & SYSCALL_WORK_ENTER is zero — the case for almost every process, since nothing is traced, audited, or seccomp-confined — the syscall is dispatched immediately, no detour. If non-zero, syscall_trace_enter() runs the hooks in a deliberate order (per kernel/entry/common.c @ v6.12): Syscall User Dispatch first (its ABI is unusual, so it must pre-empt the others — see Syscall User Dispatch), then ptrace (ptrace_report_syscall_entry()), then seccomp (“after ptrace, to catch any tracer changes” — see seccomp and Syscall Filtering), then the sys_enter tracepoint (see Syscall Tracepoints sys_enter and sys_exit), then audit. Each step re-reads the syscall number via syscall_get_nr() because ptrace, seccomp, or a BPF tracepoint hook may have changed it.

On the exit side, syscall_exit_to_user_mode_prepare() mirrors this with if (unlikely(work & SYSCALL_WORK_EXIT)) syscall_exit_work(regs, work), running audit-exit, the sys_exit tracepoint, and ptrace single-step reporting. The bits are set per-thread by set_syscall_work(fl) (which does set_bit(SYSCALL_WORK_BIT_##fl, ...)); for example installing a seccomp filter sets SYSCALL_WORK_SECCOMP, and PTRACE_SYSCALL sets SYSCALL_WORK_SYSCALL_TRACE.

6.18 — the generic TIF header and renumbered bits

Pinned to 6.18 LTS (2025-11-30), the TIF layout was substantially refactored. The common, architecture-independent flags moved into a new shared header include/asm-generic/thread_info_tif.h, with x86 opting in via feature macros:

/* arch/x86/include/asm/thread_info.h @ v6.18 */
#define HAVE_TIF_NEED_RESCHED_LAZY
#define HAVE_TIF_POLLING_NRFLAG
#define HAVE_TIF_SINGLESTEP
#include <asm-generic/thread_info_tif.h>
/* Architecture specific TIF space starts at 16 */
#define TIF_SSBD		16
/* ... */

In the generic header the common bits are renumbered into a fixed low range (bits 0–10): TIF_NOTIFY_RESUME is bit 0, TIF_SIGPENDING bit 1, TIF_NOTIFY_SIGNAL bit 2, TIF_MEMDIE bit 3, TIF_NEED_RESCHED bit 4, the new TIF_NEED_RESCHED_LAZY bit 5 (gated on HAVE_TIF_NEED_RESCHED_LAZY), TIF_POLLING_NRFLAG bit 6, TIF_USER_RETURN_NOTIFY bit 7, TIF_UPROBE bit 8, TIF_PATCH_PENDING bit 9, and TIF_RESTORE_SIGMASK bit 10. Architecture-private flags (the TIF_SPEC_*, TIF_IO_BITMAP, TIF_SINGLESTEP, etc.) start at bit 16.

The practical upshots: (1) the numeric positions of the common TIF flags changed between 6.12 and 6.18 — code that hardcodes a bit number is wrong, which is exactly why the kernel always uses the TIF_*/_TIF_* symbols; (2) TIF_NEED_RESCHED_LAZY is now also drained in exit_to_user_mode_loop() (the resched check became _TIF_NEED_RESCHED | _TIF_NEED_RESCHED_LAZY); and (3) the syscall-side functions moved to kernel/entry/syscall-common.c, though the syscall_work bits and the SYSCALL_WORK_* enum are unchanged.

x86 does not fold any extra bits into these masks: arch/x86/include/asm/entry-common.h (v6.12) defines none of ARCH_SYSCALL_WORK_ENTER, ARCH_SYSCALL_WORK_EXIT, or ARCH_EXIT_TO_USER_MODE_WORK, so each falls back to its default (0) in include/linux/entry-common.h. The generic SYSCALL_WORK_ENTER/SYSCALL_WORK_EXIT/EXIT_TO_USER_MODE_WORK masks shown above are therefore the complete set tested on x86-64 (verified against arch/x86/include/asm/entry-common.h @ v6.12).

Failure Modes and Common Misunderstandings

TIF_NEED_RESCHED means the kernel is preempted right now.” No. Setting the flag is a request; it is honoured only at the next safe point — on return to userspace via this exit path, or, for in-kernel preemption, at irqentry_exit_cond_resched()/preempt_enable(). A CONFIG_PREEMPT_NONE kernel ignores it in kernel context entirely and only acts on it at the userspace boundary. This is why a tight userspace loop is preemptible (each timer tick forces an entry+exit that drains the flag) but a tight kernel loop in a non-preemptible build is not.

“Adding a tracer to one process slows everything down.” It does not, by design. set_syscall_work() sets bits in that thread’s syscall_work word only; every other thread’s word stays zero, so their work & SYSCALL_WORK_ENTER test is still a zero-result fast path. The cost of strace/seccomp is local to the traced/confined thread. This per-thread granularity is the entire point of putting the bits in thread_info.

Confusing flags with syscall_work. A signal pending (TIF_SIGPENDING, in flags) is handled on every return to userspace, including from interrupts; a seccomp filter (SYSCALL_WORK_SECCOMP, in syscall_work) is consulted only on syscall entry. Putting seccomp in syscall_work is what keeps an interrupt return from needlessly testing for it.

Reading a flag word non-atomically. Because flags can be modified by a remote CPU at any instant, it must be read with READ_ONCE()/read_thread_flags() and modified with atomic bit ops (set_bit/test_and_clear_bit). A plain read can race; the kernel’s read_ti_thread_flags() is __always_inline and uses READ_ONCE precisely so the compiler cannot tear or hoist the load out of noinstr exit code.

Alternatives and Architectural Variation

Before CONFIG_GENERIC_ENTRY, every architecture defined its own _TIF_WORK_MASK/_TIF_ALLWORK_MASK and open-coded the flag tests in entry assembly — a recurring source of bugs when a new flag was added to one arch’s mask but forgotten in another’s. The generic layer’s EXIT_TO_USER_MODE_WORK and SYSCALL_WORK_ENTER/SYSCALL_WORK_EXIT are the single, shared definitions that every converted architecture (x86, arm64, riscv, s390, …) now uses, with ARCH_* hooks for the few genuinely arch-specific bits. arm64, for instance, keeps _TIF_* flags in its own thread_info.h but feeds the same generic masks. See Per-Architecture Syscall Entry Assembly and The Generic Syscall Entry and Exit Layer.

Production Notes

The thread_info flags are the substrate beneath several high-traffic features. io_uring and asynchronous fput/file close lean on TIF_NOTIFY_RESUME + task_work to run completions at the userspace boundary rather than in IRQ context; live patching (kpatch/klp) uses TIF_PATCH_PENDING to transition each thread lazily as it crosses the boundary; and the lazy-preemption work that shipped in the 6.x series added TIF_NEED_RESCHED_LAZY to let the scheduler distinguish “reschedule eventually” from “reschedule now” — all keyed off this same flag-draining machinery. When profiling, a thread that is unexpectedly slow on syscall entry/exit and shows syscall_trace_enter/syscall_exit_work hot almost always has a syscall_work bit set — i.e. it is being traced, audited, or seccomp-filtered; checking /proc/<pid>/status for Seccomp: and looking for an attached tracer usually explains it.

Because the masks are compile-time constants and the tests are single instructions, the cost model is simple and worth internalising: a syscall with no pending work and no observability hooks pays only the trap, the dispatch, and two masked branches; everything else is opt-in and per-thread. That predictability is what lets the kernel keep the boundary cheap for the 99% while still supporting the full menu of tracing, confinement, and deferred-work features for the 1%.

See Also