Embedded HandbookПриложения
Все разделы › XVII. Системное программирование на C
· уровень средний · 15 мин чтения
Открыть в интерактивном справочнике

136. fork, exec, wait и сигналы

Новый процесс в Unix не «создаётся» — он раздваивается: fork делает копию вызвавшего процесса, и дальше два одинаковых процесса идут каждый своим путём. Чтобы в копии запустить другую программу, её образ заменяют через exec. Родитель дожидается потомка через wait. Так работает оболочка, так запускается всё в системе.

Вторая половина раздела — сигналы: единственный способ, которым ядро и другие процессы прерывают программу посреди работы. С ними связано больше тонких ошибок, чем с чем-либо ещё в этой части. Все прогоны — _verify/sys136/ в образе stm-sys:1.

Как это называется в документации

Оригинал Перевод
fork раздвоение процесса
copy-on-write копирование при записи
exec замена образа процесса другой программой
wait, reap дождаться потомка и забрать его статус
zombie зомби: завершившийся потомок, чей статус ещё не забрали
orphan сирота: потомок пережил родителя — его усыновляет init
signal, handler сигнал, обработчик
signal mask, pending маска заблокированных сигналов, ожидающие сигналы
async-signal-safe безопасна для вызова из обработчика
EINTR системный вызов прерван сигналом
race condition гонка

136.1 fork: один процесс становится двумя

fork возвращает дважды: 0 — в потомке, номер потомка — в родителе, −1 — если не удалось. Потомок получает копии данных, кучи и стека; код общий. Программа меняет переменные в потомке и показывает, что у родителя они прежние:

    printf("перед fork: pid %ld\n", (long)getpid());
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }
    if (pid == 0) {                         /* потомок */
        globvar++; var++;
        printf("потомок: pid %ld ppid %ld globvar %d var %d\n",
               (long)getpid(), (long)getppid(), globvar, var);
        _exit(42);
    }
    int status;
    waitpid(pid, &status, 0);               /* родитель ждёт — потомок не станет зомби */
    printf("родитель: pid %ld globvar %d var %d; потомок вернул %d\n",
           (long)getpid(), globvar, var, WEXITSTATUS(status));
перед fork: pid 54
потомок: pid 55 ppid 54 globvar 7 var 89
родитель: pid 54 globvar 6 var 88; потомок вернул 42

«Копия» — ленивая: ядро не копирует память, а помечает страницы общими и копирует страницу только тогда, когда одна из сторон в неё пишет (copy-on-write). Поэтому fork небольшого процесса дёшев: 1000 пар fork + wait в контейнере — 116 мс, по 116 мкс каждая. Но таблицы страниц копируются честно: у процесса, занявшего гигабайт памяти, одна пара fork + wait в том же контейнере — около 15 мс, примерно в сто раз дольше, и потом каждая первая запись в страницу ещё и копирует её. Чтобы из большого процесса запустить программу, берут posix_spawn: он таблиц не копирует, и запуск /bin/true из того же гигабайтного процесса занял 0,3 мс.

Потомок наследует открытые файлы вместе с позицией в них, uid, текущий каталог, umask, маску сигналов, окружение, пределы. Не наследует: pid, счётчики времени, блокировки файлов, таймеры и ожидающие сигналы.

fork даёт копию, exec заменяет программу, wait забирает статус; без wait потомок остаётся зомби


136.2 Ловушка буфера

Буфер stdio — тоже часть данных, и fork копирует его вместе со всем остальным. Если вывод идёт в канал, stdout буферизован целиком, и строка, «напечатанная» до fork, лежит в буфере в обоих процессах:

    printf("перед fork\n");                 /* в канал stdout полностью буферизован */
    if (fork() == 0) { printf("потомок\n"); return 0; }
    wait(NULL);
    printf("родитель\n");
./buffered_fork | cat
перед fork
потомок
перед fork
родитель

В терминале строка вышла бы один раз — там буфер строчный. Правило: fflush(NULL) перед fork, или setbuf(stdout, NULL), как в первой программе.

Буфер stdio копируется вместе с процессом: в канал строка уходит дважды, в терминал — один раз


136.3 exec и wait

exec не создаёт процесс — он заменяет программу внутри существующего: тот же pid, те же открытые файлы (кроме помеченных O_CLOEXEC), новый код, данные и стек. Успешный exec не возвращается; вернулся — значит, не удалось, и потомок должен выйти через _exit:

    if (p == 0) { execlp("sh", "sh", "-c", "exit 5", (char *)0); perror("exec"); _exit(127); }

Шесть вариантов различаются только формой аргументов: l — списком, v — массивом, p — искать по PATH, e — своё окружение. Внутри все — execve.

wait возвращает статус, и разбирать его нужно макросами:

    if (WIFEXITED(status))
        printf("%s: завершился, код %d\n", what, WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("%s: убит сигналом %d (%s)%s\n", what, WTERMSIG(status),
               strsignal(WTERMSIG(status)), "");

Четыре потомка — четыре исхода:

sh -c 'exit 5': завершился, код 5
exec несуществующей программы: завершился, код 127
raise(SIGKILL): убит сигналом 9 (Killed)
запись по нулевому указателю: убит сигналом 11 (Segmentation fault)

Код 127 — соглашение оболочки: «команда не найдена». Между exit и гибелью от сигнала разница принципиальная: у убитого нет кода возврата. waitpid с WNOHANG не блокируется, system(cmd) — это fork + exec sh -c + wait в одном вызове.


136.4 Зомби и сироты

Завершившийся потомок остаётся в таблице процессов, пока родитель не заберёт его статус: это зомби. Памяти он не занимает, но номер процесса держит. Программа завершает потомка и читает его состояние из /proc:

State:  Z (zombie)
после wait: /proc/55/status нет

Сервер, который порождает потомков и никогда не вызывает wait, рано или поздно исчерпает pid_max (в контейнере прогона — 4 194 304; предел задаёт система, на других он бывает намного меньше). Лечение — waitpid(-1, …, WNOHANG) в цикле по SIGCHLD, либо signal(SIGCHLD, SIG_IGN): тогда ядро убирает потомков само.

Если раньше умирает родитель, потомок становится сиротой, и его усыновляет init (процесс 1), который и заберёт статус. На этом основана демонизация (раздел 138).


136.5 Сигналы: что это

Сигнал — номер, который ядро доставляет процессу асинхронно: между любыми двумя командами программы. Ядро передаёт управление обработчику, когда процесс возвращается из ядра в пользовательский режим, а потом возвращает его обратно. Источники три: терминал (SIGINT по Ctrl-C, SIGHUP при закрытии), аппаратные ошибки (SIGSEGV, SIGBUS, SIGFPE, SIGILL) и программы — kill, alarm, SIGPIPE, SIGCHLD.

Сигнал № По умолчанию Когда
SIGHUP 1 завершить терминал закрыт; демоны перечитывают настройки
SIGINT 2 завершить Ctrl-C
SIGQUIT 3 core Ctrl-\
SIGKILL 9 убить нельзя перехватить и заблокировать
SIGSEGV 11 core обращение по плохому адресу
SIGPIPE 13 завершить запись в канал без читателя
SIGALRM 14 завершить таймер alarm
SIGTERM 15 завершить вежливая просьба завершиться; так шлёт kill без ключей
SIGCHLD 17 игнорировать потомок завершился
SIGSTOP, SIGCONT 19, 18 остановить, продолжить SIGSTOP тоже нельзя перехватить
SIGUSR1, SIGUSR2 10, 12 завершить для программы

Действие по умолчанию — одно из пяти: завершить, завершить с core, игнорировать, остановить, продолжить. kill -l печатает список; номера выше — для Linux на x86-64, на других архитектурах часть отличается.


136.6 sigaction, маска, EINTR

signal(2) устарел: его поведение при повторном сигнале и при прерванных вызовах отличается между системами. Правильный вызов — sigaction, и обработчик в нём — минимальный: выставить флаг типа volatile sig_atomic_t и выйти.

static volatile sig_atomic_t got_usr1, got_alarm;

static void on_sig(int signo)
{
    if (signo == SIGUSR1) got_usr1 = 1;
    if (signo == SIGALRM) got_alarm = 1;
}
    struct sigaction sa = { .sa_handler = on_sig };
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                         /* без SA_RESTART: read вернёт EINTR */
    sigaction(SIGUSR1, &sa, NULL);
    sigaction(SIGALRM, &sa, NULL);

Четыре опыта в одной программе:

после kill: got_usr1 = 1
заблокирован: got_usr1 = 0, в ожидании = 1
маска снята: got_usr1 = 1
read после alarm: n = -1, errno = Interrupted system call, got_alarm = 1
read с SA_RESTART: n = 1, символ x, got_alarm = 1 (сигнал был, вызов перезапущен)
  1. kill(getpid(), SIGUSR1) — обработчик отработал ещё до возврата из kill.
  2. sigprocmask(SIG_BLOCK, …) — сигнал не потерян, а ждёт: sigpending его видит, и он доставляется в момент снятия маски. Так защищают критические участки от сигнала.
  3. Блокирующий read из пустого канала прерван сигналом alarm: без SA_RESTART он возвращает −1 с errno == EINTR. Это касается read, write, accept, wait, nanosleep и других медленных вызовов — их оборачивают в цикл «повторить, если EINTR». sleep, прерванный сигналом, возвращает недоспанные секунды, а pthread_cond_wait EINTR не возвращает вовсе — его и так ждут в цикле while (139.4).
  4. С SA_RESTART тот же read после сигнала продолжается и получает байт, который потомок записал через полторы секунды.

Что можно делать в обработчике — короткий список: писать в sig_atomic_t, вызывать write, _exit, kill, sigaction и другие функции из списка async-signal-safe. Что нельзя: printf, malloc, free, всё, что берёт блокировку или трогает глобальное состояние. Обработчик, вызванный посреди malloc, войдёт в malloc повторно и разрушит кучу. errno внутри обработчика сохраняют и восстанавливают.


136.7 Приёмы


136.8 Грабли

Симптом Причина
строка до fork напечаталась дважды буфер stdout скопирован; fflush(NULL) перед fork
после exec выполнился код «после exec» exec не удался и вернулся; после него — perror и _exit
потомки-зомби копятся, fork даёт EAGAIN родитель не вызывает wait; SIGCHLD или SIG_IGN
read вернул −1 без видимой причины EINTR: сигнал прервал вызов — повторить или SA_RESTART
программа виснет или падает в обработчике сигнала в обработчике printf/malloc — только async-signal-safe
сигнал «потерялся» он не ставится в очередь: два одинаковых до доставки — один; блокировать маской, а не терять; сигналы реального времени SIGRTMIN…SIGRTMAX ставятся в очередь и несут число (sigqueue)
SIGKILL не перехватывается и не должен: SIGKILL и SIGSTOP нельзя ни перехватить, ни заблокировать
kill без ключа не завершил процесс SIGTERM перехвачен или игнорируется; kill -9 — SIGKILL

136.9 Итоги

Полные исходники

fork_cow.c — fork копирует данные, переменные родителя и потомка расходятся.

/* Раздел 136: fork копирует данные (copy-on-write), код общий; переменные расходятся. */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int globvar = 6;

int main(void)
{
    int var = 88;
    setbuf(stdout, NULL);                   /* без буфера: иначе строка «перед fork» уедет дважды */
    printf("перед fork: pid %ld\n", (long)getpid());
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }
    if (pid == 0) {                         /* потомок */
        globvar++; var++;
        printf("потомок: pid %ld ppid %ld globvar %d var %d\n",
               (long)getpid(), (long)getppid(), globvar, var);
        _exit(42);
    }
    int status;
    waitpid(pid, &status, 0);               /* родитель ждёт — потомок не станет зомби */
    printf("родитель: pid %ld globvar %d var %d; потомок вернул %d\n",
           (long)getpid(), globvar, var, WEXITSTATUS(status));
    return 0;
}

buffered_fork.c — буфер stdout копируется вместе с процессом.

/* Раздел 136: буфер stdout копируется вместе с процессом — «перед fork» печатается дважды. */
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
    printf("перед fork\n");                 /* в канал stdout полностью буферизован */
    if (fork() == 0) { printf("потомок\n"); return 0; }
    wait(NULL);
    printf("родитель\n");
    return 0;
}

exec_wait.c — fork, exec и wait со статусом потомка по битам.

/* Раздел 136: fork + exec + wait — так работает любая оболочка; статус потомка по битам. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

static void report(const char *what, int status)
{
    if (WIFEXITED(status))
        printf("%s: завершился, код %d\n", what, WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("%s: убит сигналом %d (%s)%s\n", what, WTERMSIG(status),
               strsignal(WTERMSIG(status)), "");
}

int main(void)
{
    pid_t p = fork();
    if (p == 0) { execlp("sh", "sh", "-c", "exit 5", (char *)0); perror("exec"); _exit(127); }
    int st; waitpid(p, &st, 0); report("sh -c 'exit 5'", st);

    p = fork();
    if (p == 0) { execlp("no_such_program_xyz", "x", (char *)0); _exit(127); }
    waitpid(p, &st, 0); report("exec несуществующей программы", st);

    p = fork();
    if (p == 0) { raise(SIGKILL); }
    waitpid(p, &st, 0); report("raise(SIGKILL)", st);

    p = fork();
    if (p == 0) { int *z = 0; *z = 1; _exit(0); }
    waitpid(p, &st, 0); report("запись по нулевому указателю", st);
    return 0;
}

signals.c — sigaction, маска, EINTR и alarm.

/* Раздел 136: sigaction, маска, EINTR, alarm; обработчик только выставляет флаг. */
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>

static volatile sig_atomic_t got_usr1, got_alarm;

static void on_sig(int signo)
{
    if (signo == SIGUSR1) got_usr1 = 1;
    if (signo == SIGALRM) got_alarm = 1;
}

int main(void)
{
    struct sigaction sa = { .sa_handler = on_sig };
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                         /* без SA_RESTART: read вернёт EINTR */
    sigaction(SIGUSR1, &sa, NULL);
    sigaction(SIGALRM, &sa, NULL);

    /* 1. Сигнал самому себе через kill: обработчик отработал до возврата из kill. */
    kill(getpid(), SIGUSR1);
    printf("после kill: got_usr1 = %d\n", got_usr1);

    /* 2. Заблокировать SIGUSR1: сигнал ждёт, пока маску не снимут. */
    sigset_t set, old;
    sigemptyset(&set); sigaddset(&set, SIGUSR1);
    sigprocmask(SIG_BLOCK, &set, &old);
    got_usr1 = 0;
    kill(getpid(), SIGUSR1);
    sigset_t pend; sigpending(&pend);
    printf("заблокирован: got_usr1 = %d, в ожидании = %d\n", got_usr1, sigismember(&pend, SIGUSR1));
    sigprocmask(SIG_SETMASK, &old, NULL);
    printf("маска снята: got_usr1 = %d\n", got_usr1);

    /* 3. alarm прерывает блокирующий read: без SA_RESTART — EINTR. */
    int fd[2]; pipe(fd);
    char c;
    alarm(1);
    ssize_t n = read(fd[0], &c, 1);
    printf("read после alarm: n = %zd, errno = %s, got_alarm = %d\n", n, strerror(errno), got_alarm);

    /* 4. Тот же read с SA_RESTART — перезапустится, поэтому пишем в канал из обработчика нельзя;
          показываем только, что флаг SA_RESTART меняет поведение: read не вернётся, пока нет данных. */
    sa.sa_flags = SA_RESTART; sigaction(SIGALRM, &sa, NULL);
    if (fork() == 0) { usleep(1500000); write(fd[1], "x", 1); _exit(0); }
    alarm(1); got_alarm = 0;
    n = read(fd[0], &c, 1);
    printf("read с SA_RESTART: n = %zd, символ %c, got_alarm = %d (сигнал был, вызов перезапущен)\n", n, c, got_alarm);
    return 0;
}

eintr136.c — что возвращают read, sleep и pthread_cond_wait, прерванные сигналом.

/* 136.6: что возвращают вызовы, прерванные сигналом без SA_RESTART:
 * read — −1 и EINTR, sleep — недоспанные секунды, pthread_cond_wait — не EINTR никогда. */
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>

static volatile sig_atomic_t usr1;
static void on_sig(int s) { if (s == SIGUSR1) usr1++; }

static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static int ready, rc_wait = -1;
static double t_wait;

static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec / 1e9; }

static void *waiter(void *arg)
{
    double t0 = now();
    (void)arg;
    pthread_mutex_lock(&mu);
    while (!ready) {
        rc_wait = pthread_cond_wait(&cv, &mu);
        if (rc_wait != 0) break;                 /* код ошибки, если бы он был */
    }
    t_wait = now() - t0;
    pthread_mutex_unlock(&mu);
    return NULL;
}

int main(void)
{
    struct sigaction sa = { .sa_handler = on_sig };
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                             /* без SA_RESTART */
    sigaction(SIGALRM, &sa, NULL);
    sigaction(SIGUSR1, &sa, NULL);

    int p[2]; char c;
    pipe(p);
    alarm(1);
    ssize_t n = read(p[0], &c, 1);
    printf("read: n = %zd, errno = %s\n", n, n < 0 ? strerror(errno) : "-");

    alarm(1);
    unsigned left = sleep(5);
    printf("sleep(5): вернул %u\n", left);

    pthread_t th;
    pthread_create(&th, NULL, waiter, NULL);
    usleep(200000);
    for (int k = 0; k < 5; k++) { pthread_kill(th, SIGUSR1); usleep(100000); }
    int before = usr1;
    usleep(500000);
    pthread_mutex_lock(&mu); ready = 1; pthread_cond_signal(&cv); pthread_mutex_unlock(&mu);
    pthread_join(th, NULL);
    printf("pthread_cond_wait: сигналов SIGUSR1 %d, код %d (%s), ждал %.1f с\n", before, rc_wait,
           rc_wait == EINTR ? "EINTR" : rc_wait == 0 ? "0" : "другой", t_wait);
    return 0;
}

Дальше: раздел 137 — обмен между процессами: каналы, сокеты, общая память.

Открыть в интерактивном справочнике