Когда разработчики говорят о создании процессов в Linux, первое слово, которое приходит на ум, это fork(). Он знаком, понятен и присутствует в каждом учебнике по системному программированию. Но fork() это не самостоятельный примитив ядра. Это тонкая обёртка над clone(), которая вызывает его с заранее зафиксированным набором флагов. Сам fork() никогда не принимал решений о том, что именно копировать, а что оставлять общим. За него это решил кто-то другой, давно и один раз. clone() передаёт это решение в руки программиста, и именно здесь начинается настоящий контроль над созданием процессов.
Понять разницу между ними можно через один простой факт: и pthread_create(), и fork() в glibc на архитектуре x86-64 транслируются в один и тот же системный вызов clone(). Разница между потоком и процессом в Linux определяется не разными системными вызовами, а разными флагами, переданными в clone(). Это не деталь реализации, это архитектурное решение ядра: поток и процесс суть один и тот же объект, task_struct, с разной степенью разделения контекста.
Как fork() выглядит изнутри и почему его возможности ограничены
Вызов fork() при компиляции с glibc разворачивается в clone() с фиксированным набором флагов: CLONE_CHILD_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD. Дочерний процесс получает полную копию виртуального адресного пространства родителя, собственную таблицу файловых дескрипторов, собственные обработчики сигналов и собственное пространство имён. Никакого выбора нет.
Внутри ядра оба вызова ведут к функции copy_process(), которая последовательно вызывает copy_mm(), copy_files(), copy_fs(), copy_sighand(), copy_namespaces() и ряд других. Каждая из этих функций проверяет соответствующий флаг из clone_flags и принимает решение: копировать структуру или оставить на неё ссылку. При fork() флаги говорят "копировать всё". При clone() с нужными флагами говорят "вот это разделяем, вот это изолируем, а вот это создаём заново".
Копирование виртуального адресного пространства не является буквальным: ядро использует CoW-страницы. При fork() дочерний процесс изначально разделяет физические страницы с родителем, и только при записи происходит реальное копирование. Но даже с CoW-оптимизацией таблицы страниц всё равно дублируются, и для процессов с большим адресным пространством это измеримые накладные расходы при каждом вызове.
Флаги clone() и точечный контроль над разделением контекста
Сила clone() сосредоточена в битовой маске флагов. Каждый флаг определяет судьбу одной конкретной части контекста процесса. Флаги комбинируются произвольно, и именно эта комбинируемость даёт гибкость, недостижимую через fork().
CLONE_VM указывает ядру не копировать таблицы страниц, а разделить виртуальное адресное пространство между родителем и потомком. Оба процесса видят одну и ту же память, и запись в неё одним немедленно видна другому. Именно этот флаг устанавливает pthread_create(), превращая потомка в поток.
CLONE_FILES разделяет таблицу файловых дескрипторов. Без него потомок получает копию таблицы: открытые дескрипторы те же, но close() в одном процессе не влияет на другой. С флагом закрытие файла в одном потоке закрывает его для всех.
CLONE_FS разделяет контекст файловой системы: текущий рабочий каталог, корневой каталог и маску umask. Если потомок вызовет chdir() при установленном CLONE_FS, родительский процесс тоже окажется в новом каталоге.
CLONE_SIGHAND разделяет таблицу обработчиков сигналов. Изменение обработчика через sigaction() в одном потоке немедленно отражается на всех, кто разделяет эту таблицу. Этот флаг требует одновременного присутствия CLONE_VM, иначе ядро вернёт EINVAL.
CLONE_THREAD делает потомка членом той же группы потоков, что и родитель, обеспечивая соответствие требованиям POSIX: все потоки одного процесса должны иметь одинаковый PID. Вместе три флага CLONE_VM | CLONE_SIGHAND | CLONE_THREAD образуют ядро того, что glibc использует при вызове pthread_create().
Ниже показано, как fork() и pthread_create() выглядят с точки зрения реальных флагов clone(), и как можно самостоятельно вызвать clone() для точечного управления контекстом:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#define STACK_SIZE (1024 * 1024)
static int child_fn(void *arg) {
printf("Child PID: %d, PPID: %d\n", getpid(), getppid());
return 0;
}
int main(void) {
char *stack = malloc(STACK_SIZE);
if (!stack) { perror("malloc"); return 1; }
/* Эквивалент fork(): изолированное адресное пространство */
pid_t pid = clone(child_fn,
stack + STACK_SIZE,
SIGCHLD,
NULL);
if (pid < 0) { perror("clone"); return 1; }
/* Эквивалент pthread_create(): разделённое адресное пространство */
pid_t tid = clone(child_fn,
stack + STACK_SIZE,
CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD | SIGCHLD,
NULL);
waitpid(pid, NULL, 0);
free(stack);
return 0;
}
Пространства имён как главное применение clone()
Наиболее мощная возможность clone(), недоступная через fork() вообще, это создание новых пространств имён. Именно на этом механизме построены все контейнерные среды: Docker, Podman, systemd-nspawn. Пространство имён изолирует определённый ресурс ядра так, что процессы внутри него видят свою собственную версию этого ресурса, а не глобальную.
Linux поддерживает восемь типов пространств имён. Флаг CLONE_NEWPID создаёт новое пространство PID: первый процесс внутри него получает PID 1 и становится init для своего окружения, хотя снаружи у него совершенно другой PID. Флаг CLONE_NEWNET создаёт изолированный сетевой стек со своими интерфейсами, таблицами маршрутизации и правилами iptables. Флаг CLONE_NEWNS изолирует дерево монтирования: точки монтирования внутри пространства не видны снаружи и наоборот. Флаг CLONE_NEWUSER создаёт изолированное пространство пользователей, позволяя непривилегированному процессу иметь UID 0 внутри пространства, оставаясь непривилегированным снаружи.
Комбинируя эти флаги, можно вручную создать изолированное окружение, не прибегая к Docker:
/* Создать процесс с изолированными PID, сетью и деревом монтирования */
pid_t pid = clone(child_fn,
stack + STACK_SIZE,
CLONE_NEWPID | /* новое пространство PID */
CLONE_NEWNET | /* изолированный сетевой стек */
CLONE_NEWNS | /* изолированное дерево mount */
CLONE_NEWUSER | /* изолированные UID/GID */
SIGCHLD,
NULL);
Посмотреть, в каких пространствах имён живёт конкретный процесс, позволяет прямое чтение символических ссылок в /proc:
# Пространства имён процесса по его PID
ls -la /proc/<PID>/ns/
# Пример вывода:
# lrwxrwxrwx cgroup -> cgroup:[4026531835]
# lrwxrwxrwx ipc -> ipc:[4026531839]
# lrwxrwxrwx mnt -> mnt:[4026531840]
# lrwxrwxrwx net -> net:[4026531992]
# lrwxrwxrwx pid -> pid:[4026531836]
# lrwxrwxrwx user -> user:[4026531837]
# lrwxrwxrwx uts -> uts:[4026531838]
# Войти в пространство имён существующего процесса
nsenter --target <PID> --net --pid --mount /bin/bash
Число в скобках является идентификатором конкретного экземпляра пространства имён. Если два процесса показывают одинаковое число для net, они разделяют один сетевой стек. Именно так nsenter определяет, куда войти.
Производительность и накладные расходы при разных стратегиях clone()
Разница в производительности между fork() и минимальным clone() проявляется в системах, создающих тысячи процессов в секунду. Веб-серверы на модели prefork, системы пакетной обработки, фреймворки для изолированного выполнения кода, везде, где процессы короткоживущие и многочисленные, выбор флагов clone() влияет на измеримые метрики.
Проверить накладные расходы конкретной комбинации флагов удобно через strace и perf:
# Посмотреть, какие флаги передаёт fork() в clone() на самом деле
strace -e trace=clone /usr/bin/ls 2>&1 | grep clone
# Пример вывода:
# clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|
# CLONE_CHILD_SETTID|SIGCHLD, ...) = 12345
# Измерить время создания 1000 процессов через fork()
perf stat -r 5 -- /bin/sh -c \
'for i in $(seq 1 1000); do (exit); done'
Разделение адресного пространства через CLONE_VM устраняет необходимость копировать таблицы страниц, что для процессов с большим heap даёт ощутимое ускорение при старте. Разделение дескрипторов через CLONE_FILES устраняет копирование таблицы файловых дескрипторов. Каждый сэкономленный шаг в copy_process() это реальное время, складывающееся в измеримое преимущество под нагрузкой.
clone3() и направление развития интерфейса
В ядре 5.3 появился clone3(), новая версия системного вызова с расширенным интерфейсом. Вместо передачи флагов одним числом clone3() принимает структуру clone_args, что позволяет добавлять новые параметры без изменения сигнатуры вызова:
#include <linux/sched.h>
#include <sys/syscall.h>
struct clone_args args = {
.flags = CLONE_NEWPID | CLONE_NEWNET,
.exit_signal = SIGCHLD,
.stack = (uint64_t)stack,
.stack_size = STACK_SIZE,
};
/* clone3() вызывается через syscall(), так как glibc обёртки
появились позже самого системного вызова */
pid_t pid = syscall(SYS_clone3, &args, sizeof(args));
clone3() добавил поддержку set_tid, позволяющую задавать желаемый PID потомка в конкретном пространстве имён. Это было невозможно в clone() и открыло путь к воспроизводимому созданию контейнеров, где PID процессов внутри контейнера определён заранее.
fork() остаётся полезным инструментом для большинства задач, где достаточно его семантики. Но понимание того, что за ним стоит clone(), меняет отношение к созданию процессов: это не магия ядра, а набор явных решений о том, что копировать, что разделять и что изолировать. Контейнеры, потоки, изолированные окружения, всё это разные точки на одной шкале флагов одного системного вызова.