---
title: Signals
source: 'https://academia.sh/en/courses/c-systems-programming/signals'
course: 'Systems Programming with C'
language: en
updated: '2026-09-26T03:02:45+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'measures, through volatile sig_atomic_t, that the flag a handler raises reaches the main flow'
    - "observes a blocked signal's pending state directly with sigpending and shows it is delivered only once when the mask is lifted"
    - 'explains in prose why an unsafe call inside a handler and sharing a non-volatile variable are undefined behavior'
---

# Signals

A signal handler runs outside the main flow, at an unexpected point; the only safe form of sharing is a volatile sig_atomic_t flag, and if a signal arrives again while blocked it is delivered only once when it is unblocked.

`fork` gave a copy, and the two sides stayed unaware of each other. This lesson
measures a different way of carrying a change: a **signal**. While a process
continues its normal flow, the kernel stops it, runs a registered handler, the
handler returns, and the flow continues where it left off. The handler and the main
flow share the same memory space, but this sharing is not a copy like `fork`'s — the
trace the handler leaves is a handoff that reaches the main flow **once**. The
course's class **handoff** describes exactly this.

This lesson uses `raise(SIGUSR1)`, not `kill` or an external source — so the
measurement is self-contained. In real use a signal can come from three sources:
another process sends it with a `kill` call, the kernel generates it in response to
a hardware condition (invalid memory access, an arithmetic error), or the process
sends it to itself with `raise`. All three use the same delivery mechanism — the
`handler` cannot tell which path it came through, it only knows which signal number
arrived. The choice of `SIGUSR1` is not arbitrary either: POSIX leaves this pair
(`SIGUSR1`, `SIGUSR2`) specifically to the application, the kernel does not attach a
predefined meaning to them; that is why this measurement can run without risking a
change to the default handler of a signal with a predefined behavior, like `SIGINT`
or `SIGTERM`.

## The Flag Raised by the Handler

The measurement units stay the same. This lesson's own file, `signal_measurement.c`,
measures three separate questions: does the flag the handler raises reach the main
flow, does the kernel make a blocked signal visible as pending while it is masked,
and how many times is it delivered when the mask is lifted.

```c
// observe.h — a record that classifies whom a change reaches
#ifndef OBSERVE_H
#define OBSERVE_H

enum Class { COPY, SHARED, HANDOFF, UNOBSERVED };

/* Records one change: seen is whether the side that did not make the
   change can read it (1 or 0). */
void observe(const char *change, enum Class cls, int seen);

/* Prints the count per class and the reached / not reached totals. */
void observe_summary(void);

#endif
```

```c
// observe.c — the counts live only in this unit
#include <stdio.h>
#include "observe.h"

static const char *const names[] = { "copy", "shared", "handoff", "unobserved" };
static int counts[4];
static int reached = 0;
static int not_reached = 0;

void observe(const char *change, enum Class cls, int seen) {
    /* shared and handoff reach, copy and unobserved do not */
    int expected = (cls == SHARED || cls == HANDOFF);
    counts[cls]++;
    if (seen) {
        reached++;
    } else {
        not_reached++;
    }
    printf("%-34s %-8s %s%s\n", change, seen ? "saw" : "missed",
           names[cls], seen == expected ? "" : "  MISMATCH");
}

void observe_summary(void) {
    printf("classes: copy %d, shared %d, handoff %d, unobserved %d\n",
           counts[COPY], counts[SHARED], counts[HANDOFF], counts[UNOBSERVED]);
    printf("reached %d, not reached %d\n", reached, not_reached);
}
```

```c
// signal_measurement.c — the flag raised by the handler and the blocked delivery count
#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include "observe.h"

static volatile sig_atomic_t flag = 0;
static volatile sig_atomic_t delivery_count = 0;

static void handler(int s) {
    (void)s;
    flag = 1;
    delivery_count++;
}

int main(void) {
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGUSR1, &sa, NULL);

    /* first measurement: does the flag raised by the handler reach the main flow */
    raise(SIGUSR1);
    observe("flag raised by the handler", HANDOFF, flag == 1);

    /* second measurement: a signal arriving twice while blocked is delivered once when unblocked */
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGUSR1);
    sigprocmask(SIG_BLOCK, &set, NULL);

    delivery_count = 0;
    raise(SIGUSR1);
    raise(SIGUSR1);
    int while_blocked = (delivery_count == 0);

    /* third measurement: is the signal seen as pending while under the mask */
    sigset_t pending_set;
    sigpending(&pending_set);
    int is_pending = sigismember(&pending_set, SIGUSR1);
    observe("signal appears pending while masked", HANDOFF,
           while_blocked && is_pending == 1);

    sigprocmask(SIG_UNBLOCK, &set, NULL);
    observe("signal delivery count on unblocking", HANDOFF,
           while_blocked && delivery_count == 1);

    observe_summary();
    return 0;
}
```

```text
flag raised by the handler         saw      handoff
signal appears pending while masked saw      handoff
signal delivery count on unblocking saw      handoff
classes: copy 0, shared 0, handoff 3, unobserved 0
reached 3, not reached 0
```

**SI7.** The first measurement is direct: `raise(SIGUSR1)` tells the kernel to stop
the process and run `handler`, `handler` sets `flag` to `1`, and after `raise`
returns the main flow reads this flag and finds `1`. The flag is of type
`volatile sig_atomic_t` — this type choice is not arbitrary, it is the counterpart of
a rule: the compiler must re-read a variable of this type from memory on every read,
and must not leave a read or write half done. The handler cuts in independently of
the main flow, at an unexpected point; a non-`volatile` variable could be kept by the
compiler in a register, and a change the handler makes might never be reflected in
the copy the main flow sees.

## Held Under the Mask

**SI8.** The second measurement directly observes the pending state. A signal sent
after a call to `sigprocmask(SIG_BLOCK, ...)` is not delivered right away; the kernel
keeps it as **pending**, but unlike the previous measurement, which read this
indirectly (through a counter not incrementing), this can be read **directly** with
`sigpending`: a call to `sigpending(&pending_set)` fills in the set of the calling
process's blocked, not-yet-delivered signals, and `sigismember(&pending_set, SIGUSR1)`
asks whether `SIGUSR1` is in that set. After two `raise` calls, before delivery
happens, this query returns `1` — the kernel has not lost the signal, has not
processed it, it is holding it somewhere. This is not a prose description of the word
"pending," it is an executable measurement: `signal_measurement.c` shows that a state
really exists under the mask, and that this state can be queried by the process.

**SI9.** The third measurement counts what this pending state does **on unblocking**.
Even if the same signal is raised twice while blocked, the pending state is singular
for most standard signals — not a count of copies to deliver, only the information
"is one pending" is kept. When the mask is lifted (`SIG_UNBLOCK`), the kernel
delivers the pending signal, but only **once**: `delivery_count` stays `0` for the
duration of the block, and becomes `1` on unblocking, not `2`. Read together with the
`sigpending` measurement, these two findings give a single picture: while blocked,
the set holds **one bit** of information — "one is pending" or "none is" — and this
bit is independent of how many times `raise` was called. The bit turns into one
delivery on unblocking, not into as many deliveries as the counter's value.

`sigaction` itself uses the same singularity rule while a handler runs. `sa.sa_mask`
was set to the empty set, but the kernel still automatically blocks **the signal
itself** during every handler call — unless the `SA_NODEFER` flag is explicitly
requested, a handler is not reentered with a second copy of the same signal it is
running for. This is a small example of the "held under the mask" that
`signal_measurement.c` measures: the same signal arriving while inside the handler
is not lost, it is held until the handler returns, then delivered once. The
`sa_mask` field exists to widen this automatic blocking — if a handler deals with
more than one signal type, the others also need to be added to this field to prevent
a race between them; this lesson works with a single signal, so it leaves it empty
with `sigemptyset`.

Another use of blocking is to prevent a check-and-act race. If a program is going to
read a flag and act on it, a signal can arrive between the read and the action and
change the flag; this lesson does not test that, but the pattern is briefly this: the
signal in question is blocked with `sigprocmask(SIG_BLOCK, ...)`, the flag is read
and a decision made, then the mask is reopened. That way the "read and decide" step
stays inside a window closed to a signal arriving in between —
`signal_measurement.c`'s `sigpending` measurement rests on the same mechanism that
confirms a signal held pending inside that window is really sitting there, not lost.

## Undefined Behavior Inside a Handler

**SI10.** The `handler` function is only two lines: raising a flag and incrementing a
counter. This brevity is a rule, not a preference. A signal handler can cut into the
main flow at **any** point — in the middle of a `malloc` call, inside a piece of code
holding a lock, even at the moment `printf` is updating its own internal buffer.
Making a call inside a handler that is not async-signal-safe — `malloc`, `printf`,
any function that accesses a locked resource — can collide with the code it
interrupted; this is **undefined behavior**, and this lesson does not run such a
call, only describes it here. Likewise, sharing a non-`volatile` variable between the
handler and the main flow is also undefined behavior: since the compiler does not
know the variable can be changed by the handler, it may reorder or entirely skip
reads in the main flow. That is why `signal_measurement.c` only touches variables of
type `sig_atomic_t` marked `volatile`, and calls no function at all inside the
handler. This restriction is not just "good practice"; if the interrupted call is
holding a library's internal lock or static buffer right in the middle, and the
handler tries to use that same lock or buffer again, the result can be a deadlock,
corrupted data, or a random crash — neither the compiler nor the runtime reports any
error here, the behavior falls outside the definition entirely.

POSIX defines a small set of functions that are safe to call inside a handler —
direct system calls like `write`, `_exit`, `sigprocmask`, and `sigpending`, which
this lesson uses, are on that list, but buffered I/O (`printf`, `fopen`), memory
allocation (`malloc`, `free`), and most library functions are not. The reason for
this distinction is buffering and internal state: `printf` updates its own internal
buffer and a possible lock, and calling the same function again inside a handler
while that update is left half done can leave that internal state inconsistent.
`signal_measurement.c` only does `flag = 1` and `delivery_count++` — both are direct
memory writes, involving no internal state or lock, so they run safely inside a
handler.

This distinction is a different axis from the previous course's question of "which
thread runs this step": a signal handler is not a separate thread, it is the same
flow being interrupted at an unexpected point. The interrupted code and the handler
temporarily share the same stack and the same register set — that is why the trace a
handler leaves is not a copy like `fork` gives, it is a handoff back into the same
flow.

This sharing narrows further in a multithreaded process: in POSIX a signal handler
is registered at the process level, but which thread runs it depends on delivery — a
signal a thread directs at itself (with `pthread_kill`) runs on that thread, while a
signal directed at the process can run on any unmasked thread. This lesson runs
single-threaded, so this ambiguity does not surface; when the next lesson introduces
multiple threads, the question of which flow runs the signal handler stays as a
boundary that is outside that lesson's scope but worth knowing.

**SI11.** Lesson M08/K04 treated `volatile` in Java inside a multithreaded memory
model, as a keyword giving visibility and ordering guarantees: a contract coordinated
between the runtime and the hardware, ensuring a value one thread writes is seen by
another thread in the correct order. The guarantee `volatile sig_atomic_t` gives here
is much narrower: it does not establish a visibility order among multiple cores or
threads, it only prevents the compiler from keeping that variable in a register or
reordering reads or writes of it, when the **same** flow is interrupted by a signal
handler. The C standard does not define this type for multiprocessor memory
ordering; POSIX guarantees it only for an asynchronous interruption within the same
process. The same keyword in two languages solves two different problems: one
crosses a single-flow interruption boundary, the other crosses a multicore hardware
memory-consistency boundary.

## The Bounding Measurement

This measurement does not **count** standard signals. For POSIX standard signals the
pending state is a flag, not a counter: no matter how many times the same signal is
sent while blocked, only one delivery happens on unblocking. `signal_measurement.c`
shows this exactly — against two `raise` calls, `delivery_count` only rises to `1`.
Real-time signals (`SIGRTMIN` and above) follow a different rule and can accumulate
in a queue, but this lesson is limited to standard signals and does not take up that
difference.

This boundary also determines what the measurement can confirm:
`signal_measurement.c` counts not how many times `raise` was called during the
blocked period, but only how many times delivery happened after unblocking. Even if
two `raise` calls collapse into a single delivery, this is not a loss — the guarantee
the kernel gives for standard signals is already "at least once," not "once per
call." The lesson does not count anything beyond that guarantee.

This lesson also uses `sigaction`, not the `signal()` function; that is not a style
choice. The old `signal()` interface's behavior differs across systems — on some
older Unix variants the handler reverts to the default after being called, on others
it does not — and this ambiguity is exactly what portable code wants to avoid.
`sigaction` gives explicit control over the handler's persistence, which additional
signals are blocked during the handler (`sa_mask`), and whether an interrupted system
call is restarted (`SA_RESTART`). This lesson does not set `SA_RESTART`; that means a
blocking system call interrupted by a signal could return with an `EINTR` error, but
since `signal_measurement.c` contains no such call, this behavior is not observed in
this lesson — it is only noted here as a boundary to be met later.

## Summary

- A signal handler runs outside the main flow, at an unexpected point; the trace it
  leaves returns to the main flow within the same process.
- `volatile sig_atomic_t` is the only safe form of sharing between a handler and the
  main flow; it prevents compiler reordering and half-done reads or writes.
- This lesson's class is **handoff**: the flag the handler raises reaches the main
  flow, but it is not shared continuously like a record — it is carried once.
- The pending state can be observed directly with `sigpending`: while masked, the
  information held in the set is a single bit, "is one pending" — not how many times
  it was sent; that is why a blocked signal sent several times is still delivered
  only once when the mask is lifted.
- Making a call inside a handler that is not async-signal-safe, or sharing a
  non-`volatile` variable, is undefined behavior; this lesson runs neither.
- `volatile sig_atomic_t` in C and `volatile` in Java carry the same word but solve
  different problems: one is single-flow interruption safety, the other is
  multithreaded visibility guarantee.

## Next Step

The next lesson continues the handoff class, but through a different channel: the
bytes a pipe carries, a handoff that flows once from child to parent and ends with
end-of-file on the second read. The common ground is this: the trace carried in a
signal was a single bit or a single counter increment, while the trace carried in a
pipe will be a byte sequence of variable length — both follow the same "once,
irreversible" handoff rule, but the shape and amount of the information they carry
are fundamentally different.
