Что может произойти, если поток ожидает условную переменную один раз, не проверяя предикат после пробуждения?

Что может произойти, если поток ожидает условную переменную один раз, не проверяя предикат после пробуждения?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Поток может продолжить работу, хотя требуемое условие всё ещё ложно. std::condition_variable не гарантирует, что пробуждение означает выполнение условия: возможны ложные пробуждения и уведомления, относящиеся к другому изменению состояния. Поэтому ожидание выполняют в цикле по предикату, обычно через перегрузку wait с предикатом.

Исторический контекст

Условные переменные появились как эффективный механизм ожидания изменения состояния без постоянного опроса и расходования процессорного времени. В C++11 они стали частью стандартной библиотеки вместе с потоками и mutex’ами.

Изначально механизм решал задачу координации: один поток изменяет состояние, другой блокируется до тех пор, пока это состояние не станет подходящим. Уведомление при этом является лишь сигналом проверить состояние, а не сохранённым событием с гарантированной доставкой.

Постановка проблемы

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

Кроме того, уведомление, отправленное до фактического перехода ожидающего потока в состояние ожидания, не ставится в очередь как событие. Без защищённого предиката поток может уснуть навсегда или продолжить работу при неподходящем состоянии.

Подробное решение

Правильный протокол состоит из трёх частей:

  1. Состояние-предикат защищается тем же mutex’ом, который используется для ожидания.
  2. Ожидающий поток проверяет предикат в цикле.
  3. Изменяющий поток меняет состояние под mutex’ом и затем уведомляет один или несколько ожидающих потоков.
#include <condition_variable> #include <mutex> std::mutex mutex; std::condition_variable condition; bool ready = false; void consumer() { std::unique_lock<std::mutex> lock(mutex); condition.wait(lock, [] { return ready; }); // Здесь ready гарантированно истинно под защитой mutex. } void producer() { { std::lock_guard<std::mutex> lock(mutex); ready = true; } condition.notify_one(); }

Перегрузка wait с предикатом фактически повторяет проверку условия в цикле: mutex временно освобождается на время блокировки, а перед возвратом снова захватывается. Если пробуждение произошло при ложном предикате, поток снова засыпает.

Уведомление обычно выполняют после изменения состояния. Уведомлять, удерживая mutex, также допустимо, но это может привести к лишнему переключению: разбуженный поток немедленно упрётся в занятый mutex. Главное требование — корректно синхронизировать изменение и проверку предиката.

notify_one будит одного ожидающего, а notify_all — всех. После notify_all только потоки, для которых предикат стал истинным, продолжат выполнение; остальные снова заблокируются. Выбор между ними — компромисс между производительностью и необходимостью разбудить несколько разных типов потребителей.

Ситуация из практики

В пуле потоков рабочие потоки ждут задания. Наивный вариант будит поток и сразу заставляет его извлекать задание. Он ломается при ложном пробуждении и при конкуренции нескольких рабочих потоков за одно задание.

Рассматривались два решения:

  • постоянный опрос очереди — прост в реализации, но расходует CPU и увеличивает задержку или требует искусственной паузы;
  • ожидание условной переменной без предиката — экономит CPU, но допускает работу при пустой очереди;
  • ожидание в цикле по условию очередь не пуста или пул завершается — корректно обрабатывает конкуренцию, ложные пробуждения и остановку.

Выбрано третье решение. Состояние очереди и флаг завершения защищаются mutex’ом, а после добавления задания вызывается notify_one, при завершении пула — notify_all. В результате потоки не потребляют CPU во время простоя и не обращаются к пустой очереди.

Что кандидаты часто упускают

1. Почему уведомление условной переменной не является сохранённым событием?

notify_one или notify_all не увеличивает счётчик уведомлений. Если в момент уведомления подходящий поток ещё не ожидает, сигнал может не иметь последующего эффекта. Поэтому важен не сам сигнал, а состояние-предикат: ожидающий поток сначала проверяет его под mutex’ом, а уведомление лишь ускоряет повторную проверку.

2. Зачем изменять предикат под тем же mutex’ом, который используется в wait?

Это делает проверку состояния и переход к ожиданию единой синхронизированной последовательностью. Поток не может проверить ложное значение, затем потерять изменение состояния между освобождением mutex’а и засыпанием, если другой поток меняет предикат под тем же mutex’ом.

Если предикат читать и менять без необходимой синхронизации, возникает гонка данных для обычной переменной. Даже если отдельный флаг атомарен, нужно отдельно доказать корректность всего протокола ожидания и взаимосвязь этого флага с другими данными.

3. Что изменится, если после notify_all проснутся десять потоков, а задание только одно?

Все десять потоков получат возможность снова проверить предикат, но mutex будет захвачен только одним из них. Если предикат связан с непустой очередью, один поток заберёт задание, а остальные после повторной проверки увидят пустую очередь и снова уснут.

Это корректно, но может быть менее эффективно из-за эффекта «стада»: множество потоков просыпается без полезной работы. Поэтому для независимых единичных заданий обычно выбирают notify_one, а notify_all оставляют для смены глобального состояния, например завершения пула.