The Runtime Theory
KernelInternalsscheduling

What happens when a signal arrives?

A step-by-step walk from raise() and the pending bitmap, through the return-to-user delivery point, sigframe setup, handler execution, and sigreturn restore.

The Runtime Theory Team3 min read07 steps

layer stack

Kernel

HWHardware
KKernel
RTRuntime
APPApplication
SYSSystem
CLIClient
NETNetwork
TLSCrypto
SRVServer

adjacent altitudes in this subsystem are still being traced

trace spine

  1. 01 raise() or kill(): signal queued
  2. 02 Pending bit set in task_struct
  3. 03 Delivery point: return to user mode
  4. 04 get_signal: handler lookup
  5. 05 Sigframe built on the user stack
  6. 06 Handler runs in user space
  7. 07 sigreturn restores the interrupted state
On this page

Signals are the Unix interrupt system for processes: asynchronous events delivered to code that never asked for them. The subtle part is when they can be delivered. The kernel refuses to run your handler mid-syscall or mid-spinlock — so delivery is deferred to specific, safe moments, and the entire mechanism is built around that rule.

Step 1 — generation

c
raise(SIGUSR1);            /* to yourself */
kill(pid, SIGUSR1);        /* to another process */

Both become the kill() syscall (raise is a libc wrapper). The kernel's send_signal finds the target task, allocates a struct sigqueue entry (or reuses the preallocated one), and appends it to the task's signal queue.

Step 2 — the pending bitmap

For non-queued signals, what matters is the bitmap: pending.signal in the task struct — one bit per signal (64 signals on Linux, with SIGRTMIN+ having real queues). If a SIGUSR1 is already pending, another is coalesced: the bit is already set, so nothing more happens (this is why two rapid SIGCHLDs can mean one handler call). The kernel also checks whether the signal is blocked by the task's signal mask (sigprocmask/pthread_sigmask): blocked signals stay pending — bit set, no delivery — until unblocked. Then, if the task is interruptible-sleeping and this signal can wake it (TASK_INTERRUPTIBLE wait, e.g. sleep()), the task is woken. SIGKILL/SIGSTOP can't be blocked, caught, or ignored — their bits always deliver.

Step 3 — the delivery point

Here's the rule: a handler never runs while the task is in the kernel. Delivery is checked at the return-to-user-mode boundary: syscall_exit_to_user_mode() (after any syscall), the iret path after an interrupt, or returning from schedule(). The kernel sets TIF_SIGNAL_PENDING; on the way out it calls get_signal(). Consequences:

  • A CPU-bound loop that never makes a syscall only gets its signal when preempted (timer tick) — up to 4ms late.
  • A tight syscall loop gets signals essentially immediately — every syscall exit is a delivery point.

Step 4 — handler lookup

get_signal() checks the pending bitmap against the sighand->action[] table (set by sigaction). Three outcomes: SIG_IGN → drop; SIG_DFL → kernel handles it (SIGSEGV: core dump or die; SIGCHLD: reap bookkeeping; SIGSTOP: task state change); custom handler → the dance begins.

Step 5 — building the sigframe

The kernel pushes a signal frame onto the process's user stack: the full register state, the signal number, the ucontext (including the signal mask). Then it rewrites the user registers so that on return the CPU executes: (1) your handler function, (2) with the signal masked out during execution (SA_NODEFER re-adds it), (3) with a return address pointing at the kernel's restorer (on modern glibc, a rt_sigreturn syscall stub). The interrupted instruction's RIP is saved in the frame — the handler is a detour, not a destination.

Step 6 — the handler runs

Now the CPU is in user mode executing arbitrary C code, interrupted mid-anything — this is why handlers must be async-signal-safe (only write, _exit, sigaction etc., never malloc/printf — the interrupted code may hold the malloc lock). Stack overflow check: the kernel reserves SIGSTKSZ-ish guard (the alternate signal stack, sigaltstack, exists exactly because the main stack might be exhausted).

Step 7 — sigreturn

The handler returns → the restorer executes rt_sigreturn → the kernel verifies the frame (magic cookies against stack corruption — this is where some exploits die), restores the saved registers and signal mask, and resumes at the exact instruction that was interrupted. From the process's perspective: a syscall or instruction ran, and code mysteriously ran in between.

What it costs

  • raise(SIGUSR1) round trip (same process): ~1-5µs — a syscall in, a syscall out, a frame build.
  • Delivery latency on a syscall-looping thread: ~2-5µs after generation.
  • Delivery latency on a sleeping task: wake + delivery, ~10-50µs.
  • Per-delivery memory: one sigframe (~1-2KB) + sigqueue entry.
bash
$ strace -e trace=rt_sigreturn,rt_sigaction,kill ./sigtest
	rt_sigaction(SIGUSR1, {sa_handler=0x401166, ...}, NULL, 8) = 0
	kill(3861, SIGUSR1)              = 0
	rt_sigreturn(0x7ffc...)          = 0

Two syscalls to generate, one to return — and a lot of frame juggling in between. Signals are the cheapest general-purpose async notification Linux has (cheaper than threads for simple cases) — just remember the delivery latency is bounded by how often the target returns to user mode.