Системный вызов fork(): создание процессов в Linux

Системный вызов fork(): создание процессов в Linux Полезное

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 закрываются автоматически.

OTUS Журнал