Signals
Learn about signal handling, signal masks, sigaction, and async notifications between processes in Unix/Linux systems.
Learn about signal handling, signal masks, sigaction, and async notifications between processes in Unix/Linux systems.
Signals
Every process in a Unix-like system lives under the constant awareness of signals — asynchronous notifications from the kernel and other processes that something important has happened. When you press Ctrl+C in a terminal, a signal is sent. When a process crashes and the kernel detects an illegal memory access, a signal is delivered. When a child process exits, its parent receives a signal. Signals are the oldest and most fundamental form of inter-process communication, predating pipes, message queues, and sockets.
Despite their simplicity, signals are often misunderstood and mishandled. The async nature of signals makes them notoriously tricky to reason about — the signal can arrive at any point in your program’s execution, interrupting system calls, modifying global state, and causing subtle bugs if not handled carefully. Getting signal handling right is a hallmark of systems programming competence.
Introduction
A signal is a notification sent to a process (or thread) to indicate that an event has occurred. Signals are asynchronous — they can arrive at any time, interrupting the normal flow of execution. When a signal arrives, the process either:
- Runs a signal handler — a function you define to handle the specific signal
- Takes the default action — which varies by signal (terminate, ignore, stop, dump core)
- Ignores it — if you explicitly request to ignore the signal
There are over 30 standard signals defined in POSIX, each with a name (like SIGTERM, SIGINT, SIGSEGV) and a numeric value. Signals are delivered for various reasons:
- User-initiated: Ctrl+C sends
SIGINT, Ctrl+Z sendsSIGTSTP - Process communication: One process sending a signal to another via
kill() - Kernel-generated: Hardware exception (
SIGSEGV,SIGFPE), timer (SIGALRM), child exit (SIGCHLD) - Resource limits:
SIGXCPU(CPU time exceeded),SIGXFSZ(file size exceeded)
Signals are delivered to a specific thread. If the process has multiple threads, the signal is delivered to one arbitrarily selected thread that is not blocking the signal.
When to Use / When Not to Use
Use signals when:
- You need to send a simple notification to a process (terminate, pause, resume)
- You need to handle asynchronous events like timer expirations or child process exits
- You need to implement a clean shutdown mechanism (catch SIGTERM and cleanup gracefully)
- You are building a daemon that responds to external control commands
- You need to interrupt a blocking system call
Do not use signals when:
- You need to transfer data (signals carry no payload — use pipes or sockets)
- You need synchronous, ordered communication (use message queues or pipes)
- You need many different message types (use sockets with custom protocols)
- You are building complex IPC that requires confirmation/reply patterns
Architecture or Flow Diagram
sequenceDiagram
participant P1 as Process A (Sender)
participant Kernel
participant P2 as Process B (Receiver)
Note over P1: kill(PID_B, SIGTERM)
P1->>Kernel: raise(SIGTERM, target=PID_B)
Kernel->>Kernel: Validate target process exists<br/>Check sender permissions
alt Process B has handler
Kernel->>P2: Deliver signal (may interrupt syscall)
P2->>P2: Run signal handler sigaction(SIGTERM)
P2-->>P2: Resume execution after handler
else Process B ignores
Kernel->>P2: Signal delivered but ignored
Note over P2: No action taken
else Default action
Kernel->>P2: Terminate process
Note over P2: Process exits, status = 143 (SIGTERM)
end
Core Concepts
Signal Dispositions and sigaction
Every signal has a “disposition” — what happens when the signal is delivered. The default disposition varies by signal. You can change the disposition using signal() (simple, legacy) or sigaction() (more control, portable):
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
void handle_sigterm(int sig) {
// Signal-safe operations only here
// No printf, malloc, or non-async-signal-safe functions!
const char msg[] = "Received SIGTERM, shutting down...\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
// Perform cleanup...
_exit(0);
}
int main() {
struct sigaction sa;
sa.sa_handler = handle_sigterm; // Handler function
sigemptyset(&sa.sa_mask); // No signals blocked during handler
sa.sa_flags = 0; // No special flags
// SA_RESTART: restart syscalls interrupted by this signal
// SA_NODEFER: don't block this signal during its own handler
if (sigaction(SIGTERM, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
// Now SIGTERM will call handle_sigterm instead of terminating
while (1) {
pause(); // Wait for signals
}
}
Key sigaction fields:
sa_handler: Function pointer (SIG_DFLfor default,SIG_IGNto ignore)sa_mask: Set of signals to block during this handler’s executionsa_flags: Modifiers likeSA_RESTART(restart syscalls),SA_SIGINFO(usesa_sigactionwith extra info)
Signal Masks
Each thread (and by extension, process) has a signal mask — a set of signals that are currently blocked. Blocked signals are not delivered, but they may be pending. You can manipulate the signal mask with pthread_sigmask() (for threads) or sigprocmask() (for single-threaded processes):
#include <signal.h>
void block_sigchld() {
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigset_t oldmask;
pthread_sigmask(SIG_BLOCK, &mask, &oldmask);
// In this block, SIGCHLD won't interrupt us
// ... do work ...
// Restore previous mask
pthread_sigmask(SIG_SETMASK, &oldmask, NULL);
}
Sending Signals
You send signals with the kill() system call:
#include <signal.h>
#include <unistd.h>
pid_t target_pid = 1234;
// Send SIGTERM to a process
if (kill(target_pid, SIGTERM) == -1) {
perror("kill");
}
// Send SIGUSR1 with no default action (user-defined)
kill(target_pid, SIGUSR1);
The raise() function sends a signal to the current process:
raise(SIGALRM); // Equivalent to kill(getpid(), SIGALRM)
Signal Safety and Async Signal Safety
Not all functions can be safely called from a signal handler. A function is async-signal-safe if it can be safely called from a signal handler — meaning it does not use non-reentrant locks, does not allocate memory from the heap, and does not make assumptions about the interrupted execution context. The problem goes beyond just locks: when a signal handler runs, it interrupts whatever system call was in progress. If that call was holding a libc internal lock (say, for malloc’s arena), and your handler then calls malloc, you deadlock. If the interrupted code was in the middle of printf writing to a buffer, and your handler calls printf again, output gets corrupted or interleaved.
The rules that make a function async-signal-safe are:
- No internal synchronization (no mutexes, no semaphores that might already be held)
- No dynamic memory allocation (no heap malloc/free/realloc)
- No legacy buffered I/O (no stdio functions that maintain internal state)
- Reentrant (safe to call from nested signal contexts)
A practical breakdown:
Safe to call from handlers:
- _exit(), write(), sync(), fsync() — direct kernel calls, no libc buffering
- wait(), waitpid(), kill() — process and signal control
- sigprocmask(), sigemptyset(), sigaddset() — signal mask manipulation
- memcpy(), memset() — raw memory operations, no allocation
- open() (some flags), close() — low-level, no stdio buffering
Never safe in a handler:
- printf(), fprintf(), puts() — stdio maintains internal buffers and locks
- malloc(), free(), realloc() — heap allocation uses internal locks
- pthread_mutex_lock(), pthread_mutex_unlock() — libc internal locks conflict with the interrupted code’s locks
- syslog() — internally uses stdio and socket operations
- getpwnam(), getgrnam() — these functions cache in static buffers and use locks
One gotcha that bites frequently: signal handlers clobber errno. If your interrupted code had just set errno to something meaningful and the handler calls any function that modifies errno (including write() on some platforms), the original value is lost. Save and restore errno at the handler boundaries:
void handler(int sig) {
int saved_errno = errno;
// ... handler logic that may call write() ...
errno = saved_errno;
}
The list of async-signal-safe functions is in man 7 signal-safety. Keep signal handlers minimal — set a volatile sig_atomic_t flag, write a byte to a pipe, or call _exit(). Do complex cleanup in the main program after checking the flag.
Waiting for Signals: pause(), sigsuspend(), sigwaitinfo()
// Simple pause (wait for any signal)
pause();
// Block in sigsuspend atomically replacing signal mask
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, NULL); // Block SIGINT
// Critical section - SIGINT blocked
sigprocmask(SIG_SETMASK, &mask, NULL); // Unblock, delivering SIGINT
// Synchronous wait for specific signals (real-time signals)
sigset_t waitmask;
sigemptyset(&waitmask);
sigaddset(&waitmask, SIGRTMIN);
sigwaitinfo(-1, &waitmask, NULL); // Wait for SIGRTMIN
Production Failure Scenarios
Interrupted System Calls (EINTR)
When a signal handler runs during a blocking system call (like read(), write(), connect(), sleep()), the kernel may interrupt the call and return -1 with errno = EINTR. If you do not handle this, your program may interpret the interrupted call as an error.
Mitigation: Always check for EINTR and retry interrupted calls. Use SA_RESTART flag with sigaction() to automatically restart some syscalls. Review all blocking calls for EINTR handling:
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) == -1 && errno == EINTR) {
// Retry
}
if (n == -1) {
perror("read");
}
Signal Handler Race Conditions
If your signal handler modifies a global variable that is also modified by the main program without synchronization, you get a race condition. For example, a handler setting a shutdown_requested flag while the main loop checks it:
volatile sig_atomic_t shutdown_requested = 0; // Volatile needed!
void handler(int sig) {
shutdown_requested = 1;
}
int main() {
// ...
while (!shutdown_requested) {
// Do work - but this is still racy on some architectures!
}
}
Mitigation: Use volatile sig_atomic_t for simple flags (atomic enough for single-flag access). For more complex synchronization, use a self-pipe — the signal handler writes to a pipe, and the main loop monitors the pipe with select()/epoll().
Missing SIGCHLD Handling — Zombie Processes
When a child process terminates, it does not disappear immediately. The kernel keeps the child’s exit status (PID, exit code, resource usage) in the process table entry. The parent must retrieve this with wait() or waitpid() — until then, the child sits in zombie state. A zombie process consumes a slot in the kernel’s process table, and if enough zombies accumulate, you can exhaust process table entries and prevent new processes from being created.
A concrete scenario:
for (int i = 0; i < 10; i++) {
pid_t pid = fork();
if (pid == 0) {
// child: do work and exit
sleep(1);
_exit(0);
}
// parent: no wait() call here — each child becomes a zombie
}
// After this loop: 10 zombie processes
while (1) sleep(1); // they stay zombies until this process exits
In this example, each child exits within a second, but the parent never calls wait(), so each remains a zombie until the parent eventually exits. On a busy server that forks many child processes per request and does not reap them, zombies can pile up rapidly.
Three ways to handle this:
Option 1 — Ignore SIGCHLD (simplest):
signal(SIGCHLD, SIG_IGN);
On Linux, this tells the kernel to discard child exit statuses automatically — children are reaped immediately upon termination without becoming zombies. The trade-off: you cannot use wait() or waitpid() to retrieve exit statuses, so you lose that information.
Option 2 — SA_NOCLDWAIT flag:
struct sigaction sa;
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_NOCLDWAIT;
sigaction(SIGCHLD, &sa, NULL);
This has the same effect as ignoring SIGCHLD but uses the POSIX sigaction() interface. Same trade-off: no exit status retrieval.
Option 3 — Explicit handler with WNOHANG (most control):
void handle_sigchld(int sig) {
int saved_errno = errno;
while (waitpid(-1, NULL, WNOHANG) > 0);
errno = saved_errno;
}
struct sigaction sa;
sa.sa_handler = handle_sigchld;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);
The loop with WNOHANG reaps all terminated children in one call, so multiple children exiting before the handler runs still get cleaned up. This approach gives you the child’s exit status if you need it, and is the right choice for daemons that need to track child lifecycle.
Detecting zombies: ps aux | grep -i zombie shows processes in state Z. In /proc/
SIGPIPE Misconfiguration
When writing to a pipe or socket whose reading end has closed, the writer receives SIGPIPE. The default action terminates the process. If you have not handled SIGPIPE, any write() to a broken pipe terminates your program.
When the reader closes its end, the kernel sends SIGPIPE to the writer on the next write(). The signal arrives immediately, before write() returns. If you have not installed a handler, the default action fires before your code can respond. This catches many developers off guard because write() returns -1 with errno = EPIPE in addition to sending the signal. The signal terminates the process before you ever check that return value.
Two mitigations cover most cases:
Option 1 — Ignore globally (simplest):
signal(SIGPIPE, SIG_IGN);
Place this once at program startup, before any network or pipe I/O. On Linux, this works process-wide. You lose the ability to act on SIGPIPE delivery specifically, but write() still returns errno = EPIPE, so you catch the error in your normal error path.
Option 2 — Per-call suppression (more targeted):
send(fd, buf, len, MSG_NOSIGNAL);
The MSG_NOSIGNAL flag suppresses SIGPIPE for that specific call. write() still returns -1 with errno = EPIPE, so handle it in your error path.
For servers managing many connections, ignoring SIGPIPE globally avoids silent terminations on connection drops. For clients that need to tell a clean close from an error, MSG_NOSIGNAL with explicit EPIPE handling gives you that distinction.
To debug unexpected terminations: run the program under strace. Look for --- SIGPIPE in the output, which shows the write() call that triggered it. A quick diagnostic is to replace SIG_IGN with a custom handler that prints a backtrace, so you can see exactly which code path is writing to a closed pipe.
Signal Masking Deadlocks
Signal masking deadlocks bite when a blocked signal arrives while your code holds a lock, and the handler for that signal tries to grab the same lock. The thread waits on a lock the handler cannot release because the handler cannot run while the signal is masked. Recursive mutexes make this worse — the same thread can re-enter a lock it already holds, but only if the handler fires during the critical section. With SA_NODEFER set, the handler can even re-enter itself on the same signal, compounding the problem into a full deadlock.
Here is what this looks like in practice:
pthread_mutex_t guard = PTHREAD_MUTEX_INITIALIZER;
sigset_t mask;
void init() {
sigemptyset(&mask);
sigaddset(&mask, SIGUSR1);
pthread_sigmask(SIG_BLOCK, &mask, NULL); // Block SIGUSR1
}
void handle_usr1(int sig) {
pthread_mutex_lock(&guard); // May deadlock here!
// ... handler work ...
pthread_mutex_unlock(&guard);
}
void critical_section() {
pthread_mutex_lock(&guard);
// If SIGUSR1 arrives here, handler runs after mutex_unlock
// If handler runs BEFORE mutex_lock in this path, we deadlock
pthread_mutex_unlock(&guard);
}
The issue surfaces in the narrow window between entering and exiting the critical section. On Linux, pthread_mutex_lock() is not async-signal-safe. Calling it from a handler that interrupted a lock-holding region produces undefined behavior — usually a deadlock, sometimes memory corruption.
The self-pipe pattern eliminates this entire class of problem. The handler never touches application locks — it writes a single byte to a pipe and returns. The main event loop watches that pipe and does all the real work in a context where no signal can interrupt.
Beyond that: avoid putting locks in signal handlers entirely, use sigsuspend() to atomically unblock signals during critical sections, prefer non-recursive mutexes so re-entrancy fails loudly instead of silently deadlocking, and run thread-safety analysis tools to find any path where a handler touches a lock the main code also uses.
To debug: check /proc/<pid>/status | grep -i sig for blocked and pending signals. If all threads are stuck in pthread_mutex_lock() and no signals are pending, a handler is probably trying to grab a lock the interrupted code holds. strace -f shows signal delivery and lock operations interleaved.
Trade-off Table
| Feature | signal() | sigaction() | sigwaitinfo() | kill() |
|---|---|---|---|---|
| Portability | Older, less consistent across platforms | POSIX, consistent | POSIX (real-time signals) | Very portable |
| Handler parameters | Only signal number | Signal number + siginfo_t + ucontext | Signal already received | sender info not in handler |
| Atomic signal mask during handler | No | Yes (sa_mask) | Yes | N/A |
| Syscall restart on EINTR | Depends on system | Yes (SA_RESTART flag) | N/A | N/A |
| Queueing (multiple signals) | No (signals coalesce) | No (standard signals) | Yes (real-time signals queue) | No |
| Use case | Simple ignore/default | Full-featured handlers | Synchronous signal wait | Sending signals |
Implementation Snippet(s)
C: Clean Shutdown with SIGTERM
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdbool.h>
volatile sig_atomic_t g_shutdown = false;
void sigterm_handler(int sig) {
g_shutdown = true;
}
int main() {
struct sigaction sa;
sa.sa_handler = sigterm_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // Restart interrupted syscalls
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
printf("Service running (PID %d). Send SIGTERM to stop.\n", getpid());
while (!g_shutdown) {
// Do work — simulate with sleep
sleep(1);
}
printf("Shutting down gracefully...\n");
// Cleanup: close files, release resources, flush buffers
printf("Done.\n");
return 0;
}
Python: Signal Handling
import signal
import sys
def sigterm_handler(signum, frame):
print("Received SIGTERM, shutting down gracefully...")
# Note: in Python, most functions are safe to call from signal handlers
# because the GIL provides some protection
sys.exit(0)
def sigint_handler(signum, frame):
print("Received Ctrl+C, shutting down...")
sys.exit(0)
signal.signal(signal.SIGTERM, sigterm_handler)
signal.signal(signal.SIGINT, sigint_handler)
print(f"PID: {sys.executable}")
while True:
signal.pause()
Bash: Signal Trapping in Scripts
#!/bin/bash
# Trap signals for graceful shutdown
cleanup() {
echo "Received signal, cleaning up..."
rm -f /tmp/myapp.lock
exit 0
}
trap cleanup SIGTERM SIGINT SIGQUIT
# Or ignore signals during critical section
trap '' SIGINT
echo "In critical section - Ctrl+C ignored"
sleep 5
trap SIGINT # Restore default handling
# Background job with signal control
sleep 100 &
sleep_pid=$!
# Wait for job or signal
wait $sleep_pid 2>/dev/null || echo "Interrupted"
Observability Checklist
- Pending signals: Check pending signals for a process with
cat /proc/<pid>/status | grep -i SigPnd - Blocked signals: Check blocked signals with
cat /proc/<pid>/status | grep -i SigBlk - Signal handler registration: Use
strace -e trace=signalto see signal-related system calls - Process state on signal:
ps auxshows processes in interruptible sleep (stateS) waiting for signals - Core dumps: Check
ulimit -cand/proc/sys/kernel/core_patternto ensure core dumps are captured for crashes - Signal delivery timing: Use
perf schedorftraceto measure signal delivery latency - SIGCHLD zombies:
ps aux | grep -i zombieto find zombie processes indicating improper wait handling
Common Pitfalls / Anti-Patterns
Signal Permissions & DoS: Sending a signal requires appropriate permissions — either the sender must have the same effective user ID as the receiver (kill(pid, sig) succeeds), or the sender must have CAP_KILL capability (root). A process could flood another with signals (e.g., repeatedly sending SIGURG which has no default action but still triggers handler execution), consuming CPU time. Use rate limiting if implementing signal-sending functionality.
Signal-based Attacks & Audit: Some privilege escalation exploits use signals to manipulate process state — ensure signal handlers do not perform unsafe operations and security-sensitive state is not accessible via handler interfaces. Signal delivery may not generate standard audit events; for compliance requiring complete process behavior tracking, implement application-level logging around signal handling.
-
Calling non-async-signal-safe functions in handlers —
printf(),malloc(),free(),pthread_mutex_lock()are all unsafe in signal handlers. Keep handlers minimal: set a flag, write to a pipe, call_exit(). -
Not handling EINTR on blocking calls — always check for and retry interrupted system calls, or use
SA_RESTARTto let the kernel handle it. -
Using non-volatile global flags — the compiler may optimize away reads of a global variable that the signal handler sets, causing infinite loops. Use
volatile sig_atomic_t. -
Ignoring SIGCHLD — not handling child exits causes zombie processes that accumulate and consume process table entries.
-
Not setting SA_RESTART on time-consuming operations — if a signal arrives during a long
read()from a slow device, and you did not setSA_RESTART, theread()returns-1withEINTR. Handle this or useselect()/poll(). -
Signal handler modifying errno — signal handlers clobber
errno. Save and restoreerrnoin handlers if needed. -
Mixing signal() and sigaction() — the behavior of
signal()varies across platforms (some restart syscalls, some do not). Always usesigaction()for portability. -
Sending SIGKILL to processes that need cleanup —
SIGKILLcannot be caught, blocked, or ignored. It terminates the process immediately without running cleanup handlers. UseSIGTERMfor graceful shutdown.
Quick Recap Checklist
- Signals are async notifications delivered to processes; they can interrupt any execution point
- Use
sigaction()for portable, full-featured signal handling (preferred oversignal()) - Signal handlers must only call async-signal-safe functions — keep handlers minimal
volatile sig_atomic_tfor simple flags; use self-pipe pattern for complex synchronizationSIGPIPEdefaults to terminating the process — setSIG_IGNor useMSG_NOSIGNALSA_RESTARTflag automatically restarts certain interrupted system callsSIGCHLDshould be handled or ignored to prevent zombie processeskill(pid, sig)sends a signal to a specific process;raise(sig)sends to current process- Real-time signals (
SIGRTMINtoSIGRTMAX) support queuing and carry extra payload - Always handle
EINTRon blocking calls or useSA_RESTART
Interview Questions
signal() is the older, simpler API for setting signal dispositions. Its behavior varies across Unix platforms — on some systems it automatically restarts interrupted syscalls, on others it does not. Some systems reset the disposition to default after the first signal is delivered. This inconsistency makes it less reliable for portable code.
sigaction() is the POSIX-standard, feature-rich interface. It allows you to:
- Specify a handler function with three parameters (signal number, siginfo_t pointer, ucontext pointer)
- Set a signal mask that blocks additional signals during the handler's execution
- Set flags like
SA_RESTARTto control syscall behavior on EINTR - Install handlers that receive extra information about the signal's origin (using
SA_SIGINFOflag)
The consistent, portable behavior of sigaction() makes it the correct choice for production code. Always use sigaction().
Only async-signal-safe functions can be safely called from a signal handler. These are functions that do not use internal locks, do not allocate memory dynamically, and are reentrant. The canonical list includes:
- I/O:
write(),_exit(),sync() - Process control:
_exit(),wait(),kill()(sender info only) - Synchronization:
sigprocmask(),sem_post() - String/byte operations:
memcpy(),memset()(but notmalloc/free)
Unsafe: printf(), malloc(), free(), pthread_mutex_lock(), most libc functions. The practical rule: in a signal handler, only call write() to a pipe or file descriptor, set a volatile sig_atomic_t flag, or call _exit(). For complex cleanup, use the self-pipe trick — the handler writes a byte to a pipe, and the main loop monitors that pipe and performs cleanup.
When a child process terminates, it remains in the process table as a zombie until the parent explicitly retrieves its exit status with wait() or waitpid(). If the parent never calls these, zombie processes accumulate.
Three solutions:
- Ignore SIGCHLD: Call
signal(SIGCHLD, SIG_IGN). On Linux, this tells the kernel to automatically reap children without creating zombies. Simple but prevents the parent from waiting for specific children. - Use SA_NOCLDWAIT flag: With
sigaction(), setsa_flags |= SA_NOCLDWAIT. This has similar effect but more portable. - Handle SIGCHLD explicitly: Install a handler that loops calling
waitpid(-1, NULL, WNOHANG)to reap any terminated children. This allows you to track child exits and maintain a process count.
struct sigaction sa;
sa.sa_handler = handle_sigchld;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDWAIT;
sigaction(SIGCHLD, &sa, NULL);
When a signal handler is invoked during a blocking system call (e.g., read(), write(), connect(), sleep()), the kernel interrupts the call and returns -1 with errno = EINTR (Interrupted System Call). The call did not complete — it was prematurely terminated.
Your options for handling this:
- Retry the call: Loop until the call succeeds or returns an error other than EINTR. Most network code written for Unix uses this pattern.
- Use SA_RESTART: Set the
SA_RESTARTflag insigaction(). The kernel automatically restarts some (but not all) syscalls.read(),write(),ioctl(),select()are restarted;connect(),accept(),recv()are not. - Check for partial success: Some calls like
read()may return a positive length before being interrupted. Handle this case too.
The self-pipe trick is a pattern for handling signals safely in a main event loop. The problem: you want to do complex cleanup in response to a signal, but you cannot call complex functions from a signal handler (most are not async-signal-safe). The solution:
- Create a pipe before entering the event loop
- Add the read end of the pipe to your
select()/poll()/epoll()event loop - In the signal handler, write a single byte to the pipe (which is async-signal-safe)
- When the event loop detects readability on the pipe, read the byte and perform the complex cleanup in the main program context
int sigpipefd[2]; pipe(sigpipefd); signal(SIGTERM, handler); // handler does write(sigpipefd[1], "!", 1)
while (epoll_wait(efd, …) > 0) { for each event: if (event.fd == sigpipefd[0]) { read(sigpipefd[0], &byte, 1); // Do cleanup now - we are in safe context } }
This keeps signal handlers minimal and allows full cleanup logic in the main program, where all functions are available.
Real-time signals (SIGRTMIN to SIGRTMAX, at least 8 on Linux) were introduced to address the limitation that standard signals coalesce: if three SIGUSR1 signals arrive before the handler runs, only one is delivered. Real-time signals queue: each signal is delivered individually and the handler can retrieve the full set of pending signals using sigwaitinfo().
Real-time signals carry a siginfo_t payload with the sender's PID, UID, and an optional pointer-sized value (si_value.sival_ptr or si_value.sival_int) that can be used to pass data. They are delivered in numerical order (lowest signal number first). Standard signals (SIGTERM, SIGINT, SIGUSR1, etc.) cannot reliably carry data and do not queue, making real-time signals appropriate for applications that need multiple pending signal notifications.
The kill(pid, sig) syscall sends a signal to a specific process. The sender must have the same effective UID as the receiver (or be root), or the sender must have the CAP_KILL capability. This prevents arbitrary processes from sending signals to unrelated processes. The zero signal (SIGKILL number 0) is special: it validates that the target process exists and the sender has permission, without actually sending a signal.
For thread-level signals, pthread_kill(tid, sig) sends a signal to a specific thread within the same process, which is always permitted since threads share credentials. raise(sig) sends a signal to the current process (equivalent to kill(getpid(), sig)). tgkill(tgid, tid, sig) sends a signal to a specific thread, useful when thread IDs may be reused.
The signal mask is a per-thread set of signals that are currently blocked from delivery. Blocked signals are not delivered but remain pending. sigprocmask() manipulates the signal mask of the calling thread (in single-threaded processes). For multi-threaded programs, use pthread_sigmask() which operates on the calling thread's mask.
The critical pattern for atomic mask manipulation with synchronization: block the signal, enter a critical section, atomically unblock the signal while waiting (so the signal can be delivered immediately upon arrival), then re-block and handle the pending signal. The sigsuspend() syscall atomically replaces the signal mask with a specified mask and suspends the process, making the block/unblock/suspend sequence atomic and avoiding a race window between separate calls.
sigwaitinfo() synchronously waits for a specific pending signal, blocking until it arrives. It returns the signal info structure including sender PID, UID, and optional payload (for real-time signals). Unlike a signal handler, sigwaitinfo() does not involve asynchronous execution interruption—it runs in the context of the calling thread as a blocking call.
sigwaitinfo() is useful for dedicated signal-handler threads that avoid the complexity of async signal safety. The signal must be blocked (via sigprocmask) before calling sigwaitinfo(), otherwise the default handler may run instead. sigtimedwait() is a variant with a timeout. signalfd() provides an even cleaner interface by converting signals into file descriptor events readable by select()/poll()/epoll().
With SA_SIGINFO flag, the signal handler receives three arguments instead of one: void handler(int sig, siginfo_t *info, void *ucontext). The siginfo_t structure contains: the signal number, error number, signal code, sender PID and UID, address that caused the fault (for SIGSEGV, SIGBUS), and for real-time signals, the sival_ptr payload data.
This enables rich signal handling: a SIGCHLD handler can retrieve the child's PID and exit status without relying on wait() in a loop. A SIGSEGV handler can inspect the fault address to detect stack overflow (address near stack pointer) versus bad pointer access. SIGILL, SIGFPE, and SIGBUS handlers benefit similarly from code and address information. The ucontext contains saved register state for architectures that support it.
SIGKILL (signal 9) cannot be caught, blocked, or ignored. When delivered, the kernel immediately terminates the process without running any signal handler or cleanup code. The process does not have an opportunity to flush buffers, close files, or perform any graceful shutdown. For this reason, SIGKILL should be a last resort after SIGTERM has been given time to take effect.
In some states (like zombie), SIGKILL is not actually delivered to the process because the process is already exiting (zombie) and its entry will be reaped by the parent. SIGKILL also does not terminate processes in uninterruptible sleep (state D in ps) waiting for I/O or kernel resources—the process is stuck in the kernel and cannot be killed until the I/O completes or times out.
Processes belong to a process group (created via setpgid() or implicit inheritance from parent's process group). A terminal's foreground process group receives signals from the terminal (SIGINT from Ctrl+C, SIGTSTP from Ctrl+Z) and job control signals. Background process groups ignore terminal-generated signals by default but can receive them if they modify signal handling.
kill(-pgid, sig) sends a signal to all processes in the process group. SIGSTOP (not maskable) suspends a process group; SIGCONT resumes it. Shell job control uses these: Ctrl+Z sends SIGTSTP to the foreground process group, suspending it and returning control to the shell. jobs lists background jobs; fg/bg use SIGCONT to resume suspended jobs.
Certain signals (SIGQUIT, SIGILL, SIGTRAP, SIGABRT, SIGFPE, SIGSEGV, SIGBUS, SIGSYS) cause the kernel to write a core dump—a file named core or core.PID containing the process's memory image (registers, stack, memory mappings) when delivered to a process. This is the default action for these signals. SIGKILL and SIGSTOP never produce core dumps.
Core dump generation can be disabled (ulimit -c 0 or setrlimit(RLIMIT_CORE, 0)), limited by size, or configured via /proc/sys/kernel/core_pattern (which can pipe core dumps through a helper program like systemd-coredump). For production servers where core dumps are disabled, enabling them temporarily for crashes provides crucial debugging information. gdb can inspect a core dump to determine the crash location and stack trace.
SIGINT (signal 2, generated by Ctrl+C in a terminal) is the interrupt signal—a polite request to terminate, intended for interactive processes. Processes can catch or ignore SIGINT, allowing them to perform cleanup before exiting. SIGTERM (signal 15) is the terminate signal—a more formal termination request, also catchable and ignorable. Both allow the process to clean up.
For daemon processes and services, SIGTERM is the standard termination signal because it carries the semantic of "shut down gracefully." Container orchestrators like Docker send SIGTERM first and wait for a grace period before sending SIGKILL. Scripts and programs should treat SIGTERM as the signal to initiate graceful shutdown (close connections, flush buffers, exit cleanly) and SIGINT as a user interrupt in interactive contexts.
Signals are delivered to a single thread within a multi-threaded process. The kernel selects an arbitrary thread that is not currently blocking the signal (checking the thread's signal mask). If all threads are blocking the signal, the signal remains pending for the entire process until at least one thread unblocks it. The first thread to unblock that signal receives it.
Process-directed signals (sent to a PID) may be delivered to any thread. Thread-directed signals (sent to a specific thread ID via pthread_kill or tgkill) are delivered to that exact thread. When a process receives a signal with default action SIGIGN, all threads ignore it. When a thread handles a signal with a signal handler, the handler runs in that thread's context—all other threads continue execution normally.
signalfd() creates a file descriptor that wraps signal delivery into a file descriptor event model. You create it with signalfd(-1, &mask, SFD_CLOEXEC), which returns a file descriptor representing the set of blocked signals in mask. When any of those signals arrive, read(fd, &info, sizeof(info)) returns a signalfd_siginfo structure with signal details.
Advantages over traditional signal handlers: signals become first-class I/O events compatible with select()/poll()/epoll(), eliminating the need for the self-pipe trick to integrate signals into an event loop. The handler is invoked synchronously from read() rather than asynchronously at arbitrary points, so you can call any library function from the "handler" (which runs in your event loop context). signalfd is Linux-specific but available on all major Linux distributions.
SA_RESTART causes the kernel to automatically restart certain system calls that were interrupted by a signal. The kernel restarts: read(), write(), open(), ioctl(), waitpid(), creat(), and a few others. The restarted call does not return -1 with errno=EINTR—it acts as if it was never interrupted.
Not restarted: connect(), accept(), recvfrom(), sendto(), recvmsg(), sendmsg(), semop(), and tcdrain(). These must handle EINTR explicitly. The SA_RESTART flag applies to the specific signal handler being installed, not globally. This inconsistency is why many robust network programs explicitly handle EINTR rather than relying on SA_RESTART.
waitpid(-1, &status, 0) waits for any child process to terminate (equivalent to wait(&status)). waitpid(pid, &status, 0) waits for a specific child (by PID). For a specific child, if the child has already exited, the status is returned immediately. For any child (pid=-1), the call blocks until at least one child exits.
waitpid(-1, &status, WNOHANG) is non-blocking: it returns immediately with 0 if no child has exited, or the PID of a terminated child with its status. This is the basis of SIGCHLD handlers that reap children without blocking: install the handler, and periodically call waitpid(-1, NULL, WNOHANG) in the main loop to reap any exited children. Without WNOHANG in the loop, waitpid() would block when no children have exited.
By default, signal handlers execute on the same stack as the interrupted code. For programs that use small stacks (common in threaded programs where each thread has a small stack, like 8KB), a signal handler that needs deep recursion or large local variables could overflow the stack. sigaltstack() creates an alternative signal stack—a separate region of memory used for signal handler execution.
Use sigaltstack() to allocate a dedicated stack region (typically SIGSTKSZ or larger) and register it with SA_ONSTACK flag when installing the signal handler. On systems with limited stacks (embedded, some threading implementations), this prevents signal handler stack overflow. Linux's SA_SIGINFO handlers can also use ucontext_t to save and restore the full machine state when handling signals delivered during system calls.
eventfd() creates a file descriptor representing a 64-bit counter. write(fd, &value, 8) atomically adds value to the counter; read(fd, &value, 8) reads and resets the counter. The file descriptor is pollable via select()/poll()/epoll(), making it ideal for integrating event notification into an event loop without signal handling complexity.
Unlike signals, eventfd does not interrupt execution asynchronously—the read() blocks until data is available, which is the normal event-loop pattern. Unlike pipes (used in the self-pipe trick), eventfd requires only one file descriptor instead of two, supports epoll, and has lower overhead. It is particularly useful for thread notification, wake-up pipes in event loops, and synchronization between a signal handler thread and a main event loop.
Further Reading
- signal(7) — Linux man page — Overview of signals on Linux
- sigaction(2) — Linux man page — Signal action API reference
- signal-safety(7) — Linux man page — Async-signal-safe functions list
- kill(2) — Linux man page — Sending signals between processes
- Pipes & Named Pipes (FIFO) — pipe-based IPC — Anonymous and named pipe communication
- Process Concept — process management and scheduling — Process creation, termination, and lifecycle management
Conclusion
Signals are the oldest form of process communication in Unix systems — async notifications that can interrupt execution at any point. While the concept is simple, signal handling exposes the tension between the kernel’s event delivery model and safe user-space execution. The rules around async-signal-safety, EINTR handling, and signal mask management exist because signals cross protection boundaries in ways that normal function calls do not.
As systems evolved, signals gave way to more sophisticated event delivery mechanisms: event loops with select()/poll()/epoll(), kernel event queues in modern kernels, and asynchronous I/O APIs. Yet signals persist because they remain the only kernel-to-process notification mechanism for certain events (child exit, resource limits, crashes) that have no alternative.
For continued learning, explore how eventfd() provides a signal-like notification mechanism compatible with epoll(), and study how modern Linux uses real-time signals (SIGRTMIN to SIGRTMAX) with siginfo delivery for richer signal payloads beyond simple notifications.
Category
Related Posts
ASLR & Stack Protection
Address Space Layout Randomization, stack canaries, and exploit mitigation techniques
Assembly Language Basics: Writing Code the CPU Understands
Learn to read and write simple programs in x86 and ARM assembly, understanding registers, instructions, and the art of thinking in low-level operations.
Boolean Logic & Gates
Understanding AND, OR, NOT gates and how they combine into arithmetic logic units — the building blocks of every processor.