notify_one в C++: уведомление одного потока через condition_variable

Если программа на 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_onenotify_all
Будит потоковОдин (какой — не гарантируется)Все ожидающие
Типичный сценарийОдна новая задача в очередиСигнал завершения, смена состояния
Накладные расходыМинимальныеВыше: все потоки конкурируют за мьютекс
Риск при ошибкеЗависание, если нужно было разбудить всехЛишние пробуждения, но безопаснее

Классическая ошибка — использовать notify_one() для сигнала остановки. Если ждут три рабочих потока, а вы отправили одно уведомление, два останутся спать навсегда, и программа зависнет при завершении. Для broadcast-событий всегда применяйте notify_all().

📊 Какой сценарий у вас чаще всего?
Очередь задач (producer-consumer)
Сигнал завершения потоков
Ожидание готовности ресурса
Синхронизация этапов конвейера

Типичные ошибки и их диагностика

Большинство проблем с notify_one() сводится к нескольким повторяющимся паттернам. Проверьте их в первую очередь, если потоки зависают или теряют задачи:

  • 🔔 Уведомление до wait: производитель изменил данные и вызвал notify до того, как потребитель встал в ожидание. Потребитель при этом не проверил условие заранее. Лечится предикатом: если условие уже истинно, wait с предикатом вообще не уснёт.
  • 🔒 Изменение условия без мьютекса: флаг или очередь меняются вне критической секции. Это гонка данных и неопределённое поведение, даже если «обычно работает».
  • 🧩 Разные мьютексы: ожидание идёт с одним мьютексом, а данные защищены другим. Синхронизации фактически нет.
  • 📢 notify_one вместо notify_all: при завершении работы разбужен только один поток, остальные висят в wait бесконечно.
  • ⏱️ Ожидание без таймаута: для диагностики зависаний полезно временно заменить wait на wait_for и логировать, действительно ли условие остаётся ложным.

☑️ Проверка корректности синхронизации

Выполнено: 0 / 5

Ожидание с таймаутом: 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 разбудит лишь один поток, остальные зависнут.