Skip to content
academia.sh

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_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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close