Виртуализация CPU · 14 мин

Контент v0.12.0

API процессов: 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 потомка был 67992

cmd.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 и делает.

Проверь себя· API процессов

Что вернёт fork() в процессе-потомке при успехе?

Что происходит с PID процесса при успешном вызове exec()?

Кто такой процесс-зомби?

Какой PID у процесса, который по умолчанию усыновляет осиротевших потомков?

Какие утверждения о связке fork+exec и дескрипторах верны?

Почему в Go избегают голого fork() без немедленного exec()?

Проверьте себя

  1. Программа вызвала fork() два раза подряд (без if). Сколько процессов получится, считая исходный?
  2. Что напечатает родитель в примере на C, если в потомке вызвать exit(5) вместо execlp?
  3. В ps вы видите у Go-сервиса десятки потомков в состоянии Z. Какую ошибку искать в коде?
  4. Почему файл, открытый в 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.

Продолжить