JavaRush /Курсы /C++ SELF /Ложные пробуждения — почему predicate обязателен

Ложные пробуждения — почему predicate обязателен

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

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.

Сценарий в коде Наивная запись Почему плохо Правильная запись
«Подожду, пока очередь не пуста»
if (q.empty()) cv.wait(lock);
ложные пробуждения, конкуренция между consumer’ами
cv.wait(lock, []{ return !q.empty(); });
«Подожду “готово”»
cv.wait(lock);
lost wakeup: можно уснуть при уже истинном условии
cv.wait(lock, []{ return ready; });
«Хочу подсчитать, сколько раз проснулся» побочные эффекты в предикате предикат вызывается много раз предикат только 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: путать “проснулся” с “можно продолжать”.
Пробуждение означает только одно: «пришло время перепроверить состояние». Никаких гарантий, что работа появилась, никто не отнял задачу раньше, и что вы не проснулись без причины, вам не дают. Как ни странно, это хорошая новость: вы пишете код, который корректен при любых пробуждениях — и он становится гораздо надёжнее.

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