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