Многопоточность в C++: thread, mutex, atomic на практике
Гонка данных даёт неверный результат раз в тысячу запусков и не ловится отладчиком. Разбираем std::thread, mutex и atomic: чем платишь за каждый и когда какой выбрать.

Простейший счётчик, который увеличивают два потока миллион раз каждый, в конце показывает не два миллиона, а что-то вроде 1 683 402. Число меняется от запуска к запуску. Отладчик молчит, санитайзеры без явной сборки тоже. Это классическая гонка данных, и в C++ она не абстрактная страшилка, а поведение, которое стандарт объявляет неопределённым (UB) — компилятор вправе делать с таким кодом что угодно. Разберём три инструмента из <thread>, <mutex> и <atomic>: что каждый умеет, чем за него платишь и когда какой брать. Ориентир — стандарт C++17 и новее, всё компилировалось на GCC 13 и Clang 17 с флагом -std=c++17 -pthread.
Гонка данных: почему счётчик врёт
Операция counter++ — это не одна инструкция, а три: прочитать значение из памяти, прибавить единицу, записать обратно. Когда два потока делают это одновременно, они могут прочитать одно и то же значение, оба прибавить единицу и оба записать — итог теряет один инкремент. Чем чаще потоки пересекаются, тем больше потерь.
#include <thread>
#include <iostream>
int counter = 0; // общий на два потока — источник гонки
void increment() {
for (int i = 0; i < 1'000'000; ++i)
++counter; // читать-прибавить-записать, не атомарно
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join(); // ждём завершения потока
t2.join();
std::cout << counter << '\n'; // почти никогда не 2000000
}
Забыть join() или detach() перед разрушением объекта std::thread — отдельная ошибка: деструктор потока в таком состоянии вызывает std::terminate() и роняет программу. Это не UB, а честный аварийный выход, но новичков он застаёт врасплох.
Как поймать гонку до прода
Соберите проблемный код с ThreadSanitizer: -fsanitize=thread -g. Он инструментирует обращения к памяти и на первом же конфликте печатает оба стека — где читали и где писали. Замедление программы в 5–15 раз, поэтому TSan включают в CI и на нагрузочных прогонах, а не в релизной сборке.
std::mutex: замок на критическую секцию
Мьютекс защищает участок кода, внутри которого одновременно может находиться только один поток. Остальные ждут. Ручной lock()/unlock() опасен: если между ними выбросится исключение, замок не освободится и остальные потоки повиснут навсегда. Поэтому в современном C++ мьютекс почти всегда оборачивают в RAII-хранитель.
#include <thread>
#include <mutex>
#include <iostream>
int counter = 0;
std::mutex mtx;
void increment() {
for (int i = 0; i < 1'000'000; ++i) {
std::lock_guard<std::mutex> guard(mtx); // захват при входе
++counter;
} // guard разрушается — замок отпущен, даже при исключении
}
int main() {
std::thread t1(increment), t2(increment);
t1.join();
t2.join();
std::cout << counter << '\n'; // теперь стабильно 2000000
}
std::lock_guard берёт замок в конструкторе и отпускает в деструкторе. Если нужно отпустить замок раньше конца области видимости или передать его в std::condition_variable — берите std::unique_lock, он гибче, но чуть дороже за счёт хранения флага состояния.
Цена мьютекса: захват без конкуренции стоит десятки наносекунд, но при активной борьбе потоки уходят в системный вызов и засыпают — это уже микросекунды. И главная ловушка — взаимная блокировка (deadlock): поток A держит замок 1 и ждёт замок 2, поток B держит 2 и ждёт 1. Оба висят вечно.
Правило против дедлоков одно и простое: если коду нужно захватить два мьютекса сразу, всегда берите их в одном и том же порядке во всех потоках. Либо используйте std::scoped_lock (C++17), который блокирует несколько мьютексов атомарно и без риска зациклиться.
std::atomic: без замка, но не бесплатно
Для простого счётчика мьютекс — избыточен. std::atomic<int> делает операции чтения-модификации-записи неделимыми на уровне процессора, без засыпания потоков.
#include <thread>
#include <atomic>
#include <iostream>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 1'000'000; ++i)
counter.fetch_add(1, std::memory_order_relaxed); // атомарный +1
}
int main() {
std::thread t1(increment), t2(increment);
t1.join();
t2.join();
std::cout << counter.load() << '\n'; // 2000000
}
Здесь появляется memory_order — модель памяти. Она задаёт, насколько строго компилятор и процессор обязаны упорядочивать операции вокруг атомарной. По умолчанию (memory_order_seq_cst) гарантии максимальные и стоят дороже. Для счётчика, где результат не синхронизирует другие данные, хватает relaxed — самого дешёвого варианта.
Важное ограничение: std::atomic работает атомарно только над одной переменной. Если нужно согласованно менять два поля структуры, atomic не спасёт — здесь снова мьютекс. Проверить, реализован ли конкретный тип без внутреннего замка, можно через counter.is_lock_free() в рантайме или std::atomic<int>::is_always_lock_free на этапе компиляции.
Что выбирать
| Ситуация | Инструмент | Чем платите |
|---|---|---|
| Счётчик, флаг, одна переменная | std::atomic | Ограничение на одну переменную, порог входа в модель памяти |
| Изменение нескольких связанных полей | std::mutex + lock_guard | Десятки нс без конкуренции, микросекунды при борьбе |
| Захват двух мьютексов сразу | std::scoped_lock | Защита от дедлока ценой чуть большего кода |
| Ожидание события/условия | std::unique_lock + condition_variable | Сложнее в отладке, нужно проверять предикат в цикле |
| Однократная инициализация из многих потоков | std::call_once + std::once_flag | Минимальные, снимает ручную двойную проверку |
Практическое правило: начинайте с мьютекса. Он понятнее, а ошибка в нём проявляется как повисший поток, который видно. К atomic и тонкой настройке memory_order переходите, когда профилировщик показал, что именно захват замка стал узким местом. Преждевременная оптимизация на relaxed-порядках даёт код, который работает у вас и падает на другой архитектуре.
Типичные ошибки, которые видно в ревью
- Ручные
lock()/unlock()без RAII — замечание: «оберни в lock_guard, иначе исключение оставит замок захваченным». - Разный порядок захвата двух мьютексов в разных функциях — потенциальный дедлок, замечание: «зафиксируй порядок или возьми scoped_lock».
- Обычная переменная-флаг для остановки потока без
atomicи без мьютекса — компилятор вправе закешировать её в регистре, поток не увидит изменения. - Проверка условия у
condition_variableчерезifвместо циклаwhile— ложные пробуждения проснут поток раньше времени.
Что делать дальше
Соберите свой многопоточный код с -fsanitize=thread и прогоните нагрузочный тест — TSan найдёт гонки, которые не воспроизводятся вручную. Дальше посмотрите на уровни абстракции повыше: std::async и std::future для задач с результатом, std::jthread (C++20) с автоматическим join() в деструкторе и встроенным stop_token вместо самодельного флага остановки. Разбор memory_order по всем шести значениям и построение lock-free структур — отдельная большая тема, за деталями идите в раздел стандарта про модель памяти.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: cppreference — Concurrency support library, cppreference — std::memory_order
Частые вопросы
А если у меня старый компилятор без C++17?
Почему мой код с atomic всё равно даёт неверный результат?
Нужно ли всегда указывать memory_order?
Что будет, если забыть join() перед разрушением потока?
Как поймать гонку, которая проявляется раз в тысячу запусков?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.