Перейти к содержанию

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).

Связанные темы

Источники

  • man 2 fork — системный вызов fork
  • man 2 vfork — vfork и его ограничения
  • man 3 exec — семейство функций exec
  • man 2 execve — системный вызов execve
  • man 3 pthread_atfork — регистрация обработчиков fork для мьютексов
  • man 2 waitpid — ожидание завершения дочернего процесса
  • man 2 clone — низкоуровневый аналог fork с управлением разделением ресурсов