Lesson 03 / 15
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.
Published Updated
In this lesson
- 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
Contents
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.
// 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
// 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);
}
// 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;
}
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_tis 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-
volatilevariable, is undefined behavior; this lesson runs neither. volatile sig_atomic_tin C andvolatilein 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.