1. Ложные пробуждения: что это и почему это не «баг ОС»
Когда вы впервые пишете cv.wait(lock), мозг рисует наивную картинку: «поток спит, пока кто-то не вызовет notify_* — тогда он просыпается и всё хорошо». И вот тут начинается классическая история: так думали многие, а потом появлялись редкие зависания, падения или чтение из пустой очереди «раз в неделю по пятницам». Именно чтобы вы не стали героем этой истории, сегодня фиксируем ключевое понятие: ложное пробуждение (spurious wakeup).
Ложное пробуждение означает, что wait может вернуться, даже если никто «осмысленно» не сделал для вас notify_one()/notify_all(), и даже если условие, которого вы ждёте, вообще не стало истинным. Это не «злая шутка» и не «компилятор сломался». Это заложено в модель работы ожиданий: реализация может проснуться по внутренним причинам, из‑за особенностей планировщика, из‑за гонок на уровне ядра и так далее. Вам не нужно знать все причины, вам нужно знать одно: код должен быть корректным, даже если wait вернулся “просто так”.
Чтобы было проще запомнить, вот почти бытовая аналогия. notify — это не письмо с уведомлением, которое гарантированно доставят. Это скорее «кто-то постучал в дверь». Постучали — вы открыли. Но открыв, вы не обязаны увидеть курьера. Может, это сосед, может, сквозняк хлопнул, может, вам показалось. Поэтому вы каждый раз заново проверяете: «а доставка-то приехала?» — то есть проверяете состояние.
notify — не событие: ждём состояние, а не сигнал
Когда новички сталкиваются с condition variable, они часто воспринимают notify_one() как «событие», которое обязательно кто-то получит. Увы, так это не работает. notify — это подсказка: «эй, возможно, состояние поменялось — перепроверь». Смысловой источник истины — это ваше защищаемое состояние: queue, ready, tokens, closed, счётчик, что угодно.
Важная мысль, которую удобно держать в голове так: если бы мы могли гарантировать доставку “сигнала”, нам бы не понадобился mutex рядом. Но мы не можем. Поэтому протокол всегда такой: состояние хранится под mutex, и ожидание тоже проверяет это состояние под тем же mutex.
Существует даже отдельная перегрузка condition_variable::wait с предикатом, то есть «жди, пока предикат не станет истинным». Это не “хитрый трюк”, а нормальная часть контракта стандартной библиотеки.
Корректный код ждёт предикат (состояние). notify лишь заставляет ещё раз проверить предикат.
2. Антипаттерн: if (...) wait(lock); — как получить front()
Сейчас сделаем маленький «контролируемый взрыв». Мы намеренно напишем код, который выглядит логично, компилируется, часто работает… и иногда ломается.
Представьте consumer для очереди задач. Раз очередь пустая — подождём. Проснулись — значит, задача есть. Логика простая, как табуретка, и ровно поэтому она опасна.
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
int pop_bad() {
std::unique_lock<std::mutex> lock(m);
if (q.empty()) {
cv.wait(lock); // ПЛОХО: ждём "сигнал"
}
int x = q.front(); // риск: очередь всё ещё пуста
q.pop();
return x;
}
Почему это плохо? Потому что cv.wait(lock) может вернуться по ложному пробуждению. А ещё потому, что даже при «честном» пробуждении вы не один в мире: другой consumer мог проснуться раньше и забрать элемент. В итоге вы делаете front() на пустой очереди — и привет, неопределённое поведение/авария/странные эффекты (что именно — зависит от реализации и удачи).
Критическая деталь здесь в том, что if проверяет условие ровно один раз, до ожидания. Но ожидание — это «дыра во времени»: пока вы спали, мир мог поменяться сколько угодно раз, и при пробуждении вы обязаны заново проверить, что условие всё ещё истинно.
3. Правильный стиль: wait(lock, predicate) и ручной while
Теперь перепишем ту же функцию так, как её хотят видеть все: ваш тимлид, ваш будущий вы, и даже тот студент, который откроет ваш код через полгода и скажет: «О, тут всё понятно». После пробуждения мы не верим в магию, мы верим в предикат.
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
int pop_good() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return !q.empty(); }); // ЖДЁМ СОСТОЯНИЕ
int x = q.front(); // безопасно: q не пуста под тем же mutex
q.pop();
return x;
}
Что делает wait(lock, predicate) концептуально? Его удобно представлять как цикл: «пока предикат ложный — спи». Проснулся — снова проверил. И так хоть десять раз. Поэтому ложные пробуждения становятся скучными: «ну проснулся и проснулся, проверил — не готово — снова спать».
Ещё один важный нюанс: предикат вызывается под захваченным mutex. То есть проверка !q.empty() синхронизирована с тем, как producer делает q.push(...). Это и есть «единая дисциплина доступа к состоянию».
Ручной while как честная “расшифровка” предиката
Иногда полезно увидеть «что там внутри» у wait(lock, predicate). В реальности вы почти всегда будете писать именно вариант с предикатом (он короче и меньше шанс ошибиться). Но понимание ручного эквивалента — как понимание таблицы умножения: вроде калькулятор есть, но без этого в голове ощущение шаткое.
#include <condition_variable>
#include <mutex>
#include <queue>
extern std::mutex m;
extern std::condition_variable cv;
extern std::queue<int> q;
int pop_good_while() {
std::unique_lock<std::mutex> lock(m);
while (q.empty()) {
cv.wait(lock); // проснулся? не верим. проверяем снова.
}
int x = q.front();
q.pop();
return x;
}
Заметьте, что здесь while, а не if. Именно это и есть «лекарство» от ложных пробуждений и конкуренции между несколькими consumer’ами. Проснулся — проверил — если всё ещё пусто, снова ждёшь.
Если вы запомните из лекции только одну фразу, пусть будет эта: wait почти никогда не должен жить под if.
4. Lost wakeup: как “потерять уведомление”
Ложные пробуждения пугают, потому что звучат как «рандом». Но есть ещё один сценарий, который ломает наивный код даже без рандома: потерянное уведомление (lost wakeup). Это когда уведомление произошло «раньше», чем поток реально вошёл в ожидание.
Представьте такой сюжет. consumer проверяет q.empty(), видит пусто, собирается делать wait. В этот момент producer кладёт элемент и делает notify_one(). Но consumer ещё не успел реально уснуть. Потом consumer всё-таки вызывает wait и… может уснуть навсегда, потому что “событие” уже прошло и не обязано копиться.
Правильный протокол защищает от этого просто: мы ждём состояние, и если оно уже истинно, мы вообще не засыпаем.
Посмотрите на разницу:
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = true; // уже готово!
void consumer_bad() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock); // может уйти спать, хотя ready == true
}
void consumer_good() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; }); // не ждёт, если уже готово
}
Вторая версия не «ловит сигнал», а проверяет: «готово ли прямо сейчас?» Если готово — продолжаем. Если нет — ждём, но так, чтобы просыпаться и перепроверять. Именно это делает систему устойчивой к «уведомление пришло слишком рано» и к «уведомление пришло, но не про нас».
5. Как писать предикат: чистота, mutex и несколько consumer’ов
Многие впервые увидев wait(lock, predicate) думают: «О, круто! Туда можно засунуть любую логику». И иногда туда пытаются засунуть что-нибудь вроде «если пусто — печатать лог, увеличивать счётчик, отправлять метрики». Это путь к странным багам: предикат может быть вызван много раз, в неожиданные моменты, и вы получите “дубли” или нарушите инварианты.
Правильный предикат — это чистая проверка состояния. Он читает защищённые данные и возвращает bool. Всё.
Плохая идея (побочный эффект — счётчик):
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
int wake_checks = 0;
bool ready = false;
void wait_bad_predicate() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] {
++wake_checks; // ПЛОХО: побочный эффект
return ready;
});
}
Хорошая идея (чистая проверка):
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = false;
void wait_good_predicate() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; }); // только bool-проверка
}
Ещё один принцип: предикат должен проверять состояние, которое защищено тем же mutex, что и wait. Если вы читаете внутри предиката переменную, которую где-то меняете без этого mutex, вы сами ломаете дисциплину синхронизации. condition_variable вас не спасёт, он не «магический барьер», он лишь механизм сна/пробуждения вокруг правильно защищённого состояния.
Несколько consumer’ов: предикат нужен даже если «notify был честный»
В producer–consumer модели часто есть несколько потоков-потребителей. Например, один producer кладёт задачи, а два worker’а их разгребают. В этом мире notify_one() разбудит одного, но иногда notify_all() разбудит всех, а иногда ОС разбудит не того, кого вы ожидали. И даже если разбудили всех «по делу», задача в очереди — одна.
Именно тут предикат перестаёт быть “страховкой на редкий случай” и становится обычной необходимостью: каждый проснувшийся поток обязан убедиться, что работа действительно есть именно сейчас.
Схематично это выглядит так:
sequenceDiagram
participant P as Producer
participant C1 as Consumer#1
participant C2 as Consumer#2
C1->>C1: wait(...)
C2->>C2: wait(...)
P->>P: push(task)
P->>C1: notify_all (или notify_one, но разбудили C1/C2 из-за планировщика)
C1->>C1: проснулся, проверил predicate -> true, pop()
C2->>C2: проснулся, проверил predicate -> false, снова wait()
Без предиката второй consumer мог бы «поехать» дальше и попытаться достать элемент, которого уже нет. С предикатом он просто спокойно вернётся в сон.
6. Встраиваем в приложение: делаем TaskQueue::pop() корректным
Чтобы не размазывать по коду правила вида «здесь lock, здесь wait, здесь predicate», обычно делают маленький класс-обёртку: очередь + mutex + condition_variable. Сейчас мы делаем шаг ближе к «правильному коду»: добавляем pop() так, чтобы он был безопасным относительно ложных пробуждений.
Начнём с минимального каркаса:
#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>
class TaskQueue {
public:
void push(int x);
std::optional<int> pop(); // ждёт: "есть задача или закрыто"
void close();
private:
std::mutex m_;
std::condition_variable cv_;
std::queue<int> q_;
bool closed_ = false;
};
Реализация push должна менять состояние под mutex, а уведомлять уже после:
#include <mutex>
void TaskQueue::push(int x) {
{
std::lock_guard<std::mutex> lock(m_);
q_.push(x);
}
cv_.notify_one();
}
Теперь самое интересное: pop. Мы ждём не просто «очередь не пуста», а «очередь не пуста или закрыто». И делаем это через предикат.
#include <optional>
std::optional<int> TaskQueue::pop() {
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [&] { return closed_ || !q_.empty(); });
if (q_.empty()) {
return std::nullopt; // закрыто и пусто
}
int x = q_.front();
q_.pop();
return x;
}
Почему это хороший стиль именно с точки зрения ложных пробуждений? Потому что даже если wait вернулся «просто так», предикат тут же скажет: «closed_ ложь и очередь пуста» — и поток снова уснёт. А если проснулся конкурент и забрал элемент, второй поток после пробуждения снова увидит пустую очередь и тоже спокойно уйдёт ждать.
Осталось закрытие. Закрытие почти всегда будит всех, потому что «условие выхода» должно стать видимым для всех ожидающих:
#include <mutex>
void TaskQueue::close() {
{
std::lock_guard<std::mutex> lock(m_);
closed_ = true;
}
cv_.notify_all();
}
Если вы случайно сделаете здесь notify_one(), второй consumer может остаться спать навсегда. Это уже тема отдельного разговора про shutdown-протоколы, но важный мостик вы здесь почувствовали: предикат ожидания часто включает “работа или закрытие”.
Таблица‑памятка: что писать в реальном коде
Когда вы пишете многопоточность впервые, хочется держать всё в голове. Но голова не резиновая, да и честно говоря, ей есть чем заняться кроме запоминания протоколов. Поэтому вот компактная таблица, которую полезно мысленно приклеить к condition_variable.
| Сценарий в коде | Наивная запись | Почему плохо | Правильная запись |
|---|---|---|---|
| «Подожду, пока очередь не пуста» | |
ложные пробуждения, конкуренция между consumer’ами | |
| «Подожду “готово”» | |
lost wakeup: можно уснуть при уже истинном условии | |
| «Хочу подсчитать, сколько раз проснулся» | побочные эффекты в предикате | предикат вызывается много раз | предикат только bool, статистику отдельно |
7. Типичные ошибки при работе с ложными пробуждениями
Ошибка №1: использовать wait(lock) как “ждать notify”, а потом сразу работать с данными.
Это самая частая ловушка, потому что код выглядит минимально. Но wait не обещает, что после пробуждения условие истинно. Результат — попытки взять front() у пустой очереди или уменьшить счётчик ниже нуля. Лечится это скучно и надёжно: ждать только через wait(lock, predicate) либо через ручной while.
Ошибка №2: ставить if вместо while “потому что один раз же достаточно”.
На практике «одного раза» не существует. Даже если вы уверены, что producer делает notify строго после push, остаются ложные пробуждения и конкуренция между несколькими consumer’ами. if делает проверку один раз и затем верит в удачу. while делает проверку каждый раз и верит в математику.
Ошибка №3: писать предикат с побочными эффектами.
Предикат — не место для логирования, изменения счётчиков, “подчищания очереди” и прочих действий. Он может выполняться много раз, и это “много раз” не всегда совпадает с вашими ожиданиями. Держите его чистым: только проверка состояния и bool-ответ.
Ошибка №4: проверять в предикате состояние, которое изменяется без этого же mutex.
Иногда хочется “оптимизировать” и читать closed_ без блокировки, а писать под блокировкой, или наоборот. Это ломает дисциплину «одно состояние — один mutex», и дальше вы уже не можете рассуждать о корректности протокола. В нашем стиле сегодня правило простое: всё, что участвует в предикате ожидания, читается и пишется под тем же mutex.
Ошибка №5: путать “проснулся” с “можно продолжать”.
Пробуждение означает только одно: «пришло время перепроверить состояние». Никаких гарантий, что работа появилась, никто не отнял задачу раньше, и что вы не проснулись без причины, вам не дают. Как ни странно, это хорошая новость: вы пишете код, который корректен при любых пробуждениях — и он становится гораздо надёжнее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ