Гонка данных, мьютекс и lock_guard
Два потока увеличивают один счётчик, каждый по 100 000 раз. Ожидаемый итог — 200000:
#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().
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.
Частая ошибка
Два потока захватывают два мьютекса в разном порядке:
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);. Освобождает он их тоже в конце своей области видимости.