---
title: "API процессов: fork, exec, wait"
description: "Мы уже знаем, что такое процесс, как ядро ОС безопасно запускает его на CPU (прямое исполнение с ограничениями) и как выбирает, кого запускать (планирование). Остался вопрос…"
canonical: https://graphlms.ru/os/book/process-api
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# API процессов: fork, exec, wait

> Источник: https://graphlms.ru/os/book/process-api — курс «Операционные системы», GraphLMS.

## Откуда берутся процессы

Мы уже знаем, [что такое процесс](/os/book/process), как ядро ОС безопасно
запускает его на CPU ([прямое исполнение с ограничениями](/os/book/limited-direct-execution))
и как выбирает, кого запускать ([планирование](/os/book/scheduling)).
Остался вопрос, который звучит наивно, но именно его любят на собесах:
**а как один процесс вообще рождает другой?**

Классический Unix-ответ — не один вызов «запусти программу X», а три кирпичика, из
которых собирается всё, от запуска `ls` в шелле до старта контейнера:

- `fork()` — **клонировать** текущий процесс (получить копию самого себя);
- `exec()` — **заменить** программу внутри процесса на другую;
- `wait()` — **дождаться** завершения потомка и забрать его код возврата.

Звучит странно: чтобы запустить программу, надо сначала скопировать себя, а потом
себя же затереть. Но именно эта развилка между «копированием» и «заменой» даёт
Unix всю его гибкость. Разберём по очереди.

## fork(): клон самого себя

`fork()` создаёт **почти точную копию** вызвавшего процесса. Был один процесс —
стало два. Тот, кто вызвал `fork()`, называется **родитель** (parent), новый —
**потомок** (child).

Похоже на ксерокопию заполненной анкеты: получаются два одинаковых листа, и
дальше правка на одном не меняет другой. Копируется адресное пространство (код,
куча, стек), значения регистров и таблица открытых файловых дескрипторов.

```text
        до fork()                 после fork()

   ┌───────────────┐         ┌───────────────┐   ┌───────────────┐
   │  процесс A     │         │  процесс A     │   │  процесс A'    │
   │  PID = 100     │  fork() │  PID = 100     │   │  PID = 101     │
   │  своя память   │ ──────▶ │  своя память   │   │  КОПИЯ памяти  │
   │  свои fd       │         │  (родитель)    │   │  (потомок)     │
   └───────────────┘         └───────────────┘   └───────────────┘
```

Что нарисовано: был один процесс с PID 100, после `fork()` появился второй
(PID 101) с собственной независимой копией памяти. Дальше они живут параллельно.

Самое коварное место для новичка: **`fork()` возвращает значение дважды** — по
разу в каждом процессе. Это потому, что после вызова исполняются уже два
процесса, и оба продолжают с одной и той же строки. Различить, «кто я», можно по
возвращённому значению:

```text
   pid := fork()

   ┌──────────────────────────┬──────────────────────────┐
   │   в РОДИТЕЛЕ               │   в ПОТОМКЕ               │
   │   pid = PID потомка (>0)   │   pid = 0                 │
   │   "я родитель, мой        │   "я потомок"            │
   │    ребёнок — 101"          │                          │
   └──────────────────────────┴──────────────────────────┘
            при ошибке (нет памяти и т.п.): pid = -1
```

Что нарисовано: одна и та же переменная `pid` имеет разное значение в двух
процессах. Родитель получает PID потомка (положительное число), потомок получает
0, а если форк не удался — возвращается -1. Дальше код обычно ветвится через
`if pid == 0 { ...потомок... } else { ...родитель... }`.

> На самом деле память не копируется целиком в момент `fork()` — это было бы
> дорого. Используется **copy-on-write (CoW)**: оба процесса сначала смотрят в
> одни и те же физические страницы (помеченные «только чтение»), а реальное
> копирование страницы происходит лениво, только когда кто-то в неё пишет.
> Подробнее про страницы — в главе [paging](/os/book/paging).

## exec(): сменить программу, не меняя процесс

`fork()` даёт копию той же программы. Но нам обычно нужно запустить **другую**
программу. Для этого есть семейство вызовов `exec()`.

`exec()` берёт исполняемый файл с диска и **загружает его поверх текущего
процесса**: старый код, куча и стек выбрасываются, на их место кладётся новая
программа, и исполнение начинается с её точки входа. Важно: **новый процесс не
создаётся** — это тот же самый PID, та же запись в таблице процессов, просто
«начинка» стала другой.

Аналогия: вы — тот же человек с тем же паспортом (PID), но переоделись с ног до
головы и пошли заниматься совсем другим делом. Документ тот же, личность снаружи
та же, а содержание деятельности полностью поменялось.

```text
   процесс 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.

```text
   ВРЕМЕННАЯ ШКАЛА: шелл запускает "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](/os/book/process)): пока
ждём ребёнка, занимать ядро незачем.

| Вызов | Что делает |
|---|---|
| `wait()` | ждёт завершения **любого** потомка |
| `waitpid(pid)` | ждёт **конкретного** потомка по PID |
| `waitpid(pid, WNOHANG)` | не блокирует: «уже завершился? нет — иду дальше» |

## Все три вызова на живом примере

Чтобы увидеть механику без обёрток, напишем её на C. Go-разработчику C писать не
придётся, но читать такой код полезно: именно так устроены шелл, `os/exec` и
запуск контейнера.

```c
#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 у вас будут другие):

```text
до 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 и рантаймы контейнеров.)

```text
   ЗОМБИ                              СИРОТА
   ┌──────────┐                      ┌──────────┐
   │ родитель  │ жив, но не wait()    │ родитель  │ умер ✗
   └────┬─────┘                      └──────────┘
        │ ребёнок завершился ✗            │ а ребёнок ещё жив
        ▼                                  ▼
   ┌──────────┐                      ┌──────────┐
   │ потомок   │ = ZOMBIE            │ потомок   │ усыновлён PID 1
   │ (труп в   │  ждёт wait()         │ (живой)   │ ───▶ init сделает
   │  таблице) │                      │           │      wait() за него
   └──────────┘                      └──────────┘
```

Что нарисовано: слева — родитель жив, но забыл `wait()`, мёртвый потомок висит
зомби; справа — родитель умер, живой потомок переподвешивается к init (PID 1),
который потом его и похоронит. Важно: **зомби — это про мёртвого ребёнка при
живом родителе**, а **сирота — про живого ребёнка при мёртвом родителе**.

### PID 1 в контейнере

В контейнере с отдельным пространством PID (подробнее в главе
[контейнеры](/os/book/containers-namespaces)) ваш процесс из `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 в читающий. Получается труба между двумя
программами.

```text
   ШЕЛЛ выполняет:  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`:

```go
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)
}
```

```text
привет из потомка
потомок завершился с кодом 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-программа многопоточная по своей
природе: рантайм держит несколько потоков ОС, на которых крутятся
[горутины](/go/book/goroutines), сборщик мусора, [планировщик](/go/book/scheduler-deep).
`fork()` копирует только **вызвавший поток**, а остальные потоки в потомке просто
исчезают вместе с блокировками (мьютексами), которые они в тот момент держали.
Потомок легко зависает на захваченном навсегда локе или работает с испорченным
состоянием рантайма. Поэтому единственный безопасный сценарий после `fork()` в
многопоточной программе — немедленно сделать `exec()`, ничего больше не трогая.
Именно это `os/exec` и делает.

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/process-api

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

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`.

## Вопросы для самопроверки

**Квиз «API процессов»**

1. Что вернёт fork() в процессе-потомке при успехе?
   - -1
   - 0 (верно)
   - PID родителя
   - PID потомка
   - Пояснение: В потомке fork() возвращает 0, в родителе — PID нового потомка (>0), а -1 — это ошибка форка. По этому значению код и различает, кто есть кто.
2. Что происходит с PID процесса при успешном вызове exec()?
   - PID остаётся тем же — меняется только программа внутри (верно)
   - процесс завершается
   - создаётся новый процесс с новым PID
   - PID обнуляется
   - Пояснение: exec() не создаёт новый процесс: тот же PID и запись в таблице сохраняются, заменяются только код, куча и стек. Поэтому exec при успехе не возвращает управление — старого кода уже нет.
3. Кто такой процесс-зомби?
   - процесс, который завис в бесконечном цикле и ест CPU
   - потомок, чей родитель умер раньше него
   - потомок, который завершился, но родитель ещё не сделал wait() (верно)
   - процесс, заблокированный на чтении с диска
   - Пояснение: Зомби — это завершившийся потомок, чей код возврата ядро держит в таблице процессов до тех пор, пока родитель не заберёт его через wait(). Тело уже освобождено, но слот/PID заняты.
4. Какой PID у процесса, который по умолчанию усыновляет осиротевших потомков?
   - Ответ: 1
   - Пояснение: У init, процесса с PID 1 (systemd, а в контейнере его роль играет entrypoint). Он периодически вызывает wait() за усыновлёнными, чтобы они не оставались зомби. Поэтому приложение с PID 1 в контейнере обязано уметь собирать зомби.
5. Какие утверждения о связке fork+exec и дескрипторах верны?
   - наследование fd при fork и их сохранение при exec — основа пайпов и редиректов (верно)
   - fork() копирует таблицу открытых файловых дескрипторов потомку (верно)
   - при exec() открытые дескрипторы по умолчанию сохраняются (верно)
   - exec() создаёт новый процесс с новым PID
   - Пояснение: fork копирует таблицу fd, exec по умолчанию их сохраняет, а exec не создаёт новый процесс (PID тот же). Именно эти два свойства fd позволяют шеллу строить пайпы и редиректы.
6. Почему в Go избегают голого fork() без немедленного exec()?
   - копируется только вызвавший поток, остальные потоки рантайма с их локами пропадают — потомок может зависнуть или испортить состояние (верно)
   - горутины не умеют копироваться
   - fork() в Go вообще запрещён компилятором
   - fork() в Go всегда возвращает -1
   - Пояснение: Go-рантайм многопоточный (GC, планировщик, горутины). fork() копирует лишь текущий поток; прочие потоки исчезают вместе с захваченными мьютексами. Безопасно только сразу сделать exec(), что и делает os/exec.
