fork() и exec() — как запускается программа
В Unix-системах запуск новой программы устроен необычно. Не «создать процесс с такой-то программой» одним syscall, а два шага: fork() создаёт точную копию текущего процесса, потом exec() заменяет в этом процессе программу на новую. Это кажется странным, но даёт огромную гибкость, и на этом построено всё: shell, init, Docker, в конце концов любой subprocess.Popen() в Python.
В этом уроке разберём, что делают fork и exec, почему они разделены, как работает copy-on-write оптимизация, и почему этот странный Unix-овый дизайн оказался лучше альтернатив.
fork(): процесс делится надвое
Системный вызов fork() создаёт точную копию вызывающего процесса. Был один — стало два. Оба процесса продолжают выполнение с точки сразу после вызова fork(), но возвращаемое значение отличается:
- В родителе
fork()возвращает PID ребёнка. - В ребёнке
fork()возвращает 0.
Это позволяет одной и той же программе различить, в каком процессе она оказалась после fork.
Что копируется:
- Address space. Все страницы памяти (text, data, heap, stack). На самом деле через copy-on-write — см. ниже.
- fd table. Все открытые файлы и сокеты — доступны и ребёнку.
- Working directory, env, args, UID/GID — всё переносится.
- Process credentials (UID, GID, capabilities) — наследуются.
Что НЕ копируется:
- PID, PPID — ребёнок получает новый PID.
- CPU usage statistics — ребёнок начинает с нуля.
- Pending signals — очищаются.
- File locks — не наследуются (важная тонкость для БД).
- Memory locks (mlock) — сбрасываются.
Минимальный пример на C
Покажем, как fork выглядит в коде:
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
int main() {
printf("Before fork: PID=%d\n", getpid());
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
} else if (pid == 0) {
// ----- мы в ребёнке -----
printf("Child: PID=%d, parent=%d\n", getpid(), getppid());
} else {
// ----- мы в родителе -----
printf("Parent: PID=%d, child=%d\n", getpid(), pid);
// ждём, пока ребёнок завершится
int status;
wait(&status);
printf("Parent: child exited with status %d\n", status);
}
return 0;
}
Запуск:
Before fork: PID=12345
Parent: PID=12345, child=12346
Child: PID=12346, parent=12345
Parent: child exited with status 0
Обратите внимание: строка Before fork напечатана один раз — мы были одним процессом. Строки после fork — по одной от каждого процесса. Порядок не детерминирован: ребёнок и родитель работают параллельно, кто из них напечатает первым — зависит от планировщика.
Зачем именно так? Почему две syscalls?
В Windows запуск программы делается одним CreateProcess(). Зачем в Unix два шага — fork + exec?
Ответ: гибкость. Между fork и exec ребёнок — это копия родителя, и у него есть момент, чтобы изменить окружение перед запуском новой программы. Например:
Pipes: композиция команд и UNIX-философия-
Закрыть/перенаправить файлы.
ls > file.txt— shell делает fork, в ребёнке закрывает fd=1 (stdout) и открывает file.txt как fd=1, потом exec’аетls. Получаемls, у которого stdout = file.txt.lsничего об этом не знает — он пишет в fd=1, как обычно. -
Поменять рабочую директорию.
(cd /tmp && command)— fork, в ребёнке chdir, потом exec. -
Сбросить переменные окружения.
env -i bash— fork, в ребёнке очистить environ, потом exec bash. -
Понизить привилегии. Server’ы (nginx, sshd) запускаются от root, делают fork, в ребёнке setuid на менее привилегированного пользователя, потом exec обработчика. Это безопасное разделение.
-
Изоляция (контейнеры). Docker делает clone с CLONE_NEW* флагами (вариант fork), в ребёнке настраивает namespaces, потом exec вашего приложения.
В Windows для этого всего нужны были разные API: STARTUPINFO, lpEnvironment, dwCreationFlags, и т.д. Уровень контроля над запуском процесса в Unix через fork/exec получается лучше.
exec(): замена программы
После fork ребёнок имеет ту же программу, что и родитель — они близнецы. Чтобы ребёнок начал выполнять другую программу, он вызывает exec().
exec() — не один syscall, а семейство функций: execve(), execvp(), execle(), и так далее. Все они в итоге зовут execve() syscall.
Что делает execve:
- Освобождает текущий address space. Старые .text, .data, heap, stack — стираются.
- Загружает новый бинарь. Парсит ELF-файл, mmap-ит секции, разрешает динамические библиотеки.
- Создаёт новый стек с argv и envp.
- Передаёт управление в точку входа новой программы (обычно
_startв libc, который зовёт main).
После успешного execve процесс сохраняет PID, PPID, UID — те же. Но программа другая. После execve старый код не выполняется, потому что его уже нет в памяти.
PID не меняется! Это важное свойство. Когда shell делает ls, новый процесс не создаётся для ls — создаётся для копии shell, который потом сам становится ls.
Полный пример: fork + exec вместе
Вот как shell запускает программу. Псевдокод (упрощённо, как делает реальный bash):
pid_t pid = fork();
if (pid == 0) {
// ----- в ребёнке -----
// здесь можно поменять fd, ulimit, env...
char *args[] = {"ls", "-la", NULL};
execve("/bin/ls", args, environ);
// если execve успешно, эта строка НИКОГДА не выполняется
perror("execve failed");
_exit(127);
}
// ----- в родителе -----
int status;
waitpid(pid, &status, 0);
// ребёнок завершился, можно продолжать
И на Python — это упрощено через subprocess:
import subprocess
result = subprocess.run(["ls", "-la"], capture_output=True, text=True)
# subprocess делает под капотом: fork + exec + wait
print(result.stdout)
Внутри subprocess.run:
posix_spawn()илиfork()— создаёт процесс.- В ребёнке настраивает stdin/stdout/stderr через pipe (это даёт
capture_output=True). execve("/bin/ls", ["ls", "-la"], environ).- В родителе ждёт через
waitpid.
Можно увидеть это в strace:
strace -f -e trace=process python3 -c 'import subprocess; subprocess.run(["ls"])' 2>&1 | head -30
Покажет clone(...) (Linux-овский fork с тонкой настройкой), потом execve("/bin/ls", ...).
Copy-on-write: магия эффективности fork
Кажется, что fork должен быть очень дорогим: скопировать всё address space процесса (потенциально гигабайты) на каждый вызов. Если бы это было реально так, fork был бы непригоден.
Реально fork использует copy-on-write (COW) — ленивое копирование. Идея:
-
При fork ядро не копирует физическую память. Создаёт новый процесс с теми же page tables, ссылающимися на те же физические страницы. Все страницы помечаются read-only (даже writeable).
-
Пока оба процесса только читают — они делят одну физическую копию страницы. Это эффективно.
-
Когда кто-то пишет, происходит page fault (страница read-only). Ядро ловит fault, делает копию страницы, обновляет page table писавшего процесса. Теперь у него своя копия, у другого — старая.
Что это даёт:
- fork() очень быстрый. Не нужно копировать гигабайты — только page tables (KB).
- Память тратится только на изменённые страницы. Если ребёнок сразу exec-ается, ни одна страница не копируется — ребёнок просто заменяет всё address space.
- Программы, которые fork без exec (web server pre-forking, fork-bombs) — могут жить эффективно, пока они не пишут в общие страницы.
COW — одна из главных оптимизаций Unix. Без неё fork был бы непрактичен.
clone(): расширенный fork в Linux
В Linux исторически было два syscall: fork и vfork. Сейчас они оба реализованы через универсальный clone() с разными флагами. clone() — расширенная версия fork, которая позволяет тонко настроить, что делится между родителем и ребёнком.
Флаги (некоторые):
CLONE_VM— делить виртуальную память. Это потоки!CLONE_FS— делить fs info (cwd, umask).CLONE_FILES— делить fd-таблицу.CLONE_SIGHAND— делить обработчики сигналов.CLONE_THREAD— быть в одной thread group.CLONE_NEWPID— новый PID namespace (для контейнеров).CLONE_NEWNS— новый mount namespace.CLONE_NEWNET— новый network namespace.
fork() = clone(SIGCHLD), без флагов — то есть всё копируется (COW). pthread_create() = clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD) — всё shared. Контейнер = clone с CLONE_NEW* флагами — получает свой namespace для PID, mount, network.
Это даёт Linux огромную гибкость. Один syscall покрывает: fork, threads, и контейнеры.
# strace показывает clone с флагами:
strace -e clone bash -c 'sleep 1 &; wait'
# clone(child_stack=..., flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, ...)
fork-bomb — что это и почему опасно
Классический пример «зачем нужны ulimits»:
:(){ :|:& };:
Это bash-функция :, которая рекурсивно вызывает себя дважды и сразу запускает в фон через | (pipe) и &. Каждый процесс рождает двух новых, те — по двух, и так бесконечно.
За секунду — тысячи процессов. Через несколько секунд таблица процессов полна (pid_max достигнут), память кончается, система зависает.
НЕ ПЫТАЙТЕСЬ это на рабочей машине! На VM или контейнере — можно ради эксперимента. Закрыть VM, перезагрузить.
Защита — ulimit -u:
# Посмотреть лимит процессов на пользователя:
ulimit -u
# Часто 4096 или 16384
# Установить более низкий лимит для shell:
ulimit -u 100
# Теперь fork-bomb упрётся в 100 и упадёт. Полезно для тестов на VM
В production это настраивается через /etc/security/limits.conf. Каждый пользователь имеет свой лимит. Это нужно, чтобы один бажный сервис не положил систему.
posix_spawn(): альтернатива fork+exec
В очень-очень больших процессах fork (даже с COW) может быть медленным — из-за копирования page tables. Если у вас процесс на 100 GB RAM, у него миллионы PTE (page table entries), и копирование таблицы занимает миллисекунды.
Современная альтернатива — posix_spawn(). Это «atomic» функция: «создай процесс с такой программой» без промежуточной копии. На Linux она реализована либо через vfork (старый syscall, ребёнок блокирует родителя до exec), либо через clone(CLONE_VM) — который делит память.
Python 3.8+ использует posix_spawn для subprocess, когда возможно. Это даёт значительное ускорение для запуска подпроцессов из больших Python-приложений.
# В Python:
import subprocess, os
os.posix_spawn # доступная функция
subprocess.run # под капотом использует posix_spawn где можно
Концептуально fork+exec остаётся «правильным» Unix-подходом. posix_spawn — оптимизация.
Демонстрация: трассируем shell
Посмотрим, что делает shell при простой команде:
strace -f -e trace=process bash -c 'ls /tmp' 2>&1
Сокращённый вывод:
execve("/bin/bash", ["bash", "-c", "ls /tmp"], 0x7ffd...) = 0
...
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
child_tidptr=0x7f...) = 23456
strace: Process 23456 attached
[pid 23456] execve("/bin/ls", ["ls", "/tmp"], 0x55c1...) = 0
[pid 23456] ...
[pid 23456] exit_group(0) = ?
[pid 23456] +++ exited with 0 +++
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, ...} ---
wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 23456
Что произошло:
- bash запустился через execve (от родителя bash).
- bash сделал clone() (это fork) — родил ребёнка PID 23456.
- В ребёнке — execve(“/bin/ls”, …) — замена bash на ls.
- ls работал и завершился через exit_group(0).
- bash получил SIGCHLD и сделал wait4() — забрал exit code ребёнка.
Это полный цикл запуска программы в Unix.
Попробуй сам
# 1. Простой эксперимент с fork в bash:
echo "before fork, PID=$$"
( echo "inside subshell, PID=$$"; sleep 1 )
# subshell -- это fork без exec. PID родителя сохраняется, потому что bash сам печатает $$, но subshell имеет свой PID
# 2. Через python:
python3 << 'EOF'
import os, time
print(f"Before fork: PID={os.getpid()}")
pid = os.fork()
if pid == 0:
print(f"Child: PID={os.getpid()}, parent={os.getppid()}")
time.sleep(0.1)
else:
print(f"Parent: PID={os.getpid()}, child={pid}")
os.wait()
EOF
# 3. Трассировка shell при запуске ls:
strace -f -e trace=process bash -c 'ls > /dev/null' 2>&1 | head -20
# 4. Сравнение времени fork+exec и просто exec:
time bash -c 'for i in {1..100}; do true; done'
# true -- это shell builtin, без fork. Очень быстро
time bash -c 'for i in {1..100}; do /bin/true; done'
# /bin/true -- внешняя программа, требует fork+exec. Медленнее в 100+ раз
# 5. Посмотрите на rclone в действии:
strace -e clone,execve,exit_group bash -c 'echo hello | tr a-z A-Z' 2>&1 | head -30
# Pipeline: bash делает несколько clone, exec для echo и tr, всё параллельно