Async-Signal-Safety and Reentrant Handlers
A signal handler is a function the kernel makes your thread run out of nowhere — at almost any instruction boundary in your normal flow — and then resume whatever it interrupted. This is the single most dangerous corner of signal programming, because the handler shares the interrupted code’s entire process state: the same heap, the same
stdiobuffers, the same global locks. If the main flow is halfway throughmalloc()updating the heap’s free lists when a signal fires, and the handler also callsmalloc(), the second call sees a half-mutated heap and corrupts it; if the main flow holds a lock the handler then tries to take, the thread deadlocks against itself, because the only frame that could release the lock is suspended underneath the handler and cannot run until the handler returns. Both halves are reproduced further down, and the measured results are more interesting than the folklore: on glibc 2.43 a handler callingprintfdoes not hang (theFILElock is owner-recursive) — it silently corrupts about one in three of the lines it touches — while a handler taking an ordinarypthread_mutex_theld by the frame below wedges instantly and permanently. The defense is async-signal-safety: POSIX specifies an exact, finite list of library functions that are guaranteed safe to call inside a handler, and nothing else may be called. A function is async-signal-safe “either because it is reentrant or because it is atomic with respect to signals” (signal-safety(7)). The safe idioms that follow — set avolatile sig_atomic_tflag and return; the self-pipe trick; or use signalfd to leave handler context entirely — all exist to keep a handler’s body inside that safe envelope.
This note owns the safety contract of signal-handler code: why a handler can interrupt at any point, what “async-signal-safe” means precisely, the POSIX-guaranteed safe list as a reference table, why each unsafe class is unsafe at the level of the actual glibc implementation, the errno-save rule, and the handful of safe idioms that real programs use. The registration of handlers (struct sigaction, the SA_* flag table, sigaltstack) lives in sigaction and Signal Handlers; the delivery mechanism (how the kernel injects the handler frame on the return-to-userspace path) lives in Signal Delivery and the Return to Userspace; the restart-versus-EINTR behaviour that a flag-setting handler forces on the main loop lives in Restartable System Calls and ERESTARTSYS; fd-based delivery lives in signalfd. Read those for the layers around this one.
Measurement environment
The reproductions in this note were run on Linux 7.1.13-200.fc44.x86_64 (Fedora 44), glibc 2.43, GCC 16.2.1, x86-64, on 2026-09-12. The safe-function list is quoted from man-pages 6.19 (dated 2026-06-05, fetched 2026-09-12); library-internals claims are read from the glibc 2.43 release tag; kernel claims are pinned to Linux v6.12. One of the reproductions below is a negative result, reported as such.
Mental Model
The way to think about a signal handler is as a second thread of control that shares everything with the first but can preempt it at an arbitrary instruction — yet, crucially, the handler is not a separate thread. It runs on the same kernel thread that was interrupted, so any lock the interrupted code holds is held by the very stack frame underneath the handler. A normal thread blocked on a contended lock would simply wait for the holder to release it; a signal handler that blocks on such a lock waits forever, because the holder is suspended below the handler on the same stack and cannot make progress until the handler returns. That is the difference between ordinary thread-safety and async-signal-safety, and it is why the safe list is so much shorter than the thread-safe list.
sequenceDiagram autonumber participant Main as Main flow<br/>(one kernel thread) participant Kernel participant L as Lock L<br/>(plain, non-recursive) participant Handler as Signal handler<br/>(same thread, frame on top) Main->>L: acquire L — owner is now this thread Note over Main: inside the critical section,<br/>invariants temporarily broken Kernel-->>Main: SIGUSR1 deliverable at the next<br/>kernel-to-user transition Kernel->>Handler: build frame on this thread's stack, run handler Note over Main,Handler: Main's frame is now FROZEN beneath Handler.<br/>It cannot run again until Handler returns. Handler->>L: acquire L L--xHandler: already held — block on the futex Note over Handler: the only frame that could release L<br/>is the one waiting for this handler to finish Handler--xMain: never returns — thread wedged forever
A self-deadlock from a non-async-signal-safe handler. What it shows: the handler and the interrupted critical section run on the same kernel thread, so L is not “held by another thread that will release it” — it is held by a frame frozen beneath the handler, and the dependency is a cycle of length one. The insight to take: async-signal-safety is not the same problem as thread-safety; a function made thread-safe by taking a lock is, for that exact reason, unsafe in a handler. The two properties are opposed design choices, not points on one scale. This diagram is reproduced verbatim as a running program further down (pthread_mutex_lock from a handler, watchdog-bounded), where it wedges every time — but note that glibc’s stdio lock deliberately breaks this cycle with an owner check, trading the deadlock for corruption instead.
Why a Handler Can Interrupt at Almost Any Point
The asynchrony is structural, not incidental. Per signal(7), “Whenever there is a transition from kernel-mode to user-mode execution (e.g., on return from a system call or scheduling of a thread onto the CPU), the kernel checks whether there is a pending unblocked signal for which the process has established a signal handler.” Those transitions happen constantly — every system call return, every timer-interrupt-driven reschedule, every page fault resolution. From the program’s point of view, a signal can therefore be delivered between essentially any two machine instructions of normal code. The kernel “constructs a frame for the signal handler on the stack,” sets the program counter “to the first instruction of the signal handler function,” and arranges the return address to point at a signal trampoline that eventually calls sigreturn() to restore the pre-handler register state (signal(7)).
The consequence that matters here: the man page notes that “the kernel does not record any special state information indicating that the thread is currently executing inside a signal handler” — the handler is just ordinary user-space code running in the existing context. There is no privileged “handler mode” that magically makes the heap consistent. If the interrupted instruction was the third of five that malloc() needs to splice a chunk onto a free list, the data structure is observably broken for the entire duration of the handler. Any function the handler calls that reads or writes that same structure — directly or transitively — operates on corrupted state.
This is precisely the scenario the signal-safety(7) page spells out with stdio: “When performing buffered I/O on a file, the stdio functions must maintain a statically allocated data buffer along with associated counters and indexes (or pointers) that record the amount of data and the current position in the buffer.” Then: “Suppose that the main program is in the middle of a call to a stdio function such as printf(3) where the buffer and associated variables have been partially updated. If, at that moment, the program is interrupted by a signal handler that also calls printf(3), then the second call to printf(3) will operate on inconsistent data, with unpredictable results.” The conclusion is blunt: all of stdio is “not async-signal-safe.”
Drawn out, with the byte counts from the measured reproduction later in this note:
sequenceDiagram autonumber participant M as Main flow participant B as FILE buffer<br/>(base, current pos, fill count) participant H as Handler M->>B: printf("MMMM...") — copy 28 of 49 bytes,<br/>pos advanced, count not yet committed Note over B: INVARIANT BROKEN<br/>pos and count disagree for a few instructions H-->>M: signal lands right here H->>B: printf("HHHH...") — appends from the<br/>inconsistent pos, then flushes Note over B: the 28-byte fragment is emitted as a line,<br/>then the rest of M's line is concatenated with H's H->>M: handler returns M->>B: resume the interrupted printf from a pos<br/>that has since moved
The stdio reentrancy hazard at byte granularity. What it shows: the window of broken invariant is only a handful of instructions wide, but it is entered on every printf, so at a high enough signal rate it is hit constantly — measured below at roughly one handler entry in three. The two corrupted line lengths in the measurement (28 and 77 characters, against a correct length of 48) are exactly the two halves this diagram predicts. The insight to take: there is no lock being contended here and no error being returned. The failure is invisible at the call site and shows up only as wrong bytes in a file, which is why this class of bug survives code review and soak testing alike.
What “Async-Signal-Safe” Means
Per signal-safety(7), “An async-signal-safe function is one that can be safely called from within a signal handler. Many functions are not async-signal-safe. In particular, nonreentrant functions are generally unsafe to call from a signal handler.” The page gives the two distinct routes by which a function earns the label: “a function is async-signal-safe either because it is reentrant or because it is atomic with respect to signals (i.e., its execution can’t be interrupted by a signal handler).”
These two routes are worth separating because they fail differently:
- Reentrant means the function holds no static/global state and takes no locks, so a fresh call can run correctly even while a prior call to the same function is suspended mid-flight on the same thread.
strlen()reading a caller-supplied buffer is reentrant;printf()mutating a shared buffer is not. Note the asymmetry with thread-safety: a function made thread-safe by taking a lock is decidedly not reentrant, because a handler that re-enters it self-deadlocks on that very lock. Thread-safety via locking and async-signal-safety are, in this sense, opposed design choices. - Atomic with respect to signals means the function is a thin wrapper over a single system call (or otherwise executes as an indivisible unit from the handler’s perspective).
write(2)is the canonical example: it traps directly into the kernel, the kernel does the work, and there is no userspace buffer to corrupt. The list of safe I/O functions —read,write,open,close,dup,fcntl, the socket calls — is exactly the set of direct syscall wrappers.
A subtle reentrancy hazard survives even within the safe list: errno. A handler that calls write() may clobber the global errno that the interrupted code was about to inspect. The man page addresses this directly: “Fetching and setting the value of errno is async-signal-safe provided that the signal handler saves errno on entry and restores its value before returning” (signal-safety(7)). So the iron rule for any handler that calls a syscall wrapper is: save errno first, restore it last.
The POSIX-Guaranteed Safe List
“POSIX.1 specifies a set of functions that an implementation must make async-signal-safe. (An implementation may provide safe implementations of additional functions, but this is not required by the standard and other implementations may not provide the same guarantees.)” That parenthesis is the operative half: POSIX defines the floor, and anything above it is a per-implementation promise you must verify per platform. The list below is the complete table from signal-safety(7) as shipped in man-pages 6.19 (page dated 2026-06-05, fetched 2026-09-12), regrouped by purpose rather than alphabetically so the shape of the list is visible. Entries unmarked in the man page were required safe in POSIX.1-2001; the edition column records the ones added later.
| Group | Functions | Edition notes |
|---|---|---|
| Unbuffered I/O on descriptors | read, write, open, openat, creat, close, dup, dup2, lseek, fcntl, fsync, fdatasync, ftruncate, pipe, poll, select, pselect | openat added in POSIX.1-2008 |
| Filesystem metadata and namespace | access, faccessat, chdir, fchdir, chmod, fchmod, fchmodat, chown, fchown, fchownat, stat, fstat, lstat, fstatat, link, linkat, unlink, unlinkat, rename, renameat, symlink, symlinkat, readlink, readlinkat, mkdir, mkdirat, rmdir, mkfifo, mkfifoat, mknod, mknodat, umask, utime, utimes, utimensat, futimens | the *at variants, mknod, utimes, futimens added in POSIX.1-2008; fchdir in POSIX.1-2008 TC1 |
| Sockets | socket, socketpair, bind, listen, accept, connect, shutdown, send, sendto, sendmsg, recv, recvfrom, recvmsg, getsockname, getpeername, getsockopt, setsockopt, sockatmark | sockatmark added in POSIX.1-2001 TC2 |
| Process control, credentials, exec | _exit, _Exit, _Fork, execl, execle, execv, execve, fexecve, wait, waitpid, getpid, getppid, getpgrp, setpgid, setsid, getuid, geteuid, getgid, getegid, getgroups, setuid, setgid, alarm, pause, sleep, uname | execl, execv, fexecve added in POSIX.1-2008; see the glibc deviation below for execl/execle |
| Signals | kill, raise, signal, sigset, sigaction, sigprocmask, pthread_sigmask, pthread_kill, pthread_self, sigaddset, sigdelset, sigemptyset, sigfillset, sigismember, sigpending, sigsuspend, sigqueue, sigpause, abort, sem_post, longjmp, siglongjmp | abort in POSIX.1-2001 TC1; pthread_kill/pthread_self/pthread_sigmask in POSIX.1-2008 TC1; longjmp/siglongjmp in POSIX.1-2008 TC2, with a caveat below |
| Time | clock_gettime, time, times, timer_gettime, timer_settime, timer_getoverrun | |
Terminal control (termios) | cfgetispeed, cfgetospeed, cfsetispeed, cfsetospeed, tcgetattr, tcsetattr, tcdrain, tcflow, tcflush, tcgetpgrp, tcsetpgrp, tcsendbreak | |
| Asynchronous I/O | aio_error, aio_return, aio_suspend | aio_suspend is not safe in glibc — see deviations |
| Pure computation on caller buffers | memccpy, memchr, memcmp, memcpy, memmove, memset, stpcpy, stpncpy, strcat, strchr, strcmp, strcpy, strcspn, strlen, strncat, strncmp, strncpy, strnlen, strpbrk, strrchr, strspn, strstr, strtok_r, plus every wcs*/wmem*/wcp* equivalent | all added in POSIX.1-2008 TC2 |
| Small helpers | ffs, htonl, htons, ntohl, ntohs, posix_trace_event | ffs and the byte-order four added in POSIX.1-2008 TC2 |
That table settles the question the previous revision of this note flagged as uncertain: sleep(3), longjmp(3) and siglongjmp(3) are all on the current list, sleep unqualified and the two jump functions marked “Added in POSIX.1-2008 TC2”.
Three footnotes from the same page carry real weight. First, “POSIX.1-2001 and POSIX.1-2001 TC2 required the functions fpathconf(3), pathconf(3), and sysconf(3) to be async-signal-safe, but this requirement was removed in POSIX.1-2008” — so the list shrinks as well as grows, and code written against an old edition can be wrong today. Second, longjmp/siglongjmp are safe only in a narrow sense: “If a signal handler interrupts the execution of an unsafe function, and the handler terminates via a call to longjmp(3) or siglongjmp(3) and the program subsequently calls an unsafe function, then the behavior of the program is undefined.” Jumping out of a handler does not unwind the interrupted function’s half-finished state — it abandons it, permanently, so the classic “longjmp out of the SIGSEGV handler and carry on” recovery pattern is only sound if the program never touches an unsafe function again. Third, a handler that calls a cancellation point inside a region of deferred cancellation “may trigger cancelation whose behavior is as if asynchronous cancelation had occurred”, leaving application state inconsistent.
glibc’s own deviations
The man page names two, and both are load-bearing:
| Deviation | Status |
|---|---|
“Before glibc 2.24, execl(3) and execle(3) employed realloc(3) internally and were consequently not async-signal-safe.” | Fixed in glibc 2.24. Irrelevant on this host (glibc 2.43) but decisive for anything built against an older runtime. |
“The glibc implementation of aio_suspend(3) is not async-signal-safe because it uses pthread_mutex_lock(3) internally.” | Still documented as current at man-pages 6.19 / glibc 2.43. POSIX lists it as safe; glibc does not deliver on that. |
aio_suspend is the sharpest lesson here: it is on the standard’s list and is nonetheless unsafe in the implementation you are actually linking against. “POSIX says it is safe” is not sufficient evidence — the implementation’s own documentation is the deciding source.
The absences are the point
The list is deliberately closed — anything not on it is presumed unsafe — and the instinctive reaches are all missing. There are only four reasons a function ends up off the list, and knowing them means you can derive the answer for a function nobody has classified for you:
flowchart LR R["Why is a function<br/>NOT async-signal-safe?"] --> A["1. Static / global mutable state"] R --> B["2. It takes a lock"] R --> C["3. It allocates"] R --> D["4. It can block"] A --> A1["stdio FILE buffers<br/>strtok cursor<br/>localtime's struct tm<br/>strerror buffer<br/>inet_ntoa buffer<br/>environ array"] B --> B1["every pthread_mutex_*<br/>pthread_once<br/>syslog's connection state<br/>glibc aio_suspend"] C --> C1["malloc / free / realloc<br/>strdup / asprintf / getline<br/>opendir / regcomp / dlopen"] D --> D1["sem_wait<br/>any blocking library call<br/>a blocking write to a full pipe"] A1 --> F["Re-entering it observes<br/>a half-updated structure<br/>→ SILENT CORRUPTION"] C1 --> F B1 --> G["The holder is frozen<br/>beneath the handler<br/>→ SELF-DEADLOCK"] D1 --> H["The frame that would<br/>unblock you cannot run<br/>→ WEDGE"]
The four disqualifying properties, and the symptom each one produces. What it shows: allocation is really a special case of static state (the heap is one big mutable global), which is why it shares the corruption outcome; locking and blocking are the two that hang. The insight to take: the test for “may I call this from a handler?” is not “is it on the list?” but “does it touch state that is not exclusively mine, or can it wait for something?” — and if you cannot read the implementation to answer that, the answer is no. This also explains the one counter-intuitive entry on the safe list: sem_post qualifies and sem_wait does not, purely because one of them can block.
Grouping the absences by which of those four applies turns the list from something to memorise into something to derive:
| Absent function or family | Why it is unsafe | The safe substitute |
|---|---|---|
All of stdio: printf, fprintf, sprintf, snprintf, puts, fputs, fwrite, fread, fopen, fclose, fflush, perror, getchar | A shared FILE object with a buffer, a fill count, and a position index, mutated non-atomically. A handler that re-enters it observes a half-updated buffer. Measured below: roughly one handler entry in three corrupted the stream. | write(2) to a pre-opened descriptor, with hand-rolled formatting into a stack buffer |
malloc, free, calloc, realloc, posix_memalign, and everything that allocates underneath (strdup, asprintf, getline, opendir, regcomp, dlopen) | Free lists, bin arrays and per-arena chunk metadata are mutated across many instructions, so a re-entrant call sees the intermediate state. In a multi-threaded process the arena mutex is also plainly non-recursive. | Pre-allocate everything the handler needs before installing it; a handler should allocate nothing |
exit(3), and atexit/on_exit handlers | exit flushes stdio and runs registered handlers — arbitrary unsafe code, at the worst possible moment | _exit(2) / _Exit(3), which trap straight to the kernel |
syslog, openlog | Static connection state, plus internal locking, plus formatting | write(2) to a descriptor opened in advance |
getenv, setenv, putenv, unsetenv | environ is a globally mutated array, and setenv reallocates it | Snapshot the values you need at startup |
localtime, gmtime, ctime, asctime, strftime, mktime | A static struct tm; strftime also consults the locale and the timezone database, which it may load lazily | clock_gettime(2) and emit the raw numbers; convert later, outside the handler |
strerror | Historically a static buffer, and locale-dependent | Emit the raw errno integer and translate it in normal context |
strtok | A static parsing cursor carried across calls. Its _r twin is on the safe list — the suffix is exactly the distinction | strtok_r |
inet_ntoa, gethostbyname, getaddrinfo, getpwnam, getgrnam | Static return buffers, allocation, and in the resolver’s case network I/O plus NSS module loading | Resolve before installing the handler |
Nearly all of pthread_*: pthread_mutex_lock, pthread_cond_signal, pthread_create, pthread_join, pthread_once, pthread_getspecific | These are the locks. Taking a lock held by the interrupted frame beneath you is the textbook self-deadlock, reproduced deterministically below | Exactly three are safe — pthread_kill, pthread_self, pthread_sigmask. For cross-context signalling use sem_post, which is on the list precisely because it is implemented to be signal-safe |
sem_wait, sem_timedwait | They block, and blocking inside a handler stalls the very frame that would have posted the semaphore | sem_post only — the list includes the post side and excludes the wait side, deliberately |
fork(2) | Not on the list, though _Fork(3) is. fork() runs the pthread_atfork(3) handlers registered by the program and its libraries, which are arbitrary unsafe code | _Fork(3), which skips the atfork handlers, followed by an exec* from the safe list |
execvp, execlp, execvpe | They search PATH and may allocate, unlike the execv/execl family that is on the list | execve / fexecve with an absolute path resolved in advance |
The fork versus _Fork split deserves emphasis because it is the entry most often got wrong — including in the previous revision of this note, which listed fork as safe. fork(2) genuinely is not in signal-safety(7)‘s table and _Fork(3) is, and the difference is exactly the pthread_atfork callbacks. Those callbacks exist so libraries can re-initialize their locks around a fork, which makes them, by construction, code that touches locks. This matters concretely for the commonest “safe” handler pattern of all — forking a crash reporter from a SIGSEGV handler. See Signal Dispositions Inheritance Across fork and exec and fork clone and exec System Calls for what survives the boundary.
Uncertain
Verify: whether Linux-specific descriptor calls that are thin syscall wrappers —
pipe2,dup3,accept4,openat2,ppoll,epoll_wait,signalfd,eventfd_write,timerfd_settime,gettid— may be treated as async-signal-safe. Reason: none appears in signal-safety(7)‘s table, but that table is POSIX’s list and POSIX cannot list non-POSIX functions, so their absence is evidence of nothing either way. By the man page’s own criterion — “atomic with respect to signals”, i.e. a direct trap into the kernel — a wrapper that only marshals registers and issuessyscallqualifies; I did not disassemble glibc 2.43’s wrappers to confirm that none does extra work such as a first-call feature probe or a fallback path. To resolve: disassemble the specific wrapper at glibc 2.43, or check itsattributes(7)entry for anAS-Safemarking. Until then the conservative and always-correct choice for reporting from a handler iswrite(2), which is on the list explicitly. uncertain
The Safe Idioms
Because the safe list is so restrictive, robust programs almost never do real work inside a handler. They use one of three patterns to push the work back into the main flow, where the full library is available.
Idiom 1 — Set a flag and return
The minimal correct handler records that the signal happened and returns immediately, letting the main loop notice and react. The flag must be volatile sig_atomic_t: volatile so the compiler reloads it on every read in the main loop (it can change “spontaneously”), and sig_atomic_t because POSIX guarantees reads and writes of that type are atomic with respect to signal delivery — a half-written value can never be observed.
#include <signal.h>
#include <unistd.h>
static volatile sig_atomic_t got_sigterm = 0; /* the only shared state */
static void on_sigterm(int signo) {
int saved = errno; /* (1) save errno — handler must restore it */
got_sigterm = 1; /* (2) one atomic write, nothing else */
errno = saved; /* (3) restore errno before returning */
}
int main(void) {
struct sigaction sa = { .sa_handler = on_sigterm };
sigemptyset(&sa.sa_mask);
sigaction(SIGTERM, &sa, NULL);
while (!got_sigterm) { /* (4) main loop polls the flag */
do_work(); /* full libc available here */
}
cleanup_and_exit(); /* (5) react in normal context, safely */
}Line (1)/(3): the save/restore pair is technically unnecessary here because the body touches no syscall, but it is the habit to build — the moment the handler grows a write(), it becomes mandatory. Line (2): a single write to a sig_atomic_t is the entire safe payload. Line (4): the loop condition reloads got_sigterm each iteration because it is volatile — drop the qualifier and an optimizing compiler may hoist the read out of the loop and spin forever. The weakness of this idiom is latency and the race window: if the program is blocked in do_work()’s syscall rather than spinning the loop, the flag is set but not seen until the syscall returns (often via EINTR, see SA_RESTART and Signal-Interrupted Syscalls). The window is narrow but perfectly real, and it is the reason pselect/ppoll exist:
sequenceDiagram autonumber participant S as Sender participant H as Handler participant F as flag<br/>(volatile sig_atomic_t) participant M as Main loop M->>F: read flag — still 0 Note over M,F: RACE WINDOW OPENS HERE S->>H: signal delivered H->>F: flag = 1 H->>M: return M->>M: enter blocking call (read / epoll_wait) Note over M: the flag is already set, but the loop<br/>will not look at it again until the call returns alt handler had SA_RESTART M->>M: kernel silently restarts the call —<br/>could block for hours else no SA_RESTART M-->>M: call returns EINTR, loop re-checks flag end Note over M: pselect / ppoll close this window by swapping<br/>the signal mask atomically with entering the wait
The check-then-block race that defeats the flag idiom. What it shows: the signal arrives in the gap between reading the flag and entering the blocking call, so the flag is set and nobody is looking. The SA_RESTART branch is the cruel one — the flag the handler just set is correct, and the program still sleeps indefinitely. The insight to take: “set a flag and return” is only complete when paired with something that guarantees the main loop gets another look, which is either an EINTR return (so: no SA_RESTART on this handler) or an atomic mask-swap wait (pselect/ppoll) or a wakeup channel (the self-pipe, next). The measured nanosleep result later in this note shows that SA_RESTART is not even uniformly available as a knob.
Idiom 2 — The self-pipe trick
To make a signal wake a blocking multiplexer (select/poll/epoll), the handler writes one byte to the write-end of a pipe whose read-end is in the multiplexer’s watch set. Signal arrival thereby becomes an ordinary readable-fd event, and the event loop handles it synchronously between iterations.
static int self_pipe[2];
static void handler(int signo) {
int saved = errno;
char b = 0;
write(self_pipe[1], &b, 1); /* write() IS async-signal-safe; ignore short/EAGAIN */
errno = saved;
}
/* setup */
pipe(self_pipe);
fcntl(self_pipe[0], F_SETFL, O_NONBLOCK); /* read-end nonblocking */
fcntl(self_pipe[1], F_SETFL, O_NONBLOCK); /* write-end nonblocking — must NOT block in handler */
/* event loop: include self_pipe[0] in the epoll/select set;
when it is readable, drain all bytes and act on the signal. */sequenceDiagram autonumber participant K as Kernel participant H as Handler<br/>(async context) participant P as Pipe buffer<br/>(self_pipe[1] -> self_pipe[0]) participant L as Event loop<br/>(normal context) Note over L: blocked in epoll_wait() watching<br/>self_pipe[0] plus every real socket K->>H: deliver SIGTERM, build frame, run handler H->>H: saved = errno H->>P: write(self_pipe[1], &b, 1) — nonblocking alt pipe buffer full P-->>H: -1 / EAGAIN — dropped on purpose Note over H: harmless, one readable event already<br/>means "at least one signal arrived" end H->>H: errno = saved H->>K: return, rt_sigreturn restores the mask K-->>L: self_pipe[0] becomes readable L->>P: read() in a loop until EAGAIN L->>L: now in normal context — malloc, printf,<br/>log, close listeners, drain, exit cleanly
The self-pipe trick, end to end. What it shows: the handler’s entire body is three statements — save errno, one write(2), restore errno — and every piece of real work happens in the event loop, in normal context, where the whole C library is legal again. The EAGAIN branch is drawn deliberately, because it is a designed-for outcome rather than an error. The insight to take: the pattern converts an asynchronous event into a readable file descriptor, which is to say it converts a signal into something the program already knows how to handle. That conversion is the entire idea, and signalfd is the same idea moved into the kernel so the pipe and the handler both disappear.
The two design constraints both come straight from the safety rules. First, the only function the handler calls is write(), which is on the safe list. Second, the write-end is made non-blocking so that if the pipe buffer is full (many signals queued, loop behind), the write() fails with EAGAIN instead of blocking the handler — a handler that blocks inside a write() could deadlock the program, and one dropped wake byte is harmless because the read-end is drained in a loop and a single readable event already means “≥1 signal arrived.” The self-pipe trick was the standard pre-signalfd way to fold signals into an event loop; select_tut(2) discusses the underlying race it solves (a signal arriving in the gap between “check a flag” and “enter select”), for which the in-kernel atomic alternatives are pselect/ppoll (atomic signal-mask swap) and signalfd.
Uncertain
Verify: that the canonical self-pipe-trick write-up I am attributing to the man-pages ecosystem is sourced correctly. Reason: the self-pipe trick is universally documented (originating with D. J. Bernstein and popularized by Michael Kerrisk’s The Linux Programming Interface), but select_tut(2) as fetched here discusses the
pselectrace rather than naming the self-pipe trick explicitly, and does not spell out the non-blocking-write/drain detail verbatim. To resolve: cross-check against TLPI §63.5.2 and thesignalfd(2)/epoll(7)notes. The mechanism (handler does onlywrite(); nonblocking; drain read-end) is sound and follows directly from the safe-list rules. uncertain
Idiom 3 — Avoid handler context entirely with signalfd
The cleanest modern answer is to not run async handler code at all. With signalfd(2), “signalfd() creates a file descriptor that can be used to accept signals targeted at the caller,” and that descriptor “may be monitored by select(2), poll(2), and epoll(7).” The protocol is to first block the signals — “Normally, the set of signals to be received via the file descriptor should be blocked using sigprocmask(2), to prevent the signals being handled according to their default dispositions” — and then read() signalfd_siginfo records out of the fd inside the event loop. Because the signal is consumed by a read() in normal control flow, the async-signal-safe constraint evaporates: you may call malloc, printf, or anything else. The full machinery lives in signalfd; the point here is that signalfd is the safe idiom taken to its limit — it deletes the handler.
Reproductions — What glibc Actually Does
The rules above are the contract. This section is what happens when you break it on a real machine, because the gap between “undefined behaviour” and “the observed failure” is where the debugging time goes. Every transcript below is from the host named in the environment note. One result is deliberately negative and is reported as such.
Reading the implementation first
Two glibc internals decide the shape of the failure, and neither is obvious from the POSIX wording.
stdio locks are owner-recursive. From sysdeps/nptl/stdio-lock.h at glibc 2.43, the type is typedef struct { int lock; int cnt; void *owner; } _IO_lock_t; and the acquire macro is:
#define _IO_lock_lock(_name) \
do { \
void *__self = THREAD_SELF; \
if (SINGLE_THREAD_P && (_name).owner == NULL) \
{ (_name).lock = LLL_LOCK_INITIALIZER_LOCKED; (_name).owner = __self; } \
else if ((_name).owner != __self) \
{ lll_lock ((_name).lock, LLL_PRIVATE); (_name).owner = __self; } \
else \
++(_name).cnt; \
} while (0)Read the last branch. If the lock’s owner is already this thread, the macro takes ++cnt and returns — it does not wait. A signal handler runs on the same thread as the frame it interrupted, so owner == __self is exactly the case that holds when a handler re-enters printf. Therefore printf in a handler cannot self-deadlock on glibc. It sails straight into the buffer that the interrupted call was halfway through updating, which is the corruption half of the man page’s warning with the deadlock half removed.
malloc does not lock at all when the process is single-threaded. From malloc/malloc.c at glibc 2.43:
static void * __attribute_noinline__
__libc_malloc2 (size_t bytes)
{
mstate ar_ptr; void *victim;
if (SINGLE_THREAD_P)
{
victim = tag_new_usable (_int_malloc (&main_arena, bytes));
...
return victim;
}
arena_get (ar_ptr, bytes); /* this is what takes ar_ptr->mutex */
victim = _int_malloc (ar_ptr, bytes);
...and _int_free_chunk mirrors it with /* If we're single-threaded, don't lock the arena. */ if (SINGLE_THREAD_P) have_lock = true;. SINGLE_THREAD_P is glibc’s per-thread “is this process still single-threaded?” predicate, exposed publicly as __libc_single_threaded in misc/sys/single_threaded.h. On top of that, requests below mp_.tcache_max_bytes are served from the per-thread cache with no arena involvement at all. So in a single-threaded program malloc takes no lock, and a handler that calls malloc cannot deadlock — it can only corrupt.
That gives a concrete prediction table, which is worth holding next to the POSIX model because they disagree about the symptom while agreeing about the verdict:
| Handler calls… | POSIX-model prediction | glibc 2.43 actual mechanism | Observed failure |
|---|---|---|---|
printf while main flow is in printf | deadlock on the FILE lock | recursive acquire via ++cnt — no wait | stream corruption, never a hang |
malloc in a single-threaded process | deadlock on the arena mutex | no lock is taken at all (SINGLE_THREAD_P, tcache) | heap corruption, never a hang |
malloc in a multi-threaded process | deadlock on the arena mutex | arena_get takes a plain, non-recursive ar_ptr->mutex | deadlock is possible, but the lock-hold window is small — see the negative result below |
pthread_mutex_lock on a lock the interrupted frame holds | deadlock | a plain futex-backed mutex with no owner check | deadlock, deterministically |
The verdict does not change — every one of these is a bug — but the symptom you will be handed does, and “it corrupts silently” is considerably worse to debug than “it hangs with an obvious backtrace”.
Reproduced: stdio in a handler corrupts the stream
A main loop printing one fixed 48-character line, and a POSIX timer at 50 kHz whose handler prints a different fixed 48-character line. Both lines are constants, so any output line that is neither constant is proof that the two printf calls interleaved inside the shared FILE buffer. Three seconds, output to a file, then count:
[done] main lines=140020444 handler lines=113121
total lines: 145208020 malformed: 70355
BAD: 'MMMMMMMMMMMMMMMMMMMMMMMMMMMM'
BAD: 'MMMMMMMMMMMMMMMMMMMMMMMMMMMM'
BAD: 'MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM'
70,355 malformed lines against 113,121 handler entries — the corruption events come in pairs (a truncated 28-character line followed by an over-long 77-character one), so roughly one handler entry in three damaged the stream. The 28-character fragment is the main loop’s line cut off exactly where the signal landed; the 77-character line is the remainder of that line concatenated with the handler’s. This is precisely the mechanism signal-safety(7) describes — “the buffer and associated variables have been partially updated … the second call to printf(3) will operate on inconsistent data” — made visible. Note what it is not: it is not a hang, not a crash, and not something a test suite would notice. It is silently wrong output at a rate of about 30%, which in a logging path means a third of your diagnostic records are garbage exactly when a signal storm is telling you something is wrong.
Not reproduced: the handler-deadlocks-in-printf/malloc storm
The classic claim is that a handler calling printf or malloc under a signal storm will wedge the thread. On this host it did not, across four configurations and roughly five million handler entries. Reporting honestly:
| Configuration | Handler body | Handler entries | Result |
|---|---|---|---|
Single-threaded, printf storm, 20 kHz timer, 5 s × 5 runs | printf("x") | ~99,000 per run | no wedge; ~170M main-loop iterations per run |
Single-threaded, malloc(64…1087) + free, 50 kHz, 5 s × 8 runs | small malloc/free | ~250,000 per run | no wedge |
Single-threaded, malloc(~70 KB) (tcache-bypassing) + free, 5 s × 6 runs | large malloc/free | ~245,000 per run | no wedge |
Multi-threaded (a second thread parked in pause(), so SINGLE_THREAD_P is false), timer pinned to the allocating thread with SIGEV_THREAD_ID, 5 s × 6 runs | large malloc/free | ~246,000 per run | no wedge; main-loop throughput dropped ~20% (55M vs 70M iterations), which confirms the arena lock was being taken |
Each run carried a watchdog: a SIGALRM handler calling only write(2) and _exit(2), comparing a progress counter against the previous second and exiting with status 99 if it had not moved. It never fired.
The first three rows are fully explained by the source above — no lock is taken in a single-threaded process, so there is nothing to deadlock on. The fourth is not fully explained, and I will not invent an explanation: the arena mutex demonstrably was being contended (the throughput drop is the evidence) and yet ~1.5 million handler entries pinned to the allocating thread produced no self-deadlock. The honest reading is that the window in which ar_ptr->mutex is held is a small fraction of each malloc/free pair, so the probability per signal is low, and 50 kHz for five seconds is simply not enough trials. The lesson for a reader is the uncomfortable one: the unsafe call usually works, which is exactly why the bug ships.
Uncertain
Verify: why ~1.5 million handler
mallocentries pinned to a lock-taking thread did not self-deadlock on the arena mutex. Reason: measured negative across six runs; the competing explanations are (a) the arena-lock hold window is too small a fraction of each allocation for 50 kHz sampling to hit, (b) the timer signal is not in fact landing on the allocating thread despiteSIGEV_THREAD_ID, or (c) some glibc 2.43 path I have not read avoids the lock for this size class. I did not instrument the lock itself, so I cannot distinguish them. To resolve: instrument__libc_lock_lockwith a per-thread “in critical section” flag checked from the handler, or run under a deterministic tool (rr, or aptrace-driven signal injected at a breakpoint inside_int_malloc). uncertain
Reproduced deterministically: re-entering a lock the frame below holds
Strip the probability away and the hazard is instant and total. The main flow takes an ordinary PTHREAD_MUTEX_INITIALIZER mutex and then raises a signal from inside the critical section; the handler tries to take the same mutex. A three-second alarm(3) watchdog, which touches only write(2) and _exit(2), reports the outcome:
main: holds L, raising SIGUSR1 from inside the critical section
handler: entered, now locking L (held by the frame below me)
[watchdog] entered=1 acquired=0 -> WEDGED IN HANDLER
exit=99
The handler entered and never acquired. This is the shape of every real-world “the process hangs the instant a particular signal fires” report, and it is the reason the pthread_* family is almost entirely absent from the safe list. The default mutex type has no owner check — unlike glibc’s _IO_lock_t above, which does — so there is nothing to save it. Note that switching to PTHREAD_MUTEX_RECURSIVE would make this specific program complete, and would still be wrong: a recursive mutex lets the handler into a critical section whose invariants the suspended frame has half-broken, trading a visible deadlock for invisible corruption. That trade is exactly what glibc made for stdio, and the previous reproduction is what it costs.
A related trap: output parameters written before a restart
One more measured behaviour belongs here, because it bites the same code that handlers force you to write. nanosleep(2) takes a rem output parameter that receives the unslept remainder when the call is interrupted. Poison rem, sleep for one second, and interrupt the sleep at 300 ms — but with a SIGSTOP/SIGCONT pair rather than a handler, so that the kernel restarts the call through the restart_block instead of returning to user space:
child: nanosleep(1.0s) ret=0 errno=- elapsed=1.000s rem={0,700032438}
child: rem WAS WRITTEN by the kernel despite the call returning 0 (poison was {7777,123456789})
The call succeeded — return 0, full 1.000 s elapsed — and yet rem holds a stale 0.700 s written at the moment of the interruption. A caller that follows the natural pattern of “if it returned early, sleep for rem” but checks rem rather than the return value will sleep an extra 0.7 s for no reason. The remainder is written before the restart code is returned, and nothing goes back to clear it once the restarted call completes.
Worth stating precisely, because it contradicts a common assumption: nanosleep is not restarted by SA_RESTART. Running the same test with a real handler, with and without the flag, gives the identical result both times:
no SA_RESTART ret=-1 errno=Interrupted system call elapsed=0.300s handler_fired=1 rem={0,700042667}
SA_RESTART ret=-1 errno=Interrupted system call elapsed=0.300s handler_fired=1 rem={0,700042037}
SA_RESTART makes no difference at all, because nanosleep uses ERESTART_RESTARTBLOCK, which the kernel honours only when no user handler runs — a stop/continue, not a caught signal. The full taxonomy of restart codes and which syscalls use which is owned by Restartable System Calls and ERESTARTSYS; the point for a handler author is narrower and sharper: a flag-and-return handler does not make your blocking calls transparent. SA_RESTART covers a specific set, nanosleep is outside it, and the output parameters of an interrupted call may be populated even on a call that ultimately succeeds.
Failure Modes
- Self-deadlock on a lock held by the interrupted frame. Symptom: the program hangs the instant a particular signal fires, with one thread parked in
__lll_lock_waitbeneath a signal frame in the backtrace. Cause: the handler took a lock —pthread_mutex_lockdirectly, or a library that does so internally — that the code it interrupted already holds. Diagnosis: agdbbacktrace shows the handler frame stacked above the frame that owns the lock; the “other thread” you are looking for does not exist. Reproduced deterministically above. The fix is to remove the call, not to make the lock recursive. - Heap or buffer corruption with no deadlock — the common case. Symptom: intermittent crashes,
malloc(): corrupted top size, or garbled output, minutes or hours after the offending signal and far from the cause. This is by far the more likely failure on glibc, and the measurements above explain why:stdio’s lock is owner-recursive soprintfnever blocks, andmalloctakes no lock at all in a single-threaded process. The unsafe call therefore sails through and mutates a structure the main flow was mid-update on, leaving silent damage that detonates later. Do not use “it did not hang” as evidence that a handler is safe. - Concluding a handler is safe because a storm test passed. Symptom: a code review that accepts
printfin a handler on the strength of a soak test. The negative reproduction above ran roughly five million unsafe handler entries without a single hang — and, in the one test that actually checked the output, corrupted about a third of it. Storm tests can only ever find the deadlock, which is the failure mode you are least likely to have. - Lost
errno. Symptom: a syscall in the main flow appears to “randomly” return a stale or wrong error. Cause: a handler called a syscall wrapper without saving/restoringerrno, overwriting the value the main flow was about to read. - Compiler-hoisted flag. Symptom: the main loop never notices the flag and spins forever even though the handler clearly ran. Cause: the flag was not
volatile, so the compiler cached it in a register. Fix:volatile sig_atomic_t. - Calling
exit()instead of_exit()from a handler. Symptom: re-entrancy intostdioflushing oratexithandlers, hangs or double-frees during shutdown.exit(3)flushes streams and runs registered exit handlers — both unsafe;_exit(2)traps straight to the kernel and is on the safe list.
Alternatives and When to Choose Them
The three idioms above are not interchangeable; pick by what the program’s main flow is doing when the signal arrives.
flowchart TD Q0["I need to react to a signal"] --> Q1{"Can I block the signal<br/>and consume it synchronously?"} Q1 -- "yes, and I'm on Linux with an event loop" --> SFD["signalfd(2)<br/>no handler at all — the whole<br/>libc is legal in the consumer"] Q1 -- "yes, and I just want to wait for it" --> SWI["sigwaitinfo / sigtimedwait<br/>blocking, siginfo_t included"] Q1 -- "no, I must use a handler" --> Q2{"What is the main flow doing<br/>when the signal arrives?"} Q2 -- "spinning in a CPU-bound loop" --> FLAG["Idiom 1 — volatile sig_atomic_t flag<br/>zero fds, lowest latency to notice"] Q2 -- "blocked in select/poll/epoll" --> PIPE["Idiom 2 — self-pipe<br/>one write(2) in the handler,<br/>signal becomes a readable fd"] Q2 -- "blocked in one syscall, no loop" --> PSEL["pselect / ppoll<br/>atomic mask swap closes the<br/>check-then-block race"] Q2 -- "already crashed (SIGSEGV/SIGABRT)" --> CRASH["Crash handler discipline:<br/>SA_ONSTACK, write(2) to a pre-opened fd,<br/>hand-rolled formatting, _exit()"] SFD --> DONE["Real work happens in normal context"] SWI --> DONE FLAG --> DONE PIPE --> DONE PSEL --> DONE
Choosing a pattern. What it shows: the first question is not “which idiom” but “do I need a handler at all”, because every branch that avoids handler context also avoids this entire note’s rulebook. The insight to take: the crash-handler branch is the only one that genuinely cannot escape async context — a SIGSEGV is synchronous with the faulting instruction and cannot be deferred to an event loop — which is why it gets its own discipline rather than an idiom.
Spelled out as a comparison:
- Flag-and-return suits a tight CPU-bound loop that checks the flag often — simplest, zero extra fds, but useless if the program blocks in syscalls.
- Self-pipe suits an existing
select/poll/epollevent loop on older kernels or portable code — it folds signals into the readiness model with one pipe and a one-line handler. - signalfd is the right default on Linux for event-loop programs: no handler at all, signals delivered as
read()-able records, full libc available in the consumer. Its caveats (block the signals first; fork/exec interactions) are covered in signalfd. pselect/ppollsolve the narrow race of “a signal between flag-check and blocking-call” by atomically swapping the signal mask for the duration of the wait — useful when you have a flag handler and just need the blocking wait to be race-free, without a pipe.
Production Notes
Everything above reduces to one piece of code that is worth having memorised, because it is the only fully safe way to get a diagnostic out of a handler. There is no safe printf, so the number formatting has to be written by hand:
/* AS-safe integer -> decimal, into a caller-supplied buffer. Uses only
pointer arithmetic on the caller's own memory: reentrant by construction. */
static int u64_to_dec(char *buf, int cap, unsigned long v) {
char tmp[20];
int n = 0;
do { tmp[n++] = (char)('0' + v % 10); v /= 10; } while (v && n < (int)sizeof tmp);
if (n > cap) return 0; /* (1) never write past the buffer */
for (int i = 0; i < n; i++) buf[i] = tmp[n - 1 - i];
return n;
}
static int crash_fd = -1; /* (2) opened at startup, never here */
static void on_fatal(int sig, siginfo_t *info, void *uctx) {
(void)uctx;
int saved = errno; /* (3) the iron rule */
char b[128];
int n = 0;
const char pre[] = "fatal signal ";
memcpy(b + n, pre, sizeof pre - 1); n += sizeof pre - 1; /* (4) memcpy IS safe */
n += u64_to_dec(b + n, (int)sizeof b - n, (unsigned long)sig);
const char at[] = " at address "; /* decimal — a hex helper is a separate 6 lines */
memcpy(b + n, at, sizeof at - 1); n += sizeof at - 1;
n += u64_to_dec(b + n, (int)sizeof b - n, (unsigned long)info->si_addr);
b[n++] = '\n';
(void)!write(crash_fd, b, (size_t)n); /* (5) the one legal report channel */
errno = saved; /* (6) restore before leaving */
_exit(128 + sig); /* (7) _exit, never exit */
}Line (1): the bound check matters more than usual, because a handler running on a SIGSEGV from stack exhaustion has very little stack even on an alternate stack. Line (2): the descriptor is opened during startup — open(2) is on the safe list, but opening a file at crash time is one more thing that can fail when the process is already broken. Line (3) and (6): write() can set errno, and the interrupted code may have been about to read it; per signal-safety(7), “Fetching and setting the value of errno is async-signal-safe provided that the signal handler saves errno on entry and restores its value before returning.” Line (4): memcpy and memset are on the safe list because they touch only caller-supplied buffers — they are reentrant, not merely thread-safe. Line (5): write(2) is the canonical “atomic with respect to signals” function, a bare trap into the kernel with no userspace state to corrupt; note snprintf is deliberately absent from the whole example, because it is not on the safe list. Line (7): _exit(2) traps straight to the kernel, where exit(3) would flush stdio and run atexit handlers — arbitrary unsafe code executed on an already-corrupted process. Compiled with -Wall -Wextra and run against a deliberate null-ish dereference, this prints fatal signal 11 at address 4660 (decimal, i.e. the 0x1234 that was dereferenced) and exits 139, which is 128 + SIGSEGV.
Real systems take async-signal-safety seriously precisely because the failures are rare, non-local, and brutal to debug. Crash-handler code — the SIGSEGV/SIGABRT handlers that print a backtrace — is the textbook minefield: the program is already corrupted (that is why it faulted), and the natural urge to printf the backtrace re-enters malloc on a wrecked heap. Production crash handlers therefore use only write() to a pre-opened fd, pre-format strings into stack buffers with hand-rolled integer-to-string code, and call _exit — exactly the shape of the example above. snprintf is excluded not because it is known to allocate on every path but for the simpler and stronger reason that it is absent from signal-safety(7)‘s table, and the list is closed: absence is the whole argument, and no further justification is needed or available.
What a real production crash handler actually does
It is worth checking that claim against real code rather than repeating it, because the real code is more interesting than the idealised rule. Reading Abseil’s absl/debugging/failure_signal_handler.cc (fetched 2026-09-12):
| What the idealised rule says | What Abseil does | Reading |
|---|---|---|
Use SA_ONSTACK with a pre-armed alternate stack | Yes — sigstk.ss_sp = malloc(sigstk.ss_size); at install time, then sigaltstack(), and SA_ONSTACK in the flags | Exactly the discipline. Note the malloc is at setup, never in the handler. |
Report only through write(2) | Yes — the first writer is absl::raw_log_internal::AsyncSignalSafeWriteError(data, strlen(data)) | The name is the intent stated outright. |
Never call snprintf | No — it calls snprintf three times in WriteSignalMessage, formatting into a 96-byte stack buffer, and calls time(nullptr) (which is on the safe list) | A deliberate, documented-by-practice deviation. snprintf into a fixed stack buffer with no floating point and no wide characters is judged acceptable in practice even though POSIX does not bless it. |
| Do the least possible | Partly — it writes the signal line, then a stack trace, and only then runs the user’s writerfn, behind the comment “Riskier code (because it is less likely to be async-signal-safe) goes after this point” | The risk is acknowledged in a source comment and ordered so that the safest output happens first. |
| — | It arms alarm(fsh_options.alarm_on_failure_secs) with an ImmediateAbortSignalHandler, described in a comment as “Set an alarm to abort the program in case this code hangs or deadlocks” | A watchdog — the same pattern used to bound every reproduction in this note. Production code that does unsafe things in a handler defends against the hang rather than pretending it cannot happen. |
The honest conclusion is not “Abseil violates the rules” but something more useful: a crash handler is the one place where you are already past the point of guaranteed correctness, so the engineering becomes risk ordering rather than rule following — emit the safest, most valuable output first, put anything riskier behind it, and arm a watchdog so a wedge becomes an abort instead of a hang. That last move is worth stealing regardless of what your handler does.
Uncertain
Verify: that
glog’s failure-signal handler and the kernel’soopspath follow the same discipline. Reason: only Abseil’s handler was actually read during this pass; the other two are recollection. Note also that the Abseil file above was fetched frommaster, not from a release tag, so it reflects the tip at the time of reading rather than any shipped version. To resolve: readsrc/utilities.cc/src/signalhandler.ccingoogle/glogandkernel/panic.cplusarch/x86/kernel/dumpstack.cin Linux v6.12, and re-fetch the Abseil file at a tagged release. uncertain
The general engineering rule that falls out of all of it: a signal handler should do the least possible — ideally one atomic store or one write() — and defer everything else to normal context, where the entire library is available again. Whenever the deferred-handling design is feasible (which is almost always), prefer signalfd and delete the handler outright.
See Also
- sigaction and Signal Handlers — how handlers are registered: the full
SA_*flag table,sigaltstack, and the measuredsignal()-versus-sigaction()difference - Signal Delivery and the Return to Userspace — the kernel mechanism that injects the handler frame and the trampoline/
sigreturnreturn path - signalfd — the fd-based way to consume signals synchronously and escape handler context entirely
- Pipes and the Pipe Buffer — the pipe under the self-pipe trick, and why a full buffer means
EAGAINrather than a blocked handler - Signal Dispositions Inheritance Across fork and exec — what happens to handlers across the two process-creation boundaries; relevant to the
forkversus_Forksplit above - fork clone and exec System Calls — the
pthread_atforkmachinery that keepsfork(2)off the safe list - Restartable System Calls and ERESTARTSYS — the restart codes behind the
nanosleepresult measured above, and which syscallsSA_RESTARTactually covers - SA_RESTART and Signal-Interrupted Syscalls — the
EINTRinteraction that flag-and-return handlers must cope with - EINTR and Interrupted System Calls — the caller-side handling pattern
- The errno Convention and Negative Return Values — the mechanism behind the save/restore-
errnorule - Standard Signals and Their Default Actions — which signals a handler is allowed to catch at all
- Realtime Signals — queued delivery, for when a flag-and-return handler’s coalescing loses events
- The Kernel Futex Interface — what
lll_lockis doing underneath the deadlock reproduced above - Linux IPC MOC — the parent map