Signals in XNU: where POSIX semantics meet Mach reality
POSIX says signals are per-process. Mach says everything is a thread. Here's how XNU bridges the two — pending masks, delivery threads, the AST mechanism, and exception ports.
A signal in POSIX is conceptually simple: "send SIGTERM to PID 1234". On any Unix it boils down to setting a bit in the target process and getting it noticed at a convenient moment. Implementing it on XNU is the harder Unix problem — because BSD says "process" and Mach says "thread", and they need to agree on what just happened.
This is how XNU does it.
The two-level pending mask
A struct proc carries a process-wide pending signals mask (p_siglist). Every thread in the process additionally carries a per-thread pending mask (uth_siglist) and a per-thread blocked mask (uth_sigmask).
apple-oss-distributions/xnubsd/kern/kern_sig.cSignal generation, masking, delivery — the entire POSIX surface lives here.View on GitHub(line —) apple-oss-distributions/xnubsd/sys/proc_internal.hstruct proc / struct uthread — where the masks are stored.View on GitHub(line —)
POSIX 2008 (which macOS aims for) specifies that a signal sent to a process can be handled by any thread in the process that isn't blocking it. So when kill(pid, sig) arrives:
- The kernel sets the bit in
proc->p_siglist. - It picks a thread to deliver to: the first one whose blocked mask doesn't include this signal.
- It sets the bit in that thread's
uth_siglistand arranges for it to notice on its next return to userspace.
Thread-directed signals (pthread_kill(thread_id, sig)) skip step 2 — they go straight to a named thread's mask.
How "notice on next return to userspace" works
XNU uses the AST mechanism — Asynchronous System Trap. Each thread has an AST mask in its kernel stack frame. When the kernel enters or exits a syscall, returns from an interrupt, or makes a context switch, it checks the AST mask. If the AST_BSD bit is set, BSD-side code runs to check for pending signals on that thread and, if any, build the signal delivery frame.
This is why a stopped-in-syscall thread takes a signal "instantly" but a tight CPU-bound loop in userspace doesn't until the next preemption. The kernel needs to be at an AST checkpoint to deliver, and a thread that doesn't trap into the kernel won't visit one. The scheduler tick eventually preempts it and the AST check runs on the way back, so worst-case latency is one quantum (a few ms).
Building the delivery frame
Signal delivery isn't a call from kernelspace — the kernel can't call userspace functions directly. Instead, the kernel modifies the thread's user-mode CPU state so that on return to userspace, the thread starts executing the signal handler with a synthetic stack frame:
- Push the current user-mode register state onto the user stack (so
sigreturncan restore it). - Push a sigcontext describing the signal and any siginfo data.
- Set the user PC to point at the signal trampoline (a small assembly stub in libc).
- Set the user SP to a fresh frame.
- Return from kernel.
The thread resumes in libc's trampoline, which calls the user's SA_SIGINFO handler, then invokes sigreturn(2) to ask the kernel to restore the original state.
apple-oss-distributions/xnubsd/dev/arm/unix_signal.cARM signal delivery — the per-arch code that builds the user-mode frame.View on GitHub(line —) apple-oss-distributions/xnubsd/dev/i386/unix_signal.cx86_64 signal delivery.View on GitHub(line —)
The trampoline lives in libc, not in the kernel — Apple Silicon adds an extra wrinkle here, because the kernel can't write executable instructions into user pages on every signal. The trampoline is pre-mapped at process start and the kernel just points the PC at it.
Exception ports — Mach's version of the same idea
While BSD signals are how Unix expects to be told about faults, Mach has its own mechanism: exception ports. Every Mach task (and thread) has a set of ports the kernel sends exception messages to when something goes wrong — segfault, illegal instruction, breakpoint, etc.
apple-oss-distributions/xnuosfmk/kern/exception.cException delivery — same primitive (Mach IPC), different message id.View on GitHub(line —)The delivery order:
- The fault happens (e.g., the CPU traps on a bad load).
- The kernel sends an exception message to the thread's exception port.
- If no one's listening, it falls back to the task's exception port.
- If still no one, it falls back to the host's exception port (debugger).
- If still no one, the BSD layer is asked to translate the exception into a POSIX signal (
SIGSEGV,SIGILL, …) and deliver through the signal machinery.
That fall-through chain is why a debugger can catch a SIGSEGV "before" it kills the process — the debugger holds the task exception port and replies "I handled it, continue" before BSD ever sees the fault.
apple-oss-distributions/xnubsd/uxkern/ux_exception.cux_exception — the bridge from Mach exception ports to POSIX signals.View on GitHub(line —)Signal-safety in libc
POSIX defines a tiny list of async-signal-safe functions — the ones you can call from inside a signal handler without risking deadlock. The list is shorter than people remember: _exit, signal, write, kill, raise, sigprocmask, the syscall direct wrappers, and a handful of others. Crucially, printf is not safe (it takes locks), neither is malloc (same), and almost nothing in Foundation is.
The reason is the BSD/Mach interaction. A signal can be delivered to a thread that's holding a libc lock. If the handler tries to take the same lock, it deadlocks. There's no way for the kernel to know which thread is currently holding which lock, so it can't be helpful here — the rule is on you.
apple-oss-distributions/Libcgen/raise.craise(3) — about as small a syscall wrapper as exists.View on GitHub(line —)Why signals are harder than they look on XNU
The interactions get gnarly:
- A thread in
mach_msgwaiting on a port is interruptible by signals. The kernel has to wake it up, fail themach_msgwithMACH_RCV_INTERRUPTED, run the AST, deliver the signal, and then libc's wrapper has to decide whether to retry or propagateEINTR. - Real-time threads can have signals deferred. If the scheduler honored a signal during a real-time quantum it could miss its deadline. XNU's signal delivery for
THREAD_TIME_CONSTRAINT_POLICYthreads is best-effort, deferred to the next non-critical AST. - Forked children inherit pending signals, but the threading state is reset (one thread post-fork). All pending signals fold into that single thread's mask.
forkis not async-signal-safe on modern macOS — Apple has been deprecating it in favor ofposix_spawn. The interaction between fork and libdispatch was the source of years of subtle bugs.
What to read next
For Mach's side of the story:
apple-oss-distributions/xnuosfmk/kern/exception.hThe exception types Mach knows about — the bottom of the chain.View on GitHub(line —) apple-oss-distributions/xnuosfmk/kern/syscall_emulation.cSyscall interruption — how a blocked syscall becomes EINTR.View on GitHub(line —)
And read the Mach IPC article again, this time noticing that exception delivery is just a mach_msg with a special message ID. Signals are POSIX paint on a Mach surface.