JavaRush /Курсы /C++ SELF /std::condition_variable: wait(lock, predicate)

std::condition_variable: wait(lock, predicate)

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

1. Зачем нужен std::condition_variable, если уже есть mutex

Если смотреть на mutex глазами новичка, кажется: «ну я же уже могу защитить очередь задач, значит всё хорошо». И правда, mutex отлично защищает данные от гонок. Но есть проблема: он не умеет “красиво ждать”. Если потребителю нечего делать, он либо крутится в цикле (busy-wait), либо периодически засыпает через sleep_for и «подглядывает», не появилась ли работа. Оба подхода — компромисс между прожорливостью и тормозами.

std::condition_variable — это как звонок в двери: вы не стоите у коврика и не проверяете каждые 10 миллисекунд «не пришёл ли курьер». Вы нажали «ждать» и… действительно ждёте. Когда кто-то принесёт пиццу (задачу) — вам позвонят, и вы продолжите.

Три участника протокола: состояние, mutex и condition_variable

Сейчас будет важная мысль, которую в многопоточности нужно повторять как заклинание: мы ждём не “сигнал”, мы ждём состояние. Состояние — это что-то проверяемое «прямо сейчас»: флаг ready, счётчик tokens, очередь queue (пустая/не пустая). А уведомление (notify_*) — всего лишь вежливый намёк: «проверь ещё раз».

Связка выглядит так: у нас есть защищаемое состояние (например, очередь задач), которое мы читаем и меняем только под одним mutex. И рядом есть condition_variable, который позволяет потоку «уснуть» до момента, когда состояние может стать подходящим.

Небольшая схема протокола:

flowchart TD
    A[Consumer захватил mutex] --> B{Состояние готово?}
    B -- нет --> C["cv.wait(lock, predicate) mutex временно отпущен поток спит"]
    C --> D[Пробуждение mutex снова захвачен]
    D --> B
    B -- да --> E[Забираем данные и выходим из критической секции]

Именно такой «цикл проверки» и встроен в wait(lock, predicate).

2. Почему wait работает с std::unique_lock, а не с std::lock_guard

На первый взгляд это может раздражать: «почему нельзя, как всегда, lock_guard?» А потому что wait делает трюк, который lock_guard принципиально не умеет: временно отпускает мьютекс и потом снова захватывает его. Поток должен уснуть, но при этом не держать mutex, иначе другие потоки не смогут изменить состояние и разбудить его.

Посмотрите на идею unique_lock: он не просто «захватил и держит до конца scope», он ещё умеет unlock() и lock().

#include <mutex>

std::mutex m;

void unique_lock_demo() {
    std::unique_lock<std::mutex> lock(m);
    lock.unlock(); // отпустили мьютекс
    lock.lock();   // снова захватили
}

А теперь сравнение в табличке:

Инструмент Что делает Можно ли вручную unlock()? Подходит для cv.wait()
std::lock_guard
«захватил → держу до конца scope» нет нет
std::unique_lock
«захватил → могу отпустить → могу снова захватить» да да

И вот из-за этой «управляемости» unique_lock — стандартный напарник condition_variable.

4. Сигнатура и смысл wait(lock, predicate)

Сейчас будет центральная часть лекции: правильный паттерн ожидания. В стандартной библиотеке есть перегрузка wait, которая принимает предикат (булеву функцию). Эта перегрузка существует специально для того, чтобы ожидание было корректным и устойчивым. В материалах по стандарту отдельно фигурирует тема «condition_variable::wait с предикатом» (в том числе как предмет обсуждения формулировок требований).

«Голый» wait(lock) — ожидание без логики

Если вы пишете так:

cv.wait(lock);

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

wait(lock, predicate) — ожидание с правильной проверкой

Правильная форма выглядит так:

cv.wait(lock, [&] { return /* условие готовности */; });

И смысл здесь такой: «жди, пока условие не станет истинным». Внутри библиотека делает эквивалент цикла «проверил → если нет, уснул → проснулся → проверил снова».

Классическая «эквивалентная запись»:

while (!predicate()) {
    cv.wait(lock);
}

И именно поэтому предикат не должен иметь побочных эффектов: он может вызываться много раз.

5. Мини-пример: корректно ждём флаг ready

Когда начинаешь писать многопоточный код, хочется сначала попробовать на чём-то простом: есть флаг «готово/не готово», producer выставляет true, consumer ждёт.

#include <condition_variable>
#include <mutex>

std::mutex m;
std::condition_variable cv;
bool ready = false;

void consumer_waits() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return ready; });
    // здесь ready == true, и mutex снова захвачен
}

Producer при этом обязан менять состояние под тем же мьютексом:

#include <condition_variable>
#include <mutex>

extern std::mutex m;
extern std::condition_variable cv;
extern bool ready;

void producer_sets_ready() {
    {
        std::lock_guard<std::mutex> lock(m);
        ready = true;
    }
    cv.notify_one();
}

Обратите внимание на форму: мы меняем ready внутри блока, чтобы мьютекс гарантированно отпустился, и только потом зовём notify_one().

6. Встраиваем wait(lock, predicate) в мини-приложение

Представим, что в прошлой лекции (про busy-wait) мы уже начали писать «консольный обработчик задач»: один поток добавляет задачи, второй поток их выполняет. Пока что consumer мог «крутиться» и проверять queue.empty(). Теперь мы сделаем правильно: consumer будет спать, пока очередь пуста.

Для простоты пусть задача — это просто число int (например, «сколько раз напечатать строку» или «какое число посчитать»).

Состояние приложения: очередь + синхронизация.

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex g_mutex;
std::condition_variable g_cv;
std::queue<int> g_tasks;

Producer: кладём задачу и будим consumer

Producer — это, например, main, который читает команды пользователя.

#include <condition_variable>
#include <mutex>
#include <queue>

extern std::mutex g_mutex;
extern std::condition_variable g_cv;
extern std::queue<int> g_tasks;

void push_task(int x) {
    {
        std::lock_guard<std::mutex> lock(g_mutex);
        g_tasks.push(x);
    }
    g_cv.notify_one();
}

Consumer: ждём, пока очередь не пустая

Вот здесь и живёт наш wait(lock, predicate):

#include <condition_variable>
#include <mutex>
#include <queue>

extern std::mutex g_mutex;
extern std::condition_variable g_cv;
extern std::queue<int> g_tasks;

int pop_task_blocking() {
    std::unique_lock<std::mutex> lock(g_mutex);
    g_cv.wait(lock, [] { return !g_tasks.empty(); });

    int x = g_tasks.front();
    g_tasks.pop();
    return x;
}

Заметьте, насколько это «по-человечески»: мы буквально записали «жди, пока очередь не пуста». И это не магия: просто wait освобождает mutex, засыпает, а потом при пробуждении снова захватывает и перепроверяет предикат.

7. Как захватывать состояние в предикате: [&], [], [this]

Когда вы пишете cv.wait(lock, ...), вы часто используете лямбду. А значит, возникает вопрос: «как её захватывать?». Это не косметика — это влияет на корректность и читаемость.

Если состояние — глобальные переменные (как в наших коротких учебных примерах), можно писать [], потому что доступ и так будет к глобальным объектам:

g_cv.wait(lock, [] { return !g_tasks.empty(); });

Если же вы инкапсулируете очередь в объекте (что мы будем делать далее по дню), то чаще будет так: this->tasks_, this->closed_, и тогда удобнее захватить this неявно через [&] или явно через [this]. В учебном коде обычно читаемее [&]:

cv_.wait(lock, [&] { return !tasks_.empty(); });

Тут важная привычка: предикат должен быть «чистой проверкой» состояния, без push, без pop, без вывода в консоль. Он может вызываться часто, и вы не хотите, чтобы ваш код печатал «проснулся!» 200 раз просто потому, что поток решил проснуться не вовремя.

8. notify_one(), notify_all() и место вызова notify_*

На этом этапе важно не утонуть в деталях, но базовую интуицию зафиксировать нужно. Есть два способа будить ожидающих:

notify_one() будит один ожидающий поток. Это идеально подходит для «одна новая задача → один worker проснулся и забрал её».

notify_all() будит всех ожидающих. Это полезно, когда состояние изменилось так, что потенциально много потоков могут продолжить (или когда нужно всем сообщить важное изменение). Тему корректного завершения и случаи, когда notify_all() обязателен, мы нормально разберём в конце дня.

Кстати, в текстах про стандартную библиотеку отдельно отмечается, что вокруг condition_variable уделяется внимание эффективности и корректным формулировкам поведения.

Теперь классический вопрос: «а можно сделать notify_one() пока mutex ещё захвачен?». Технически можно, но чаще всего удобнее и чище делать так: сначала меняем состояние под мьютексом, потом отпускаем мьютекс (выходим из scope), и только потом уведомляем.

Причина довольно практичная. Если вы вызовете notify_one() под мьютексом, разбудившийся поток почти сразу попытается захватить mutex, но не сможет, потому что вы его ещё держите. Формально это не ошибка, но похоже на ситуацию «я позвонил в дверь, но держу дверь запертой и ключ не отдаю».

Хороший учебный шаблон:

{
    std::lock_guard<std::mutex> lock(m);
    ready = true;
}
cv.notify_one();

Такой код легче читать и легче сопровождать: изменение состояния и сигнал о нём визуально разделены.

9. Ещё пара мини-примеров wait(lock, predicate) для закрепления

Иногда полезно посмотреть на один и тот же паттерн в разных декорациях. Это как учить грамматику иностранного языка: пока не увидел 10 предложений — кажется, что понял, но это не точно.

«Токены»: ждём, пока счётчик станет больше нуля

#include <condition_variable>
#include <mutex>

std::mutex m;
std::condition_variable cv;
int tokens = 0;

void take_token() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return tokens > 0; });
    --tokens;
}

«Есть работа»: ждём, пока появится хотя бы один элемент

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex m;
std::condition_variable cv;
std::queue<int> q;

int pop_one() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return !q.empty(); });
    int x = q.front();
    q.pop();
    return x;
}

«Проверка готовности» и вывод в консоль

#include <iostream>
#include <condition_variable>
#include <mutex>

std::mutex m;
std::condition_variable cv;
bool ready = false;

void wait_and_print() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return ready; });
    std::cout << "Ready!\n"; // Ready!
}

10. Типичные ошибки при работе с wait(lock, predicate)

Ошибка №1: использовать cv.wait(lock) «потому что короче».
Так действительно короче, но это как написать if (x) ... без понимания, что такое x и кто его меняет. Если вы ждёте конкретное условие (очередь не пуста, флаг готовности поднят, токены появились), то корректный базовый стиль — wait(lock, predicate). Он делает ожидание привязанным к состоянию, а не к «мистическому факту пробуждения».

Ошибка №2: пытаться передать в wait std::lock_guard.
lock_guard не умеет отпускать мьютекс, а wait обязан его отпускать во время сна. Поэтому для ожидания нужен именно std::unique_lock. Если вы ловите себя на мысли «почему компилятор ругается?», скорее всего вы выбрали не тот тип блокировки.

Ошибка №3: предикат с побочными эффектами.
Иногда новички пишут предикат, который печатает в консоль, увеличивает счётчик или даже пытается pop() из очереди. Это очень полезный способ получить хаос: предикат может вызываться много раз, в том числе в моменты, когда реальной работы ещё нет. Предикат должен быть чистой проверкой bool, не более.

Ошибка №4: менять состояние без мьютекса и надеяться, что notify_one() «как-то синхронизирует».
notify_one() не защищает данные и не заменяет mutex. Он всего лишь будит поток. Если запись в ready или queue.push сделана без того же mutex, вы ломаете дисциплину доступа к общему состоянию. В лучшем случае получите редкие странные баги, в худшем — вечное ожидание или падения.

Ошибка №5: держать мьютекс во время «долгой работы» после пробуждения.
Правильная критическая секция обычно выглядит так: «проверил условие → забрал данные → обновил структуру → отпустил мьютекс». Если вы после wait начинаете выполнять тяжёлые вычисления, печатать гигабайты текста или имитировать работу через sleep_for под захваченным mutex, вы превращаете весь многопоточный код в однопоточный, только с дополнительными нервами.

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