Если программа на C++ зависает при вызове notify_one(), а поток-потребитель так и не просыпается, почти всегда причина в том, что условие проверяется без предиката или уведомление отправлено до того, как поток встал в wait(). Метод notify_one будит ровно один ожидающий поток, но не сохраняет «факт вызова»: если никто не ждёт в момент уведомления, сигнал просто теряется.
Эта статья разбирает, как работает связка std::condition_variable и notify_one(), почему возникают ложные пробуждения, чем notify_one отличается от notify_all и как написать корректный код синхронизации потоков без гонок и взаимных блокировок.
Что такое condition_variable и зачем нужен notify_one
std::condition_variable — это примитив синхронизации из стандартной библиотеки C++ (заголовок <condition_variable>), который позволяет потоку «уснуть» в ожидании некоторого условия и не тратить процессорное время на активный опрос. Вместо цикла с проверкой флага поток вызывает wait() и освобождает мьютекс, а другой поток, изменивший состояние, уведомляет его через notify_one() или notify_all().
Метод notify_one() пробуждает один из потоков, ожидающих на данной condition_variable. Какой именно — стандарт не гарантирует, выбор зависит от реализации и планировщика ОС. Если ожидающих потоков нет, вызов ни к чему не приводит: уведомление не накапливается и не «дожидается» будущих подписчиков.
Типичный сценарий — очередь задач (producer-consumer): рабочие потоки ждут появления задач, а поток-производитель после добавления элемента в очередь вызывает notify_one(), чтобы разбудить одного исполнителя. Будить всех в этом случае неэффективно — задача одна, и лишние пробуждения только создают конкуренцию за мьютекс.
Минимальный рабочий пример
Корректный паттерн использования всегда включает три элемента: мьютекс, условие (обычно переменная или проверка очереди) и цикл ожидания. Вот канонический пример с очередью:
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> tasks;
// Поток-потребитель
void worker() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !tasks.empty(); });
int task = tasks.front();
tasks.pop();
lock.unlock();
// обработка задачи вне мьютекса
}
// Поток-производитель
void producer(int value) {
{
std::lock_guard<std::mutex> lock(mtx);
tasks.push(value);
}
cv.notify_one();
}
Обратите внимание на детали. Потребитель использует std::unique_lock, потому что wait() требует именно его — этот тип лока умеет временно освобождать и повторно захватывать мьютекс. Производитель сначала изменяет данные под мьютексом, затем вызывает notify_one() уже после выхода из критической секции — это не обязательно, но обычно эффективнее, так как разбуженный поток не будет сразу блокироваться на ещё занятом мьютексе.
Почему нужен предикат: ложные пробуждения
Стандарт C++ допускает spurious wakeup — ситуацию, когда wait() возвращается без всякого вызова notify. Это не ошибка реализации, а осознанное разрешение, связанное с особенностями работы ОС. Поэтому проверка условия после пробуждения обязательна, даже если вы уверены, что уведомление было отправлено.
Вторая причина — украденное уведомление. Если несколько потоков ждут на одной condition_variable, а вызван notify_one(), проснётся один поток, но к моменту захвата им мьютекса условие может снова стать ложным: другой поток успел забрать задачу. Без цикла проверки такой поток начнёт обрабатывать несуществующие данные.
Эквивалентная ручная запись без предиката выглядит так:
std::unique_lock<std::mutex> lock(mtx);
while (tasks.empty()) {
cv.wait(lock);
}
// теперь условие точно выполнено
Обе формы — и цикл while, и wait(lock, pred) — семантически одинаковы. Форма с предикатом короче и меньше подвержена ошибкам, поэтому в современном коде используют именно её.
⚠️ Внимание: никогда не заменяйте цикл проверки условия на if. Одиночная проверка пропускает ложные пробуждения и приводит к обработке невалидных данных — такие ошибки проявляются редко и плохо воспроизводятся при отладке.
notify_one против notify_all: что выбрать
Выбор между двумя методами определяется логикой приложения. notify_one() подходит, когда пробуждения достаточно одному потоку и остальные должны продолжить ждать. notify_all() нужен, когда изменилось глобальное состояние, важное для всех ожидающих: завершение работы, смена режима, готовность разделяемого ресурса.
| Критерий | notify_one | notify_all |
|---|---|---|
| Будит потоков | Один (какой — не гарантируется) | Все ожидающие |
| Типичный сценарий | Одна новая задача в очереди | Сигнал завершения, смена состояния |
| Накладные расходы | Минимальные | Выше: все потоки конкурируют за мьютекс |
| Риск при ошибке | Зависание, если нужно было разбудить всех | Лишние пробуждения, но безопаснее |
Классическая ошибка — использовать notify_one() для сигнала остановки. Если ждут три рабочих потока, а вы отправили одно уведомление, два останутся спать навсегда, и программа зависнет при завершении. Для broadcast-событий всегда применяйте notify_all().
Типичные ошибки и их диагностика
Большинство проблем с notify_one() сводится к нескольким повторяющимся паттернам. Проверьте их в первую очередь, если потоки зависают или теряют задачи:
- 🔔 Уведомление до wait: производитель изменил данные и вызвал notify до того, как потребитель встал в ожидание. Потребитель при этом не проверил условие заранее. Лечится предикатом: если условие уже истинно,
waitс предикатом вообще не уснёт. - 🔒 Изменение условия без мьютекса: флаг или очередь меняются вне критической секции. Это гонка данных и неопределённое поведение, даже если «обычно работает».
- 🧩 Разные мьютексы: ожидание идёт с одним мьютексом, а данные защищены другим. Синхронизации фактически нет.
- 📢 notify_one вместо notify_all: при завершении работы разбужен только один поток, остальные висят в wait бесконечно.
- ⏱️ Ожидание без таймаута: для диагностики зависаний полезно временно заменить
waitнаwait_forи логировать, действительно ли условие остаётся ложным.
☑️ Проверка корректности синхронизации
Ожидание с таймаутом: wait_for и wait_until
Когда бесконечное ожидание недопустимо — например, поток должен периодически проверять флаг остановки или реагировать на деградацию системы, — используют wait_for() и wait_until(). Обе версии поддерживают предикат и возвращают false (или std::cv_status::timeout в варианте без предиката), если время истекло, а условие так и не выполнилось.
std::unique_lock<std::mutex> lock(mtx);
bool ready = cv.wait_for(lock, std::chrono::milliseconds(100),
[] { return !tasks.empty(); });
if (!ready) {
// таймаут: задач нет, можно проверить флаг остановки
}
Здесь notify_one() по-прежнему работает как «ускоритель»: поток проснётся сразу при появлении задачи, не дожидаясь истечения интервала. Это сочетание отзывчивости и защиты от вечного зависания — стандартный приём для фоновых рабочих потоков.
Почему wait требует именно unique_lock
Метод wait должен атомарно освободить мьютекс и уснуть, а при пробуждении — снова захватить его. std::lock_guard не имеет методов unlock/lock, поэтому не подходит. unique_lock предоставляет гибкое управление владением мьютексом, что и требуется condition_variable.
Альтернативы в современном C++
Начиная с C++20 доступны дополнительные примитивы, которые в ряде задач проще condition_variable. std::latch подходит для одноразового ожидания группы потоков, std::barrier — для повторяющейся синхронизации фаз, а std::counting_semaphore естественно моделирует ограниченные ресурсы. Для одноразового сигнала «результат готов» удобна связка std::promise/std::future.
Тем не менее condition_variable с notify_one остаётся базовым инструментом для очередей и долгоживущих циклов ожидания: он гибок, поддерживается везде и не требует C++20. Если проект собирается старым стандартом, альтернатив ему по сути нет.
⚠️ Внимание: не используйте condition_variable совместно сstd::recursive_mutex— ожидание на нём черезwait()приводит к неопределённому поведению. Для таких случаев существуетstd::condition_variable_any, но необходимость в нём обычно сигнализирует о проблемах в дизайне блокировок.
Часто задаваемые вопросы
Что произойдёт, если вызвать notify_one, когда никто не ждёт?
Ничего. Уведомление не сохраняется и не ставится в очередь — вызов просто игнорируется. Именно поэтому потребитель обязан проверять условие перед входом в wait: если данные уже готовы, предикат не даст потоку уснуть.
Можно ли вызывать notify_one, удерживая мьютекс?
Можно, это не ошибка. Но обычно эффективнее вызывать его после освобождения мьютекса: разбуженный поток сразу сможет захватить лок, а не встанет в очередь на занятый мьютекс.
Просыпается ли «самый долгождавший» поток при notify_one?
Стандарт этого не гарантирует. Выбор потока зависит от реализации библиотеки и планировщика операционной системы. Если порядок обработки критичен, его нужно обеспечивать на уровне логики приложения, а не примитива синхронизации.
Чем отличается ложное пробуждение от потери уведомления?
Ложное пробуждение — это возврат из wait без вызова notify, разрешённый стандартом. Потеря уведомления — ситуация, когда notify вызван до входа потока в wait и сигнал пропал. От обеих проблем защищает один приём: проверка условия через предикат или цикл while.
Нужен ли notify_all при завершении программы?
Да, если на condition_variable может ждать больше одного потока. Установите флаг остановки под мьютексом и вызовите notify_all — тогда все потоки проснутся, увидят флаг в предикате и корректно завершатся. Одиночный notify_one разбудит лишь один поток, остальные зависнут.