JavaRush /Курсы /C++ SELF /std::unique_lock и std::scoped_lock — когда нужны

std::unique_lock и std::scoped_lock — когда нужны

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

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

Когда инструментов становится больше, мозг пытается паниковать (это нормально). Помогает простая таблица принятия решений — не как догма, а как шпаргалка.

Инструмент Когда брать Что умеет Цена ошибки
std::lock_guard
«Залочить на весь scope и забыть» только RAII-удержание минимальная: сложно забыть unlock()
std::unique_lock
Нужно unlock()/lock() внутри функции или отложенный захват ранний unlock(), повторный lock(), defer_lock выше: можно перепутать, залочено ли сейчас
std::scoped_lock
Нужно держать несколько мьютексов одной операцией RAII для нескольких мьютексов высокая, если пытаться делать вручную разным порядком

Общий принцип дня остаётся прежним: начинайте с самого простого (lock_guard), и только когда появляется конкретная необходимость — переходите на более гибкий инструмент. В мире потоков «слишком умный код» обычно означает «будущий баг, который пока стесняется».

Контрольные вопросы

  1. В каком сценарии std::unique_lock полезнее, чем блок { ... } с std::lock_guard?
  2. Почему паттерн “snapshot → compute → commit” часто ускоряет многопоточный код даже без «оптимизаций»?
  3. Почему перевод денег между двумя объектами требует захвата сразу двух мьютексов?
  4. Какую проблему по смыслу решает std::scoped_lock, если сравнивать его с «двумя lock_guard подряд»?
  5. Почему 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 — только то, что действительно должно быть взаимно исключающим.

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