fork и exec: создание и замена процессов¶
Системный вызов fork¶
fork() создаёт новый процесс как почти полную копию текущего. Новый процесс называется дочерним (child), а
вызвавший — родительским (parent).
После fork() оба процесса продолжают выполнять один и тот же код, но являются независимыми. Их можно различить по
возвращаемому значению:
- в родительском процессе
fork()возвращает PID дочернего процесса; - в дочернем процессе
fork()возвращает 0; - при ошибке возвращает −1 и устанавливает
errno.
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// Дочерний процесс
printf("Child: мой PID = %d\n", getpid());
} else if (pid > 0) {
// Родительский процесс
printf("Parent: создан дочерний процесс с PID = %d\n", pid);
} else {
perror("fork failed");
return 1;
}
return 0;
}
Что именно копируется при fork¶
Дочерний процесс получает копию:
- адресного пространства (сегменты кода, данных, стека, кучи);
- таблицы файловых дескрипторов (те же открытые файлы, те же позиции чтения);
- обработчиков сигналов и маски сигналов;
- значений UID, GID, EUID, EGID;
- текущей директории и umask.
Copy-on-Write (CoW)¶
Физически копирование памяти при fork() не происходит немедленно. Вместо этого ядро применяет механизм Copy-on-Write
: страницы памяти помечаются как доступные только для чтения и разделяются между родителем и дочерним процессом.
Физическая копия страницы создаётся лишь в тот момент, когда один из процессов попытается её изменить. Это делает
fork() очень быстрым даже для больших процессов.
fork(): parent/child с Copy-on-Write
До fork(): После fork():
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ parent │ │ parent │ │ child │
│ PID=100 │ │ PID=100 │ │ PID=101, PPID=100│
│ │ │ │ │ │
│ page table │ │ page table │ │ page table │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ code ──────┼─┼──▶ [phys] │ │ code ──────┼─┼──▶│ │ code ──────┼─┼──▶ [phys] (shared, RO без CoW)
│ │ data ──────┼─┼──▶ [phys] │ │ data ──────┼─┼──▶│ │ data ──────┼─┼──▶ [phys] (общий, RO)
│ │ heap ──────┼─┼──▶ [phys] │ │ heap ──────┼─┼──▶│ │ heap ──────┼─┼──▶ [phys] (общий, RO)
│ │ stack ──────┼─┼──▶ [phys] │ │ stack ──────┼─┼──▶│ │ stack ──────┼─┼──▶ [phys] (общий, RO)
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
Запись в страницу (CoW-fault): child пишет в heap ──▶ ядро копирует страницу:
┌──────────┐ ┌──────────┐
│ parent │──▶ [phys A] (оригинал)
└──────────┘
┌──────────┐
│ child │──▶ [phys B] (копия, RW)
└──────────┘
Что происходит с сигналами после fork¶
После fork() дочерний процесс наследует:
- обработчики сигналов (указатели на функции);
- маску заблокированных сигналов (
sigprocmask); - набор ожидающих сигналов сбрасывается (pending signals в дочернем — пустые).
Если родительский процесс установил обработчик SIGCHLD, он унаследуется и дочерним. Подробнее — Сигналы.
Особенности fork с мьютексами¶
Если в момент вызова fork() один из потоков держит мьютекс, дочерний процесс унаследует мьютекс в заблокированном
состоянии, но без потока, который его разблокирует. Это может привести к deadlock. Для решения этой проблемы
существует pthread_atfork(), позволяющий зарегистрировать обработчики для корректного освобождения ресурсов перед и
после fork().
pthread_atfork(
prepare, // вызывается перед fork() в родителе — захватить все мьютексы
parent, // вызывается после fork() в родителе — отпустить мьютексы
child // вызывается после fork() в дочернем — отпустить мьютексы
);
Системный вызов exec¶
Семейство функций exec заменяет образ текущего процесса новой программой. Новая программа загружается поверх
старой: стек, куча и сегменты данных перезаписываются. При этом PID процесса не меняется.
Если вызов exec успешен, он никогда не возвращает управление — выполнение продолжается с точки входа новой
программы. Возврат из exec означает ошибку.
Варианты функций exec¶
В стандартной библиотеке существует несколько вариантов, отличающихся способом передачи аргументов и переменных окружения:
| Функция | Аргументы | Окружение | Поиск в PATH |
|---|---|---|---|
execl(path, arg0, ..., NULL) |
Список | Наследуется | Нет |
execv(path, argv[]) |
Массив | Наследуется | Нет |
execle(path, arg0, ..., NULL, envp[]) |
Список | Явное | Нет |
execve(path, argv[], envp[]) |
Массив | Явное | Нет |
execlp(file, arg0, ..., NULL) |
Список | Наследуется | Да |
execvp(file, argv[]) |
Массив | Наследуется | Да |
Единственным настоящим системным вызовом является execve(). Остальные функции — это библиотечные обёртки.
Что сохраняется после exec¶
- PID и PPID;
- открытые файловые дескрипторы (если у них не установлен флаг
FD_CLOEXEC); - credentials (UID, GID), если не используется SUID/SGID;
- текущая директория и umask;
- сигнальная маска.
Что не сохраняется: стек, куча, обработчики сигналов (сбрасываются в SIG_DFL), memory mappings.
exec: замена адресного пространства¶
execve(): адресное пространство до и после
До execve(): После execve():
┌───────────────────────────┐ ┌───────────────────────────┐
│ процесс PID=101 │ │ процесс PID=101 (тот же) │
├───────────────────────────┤ ├───────────────────────────┤
│ stack (старый) │ ──▶ │ stack (новый) │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ heap (старый) │ ──▶ │ heap (новый, пустой) │
├───────────────────────────┤ ├───────────────────────────┤
│ .data / .bss (старые) │ ──▶ │ .data / .bss (новые) │
├───────────────────────────┤ ├───────────────────────────┤
│ .text (старая программа) │ ──▶ │ .text (новая программа) │
└───────────────────────────┘ └───────────────────────────┘
Что сохраняется: PID, PPID, открытые FD (без FD_CLOEXEC), UID/GID, CWD
Что уничтожается: стек, куча, .data/.bss/.text, обработчики сигналов → SIG_DFL
Идиома fork + exec¶
Стандартный способ запуска новой программы — комбинация fork() и exec():
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
int main() {
pid_t child = fork();
if (child == 0) {
// Дочерний процесс: заменить себя новой программой
execl("/bin/ls", "ls", "-la", "/tmp", NULL);
// Эта строка выполнится только при ошибке execl
perror("execl failed");
return 1;
} else if (child > 0) {
// Родительский процесс: ждать завершения дочернего
int status;
waitpid(child, &status, 0);
if (WIFEXITED(status)) {
printf("Дочерний процесс завершился с кодом %d\n", WEXITSTATUS(status));
}
} else {
perror("fork failed");
return 1;
}
return 0;
}
Дочерний процесс создаётся через fork(), а затем немедленно заменяет своё содержимое новой программой через execl().
Родительский процесс ждёт завершения дочернего через waitpid().
Fork-бомба¶
Fork-бомба — программа, которая непрерывно создаёт новые процессы, пока не исчерпает системный лимит на их количество.
#include <unistd.h>
int main() {
while (1) {
fork(); // каждый новый процесс тоже выполняет этот код
}
return 0;
}
Количество процессов растёт экспоненциально. Система становится неотзывчивой из-за истощения таблицы процессов и ресурсов планировщика.
Методы защиты:
- ограничение числа процессов через
ulimit -uилиsetrlimit(RLIMIT_NPROC, ...); - контрольные группы (cgroups) с лимитом
pids.max; - изоляция в контейнерах.
vfork¶
vfork() — устаревшая оптимизация fork(), при которой дочерний процесс разделяет адресное пространство родителя (
без CoW) и родитель блокируется до вызова дочерним exec() или _exit(). Использование vfork() крайне опасно: запись
в переменные стека в дочернем процессе портит данные родителя. На практике следует использовать fork() — современный
CoW делает его достаточно быстрым. Флаг CLONE_VFORK реализует аналогичную семантику через clone(2).
Связанные темы¶
- Состояния процессов, wait, sleep — что происходит с процессом после fork: состояния R/S/Z, зомби
- Сигналы — наследование обработчиков и маски сигналов дочерним процессом
- Реализация потоков (clone) —
clone(2)как обобщение fork
Источники¶
man 2 fork— системный вызов forkman 2 vfork— vfork и его ограниченияman 3 exec— семейство функций execman 2 execve— системный вызов execveman 3 pthread_atfork— регистрация обработчиков fork для мьютексовman 2 waitpid— ожидание завершения дочернего процессаman 2 clone— низкоуровневый аналог fork с управлением разделением ресурсов