389 lines
34 KiB
Markdown
389 lines
34 KiB
Markdown
# 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`), ребёнок не создан.
|
||
|
||
Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова —
|
||
единственный способ понять, в какой копии сейчас исполняется код, это проверить
|
||
возвращённое значение. Поэтому классический код всегда ветвится:
|
||
|
||
```cpp
|
||
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` закодированы
|
||
сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:
|
||
|
||
```cpp
|
||
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` из обработчика способно повредить кучу или подвесить
|
||
процесс).
|
||
|
||
```cpp
|
||
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`.
|
||
|
||
```cpp
|
||
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 строк
|
||
|
||
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
|
||
|
||
```cpp
|
||
#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. Проверь себя
|
||
|
||
1. Что вернёт `fork()` в ребёнке и в родителе?
|
||
2. Что происходит со страницами памяти при `fork()`?
|
||
3. Что такое зомби и кто его убирает?
|
||
4. Откуда код возврата 137 и 139?
|
||
5. Почему `exec` не возвращает управление при успехе?
|
||
6. Какие файловые дескрипторы сохраняются после `exec` и какой флаг это меняет?
|
||
7. Что произойдёт с процессом, который игнорирует SIGTERM, при `systemctl stop`?
|
||
|
||
<details>
|
||
<summary>Ответы</summary>
|
||
|
||
1. В ребёнке 0, в родителе — PID ребёнка, при ошибке −1.
|
||
2. Ничего сразу: copy-on-write, копия страницы создаётся при первой записи.
|
||
3. Завершившийся процесс, чья запись ждёт `wait/waitpid`; убирает родитель (или init,
|
||
если родитель умер).
|
||
4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
|
||
5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
|
||
6. Все дескрипторы, открытые до `exec`, кроме помеченных `FD_CLOEXEC`.
|
||
7. По истечении таймаута остановки (`TimeoutStopSec`, по умолчанию ~90 c) systemd пришлёт
|
||
SIGKILL принудительно.
|
||
</details>
|
||
|
||
## 10. Задачи дня
|
||
|
||
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
|
||
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).
|
||
</content>
|