1. Одного std::lock_guard иногда недостаточно
Если смотреть на std::lock_guard как на «замок на дверь», то это такой замок без ключа: вы закрыли дверь и можете открыть её только когда выйдете из комнаты. Для многих случаев это идеально: чем меньше возможностей «поиграться» с блокировкой, тем меньше шансов сделать себе боль.
Но бывают два типичных сюжета.
Первый: внутри функции есть маленький кусок, где надо безопасно взять данные, а потом начинается долгая работа — вычисления, форматирование строк, сортировка, подготовка отчёта. Держать мьютекс на всё это время — значит искусственно тормозить остальные потоки. Хочется сделать так: «быстро взял → быстро отпустил → долго думал без блокировки → быстро вернул результат».
Второй: операция затрагивает два защищённых объекта. Например, перевод денег между двумя счетами: нужно изменить баланс и там, и там так, чтобы никто не увидел «промежуточное» состояние. Тут уже появляется задача «захватить два мьютекса одной логической операцией».
Под эти два сюжета и придуманы std::unique_lock и std::scoped_lock.
std::unique_lock: RAII-замок, который можно отпустить и снова взять
std::unique_lock<Mutex> — это RAII-обёртка, как и lock_guard, но более «умная» и более гибкая. Под капотом идея всё та же: объект живёт — блокировка удерживается, объект уничтожился — блокировка снялась. Но у unique_lock появляется управление временем удержания: можно сделать unlock() в середине и при необходимости снова сделать lock().
Важно держать в голове философию: unique_lock не «лучше», а «опаснее, но нужнее». Он даёт больше власти — а власть, как известно, портит даже начинающих программистов.
Минимальный скелет выглядит так:
#include <mutex>
std::mutex m;
void f() {
std::unique_lock<std::mutex> lock(m); // залочили
// критическая секция
lock.unlock(); // разлочили раньше конца scope
// некритическая секция
} // если всё ещё залочено — разлочится в деструкторе
Тут полезно заметить, что unique_lock существует не ради «красоты», а ради сценариев, где граница критической секции не совпадает с границей блока { ... }.
2. Паттерн “snapshot → compute → commit” на std::unique_lock
Сейчас сделаем маленький, но очень жизненный пример: несколько потоков добавляют числа в общий список (условно, «метрики»), а периодически мы строим отчёт. Отчёт строить долго (например, суммировать, форматировать), а блокировать на это всё общий мьютекс — плохая идея.
Мы начнём с простейшей «заготовки приложения дня»: общий Metrics-контейнер и поток-работник, который добавляет значения. Здесь намеренно всё упрощено: без очередей задач и ожиданий событий — это будет в другие дни.
Общие данные и поток-работник
#include <mutex>
#include <vector>
std::mutex metrics_m;
std::vector<int> metrics;
void push_metric(int x) {
std::lock_guard<std::mutex> lock(metrics_m);
metrics.push_back(x);
}
Здесь lock_guard идеален: мы хотим ровно «взял → добавил → вышел». Никаких фокусов.
Строим отчёт: копируем под lock, считаем без lock
Теперь функция отчёта. Сначала возьмём «снимок» (snapshot) под защитой, потом отпустим мьютекс и будем считать сколько угодно долго.
#include <mutex>
#include <numeric>
#include <vector>
std::vector<int> snapshot_metrics() {
std::unique_lock<std::mutex> lock(metrics_m);
std::vector<int> copy = metrics; // быстро скопировали
lock.unlock(); // отпустили мьютекс
return copy;
}
Обратите внимание: мы не пытаемся «читать metrics без защиты». Мы честно делаем копию, а дальше работаем с локальными данными. Это ключевой трюк многопоточности: если можно превратить «общие данные» в «локальные данные» — вы резко снижаете количество проблем.
Теперь считаем сумму и среднее уже по копии:
#include <iostream>
#include <numeric>
#include <vector>
void print_report(const std::vector<int>& data) {
const int sum = std::accumulate(data.begin(), data.end(), 0);
std::cout << "count=" << data.size() << " sum=" << sum << '\n';
// например: count=10 sum=42
}
И вместе это выглядит как понятный поток:
flowchart TD
A[lock] --> B[copy metrics]
B --> C[unlock]
C --> D[compute on local copy]
D --> E[print / format]
Смысл unique_lock тут ровно в том, чтобы граница блокировки была «там, где нужно по смыслу», а не «там, где заканчивается функция».
Почему не сделать то же самое блоком { ... }?
Хороший вопрос: иногда можно.
std::vector<int> copy;
{
std::lock_guard<std::mutex> lock(metrics_m);
copy = metrics;
} // lock_guard разрушился — мьютекс отпустили
Это работает и часто даже предпочтительнее, потому что проще. Но unique_lock становится полезным, когда у вас более сложная логика: ранние выходы, несколько фаз, условное удержание блокировки, необходимость снова залочить мьютекс ближе к концу.
4. Отложенный захват: std::defer_lock
Иногда в функции мы заранее создаём объект «замка», но брать мьютекс хотим только если реально дойдём до места, где он нужен. Например, у нас есть быстрый путь выхода: «если данных нет — выходим», и мы не хотим лишний раз лочиться.
Для этого у unique_lock есть режим “deferred locking” — «создать объект, но не захватывать мьютекс сразу».
#include <mutex>
extern std::mutex metrics_m;
void maybe_touch_metrics(bool need) {
std::unique_lock<std::mutex> lock(metrics_m, std::defer_lock); // пока не залочено
if (!need) {
return; // и это нормально: мы так и не брали lock
}
lock.lock(); // вот теперь залочили
// работа с общими данными
}
Тут важно быть честным с самим собой: да, гибкость полезна. Но именно здесь начинаются ошибки уровня «я думал, что уже залочено». Поэтому defer_lock — инструмент не на каждый день, а на те случаи, где вы прям чувствуете, что без него код либо некрасиво дублируется, либо держит блокировку слишком рано.
5. std::scoped_lock: один RAII-объект для нескольких мьютексов
Если unique_lock решает проблему «время удержания блокировки», то std::scoped_lock решает проблему «мне нужно захватить несколько мьютексов одной логической операцией».
Идея простая: вы создаёте один scoped_lock, передаёте ему несколько мьютексов, и он гарантированно удерживает их все, пока живёт. Это лучше, чем два lock_guard подряд, потому что “два подряд” создают соблазн в разных местах кода захватывать мьютексы в разном порядке, а это уже пахнет неприятностями.
(Детально про взаимные блокировки вы разберёте в следующей лекции — сегодня нам важно научиться «как правильно держать два замка в руках», не влезая глубоко в детектив.)
Мини-модель: два счёта и перевод между ними
Сделаем простую структуру счёта: баланс + свой мьютекс.
#include <mutex>
struct Account {
int balance = 0;
std::mutex m;
};
Перевод денег — операция на двух счетах. Значит, нам нужны два мьютекса одновременно:
#include <mutex>
void transfer(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.m, to.m);
from.balance -= amount;
to.balance += amount;
}
Код короткий, читается честно: «для перевода надо одновременно защитить оба объекта». И, что важно для новичка, граница критической секции снова равна области видимости переменной lock.
Если мьютексов больше двух
С точки зрения синтаксиса — ничего страшного: scoped_lock умеет принимать несколько мьютексов.
std::scoped_lock lock(m1, m2, m3);
С точки зрения дизайна — это уже повод задуматься: «а точно ли моя операция не делает слишком много всего сразу?». Но сам инструмент это поддерживает.
6. Мини-таблица выбора: lock_guard vs unique_lock vs scoped_lock
Когда инструментов становится больше, мозг пытается паниковать (это нормально). Помогает простая таблица принятия решений — не как догма, а как шпаргалка.
| Инструмент | Когда брать | Что умеет | Цена ошибки |
|---|---|---|---|
|
«Залочить на весь scope и забыть» | только RAII-удержание | минимальная: сложно забыть unlock() |
|
Нужно unlock()/lock() внутри функции или отложенный захват | ранний unlock(), повторный lock(), defer_lock | выше: можно перепутать, залочено ли сейчас |
|
Нужно держать несколько мьютексов одной операцией | RAII для нескольких мьютексов | высокая, если пытаться делать вручную разным порядком |
Общий принцип дня остаётся прежним: начинайте с самого простого (lock_guard), и только когда появляется конкретная необходимость — переходите на более гибкий инструмент. В мире потоков «слишком умный код» обычно означает «будущий баг, который пока стесняется».
Контрольные вопросы
- В каком сценарии std::unique_lock полезнее, чем блок { ... } с std::lock_guard?
- Почему паттерн “snapshot → compute → commit” часто ускоряет многопоточный код даже без «оптимизаций»?
- Почему перевод денег между двумя объектами требует захвата сразу двух мьютексов?
- Какую проблему по смыслу решает std::scoped_lock, если сравнивать его с «двумя lock_guard подряд»?
- Почему std::defer_lock может сделать код и красивее, и опаснее одновременно?
7. Типичные ошибки при работе с std::unique_lock и std::scoped_lock
Ошибка №1: использовать std::unique_lock «везде на всякий случай».
Это частый рефлекс: раз unique_lock умеет больше, значит он «универсальнее». На практике получается наоборот: вы размазываете по коду потенциально опасный инструмент, и потом сложно глазами проверить, где мьютекс удерживается, а где уже отпущен. Правильная привычка: по умолчанию lock_guard, а unique_lock — только там, где без него реально хуже.
Ошибка №2: делать unlock(), не восстановив инвариант.
Самая коварная логическая ошибка: вы изменили часть защищённого состояния, отпустили мьютекс, а потом «доделаете». В многопоточном мире «потом» может не наступить или наступить слишком поздно: другой поток увидит полусобранное состояние. Если отпускаете мьютекс в середине функции, отпускайте его только в момент, когда защищённые данные снова согласованы.
Ошибка №3: после unlock() продолжать читать/писать общие данные “по привычке”.
Выглядит так: вы взяли unique_lock, прочитали sharedValue, сделали unlock(), а потом где-то ниже снова используете sharedValue как будто оно актуально и защищено. Это не «ошибка компиляции», а ошибка мышления. Помогает дисциплина: после unlock() работайте только с локальными копиями, а если снова нужен общий ресурс — явно делайте lock() и возвращайтесь в критическую секцию.
Ошибка №4: захватывать несколько мьютексов вручную разным порядком в разных функциях.
Сегодня мы не разбирали deadlock детально, но даже без теории можно понять: если в одном месте вы берёте mA, потом mB, а в другом — наоборот, вы сами создаёте условия для «вечного ожидания». std::scoped_lock хорош тем, что делает намерение «взять всё сразу» явным и снижает риск ошибок протокола блокировок.
Ошибка №5: держать std::scoped_lock слишком долго и делать под ним “медленные” вещи.
scoped_lock легко «расширяет» критическую секцию: два мьютекса удерживаются одновременно, значит вы блокируете больше потоков и больше кода. Если внутрь такой секции случайно попадёт, например, тяжёлое форматирование строк или ввод/вывод, вы получите пробку. Привычка та же: под lock — только то, что действительно должно быть взаимно исключающим.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ