
Полная версия:
Многопоточность на C++ через практику и замеры
— Глобальный порядок захвата. Все мьютексы упорядочены (например, по идентификатору объекта), и любой код захватывает их только по возрастанию. Тогда цикл невозможен. Работает, даже когда мьютексы захватываются в разных функциях в разное время, но требует дисциплины.
— Иерархия блокировок. Каждому мьютексу назначается уровень, и поток, держащий мьютекс уровня N, может захватывать только мьютексы уровня ниже N. Нарушение сразу видно, а не «когда-нибудь под нагрузкой». Такой мьютекс вы напишете в задаче 2.3.
— Не держать два мьютекса одновременно. Самый надёжный способ. Если архитектура позволяет — лучшее решение.
Отдельно про вызов merge(a, a): попытка захватить std::mutex, который уже держит текущий поток, — это UB (на практике обычно дедлок). Поэтому в merge есть проверка на один и тот же объект.
2.6. shared_mutex: много читателей, редкий писатель
Если данные в основном читают и редко меняют, обычный мьютекс заставляет читателей ждать друг друга, хотя читать одновременно безопасно. std::shared_mutex позволяет захватить его двумя способами:
— разделяемо (std::shared_lock) — сколько угодно читателей одновременно;
— эксклюзивно (std::unique_lock или std::lock_guard) — один писатель, читателей нет.


Листинг 2.8 — много читателей, редкий писатель (ex06_shared_mutex.cpp)
Звучит как бесплатное ускорение, но это не так:
— shared_mutex дороже обычного: даже разделяемый захват меняет общий счётчик читателей, и при большом числе ядер этот счётчик сам становится горячей точкой (почему — в главе 4);
— если критическая секция короткая (поиск в хеш-таблице), накладные расходы shared_mutex могут съесть весь выигрыш;
— поведение при постоянном потоке читателей зависит от реализации: писатель может долго ждать.
Правило: shared_mutex выигрывает, когда чтение долгое, читателей много, а записи редкие. Во всех остальных случаях — меряйте. В главе 4 мы сделаем это на shardkv.
2.7. recursive_mutex — запах кода
std::recursive_mutex позволяет одному потоку захватить мьютекс несколько раз (и отпустить столько же раз). Обычно его берут, когда публичный метод под локом вызывает другой публичный метод, который тоже берёт лок.
Это почти всегда признак плохой структуры: класс не понимает, в каком состоянии его инварианты в момент вызова. Если метод A захватил мьютекс и временно нарушил инвариант, а потом вызвал метод B, рассчитывающий на целый инвариант, recursive_mutex этот баг не исправит, а спрячет.
Правильное решение: разделить методы на публичные (берут лок) и приватные (предполагают, что лок уже взят, часто с суффиксом _locked). Публичные вызывают приватные, приватные никогда не вызывают публичные.
2.8. Таймауты: timed_mutex и правильные часы
Иногда ждать мьютекс вечно нельзя: лучше сообщить «занято» и попробовать позже. Для этого есть std::timed_mutex с методами try_lock_for и try_lock_until:



Листинг 2.9 — захват с таймаутом и однократная инициализация (ex07_timeouts_once.cpp)
Две важные детали.
Используйте steady_clock для таймаутов. std::chrono::system_clock — это «настенные часы»: их может перевести NTP, администратор или переход на летнее время. Таймаут «до 12:00:05» превратится в час ожидания, если часы откатили назад. steady_clock монотонны и не прыгают. try_lock_for с длительностью сам использует steady_clock, а вот в try_lock_until вы передаёте момент времени, и часы выбираете сами — выбирайте steady_clock.
Таймаут на мьютексе — часто симптом. Если поток не может дождаться мьютекса за разумное время, обычно проблема в том, что кто-то держит его слишком долго. Таймаут — способ деградировать, а не способ лечить.
И практическая ремарка про инструменты. В некоторых связках GCC и libtsan этот пример вызывает ложное срабатывание TSan: «unlock of an unlocked mutex». Причина: try_lock_for в libstdc++ вызывает pthread_mutex_clocklock, а libtsan этот вызов не перехватывает и не знает, что мьютекс был захвачен. Это ошибка инструмента, а не кода. Лечится обновлением тулчейна или файлом подавлений:

Листинг 2.10 — файл подавлений TSan (tsan.supp)

Листинг 2.11 — запуск с файлом подавлений
Подавления — это последнее средство: каждая строка в таком файле должна иметь комментарий, почему это ложное срабатывание. Подробнее о работе с TSan в CI поговорим в главе 13.
2.9. Однократная инициализация
Ленивая инициализация («создадим объект при первом обращении») — классическое место для гонок. Два потока одновременно видят, что объекта ещё нет, и оба его создают.
С C++11 есть два правильных способа (оба в примере выше):
— локальная статическая переменная. Стандарт гарантирует, что её инициализация выполнится ровно один раз, даже если функцию вызывают из многих потоков одновременно; остальные потоки подождут. Это называют «magic statics». Самый простой способ, и почти всегда достаточный;
— std::call_once с std::once_flag — когда инициализация не привязана к одной функции, или флаг должен быть членом класса.
И отдельно о том, чего делать не надо: «блокировка с двойной проверкой» (double-checked locking) на обычном указателе. Это data race на чтении указателя без лока. Правильная версия требует атомиков, и мы разберём её в главе 5. А пока: magic statics.
2.10. Что потокобезопасно в стандартной библиотеке
Общее правило стандарта: конкурентный вызов const-методов одного объекта безопасен, если тип не говорит обратного; конкурентная запись или запись одновременно с чтением — гонка. Разные объекты можно использовать из разных потоков свободно.


Листинг 2.12 — что в стандартной библиотеке потокобезопасно, а что притворяется (ex08_stdlib.cpp)
Конкретно:
— Контейнеры. Читать один std::vector или std::map из многих потоков можно, если никто в это время не пишет. Осторожно с «читающими» методами, которые на самом деле пишут: operator[] у std::map вставляет элемент, если ключа нет.
— std::cout и другие стандартные потоки. Data race не будет, но вывод разных потоков может перемешаться посимвольно. std::osyncstream (C++20) накапливает вывод и сбрасывает его одним куском.
— rand(). Использует скрытое глобальное состояние, не потокобезопасен. Используйте генератор из
— localtime(), gmtime(), strtok(), setlocale(), getenv() при одновременном setenv(). Работают с глобальным или статическим состоянием. Для времени используйте std::chrono и std::format, вместо strtok — std::string_view и std::views::split.
— std::shared_ptr. Тут хитро: счётчик ссылок атомарный, а объект и сам указатель — нет. Это отдельный большой разговор в главе 5.
2.11. ThreadSanitizer: первый запуск
ThreadSanitizer (TSan) — инструмент, встроенный в GCC и Clang, который находит data race во время выполнения программы. Он отслеживает каждое обращение к памяти и каждую синхронизацию и сообщает, если два обращения не упорядочены через happens-before.
Как включить:

Листинг 2.13 — сборка и запуск под TSan вручную
В нашем проекте это пресет tsan:

Листинг 2.14 — пресет tsan: сборка и десять прогонов тестов
Как читать отчёт:
— WARNING: ThreadSanitizer: data race — тип проблемы;
— Write of size 8… by thread T2 и стек вызовов — первое обращение;
— Previous read/write… by thread T1 и стек — второе, конфликтующее обращение;
— Location is … — где находится память (глобальная переменная, куча, стек какого потока);
— Thread T2 created at — где был создан каждый поток.
Что нужно знать про TSan:
— он находит только гонки, которые реально произошли в этом запуске. Если код с гонкой не выполнился, TSan промолчит. Поэтому тесты под TSan гоняют многократно и под нагрузкой;
— он не находит race condition без data race (как WalletBad);
— замедляет программу в 5–15 раз и увеличивает потребление памяти в 5–10 раз — в продакшен его не ставят, только в тесты и CI;
— TSan нельзя совмещать с ASan в одной сборке, поэтому у нас два отдельных пресета;
— ложные срабатывания редки, но бывают (раздел 2.8). Ложным срабатывание можно считать только после того, как вы можете объяснить, какая синхронизация его исключает.
2.12. Грабли с ревью
— «Это просто bool/int, гонка тут безвредна». Безвредных data race не бывает, бывает UB, которое ещё не выстрелило.
— Мьютекс, который защищает функцию, а не данные. Те же данные меняются в другом месте без лока.
— Ссылка или указатель на данные, вынесенные из-под лока.
— Пары «проверка + действие» через два публичных вызова. empty() + pop(), contains() + insert(), get() + set().
— Вызов колбэков, виртуальных функций или чужого кода под локом.
— Захват двух мьютексов по очереди через два lock_guard. Используйте std::scoped_lock.
— Тяжёлая работа под локом: логирование, сетевые вызовы, аллокации, которые можно вынести.
— recursive_mutex «чтобы не думать».
— Таймауты на system_clock.
— Ручные m.lock()/m.unlock() вне RAII-обёрток.
2.13. Что спросят на собеседовании
Что такое data race и чем он отличается от race condition? (middle) Data race — несинхронизированный конкурентный доступ к одной памяти, хотя бы один из которых запись; по стандарту UB. Race condition — логическая зависимость результата от порядка выполнения; может быть и без data race. Хороший ответ приводит пример второго без первого.
Почему ++counter из двух потоков — это не просто «потеря инкрементов»? (middle/senior) Это UB: компилятор оптимизирует код в предположении, что гонок нет, и может, например, вынести чтение из цикла. Потеря инкрементов — лишь одно из проявлений.
Чем lock_guard отличается от unique_lock и scoped_lock? (junior/middle) lock_guard — простейший RAII; unique_lock — гибкий (отложенный захват, ранний unlock, таймауты, передача владения, нужен для condition_variable); scoped_lock — несколько мьютексов без дедлока.
Назовите условия дедлока и способы его избежать. (middle) Четыре условия Коффмана; на практике ломаем циклическое ожидание: std::scoped_lock, глобальный порядок захвата, иерархия блокировок, отказ от удержания двух мьютексов.
Почему интерфейс стека с empty(), top(), pop() плох в многопоточном коде? (middle) Последовательность вызовов не атомарна, между ними другой поток меняет состояние. Решение — try_pop(), возвращающий std::optional
Когда shared_mutex медленнее обычного mutex? (senior) При коротких критических секциях и большом числе ядер: разделяемый захват всё равно пишет в общий счётчик, кеш-линия с ним прыгает между ядрами. Сильный ответ: «меряю, а не угадываю».
Потокобезопасна ли инициализация локальной статической переменной? (middle) Да, с C++11 (magic statics). Бонус: рассказать, почему double-checked locking на обычном указателе — data race.
Можно ли читать std::vector из нескольких потоков одновременно? (junior/middle) Да, через const-методы, если никто не пишет. Бонус: operator[] у std::map пишет.
2.14. Сквозной проект shardkv, шаг 2: глобальный мьютекс и первый бенчмарк
Что делаем:
— убеждаемся, что TSan видит гонку шага 1 (мы это сделали в конце главы 1);
— делаем Storage потокобезопасным: один мьютекс на всё хранилище;
— INCR и SETNX выполняются внутри Storage под одним захватом — проверка и изменение не разделены;
— добавляем стресс-тесты под TSan;
— пишем первую версию нагрузочного клиента kvbench и впервые меряем сервер.
Что проверяем:
— 8 потоков по 5000 INCR дают ровно 40 000;
— 8 потоков одновременно делают SETNX одного ключа — ровно один выигрывает, 200 раундов подряд;
— 6 клиентов через настоящий TCP-сервер делают INCR — ни один не потерян;
— всё это под TSan и ASan, 10 прогонов подряд.
Что меряем: пропускную способность сервера в зависимости от числа клиентов.
Новое хранилище



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


Листинг 2.16 — реализация (storage.cpp)
Обратите внимание на три приёма из этой главы:
— мьютекс mutable, потому что захватывается и в const-методах;
— строки ключа и значения создаются до захвата лока, чтобы аллокации не выполнялись в критической секции;
— get() возвращает копию, а не ссылку.
В server.cpp из шага 1 меняется только комментарий над вызовом execute: гонки больше нет.
Стресс-тесты



Листинг 2.17 — тесты (test_storage_stress.cpp)
В тесте SetnxHasExactlyOneWinner используется std::atomic
kvbench, версия 1




Листинг 2.18 — нагрузочный клиент shardkv (kvbench.cpp)
Каждый клиент — отдельный поток со своим соединением. Обратите внимание: клиенты пишут статистику каждый в свою ячейку вектора, а читаем мы её только после join всех потоков. Общих изменяемых данных нет, синхронизация не нужна. Это лучший вид синхронизации.
Первые замеры
Сборка release, сервер и kvbench на одной машине, режим mix (90% GET, 10% SET), 20 000 запросов на клиента:

Листинг 2.19 — сборка release и запуск сервера
Результаты на моей машине:

Листинг 2.20 — kvbench версии 1: пропускная способность и средняя задержка
Что видно:
— до 4 клиентов пропускная способность растёт;
— дальше начинается плато, а на 16 клиентах она падает;
— задержка растёт быстрее, чем пропускная способность: с 8 до 16 клиентов задержка выросла почти втрое, а ops/s упали.
Честная оговорка, без которой замеры ничего не стоят: эти цифры сняты в контейнере с двумя ядрами, где сервер и kvbench делят одни и те же два ядра. На такой машине плато наступает уже из-за нехватки CPU, и отделить его от конкуренции за мьютекс по этим цифрам нельзя. Прогоните бенчмарк на своей машине с 8+ ядрами: там картина «пропускная способность упёрлась, хотя ядра простаивают» будет видна отчётливо. Как доказать, что виноват именно лок, а не CPU, мы научимся в главе 13 (off-CPU профилирование). А в главе 4 уберём глобальный мьютекс и сравним цифры.
Сохраните свои результаты в bench/results/step02.txt. Вся книга построена на сравнении «было/стало», и без «было» сравнивать будет не с чем.
Полный код шага 2
Остальные файлы не изменились по сравнению с шагом 1, кроме CMakeLists.txt (добавлены kvbench и стресс-тесты):



Листинг 2.21 — файл сборки CMake (CMakeLists.txt)
Задачки для разминки мозгов
Напоминаю правило самопроверки: решение засчитывается, только если тесты проходят под TSan и ASan десять раз подряд.
Задача 2.1 (лёгкая). Банк, который зависает


Листинг 2.22 — код к задаче 2.1 (task2_1_bank.cpp)
Эта программа зависает (у меня — каждый раз). Найдите дедлок и опишите конкретную последовательность событий, которая к нему приводит. Исправьте функцию transfer двумя способами:
1. через std::scoped_lock;
2. через захват мьютексов в порядке возрастания id счёта.
Объясните, почему оба способа корректны. Отдельно подумайте: что должно происходить при transfer(alice, alice, 10)?
Проверка: программа завершается за секунды, итоговая сумма денег в банке равна 2 000 000; TSan чист; перевод самому себе не приводит ни к дедлоку, ни к UB.
Задача 2.2 (средняя). ThreadSafeStack без гонок в интерфейсе
Напишите шаблон ThreadSafeStack

Листинг 2.23 — интерфейс ThreadSafeStack
Требования:
— никаких методов empty() + top() + pop(), которые нужно вызывать связкой;
— исключение в конструкторе копирования или перемещения T не должно оставить стек в некорректном состоянии и не должно потерять элемент;
— нельзя возвращать ссылки на внутренние данные.
Напишите стресс-тест: 4 потока кладут по 100 000 уникальных чисел, 4 потока одновременно забирают, пока не заберут все. Каждое число должно быть извлечено ровно один раз.
Проверка: стресс-тест проходит под TSan 10 раз подряд; вы можете объяснить, почему size() в многопоточном коде годится только для статистики.
Задача 2.3 (сложная). hierarchical_mutex
Напишите класс hierarchical_mutex: мьютекс с числовым уровнем. Правило: поток, который держит мьютекс уровня N, может захватить только мьютекс с уровнем строго меньше N. Попытка нарушить порядок бросает std::logic_error сразу, до захвата, а не приводит к дедлоку когда-нибудь потом.
Требования:
— методы lock(), unlock(), try_lock(), чтобы класс работал с std::lock_guard и std::unique_lock;
— текущий уровень потока хранится в thread_local переменной;
— после unlock() уровень потока восстанавливается до предыдущего (поток может держать несколько мьютексов по иерархии);
— подумайте, что делать, если мьютексы отпускают не в обратном порядке захвата.
Проверка: тесты на захват по правильной иерархии; на исключение при нарушении порядка; на корректное восстановление уровня после цепочки lock/unlock; тест с несколькими потоками под TSan.
Решения и разбор ваших неизбежных ошибок — в главе «Ответы к задачам» в конце книги.
Глава 3. Condition variables и потокобезопасная очередь
«Добавил sleep(100), и баг пропал». Баг не пропал. Он ушёл в отпуск и вернётся в пятницу.
Мьютекс отвечает на вопрос «как не мешать друг другу». Но потокам нужно ещё и ждать друг друга: потребитель ждёт, пока в очереди появится задача; фоновый поток ждёт ближайшего дедлайна; главный поток ждёт, пока загрузится конфиг. Делать это через while (!ready) sleep_for(…) — путь в никуда: либо реакция запаздывает на время сна, либо CPU сгорает на холостых проверках.
Правильный инструмент — condition variable (условная переменная). У неё самая коварная семантика из всех примитивов стандартной библиотеки: ошибиться в ней можно тремя разными способами, и все три проявляются раз в сто тысяч запусков. Сегодня мы разберём все три, напишем блокирующую очередь, которая не теряет элементы и умеет закрываться, и научим shardkv удалять ключи по времени жизни.
К концу главы вы:
— поймёте, почему condition variable всегда используется вместе с мьютексом и предикатом;
— научитесь распознавать spurious wakeup и lost wakeup;
— разберётесь, когда звать notify_one, когда notify_all и почему уведомлять лучше после освобождения лока;
— научитесь ждать с таймаутом и дедлайном и узнаете, почему дедлайн лучше;
— освоите condition_variable_any вместе с stop_token;
— напишете блокирующую очередь с закрытием и корректной остановкой;

