Потоки: N:1, 1:1, M:N и событийный цикл
В программе два потока. Один тикает раз в полсекунды, второй вызвал sleep(2). Вот что она напечатала:
0.0 с тик
0.0 с worker: ухожу в sleep(2)
2.0 с worker: вернулся
2.0 с тик
2.5 с тик
3.0 с тик
Спать просил один поток, а замолчали оба. Это не баг в планировщике: так устроена одна из трёх моделей потоков. Через несколько разделов мы соберём эту программу сами и починим её двумя способами.
Весь урок держится на одной фразе: процесс предоставляет ресурсы, а поток представляет выполнение. Дальше спросим, кто и как это выполнение переключает: ядро, рантайм языка или твой собственный цикл. От ответа зависит, что случится с соседями при блокирующем вызове, сколько ядер получит программа и во что обойдётся миллион соединений.
Все программы урока запущены на одной машине: Linux 6.18, arm64, 16 ядер, gcc 15.3, Go 1.26, JDK 25, Elixir 1.18. Смотри на порядок чисел, а не на последний знак: на твоей машине они будут другими.
Карта урока
- Раздел 1: что у потоков общее, а что своё.
- Раздел 2: сколько стоит переключение.
- Раздел 3:
forkв многопоточном процессе и его ловушка. - Раздел 4: потоки в POSIX, Windows и C++.
- Разделы 5 и 6: модели N:1 и 1:1.
- Раздел 7: M:N, горутины, виртуальные потоки Java и процессы Erlang.
- Разделы 8 и 9: локальная память потока, потокобезопасность и реентерабельность.
- Раздел 10: событийный цикл и выбор архитектуры сервера.
Раздел 1 · Процесс и поток
В уроке про машину мы договорились: процесс это изоляция памяти, поток это стек плюс регистры. Теперь представь кухню. Кухня с плитами, холодильником и запасами это процесс. Повара это потоки. Холодильник у них один, поэтому один может взять продукт, за которым тянется другой. Зато у каждого свой рецепт, открытый на своей строчке, и своя разделочная доска.
| Своё у каждого потока | Общее у всех потоков процесса |
|---|---|
| регистры и счётчик команд | код и глобальные переменные |
| стек | куча |
| номер потока | номер процесса |
| маска сигналов | обработчики сигналов |
errno и другая локальная память потока | открытые дескрипторы, текущий каталог |
Проверим разницу на одной переменной. Сначала её меняет ребёнок после fork, потом поток:
#include <pthread.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int counter = 0;
void *bump(void *arg) {
counter = 42;
return NULL;
}
int main(void) {
pid_t pid = fork();
if (pid == 0) { // ребёнок пишет в свою копию памяти
counter = 42;
_exit(0);
}
waitpid(pid, NULL, 0);
printf("после процесса: counter = %d\n", counter);
pthread_t t;
pthread_create(&t, NULL, bump, NULL);
pthread_join(t, NULL);
printf("после потока: counter = %d\n", counter);
}
$ gcc -pthread shared.c -o shared && ./shared
после процесса: counter = 0
после потока: counter = 42
Запись ребёнка родитель не увидел: у ребёнка своя кухня. Запись потока видна сразу, потому что ячейка одна. Отсюда и выгода потоков, и все их беды: обмен данными ничего не стоит, но и защиты между поварами нет. Как потоки гоняются за общими данными, разбирает урок про гонки.
На Linux в ядре нет отдельной сущности «поток»: есть задача, и она делит с создателем больше или меньше состояния. Как это выглядит на уровне clone и его флагов, показывает урок 32-systems/69.
Раздел 2 · Переключение контекста
Повар отходит от плиты, другой становится на его место. Чтобы первый потом продолжил с той же строчки, ядро сохраняет его регистры, счётчик команд и указатель стека и загружает те же значения другого потока. Если новый поток из другого процесса, вдобавок меняется адресное пространство, а записи в кэше трансляций адресов становятся бесполезными.
Замерим. Два потока перебрасывают байт через пару пайпов, оба привязаны к одному ядру, так что каждый ход это настоящее переключение. Потом то же для двух процессов. Для сравнения два зелёных потока (о них в разделе 5) и пустой системный вызов:
| Что делаем | Цена одного шага |
|---|---|
пустой системный вызов getppid | 136 нс |
swapcontext, переключение внутри одного потока ядра | 307 нс |
| поток на поток, через пайп | 1420 нс |
| процесс на процесс, через пайп | 2214 нс |
Читать таблицу надо с двумя оговорками. В строках с пайпом заодно сидят read и write. А swapcontext в glibc на каждом вызове делает системный вызов rt_sigprocmask, чтобы сохранить маску сигналов: это видно в strace. Рукописное переключение без него ещё дешевле.
И главное, чего в таблице нет: остывший кэш. Новый поток работает со своими данными, и их снова надо поднимать из памяти. Поэтому тысячи потоков, которые одновременно хотят процессор, это плохая архитектура, а тысячи спящих вполне терпимы. Мы к этому вернёмся.
Раздел 3 · fork в многопоточном процессе
В процессе три потока, один из них вызывает fork. Сколько потоков у ребёнка? Один, копия вызвавшего. А память копируется вся, вместе с состоянием замков. Если замок в этот момент держал другой поток, в ребёнке этот замок закрыт, а открыть его некому:
// fork в многопоточном процессе: ребёнок получает один поток и чужие замки.
#include <dirent.h>
#include <pthread.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static int count_threads(void) {
DIR *dir = opendir("/proc/self/task");
int n = 0;
while (readdir(dir)) n++;
closedir(dir);
return n - 2; // минус . и ..
}
static void *holder(void *arg) {
pthread_mutex_lock(&lock);
sleep(100); // держит замок и спит
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, holder, NULL);
pthread_create(&t2, NULL, holder, NULL);
usleep(100000); // пусть первый успеет взять замок
printf("родитель: потоков %d\n", count_threads());
fflush(stdout);
pid_t pid = fork();
if (pid == 0) {
printf("ребёнок: потоков %d\n", count_threads());
printf("ребёнок: беру замок...\n");
fflush(stdout);
alarm(2); // иначе ждали бы вечно: хозяина замка в ребёнке нет
pthread_mutex_lock(&lock);
printf("ребёнок: взял\n");
_exit(0);
}
int status;
waitpid(pid, &status, 0);
if (WIFSIGNALED(status))
printf("родитель: ребёнка убил сигнал %d (SIGALRM), замок он так и не взял\n", WTERMSIG(status));
fflush(stdout);
_exit(0); // не ждём спящих держателей замка
}
$ ./forked
родитель: потоков 3
ребёнок: потоков 1
ребёнок: беру замок...
родитель: ребёнка убил сигнал 14 (SIGALRM), замок он так и не взял
Такой замок может сидеть не только в твоём коде, но и внутри библиотеки, которую ты зовёшь. Поэтому man 2 fork разрешает ребёнку многопоточной программы до execve звать только async-signal-safe функции.
Правило на практике короткое: в многопоточном процессе после fork сразу делай exec, а ещё лучше зови posix_spawn. Есть и pthread_atfork, он берёт замки перед fork и отпускает их после, но за всеми библиотеками процесса так не уследишь. В Windows ловушки нет, потому что нет и самого fork: CreateProcess сразу запускает новую программу.
Раздел 4 · Потоки в POSIX, Windows и C++
Идея везде одна, различаются имена и детали:
| Действие | POSIX | Windows | C++ |
|---|---|---|---|
| создать | pthread_create | _beginthreadex | конструктор std::thread, std::jthread |
| дождаться | pthread_join | WaitForSingleObject | join() |
| не ждать | pthread_detach | CloseHandle | detach() |
| узнать номер | pthread_self | GetCurrentThreadId | std::this_thread::get_id |
| локальная память | _Thread_local, pthread_key_create | __declspec(thread), TlsAlloc | thread_local |
POSIX ты уже видел. Запомни одну деталь: pthread_create возвращает номер ошибки прямо, а не кладёт его в errno.
В Windows поток это объект ядра, и работают с ним через описатель. Завершение потока переводит объект в сигнальное состояние, поэтому ждут его той же функцией, что и любой другой объект. WaitForMultipleObjects умеет дождаться любого из нескольких потоков, а pthread_join так не может.
#include <process.h>
#include <stdio.h>
#include <windows.h>
static unsigned __stdcall run(void *arg) {
printf("поток %lu, аргумент %d\n", GetCurrentThreadId(), *(int *)arg);
return 0;
}
int main(void) {
int value = 42;
HANDLE thread = (HANDLE)_beginthreadex(NULL, 0, run, &value, 0, NULL);
if (thread == NULL) return 1;
WaitForSingleObject(thread, INFINITE); // аналог pthread_join
CloseHandle(thread); // иначе объект потока останется в системе
}
Этот листинг мы только собрали кросс-компилятором (zig cc -target x86_64-windows-gnu), а не запускали: Windows под рукой не было. Почему _beginthreadex, а не CreateThread? Документация CreateThread велит потокам, которые зовут библиотеку C, использовать именно его: поток из CreateThread рискует обрушить процесс при нехватке памяти внутри библиотеки.
В C++ поток это объект, и у него строгий деструктор. Забыл join или detach, и программа умрёт:
#include <iostream>
#include <thread>
int main() {
std::thread worker([] { std::cout << "работаю\n"; });
std::cout << "забыл join\n";
} // деструктор joinable-потока зовёт std::terminate
$ ./t
забыл join
terminate called without an active exception
Иногда перед смертью успевает напечатать и сам поток: кто успел, зависит от планировщика.
std::jthread из C++20 чинит это: его деструктор просит поток остановиться и дожидается его. Просьба приходит через std::stop_token, и поток сам решает, когда на неё ответить. Убить поток извне стандарт не позволяет: он мог держать замок или писать файл.
#include <chrono>
#include <iostream>
#include <stop_token>
#include <thread>
using namespace std::chrono_literals;
void tick(std::stop_token stop) {
int ticks = 0;
while (!stop.stop_requested()) {
++ticks;
std::this_thread::sleep_for(100ms);
}
std::cout << "тиков: " << ticks << "\n";
}
int main() {
std::jthread worker(tick);
std::this_thread::sleep_for(350ms);
std::cout << "main выходит\n";
} // деструктор: request_stop(), затем join()
$ g++ -std=c++20 -pthread jt.cpp -o jt && ./jt
main выходит
тиков: 4
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.