Программирование C++Современный C++C++ разработчик серверных и многопоточных систем

Практическая ситуация: поток должен ждать изменения атомарного состояния без активного опроса. Какой механи...

Практическая ситуация: поток должен ждать изменения атомарного состояния без активного опроса. Какой механизм C++20 выбрать?

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

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

Выберите операции std::atomic::wait и notify_one/notify_all. Поток передаёт в wait последнее наблюдавшееся значение и блокируется, пока атомарный объект не начнёт содержать другое значение; активное ожидание и внешний мьютекс не требуются.

Уведомление не является самостоятельным событием: оно лишь заставляет ожидающие потоки повторно проверить атомарное состояние. Поэтому условие нужно выражать значением атомика и проверять в цикле.

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

До C++20 для ожидания изменения атомарного флага обычно использовали std::condition_variable вместе с мьютексом, периодический опрос или активное ожидание. У каждого подхода были недостатки: условная переменная требовала отдельного объекта синхронизации, опрос расходовал процессорное время, а активное ожидание плохо подходило для длительных пауз.

C++20 добавил блокирующее ожидание непосредственно к атомарным типам. Реализация может эффективно припарковать поток, а затем разбудить его после изменения состояния или уведомления.

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

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

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

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

wait(old) сначала сравнивает текущее значение атомика с old. Если значения различаются, блокировка не требуется. Если они совпадают, поток может быть заблокирован; после изменения или уведомления операция снова проверяет значение.

Производитель сначала публикует новое состояние, затем вызывает уведомление:

#include <atomic> std::atomic<int> state{0}; void consumer() { int old = state.load(std::memory_order_acquire); while (old != 1) { state.wait(old, std::memory_order_acquire); old = state.load(std::memory_order_acquire); } } void producer() { state.store(1, std::memory_order_release); state.notify_all(); }

Пара store(..., release) и успешное чтение с acquire обеспечивает публикацию данных, записанных до изменения состояния. notify_one будит одного ожидающего, а notify_all — всех; выбор зависит от того, достаточно ли одному потоку обработать новое состояние.

wait не гарантирует, что пробуждение произошло именно из-за нужного перехода. После возврата состояние могло измениться снова, поэтому цикл с проверкой предиката обязателен. Допустимые порядки памяти для wait не должны содержать только освобождающую семантику: release и acq_rel для этого аргумента недопустимы.

Механизм не является очередью уведомлений. Если состояние изменилось с 0 на 1, а затем обратно на 0 до проверки ожидающим потоком, переход может остаться незамеченным: это классическая проблема ABA. Если важен каждый переход, нужно хранить счётчик поколений или использовать очередь событий.

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

В очереди фоновых задач рабочий поток ожидал флаг готовности. Вариант с циклом load и yield потреблял процессор даже при отсутствии задач. Вариант с условной переменной решал проблему, но требовал мьютекса и отдельной дисциплины доступа к флагу.

Был выбран атомарный счётчик поколения с wait и notify_all. Производитель увеличивал счётчик после публикации новой партии задач, а рабочие потоки ждали изменения последнего известного поколения. Такой вариант не терял уведомление, произошедшее до входа в ожидание, и не путал повторные изменения состояния с одним булевым флагом.

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

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

  1. Чем atomic::wait отличается от ожидания на условной переменной?

atomic::wait наблюдает непосредственно атомарное значение и принимает ожидаемое старое значение. Благодаря этому проверка состояния и решение о блокировке связаны с атомарным объектом, а отдельный мьютекс для самого предиката не нужен. Условная переменная обычно применяется в паре с мьютексом и предикатом, защищённым этим мьютексом.

Однако atomic::wait не заменяет мьютекс для сложного составного состояния. Если условие зависит от нескольких объектов или требуется эксклюзивно извлекать элементы из контейнера, атомарного ожидания недостаточно.

  1. Можно ли вызвать notify_one до wait и потерять сигнал?

Само уведомление действительно не накапливается. Но корректный ожидающий поток сначала сравнивает текущее значение атомика с переданным старым значением. Если производитель уже изменил значение, wait сразу завершится и не будет ждать прежнее уведомление.

Это защищает от обычной гонки между изменением состояния и входом в ожидание. Защита не распространяется на схему, где состояние успело измениться и вернуться назад: переход 0 → 1 → 0 может быть незаметен.

  1. Как выбрать notify_one или notify_all?

notify_one подходит, когда одно изменение состояния может быть обработано одним из конкурирующих потоков, например при появлении одной задачи. Он уменьшает число пробуждений и последующих конкурентных проверок.

notify_all нужен, когда новое состояние должно быть доступно всем ожидающим или когда каждый поток имеет собственное условие, зависящее от общего изменения. Цена — массовое пробуждение: часть потоков может снова заснуть, если их индивидуальное условие всё ещё ложно.