Гонка данных, мьютекс и lock_guard
На двери общего холодильника мелом записано, сколько порций супа продано. Отдав порцию, повар стирает число и пишет на единицу больше. Однажды оба повара подходят к доске одновременно: оба видят 5, оба пишут 6. Продано две порции, а на доске прибавилась одна.
Тот же сбой случается в программе. Два потока увеличивают один счётчик, каждый по 100000 раз. Ожидаемый итог — 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, и одно увеличение пропадёт — как с мелом на двери холодильника.
Попробуйте сами: нажмите «Шаг A» и «Шаг B» в разном порядке и следите, сколько окажется в counter, когда оба потока закончат.
counter- прочитать
mov eax, [counter] - прибавить
add eax, 1 - записать
mov [counter], eax
- прочитать
mov eax, [counter] - прибавить
add eax, 1 - записать
mov [counter], eax
counter = 5. В каждом потоке ++counter — три шага. Нажимайте «Шаг A» и «Шаг B» в любом порядке: получится потерять увеличение?
Гонка данных — несинхронизированный доступ двух потоков к одной переменной, при котором хотя бы один из них пишет. Гонка данных — неопределённое поведение всей программы, а не только неверное число. Этот вопрос часто задают на собеседовании: почему два потока, которые увеличивают один int, теряют увеличения.
Мьютекс
Повара договариваются: писать на доске может только тот, кто держит единственный кусок мела. Кто хочет записать число, ждёт, пока мел вернут. Мьютекс работает так же: он пропускает к данным один поток за раз.
У std::mutex из заголовка <mutex> есть два метода:
lock()захватывает мьютекс; если его уже держит другой поток,lock()ждёт;unlock()освобождает мьютекс, и его может захватить следующий поток.
Здесь аналогия с мелом заканчивается: повар видит, что доска занята, а поток, который не захватил мьютекс, ничего не замечает и пишет в память. Мьютекс защищает данные, только если каждый поток захватывает его перед доступом к ним.
lock_guard
Мьютекс надёжнее захватывать через 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.
Частая ошибка
Мьютекс захватывают и освобождают вручную, а в середине функции стоит ранний return:
std::mutex m;
int total = 0;
void add(int x) {
m.lock();
if (x < 0) {
return; // ✗
}
total += x;
m.unlock();
}Программа собирается без предупреждений. Но после add(-1) мьютекс так и остаётся захваченным: return пропустил unlock(). Любой другой поток, который вызовет add, будет ждать вечно. Замените пару lock() и unlock() на std::lock_guard<std::mutex> guard(m); — тогда мьютекс освободится при любом выходе из функции.
Главное
++counter— это три шага: прочитать, прибавить, записать; шаги двух потоков могут чередоваться, и увеличение теряется.- Гонка данных — одновременный доступ к переменной без синхронизации, когда хотя бы один поток пишет; это неопределённое поведение.
- Мьютекс пропускает к данным один поток за раз:
lock()захватывает его,unlock()освобождает. std::lock_guardосвобождает мьютекс при любом выходе из области видимости, в том числе при раннемreturn.
Подпишись, чтобы пройти курс
Курс бесплатный. Войди через Telegram и подпишись на канал — откроются все уроки.