Потоки

Предпосылки: процессы (fork, exec, адресное пространство, task_struct).

Процессы | Файловые дескрипторы

Часто говорят: «поток — это лёгкий процесс». Наполовину правда: лёгкий — да, но у потока нет своего адресного пространства, и это меняет всё — от создания и переключения до корректности кода.

Вызов fork создаёт независимый процесс с копией адресного пространства родителя. Для изолированных задач — парсинг конфигурации в отдельном процессе, запуск внешней программы через exec — это ровно то, что нужно. Но когда задача требует параллельной работы над общими данными, изоляция превращается из защиты в помеху.

Цена изоляции

Веб-сервер обслуживает 10 000 одновременных клиентов. Классическая архитектура — по процессу на клиента: master принимает соединение, вызывает fork, дочерний процесс обрабатывает запрос. Такую модель использовали ранние версии Apache (prefork MPM — Multi-Processing Module).

У этой модели три проблемы.

Первая — память. Каждый процесс получает собственное адресное пространство. Да, copy-on-write откладывает физическое копирование страниц до момента записи, но эта отсрочка не спасает: каждый процесс неизбежно пишет в свой стек, модифицирует heap-данные, меняет служебные структуры — каждая такая запись превращает общую страницу в приватную копию. При 10 000 процессов, даже если каждый потребляет всего 10 МБ уникальной памяти, это 100 ГБ — только на изолированные копии. На типичном сервере с 64–128 ГБ физической RAM это означает, что модель «процесс на клиента» упирается в потолок задолго до того, как заканчивается CPU.

Вторая — кэш. Веб-серверу нужен разделяемый кэш ответов, пул подключений к базе данных, таблица активных сессий. Процессы не видят память друг друга. Чтобы разделить данные, приходится использовать IPC (Inter-Process Communication, межпроцессное взаимодействие): разделяемую память (shared memory) через mmap или System V, каналы, сокеты. Каждый из этих механизмов требует явной синхронизации — семафоров, блокировок, сериализации данных. Общий кэш, который в одном адресном пространстве был бы простым обращением к хеш-таблице, превращается в архитектурную задачу.

Третья — скорость создания и переключения. fork на современном Linux занимает 50–100 мкс для лёгкого процесса: ядру нужно скопировать таблицу страниц, дескрипторы файлов, структуры сигналов. Это нижняя оценка — главный драйвер стоимости fork — копирование таблицы страниц, и с ростом рабочего набора цена растёт: процесс с десятками гигабайт mmap-памяти форкается за миллисекунды, а не микросекунды. Переключение контекста между процессами стоит 3–10 мкс, потому что ядро перезагружает регистр CR3 (базовый адрес таблицы страниц), и трансляции адресов предыдущего процесса в TLB (Translation Lookaside Buffer — кэш трансляций виртуальных адресов в физические) становятся неактуальными. После этого каждое первое обращение к памяти в новом процессе проходит через полную аппаратную трансляцию, пока TLB не прогреется заново.

Все три проблемы сходятся к одному: изоляция адресных пространств — дорогая абстракция для задач, где параллельные потоки исполнения работают над общими данными. Нужна единица параллелизма, которая разделяет память с другими единицами внутри одного процесса.

Операционные системы дают такой единице отдельное имя.

Поток: параллелизм без изоляции

Поток (thread) — это независимая последовательность инструкций, исполняемая внутри процесса и разделяющая с ним общее адресное пространство.

У каждого потока своё: указатель инструкций (instruction pointer, rip на x86-64), набор регистров общего назначения, стек. Стек по умолчанию занимает 8 МБ виртуальной памяти (значение можно посмотреть через ulimit -s), но физические страницы выделяются по мере роста стека — при неглубокой рекурсии реальное потребление составляет десятки–сотни килобайт.

Всё остальное — общее: адресное пространство (код, глобальные переменные, heap), таблица файловых дескрипторов, обработчики сигналов, PID (Process ID). Два потока одного процесса читают и пишут одни и те же адреса без какого-либо IPC.

Процесс (PID 4200)
┌──────────────────────────────────────────────────┐
│                Адресное пространство               │
│  ┌──────┐  ┌──────┐  ┌───────┐  ┌────────────┐  │
│  │ Code │  │ Data │  │  Heap │  │   mmap()   │  │
│  └──────┘  └──────┘  └───────┘  └────────────┘  │
│        ^         ^         ^          ^          │
│        |         |         |          |          │
│   ┌────+────┐ ┌──+───┐ ┌──+───┐                 │
│   │Thread 0 │ │Thr. 1│ │Thr. 2│                  │
│   │ rip     │ │ rip  │ │ rip  │                  │
│   │ regs    │ │ regs │ │ regs │                  │
│   │ stack   │ │ stack│ │ stack│                  │
│   │ (8 MB)  │ │(8 MB)│ │(8 MB)│                  │
│   └─────────┘ └──────┘ └──────┘                  │
└──────────────────────────────────────────────────┘

Для тех же 10 000 клиентов это меняет арифметику: один процесс с 10 000 потоков вместо 10 000 процессов. Код сервера, кэш ответов, пул соединений к базе — всё в общей памяти. Обращение к кэшу — обычное чтение по указателю, без mmap и сериализации. Стеки: 10 000 * 8 МБ = 80 ГБ виртуальной памяти, но физических страниц — сотни мегабайт (стеки растут лениво). Для сравнения: 10 000 процессов по 10 МБ уникальной памяти каждый = 100 ГБ физической.

Стоимость: создание и переключение

Создание потока занимает 10–50 мкс — ядру не нужно копировать таблицу страниц, достаточно выделить стек и создать управляющую структуру.

Переключение контекста между потоками одного процесса — 1–5 мкс. Ключевая разница с процессами: потоки разделяют адресное пространство, поэтому при переключении ядро не перезагружает CR3 и трансляции в TLB остаются валидными — оба потока используют одни и те же таблицы страниц, значит закешированные пары «виртуальный адрес → физический» подходят обоим. Сохраняются и восстанавливаются только регистры общего назначения и указатель стека.

Разница между процессом и потоком по трём характеристикам — создание, переключение, память — выглядит так:

Операция                          Процесс       Поток
─────────────────────────────────────────────────────────
Создание                          50-100 мкс    10-50 мкс
Контекстное переключение          3-10 мкс      1-5 мкс
Сброс TLB при переключении        да            нет
Общая память                       нет (IPC)     да
Стоимость 1000 экземпляров (RAM)  ~10 ГБ        ~0.5 ГБ

Числа зависят от оборудования и ядра. На серверном Xeon переключение потоков может укладываться в 1–2 мкс, на слабом ARM — в 5–8 мкс. Порядок величин стабилен: потоки дешевле процессов в 2–5 раз по переключению и на порядок по памяти.

Есть и потолок масштабирования. Каждый поток — это служебная структура-дескриптор в ядре (~7–8 КБ в ядрах 5.x/6.x) плюс стек ядра (обычно 16 КБ на x86-64) плюс пользовательский стек. При 10 000 потоков только на стеки ядра уходит ~160 МБ. Системный лимит виден через cat /proc/sys/kernel/threads-max — на сервере с 64 ГБ RAM он обычно составляет несколько сотен тысяч.

Linux: потоков не существует

В ядре Linux нет отдельной сущности «поток». Процесс и поток — одна и та же структура-дескриптор task_struct, описывающая исполняемую задачу: регистры на момент последнего переключения, состояние, указатели на ресурсы — адресное пространство, открытые файлы, обработчики сигналов. То, что со стороны API выглядит как два разных механизма, на уровне ядра различается только одним: какие из этих ресурсов новая задача получает в совместное владение с родителем, а какие — в виде собственной копии.

За создание и процессов, и потоков отвечает один системный вызов — clone(). Он принимает набор флагов, каждый из которых указывает, какой ресурс разделять с родительской задачей:

clone() с флагами:

CLONE_VM       — разделять адресное пространство
CLONE_FILES    — разделять таблицу файловых дескрипторов
CLONE_FS       — разделять корневой и текущий каталог
CLONE_SIGHAND  — разделять обработчики сигналов
CLONE_THREAD   — быть частью одной группы потоков (thread group, общий PID для getpid())

Если флаг не указан, ресурс не разделяется — ядро создаёт для новой задачи собственную копию, как при обычном fork. Так это и работает: fork — это clone без флагов разделения, дочерняя задача получает копию всего. Библиотечная функция pthread_create() — это clone с полным набором: CLONE_VM | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD и другими. Между этими двумя крайностями возможны промежуточные комбинации — например, разделить адресное пространство, но не файловые дескрипторы.

Отдельно стоит флаг CLONE_THREAD. Без него новая задача получает собственный PID, и ядро, и утилиты вроде ps видят её как независимый процесс — даже если она на самом деле разделяет адресное пространство с родителем.

С CLONE_THREAD задача присоединяется к thread group родителя — внутренней группировке task_struct, у которой один лидер и общий для всей группы PID. Это меняет то, что видит пользовательское пространство: getpid() возвращает PID лидера группы (а не идентификатор конкретной задачи), gettid() возвращает индивидуальный идентификатор потока, а ps показывает процесс с PID 4200 одной строкой — несмотря на то, что ls /proc/4200/task/ раскрывает по отдельному каталогу для каждого потока. Thread group — это именно механизм, через который «много задач в ядре» становятся «одним процессом с несколькими потоками» для пользовательских инструментов.

Планировщик ядра (CFS — Completely Fair Scheduler) не различает процессы и потоки. Каждый task_struct — это единица планирования. Два потока одного процесса конкурируют за CPU наравне с задачами из других процессов. Наблюдаемое следствие: процесс с 10 потоками получает примерно в 10 раз больше процессорного времени, чем процесс с одним потоком — если не ограничен через cgroups (группы процессов с лимитами на ресурсы ядра) или autogroup (группирует задачи одной TTY-сессии в одну планировочную группу; включён по умолчанию с ядра 2.6.38). На практике это видно в top: поле %CPU для многопоточного процесса превышает 100% и может доходить до N × 100% на машине с N ядрами.

Унифицированная модель «всё есть task_struct» — сознательное решение Линуса Торвальдса. Вместо двух отдельных механизмов (процессы и потоки) с разными API и путями в ядре — один clone с набором флагов. Это упрощает планировщик, упрощает код ядра и делает поведение предсказуемым: поток ведёт себя так же, как процесс, с точки зрения планирования, приоритетов, сигналов (с поправкой на thread group).

Библиотека pthreads

Приложения работают с потоками не через clone напрямую, а через POSIX (Portable Operating System Interface) Threads (pthreads) — стандартизированный интерфейс, реализованный в glibc как NPTL (Native POSIX Threads Library). NPTL создаёт каждый поток как отдельный task_struct через clone, поэтому каждый поток виден ядру и планируется независимо.

#include <pthread.h>
 
void *handle_request(void *arg) {
    int client_fd = *(int *)arg;
    // обработка запроса — читаем из общего кэша напрямую
    return NULL;
}
 
// В основном потоке:
pthread_t tid;
pthread_create(&tid, NULL, handle_request, &client_fd);
// Основной поток продолжает принимать соединения.
// handle_request исполняется параллельно.

pthread_create выделяет стек для нового потока (через mmap), вызывает clone с нужными флагами, и ядро начинает исполнять handle_request на доступном CPU-ядре. Основной поток продолжает работу без ожидания.

Когда поток завершает работу, его ресурсы не освобождаются автоматически — другой поток должен вызвать pthread_join(tid, &result), чтобы забрать результат и освободить стек. Без join завершившийся поток остаётся в состоянии, аналогичном zombie-процессу: task_struct существует, стек выделен, но полезной работы не происходит. При 10 000 потоков, завершающихся без join, — это 10 000 невостребованных стеков по 8 МБ виртуальной памяти. Если поток не нуждается в join — его можно пометить как detached через pthread_detach(tid), и ресурсы освободятся при завершении автоматически. На практике серверные потоки из пула обычно не завершаются вовсе — они обрабатывают запрос и возвращаются к ожиданию следующего.

Общая память: преимущество и проблема

Общее адресное пространство устраняет необходимость в IPC — это главное преимущество потоков. Но оно же порождает проблемы, которых нет у процессов.

В сценарии веб-сервера это значит: два потока одновременно обновляют счётчик активных соединений — counter++. На уровне машинных инструкций counter++ раскладывается на три операции: загрузка значения из памяти в регистр (mov), инкремент регистра (inc), запись обратно (mov).

Если два потока выполняют эту последовательность одновременно на разных ядрах CPU, оба могут прочитать одно и то же значение, оба увеличат его на единицу, оба запишут одинаковый результат — вместо двух инкрементов произойдёт один:

counter = 0

Поток A                      Поток B
──────────────────────────────────────────
read counter -> reg = 0
                             read counter -> reg = 0
reg = reg + 1    (reg = 1)
                             reg = reg + 1    (reg = 1)
write reg -> counter = 1
                             write reg -> counter = 1

Ожидание: counter = 2
Реальность: counter = 1

Это гонка данных (race condition) — результат зависит от порядка чередования инструкций, который не детерминирован. Процессы защищены от этого по умолчанию: каждый работает со своей копией данных. Потоки вынуждены использовать механизмы синхронизации — мьютексы, атомарные операции, условные переменные — чтобы координировать доступ к общим данным.

Синхронизация вносит свою стоимость. Мьютекс (mutex, от mutual exclusion — взаимное исключение) гарантирует, что критическая секция выполняется только одним потоком. Остальные потоки, пытающиеся захватить тот же мьютекс, блокируются — они не выполняют полезную работу, а ждут. При высокой конкуренции за один мьютекс (hot lock — «горячая» блокировка) параллелизм вырождается в последовательное выполнение, и накладные расходы на переключение контекста и синхронизацию делают многопоточную программу медленнее однопоточной.

Помимо мьютексов существуют и другие механизмы. Атомарные операции (atomic operations) — специальные процессорные инструкции вроде lock cmpxchg, которые выполняют чтение-модификацию-запись как одну неделимую операцию. Для простых счётчиков атомарная инструкция обходится в 10–100 нс — дешевле мьютекса (100–200 нс без конкуренции, микросекунды при конкуренции). Условные переменные (condition variables) позволяют потоку заснуть до наступления определённого события, вместо того чтобы проверять условие в цикле и тратить CPU.

Это фундаментальный компромисс потоков: общая память даёт скорость доступа к данным, но требует дисциплины при записи. Процессы — наоборот: изоляция по умолчанию, но дорогой обмен данными. Поэтому правило выбора простое: потоки — правильный инструмент там, где данные естественно общие и где стоимость блокировок при типичной нагрузке заметно ниже цены IPC между процессами. Веб-сервер с разделяемым кэшем, пул соединений к базе, in-memory индекс — типичные случаи. Но как только рабочий набор потоков становится почти независимым и общаются они редкими сообщениями, изоляция процессов (или подхода в духе actor model) возвращает себе преимущество: там уже не нужна дисциплина синхронизации, и один упавший процесс не утаскивает остальных.

Локальное хранилище потока (thread-local storage) и errno

Не все данные должны быть общими. Переменная errno — классический пример: каждый системный вызов, завершившийся ошибкой, записывает код ошибки в errno. В однопоточной программе это глобальная переменная. Но если два потока одновременно вызывают read и write, и оба завершаются с ошибкой — второй перезапишет errno первого прежде, чем тот успеет его прочитать.

Решение — локальное хранилище потока (thread-local storage, TLS; не путать с Transport Layer Security). Каждый поток получает собственную копию переменной. В POSIX это реализуется через pthread_key_create / pthread_getspecific / pthread_setspecific. В GCC (GNU Compiler Collection) и Clang есть более удобный синтаксис — ключевое слово __thread (или _Thread_local в C11):

__thread int errno;  // у каждого потока своя копия

На уровне реализации TLS-переменные хранятся в специальном сегменте памяти, выделяемом для каждого потока. Регистр fs на x86-64 указывает на TLS-блок текущего потока, поэтому в типичном случае доступ к thread-local переменной — одна инструкция с fs-префиксом, без вызовов ядра. В динамически загружаемых библиотеках модель сложнее: компилятор вставляет вызов функции-резолвера (__tls_get_addr), и доступ обходится дороже одной инструкции.

В glibc errno реализован именно так: это макрос, который разворачивается в (*__errno_location()), где __errno_location возвращает адрес errno для текущего потока.

Предел 1:1 и user-space потоки

Модель 1:1 (один user-поток = один task_struct через clone) — единственная нативная в Linux. Она упирается в потолок: task_struct ~7-8 КБ плюс kernel-stack (на x86-64 defconfig — 16 КБ, THREAD_SIZE = 4 × PAGE_SIZE; с CONFIG_KASAN — 32 КБ; на 32-битных архитектурах — 8 КБ) на поток ограничивают масштаб 10-100 тысячами потоков; для 100 000 соединений это уже 1.6 ГБ только на стеки ядра (на x86-64 defconfig). Альтернатива — потоки пользовательского пространства, мультиплексируемые на пул OS-потоков (M:N): Go-горутины, Ruby Fiber, Java Virtual Threads. Стек user-space потока маленький (2 КБ у горутины с Go 1.4), переключение не требует syscall (cooperative yield в user space, 100-200 нс). Цена — собственный планировщик в runtime и необходимость разворачивать блокирующие syscall в неблокирующие: иначе один заблокированный пользовательский поток уносит с собой весь kernel-поток, на который он был привязан. Детали реализации — в заметках про конкретные рантаймы (Ruby Fiber и Ractor).

Термин «kernel thread» в Linux обычно означает другое — потоки, работающие целиком в ядре, без пользовательского адресного пространства (kworker, ksoftirqd, migration). Их видно в ps по квадратным скобкам: [kworker/0:1]. Потоки, созданные через pthread_create() — пользовательские потоки, управляемые ядром, но не kernel threads в этом смысле.

Потоки и сигналы

Сигналы добавляют ещё одно измерение сложности. Обработчики сигналов (CLONE_SIGHAND) общие для всех потоков процесса, но сигнал может быть доставлен конкретному потоку — через pthread_kill — или процессу в целом: тогда ядро выбирает произвольный поток, у которого этот сигнал не заблокирован. SIGTERM, отправленный через kill, попадёт в один из потоков — какой именно, не определено. На практике многопоточные серверы решают эту неопределённость, заранее блокируя сигналы во всех рабочих потоках и оставляя обработку выделенному потоку.

От потоков к файловым дескрипторам

Потоки и процессы — это контексты исполнения: они определяют, кто выполняет код и в каком адресном пространстве. Но любая полезная программа взаимодействует с внешним миром — читает файлы, отправляет данные по сети, пишет в терминал. Для этого нужен механизм, через который процесс обращается к ресурсам ядра. Этот механизм — файловые дескрипторы.

См. также

  • Ruby GVL — Global VM Lock как pthread_mutex_t + condition variable, сериализующий доступ к Ruby VM state между потоками

Sources


Процессы | Файловые дескрипторы