fork() — это системный вызов Unix и Linux, который создает новый процесс, делая почти точную копию вызвавшего процесса. Исходный процесс называют родителем, новый — потомком. После вызова оба процесса продолжают выполнять один и тот же код с той строки, что идет сразу за fork().
Содержание
Речь именно про процессы операционной системы, а не про «форк репозитория» на GitHub — это разные вещи с одинаковым словом. Ниже разберем, что fork() возвращает, как копируется память по copy-on-write, зачем нужна связка fork и exec и как убирать зомби-процессы.
Что возвращает fork() и как это читать
fork() вызывается один раз, а возвращает управление дважды: в родителе и в потомке. Различить их можно по возвращаемому значению. Тип результата — pid_t (целочисленный идентификатор процесса).
| Где выполняется | Возврат fork() | Смысл |
|---|---|---|
| В родителе (успех) | PID потомка (число > 0) | идентификатор созданного процесса |
| В потомке (успех) | 0 | признак «я потомок» |
| При ошибке | -1 | процесс не создан, выставлен errno |
Минимальный полный пример — файл fork_demo.c:
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork"); // не удалось создать процесс
return 1;
}
if (pid == 0) {
printf("Потомок: мой PID=%d, PID родителя=%d\n", getpid(), getppid());
} else {
printf("Родитель: мой PID=%d, PID потомка=%d\n", getpid(), pid);
}
return 0;
}
Собираем и запускаем:
cc fork_demo.c -o fork_demo
./fork_demo
Вывод (конкретные номера у вас будут другими):
Родитель: мой PID=4120, PID потомка=4121
Потомок: мой PID=4121, PID родителя=4120
Разбор построчно. fork() создает потомка, поэтому ветка pid == 0 выполняется в потомке, а ветка else — в родителе. PID потомка, который родитель увидел в pid, совпал с тем, что потомок получил из getpid() — это один и тот же идентификатор.
Порядок двух строк в выводе не гарантирован: после fork() планировщик ядра сам решает, кому дать процессор первым. На одном запуске первой напечатает родитель, на другом — потомок. Полагаться на порядок нельзя.
PID и PPID: кто есть кто
Стоит развести два близких понятия. PID (process id) — идентификатор самого процесса, его возвращает getpid(). PPID (parent process id) — идентификатор родителя, его возвращает getppid(). У потомка PPID равен PID того, кто его породил.
Идентификаторы уникальны на момент жизни процесса, но переиспользуются: после завершения процесса его номер со временем может достаться новому. Поэтому PID — надежный ключ только пока процесс жив.
Копирование адресного пространства: copy-on-write
Логически потомок получает собственную независимую копию всей памяти родителя: те же переменные с теми же значениями, но дальше они меняются раздельно. Изменение переменной в потомке не видно родителю, и наоборот.
Физически ядро не копирует всю память сразу — это было бы дорого. Применяется механизм copy-on-write (копирование при записи): страницы памяти помечаются только для чтения и разделяются между процессами. Как только один из них пишет в страницу, срабатывает отказ страницы (page fault), и ядро делает отдельную копию именно этой страницы. Пока идет только чтение, страница остается общей.
Отсюда практический вывод: fork() дешев, а реальные затраты памяти растут по мере того, как процессы расходятся в данных. Это упрощенная модель; точное поведение зависит от размера страниц и настроек ядра.
Важная граница: копируется адресное пространство, но открытые файловые дескрипторы после fork() разделяются — родитель и потомок ссылаются на одно открытое описание файла с общим смещением. Помнить об этом нужно, чтобы не удивляться перемешанному выводу в файл.
Связка fork + exec: запуск другой программы
Сам по себе fork() лишь дублирует текущую программу. Чтобы потомок выполнил другую программу, после fork() в нем вызывают один из семейства exec (execlp, execvp, execve и другие). exec не создает новый процесс — он замещает образ текущего процесса новой программой: код, данные и стек заменяются, а PID сохраняется.
Различайте два шага: fork() создает процесс-копию, exec заменяет содержимое процесса другой программой. Именно так работает оболочка (shell), когда вы запускаете команду: она делает fork(), затем в потомке — exec нужной программы.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
execlp("date", "date", "+%Y", (char *)NULL);
perror("execlp"); // сюда попадем ТОЛЬКО если exec не сработал
_exit(127);
}
waitpid(pid, NULL, 0); // родитель ждет завершения потомка
printf("Родитель: команда завершилась\n");
return 0;
}
Вывод:
2026
Родитель: команда завершилась
Ключевой момент: строка perror("execlp") выполнится только при ошибке запуска. При успехе execlp образ потомка уже заменен программой date, и возврата в старый код нет — следующие строки недостижимы. После неудачного exec завершаемся через _exit(), а не exit(), чтобы не сбросить повторно буферы, унаследованные от родителя.
Зомби и wait(): уборка за потомками
Когда потомок завершается, ядро не удаляет его запись сразу. Оно хранит минимальную запись с кодом возврата, пока родитель не заберет этот код вызовом wait() или waitpid(). Завершившийся, но еще не «собранный» процесс — это зомби (в выводе ps помечается как Z или defunct). Зомби не занимает память и процессор, но держит запись в таблице процессов.
Разведем два похожих состояния:
| Состояние | Что случилось | Кто убирает |
|---|---|---|
| Зомби | потомок завершился, родитель еще не вызвал wait() | родитель через wait()/waitpid() |
| Сирота (orphan) | родитель завершился раньше живого потомка | процесс init (PID 1), он же и соберет потомка |
Пример корректной уборки с чтением кода возврата:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
printf("Потомок %d работает\n", getpid());
return 42; // код возврата потомка
}
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("Потомок вышел с кодом %d\n", WEXITSTATUS(status));
}
return 0;
}
Вывод:
Потомок 5210 работает
Потомок вышел с кодом 42
Здесь waitpid() блокирует родителя, пока потомок не завершится, а затем возвращает его статус. Макрос WIFEXITED(status) проверяет нормальное завершение, а WEXITSTATUS(status) достает код возврата (42). Правило простое: кто породил процесс, тот и обязан его собрать. Иначе зомби накапливаются и упираются в лимит процессов.
Типовой симптом «в системе много процессов <defunct>» — родитель не собирает потомков. Что делать: вызывать waitpid() по сигналу SIGCHLD, который ядро шлет родителю при завершении потомка, либо периодически вызывать waitpid(-1, &status, WNOHANG) без блокировки.
Осторожно: неограниченное порождение (fork-бомба)
Если процесс в бесконечном цикле вызывает fork() без ограничения, число процессов растет лавинообразно и исчерпывает таблицу процессов — система перестает отвечать. Такой антипример называют fork-бомбой, запускать его нельзя.
Защита выстраивается на уровне ограничений, а не кода: лимит процессов на пользователя через ulimit -u <N> (ограничение RLIMIT_NPROC) в сессии, а на уровне системы — pids.max в cgroups. Практическое правило: цикл с fork() всегда пишут с явным условием остановки и уборкой потомков.
Выводы
fork()создает процесс-потомок как копию родителя; вызывается один раз, а возвращает управление дважды — в обоих процессах.- Возврат читается так: 0 — в потомке, PID потомка (> 0) — в родителе, -1 — ошибка (создания процесса не произошло).
- Память копируется по механизму copy-on-write: страницы разделяются, пока их только читают, и копируются при первой записи; открытые файловые дескрипторы при этом разделяются.
- Связка fork + exec — основа запуска программ:
fork()создает процесс,execзамещает его образ другой программой без смены PID. - Каждого потомка обязан собрать родитель через
wait()/waitpid(), иначе остаются зомби; сирот при завершении родителя подбирает процесс init (PID 1).
Где применяется / связь с практикой
Модель fork + exec + wait лежит в основе всего, что запускает программы: командных оболочек, менеджеров процессов, серверов, которые обрабатывают соединения отдельными процессами. Понимание возвращаемого значения, copy-on-write и уборки зомби — базовый навык системного программирования под Unix и Linux, без которого не написать надежный демон или свою мини-оболочку.
Освойте тему на практике
Разобрать процессы, сигналы, файловые дескрипторы и системные вызовы на практике помогает курс Программист C/C++ и системное программирование. Посмотреть формат и уровень занятий можно на открытых уроках Otus — там разбирают темы вживую и отвечают на вопросы.
FAQ
Чем fork() отличается от потоков (threads)?
fork() создает отдельный процесс с собственным адресным пространством (после copy-on-write память расходится), а потоки живут внутри одного процесса и делят общую память. Процессы изолированнее и надежнее, потоки легче и быстрее обмениваются данными.
Что такое vfork() и зачем он был нужен?
vfork() — исторический вариант, который создавал потомка без копирования адресного пространства в расчете на немедленный exec. С распространением copy-on-write в fork() он потерял смысл и считается устаревшим; в новом коде используют обычный fork().
Наследует ли потомок открытые файлы и переменные окружения?
Да. Потомок получает копии переменных окружения и разделяет открытые файловые дескрипторы родителя (общее открытое описание файла и смещение). После exec окружение можно передать заново, а дескрипторы с флагом close-on-exec закрываются автоматически.



