Потоки
Предпосылки: процессы (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
- Michael Kerrisk, 2010, The Linux Programming Interface — Chapters 28–33: Threads: https://man7.org/tlpi/
man 2 clone: https://man7.org/linux/man-pages/man2/clone.2.htmlman 7 pthreads: https://man7.org/linux/man-pages/man7/pthreads.7.html
← Процессы | Файловые дескрипторы →