34 KiB
D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)
Модель одна на весь урок: процесс = адресное пространство (mm_struct в ядре) + поток
выполнения + таблица открытых файловых дескрипторов + запись в таблице процессов
(task_struct). fork копирует эту запись и помечает память как copy-on-write, exec
заменяет содержимое адресного пространства, оставляя PID и дескрипторы, wait забирает у
ядра код возврата и освобождает запись, сигнал — асинхронное прерывание исполнения ядром.
1. fork(): что реально происходит
pid_t fork(void) создаёт новый процесс — копию вызывающего: своё адресное
пространство, свой PID, свой поток. Родитель продолжает с того же места.
Механически ядро: (1) выделяет новый task_struct и PID; (2) копирует таблицу файловых
дескрипторов — обе записи после fork указывают на те же открытые файловые описания в
ядре, а не на независимые; (3) копирует таблицу страниц родителя, помечая все страницы
данных и кучи как read-only в обеих копиях; (4) добавляет новый процесс в очередь
планировщика. Само копирование данных при этом не происходит — см. COW ниже.
Возвращает дважды — и это ключ к пониманию:
- в родителе — PID ребёнка (> 0);
- в ребёнке — 0;
- при ошибке — −1 (и
errno), ребёнок не создан.
Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова — единственный способ понять, в какой копии сейчас исполняется код, это проверить возвращённое значение. Поэтому классический код всегда ветвится:
pid_t pid = fork();
if (pid == 0) {
// это ребёнок
execlp("ls", "ls", "-l", nullptr);
_exit(127); // exec не вернулся — значит не смог
} else if (pid > 0) {
// это родитель: pid — номер ребёнка
int status = 0;
waitpid(pid, &status, 0); // дождаться и забрать код возврата
} else {
perror("fork"); // ошибка
}
Copy-on-write. Память физически не копируется: обе стороны смотрят на одни физические
страницы, помеченные «только чтение» в таблице страниц каждого процесса. При первой записи
в такую страницу возникает page fault, ядро перехватывает его, выделяет новую физическую
страницу (обычно 4 КБ на x86-64), копирует туда содержимое и переписывает таблицу страниц
только пишущего процесса — только тогда. Поэтому fork дешёвый, даже если процесс занимает
гигабайты: копируется не память, а только записи таблицы страниц. Именно это спрашивают в
формулировке «что происходит со страницами памяти при fork?» — ответ: COW, копируются
при первой записи, а не при самом fork.
Причина именно такого устройства — частый паттерн «fork сразу за которым exec»: если бы
ядро копировало всё адресное пространство заранее, эта работа почти всегда оказывалась бы
выброшенной, ведь exec тут же заменяет содержимое памяти новой программой.
Совет: fflush(stdout) перед fork, иначе непустой буфер stdout скопируется в ребёнка
вместе с памятью (COW это не мешает) и будет сброшен на диск/терминал дважды.
Ловушки
- Не проверить возвращаемое значение
fork()и не разветвить логику по нему → родитель и ребёнок выполняют один и тот же код дважды → видно как задвоенный вывод или два PID вps aux, делающих одну и ту же работу. - Ребёнок долго не вызывает
exec(), активно пишет в большие структуры данных → всплеск реального потребления памяти именно в момент записи (COW-копирование страниц), а не в моментfork→ видно по росту RSS вtop/psуже после fork, а не сразу. - Вызвать
exit()вместо_exit()в ребёнке после неудачногоexec→exit()сбрасывает стандартные буферы stdio, которые ребёнок унаследовал от родителя через COW, и может продублировать ранее не выведенный текст родителя.
Факты для карточек
- base | Что возвращает
fork()в родителе и в ребёнке? — в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1 - core | Что происходит со страницами памяти при
fork()? — ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault) - core | Почему
forkдешёвый даже для процесса с гигабайтами памяти? — копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят - base | Что нужно сделать с
stdoutпередfork, если он не пуст? — вызватьfflush(stdout), иначе буфер продублируется в ребёнке - deep | Какой размер страницы памяти на x86-64, о которой копия делается при COW? — 4 КБ
Почему дальше: раз память после fork временно общая и почти всегда тут же заменяется — что конкретно делает exec с этим адресным пространством?
2. exec(): замена образа
exec* не создаёт процесс, а заменяет содержимое текущего: код, данные, стек —
всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда:
при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.
Механизм: execve (системный вызов, к которому в итоге сводится всё семейство exec*)
загружает исполняемый файл с диска, разбирает его как ELF, строит новый mm_struct —
новые сегменты кода и данных, новую кучу, новый стек — и подменяет им адресное пространство
текущего task_struct, не трогая PID и таблицу файловых дескрипторов. Дескрипторы, открытые
до exec, остаются открытыми в новой программе кроме помеченных флагом FD_CLOEXEC —
это то, чем перенаправление ввода-вывода (dup2 на 0/1/2 перед exec) переживает замену
образа, а служебные дескрипторы, которые новой программе видеть не нужно, — нет.
Отсюда рабочий шаблон: fork + exec в ребёнке = запуск внешней программы: fork даёт
новый процесс с независимой копией состояния (в том числе уже перенастроенные дескрипторы
для редиректа), а exec в этом новом процессе подгружает нужную программу, не трогая
родителя. Если вызвать exec без предварительного fork, текущая программа заменится и не
вернёт управление — например, exec внутри shell-скрипта заменяет саму оболочку.
Семейство: execlp ищет в PATH, execv — по полному пути, execvp — по имени в PATH.
Ловушки
- Забыть
_exit(127)(или любой выход) после неудачногоexec*в ребёнке → код продолжает исполняться как будто это родительская логика → дублирование родительской работы в дочернем процессе, видно по неожиданным побочным эффектам после «сбоя» запуска. - Не поставить
FD_CLOEXECна служебный/секретный дескриптор передexec→ он утекает в запущенную внешнюю программу → видно в/proc/<pid>/fdзапущенного процесса — там лишний открытый файл, которого «не должно быть».
Факты для карточек
- base | Чем
execотличается отfork? —forkсоздаёт новый процесс,execзаменяет образ текущего процесса, не создавая нового - core | Что сохраняется у процесса после успешного
exec? — PID и таблица открытых файловых дескрипторов, кроме помеченныхFD_CLOEXEC - core | Почему
execпри успехе никогда не возвращает управление? — старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом - base | Разница между
execlp,execv,execvp? —execlpищет вPATH,execv— по полному пути,execvp— по имени вPATH
Почему дальше: ребёнок исполнился и завершился — как родитель узнаёт, чем это закончилось, и что мешает ему узнать об этом мгновенно?
3. wait/waitpid и коды возврата
Завершившийся ребёнок не исчезает: когда он вызывает exit(), ядро не удаляет его
task_struct немедленно, а сохраняет минимальную запись (PID, код возврата, статистику
использования ресурсов), пока родитель не заберёт её через wait/waitpid. Такой процесс
называется зомби (состояние Z в ps). Зомби не занимает память данных, но занимает
слот в таблице процессов — их накопление плохо, вплоть до упора в лимит PID на системе.
Зомби не исчезает сам именно потому, что ядру физически некуда передать код возврата, кроме
как дождаться, когда родитель за ним придёт — сам процесс уже не исполняется и ничего
сообщить не может.
wait(&status)— ждёт любого ребёнка;waitpid(pid, &status, 0)— конкретного;WNOHANG— не блокироваться.
Разбор status делается макросами, а не вручную, потому что в одном int закодированы
сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:
if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
Дополнительно есть WIFSTOPPED/WSTOPSIG (ребёнок остановлен, например, по SIGSTOP) и
WCOREDUMP(status) (завершение по сигналу сопровождалось дампом памяти на диск, core).
Откуда 137 и 139. Оболочка показывает код как 128 + номер сигнала:
- 137 = 128 + 9 → SIGKILL (убит
kill -9, часто OOM-killer); - 139 = 128 + 11 → SIGSEGV (падение по памяти);
- 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).
Сирота — процесс, чей родитель умер раньше него: его усыновляет init/systemd (PID 1,
либо выделенный subreaper), который в цикле собирает статусы всех своих детей — поэтому
сирота гарантированно не застревает зомби навсегда, в отличие от зомби при живом, но
нерадивом родителе. Разница именно в том, кто виноват: зомби — родитель жив, но не вызвал
wait; сирота — родитель умер, но дождаться его теперь придётся init.
Ловушки
- Родитель никогда не вызывает
waitpidдля завершившихся детей → записи зомби копятся → видно как растущий списокps aux | grep Z, в пределе — упор в лимит PID. - Сравнивать
statusнапрямую с кодом возврата вместоWEXITSTATUS(status)→ вstatusзакодированы и код выхода, и флаг сигнала одновременно, сырое значение не совпадает с тем, что вернула программа. - Долгоживущий процесс с
fork-воркерами не занимается сбором детей вообще → зомби накапливаются постепенно, а не сразу → проявляется не в первый час работы, а через дни аптайма ростом числаZ-процессов.
Факты для карточек
- base | Зачем нужен
waitpid? — забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов - core | Что такое зомби и почему он не исчезает сам? — процесс, завершивший
exit(), чью запись ещё не забрал родитель черезwait; ядру некуда передать код возврата, кроме как дождатьсяwait - core | Чем зомби отличается от сироты? — зомби: родитель жив, но не вызвал
wait; сирота: родитель умер, процесс усыновлён init/systemd (PID 1) - base | Откуда код возврата 137 и 139? — 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу
- core | Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно? — макросами
WIFSIGNALED(status)/WTERMSIG(status), не сравнениемstatusнапрямую - deep | Что показывает
WCOREDUMP(status)? — что завершение по сигналу сопровождалось записью core-дампа на диск
Почему дальше: коды 137/139/143 — это сигналы, доставленные процессу; что вообще такое сигнал и какие из них процесс может перехватить, а какие — нет?
4. Сигналы
Сигнал — асинхронное уведомление, которое ядро доставляет процессу, прерывая его обычное исполнение и передавая управление либо зарегистрированному обработчику, либо выполняя действие по умолчанию (завершить, завершить с core-дампом, игнорировать, приостановить). Основные: SIGINT (2, Ctrl+C, по умолчанию завершает, перехватывается), SIGKILL (9, нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно», перехватывается), SIGSEGV (11, обращение к недопустимой памяти), SIGPIPE (13, запись в закрытый сокет/pipe), SIGCHLD (17 на Linux/x86, ребёнок изменил состояние — завершился или остановился).
Разделение на перехватываемые и неперехватываемые сигналы существует ради надёжности
управления системой: администратору и супервизору всегда нужен гарантированный способ
остановить процесс, даже если тот завис в бесконечном цикле или его собственный обработчик
сигналов содержит баг — отсюда SIGKILL, который ядро обрабатывает на уровне планировщика,
снимая процесс с исполнения без единой инструкции пользовательского кода в ответ. SIGTERM
устроен наоборот — он предполагает, что процесс жив и способен среагировать: закрыть файлы,
сбросить буферы, освободить ресурсы. Отсюда практика эксплуатации: сначала всегда посылают
SIGTERM и ждут; так, systemctl stop/docker stop по умолчанию ждут несколько секунд
(в systemd таймаут задаётся TimeoutStopSec, по умолчанию около 90 секунд) и только затем,
если процесс не завершился, посылают SIGKILL — потому что SIGKILL не даёт дописать данные
на диск, и незавершённая операция может остаться в неконсистентном состоянии.
Обработчик ставится sigaction (надёжнее устаревшего signal — поведение signal
исторически различалось между Unix-системами), внутри обработчика можно менять только
volatile sig_atomic_t — никаких printf/malloc (не async-signal-safe: malloc не
реентерабелен и может быть прерван сигналом посреди изменения своих внутренних структур,
что при вызове malloc/printf из обработчика способно повредить кучу или подвесить
процесс).
static volatile sig_atomic_t stop = 0;
static void on_term(int) { stop = 1; } // минимум действий
int main() {
struct sigaction sa{}; sa.sa_handler = on_term;
sigaction(SIGTERM, &sa, nullptr); // ловим мягкое завершение
while (!stop) { /* работа */ }
}
Ловушки
- Вызвать
printf/malloc/freeвнутри обработчика сигнала → не async-signal-safe → в редких случаях повреждение кучи или взаимная блокировка, если сигнал прервал программу ровно во время работы аллокатора — воспроизводится нестабильно, под нагрузкой. - Использовать
signal()вместоsigaction()→ поведение (сброс обработчика в default после первого срабатывания, поведение при повторном сигнале) исторически различается между реализациями Unix → код, проверенный на одной системе, ведёт себя иначе на другой. - Ждать, что демон корректно остановится по SIGTERM, не поставив на него обработчик →
docker stop/systemctl stopв итоге шлют SIGKILL по таймауту → в логах виден резкий обрыв процесса без финализации (незакрытые файлы, недописанные данные).
Факты для карточек
- base | Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD? — 2/9/15/11/13/17
- core | Чем SIGTERM отличается от SIGKILL? — SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую
- core | Что можно делать внутри обработчика сигнала? — только менять
volatile sig_atomic_t;printf/mallocнебезопасны (не async-signal-safe) - base | Чем
sigactionлучшеsignal? — поведениеsignalисторически различается между Unix-системами,sigactionдаёт предсказуемую и переносимую семантику - deep | Что происходит, если сервис игнорирует SIGTERM при
systemctl stop? — по истеченииTimeoutStopSec(по умолчанию ~90 c) systemd посылает SIGKILL принудительно
Почему дальше: сигнал может прервать системный вызов на середине — как код узнаёт об этом и что делать дальше?
5. errno и возвраты системных вызовов
Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и errno. Читать errno
можно только сразу после ошибки, до вызова любой другой функции, которая может сама
переписать errno (например, printf при внутренней ошибке форматирования). EINTR — вызов
прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе,
повторить позже; EINPROGRESS — неблокирующее соединение в процессе; EMFILE — процесс упёрся
в лимит открытых дескрипторов (ulimit -n), диагностируется через lsof -p PID или
/proc/PID/fd.
ssize_t n = read(fd, buf, sizeof buf);
if (n < 0) {
if (errno == EINTR) continue; // прервали сигналом — просто повторить
perror("read"); // иначе настоящая ошибка
break;
}
Ловушки
- Проверить
errnoбез предварительной проверки, что вызов вообще вернул ошибку →errnoможет быть ненулевым от предыдущего, уже обработанного вызова → ложное срабатывание. - Вызвать любую функцию (даже
printf) между системным вызовом и чтениемerrno→ промежуточный вызов может переписатьerrno→ в обработчике окажется код чужой ошибки.
Факты для карточек
- base | Что означает EINTR и как на него реагировать? — вызов прерван доставкой сигнала; корректная реакция — повторить вызов
- base | Чем EAGAIN отличается от обычной ошибки чтения? — данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже»
- core | Когда безопасно читать
errno? — сразу после ошибки вызова, до любого другого вызова, способного его перезаписать - deep | Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса? — EMFILE (лимит задаётся
ulimit -n)
Почему дальше: fork/exec/wait/сигналы/errno вместе — из этого уже можно собрать минимальный shell; что там на практике ломается первым?
6. Разбор примера: мини-шелл на 40 строк
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <string>
#include <vector>
#include <sstream>
int main() {
std::string line;
while (std::printf("sh> "), std::fflush(stdout), std::getline(std::cin, line)) {
if (line == "exit") break;
if (line.empty()) continue;
std::istringstream is(line);
std::vector<std::string> argv; std::string tok;
while (is >> tok) argv.push_back(tok);
if (argv.empty()) continue;
std::vector<char*> cargv;
for (auto& a : argv) cargv.push_back(a.data());
cargv.push_back(nullptr);
pid_t pid = fork();
if (pid == 0) {
execvp(cargv[0], cargv.data());
std::fprintf(stderr, "не могу запустить %s\n", cargv[0]);
_exit(127); // exec не удался
} else if (pid > 0) {
int status = 0;
waitpid(pid, &status, 0);
if (WIFEXITED(status))
std::printf("код возврата: %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
std::printf("убит сигналом: %d (код %d)\n",
WTERMSIG(status), 128 + WTERMSIG(status));
} else {
std::perror("fork");
}
}
}
Что тут проверить руками: ls -l работает; sleep 5 в фоне (& — уже доработка);
kill -9 по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»;
несуществующая команда даёт 127. Дочерний процесс перед execvp уже унаследовал от
родителя дескрипторы 0/1/2 (stdin/stdout/stderr) через fork, поэтому вывод запущенной
программы сразу идёт в тот же терминал — отдельно настраивать редирект не нужно, пока не
требуется перенаправление в файл или pipe.
Проверка на утечки и падения — санитайзеры:
g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell
Ловушки
- Не проверять, пуст ли
argvпередexecvp→execvp(nullptr, ...)на пустой строке → неопределённое поведение вместо ожидаемого «ничего не делать» (в коде это уже предусмотрено проверкойif (argv.empty()) continue;, но при рефакторинге легко потерять). - Забыть
_exit(127)после неудачногоexecvp→ дочерний процесс продолжит исполнять тело циклаwhileнаравне с родителем → двойной ввод команд из одного терминала.
Факты для карточек
- base | Какой код возврата у шелла даст несуществующая команда? — 127
- base | Какие дескрипторы дочерний процесс получает от родителя при
forkи почему вывод команды сразу виден в терминале? — 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов приfork - core | Каким флагом собрать бинарник для проверки на утечки и UB? —
-fsanitize=address,undefined -fno-omit-frame-pointer
Почему дальше: те же вопросы про fork/exec/wait/сигналы задают на собеседовании почти дословно — какие формулировки ждут в ответ?
7. Что спросят на собеседовании (готовые ответы)
- «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка, в ребёнке 0, при ошибке −1.
- «Что с памятью при fork?» — copy-on-write, страницы копируются при первой записи.
- «Чем exec отличается от fork?» — fork создаёт новый процесс, exec заменяет образ текущего, не создавая процесса.
- «Зачем waitpid?» — забрать код возврата и не оставлять зомби.
- «Как узнать, что процесс убит сигналом?» —
WIFSIGNALED/WTERMSIG, код оболочки 128 + сигнал, например 137 = SIGKILL, 139 = SIGSEGV. - «SIGTERM против SIGKILL?» — SIGTERM можно перехватить и завершиться корректно, SIGKILL не перехватывается и не игнорируется.
8. Материалы (первопартийные)
man 2 fork,man 2 execve,man 2 waitpid,man 7 signal— локально, читаются за 10 минут- OSTEP, главы 4–5 (процессы, API процессов) — https://pages.cs.wisc.edu/~remzi/OSTEP/
- Beej, «Processes» — https://beej.us/guide/bgipc/
- Про COW:
man 2 fork+ статья «Anatomy of a Program in Memory» — https://manybutfinite.com/post/anatomy-of-a-program-in-memory/
9. Проверь себя
- Что вернёт
fork()в ребёнке и в родителе? - Что происходит со страницами памяти при
fork()? - Что такое зомби и кто его убирает?
- Откуда код возврата 137 и 139?
- Почему
execне возвращает управление при успехе? - Какие файловые дескрипторы сохраняются после
execи какой флаг это меняет? - Что произойдёт с процессом, который игнорирует SIGTERM, при
systemctl stop?
Ответы
- В ребёнке 0, в родителе — PID ребёнка, при ошибке −1.
- Ничего сразу: copy-on-write, копия страницы создаётся при первой записи.
- Завершившийся процесс, чья запись ждёт
wait/waitpid; убирает родитель (или init, если родитель умер). - 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
- Потому что адресное пространство заменено новым образом: старого кода больше нет.
- Все дескрипторы, открытые до
exec, кроме помеченныхFD_CLOEXEC. - По истечении таймаута остановки (
TimeoutStopSec, по умолчанию ~90 c) systemd пришлёт SIGKILL принудительно.
10. Задачи дня
tasks/03_ring— кольцевой буфер (база для сетевого кода).- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).