Раздел 2 · Программирование / CS

Потоки: N:1, 1:1, M:N и событийный цикл

middle~110 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Потоки: 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) и пустой системный вызов:

Что делаемЦена одного шага
пустой системный вызов getppid136 нс
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++

Идея везде одна, различаются имена и детали:

ДействиеPOSIXWindowsC++
создатьpthread_create_beginthreadexконструктор std::thread, std::jthread
дождатьсяpthread_joinWaitForSingleObjectjoin()
не ждатьpthread_detachCloseHandledetach()
узнать номерpthread_selfGetCurrentThreadIdstd::this_thread::get_id
локальная память_Thread_local, pthread_key_create__declspec(thread), TlsAllocthread_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.