
Полная версия:
Многопоточность на C++ через практику и замеры
— каждое соединение регистрирует свой stop_callback, который делает shutdown() своего сокета. recv() возвращает 0, цикл чтения заканчивается;
— объекты Connection хранят потоки соединений, и при выходе из run() все они дожидаются через деструкторы jthread.
Отдельно посмотрите на порядок объявления в serve(): stop_callback объявлен после сокета, а значит, разрушается раньше. Деструктор stop_callback подождёт, если колбэк выполняется в другом потоке. Поэтому shutdown() никогда не попадёт в уже закрытый дескриптор.
Ещё одна деталь: сервер убирает отработавшие соединения из списка (reap_finished). Флаг done читается из потока сервера и пишется из потока соединения, поэтому он std::atomic с порядками release/acquire. Что это значит, мы подробно разберём в главе 5, а пока примите как данность: без атомика это была бы ещё одна гонка.
Ключевой код
Заголовок сервера:


Листинг 1.8 — TCP-сервер shardkv, шаг 1 (server.hpp)
Реализация:




Листинг 1.9 — реализация (server.cpp)
Точка входа. Обработку SIGTERM мы добавим в главе 4, пока сервер останавливается по Enter:


Листинг 1.10 — точка входа shardkv, шаг 1 (main.cpp)
Сборка, запуск, проверка
Сборка и тесты под ThreadSanitizer, десять прогонов подряд:

Листинг 1.11 — сборка и тесты под TSan, десять прогонов подряд
Тесты проверяют протокол и две вещи про потоки: сервер отвечает и корректно останавливается, а остановка будит соединения, которые подключились и молчат. Если остановка сломана, тест не упадёт, а повиснет, поэтому у тестов стоит таймаут.
Запуск руками:

Листинг 1.12 — ручная проверка сервера через nc
Тизер главы 2
Теперь запустим сервер под TSan и подключим четыре клиента, которые одновременно делают INCR одного ключа:

Листинг 1.13 — четыре клиента одновременно делают INCR
В логе сервера появится:

Листинг 1.14 — отчёт TSan в логе сервера
Это не ложное срабатывание. Четыре потока одновременно меняют один unordered_map. В release-сборке без санитайзера такой код может терять инкременты, возвращать мусор или упасть посреди перехеширования таблицы. Как это чинить и почему «оно же почти всегда работает» — не аргумент, разберём в следующей главе.
Полный код шага 1
Для тех, кто собирает проект с нуля, — остальные файлы. В них нет ничего многопоточного: хранилище, протокол и RAII-обёртка над сокетами.


Листинг 1.15 — хранилище shardkv, шаг 1 (storage.hpp)

Листинг 1.16 — реализация (storage.cpp)

Листинг 1.17 — текстовый протокол shardkv (protocol.hpp)



Листинг 1.18 — реализация (protocol.cpp)



Листинг 1.19 — минимальная RAII-обёртка над POSIX-сокетами (socket.hpp)




Листинг 1.20 — реализация (socket.cpp)


Листинг 1.21 — файл сборки CMake (CMakeLists.txt)


Листинг 1.22 — пресеты сборки CMake (CMakePresets.json)


Листинг 1.23 — тесты (test_protocol.cpp)



Листинг 1.24 — тесты (test_server.cpp)
Задачки для разминки мозгов
Правило самопроверки для всех задач книги: решение засчитывается, только если тесты проходят под TSan и ASan десять раз подряд (ctest --repeat until-fail:10). Многопоточный тест, прошедший один раз, ничего не доказывает.
Задача 1.1 (лёгкая). Найди три бага
В этом коде три бага. Код компилируется без предупреждений. Иногда даже печатает то, что нужно.

Листинг 1.25 — код к задаче 1.1 (task1_1_bugs.cpp)
Найдите все три бага. Для каждого объясните в одной фразе, что именно сломается и при каких условиях. Затем перепишите код на std::jthread так, чтобы он был корректным, а log_async по-прежнему не блокировал вызывающий код.
Проверка: исправленный код чист под ASan и TSan; объяснение каждого бага укладывается в одну фразу и называет конкретную причину (не «потому что многопоточность»).
Задача 1.2 (средняя). parallel_sum с доставкой исключений
Реализуйте функцию:

Листинг 1.26 — сигнатура функции parallel_sum
Функция применяет transform к каждому элементу и возвращает сумму результатов. Работа делится на threads примерно равных частей, каждая обрабатывается в своём потоке. Каждый поток пишет результат в свою ячейку, без общего счётчика. Если transform бросает исключение в любом потоке, parallel_sum дожидается всех потоков и бросает это исключение вызывающему коду.
Учтите граничные случаи: пустой data, threads == 0, threads больше, чем элементов.
Проверка: результат совпадает с std::transform_reduce на случайных данных; тест, в котором transform бросает на одном элементе, получает именно это исключение; ни один поток не остаётся работать после выхода из функции.
Задача 1.3 (сложная). Сервис, который нельзя сломать деструктором
Напишите класс TickService:
— конструктор принимает число воркеров N и запускает N потоков;
— каждый воркер увеличивает свою ячейку в std::vector
— метод stop_and_total() останавливает всех воркеров и возвращает сумму ячеек;
— деструктор корректно останавливает воркеров, даже если stop_and_total() не вызывался;
— для остановки используется один общий std::stop_source.
Главное требование: класс должен быть корректным при любом сценарии использования, включая «создали и сразу уничтожили». Подсказка: обратите внимание, в каком порядке разрушаются члены класса и что из этого следует для потоков, которые к ним обращаются. Объясните, почему ваш порядок членов правильный, а обратный — нет.
Проверка: тест «создать и сразу уничтожить» 1000 раз подряд проходит под ASan и TSan; тест stop_and_total() возвращает ненулевую сумму после короткой работы; повторный вызов stop_and_total() не ломает объект.
Решения и разбор ваших неизбежных ошибок — в главе «Ответы к задачам» в конце книги.
Глава 2. Data race, мьютексы и тот самый deadlock
«Там же просто int, что может пойти не так?» — надпись на надгробии одного сервиса.
В прошлой главе мы оставили shardkv с бомбой внутри: несколько потоков одновременно меняют один std::unordered_map. На тестах всё работает. Под четырьмя клиентами ThreadSanitizer кричит «data race». Сегодня мы разберёмся, почему это не «предупреждение, которое можно игнорировать», а неопределённое поведение, и научимся его лечить.
Лекарство — мьютексы. У лекарства есть побочные эффекты: дедлоки, медленный код и интерфейсы, которые потокобезопасны по отдельности и сломаны вместе. Их мы тоже разберём.
К концу главы вы:
— будете отличать data race от race condition (на собеседованиях их путают постоянно);
— научитесь выбирать между lock_guard, unique_lock, scoped_lock и shared_lock;
— поймёте, почему защищать нужно данные и инварианты, а не строчки кода;
— научитесь проектировать интерфейс, в котором нельзя устроить гонку снаружи;
— узнаете четыре условия дедлока и как сломать хотя бы одно;
— выясните, что в стандартной библиотеке потокобезопасно, а что только притворяется;
— сделаете хранилище shardkv потокобезопасным и впервые измерите сервер нагрузкой.
2.1. Data race: почему это UB, а не «иногда не то значение»
Начнём с самой короткой гонки в мире:


Листинг 2.1 — самая короткая гонка данных в мире и её последствия (ex01_race.cpp)
Четыре потока по миллиону раз делают ++counter. Ожидаем 4 000 000. На моей машине в сборке -O0 получилось 2 176 855. Почти половина инкрементов исчезла.
Почему? ++counter — это не одна операция, а три: прочитать значение из памяти в регистр, увеличить регистр, записать обратно. Два потока читают 41, оба увеличивают до 42, оба записывают 42. Один инкремент потерян.
Но объяснение «операции перемешались» — это только половина правды, и, честно говоря, менее страшная половина. Стандарт C++ определяет data race так: два обращения к одной ячейке памяти из разных потоков, хотя бы одно из них — запись, хотя бы одно не атомарное, и между ними нет отношения happens-before (то есть никакой синхронизации). И дальше стандарт говорит: программа с data race имеет неопределённое поведение.
Не «может вернуть неправильное значение». Неопределённое поведение. Компилятор оптимизирует код, исходя из предположения, что гонок нет. Он может держать переменную в регистре и никогда не перечитывать её из памяти. Может свернуть цикл в одну запись. Может переставить операции местами. Поэтому с -O2 этот же пример иногда выдаёт ровно 4 000 000: компилятор превратил цикл в counter += 1000000, и окно для гонки стало крошечным. Это не исправление. Это UB, которому сегодня повезло.
Классический пример того, как это стреляет:

Листинг 2.2 — гонка на обычном bool
Компилятор видит цикл, в котором ready не меняется (по его мнению, ведь гонок нет), выносит чтение из цикла, и поток 1 зависает навсегда. На -O0 работает, на -O2 висит. Отлаживать такое в продакшене — отдельный вид удовольствия.
Вывод: data race — это не «маленькая неточность», а ошибка того же класса, что выход за границу массива. Её не терпят «потому что значение не критичное». Её исправляют.
Data race и race condition — разные вещи
— Data race — формальное понятие стандарта: несинхронизированный конкурентный доступ с записью. Всегда UB. Ловится ThreadSanitizer.
— Race condition — логическая ошибка: результат зависит от того, в каком порядке выполнились потоки, и некоторые порядки дают неправильный результат. Может существовать без всякого data race: все обращения под мьютексом, а логика всё равно сломана. TSan такие ошибки не ловит.
Пример race condition без data race будет в разделе 2.4. Запомните разницу сейчас: на собеседовании вас о ней спросят.
2.2. std::mutex и RAII-обёртки
Мьютекс (mutual exclusion) — объект, который в каждый момент может быть захвачен только одним потоком. Остальные, кто пытается его захватить, ждут. Код между захватом и освобождением называется критической секцией: в ней в каждый момент находится не больше одного потока.


Листинг 2.3 — тот же счётчик, но честный (ex02_mutex.cpp)
Теперь результат всегда 4 000 000. Мьютекс даёт сразу две гарантии, и вторая важнее, чем кажется:
— взаимное исключение: в критической секции один поток;
— видимость: всё, что поток записал до unlock(), гарантированно увидит поток, который после этого сделал lock(). Освобождение мьютекса синхронизируется с его следующим захватом. Без этой гарантии второй поток мог бы прочитать устаревшее значение из кеша процессора или регистра. Формально это то самое отношение happens-before, разберём его в главе 5.
Руками вызывать m.lock() и m.unlock() не нужно: если между ними вылетит исключение или кто-то добавит ранний return, мьютекс останется захваченным навсегда, и следующий поток повиснет. Для этого есть RAII-обёртки:
— std::lock_guard — захватывает в конструкторе, освобождает в деструкторе. Ничего больше не умеет, и это хорошо. Выбор по умолчанию для одного мьютекса.
— std::unique_lock — может отпустить мьютекс раньше конца области видимости, захватить позже (std::defer_lock), попробовать захватить с таймаутом, передать владение. Нужен для condition_variable (глава 3). Чуть дороже lock_guard: хранит флаг владения.
— std::scoped_lock (C++17) — захватывает один или несколько мьютексов сразу, без риска дедлока. Для одного мьютекса работает как lock_guard, поэтому многие используют его везде.
— std::shared_lock — разделяемый захват shared_mutex (раздел 2.6).
Благодаря CTAD (вывод аргументов шаблона класса) тип мьютекса писать не нужно: std::lock_guard lock(m) вместо std::lock_guard
Сколько стоит мьютекс
Незахваченный мьютекс в Linux стоит очень дёшево: одна атомарная операция на захват и одна на освобождение, без системных вызовов (как это устроено через futex, разберём в главе 4). Дорогим мьютекс становится при конкуренции (contention): когда потоки постоянно сталкиваются на нём, они уходят спать в ядро, просыпаются, переключают контекст. Отсюда главное правило:
Держите мьютекс как можно меньше. Всё, что можно сделать до захвата (аллокации, вычисления, разбор строк) или после освобождения (логирование, отправка по сети), делайте снаружи критической секции. В хранилище shardkv мы так и сделаем: строки для ключа и значения создаются до захвата лока.
2.3. Защищайте данные, а не код
Типичная ошибка новичка: «вот эта функция вызывается из нескольких потоков, обложу-ка её мьютексом». Мьютекс защищает не функцию, а данные и инварианты. Если те же данные меняются в другой функции без того же мьютекса, защита первой бессмысленна.
Правило: у каждого общего изменяемого объекта должен быть ровно один мьютекс, который его охраняет, и это должно быть очевидно из кода. Лучший способ сделать это очевидным — не давать доступа к данным без мьютекса вообще:



Листинг 2.4 — защищаем данные, а не код (ex03_synchronized.cpp)
Synchronized
Но и тут есть дыра: если функция вернёт ссылку или указатель на защищённые данные, этот указатель будет жить после освобождения мьютекса. Любое обращение через него — data race. Правило: из-под лока выносят копии, а не ссылки. То же относится к хранилищу shardkv: get() возвращает копию строки, а не const std::string&.
Ещё одна дыра — вызов пользовательского кода под локом. Если вы под мьютексом вызываете колбэк, а колбэк вызывает метод того же объекта, который захватывает тот же мьютекс, это дедлок (std::mutex нерекурсивен). А если колбэк захватывает другой мьютекс, вы получаете неконтролируемый порядок захвата (раздел 2.5). Правило: не вызывайте под локом код, которого не контролируете.
2.4. Потокобезопасный интерфейс: когда каждый метод безопасен, а класс сломан
Вот обещанный пример race condition без data race:



Листинг 2.5 — каждый метод потокобезопасен, а класс всё равно сломан (ex04_check_then_act.cpp)
У WalletBad каждый метод захватывает мьютекс. Data race нет, TSan промолчит. Но код «проверь баланс, потом спиши» состоит из двух операций, и между ними другой поток успевает списать деньги. Восемь покупателей одновременно видят баланс 100, и каждый списывает 30. Итог −140.
Это ошибка проектирования интерфейса, а не реализации. Мьютекс внутри методов не может защитить последовательность вызовов снаружи. Решение — сделать операцию, которую нужно выполнить атомарно, одним методом: try_withdraw проверяет и списывает под одним захватом.
Классический пример того же класса ошибок — стек или очередь с методами empty(), top() и pop():

Листинг 2.6 — проверка и действие по отдельности не атомарны
Каждый метод может быть потокобезопасным, а связка всё равно сломана. Правильный интерфейс объединяет проверку и действие: std::optional
Признаки сломанного потокобезопасного интерфейса на ревью:
— методы-«вопросы» (empty, size, contains, count), результат которых используется для принятия решения;
— методы, возвращающие ссылки на внутренние данные;
— необходимость вызвать два метода подряд, чтобы выполнить одно логическое действие.
В shardkv по этой причине INCR и SETNX реализованы внутри хранилища под одним захватом, а не через get() + set() в обработчике протокола.
2.5. Deadlock: четыре условия и как сломать хотя бы одно
Дедлок — ситуация, когда потоки ждут друг друга по кругу и никто не может продвинуться. Программа не падает, не пишет ошибок, не жрёт CPU. Она просто стоит. Мониторинг в это время показывает, что «всё зелёное», потому что процесс жив.
Дедлок возможен, только если одновременно выполнены четыре условия Коффмана:
1. Взаимное исключение: ресурс может держать только один поток.
2. Удержание и ожидание: поток держит один ресурс и ждёт другой.
3. Нет принудительного освобождения: ресурс нельзя отобрать, его можно только отдать.
4. Циклическое ожидание: A ждёт B, B ждёт A (или длиннее цикл).
Первые три для мьютексов выполнены всегда, по определению. Значит, бороться можно только с четвёртым: не допускать циклов.


Листинг 2.7 — два мьютекса, два потока, вечность ожидания (ex05_deadlock.cpp)
Функция merge_bad захватывает мьютексы в порядке аргументов. Поток 1 вызывает merge_bad(a, b), поток 2 — merge_bad(b, a). Поток 1 захватил a и ждёт b, поток 2 захватил b и ждёт a. Цикл.
Способы не допустить цикла:
— std::scoped_lock с несколькими мьютексами (или std::lock). Внутри используется алгоритм, который захватывает мьютексы так, чтобы дедлок был невозможен при любом порядке аргументов: если не удаётся захватить все, он отпускает уже захваченные и пробует снова. Самый простой и надёжный способ, когда все нужные мьютексы известны заранее.

