Многопоточность: race condition, mutex, atomic

Потоки — мощный инструмент, но с одним подвохом: они работают одновременно. Если два потока обращаются к общим данным без синхронизации, результат становится непредсказуемым. Здесь мы разберём три классических способа обращения с общим счётчиком: без защиты, через mutex и через atomic.

<thread> <mutex> <atomic> Race condition

Зачем нужны потоки и где они ломаются

Программа выполняется в одном потоке — это последовательное выполнение инструкций. Но современные процессоры имеют несколько ядер, и чтобы использовать их все, программист запускает несколько потоков, которые работают одновременно.

минимальный пример
#include <iostream>
#include <thread>
using namespace std;

void sayHello(const string& name) {
    cout << "Привет, " << name << "!" << endl;
}

int main() {
    thread t1(sayHello, "Алиса");
    thread t2(sayHello, "Борис");

    t1.join();   // ждём завершения t1
    t2.join();   // ждём завершения t2
}
Идея. Два потока выполняются параллельно. На многоядерном процессоре они могут реально идти одновременно, на одноядерном — быстро переключаться. В обоих случаях порядок выполнения отдельных инструкций не определён.
Опасность в одном предложении. Если оба потока пишут в одну переменную без синхронизации — результат становится недетерминированным. Программа может работать правильно у вас на компьютере и «падать» у преподавателя. Это и есть race condition.

Сценарий: два потока увеличивают общий счётчик

Оба потока выполняют одну и ту же операцию counter++ и должны получить итоговое значение 2. Посмотрим, что произойдёт при разных подходах.

Поток A
прочитанное значение
—
поток ещё не запущен
Общий счётчик
counter
0
—
Поток B
прочитанное значение
—
поток ещё не запущен
Шаг
0 / 0
counter
0
Ожидаемое значение
2
Результат
—
Готово к запуску
$ // нажмите «Шаг», чтобы запустить сценарий

Код трёх сценариев

без защиты
int counter = 0;   // общая переменная

void increment() {
    for (int i = 0; i < 1; i++) {
        counter++;   // читаем → прибавляем → пишем
    }
}

int main() {
    thread t1(increment);
    thread t2(increment);
    t1.join();
    t2.join();
    cout << counter;   // может вывести 1!
}
через mutex
int counter = 0;
mutex mtx;

void increment() {
    lock_guard<mutex> lock(mtx);
    counter++;   // только один поток в этой области
}

int main() {
    thread t1(increment);
    thread t2(increment);
    t1.join();
    t2.join();
    cout << counter;   // всегда 2
}
через atomic
atomic<int> counter{0};   // атомарный счётчик

void increment() {
    counter++;   // операция выполняется как одно неделимое действие
}

int main() {
    thread t1(increment);
    thread t2(increment);
    t1.join();
    t2.join();
    cout << counter;   // всегда 2
}
Ключевая разница. mutex — это «замок»: потоки по очереди входят в критическую секцию. atomic — это специальный тип, для которого сам процессор гарантирует неделимость операции. Второй вариант обычно быстрее, но подходит не для любой логики.

Почему counter++ — это не одна операция

Выглядит как одно действие, но процессор выполняет его в три шага. Именно на этих шагах и происходит race condition.

1️⃣

Read

Считать текущее значение counter из памяти в регистр процессора.

2️⃣

Modify

Прибавить единицу к значению в регистре.

3️⃣

Write

Записать новое значение обратно в память.

Проблема. Поток может быть прерван между шагами. Если поток A прочитал 0, но ещё не записал 1, а поток B тоже прочитал 0 — они оба запишут 1. Одно из изменений потеряется.

Обратная сторона: deadlock

Mutex решает race condition, но создаёт новую опасность. Если два потока захватывают два mutex-а в разном порядке — оба будут ждать вечно.

Классический deadlock

плохой код
mutex mtxA, mtxB;

void thread1() {
    lock_guard<mutex> lockA(mtxA);
    // ...
    lock_guard<mutex> lockB(mtxB);   // ждём mtxB
    // ...
}

void thread2() {
    lock_guard<mutex> lockB(mtxB);
    // ...
    lock_guard<mutex> lockA(mtxA);   // ждём mtxA
    // ...
}

Что произойдёт. Поток 1 захватил mtxA и ждёт mtxB. Поток 2 захватил mtxB и ждёт mtxA. Ни один не может продолжить, ни один не отпускает свой замок. Программа навсегда зависает.

Как избежать deadlock

  1. Единый порядок захвата. Всегда захватывайте mutex-ы в одном и том же порядке во всех потоках. Например, сначала mtxA, потом mtxB — везде.
  2. Одновременный захват. Используйте std::scoped_lock (C++17), который захватывает несколько mutex-ов безопасно: scoped_lock lock(mtxA, mtxB);.
  3. Захватывайте на минимальное время. Чем меньше критическая секция, тем меньше шансов на конфликт.
  4. Не вызывайте чужой код под замком. Вы не знаете, какие mutex-ы он может захватить внутри.
  5. Используйте lock_guard / unique_lock вместо ручных lock() / unlock() — освобождение произойдёт даже при исключении.

Mutex или atomic: что выбрать

Mutex Atomic
Область применения Любая логика в критической секции Только одиночные операции над одним значением
Скорость Ниже — блокировка и переключение контекста Выше — процессор выполняет операцию неделимо
Риск deadlock Есть Нет
Сложность Нужно следить за порядком захвата и размером секции Нужно понимать memory ordering (для сложных случаев)
Когда применять Несколько операций связаны, работа с контейнерами, файлами, сокетами Одиночный счётчик, флаг, указатель
Правило. Если операция — «увеличить счётчик на 1», «переключить флаг», «записать указатель» — используйте atomic. Если операция сложнее (например, «добавить элемент в vector, если он не пуст, и обновить размер») — нужен mutex. Никогда не используйте atomic для структур данных.

Лучшие практики многопоточного программирования

1. Не разделяйте состояние — это лучшая оптимизация

Если два потока могут работать с собственными данными и лишь изредка обмениваться результатами — сделайте это. Проблема race condition существует только там, где есть общая память.

2. Используйте lock_guard и unique_lock

Не вызывайте mtx.lock() напрямую — если между lock и unlock вылетит исключение, mutex останется захваченным навсегда. RAII-обёртка освободит его автоматически.

правильно
void safeIncrement() {
    lock_guard<mutex> lock(mtx);
    counter++;
}   // ← mtx.unlock() вызывается автоматически

3. Минимизируйте критическую секцию

Захватывайте mutex как можно позже и отпускайте как можно раньше. Долгие операции (ввод-вывод, сетевые запросы) — вне критической секции. Это резко снижает конкуренцию и риск deadlock.

4. Проверяйте сценарии под нагрузкой

Race condition может не проявляться при запуске «один раз». Запускайте программу в цикле, увеличивайте число потоков, используйте Thread Sanitizer (-fsanitize=thread). Он находит гонки, которые не видны глазами.

Антипаттерн: «всё под одним большим mutex-ом»

Иногда, чтобы не думать, программист берёт один глобальный mutex и захватывает его вокруг всего кода. Программа становится корректной, но почти однопоточной — все потоки выстраиваются в очередь. Это лишает смысла многопоточность.

Антипаттерн: std::atomic для составных операций

плохо — race condition остаётся
atomic<int> a{0};
// ... в двух потоках:
if (a.load() < 10) {   // проверка
    a.store(a.load() + 1);   // и запись — не атомарно вместе!
}

Между load и store другой поток может изменить значение. Для таких случаев есть compare_exchange_weak, либо просто mutex.

Шпаргалка

Задача Инструмент
Запустить потокstd::thread t(func, args...);
Дождаться завершенияt.join();
Отпустить поток и не ждатьt.detach();
Защитить критическую секциюstd::mutex + lock_guard
Защитить несколько mutex-овstd::scoped_lock (C++17)
Атомарный счётчик, флаг, указательstd::atomic<T>
Дождаться события из другого потокаstd::condition_variable
Асинхронная задача с результатомstd::async + std::future

Задание

  1. Запустите демонстрацию race condition. Опишите, что произошло после шагов, где оба потока прочитали одно значение.
  2. Сделайте то же самое с mutex и с atomic. Сколько шагов понадобилось в каждом случае?
  3. Напишите программу с двумя потоками: один печатает нечётные числа от 1 до 99, второй — чётные. Используйте mutex, чтобы вывод не смешивался.
  4. Реализуйте через std::thread и std::atomic подсчёт простых чисел в диапазоне [1; 100000]. Разделите диапазон на 4 части, по потоку на каждую.
  5. Найдите ошибку в этом коде:
    atomic<int> a{0};
    // поток 1:
    if (a.load() < 5) a.store(a.load() + 1);
    // поток 2:
    a.store(a.load() * 2);
  6. Почему lock_guard предпочтительнее, чем прямой mtx.lock()? Приведите пример с исключением.
  7. Реализуйте потокобезопасную очередь на базе std::queue, std::mutex и std::condition_variable.