# D1, часть 2. Процессы: fork, exec, wait, сигналы (урок) Тут всё держится на одной картинке: процесс = адресное пространство + поток выполнения + открытые дескрипторы. `fork` копирует это, `exec` заменяет содержимое, `wait` собирает результат, сигнал — асинхронное уведомление. ## 1. fork(): что реально происходит `pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное пространство, свой PID, свой поток. Родитель продолжает с того же места. Возвращает **дважды** — и это ключ к пониманию: - в родителе — 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.** Память физически не копируется: обе стороны смотрят на одни страницы, помеченные «только чтение». При первой записи в страницу ядро делает её копию — только тогда. Поэтому `fork` дешёвый, даже если процесс занимает гигабайты. Именно это спрашивают в формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются при первой записи**. Совет: `fflush(stdout)` перед `fork`, иначе буфер вывода может продублироваться в ребёнке. ## 2. exec(): замена образа `exec*` **не создаёт процесс**, а заменяет содержимое текущего: код, данные, стек — всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда: при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1. Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы. Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`. ## 3. wait/waitpid и коды возврата Завершившийся ребёнок не исчезает: ядро держит его запись, пока родитель не заберёт код возврата. Такой процесс называется **зомби** (состояние `Z` в `ps`). Зомби не занимает память, но занимает слот в таблице процессов — их накопление плохо. - `wait(&status)` — ждёт любого ребёнка; - `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться. Разбор `status` делается макросами, а не вручную: ```cpp if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status)); else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status)); ``` **Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала: - **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer); - **139 = 128 + 11** → SIGSEGV (падение по памяти); - 143 = 128 + 15 → SIGTERM (корректный запрос на завершение). **Сирота** — процесс, чей родитель умер: его усыновляет init/systemd (PID 1), он не зомби. ## 4. Сигналы Сигнал — асинхронное уведомление процессу. Основные: SIGINT (2, Ctrl+C), SIGKILL (9, нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно»), SIGSEGV (11), SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17, ребёнок завершился). Обработчик ставится `sigaction` (надёжнее устаревшего `signal`), внутри обработчика можно менять только `volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe). ```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) { /* работа */ } } ``` ## 5. errno и возвраты системных вызовов Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno` можно только сразу после ошибки. EINTR — вызов прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже; EINPROGRESS — неблокирующее соединение в процессе. ```cpp ssize_t n = read(fd, buf, sizeof buf); if (n < 0) { if (errno == EINTR) continue; // прервали сигналом — просто повторить perror("read"); // иначе настоящая ошибка break; } ``` ## 6. Разбор примера: мини-шелл на 40 строк Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня. ```cpp #include #include #include #include #include #include #include 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 argv; std::string tok; while (is >> tok) argv.push_back(tok); if (argv.empty()) continue; std::vector 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. Проверка на утечки и падения — санитайзеры: `g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell` ## 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` не возвращает управление при успехе?
Ответы 1. В ребёнке 0, в родителе — PID ребёнка, при ошибке −1. 2. Ничего сразу: copy-on-write, копия страницы создаётся при первой записи. 3. Завершившийся процесс, чья запись ждёт `wait/waitpid`; убирает родитель (или init, если родитель умер). 4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка. 5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
## 10. Задачи дня - `tasks/03_ring` — кольцевой буфер (база для сетевого кода). - Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).