Перейти к содержимому
Теория · Многопоточность: обзор · урок 3 из 10

Гонка данных, мьютекс и lock_guard

Два потока увеличивают один счётчик, каждый по 100 000 раз. Ожидаемый итог — 200000:

C++
#include <iostream>
#include <thread>

int main() {
    int counter = 0;
    auto count_up = [&counter]() {
        for (int i = 0; i < 100000; ++i) {
            ++counter;  // ✗
        }
    };
    std::thread worker(count_up);
    count_up();
    worker.join();
    std::cout << counter << '\n';
}

Программа собирается без предупреждений, но итог не гарантирован. Запуск может напечатать меньше 200000, каждый раз разное число, но стандарт не обещает даже этого.

Гонка данных

++counter — не одно действие, а три: прочитать значение, прибавить единицу, записать результат. Если оба потока прочитают 5, оба запишут 6, и одно увеличение пропадёт. Гонка данных (data race) — несинхронизированный доступ двух потоков к одной переменной, при котором хотя бы один из них пишет. Гонка данных — неопределённое поведение всей программы, а не только неверное число.

Мьютекс и lock_guard

Мьютекс (mutex) пропускает к данным один поток за раз. У std::mutex из заголовка <mutex> есть методы lock() и unlock(). Пока один поток держит мьютекс захваченным, lock() в другом потоке ждёт. Вызывать их вручную рискованно: ранний return пропустит unlock(), и другие потоки будут ждать вечно.

Поэтому мьютекс захватывают через std::lock_guard — это RAII: конструктор вызывает lock(), а деструктор — unlock().

C++
std::mutex m;
auto count_up = [&counter, &m]() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> guard(m);
        ++counter;
    }
};

Мьютекс освобождается, когда заканчивается область видимости guard: здесь — в конце каждой итерации. При любом другом выходе из области, например через return, деструктор тоже освободит мьютекс. Теперь программа всегда печатает 200000.

Частая ошибка

Два потока захватывают два мьютекса в разном порядке:

C++
std::mutex a;
std::mutex b;

void left() {
    std::lock_guard<std::mutex> ga(a);
    std::lock_guard<std::mutex> gb(b);
}

void right() {
    std::lock_guard<std::mutex> gb(b);
    std::lock_guard<std::mutex> ga(a);  // ✗
}

Если один поток выполняет left, а другой одновременно right, программа может зависнуть навсегда. Первый поток захватит a и будет ждать b, а второй захватит b и будет ждать a. Такое взаимное ожидание называют взаимной блокировкой (deadlock). Она случается не при каждом запуске, поэтому её трудно найти.

std::scoped_lock захватывает несколько мьютексов сразу и без взаимной блокировки, в каком бы порядке их ни передали: std::scoped_lock lock(a, b);. Освобождает он их тоже в конце своей области видимости.