GraphLMS

ОС
Начать

ОС · Виртуализация 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 + wait

exec.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 и делает за вас, аккуратно и в правильном порядке.

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

Что вернёт 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.
MLFQ: планировщик, который угадывает будущееПропорциональное планирование и CFS