Что может произойти, если поток ожидает условную переменную один раз, не проверяя предикат после пробуждения?
Поток может продолжить работу, хотя требуемое условие всё ещё ложно. std::condition_variable не гарантирует, что пробуждение означает выполнение условия: возможны ложные пробуждения и уведомления, относящиеся к другому изменению состояния. Поэтому ожидание выполняют в цикле по предикату, обычно через перегрузку wait с предикатом.
Условные переменные появились как эффективный механизм ожидания изменения состояния без постоянного опроса и расходования процессорного времени. В C++11 они стали частью стандартной библиотеки вместе с потоками и mutex’ами.
Изначально механизм решал задачу координации: один поток изменяет состояние, другой блокируется до тех пор, пока это состояние не станет подходящим. Уведомление при этом является лишь сигналом проверить состояние, а не сохранённым событием с гарантированной доставкой.
Предположим, поток ждёт, пока очередь станет непустой. Если после пробуждения он сразу извлекает элемент, возможны две проблемы: пробуждение может быть ложным, либо другой поток уже забрал последний элемент.
Кроме того, уведомление, отправленное до фактического перехода ожидающего потока в состояние ожидания, не ставится в очередь как событие. Без защищённого предиката поток может уснуть навсегда или продолжить работу при неподходящем состоянии.
Правильный протокол состоит из трёх частей:
Перегрузка wait с предикатом фактически повторяет проверку условия в цикле: mutex временно освобождается на время блокировки, а перед возвратом снова захватывается. Если пробуждение произошло при ложном предикате, поток снова засыпает.
Уведомление обычно выполняют после изменения состояния. Уведомлять, удерживая mutex, также допустимо, но это может привести к лишнему переключению: разбуженный поток немедленно упрётся в занятый mutex. Главное требование — корректно синхронизировать изменение и проверку предиката.
notify_one будит одного ожидающего, а notify_all — всех. После notify_all только потоки, для которых предикат стал истинным, продолжат выполнение; остальные снова заблокируются. Выбор между ними — компромисс между производительностью и необходимостью разбудить несколько разных типов потребителей.
В пуле потоков рабочие потоки ждут задания. Наивный вариант будит поток и сразу заставляет его извлекать задание. Он ломается при ложном пробуждении и при конкуренции нескольких рабочих потоков за одно задание.
Рассматривались два решения:
очередь не пуста или пул завершается — корректно обрабатывает конкуренцию, ложные пробуждения и остановку.Выбрано третье решение. Состояние очереди и флаг завершения защищаются 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 оставляют для смены глобального состояния, например завершения пула.