6. Как Linux создает процессы. fork, exec, wait
Как Linux создает процессы. fork, exec, wait
«Unix прост. Требуется лишь быть гением, чтобы постичь эту простоту».
— Деннис Ритчи
В бэкенд-разработке на Go мы редко пишем код, который существует в вакууме. Каждый раз, когда ваш сервис запускает CLI-утилиту через os/exec, выполняет миграции базы данных, сжимает видео через ffmpeg, генерирует PDF или запускает изолированного воркера, вы вступаете в прямой диалог с операционной системой.
В 1969 году в Bell Labs Кен Томпсон и Деннис Ритчи приняли одно из самых изящных и спорных решений в истории вычислительной техники: разделить рождение новой программы на два независимых шага — клонирование (fork) и перерождение (exec).
В других операционных системах (например, Windows с ее исполинским системным вызовом CreateProcess(), принимающим десяток параметров) создание процесса атомарно. Но в мире Unix и Linux именно разделение на fork и exec подарило миру конвейеры командной строки (|), перенаправление потоков ввода-вывода (>, <), плавную смену привилегий и оптимизацию Copy-On-Write (COW).
Давайте снимем слои абстракций и разберем, как ядро Linux создает, трансформирует и хоронит процессы, и как Go-разработчику управлять этим оркестром без утечек памяти и зависших дескрипторов.
Модель создания процессов: Fork + Exec
В POSIX-системах (включая Linux) запуск новой программы никогда не происходит «с чистого листа». Процесс не может возникнуть из ниоткуда: он обязан родиться от уже существующего родительского процесса.
Модель состоит из двух фундаментальных фаз:
fork(): Создает почти точную копию текущего процесса. Новый процесс (потомок, child) получает свой собственный уникальныйPID, но наследует виртуальное адресное пространство родителя, дубликаты файловых дескрипторов, переменные окружения и текущее состояние регистров.exec(): Полностью уничтожает старое адресное пространство вызвавшего его процесса и заменяет его новым исполняемым файлом. Память, код, куча и стек очищаются, с диска загружается новый бинарник, и исполнение начинается заново с точки входа (entry point).
РОДИТЕЛЬСКИЙ ПРОЦЕСС (PID 100)
│
│ fork()
▼
┌─────────────────────────────────────┐
│ Ядро дублирует task_struct и память │
└──────────────────┬──────────────────┘
│
┌────────────────┴────────────────┐
▼ ▼
РОДИТЕЛЬ (PID 100) ПОТОМОК (PID 101)
fork() вернул 101 fork() вернул 0
(Продолжает старый код) (Готовит окружение)
│
│ execve("/bin/ls")
▼
НОВАЯ ПРОГРАММА (PID 101)
Память сброшена, запущен /bin/ls
Точка входа: _start
Под капотом: что делает fork?
Когда в коде вызывается fork(), процессор переходит в режим ядра (Ring 0). В исходном коде Linux за этот системный вызов отвечает универсальная внутренняя функция kernel_clone() (в старых ядрах — _do_fork()).
Основной структурный элемент процесса в Linux — это task_struct. Путь ядра при fork() выглядит следующим образом:
- Выделение памяти: Ядро выделяет новый экземпляр
task_structи стек ядра через кэш слябов (kmem_cache). - Инициализация идентификаторов: Процессу назначается новый
PID, а егоPPID(Parent PID) устанавливается равным PID родителя. - Самое важное: копирование адресного пространства (без копирования данных). Если бы Linux физически копировал гигабайты оперативной памяти родителя, вызов
fork()занимал бы секунды. Вместо этого ядро копирует таблицы страниц (Page Tables).- Создаются новые записи в таблице страниц потомка, которые указывают на те же самые физические фреймы RAM, что и у родителя.
- И родительские, и дочерние записи в таблицах страниц помечаются флагом Read-Only (только для чтения).
- Счетчик ссылок (
refcount) на физические страницы в структуреstruct pageинкрементируется.
- Дублирование дескрипторов: Копируется таблица файловых дескрипторов (
struct files_struct), счетчики ссылок на открытые файлы (struct file) увеличиваются. - Возврат значений: Ядро помещает в регистр возврата (
RAXна x86-64) значение:- Родителю возвращается PID созданного потомка (положительное число).
- Потомку возвращается 0.
sequenceDiagram
participant Parent as Родительский процесс
participant Kernel as Ядро Linux
participant Child as Потомок
participant RAM as Физическая память
Parent->>Kernel: Вызов fork
Kernel->>Kernel: Выделить task_struct
Kernel->>Kernel: Скопировать Page Tables (R-O флаги)
Kernel->>RAM: Пометить страницы как COW
Kernel-->>Parent: Вернуть PID родителя
Kernel-->>Child: Вернуть PID 0 (потомок)
Parent->>RAM: Запись в страницу
RAM-->>Kernel: Page Fault
Kernel->>RAM: Выделить новую страницу и скопировать данные
Child->>RAM: Чтение страницы (без копирования)
Как только fork возвращает управление, оба процесса исполняются параллельно. Если потомок или родитель попытается выполнить обычное чтение, данные будут прочитаны из общей физической памяти с максимальной скоростью кэшей процессора.
Но как только любой из них попытается записать хотя бы один байт в унаследованную страницу, аппаратный блок MMU обнаружит флаг Read-Only и сгенерирует прерывание процессора — Page Fault (ошибка страницы #PF). Ядро перехватывает его, видит флаг COW, выделяет новую физическую страницу памяти 4 КБ, копирует в нее старое содержимое, обновляет запись в таблице страниц пишущего процесса с правами записи (Read-Write) и возобновляет команду. Это и есть Copy-On-Write.
Под капотом: что делает exec?
После fork потомок все еще является близнецом родителя. Чтобы превратить его в совершенно другую программу, вызывается фундаментальный системный вызов execve() (или его библиотечные обертки execl, execv, execvp из glibc):
int execve(const char *pathname, char *const argv[], char *const envp[]);
Что делает ядро при получении этого вызова?
- Очистка: Ядро безжалостно освобождает старое адресное пространство потомка: код, сегмент инициализированных данных, стек и куча уничтожаются. Соответствующие записи удаляются из таблицы страниц, физическая память освобождается (или декрементируется refcount).
- Парсинг бинарного формата (ELF):
- Ядро считывает заголовок файла (в Linux это Executable and Linkable Format — ELF).
- Проверяются «магические байты» (
0x7F E L F), архитектура CPU и права на исполнение (chmod +x). - Определяются сегменты программы:
.text(машинный код),.data(инициализированные переменные),.bss(неинициализированные переменные).
- Отображение в виртуальную память (mmap): Ядро настраивает новые виртуальные области памяти (
vm_area_struct), отображая секции ELF-файла напрямую в адресное пространство процесса через страничный механизм (Demand Paging). Код программы даже не загружается в RAM целиком сразу: страницы подгружаются с диска по мере исполнения инструкций! - Формирование стека: На самую вершину нового стека помещаются аргументы командной строки (
argv), переменные окружения (envp) и вспомогательный вектор ядра (auxv). Устанавливается новый указатель стека (RSPилиesp). - Точка входа и старт: Процессор устанавливает регистр инструкций на адрес
entry_point.- Для статически скомпилированных программ (например, большинства Go-бинарников) управление сразу передается на адрес
_start. - Для динамически скомпилированных C/C++ программ управление передается динамическому компоновщику (обычно
/lib64/ld-linux-x86-64.so.2), который подгружает разделяемые библиотеки (.so), вызывает__libc_start_mainи лишь затем передает управление функцииmain().
- Для статически скомпилированных программ (например, большинства Go-бинарников) управление сразу передается на адрес
Синхронизация и жизненный цикл: wait и сигналы
После того как потомок ушел исполнять свой собственный код через exec, родитель должен управлять его жизненным циклом. Процессы не должны растворяться бесследно — родитель обязан получить результат работы своего детища.
Здесь в игру вступают wait() и сигналы ядра:
- Zombie (Зомби): Когда потомок вызывает
exit()или падает, ядро мгновенно отбирает у него всю память и закрывает открытые файлы. Однако сам дескрипторtask_structне удаляется из таблицы процессов! Он замирает в состоянии зомби, сохраняя код возврата и статистику использования ресурсов (rusage), ожидая, пока родитель прочитает этот статус. - Orphan (Сирота): Если родитель погиб раньше своего потомка, потомок становится «сиротой». Ядро предотвращает зависание таких процессов: оно переназначает
PPIDпотомка корневому системному процессору (PID 1, историческиinit, в современных дистрибутивах —systemd). Корневой процесс непрерывно мониторит сирот и автоматически забирает их статус черезwait(). waitpid(): Системный вызов, позволяющий родителю приостановить свое выполнение до завершения конкретного дочернего процесса (или любого из них) и безопасно забрать код возврата (exit code).- Сигнал
SIGCHLD: Когда состояние любого дочернего процесса меняется (завершился, упал, остановлен сигналом), ядро посылает родительскому процессу асинхронный сигналSIGCHLD.- В высоконагруженных демонах родитель не может стоять и ждать каждого потомка через блокирующий
wait(). -
Вместо этого устанавливается обработчик сигнала
SIGCHLD, внутри которого вызывается неблокирующийwaitpid()с флагомWNOHANG:C // Классический Unix-паттерн очистки зомби while (waitpid(-1, &status, WNOHANG) > 0) { // Очищаем всех завершившихся потомков без блокировки потока }
- В высоконагруженных демонах родитель не может стоять и ждать каждого потомка через блокирующий
Реализация в Go: os/exec и особенности рантайма
Пакет os/exec стандартной библиотеки Go предоставляет высокоуровневую абстракцию над системными вызовами fork, execve и waitpid. Но за лаконичным API скрывается тонкая инженерная работа рантайма Go.
package main
import (
"bytes"
"context"
"fmt"
"log"
"os/exec"
"time"
)
func runCommand() error {
// 1. Создаем контекст с жестким таймаутом на выполнение
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// 2. Инициализация команды
cmd := exec.CommandContext(ctx, "ls", "-la", "/tmp")
// 3. Буферы для захвата stdout и stderr
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// 4. Запуск (внутренне: fork + execve)
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start: %w", err)
}
// 5. Ожидание завершения (внутренне: waitpid)
// cmd.Wait блокирует текущую горутину; в системном вызове ожидания застревает её поток M,
// но рантайм передаёт P другому потоку, и остальные горутины работают дальше.
if err := cmd.Wait(); err != nil {
return fmt.Errorf("process exited with error: %w (stderr: %s)", err, stderr.String())
}
fmt.Printf("Output:\n%s\n", stdout.String())
return nil
}
func main() {
if err := runCommand(); err != nil {
log.Fatalf("Command execution failed: %v", err)
}
}
Как os/exec работает под капотом в Go
cmd.Start()вызываетos.ForkExec/syscall.ForkExec:
Классическийfork()в многопоточной программе смертельно опасен: если в момент форка другой тред родительского процесса удерживал внутренний мьютекс аллокатора памяти, в дочернем процессе этот мьютекс навсегда останется заблокированным, что приведет к мгновенному дедлоку при любой аллокации.
Поэтому код между созданием потомка иexecveв пакетеsyscall(forkAndExecInChild) написан очень осторожно: на Go, но в режимеnosplitи без аллокаций (ассемблерными остаются лишь самые низкоуровневые вставки вродеrawVforkSyscall). В Linux Go вызывает системный вызовcloneс флагамиCLONE_VFORK | CLONE_VM | SIGCHLD. Потомок не дублирует память родителя, а временно использует ее, не совершая аллокаций, и сразу переходит кexecve().- Изоляция дескрипторов:
ФлагCLONE_FILESнамеренно не передается: потомку необходима независимая таблица дескрипторов, чтобы безопасно настроить пайпы и перенаправить stdin/stdout/stderr черезdup3/dup2перед вызовомexecve, не повреждая дескрипторы родительского процесса. - Потомок вызывает
execve():
Образ процесса замещается указанным исполняемым файлом. cmd.Wait()ждёт завершения потомка (на современных ядрах черезwaitidпо pidfd, иначе черезwait4, обёртку надwaitpid):
Это обычный блокирующий системный вызов: он удерживает потокM, а рантайм передаётPдругому потоку (hand-off), так что остальные горутины продолжают работать. Когда ядро отдаёт статус завершения, вызов возвращается. Если вы забыли вызватьWait(), процесс гарантированно станет зомби, а выделенные файловые дескрипторы пайпов зависнут в открытом состоянии.
Ловушки и вопросы на собеседованиях
1. Утечка файловых дескрипторов при exec и спасение FD_CLOEXEC
По стандарту POSIX все открытые файловые дескрипторы родителя по умолчанию остаются открытыми и в дочернем процессе после exec().
Представьте: ваш веб-сервер слушает входящие TCP-соединения на порту 8080. Вы вызываете утилиту grep через os/exec. Если не принять мер, запущенный grep унаследует слушающий сокет порта 8080! Даже если вы перезагрузите основной сервер, порт 8080 останется занятым утилитой grep, и новый сервер упадет с ошибкой bind: address already in use.
Как это решено: В Linux существует флаг FD_CLOEXEC (Close-on-Exec), устанавливаемый через системный вызов fcntl, а также флаги O_CLOEXEC и SOCK_CLOEXEC. Стандартная библиотека Go (os.Open, net.Dial, net.Listen) по умолчанию автоматически помечает абсолютно все создаваемые дескрипторы флагом FD_CLOEXEC. При вызове execve() ядро само закрывает все лишние дескрипторы, кроме тех, что явно переданы в cmd.Stdin, cmd.Stdout, cmd.Stderr.
2. Зомби-апокалипсис в контейнерах Docker и Kubernetes
Если вы запускаете Go-сервис, который порождает подпроцессы, и забываете вызывать cmd.Wait() (например, выстрелили через cmd.Start() в фоновой горутине без ожидания), дочерние процессы по завершении превращаются в зомби (TASK_ZOMBIE).
Хуже всего, если ваш Go-бинарник запущен внутри Docker-контейнера как PID 1 (Entrypoint). Корневой процесс контейнера обязан брать на себя роль init и вычитывать завершившихся зомби-сирот. Если Go-код этого не делает, таблица PID контейнера мгновенно забивается мертвыми процессами.
Решение: Использовать утилиты инициализации вроде tini (docker run --init) или всегда гарантировать вызов cmd.Wait() в коде (например, через явный вызов или паттерн defer cmd.Wait()).
3. Зависание cmd.Wait() и Uninterruptible Sleep (D-state)
Что произойдет, если подпроцесс завис на чтении поврежденного NFS-диска, а вы отправили ему сигнал SIGINT или SIGKILL через cmd.Process.Kill() или сработал таймаут контекста context.WithTimeout?
Ядро посылает сигнал SIGKILL. Однако если процесс находится в состоянии непрерываемого сна (uninterruptible sleep, флаг D в выводе ps), процессор не доставит сигнал до тех пор, пока драйвер диска не вернет управление. В результате вызов cmd.Wait() в Go продолжит висеть бесконечно!
Совет архитектору: Помните, что context.Context в os/exec отменяет процесс отправкой сигнала SIGKILL, но он бессилен против зависших на аппаратном уровне I/O-операций.
4. Производительность fork на процессах с огромной памятью (RSS)
Если ваш родительский Go-сервер держит в оперативной памяти 30 ГБ кэша, насколько быстр будет fork()?
Хотя Copy-On-Write избавляет от копирования 30 ГБ данных, ядру все равно приходится скопировать таблицы страниц (Page Tables). Для 30 ГБ памяти размер самих таблиц страниц составляет около 60 МБ! Копирование 60 МБ в ядре под блокировкой может вызвать микропаузу (latency spike) до 20–50 миллисекунд. Именно поэтому Go использует связку vfork / clone(CLONE_VM), полностью устраняя даже эту задержку.
Итог
- Двухэтапная модель
fork+exec— фундаментальное архитектурное наследие Unix, предоставляющее гибкость настройки дескрипторов и окружения перед запуском нового бинарника. - Copy-On-Write (COW) защищает систему от бессмысленного копирования терабайтов памяти: физические страницы делятся между процессами до первой попытки модификации данных.
- Системный вызов
execveне создает новый процесс, а замещает тело и память существующего процесса новым кодом в формате ELF. - Процесс, код которого завершился, остается зомби (
TASK_ZOMBIE) в таблице ядра до тех пор, пока родитель не вызоветwaitpidи не освободит егоPID. - В Go пакет
os/execреализует безопасное порождение процессов черезclone(CLONE_VFORK | CLONE_VM)в аккуратно написанном кодеsyscall.forkAndExecInChild. Всегда вызывайтеcmd.Wait()или используйте готовые методыcmd.Run()иcmd.Output(), чтобы исключить утечки дескрипторов и ресурсов.
Мы разобрали, как операционная система порождает независимые процессы с изолированной памятью. Но что, если нам нужно исполнять тысячи задач параллельно внутри одного и того же адресного пространства? В следующей статье мы перейдем к 7. Потоки выполнения. Thread vs Process и разберем анатомию системных потоков ОС и их сопоставление с горутинами Go.