std::mutex + std::lock_guard — RAII‑блокировка

C++ SELF
69 уровень , 1 лекция
Открыта

1. Почему в многопоточности вообще нужен mutex

Если вы только что начали писать многопоточный код, мозг обычно рисует картинку «пара потоков честно делает свою работу». Реальность чуть более драматичная: потоки — как два стажёра, которым вы выдали один ноутбук и сказали «редактируйте этот документ по очереди, но я не буду следить». Даже если стажёры очень хорошие, документ всё равно будет иногда превращаться в абстрактное искусство.

В C++ «документ» — это общий ресурс: переменная, структура, контейнер, да даже std::cout. Если два потока одновременно меняют одно и то же состояние, вы получаете гонку. Нам нужен механизм, который введёт правило: в каждый момент времени только один поток делает критическую часть работы.

std::mutex — это примитив взаимного исключения (mutual exclusion). Идея простая: у мьютекса есть два состояния — «свободен» и «занят». Поток может «занять» мьютекс, выполнить критическую секцию, а затем «освободить».

Схематично это можно представить так:

flowchart TD
    A[Поток 1 хочет обновить общий ресурс] --> B["lock(mutex)"]
    B --> C[Критическая секция]
    C --> D["unlock(mutex)"]
    D --> E[Другие потоки могут войти]

Важно помнить: mutex::lock() — операция блокирующая, то есть поток может ждать сколько угодно, пока мьютекс освободится. Это прямо заложено в идею lock() как «blocking» операции.

2. std::mutex: минимум и ловушки ручного lock()/unlock()

Когда вы впервые видите std::mutex, хочется воспринимать его как «магический амулет от багов». Это нормальная стадия — у всех так. Но мьютекс не «делает код потокобезопасным сам по себе», он всего лишь позволяет вам ввести дисциплину: вот этот участок выполняется строго по одному потоку за раз.

Подключается всё довольно скромно:

#include <mutex>

Минимальный пример

Базовый сценарий выглядит так:

std::mutex m;

m.lock();
// ... критическая секция ...
m.unlock();

Здесь полезно зафиксировать два момента. Во-первых, std::mutex нельзя копировать: копия мьютекса — это концептуально странная штука, потому что копия «ключа от туалета» не должна появляться из воздуха. Во-вторых, lock() по смыслу не должен «случайно» падать с исключениями.

Но на практике вам важнее другое: даже если не было исключений, можно забыть unlock() — и вот это уже ваша реальная проблема как прикладного программиста.

Почему ручные lock()/unlock() — это ловушка

С ручными lock() / unlock() есть неприятная особенность: они требуют от вас идеальной дисциплины. А мы пишем код не в стерильной лаборатории, а в живом проекте, где бывают ранние return, ошибки ввода, несколько веток if, и иногда даже «ой, я добавил return и забыл про unlock».

Типичный (плохой, но жизненный) сценарий:

#include <mutex>

std::mutex m;
int x = 0;

void set_if_positive(int v) {
    m.lock();
    if (v <= 0) return;   // <-- забыли unlock()
    x = v;
    m.unlock();
}

Выглядит невинно, но теперь при v <= 0 мьютекс останется навсегда занятым. Следующий поток, который вызовет set_if_positive, может «зависнуть» на lock() и ждать бесконечно.

Почти всегда, когда в продакшене говорят «приложение зависло», где‑то рядом грустит мьютекс, который не дождался unlock(). И это реальный жанр багов: «забыли unlock в одной из веток».

Здесь и появляется RAII: мы хотим, чтобы «взял блокировку» и «отпустил блокировку» были привязаны не к человеческой памяти, а к области видимости.

3. RAII‑блокировка через std::lock_guard

std::lock_guard — это маленький объект, который делает ровно одну вещь, но делает её стабильно: в конструкторе берёт мьютекс, в деструкторе отпускает. То есть он превращает блокировку в ресурс в стиле RAII.

Базовый пример lock_guard

#include <mutex>

std::mutex m;
int x = 0;

void safe_set(int v) {
    std::lock_guard<std::mutex> lock(m);
    x = v;
} // <-- lock_guard уничтожился, мьютекс отпустился автоматически

Ранний return больше не ломает дисциплину

#include <mutex>

std::mutex m;
int x = 0;

void set_if_positive(int v) {
    std::lock_guard<std::mutex> lock(m);
    if (v <= 0) return;   // unlock произойдёт автоматически
    x = v;
}

Здесь вы можете смело писать return где угодно: при выходе из функции локальные объекты уничтожаются, lock_guard уничтожается — и мьютекс отпускается.

Чтобы зафиксировать разницу, удобно один раз увидеть её в таблице:

Подход Как выглядит Где опасность
Ручной lock()/unlock()
m.lock(); ...; m.unlock();
легко забыть unlock() на одном из путей выхода
RAII через lock_guard
std::lock_guard lock(m);
почти нечего забывать — «unlock» привязан к scope

Граница критической секции = граница области видимости

Когда вы освоили lock_guard, следующий шаг — научиться рисовать «границы» критической секции. Новички часто делают так: «взял мьютекс в начале функции и держу до конца, на всякий случай». Это безопасно, но иногда превращается в пробку на дороге: остальные потоки стоят и ждут, хотя вы под мьютексом делаете что‑то долгое и не связанное с общими данными.

Правильная мысль такая: критическая секция должна быть минимальной по смыслу. Часто это означает «обновить общий счётчик» или «добавить элемент в общий контейнер», а всё вычисление можно вынести наружу.

Синтаксически это удобно делается дополнительным блоком { ... }:

#include <mutex>

std::mutex m;
int total = 0;

void add_sum(int a, int b) {
    int local = a + b;           // долго/безопасно: вне lock
    {
        std::lock_guard<std::mutex> lock(m);
        total += local;          // коротко и по делу: под lock
    } // lock снят здесь
}

Этот маленький блок — как мини‑комната переговоров: зашли, быстро договорились, вышли. А не поселились там жить.

4. Мини‑примеры на практике

Мини‑пример: «сломанный» счётчик и его починка

Счётчик — классика, потому что он выглядит максимально безопасно («это же просто int!»), но ломается максимально наглядно. Давайте сделаем два потока, которые увеличивают общий counter. Сначала — как делать не надо:

#include <thread>
#include <iostream>

int main() {
    int counter = 0; // общий ресурс

    auto work = [&] {
        for (int i = 0; i < 100000; ++i) ++counter; // гонка
    };

    std::thread t1(work), t2(work);
    t1.join(); t2.join();
    std::cout << counter << '\n'; // ожидали 200000, получили "что-то"
}

Теперь добавим std::mutex и std::lock_guard. Обратите внимание: мы не меняем структуру программы, мы добавляем правило «инкремент делается строго по одному потоку».

#include <thread>
#include <mutex>
#include <iostream>

int main() {
    int counter = 0;
    std::mutex m;

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

    std::thread t1(work), t2(work);
    t1.join(); t2.join();
    std::cout << counter << '\n'; // 200000
}

Да, это может быть медленнее (мы слишком часто берём lock), но здесь наша цель — корректность и надёжная техника.

Мини‑пример: std::cout тоже «общий ресурс»

Есть один сюрприз, который почти все встречают на практике: два потока, которые печатают в консоль, начинают выводить «кашу». Это не мистика, это просто конкурентный доступ к общему объекту вывода.

Представим, что у нас есть функция логирования:

#include <iostream>
#include <string>

void log_line(const std::string& s) {
    std::cout << s << '\n';
}

Если два потока вызывают log_line одновременно, строка может «перемешаться» с другой строкой. Поэтому вывод тоже стоит защищать:

#include <mutex>
#include <iostream>
#include <string>

std::mutex out_m;

void log_line(const std::string& s) {
    std::lock_guard<std::mutex> lock(out_m);
    std::cout << s << '\n'; // печатаем атомарно "по смыслу"
}

Здесь важная мысль: мы защищаем не «cout как таковой», а логическую операцию «вывести строку целиком». Если печатать по кусочкам из разных потоков, пользователь видит шум, а вы — головную боль.

Практический мини‑шаг: «телеметрия» для потоков

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

Сделаем модель «статистика обработки»:

struct Stats {
    int ok = 0;
    int failed = 0;
};

Теперь общий объект и мьютекс:

#include <mutex>

Stats stats;
std::mutex stats_m;

И две функции обновления статистики. Блокировка оборачивает только одну логическую операцию:

#include <mutex>

extern Stats stats;
extern std::mutex stats_m;

void add_ok() {
    std::lock_guard<std::mutex> lock(stats_m);
    ++stats.ok;
}

void add_failed() {
    std::lock_guard<std::mutex> lock(stats_m);
    ++stats.failed;
}

Теперь рабочая функция потока. Мы специально делаем «работу» вне мьютекса, а обновление статистики — под мьютексом:

void worker(int id) {
    // ... тут какая-то работа (в нашем примере — просто логика) ...
    if (id % 2 == 0) add_ok();
    else add_failed();
}

И маленький main, который запускает пару потоков и выводит итог:

#include <thread>
#include <iostream>

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);

    t1.join();
    t2.join();

    std::cout << "ok=" << stats.ok << " failed=" << stats.failed << '\n';
    // ok=1 failed=1
}

Этот шаг важен именно как привычка: как только появляется общее состояние, сразу появляется мьютекс и правило доступа. И лучше, если правило выражено в коде так, чтобы его было трудно нарушить «случайно».

5. Типичные ошибки при работе с std::mutex и std::lock_guard

Ошибка №1: «Это же просто int, значит безопасно».
Размер типа не делает конкурентный доступ корректным. Проблема не в том, что int «маленький», а в том, что операция ++counter по смыслу — это цепочка «прочитать → увеличить → записать», и два потока могут вмешаться друг в друга.

Ошибка №2: ручной lock()/unlock() «потому что так понятнее».
На первых порах кажется, что ручной код прозрачнее: «вот lock, вот unlock». Но он же и наиболее хрупкий: вы добавили ранний return, новую ветку if, ещё один выход из функции — и уже есть шанс забыть unlock() и повесить программу. lock_guard почти всегда должен быть вариантом по умолчанию.

Ошибка №3: держать мьютекс слишком долго, потому что «а вдруг».
Когда lock_guard создан в начале функции и живёт до конца, под блокировкой оказываются лишние вычисления, форматирование строк, иногда даже вывод в консоль. Это снижает параллелизм и может создавать неожиданные «подвисания». Правильная привычка — ограничивать критическую секцию маленьким блоком {...}.

Ошибка №4: защищать запись, но не защищать чтение.
Частая логика новичка: «я же только читаю stats.ok, значит безопасно». В многопоточности чтение общего состояния без синхронизации тоже может участвовать в гонке, если где‑то рядом другой поток пишет. Если у состояния есть правило «доступ под мьютексом», оно должно соблюдаться и для чтения тоже.

Ошибка №5: защищать один и тот же ресурс разными мьютексами или иногда вообще без мьютекса.
Иногда в проекте появляется «мьютекс на запись» и «мьютекс на чтение», или часть функций лочит, а часть «и так работает». Это ломает саму идею дисциплины. У одного ресурса должно быть одно ясное правило защиты, иначе вы получаете мнимую безопасность.

1
Задача
C++ SELF, 69 уровень, 1 лекция
Недоступна
Общий счётчик кликов
Общий счётчик кликов
1
Задача
C++ SELF, 69 уровень, 1 лекция
Недоступна
Журнал действий команды
Журнал действий команды
1
Задача
C++ SELF, 69 уровень, 1 лекция
Недоступна
Статистика с ранним выходом
Статистика с ранним выходом
1
Задача
C++ SELF, 69 уровень, 1 лекция
Недоступна
Сумма квадратов по частям
Сумма квадратов по частям
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ