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

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

На двери общего холодильника мелом записано, сколько порций супа продано. Отдав порцию, повар стирает число и пишет на единицу больше. Однажды оба повара подходят к доске одновременно: оба видят 5, оба пишут 6. Продано две порции, а на доске прибавилась одна.

Тот же сбой случается в программе. Два потока увеличивают один счётчик, каждый по 100000 раз. Ожидаемый итог — 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 — не одно действие, а три:

  1. прочитать значение;
  2. прибавить единицу;
  3. записать результат.

Шаги двух потоков могут чередоваться как угодно. Если оба потока прочитают 5, оба запишут 6, и одно увеличение пропадёт — как с мелом на двери холодильника.

Попробуйте сами: нажмите «Шаг A» и «Шаг B» в разном порядке и следите, сколько окажется в counter, когда оба потока закончат.

ВживуюДва потока, один ++countercounter = 5
общая переменная
5
counter
поток A
  1. прочитатьmov eax, [counter]
  2. прибавитьadd eax, 1
  3. записатьmov [counter], eax
регистр: —
поток B
  1. прочитатьmov eax, [counter]
  2. прибавитьadd eax, 1
  3. записатьmov [counter], eax
регистр: —
++counter на int — три инструкции (clang, x86-64, -O0)

counter = 5. В каждом потоке ++counter — три шага. Нажимайте «Шаг A» и «Шаг B» в любом порядке: получится потерять увеличение?

Порядок шагов выбираете вы

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

Мьютекс

Повара договариваются: писать на доске может только тот, кто держит единственный кусок мела. Кто хочет записать число, ждёт, пока мел вернут. Мьютекс работает так же: он пропускает к данным один поток за раз.

У std::mutex из заголовка <mutex> есть два метода:

  • lock() захватывает мьютекс; если его уже держит другой поток, lock() ждёт;
  • unlock() освобождает мьютекс, и его может захватить следующий поток.

Здесь аналогия с мелом заканчивается: повар видит, что доска занята, а поток, который не захватил мьютекс, ничего не замечает и пишет в память. Мьютекс защищает данные, только если каждый поток захватывает его перед доступом к ним.

lock_guard

Мьютекс надёжнее захватывать через 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.

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

Мьютекс захватывают и освобождают вручную, а в середине функции стоит ранний return:

C++
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 и подпишись на канал — откроются все уроки.

Войти другим способом