Потоки

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

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

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

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

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

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

Первая — память. Каждый процесс получает собственное адресное пространство. Да, copy-on-write откладывает физическое копирование страниц до момента записи, но каждый процесс неизбежно модифицирует стек, heap-данные, служебные структуры. При 10 000 процессов, даже если каждый потребляет всего 10 МБ уникальной памяти, это 100 ГБ — только на изолированные копии.

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

Третья — скорость создания и переключения. fork на современном Linux занимает 50–100 мкс: ядру нужно скопировать таблицу страниц, дескрипторы файлов, структуры сигналов. Переключение контекста между процессами стоит 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 потоков. Код сервера, кэш ответов, пул соединений к базе — всё в общей памяти. Обращение к кэшу — обычное чтение по указателю, без 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 раз по переключению и на порядок по памяти.

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

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

В ядре Linux нет отдельной сущности «поток». И процесс, и поток — это task_struct, структура в ядре, которая описывает исполняемую задачу. Различие определяется тем, какие ресурсы задача разделяет с родителем.

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

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

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

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

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

Планировщик ядра (CFS — Completely Fair Scheduler) не различает процессы и потоки. Каждый task_struct — это единица планирования. Два потока одного процесса конкурируют за CPU наравне с задачами из других процессов. Это означает, что процесс с 10 потоками получает (приблизительно) в 10 раз больше процессорного времени, чем процесс с одним потоком — если не используются cgroups для ограничения.

Унифицированная модель «всё есть 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.

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

Локальное хранилище потока (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-префиксом, без вызовов ядра.

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

Потоки ядра и потоки пользовательского пространства

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

Вернёмся к веб-серверу: 10 000 OS-потоков в модели 1:1 — это 160 МБ только на стеки ядра. А если нужно обслужить 100 000 соединений?

Альтернатива — потоки пользовательского пространства (user-space threads). Библиотека в пространстве процесса сама переключает контексты исполнения: сохраняет регистры и указатель стека одного контекста, восстанавливает другой — без обращения к ядру. Переключение обходится в 100–200 нс (нет перехода user mode / kernel mode), и происходит кооперативно: поток добровольно уступает управление через yield. Но если пользовательский поток выполняет блокирующий системный вызов — read из сокета, write на диск — блокируется весь kernel thread, а с ним все пользовательские потоки, привязанные к нему. В чистом виде --- когда все пользовательские потоки на одном kernel thread --- это модель N:1.

Эту проблему решает модель M:N — M пользовательских потоков мультиплексируются на N потоков ядра. Если один пользовательский поток блокируется в системном вызове, остальные N−1 kernel threads продолжают работу. Горутины в Go — реализация M:N модели: Go runtime содержит планировщик GMP (Goroutine, Machine, Processor), который распределяет горутины по OS-потокам. Стек горутины начинается с 2 КБ (начиная с Go 1.4; вместо 8 МБ у OS-потока) и растёт по мере необходимости. 100 000 горутин по 2 КБ = 200 МБ стеков, тогда как 100 000 OS-потоков по 8 МБ = 800 ГБ виртуальной памяти.

1:1 (pthreads)           M:N (Go)
User    Kernel           User         Kernel
T1  --> KT1              G1 -+
T2  --> KT2              G2 -+-> KT1
T3  --> KT3              G3 -+
                         G4 -+-> KT2
Модель       Стоимость переключения  Блокирующие вызовы    Масштаб
──────────────────────────────────────────────────────────────────────
1:1          1-5 мкс                 блокируют один KT     тысячи
M:N          100-200 нс              runtime обрабатывает   сотни тысяч
N:1          100-200 нс              блокируют все          тысячи

На практике веб-сервер с 10 000 клиентами не создаёт 10 000 OS-потоков — используется либо пул потоков фиксированного размера в комбинации с неблокирующим I/O (epoll), либо M:N runtime с горутинами.

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

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

На практике многопоточные серверы блокируют сигналы во всех рабочих потоках (через pthread_sigmask) и выделяют один поток, который ожидает сигналы через sigwait. Это превращает асинхронную доставку сигналов в синхронную обработку в предсказуемом контексте.

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

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

См. также

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

Sources


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