Виртуализация CPU · 14 мин
Контент v0.12.0API процессов: fork, exec, wait
Откуда берутся процессы
Мы уже знаем, что такое процесс, как ядро ОС безопасно запускает его на CPU (прямое исполнение с ограничениями) и как выбирает, кого запускать (планирование). Остался вопрос, который звучит наивно, но именно его любят на собесах: а как один процесс вообще рождает другой?
Классический Unix-ответ — не один вызов «запусти программу X», а три кирпичика, из
которых собирается всё, от запуска ls в шелле до старта контейнера:
fork()— клонировать текущий процесс (получить копию самого себя);exec()— заменить программу внутри процесса на другую;wait()— дождаться завершения потомка и забрать его код возврата.
Звучит странно: чтобы запустить программу, надо сначала скопировать себя, а потом себя же затереть. Но именно эта развилка между «копированием» и «заменой» даёт Unix всю его гибкость. Разберём по очереди.
fork(): клон самого себя
fork() создаёт почти точную копию вызвавшего процесса. Был один процесс —
стало два. Тот, кто вызвал fork(), называется родитель (parent), новый —
потомок (child).
Похоже на ксерокопию заполненной анкеты: получаются два одинаковых листа, и дальше правка на одном не меняет другой. Копируется адресное пространство (код, куча, стек), значения регистров и таблица открытых файловых дескрипторов.
до fork() после fork()
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ процесс A │ │ процесс A │ │ процесс A' │
│ PID = 100 │ fork() │ PID = 100 │ │ PID = 101 │
│ своя память │ ──────▶ │ своя память │ │ КОПИЯ памяти │
│ свои fd │ │ (родитель) │ │ (потомок) │
└───────────────┘ └───────────────┘ └───────────────┘Что нарисовано: был один процесс с PID 100, после fork() появился второй
(PID 101) с собственной независимой копией памяти. Дальше они живут параллельно.
Самое коварное место для новичка: fork() возвращает значение дважды — по
разу в каждом процессе. Это потому, что после вызова исполняются уже два
процесса, и оба продолжают с одной и той же строки. Различить, «кто я», можно по
возвращённому значению:
pid := fork()
┌──────────────────────────┬──────────────────────────┐
│ в РОДИТЕЛЕ │ в ПОТОМКЕ │
│ pid = PID потомка (>0) │ pid = 0 │
│ "я родитель, мой │ "я потомок" │
│ ребёнок — 101" │ │
└──────────────────────────┴──────────────────────────┘
при ошибке (нет памяти и т.п.): pid = -1Что нарисовано: одна и та же переменная pid имеет разное значение в двух
процессах. Родитель получает PID потомка (положительное число), потомок получает
0, а если форк не удался — возвращается -1. Дальше код обычно ветвится через
if pid == 0 { ...потомок... } else { ...родитель... }.
На самом деле память не копируется целиком в момент
fork()— это было бы дорого. Используется copy-on-write (CoW): оба процесса сначала смотрят в одни и те же физические страницы (помеченные «только чтение»), а реальное копирование страницы происходит лениво, только когда кто-то в неё пишет. Подробнее про страницы — в главе paging.
exec(): сменить программу, не меняя процесс
fork() даёт копию той же программы. Но нам обычно нужно запустить другую
программу. Для этого есть семейство вызовов exec().
exec() берёт исполняемый файл с диска и загружает его поверх текущего
процесса: старый код, куча и стек выбрасываются, на их место кладётся новая
программа, и исполнение начинается с её точки входа. Важно: новый процесс не
создаётся — это тот же самый PID, та же запись в таблице процессов, просто
«начинка» стала другой.
Аналогия: вы — тот же человек с тем же паспортом (PID), но переоделись с ног до головы и пошли заниматься совсем другим делом. Документ тот же, личность снаружи та же, а содержание деятельности полностью поменялось.
процесс PID=101 ДО exec("/bin/ls") ПОСЛЕ exec("/bin/ls")
┌──────────────────────┐ ┌──────────────────────┐
│ код: копия шелла │ exec() │ код: /bin/ls │
│ куча/стек: от шелла │ ─────────────▶ │ куча/стек: новые │
│ PID = 101 │ │ PID = 101 (тот же!) │
│ открытые fd ──────────┼── сохраняются ─┼──▶ открытые fd │
└──────────────────────┘ └──────────────────────┘Что нарисовано: при exec() PID не меняется, код и память заменяются целиком, а
вот открытые файловые дескрипторы по умолчанию переживают замену программы.
На этом держатся пайпы и редиректы. Исключение — дескрипторы с флагом
close-on-exec: ядро закрывает их при exec. Для Go-разработчика это важная
деталь, вернёмся к ней в конце главы.
Ещё важная деталь: если exec() прошёл успешно, он не возвращает управление —
возвращаться-то некуда, старого кода больше нет. Управление возвращается только
при ошибке (например, файла нет или нет прав).
Зачем два вызова, а не один
Логичный вопрос: почему нельзя одним вызовом «запусти программу X»? Зачем этот странный танец fork → exec?
Ответ: между fork() и exec() есть окно, в котором мы уже в новом процессе,
но ещё в старой программе. В этом окне потомок может настроить себе окружение
перед запуском новой программы: перенаправить вывод в файл, закрыть лишние
дескрипторы, поменять рабочую директорию, понизить привилегии. Именно так шелл
реализует редиректы и пайпы — он форкается, в потомке перенаправляет
stdin/stdout, и только потом делает exec.
ВРЕМЕННАЯ ШКАЛА: шелл запускает "ls > out.txt"
shell (PID 100)
│ fork()
├───────────────┐
│ ▼ потомок (PID 101)
│ [окно между fork и exec]
│ открыть out.txt, направить туда stdout (fd 1)
│ │ exec("/bin/ls")
│ ▼
│ теперь это /bin/ls, пишет в out.txt
│ wait(101) │
│ (спит) ◀──────┤ ls завершился, код возврата 0
▼ ✗
получил код, печатает приглашение дальшеЧто нарисовано: родитель-шелл форкается, потомок в «окне» перенаправляет вывод в
файл, затем превращается в ls, а родитель в это время ждёт его в wait().
Если бы был один вызов «запусти X», такой настройки между шагами просто не было бы.
wait(): забрать результат потомка
После fork() родитель и потомок бегут параллельно, и порядок их выполнения не
гарантирован. Часто родителю нужно дождаться, пока потомок закончит, и узнать,
с каким кодом тот завершился (0 — успех, ненулевой — ошибка). Для этого есть
wait() / waitpid().
wait() блокирует родителя, пока какой-нибудь его потомок не завершится, и
возвращает его PID и статус выхода. Это та самая блокировка, из-за которой
процесс уходит в состояние blocked (см. process): пока
ждём ребёнка, занимать ядро незачем.
| Вызов | Что делает |
|---|---|
wait() |
ждёт завершения любого потомка |
waitpid(pid) |
ждёт конкретного потомка по PID |
waitpid(pid, WNOHANG) |
не блокирует: «уже завершился? нет — иду дальше» |
Все три вызова на живом примере
Чтобы увидеть механику без обёрток, напишем её на C. Go-разработчику C писать не
придётся, но читать такой код полезно: именно так устроены шелл, os/exec и
запуск контейнера.
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
int x = 100;
printf("до fork: PID %d\n", getpid());
fflush(stdout);
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(1);
}
if (pid == 0) {
x = 1; /* меняем копию в потомке */
printf("потомок: PID %d, x = %d\n", getpid(), x);
fflush(stdout); /* иначе буфер stdout пропадёт при exec */
execlp("echo", "echo", "это уже echo, PID тот же", (char *)NULL);
perror("exec"); /* сюда попадём, только если exec не удался */
exit(127);
}
int status;
waitpid(pid, &status, 0);
printf("родитель: потомок %d завершился с кодом %d, x = %d\n",
pid, WEXITSTATUS(status), x);
return 0;
}Вывод (cc fork.c -o fork && ./fork, PID у вас будут другие):
до fork: PID 67852
потомок: PID 67853, x = 1
это уже echo, PID тот же
родитель: потомок 67853 завершился с кодом 0, x = 100Что здесь видно:
- после
forkу потомка свой PID (67853), а родитель узнал его из возвращённого значения; - потомок поменял
xна 1, а у родителяxтак и осталась 100: память у них разная; echoнапечатал свою строку из того же процесса 67853, потому чтоexecне создаёт новый процесс;- родитель дождался потомка в
waitpidи получил код возврата 0, который вернулecho.
Ловушка, на которой спотыкаются при первом опыте: уберите fflush(stdout) в
потомке и запустите ./fork | cat. Строка «потомок: ...» пропадёт. Когда stdout
направлен в пайп, библиотека C копит вывод в буфере внутри памяти процесса, а
exec эту память выбрасывает вместе с недописанным буфером. По той же причине
fflush стоит и перед fork: иначе несброшенный буфер скопируется в потомка, и
строка «до fork» напечатается дважды.
Зомби и сироты
Здесь живут два классических термина, которые путают на собесах.
Зомби (zombie) — потомок уже завершился, но родитель ещё не сделал
wait(). Почему процесс не исчезает сразу? Потому что ядро обязано сохранить
его код возврата — вдруг родитель захочет его прочитать. Поэтому от процесса
остаётся крошечная запись в таблице процессов («труп»), которая висит, пока
родитель не заберёт статус через wait(). После wait() запись окончательно
удаляется — зомби «упокаивается».
Зомби не потребляет CPU и память (тело уже освобождено), но занимает слот в
таблице процессов и номер PID. Если родитель плодит детей и не делает wait(),
зомби копятся и могут исчерпать лимит PID — это реальный баг в продакшене.
Сирота (orphan) — наоборот: родитель умер раньше потомка. Кто теперь
сделает wait()? Потомка усыновляет init — процесс с PID 1 (в современных
системах это systemd или, внутри контейнера, ваш entrypoint). Когда
усыновлённый потомок завершается, init получает сигнал SIGCHLD и делает за него
wait(), поэтому сирота не превращается в вечного зомби. (Linux умеет назначать
«приёмным родителем» и другой процесс, так называемый subreaper; этим пользуются
systemd и рантаймы контейнеров.)
ЗОМБИ СИРОТА
┌──────────┐ ┌──────────┐
│ родитель │ жив, но не wait() │ родитель │ умер ✗
└────┬─────┘ └──────────┘
│ ребёнок завершился ✗ │ а ребёнок ещё жив
▼ ▼
┌──────────┐ ┌──────────┐
│ потомок │ = ZOMBIE │ потомок │ усыновлён PID 1
│ (труп в │ ждёт wait() │ (живой) │ ───▶ init сделает
│ таблице) │ │ │ wait() за него
└──────────┘ └──────────┘Что нарисовано: слева — родитель жив, но забыл wait(), мёртвый потомок висит
зомби; справа — родитель умер, живой потомок переподвешивается к init (PID 1),
который потом его и похоронит. Важно: зомби — это про мёртвого ребёнка при
живом родителе, а сирота — про живого ребёнка при мёртвом родителе.
PID 1 в контейнере
В контейнере с отдельным пространством PID (подробнее в главе
контейнеры) ваш процесс из ENTRYPOINT получает
PID 1 и автоматически становится init для всех процессов контейнера. Отсюда две
частые проблемы на проде.
Первая: обычный Go-сервис не умеет быть init. Если он запускает дочерние процессы
(например, через sh -c), а те порождают своих потомков и умирают, сироты
переподвешиваются к вашему сервису, и никто не вызывает для них wait(). В
контейнере копятся зомби.
Вторая: ядро не применяет к PID 1 действия сигналов по умолчанию. Если программа
не установила обработчик SIGTERM, обычный процесс от SIGTERM умирает, а PID 1
его просто игнорирует. Контейнер не останавливается по docker stop и через
10 секунд получает SIGKILL. (Go-рантайм устанавливает свои обработчики сигналов,
поэтому Go-сервис от этого защищён лучше, но зомби он всё равно не соберёт.)
Решение: запускать маленький init перед сервисом. Это делает
docker run --init (подставляет tini) или tini в ENTRYPOINT образа.
Наследование дескрипторов и пайпы
Мы говорили, что при fork() копируется таблица файловых дескрипторов, а при
exec() дескрипторы сохраняются. Эти два факта вместе и делают возможными пайпы
(cmd1 | cmd2) и редиректы.
Напомним: файловый дескриптор (fd) — это маленькое целое число, «ручка», по которой процесс обращается к открытому файлу, сокету или каналу. У всех есть fd 0 (stdin), fd 1 (stdout), fd 2 (stderr).
pipe() создаёт канал — пару связанных дескрипторов: что записали в
«пишущий» конец, можно прочитать из «читающего». Шелл создаёт пайп до форка,
а после форка оба потомка наследуют эти концы. Один направляет свой stdout в
пишущий конец, другой — свой stdin в читающий. Получается труба между двумя
программами.
ШЕЛЛ выполняет: ls | wc -l
shell: pipe() ──▶ [ read_fd ]◀══════════╗ [ write_fd ]
│ ║ │
fork+exec ls│ fork+exec wc │
▼ ║ ▼
┌──────────────┐ │ ┌──────────────┐ │
│ ls │ │ │ wc -l │ │
│ stdout(1) ────┼───╫────▶│ │ │
│ │ write │ stdin(0) ◀────┼──────┘
└──────────────┘ конец └──────────────┘ read конецЧто нарисовано: шелл сделал pipe(), затем форкнул двух потомков; ls своим
stdout пишет в пишущий конец трубы, wc своим stdin читает из читающего конца.
Дескрипторы пережили exec(), поэтому уже запущенные ls и wc ничего не знают
о пайпе — для них это просто «обычные» stdin/stdout. Программы пишутся
независимо, а соединяет их шелл через наследование fd.
А как это в Go
В Go вы почти никогда не вызываете голый fork(). Стандартный путь — пакет
os/exec:
package main
import (
"errors"
"fmt"
"os"
"os/exec"
)
func main() {
cmd := exec.Command("sh", "-c", "echo привет из потомка; exit 3")
cmd.Stdout = os.Stdout // в окне между fork и exec потомку подставят наш stdout
err := cmd.Run() // fork + exec + wait
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
fmt.Println("потомок завершился с кодом", exitErr.ExitCode())
}
fmt.Println("PID потомка был", cmd.Process.Pid)
}привет из потомка
потомок завершился с кодом 3
PID потомка был 67992cmd.Run() — это Start() (порождение процесса и exec) плюс Wait() (тот самый
wait(), который забирает код возврата и не даёт потомку остаться зомби).
Ненулевой код возврата Go отдаёт как ошибку типа *exec.ExitError. Частая ошибка:
вызвать cmd.Start() и забыть cmd.Wait(). Потомок отработает, но останется
зомби до конца жизни вашего сервиса.
На Linux Go вызывает не fork(), а системный вызов clone() с
флагами, при которых потомок временно делит память с родителем и сразу делает
exec (так быстрее: не нужно даже размечать страницы для copy-on-write). Смысл тот
же: новый процесс, окно для настройки, exec.
Дескрипторы и close-on-exec. Выше говорилось, что дескрипторы переживают
exec. В Go это почти никогда не так: стандартная библиотека открывает все файлы
и сокеты с флагом O_CLOEXEC, и при exec ядро их закрывает. Потомок получает
только то, что вы передали явно: cmd.Stdin, cmd.Stdout, cmd.Stderr (fd 0, 1,
2) и cmd.ExtraFiles (они станут fd 3, 4 и дальше). Это защита от утечки: иначе
дочерняя утилита унаследовала бы, например, слушающий сокет сервиса и держала бы
порт открытым после его остановки.
Почему Go не любит голый fork(). Go-программа многопоточная по своей
природе: рантайм держит несколько потоков ОС, на которых крутятся
горутины, сборщик мусора, планировщик.
fork() копирует только вызвавший поток, а остальные потоки в потомке просто
исчезают вместе с блокировками (мьютексами), которые они в тот момент держали.
Потомок легко зависает на захваченном навсегда локе или работает с испорченным
состоянием рантайма. Поэтому единственный безопасный сценарий после fork() в
многопоточной программе — немедленно сделать exec(), ничего больше не трогая.
Именно это os/exec и делает.
Что вернёт fork() в процессе-потомке при успехе?
Что происходит с PID процесса при успешном вызове exec()?
Кто такой процесс-зомби?
Какой PID у процесса, который по умолчанию усыновляет осиротевших потомков?
Какие утверждения о связке fork+exec и дескрипторах верны?
Почему в Go избегают голого fork() без немедленного exec()?
Проверьте себя
- Программа вызвала
fork()два раза подряд (безif). Сколько процессов получится, считая исходный? - Что напечатает родитель в примере на C, если в потомке вызвать
exit(5)вместоexeclp? - В
psвы видите у Go-сервиса десятки потомков в состоянииZ. Какую ошибку искать в коде? - Почему файл, открытый в Go через
os.Open, не виден в запущенной черезos/execутилите?
Показать ответы
Ответы. (1) Четыре: после первого fork два процесса, каждый делает второй
fork. (2) «потомок ... завершился с кодом 5, x = 100». (3) cmd.Start() без
последующего cmd.Wait(). (4) Go открывает файлы с O_CLOEXEC, ядро закрывает
такой дескриптор при exec; передать его можно через cmd.ExtraFiles.
Что спрашивают на собесе
- Что возвращает
fork()и почему дважды? В родителе — PID потомка (>0), в потомке — 0, при ошибке -1. Возврат «дважды», потому что после вызова исполняются уже два процесса. - Зачем разделили
fork()иexec()? Из-за окна между ними: потомок успевает настроить дескрипторы/окружение (редиректы, пайпы, понижение прав) до запуска новой программы. - Чем зомби отличается от сироты? Зомби — мёртвый потомок при живом родителе,
который не сделал
wait()(висит запись в таблице). Сирота — живой потомок при мёртвом родителе, его усыновляет init (PID 1). - Как сделать так, чтобы зомби не копились? Делать
wait()/waitpid()(в Go — не забыватьcmd.Wait()), обрабатыватьSIGCHLD; в контейнере ставить init вродеtini. - Почему пайп
a | bвообще работает?pipe()доfork()+ наследование fd при fork + сохранение fd при exec: концы трубы попадают в потомков как stdin/stdout. - Почему в Go опасен голый
fork()безexec()? Копируется только текущий поток; остальные потоки рантайма пропадают вместе с удерживаемыми локами — потомок может зависнуть или испортить состояние. Поэтому используютos/exec.