
Полная версия:
Многопоточность на C++ через практику и замеры

Многопоточность на C++ через практику и замеры
Как читать эту книгу
Эта книга — для разработчика, который уже пишет на C++, знает RAII, умные указатели и move-семантику, но с многопоточностью пока знаком на уровне «запустил std::thread, вроде работает». Цель — дойти до уровня, на котором вы проектируете конкурентные подсистемы, обосновываете решения цифрами и уверенно проходите senior-собеседования.
Правило книги: «так быстрее» подтверждается бенчмарком, «так безопасно» — тестом под ThreadSanitizer и рассуждением через happens-before. Весь код книги собран и проверен под ThreadSanitizer и AddressSanitizer; тесты сквозного проекта проходят десять прогонов подряд.
Как устроена глава: вступление; теория и практика с проверенным кодом; грабли с ревью; вопросы с собеседований; шаг сквозного проекта (что делаем, чем проверяем, что меряем); три задачи разной сложности с критериями самопроверки. Решения всех задач с разбором — в главе «Ответы к задачам» в конце книги.
Сквозной проект shardkv — in-memory key-value сервер, который за 14 шагов проходит путь от потока на соединение с гонкой данных до асинхронного сервера с вычислительным пулом, сбросом нагрузки, CI и журналом архитектурных решений. Два шага из четырнадцати — неудавшиеся эксперименты, и это осознанно: умение отказаться от красивой оптимизации, которая не прошла замер, — тоже навык сеньора.
Что нужно для примеров: GCC 14+ или Clang 18+ с -std=c++23, CMake 3.25+, Ninja; для глав 10–13 — Asio (standalone) и stdexec, проект скачивает их сам через FetchContent. Все цифры замеров сняты в контейнере с двумя виртуальными ядрами, где сервер и нагрузочный клиент делят процессор; на вашей машине абсолютные значения будут другими — прогоните бенчмарки сами, это часть обучения.
Для первой работы достаточно глав 1–7, лёгких и средних задач из них и проекта до шага 7. Остальное — путь к уровню senior.
Глава 1. std::thread, std::jthread и stop_token: как запустить поток и не вызвать std::terminate
«У меня локально работало». Последние слова перед инцидентом.
Поздравляю: вы решили заняться многопоточностью. Это примерно как решить заняться жонглированием, только вместо мячиков бензопилы, а зрители — ваши пользователи в продакшене.
Хорошая новость: запустить поток в C++ можно одной строкой. Плохая: уронить программу тоже можно одной строкой, причём той же самой. Эта глава о том, как запускать потоки так, чтобы они гарантированно завершались, не оставляли висячих ссылок и не превращали выход из программы в лотерею.
К концу главы вы:
— поймёте, что такое поток с точки зрения ОС и почему его не создают на каждый запрос;
— узнаете, почему деструктор std::thread вызывает std::terminate и почему detach() — это не решение, а отсрочка проблемы;
— научитесь передавать в поток аргументы и не терять их по дороге;
— освоите std::jthread и кооперативную отмену через stop_token;
— научитесь доставлять исключения из потока туда, где их могут обработать;
— напишете первый шаг сквозного проекта: TCP-сервер shardkv с потоком на соединение, который умеет корректно останавливаться.
Про примеры. Весь код в книге написан на C++23 и собирается GCC 14+ или Clang 18+ с флагом -std=c++23. Для вывода используется std::println из заголовка
1.1. Что такое поток и сколько он стоит
Процесс — это изолированная песочница: своё адресное пространство, свои открытые файлы, свои ресурсы. Поток — это поток выполнения внутри процесса: свой стек, свои регистры, свой указатель команд. А вот память, куча, глобальные переменные и файловые дескрипторы у всех потоков процесса общие.
Именно в этом «общие» и кроется вся сила многопоточности и вся её боль. Потоки могут работать с одними данными без копирования. И точно так же они могут одновременно эти данные испортить.
В Linux std::thread — это обёртка над pthread, а pthread — над системным вызовом clone. Когда вы создаёте поток, происходит следующее:
— ядро выделяет структуру задачи и регистрирует её в планировщике;
— резервируется стек (в Linux по умолчанию 8 МБ виртуальной памяти, физическая выделяется по мере использования);
— создаётся локальное хранилище потока (thread_local);
— планировщик решает, на каком ядре и когда поток начнёт работать.
Давайте не верить на слово, а померить:


Листинг 1.1 — сколько стоит поток (ex01_cost.cpp)
На моей машине в release-сборке получилось около 55 мкс на поток. На голом железе с хорошим ядром будет от единиц до десятков микросекунд. Кажется, немного? Вызов пустой функции стоит около наносекунды. Поток дороже в десятки тысяч раз.
Отсюда первое правило: потоки не создают на каждый чих. Если сервер на каждый запрос создаёт поток, при нагрузке он будет тратить время не на работу, а на создание и уничтожение потоков. Правильный ответ — пул потоков, мы напишем его в главе 7. А пока, для учебного сервера, поток на соединение — нормальная отправная точка.
Второе правило: потоков полезно иметь примерно столько, сколько ядер. std::thread::hardware_concurrency() вернёт число аппаратных потоков, но это подсказка, а не гарантия: функция имеет право вернуть 0, если не знает. Если вычислительных потоков больше, чем ядер, они не ускоряют работу, а толкаются за процессорное время, и каждый переключатель контекста стоит микросекунды.
1.2. join, detach и std::terminate
Создать поток просто: передаём в конструктор std::thread что-нибудь вызываемое. Дальше у вас есть ровно два варианта, и выбрать один из них нужно обязательно:
— join() — дождаться завершения потока;
— detach() — отпустить поток, он будет работать сам по себе, и вы больше не можете его дождаться.
А если не выбрать ни один?

Листинг 1.2 — как уронить программу одной строкой (ex02_terminate.cpp)
Программа упадёт через std::terminate. Это не баг стандартной библиотеки, это осознанное решение. Объект std::thread, у которого joinable() == true, представляет живой поток. Если такой объект разрушается, у деструктора есть три варианта: дождаться потока (а вдруг он вечный, и программа зависнет), отпустить поток (а вдруг он работает с локальными переменными, которые сейчас умрут) или убить программу. Комитет выбрал третий: громкая смерть лучше тихого UB.
Почему detach() — почти всегда ошибка
Джун, впервые увидевший terminate, обычно делает так: «а, нужно вызвать detach, и всё заработает». Работать действительно начинает. До первого инцидента.
После detach() у вас нет никакого способа узнать, когда поток закончил работу. А значит, нет способа гарантировать, что всё, к чему поток обращается, ещё живо:
— локальные переменные функции, которая создала поток, умрут при выходе из неё;
— объект, чей метод запущен в потоке, может быть уничтожен владельцем;
— при выходе из main разрушаются глобальные и статические объекты, а отпущенный поток продолжает ими пользоваться (подробности в разделе 1.9).
Это классический use-after-free, только с отложенным действием и проявляется он «иногда». Под нагрузкой. В пятницу.
У detach есть законные применения, но их мало: поток, который не обращается ни к чему, кроме собственных данных, переданных по значению, и время жизни которого вам действительно безразлично. На практике это редкость. Правило для ревью: каждый detach() в коде требует письменного обоснования, почему все используемые данные переживут поток.
1.3. Исключения и join: почему «просто не забыть» не работает
Хорошо, договорились: всегда вызываем join(). Но посмотрите на этот код:


Листинг 1.3 — почему «просто не забыть join()» не работает (ex03_exception_join.cpp)
В функции bad() между созданием потока и join() есть код, который может бросить исключение. Исключение начинает раскрутку стека, деструктор std::thread видит живой поток и вызывает terminate. Исключение, которое можно было бы аккуратно обработать, превращается в падение программы.
Можно, конечно, обложить всё try/catch и звать join() в каждой ветке. Но мы пишем на C++, а в C++ ресурс, который нужно освободить, отдают RAII-объекту. Поток — тоже ресурс.
1.4. std::jthread: поток, который умеет за собой убирать
В C++20 появился std::jthread («joining thread»). Его отличия от std::thread:
— деструктор не вызывает terminate, а сначала просит поток остановиться (request_stop()), затем делает join();
— у него встроенный механизм кооперативной отмены: std::stop_source и std::stop_token;
— если первый параметр функции потока имеет тип std::stop_token, jthread сам передаст туда свой токен.
Функция good() из предыдущего примера использует именно jthread. Исключение летит дальше, а поток корректно дожидается в деструкторе. Никаких terminate.
Правило книги, которое будет действовать до последней главы: по умолчанию используйте std::jthread. std::thread оставьте для случаев, когда вам действительно нужно поведение «terminate, если забыли» (почти никогда) или вы работаете со старым стандартом.
Одна тонкость: join в деструкторе означает, что деструктор может ждать. Если поток не проверяет запрос остановки, деструктор jthread будет ждать вечно. Программа не упадёт, а зависнет, и это тоже плохо, поэтому потоки должны уметь останавливаться (раздел 1.6).
1.5. Передача аргументов: копии, ссылки и ловушки
Аргументы конструктора потока копируются (точнее, проходят decay-copy) во внутреннее хранилище потока в том потоке, который его создаёт. Потом новый поток вызывает функцию, передав ей эти копии как rvalue. Из этого следует несколько неочевидных вещей.


Листинг 1.4 — как аргументы попадают в поток и где они теряются по дороге (ex04_args.cpp)
Ссылки не передаются неявно. Если функция принимает std::vector
Неявные преобразования случаются в новом потоке. Если функция принимает const std::string&, а вы передаёте char*, сам указатель скопируется сразу, а превращение в std::string произойдёт позже, уже внутри нового потока. Если к этому моменту буфер умер (например, функция-создатель вернулась, а поток был отпущен через detach), вы получите строку из мусора. Лечение: конвертируйте явно, до передачи в поток.
Move-only типы перемещаются. std::unique_ptr передаётся через std::move — и это лучший способ отдать потоку владение данными: если поток владеет данными, никто не может уничтожить их раньше времени.
Лямбды с захватом по ссылке — это обещание. Захват [&] читается приятнее всего, но означает ровно то же, что std::ref: «я гарантирую, что всё это переживёт поток». С jthread в той же области видимости обещание выполняется автоматически: деструктор jthread сработает раньше, чем захваченные переменные, объявленные до него. Порядок объявления здесь не случаен — к этому мы вернёмся в задаче 1.3.
1.6. Кооперативная отмена: stop_token, stop_source, stop_callback
Первое желание новичка — «убить поток снаружи». В C++ такой возможности нет, и это правильно. Поток, убитый посреди работы, может держать захваченный мьютекс, недописанный файл, наполовину изменённую структуру данных. После такой операции вся программа находится в неизвестном состоянии.
Поэтому в C++ поток просят остановиться, а он сам решает, в какой точке это безопасно. Механизм состоит из трёх частей:
— std::stop_source — «кнопка». Метод request_stop() нажимает её. Нажать можно один раз, это необратимо.
— std::stop_token — «лампочка», которую выдаёт stop_source через get_token(). Поток спрашивает stop_requested() и узнаёт, нажата ли кнопка. Токены дешёво копируются, и все они смотрят на одну кнопку.
— std::stop_callback — «реакция на нажатие». Регистрирует функцию, которая будет вызвана при запросе остановки.
У каждого jthread есть свой stop_source. Его токен приходит в функцию потока первым параметром, если она его принимает:


Листинг 1.5 — кооперативная отмена (ex05_stop.cpp)
Обратите внимание на три детали.
Проверка флага работает только для потоков, которые не спят. Цикл while (!st.stop_requested()) хорош, пока поток крутится и периодически возвращается к проверке. Но если поток заблокирован в системном вызове (ждёт данные из сокета в recv() или клиента в accept()), он флаг не проверит, пока вызов не вернётся. А вернуться он может через час. Вот тут и нужен stop_callback: в нём можно разбудить блокирующую операцию — закрыть сокет на чтение, отменить ввод-вывод, разбудить condition variable (глава 3). Именно так работает остановка в нашем сервере.
Где выполняется stop_callback. Колбэк вызывается в том потоке, который вызвал request_stop(), а не в потоке, который ждёт. Если на момент регистрации остановка уже запрошена, колбэк выполнится сразу, в конструкторе stop_callback. Деструктор stop_callback подождёт, если колбэк прямо сейчас выполняется в другом потоке. Эта гарантия нам ещё пригодится.
Один stop_source может управлять многими потоками. Во второй части примера два потока получают токены от одного источника и останавливаются одной командой. Это стандартный приём для сервисов: одна кнопка «стоп» на все рабочие потоки.
1.7. Исключения в потоке
Если исключение вылетает из функции потока наружу, вызывается std::terminate. Поймать его в main нельзя: у каждого потока свой стек, и раскрутка стека одного потока не может «перепрыгнуть» в другой.
Значит, исключение нужно перевезти. Для этого есть std::exception_ptr — указатель, который может хранить исключение любого типа:


Листинг 1.6 — исключение, вылетевшее из функции потока, убивает всю программу (ex06_exceptions.cpp)
Внутри потока ловим всё через catch (…), упаковываем через std::current_exception(), а в вызывающем потоке после join() распаковываем через std::rethrow_exception(). Почему после join()? Потому что join() гарантирует, что всё, что сделал поток, видно потоку, который его дождался. Без этой гарантии чтение error в main было бы гонкой данных. Формально это отношение называется happens-before, и в главе 5 мы разберём его досконально.
В главе 7 то же самое за нас будет делать std::future, но полезно один раз сделать это руками, чтобы понимать, что происходит внутри.
1.8. thread_local и hardware_concurrency
Переменная с thread_local существует в отдельной копии для каждого потока. Каждый поток видит и меняет только свою копию, поэтому гонок на ней не бывает:

Листинг 1.7 — у каждого потока своя копия переменной (ex07_thread_local.cpp)
Где это полезно: счётчики и статистика по потокам (сливаем в общий итог редко), кеши и буферы, которые дорого создавать на каждый вызов, генераторы случайных чисел (std::mt19937 не потокобезопасен, а заводить мьютекс ради rand — грустно).
Где это опасно: копия создаётся для каждого потока, и с пулом из 64 потоков «маленький кеш на 10 МБ» внезапно становится 640 МБ. И деструкторы thread_local-объектов выполняются при завершении потока, то есть у отпущенного через detach потока — неизвестно когда.
1.9. Завершение программы при живых потоках
Вот сценарий, который ломает программы, считающиеся рабочими годами. Поток отпущен через detach() и пишет в лог через глобальный объект-логгер. main() возвращает управление. Что происходит дальше:
— возврат из main эквивалентен вызову std::exit();
— std::exit() запускает деструкторы статических и глобальных объектов, в том числе вашего логгера;
— отпущенный поток в это время продолжает работать и вызывает методы уже разрушенного логгера;
— в лучшем случае программа падает при выходе, в худшем — портит данные, которые успела записать на диск.
Самое неприятное: это проявляется не всегда, а только когда поток не успел закончить до выхода. На машине разработчика он успевает. В продакшене под нагрузкой — нет.
Что можно сделать:
— Правильно: все потоки завершаются до выхода из main. Владелец потоков (обычно какой-то сервис) останавливает их через stop_token и дожидается в деструкторе. С jthread это получается само собой.
— Аварийно: std::quick_exit() завершает программу без деструкторов статических объектов (выполняются только функции, зарегистрированные через std::at_quick_exit). Это способ выйти, когда корректная остановка невозможна, а не способ лечить архитектуру.
— Никогда: надеяться, что «поток успеет».
1.10. Грабли с ревью
Список того, что я регулярно встречаю в pull request'ах. Каждый пункт — повод для комментария «Request changes».
— detach() без обоснования. Почти всегда означает «я не знаю, как остановить этот поток».
— std::thread в коде на C++20. Используйте std::jthread, если нет конкретной причины.
— Захват [&] в лямбде потока, который живёт дольше текущей области видимости. Особенно в циклах: счётчик цикла, захваченный по ссылке, — классическая гонка.
— Поток, который нельзя остановить. Бесконечный цикл без проверки stop_token или блокирующий вызов без способа его разбудить.
— Исключения, вылетающие из функции потока. Это std::terminate, а не «обработается где-нибудь».
— sleep_for как способ синхронизации. «Подождём 100 мс, поток наверняка успеет» — не синхронизация, а надежда.
— Создание потока на каждый запрос. Работает на тестах, умирает под нагрузкой.
— Чтение результатов потока без join() или другой синхронизации. Это data race, даже если «там просто int».
1.11. Что спросят на собеседовании
Что произойдёт, если не вызвать join() или detach() у std::thread? (middle) Деструктор вызовет std::terminate. Объяснение «почему» ценится больше, чем сам факт: у деструктора нет безопасного способа решить, ждать или отпустить поток.
Чем std::jthread отличается от std::thread? (middle) Деструктор делает request_stop() и join() вместо terminate; встроенная кооперативная отмена через stop_source/stop_token; автоматическая передача stop_token в функцию потока.
Как остановить поток? (middle) Только кооперативно: поток периодически проверяет stop_token или его будят через stop_callback. Убить поток снаружи в стандартном C++ нельзя, и это правильно. Сильный ответ отдельно разбирает поток, заблокированный в системном вызове.
Почему detach() опасен? (middle) Нельзя узнать, когда поток закончится, а значит, нельзя гарантировать время жизни данных, которые он использует. Плюс проблема разрушения статических объектов при выходе из программы.
Как передать исключение из потока в вызывающий код? (middle) std::exception_ptr через std::current_exception()/std::rethrow_exception(), либо std::promise/std::future, которые делают то же самое.
Как передать в поток аргумент по ссылке и чем это опасно? (junior/middle) std::ref или захват по ссылке в лямбде. Опасно тем, что объект должен пережить поток. Бонус: рассказать про преобразование char* в std::string, которое происходит уже в новом потоке.
Сколько потоков стоит создавать? (senior) Для вычислительной работы — порядка hardware_concurrency(). Для работы с ожиданием I/O больше, но лучше асинхронный I/O (глава 10). Сеньор говорит «зависит от профиля нагрузки» и сразу объясняет, как это измерить.
Что произойдёт, если отпущенный поток работает в момент выхода из main? (senior) exit() разрушит статические объекты, пока поток ими пользуется: UB. Правильное решение — останавливать и дожидаться всех потоков до выхода.
1.12. Сквозной проект shardkv, шаг 1: поток на соединение
Пора писать сервер. Напомню: shardkv — in-memory key-value хранилище с текстовым протоколом в духе Redis. В течение книги оно пройдёт путь от наивной версии до архитектуры, которую не стыдно обсуждать на senior-собеседовании. На каждом шаге мы делаем, проверяем и меряем.
Что делаем на шаге 1:
— скелет проекта: CMake с пресетами debug, release, tsan, asan;
— однопоточное хранилище на std::unordered_map;
— протокол: PING, GET, SET, DEL, SETNX, INCR;
— TCP-сервер на блокирующих сокетах, по потоку std::jthread на каждое соединение;
— корректную остановку: сервер выходит, даже если клиенты подключены и молчат.
Что специально оставляем сломанным: хранилище не защищено от одновременного доступа. Несколько соединений одновременно меняют один unordered_map. Это data race, и в главе 2 мы начнём именно с того, что ThreadSanitizer его найдёт.
Что меряем: ничего. Мерить сломанный код рано.
Как устроена остановка
Это главная многопоточная часть шага. Поток сервера почти всё время висит в accept(), а потоки соединений — в recv(). Проверка stop_requested() в цикле их не разбудит. Поэтому:
— сервер регистрирует stop_callback, который вызывает shutdown() на слушающем сокете. В Linux это заставляет accept() вернуть ошибку, и цикл приёма заканчивается;
— у сервера есть собственный std::stop_source для соединений. Он срабатывает и когда остановку попросили снаружи, и когда accept() сломался сам по себе. Без этого при фатальной ошибке accept сервер вышел бы из цикла и навсегда завис бы в ожидании соединений, которые никто не разбудил;

