Learning Platform
Глоссарий Troubleshooting
Урок 04.02 · 22 мин
Начальный
forkexecsubprocessUnixCOW

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.

fork(): один процесс становится двумя
Процесс PID 100Один процесс до fork. Имеет PID 100, своё address space, fd table, etc
fork()syscall fork (или clone в Linux). Ядро копирует структуру процесса: новый PID, копия address space (через copy-on-write), копия fd table
Родитель PID 100После fork: продолжает с PID 100. fork() возвращает PID ребёнка (например, 101)
Ребёнок PID 101После fork: новый процесс с PID 101. PPID=100. Идентичное состояние родителя в момент fork. fork() возвращает 0

Что копируется:

  • 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-философия
  1. Закрыть/перенаправить файлы. ls > file.txt — shell делает fork, в ребёнке закрывает fd=1 (stdout) и открывает file.txt как fd=1, потом exec’ает ls. Получаем ls, у которого stdout = file.txt. ls ничего об этом не знает — он пишет в fd=1, как обычно.

  2. Поменять рабочую директорию. (cd /tmp && command) — fork, в ребёнке chdir, потом exec.

  3. Сбросить переменные окружения. env -i bash — fork, в ребёнке очистить environ, потом exec bash.

  4. Понизить привилегии. Server’ы (nginx, sshd) запускаются от root, делают fork, в ребёнке setuid на менее привилегированного пользователя, потом exec обработчика. Это безопасное разделение.

  5. Изоляция (контейнеры). Docker делает clone с CLONE_NEW* флагами (вариант fork), в ребёнке настраивает namespaces, потом exec вашего приложения.

docker run: базовая команда

В Windows для этого всего нужны были разные API: STARTUPINFO, lpEnvironment, dwCreationFlags, и т.д. Уровень контроля над запуском процесса в Unix через fork/exec получается лучше.


exec(): замена программы

После fork ребёнок имеет ту же программу, что и родитель — они близнецы. Чтобы ребёнок начал выполнять другую программу, он вызывает exec().

exec() — не один syscall, а семейство функций: execve(), execvp(), execle(), и так далее. Все они в итоге зовут execve() syscall.

Что делает execve:

  1. Освобождает текущий address space. Старые .text, .data, heap, stack — стираются.
  2. Загружает новый бинарь. Парсит ELF-файл, mmap-ит секции, разрешает динамические библиотеки.
  3. Создаёт новый стек с argv и envp.
  4. Передаёт управление в точку входа новой программы (обычно _start в libc, который зовёт main).

После успешного execve процесс сохраняет PID, PPID, UID — те же. Но программа другая. После execve старый код не выполняется, потому что его уже нет в памяти.

exec(): замена программы внутри процесса
Процесс PID 101После fork: процесс с PID 101, содержит копию родительского bash
execve('/bin/ls')Вызов execve. Ядро освобождает старое адресное пространство, загружает /bin/ls
Процесс PID 101 (ls)Тот же PID, та же fd-таблица, тот же CWD. Но программа теперь -- ls. Память bash удалена, теперь ls .text, .data, новый stack

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:

  1. posix_spawn() или fork() — создаёт процесс.
  2. В ребёнке настраивает stdin/stdout/stderr через pipe (это даёт capture_output=True).
  3. execve("/bin/ls", ["ls", "-la"], environ).
  4. В родителе ждёт через 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) — ленивое копирование. Идея:

  1. При fork ядро не копирует физическую память. Создаёт новый процесс с теми же page tables, ссылающимися на те же физические страницы. Все страницы помечаются read-only (даже writeable).

  2. Пока оба процесса только читают — они делят одну физическую копию страницы. Это эффективно.

  3. Когда кто-то пишет, происходит page fault (страница read-only). Ядро ловит fault, делает копию страницы, обновляет page table писавшего процесса. Теперь у него своя копия, у другого — старая.

Copy-on-write при fork
До forkОдин процесс, его страницы памяти в физическом RAM. Каждая страница writable, mapped к virtual addresses процесса
fork
После forkДва процесса, обе page tables ссылаются на ТЕ ЖЕ физические страницы. Все страницы помечены read-only в обоих
Чтение OKЛюбой процесс читает -- работает напрямую, без копирования. Эффективно, как будто никакого fork не было
запись
Page faultКто-то пишет в страницу. CPU видит RO + W попытку -> #PF. Ядро ловит, выделяет новую страницу, копирует туда содержимое, обновляет page table
Дальше -- свои копииПосле COW, эта страница у каждого процесса своя. Дальнейшие записи нормальные. Остальные страницы всё ещё shared

Что это даёт:

  • 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

Что произошло:

  1. bash запустился через execve (от родителя bash).
  2. bash сделал clone() (это fork) — родил ребёнка PID 23456.
  3. В ребёнке — execve(“/bin/ls”, …) — замена bash на ls.
  4. ls работал и завершился через exit_group(0).
  5. 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, всё параллельно

Проверка знанийKnowledge check
Junior спрашивает: 'Если fork делает копию всего процесса, это же безумно дорого для большой программы. Postgres на 4GB памяти будет копировать 4GB? Как тогда они работают, у Postgres же есть много worker процессов через fork?'
ОтветAnswer
Отличный вопрос про самую важную оптимизацию fork: copy-on-write. Когда вы делаете fork() на Postgres процессе с 4GB памяти -- НЕ копируется 4GB. Копируются только page tables -- структуры, которые описывают virtual->physical mapping. Это килобайты, не гигабайты. Все физические страницы остаются shared между родителем и ребёнком, помеченные read-only. Дальше -- лениво: - Если ребёнок только читает память -- никакого копирования не происходит. Они продолжают разделять физическую RAM. - Если ребёнок пишет в страницу -- CPU генерирует page fault (RO violation), ядро ловит, выделяет новую физическую страницу, копирует туда содержимое, обновляет page table ребёнка. Только эта одна страница (4 KB) скопирована, остальные всё ещё shared. Для Postgres это идеально: shared catalogs, статические данные, .text сегмент -- остаются shared. Каждый worker создаёт свои страницы только для рабочего набора (рабочие буферы, локальные переменные). Реальное потребление памяти на worker -- сотни мегабайт, не 4 GB. Есть тонкий момент: Linux умеет 'overcommit' -- разрешает виртуально выделить больше памяти, чем физической. fork() от 100 GB процесса 'выделит' 100 GB виртуальной памяти ребёнку, но физически 100 GB не нужны. Это работает, пока процессы не пишут активно во все страницы. Проблемы возникают если: (1) Программа агрессивно пишет в страницы после fork -- COW часто срабатывает, копирование происходит. Тогда форк действительно дорогой. (2) Включён vm.overcommit_memory=2 (strict overcommit) -- система откажется делать fork, если 'физически не хватит' (даже если COW бы спасло). Многие БД настраивают это специально. (3) Очень крупные процессы с миллионами страниц -- даже копирование page tables занимает миллисекунды. Для этих случаев есть posix_spawn() и vfork(). В итоге: fork эффективен для 'большой родитель + быстро exec'ящие или малопишущие дети'. Это типичный паттерн Unix. Когда не работает -- появляются альтернативы (posix_spawn).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что делает syscall fork()?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4