AlgoMaster Logo

Signals

22 min readUpdated August 7, 2026
Listen to this chapter
Unlock Audio

When an operator runs:

the operating system does not immediately call the server's cleanup function. It sends a small asynchronous notification named SIGTERM.

The notification can arrive while the server is parsing a request, modifying a data structure, waiting for input, or executing inside a library. The server must decide what SIGTERM means and handle it without corrupting its own state.

This notification mechanism is called a signal.

A signal tells a process or thread that an event has occurred. It can interrupt the normal flow of execution, invoke a handler, or trigger a predefined operating-system action.

Signals support termination requests, terminal interaction, child-process notifications, timers, fault reporting, and application-defined events. Their compact interface is powerful, but their asynchronous nature requires care.

The Signal Lifecycle

A signal moves through three conceptual stages:

  1. Generation: an event causes the signal to be created.
  2. Pending: the signal exists but has not yet been delivered.
  3. Delivery: the operating system applies the signal's configured action.

Generation and delivery are separate events. A signal can sit in the pending state for a long time, and what happens at the end depends on the disposition the process has set.

Generation and delivery are not necessarily simultaneous. If the destination thread currently blocks that signal, it remains pending until it becomes unblocked.

A signal carries a type, represented in programs by a symbolic name such as SIGTERM or SIGINT. Programs should use these names rather than hard-coded signal numbers because numeric assignments can vary across systems and processor architectures.

Where Signals Come From

Signals can originate from several places.

The kernel generates signals for events such as an invalid memory access, an expired timer, or a child-process state change. A terminal can generate a signal when the user types a control character such as Ctrl+C. One process can request delivery to another process, and a process can signal itself.

Examples include:

  • Ctrl+C commonly generates SIGINT for a terminal-controlled program.
  • Writing to a pipe with no reader can generate SIGPIPE.
  • Accessing an invalid address can generate SIGSEGV.
  • A child terminating can generate SIGCHLD for its parent.
  • An operator or supervisor can send SIGTERM to request shutdown.

Signals report that something happened. They are not general-purpose messages containing arbitrary application data, and most standard signals do not count repeated occurrences reliably.

Common Signals and Default Actions

Every signal has a default action. The most useful signal names are consistent across POSIX systems, although some details remain operating-system-specific.

SignalTypical cause or purposeDefault action
SIGTERMPolite termination requestTerminate
SIGINTInteractive interruption, commonly Ctrl+CTerminate
SIGQUITInteractive quit requestTerminate and produce a core dump
SIGHUPTerminal disconnection; often repurposed for reloadTerminate
SIGCHLDA child changed stateIgnore
SIGPIPEWrite to a pipe or socket with no readerTerminate
SIGSEGVInvalid memory accessTerminate and produce a core dump
SIGSTOPUnconditional stop requestStop
SIGCONTResume a stopped processContinue
SIGKILLUnconditional termination requestTerminate

The descriptions indicate common use, not universal application policy. For example, many daemons choose to interpret SIGHUP as a configuration-reload request, but that meaning comes from the program's installed handler rather than from the kernel.

Core-dump production also depends on system configuration and resource limits. “Core” describes the signal's default action even when no dump file is ultimately written.

Loading simulation...

Dispositions: Default, Ignore, or Catch

Each signal has a disposition that determines what happens when it is delivered.

A process can generally choose one of three dispositions:

  • Perform the signal's default action
  • Ignore the signal
  • Catch the signal by running a programmer-defined handler

The disposition is process-wide. In a multithreaded program, every thread sees the same installed disposition for a given signal.

Two signals are deliberately outside this control:

  • SIGKILL cannot be caught, ignored, or blocked.
  • SIGSTOP cannot be caught, ignored, or blocked.

These guarantees give the operating system and authorized administrators a final way to stop or terminate a process, even if its signal-handling code is broken.

SIGTERM is different. A process can catch it, ignore it, or block it. That makes graceful shutdown possible, but it also means that sending SIGTERM is a request rather than a guarantee of immediate termination.

Sending a Signal

The shell's kill command sends signals despite its destructive-sounding name:

Signal names make intent clearer than commands such as kill -9. SIGKILL should usually be a last resort because the target gets no opportunity to flush application buffers, release distributed leases, finish requests, or report why it stopped.

In C, a positive PID targets one process:

Sending signal number 0 performs existence and permission checks without delivering a signal:

This is only a momentary observation. The process can exit immediately afterward, and its PID can eventually be reused. It is not a durable process handle.

The sender must also have permission. A process normally cannot signal an arbitrary process belonging to another user merely because it knows the PID.

sigaction() for Signal Handlers

POSIX provides sigaction() to inspect or change a signal's disposition:

The older signal() function has historically varied in behavior across Unix systems. sigaction() expresses the handler, temporary mask, and behavior flags explicitly and is the appropriate interface for robust code.

Its main fields are:

sa_mask lists additional signals to block while the handler executes. The signal currently being handled is also normally blocked during its own handler, preventing unbounded recursive entry.

sa_flags enables optional behavior. Important examples include SA_RESTART, which requests automatic restart for some interrupted operations, and SA_SIGINFO, which selects a three-argument handler that receives additional information about the signal.

A Safe Minimal Shutdown Handler

A signal handler can run between almost any two instructions in the main program. The safest pattern is therefore to make the handler do almost nothing: record the request and return.

Compile and exercise it:

The handler only assigns to stop_requested. Normal program flow notices the flag and performs cleanup where the full C library is available.

sig_atomic_t is an integer type that can be read and written atomically with respect to a signal handler. volatile prevents the compiler from assuming that the value cannot change unexpectedly.

This combination does not make the variable a general synchronization primitive for multiple threads. Thread-to-thread communication requires the language's atomic or synchronization facilities.

Why Handlers Must Be Tiny

A handler can interrupt code that already holds an internal library lock or is halfway through modifying shared state.

Imagine that the main flow is inside printf() updating a buffered stream. A signal arrives, and the handler calls printf() again. The second call can encounter partially updated state or attempt to acquire a lock already held by the interrupted first call.

For that reason, POSIX defines a limited set of async-signal-safe functions. Only those functions are guaranteed to be callable from an asynchronous signal handler.

Common operations that should not be performed in a handler include:

  • printf() and most standard buffered I/O
  • malloc() and free()
  • Acquiring a normal thread mutex
  • Most application logging frameworks
  • Complex container or data-structure updates

write() and _exit() are examples of async-signal-safe operations, but even safe calls should be kept minimal. A handler that uses errno must save its incoming value and restore it before returning.

The simple rule for application code is:

Record the event in the handler; process the event in normal control flow.

How a Handler Interrupts and Returns

When the kernel is about to return to user mode, it checks for a pending signal that the current thread can receive.

If a handler is installed, the kernel arranges a user-space stack frame containing the interrupted execution context and begins the handler. When the handler returns, a signal-return mechanism restores that context.

Conceptually:

Normal code runs, a signal is delivered, the handler executes, and the interrupted code then resumes.

The interrupted code does not call the handler in the ordinary function-call sense. It may have been inside an unrelated function with no expectation that application logic would run at that instant.

This explains both the usefulness and danger of handlers. They let a process react promptly, but they introduce asynchronous control flow into nearly any point in the program.

Interrupted System Calls and EINTR

Suppose a thread is blocked in an operating-system operation when a handler runs. After the handler returns, one of two broad outcomes is possible:

  • The operation resumes automatically.
  • The operation returns an error with errno set to EINTR.

The result depends on the operation, operating system, and flags used when installing the handler.

SA_RESTART asks the system to restart certain interrupted calls:

It does not restart every possible operation. Some waits and timeout-related interfaces still report EINTR, and an I/O operation that already transferred data may return a partial success instead of an error.

Robust code handles the documented contract of each operation. The shutdown example deliberately leaves SA_RESTART unset, detects EINTR from nanosleep(), and checks the shutdown flag before sleeping again.

Blindly retrying every EINTR can also be wrong. If interruption represents a shutdown request, immediately repeating a long blocking operation without consulting program state can delay shutdown indefinitely.

Signal Masks and Pending Signals

Each thread has a signal mask, which is the set of signals currently blocked for that thread.

Blocking is not the same as ignoring:

  • An ignored signal is discarded according to its disposition.
  • A blocked signal is normally retained as pending for later delivery.
  1. SIGTERM is generated.
  2. SIGTERM is currently blocked, so it remains pending.
  3. The thread unblocks SIGTERM.
  4. SIGTERM becomes eligible for delivery.

A single-threaded program can change its mask with sigprocmask(). A multithreaded program should use pthread_sigmask(), which explicitly changes the calling thread's mask.

Signal sets are manipulated with functions rather than integer bit operations:

The kernel silently prevents SIGKILL and SIGSTOP from being added to an effective blocked set.

Standard Signals vs. Event Queues

Suppose SIGUSR1 is blocked and is generated five times before it becomes unblocked.

For a standard signal, the kernel generally records that SIGUSR1 is pending, not that five separate instances arrived. When it is unblocked, the program may observe only one delivery.

Five arrivals produce one pending state, not a queue of five. A handler written to assume one run per generated signal will undercount.

This means a standard signal should not be used as a reliable job counter. A handler that increments a counter still cannot recover generations that were coalesced before delivery.

POSIX real-time signals provide queued delivery, defined ordering, and optional accompanying values. They are useful for specialized designs, but a pipe, queue, event descriptor, or other normal communication mechanism is often easier for transmitting application data.

When real-time signals are used, refer to them relative to SIGRTMIN and verify that the chosen value does not exceed SIGRTMAX; their absolute numbers must not be hard-coded.

Loading simulation...

Avoiding the Check-Then-Sleep Race

This pattern contains a race:

The signal can arrive after the condition is checked but before pause() begins. The handler sets event_arrived, returns, and then the program sleeps while waiting for a signal that has already been handled.

Correct interfaces combine mask changes with waiting atomically. sigsuspend() temporarily installs a mask and sleeps in one operation. pselect() and ppoll() provide the same kind of atomic mask transition while waiting for file-descriptor activity.

The general pattern is:

  1. Block the relevant signal.
  2. Check or modify shared state while it cannot be delivered.
  3. Atomically unblock it as part of entering the wait.
  4. Recheck the condition after waking.

This is the signal equivalent of protecting an ordinary shared-state condition from a lost wakeup.

Process-Directed and Thread-Directed Signals

Signal dispositions belong to the process, but signal masks belong to individual threads.

A process-directed signal targets the process as a whole. If it is caught, the kernel selects one thread that does not block it to perform the delivery. Code should not assume that an arbitrary process-directed signal will run on a particular worker thread.

A thread-directed signal targets one specific thread. Hardware-fault signals such as SIGSEGV are normally directed to the thread that executed the faulting instruction. Interfaces such as pthread_kill() can explicitly target a thread.

These rules create a common source of bugs:

The disposition is process-wide while the masks are per-thread, and the combination means delivery can occur on any eligible thread.

Installing one handler does not make one thread the permanent “signal thread.” A disciplined multithreaded design must control masks across all threads.

A Robust Multithreaded Pattern with sigwait()

Instead of running an asynchronous handler, a thread can accept blocked signals synchronously with sigwait().

The important sequence is:

  1. Block the chosen signals before creating worker threads.
  2. Let new threads inherit that blocked mask.
  3. Have one coordinating thread call sigwait().
  4. Perform ordinary synchronized application logic after it returns.

The following program uses its main thread as the signal coordinator:

Compile it with thread support:

Press Ctrl+C or send SIGTERM from another terminal. sigwait() returns the signal number as ordinary control flow, so the coordinator can safely use printf(), atomics, mutexes, and other normal facilities.

The initial pthread_sigmask() call must occur before pthread_create(). A new thread inherits a copy of its creator's mask, which ensures that the selected signals remain blocked in the worker and are available for the coordinator's sigwait().

SIGCHLD: Notification, Not Reaping

When a child changes state, its parent can receive SIGCHLD. That tells the parent to check its children; it does not by itself collect a terminated child's exit information.

Because standard signals can coalesce, several children may exit while only one SIGCHLD delivery is observed. Correct child management drains all currently completed children:

The loop, rather than the number of signal deliveries, determines how many children are ready to be collected.

Linux and POSIX also support configurations that prevent children from becoming zombies, such as explicitly ignoring SIGCHLD or using SA_NOCLDWAIT. Those choices discard normal waitable termination records and therefore change how the parent can obtain exit status.

A subtle point is that the default action for SIGCHLD is listed as “ignore,” but explicitly setting its disposition to SIG_IGN has special child-reaping semantics. The default state and an explicit ignore are not interchangeable here.

SIGPIPE in Backend Services

If a process writes to a pipe or connected socket after the peer has closed its reading side, the write can generate SIGPIPE. Its default action terminates the process.

That behavior is convenient in a simple command pipeline: an upstream command can disappear automatically when no downstream reader remains. It can be surprising in a network service, where one disconnected client should not normally terminate the entire server.

Servers commonly prevent the default termination and handle the write failure through an error result such as EPIPE. Depending on the interface and platform, this may involve ignoring SIGPIPE, changing its disposition, or using a send option that suppresses it for one operation.

Ignoring SIGPIPE globally affects every thread and library in the process, so the decision belongs in the application's I/O policy rather than in an isolated helper function.

Fault Signals vs. Recovery Exceptions

Signals such as SIGSEGV, SIGBUS, SIGILL, and SIGFPE can report faults caused by the currently executing instruction.

It is tempting to install a SIGSEGV handler, log the error with ordinary application code, and continue. That is generally unsafe:

  • The handler may interrupt code while memory or locks are already corrupted.
  • Returning can retry the same faulting instruction.
  • Most logging and allocation facilities are not async-signal-safe.
  • The program's invariants may no longer be trustworthy.

Low-level runtimes and debuggers sometimes use advanced recovery techniques with carefully controlled execution contexts. Ordinary applications should treat these signals as crash conditions.

A minimal crash path may record a small amount of information using safe operations and then terminate, but preserving the default core-dump behavior is often more useful for diagnosis than attempting elaborate cleanup in a damaged process.

Signals Across fork() and exec()

Signal state does not have one uniform inheritance rule.

Signal propertyAfter fork()After successful exec()
DispositionsChild receives copiesCaught dispositions reset to default; ignored dispositions remain ignored
Signal maskChild receives a copyPreserved
Pending signalsChild starts with nonePreserved on Linux

Resetting caught handlers during exec() is necessary because the handler's code belonged to the replaced program image. An ignored disposition can remain meaningful without referring to old code.

Preserving the mask can cause a subtle bug. If a launcher blocks SIGTERM, executes another program, and forgets to restore the old mask first, the new program begins with SIGTERM blocked even though its caught handler was reset.

Code that prepares to execute another program should therefore review both inherited dispositions and the current mask.

Inspecting Signal State on Linux

Linux exposes signal state in /proc:

The fields are hexadecimal bit masks:

  • SigPnd shows signals pending for the inspected thread.
  • ShdPnd shows process-directed pending signals.
  • SigBlk shows blocked signals.
  • SigIgn shows ignored signals.
  • SigCgt shows signals with installed handlers.

For a specific thread in a multithreaded process, inspect:

The kill -l command lists signal names known to the shell. On Linux, tracing tools can also display signal generation and delivery:

Attaching a tracer changes timing and may require elevated permission, so use its output as diagnostic evidence rather than as a perfectly passive observation.

Summary

  • A signal is an asynchronous notification that moves through generation, pending, and delivery.
  • Each signal has a process-wide disposition: default action, ignore, or a handler; SIGKILL and SIGSTOP cannot be overridden or blocked.
  • Handlers can interrupt nearly any code and must use only async-signal-safe operations.
  • A minimal handler should record an event and let normal control flow perform cleanup.
  • Masks are per-thread, and blocked signals normally remain pending until unblocked.
  • Standard signals can coalesce, so they must not be treated as reliable event counters.
  • Multithreaded services can block selected signals in all workers and accept them synchronously with sigwait().
  • Correct production handling includes deliberate policies for graceful termination, SIGCHLD, SIGPIPE, interrupted operations, and inherited signal state.

Quiz

Signals Quiz

5 quizzes