Systems programming with POSIX · about 60 min

Signals and the environment

Why this matters

When you press Ctrl-C, when docker stop or systemctl stop ends a service, when a program reads past the end of an array and "segfaults", when a container is killed for using too much memory: each time, a signal is delivered to a process. Signals are the operating system's way of interrupting a program asynchronously, and handling the right ones is what separates a program that loses data when stopped from one that saves its state and exits cleanly. The second half of this lesson covers the environment, the other channel (besides arguments) through which a process receives information from whoever started it: PATH, HOME, LANG, and every DATABASE_URL in the world of deployed software.

What a signal is

A signal is a small integer notification sent to a process by the kernel or by another process. When it arrives, the process's normal flow is interrupted and one of three things happens: the default action (terminate, terminate with a core dump, stop, continue or ignore, depending on the signal), the signal is ignored, or a handler function you installed runs, after which the program continues where it was interrupted.

signalnumbersent whendefault
SIGINT2Ctrl-C in the terminalterminate
SIGQUIT3Ctrl-\terminate + core
SIGKILL9kill -9, out-of-memory killerterminate, cannot be caught or ignored
SIGSEGV11invalid memory accessterminate + core
SIGPIPE13write to a pipe with no readerterminate
SIGALRM14timer set with alarm expiredterminate
SIGTERM15polite request to stop (kill default)terminate
SIGCHLD17a child process endedignore
SIGUSR1, SIGUSR210, 12whatever your program decidesterminate
SIGSTOP19stop (pause) the process, cannot be caughtstop

(Numbers are those of Linux on x86-64; use the names in code.) kill -l lists them all, and in the sandbox man 7 signal describes every one.

When a signal kills a process, the parent sees it through WIFSIGNALED/WTERMSIG (previous two lessons), and shells report status 128 + N:

$ sleep 100 &
$ kill -KILL %1
$ wait %1; echo $?
137

Sending signals

From the shell: kill -TERM PID (or just kill PID), kill -USR1 PID, Ctrl-C (SIGINT to the foreground process group). From C: kill(pid, sig) from <signal.h>, and raise(sig) to signal yourself. A process may only signal processes of the same user (unless privileged).

Handling signals with sigaction

The portable way to install a handler is sigaction:

#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static volatile sig_atomic_t stop = 0;

static void on_signal(int sig)
{
    (void)sig;
    stop = 1;                 /* only set a flag: nothing else is safe here */
}

int main(void)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_signal;
    sigemptyset(&sa.sa_mask);
    if (sigaction(SIGINT, &sa, NULL) < 0 || sigaction(SIGTERM, &sa, NULL) < 0) {
        perror("sigaction");
        return 1;
    }
    printf("working (pid %ld); press Ctrl-C or send SIGTERM\n", (long)getpid());
    fflush(stdout);
    int ticks = 0;
    while (!stop) {
        sleep(1);             /* returns early when a signal arrives */
        ticks++;
    }
    printf("signal received after %d ticks: saving state and exiting cleanly\n", ticks);
    return 0;
}

Started in the background and sent SIGTERM after about two and a half seconds:

$ ./prog > o.txt & sleep 2.5; kill -TERM $!; wait $!; echo "status $?"
status 0
$ cat o.txt
working (pid 12); press Ctrl-C or send SIGTERM
signal received after 3 ticks: saving state and exiting cleanly

(The PID and tick count vary.) Without the handler, the status would be 143 and the second line would never appear. The pieces:

What a handler may do

A handler runs in the middle of whatever the program was doing: perhaps halfway through printf updating its buffer, or malloc updating its free lists. If the handler calls printf or malloc itself, it re-enters code that is in an inconsistent state, with results ranging from garbled output to deadlock. So handlers may only call async-signal-safe functions (write, _exit, kill and a list of others in signal-safety(7)), and the standard pattern is to do almost nothing:

  1. Set a flag of type volatile sig_atomic_t. sig_atomic_t is an integer type that is read and written in one indivisible step; volatile tells the compiler that the value can change behind its back, so it must reload it every time the loop checks it, instead of caching it in a register and looping forever.
  2. Let the main loop notice the flag and do the real work: save files, close connections, print.

Interrupted system calls

When a signal arrives during a blocking call like sleep, read or waitpid, the handler runs and the call returns early (sleep returns the seconds left; read returns -1 with errno == EINTR). That is useful, since it lets the loop notice the flag promptly, and it is why the write_all helper from the first lesson of this module retries on EINTR.

The environment

Every process has an environment: a list of NAME=value strings, copied from its parent at fork and passed on through exec. It is how settings flow down the process tree without being mentioned on every command line:

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

extern char **environ;

int main(void)
{
    const char *home = getenv("HOME");
    const char *editor = getenv("EDITOR");
    printf("HOME=%s\n", home ? home : "(unset)");
    printf("EDITOR=%s\n", editor ? editor : "(unset, using vi)");
    if (setenv("GREETING", "hello", 1) != 0)
        perror("setenv");
    printf("GREETING=%s\n", getenv("GREETING"));
    int n = 0;
    for (char **e = environ; *e != NULL; e++)
        n++;
    printf("%d variables in the environment\n", n);
    return 0;
}

In the sandbox:

HOME=/home/learner
EDITOR=(unset, using vi)
GREETING=hello
10 variables in the environment

And with EDITOR=nano ./prog, the second line becomes EDITOR=nano: the shell syntax NAME=value command sets a variable for that one command only.

Treat environment values as untrusted input: validate them like anything a user typed, with strtol and range checks for numbers.

Common mistakes

Recap

Going further: in the sandbox, man 7 signal and man 7 signal-safety are installed and list every signal and every async-signal-safe function. For a modern alternative to handlers, read about signalfd(2) at man7.org, which turns signals into data you read from a file descriptor.

Exercises

Exercise 1 · quiz

Exit status 137

A program in a container keeps dying, and the shell (or the container runtime) reports exit status

  1. What happened to it?
Answer
  • It was killed by signal 9, SIGKILL (128 + 9), very often by the kernel's out-of-memory killer or a timeout

Shells report a process killed by signal N as 128 + N. 137 = 128 + 9 = SIGKILL, which cannot be caught, so the program had no chance to clean up. In containers the usual culprit is the memory limit: the kernel's OOM killer sends SIGKILL. A segmentation fault is 139 (signal 11), SIGTERM is 143 (signal 15), and SIGINT from Ctrl-C is 130. A program could also exit(137) itself, but that would be perverse.

Exercise 2 · quiz

What may a handler do?

Which of these are safe to do inside a signal handler? Select all that apply.

Answer
  • Assign to a global volatile sig_atomic_t flag

  • Call write(2, msg, len) with a fixed message

  • Call _exit(1)

A handler can run between any two instructions of the main program, including in the middle of printf or malloc while they are updating their internal state. Calling them again from the handler can corrupt that state or deadlock on an internal lock. Only async-signal-safe functions may be called: write, _exit, and a list of others in signal-safety(7). Setting a volatile sig_atomic_t flag and letting the main loop do the real work is the standard pattern.

Exercise 3 · write code

Configuration from the environment

Many programs take settings from environment variables. Write a program that prints the word given by the environment variable WORD as many times as the variable COUNT says, one per line.

Use getenv from <stdlib.h>. (The check runs your program with different environments, for example COUNT=2 WORD=hi ./prog.)

$ ./prog
hello
hello
hello
$ COUNT=2 WORD=hi ./prog
hi
hi
$ COUNT=lots ./prog
bad COUNT: lots

File: main.c

Starting point

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const char *word = "hello";
    long count = 3;
    for (long i = 0; i < count; i++)
        printf("%s\n", word);
    return 0;
}

Example

expected output

hello
hello
hello
Hint 1

getenv returns NULL when the variable is not set; never pass that NULL to printf.

Hint 2

For COUNT: strtol(c, &end, 10). It is invalid if end == c (no digits) or *end != '\0' (junk after the number), or out of range.

Reference solution
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const char *word = getenv("WORD");
    if (word == NULL || word[0] == '\0')
        word = "hello";

    long count = 3;
    const char *c = getenv("COUNT");
    if (c != NULL) {
        char *end;
        count = strtol(c, &end, 10);
        if (end == c || *end != '\0' || count < 0 || count > 100) {
            fprintf(stderr, "bad COUNT: %s\n", c);
            return 2;
        }
    }
    for (long i = 0; i < count; i++)
        printf("%s\n", word);
    return 0;
}

Exercise 4 · write code

Shut down gracefully

Long-running programs are stopped with SIGTERM and should finish cleanly; SIGUSR1 is often used as a "do something" request (for example "reload configuration"). Write a program that:

  1. installs handlers with sigaction for SIGUSR1 (count how many arrived) and SIGTERM (request shutdown), both using only volatile sig_atomic_t variables;
  2. prints ready and flushes stdout;
  3. waits in a loop until shutdown is requested (use sleep(1) in the loop: it returns early when a signal arrives);
  4. then prints received N SIGUSR1, shutting down and exits with status 0.

The check starts your program in the background, sends it three SIGUSR1 signals a moment apart, then SIGTERM, and expects:

ready
received 3 SIGUSR1, shutting down

and exit status 0 (without a handler, SIGTERM would kill it with status 143).

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static volatile sig_atomic_t usr1_count = 0;
static volatile sig_atomic_t stop = 0;

int main(void)
{
    printf("ready\n");
    fflush(stdout);
    while (!stop)
        sleep(1);
    printf("received %d SIGUSR1, shutting down\n", (int)usr1_count);
    return 0;
}
Hint 1

Each handler is a function taking an int (the signal number). One does usr1_count++, the other sets stop = 1.

Hint 2

Install them with struct sigaction: memset it to zero, set sa_handler, sigemptyset(&sa.sa_mask), then sigaction(SIGUSR1, &sa, NULL).

Hint 3

Install the handlers before printing ready, or a signal could arrive before they exist.

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static volatile sig_atomic_t usr1_count = 0;
static volatile sig_atomic_t stop = 0;

static void on_usr1(int sig)
{
    (void)sig;
    usr1_count++;
}

static void on_term(int sig)
{
    (void)sig;
    stop = 1;
}

static int install(int sig, void (*handler)(int))
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);
    return sigaction(sig, &sa, NULL);
}

int main(void)
{
    if (install(SIGUSR1, on_usr1) < 0 || install(SIGTERM, on_term) < 0) {
        perror("sigaction");
        return 1;
    }
    printf("ready\n");
    fflush(stdout);
    while (!stop)
        sleep(1);
    printf("received %d SIGUSR1, shutting down\n", (int)usr1_count);
    return 0;
}