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() | |
легко забыть unlock() на одном из путей выхода |
| RAII через lock_guard | |
почти нечего забывать — «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: защищать один и тот же ресурс разными мьютексами или иногда вообще без мьютекса.
Иногда в проекте появляется «мьютекс на запись» и «мьютекс на чтение», или часть функций лочит, а часть «и так работает». Это ломает саму идею дисциплины. У одного ресурса должно быть одно ясное правило защиты, иначе вы получаете мнимую безопасность.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ