Многопоточность в C++: рабочие приёмы без гонок и дедлоков
Как безопасно делить данные между потоками в C++17/20: mutex против atomic, где нужен lock_guard, а где обойтись без него, и почему condition_variable проверяют в цикле.

Классическая задача: у вас есть счётчик, к которому обращаются несколько потоков. Вы пишете counter++ и получаете значение меньше ожидаемого — часть инкрементов теряется. Очевидное «обернуть в mutex» работает, но на горячем пути тормозит. А если данных больше одного поля, появляется вторая беда — дедлок при захвате двух мьютексов в разном порядке. Разберём приёмы, которые закрывают эти случаи в C++17 и C++20, с ценой каждого варианта.
Смежные темы вроде lock-free структур данных и модели памяти (memory_order) здесь трогаем по касательной — это отдельный разбор. Фокус на том, что нужно, когда вы делите общее состояние между потоками в обычном сервисе.
Почему counter++ теряет инкременты
Инкремент — это чтение, увеличение и запись. Три операции, а не одна. Два потока могут прочитать одно и то же значение, каждый прибавит единицу и запишет — итог вырастет на единицу вместо двух. Это гонка данных (data race), и по стандарту она даёт неопределённое поведение, а не «просто неточный результат».
Для одиночного числового поля не нужен mutex — нужен std::atomic.
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
// C++17, компилировать с -std=c++17 -pthread
std::atomic<long> counter{0};
void worker() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed); // атомарный инкремент
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 8; ++i) threads.emplace_back(worker);
for (auto& t : threads) t.join();
std::cout << counter.load() << "\n"; // ровно 800000
return 0;
}
memory_order_relaxed здесь допустим, потому что нам важна только атомарность самого счётчика, а не порядок относительно других переменных. Как только счётчик служит флагом готовности других данных — relaxed уже нельзя, нужен минимум acquire/release.
Когда atomic не спасает: mutex
Atomic работает для одной ячейки. Как только инвариант охватывает несколько полей — например, вы одновременно меняете размер контейнера и указатель на его данные — атомарность отдельных полей ничего не гарантирует. Между двумя атомарными операциями другой поток увидит промежуточное состояние.
Здесь нужен mutex, и захватывать его следует через RAII-обёртку, а не вручную lock()/unlock(). Ручной unlock забудут при раннем return или исключении.
#include <mutex>
#include <map>
#include <string>
class Cache {
std::map<std::string, int> data_;
mutable std::mutex mtx_;
public:
void set(const std::string& key, int value) {
std::lock_guard<std::mutex> lock(mtx_); // захват на время блока
data_[key] = value;
} // unlock вызовется автоматически, даже при исключении
bool get(const std::string& key, int& out) const {
std::lock_guard<std::mutex> lock(mtx_);
auto it = data_.find(key);
if (it == data_.end()) return false;
out = it->second;
return true;
}
};
std::lock_guard — самый дешёвый вариант, когда область захвата совпадает с блоком. Если нужно освободить мьютекс раньше конца блока или передать его в condition_variable, берите std::unique_lock — он умеет unlock() и повторный lock(), но чуть тяжелее из-за хранения состояния.
Правило, которое экономит часы отладки: одна единица данных — один мьютекс, и никакой публичный метод класса не должен возвращать наружу указатель или ссылку на защищённое поле. Иначе защита утекает вместе со ссылкой.
Дедлок на двух мьютексах и как его убрать
Перевод денег между счетами: захватываем мьютекс отправителя, потом получателя. В другом потоке — перевод в обратную сторону, мьютексы захватываются в обратном порядке. Оба потока ждут друг друга. Классический дедлок.
Решение — захватывать оба мьютекса одной операцией, которая гарантирует отсутствие взаимной блокировки. В C++17 это std::scoped_lock:
#include <mutex>
struct Account {
std::mutex mtx;
long balance = 0;
};
// C++17
void transfer(Account& from, Account& to, long amount) {
// scoped_lock блокирует оба мьютекса без риска дедлока
std::scoped_lock lock(from.mtx, to.mtx);
from.balance -= amount;
to.balance += amount;
}
std::scoped_lock использует алгоритм захвата, который не зависит от порядка передачи мьютексов — вызвать его можно с (from, to) в одном потоке и (to, from) в другом, дедлока не будет. До C++17 тот же результат давал std::lock() плюс два lock_guard с флагом std::adopt_lock.
Ожидание события: condition_variable
Опрос флага в цикле while (!ready) {} жжёт процессор впустую. Правильный способ — усыпить поток до наступления события через std::condition_variable. Ключевая ошибка новичков: проверять условие один раз, а не в цикле.
#include <condition_variable>
#include <mutex>
#include <queue>
std::queue<int> tasks;
std::mutex mtx;
std::condition_variable cv;
bool done = false;
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
// предикат защищает от ложных пробуждений
cv.wait(lock, [] { return !tasks.empty() || done; });
if (tasks.empty() && done) return;
int task = tasks.front();
tasks.pop();
lock.unlock(); // отпускаем мьютекс до обработки
// ... обработка task ...
}
}
Форма cv.wait(lock, predicate) сама проверяет предикат в цикле и переживает ложные пробуждения1. Если пишете cv.wait(lock) без предиката, оборачивайте вызов в while вручную — иначе поток проснётся от чужого сигнала и продолжит работать по неготовым данным.
Что выбрать под задачу
| Ситуация | Инструмент | Цена |
|---|---|---|
| Один счётчик или флаг | std::atomic | Дёшево, но только одна ячейка |
| Инвариант из нескольких полей | mutex + lock_guard | Блокировка, возможна конкуренция |
| Захват двух+ мьютексов | std::scoped_lock | Защита от дедлока, чуть дороже |
| Ожидание события | condition_variable | Требует предиката и парного мьютекса |
| Ленивая одноразовая инициализация | std::call_once + once_flag | Проще двойной проверки блокировки |
Типичные ошибки, которые ловит санитайзер
- Ссылка на защищённое поле утекла наружу. Метод вернул
data_[key]по ссылке — вызывающий код читает её без мьютекса. Возвращайте копию. - Захват мьютекса на время долгой операции. Держать lock во время сетевого запроса — сериализация всех потоков. Скопируйте нужное под мьютексом, отпустите, потом работайте.
- condition_variable без предиката. Ложное пробуждение — и поток идёт по неготовым данным. Всегда с предикатом.
- Забытый join/detach. Деструктор
std::threadс активным потоком вызываетstd::terminate. В C++20 естьstd::jthread, который сам делает join в деструкторе.
Гонки данных не проявляются стабильно — тест проходит десять раз и падает на одиннадцатый под нагрузкой. Собирайте отладочную сборку с ThreadSanitizer: g++ -std=c++17 -fsanitize=thread -g file.cpp. Он ловит гонки и порядок захвата мьютексов, которого хватает на дедлок, ещё до того как это случится в проде.
Что смотреть дальше
Если конкуренция на мьютексе стала узким местом — измеряйте её профилировщиком (в Linux подойдёт perf), прежде чем переходить к lock-free: последнее сложнее и легче сделать хуже. В C++20 появились std::jthread с кооперативной отменой через std::stop_token и std::latch/std::barrier для синхронизации групп потоков — они закрывают часть сценариев, где раньше вручную городили condition_variable. Следующий разумный шаг — модель памяти и осознанный выбор memory_order, но только когда relaxed/acquire-release перестанут покрывать задачу.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: cppreference — Concurrency support library, cppreference — std::condition_variable
Частые вопросы
У меня C++11, а не C++17 — scoped_lock есть?
Почему atomic не подходит для структуры из нескольких полей?
Тест на многопоточность проходит, но в проде иногда падает — что делать?
lock_guard или unique_lock — что использовать?
Что будет, если не вызвать join у std::thread?
Можно ли использовать один мьютекс на весь класс с множеством полей?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.