ОС · Виртуализация CPU · 14 мин
API процессов: fork, exec, wait
Откуда берутся процессы
В прошлых главах мы разобрали, что такое процесс и как ядро безопасно пускает его на ядро через limited direct execution. Остался вопрос, который звучит наивно, но именно его любят на собесах: а как один процесс вообще рождает другой?
В Unix нет вызова «запусти программу X». Вместо этого есть три кирпичика, из
которых собирается всё — от запуска ls в шелле до старта пода в Kubernetes:
fork()— клонировать текущий процесс (получить копию самого себя);exec()— заменить программу внутри процесса на другую;wait()— дождаться завершения потомка и забрать его код возврата.
Звучит странно: чтобы запустить программу, надо сначала скопировать себя, а потом себя же затереть. Но именно эта развилка между «копированием» и «заменой» даёт Unix всю его гибкость. Разберём по очереди.
fork(): клон самого себя
fork() создаёт почти точную копию вызвавшего процесса. Был один процесс —
стало два. Тот, кто вызвал fork(), называется родитель (parent), новый —
потомок (child).
Аналогия: представьте, что вы заполнили длинную анкету, а потом сделали её
ксерокопию. Теперь у вас два одинаковых листа. Дальше каждый можно править
отдельно — правка на одном не меняет другой. Вот fork() — это и есть ксерокс
процесса: копируется адресное пространство (код, куча, стек), копируются
значения регистров, копируется таблица открытых файловых дескрипторов.
до 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 не меняется, код и память заменяются целиком, а
вот открытые файловые дескрипторы по умолчанию переживают замену программы
(к этому вернёмся — на этом держатся пайпы и редиректы).
Ещё важная деталь: если 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) |
не блокирует: «уже завершился? нет — иду дальше» |
Зомби и сироты
Здесь живут два классических термина, которые путают на собесах.
Зомби (zombie) — потомок уже завершился, но родитель ещё не сделал
wait(). Почему процесс не исчезает сразу? Потому что ядро обязано сохранить
его код возврата — вдруг родитель захочет его прочитать. Поэтому от процесса
остаётся крошечная запись в таблице процессов («труп»), которая висит, пока
родитель не заберёт статус через wait(). После wait() запись окончательно
удаляется — зомби «упокаивается».
Зомби не потребляет CPU и память (тело уже освобождено), но занимает слот в
таблице процессов и номер PID. Если родитель плодит детей и не делает wait(),
зомби копятся и могут исчерпать лимит PID — это реальный баг в продакшене.
Сирота (orphan) — наоборот: родитель умер раньше потомка. Кто теперь
сделает wait()? Потомка усыновляет init — процесс с PID 1 (в современных
системах это systemd или, внутри контейнера, ваш entrypoint). init периодически
делает wait() за всех усыновлённых, поэтому корректно завершившийся сирота
не превращается в вечного зомби.
ЗОМБИ СИРОТА
┌──────────┐ ┌──────────┐
│ родитель │ жив, но не wait() │ родитель │ умер ✗
└────┬─────┘ └──────────┘
│ ребёнок завершился ✗ │ а ребёнок ещё жив
▼ ▼
┌──────────┐ ┌──────────┐
│ потомок │ = ZOMBIE │ потомок │ усыновлён PID 1
│ (труп в │ ждёт wait() │ (живой) │ ───▶ init сделает
│ таблице) │ │ │ wait() за него
└──────────┘ └──────────┘Что нарисовано: слева — родитель жив, но забыл wait(), мёртвый потомок висит
зомби; справа — родитель умер, живой потомок переподвешивается к init (PID 1),
который потом его и похоронит. Важно: зомби — это про мёртвого ребёнка при
живом родителе, а сирота — про живого ребёнка при мёртвом родителе.
Наследование дескрипторов: магия пайпов
Мы говорили, что при 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:
cmd := exec.Command("ls", "-l")
out, err := cmd.Output() // под капотом: fork + exec + waitexec.Command + Run/Output/Start внутри делают связку
fork()+exec() (в Go это syscall.ForkExec / os.StartProcess), а cmd.Wait()
— это wait(), который забирает код возврата (cmd.ProcessState.ExitCode()).
Перенаправление вывода (cmd.Stdout = ...) реализовано ровно через тот же приём
с дескрипторами в «окне» между fork и exec.
Почему Go не любит голый fork(). Go-программа — многопоточная по своей
природе: рантайм держит пул потоков ОС, на которых крутятся
горутины, сборщик мусора, планировщик.
fork() копирует только вызвавший поток, а все остальные потоки в потомке
просто исчезают — вместе с любыми блокировками (мьютексами), которые они в тот
момент держали. В результате потомок легко зависает на захваченном навсегда
локе или ловит порчу состояния рантайма. Поэтому единственный безопасный сценарий
после голого fork() — немедленно сделать exec(), ничего больше не трогая.
Именно это os/exec и делает за вас, аккуратно и в правильном порядке.
Что вернёт fork() в процессе-потомке при успехе?
Что происходит с PID процесса при успешном вызове exec()?
Кто такой процесс-зомби?
Кто усыновляет осиротевший процесс (родитель которого умер раньше)?
Какие утверждения о связке fork+exec и дескрипторах верны?
Почему в Go избегают голого fork() без немедленного exec()?
Что спрашивают на собесе
- Что возвращает
fork()и почему дважды? В родителе — PID потомка (>0), в потомке — 0, при ошибке -1. Возврат «дважды», потому что после вызова исполняются уже два процесса. - Зачем разделили
fork()иexec()? Из-за окна между ними: потомок успевает настроить дескрипторы/окружение (редиректы, пайпы, понижение прав) до запуска новой программы. - Чем зомби отличается от сироты? Зомби — мёртвый потомок при живом родителе,
который не сделал
wait()(висит запись в таблице). Сирота — живой потомок при мёртвом родителе, его усыновляет init (PID 1). - Как сделать так, чтобы зомби не копились? Делать
wait()/waitpid(); как чинить накопившихся — забирать статусы или обрабатыватьSIGCHLD. - Почему пайп
a | bвообще работает?pipe()доfork()+ наследование fd при fork + сохранение fd при exec: концы трубы попадают в потомков как stdin/stdout. - Почему в Go опасен голый
fork()безexec()? Копируется только текущий поток; остальные потоки рантайма пропадают вместе с удерживаемыми локами — потомок может зависнуть или испортить состояние. Поэтому используютos/exec.