Ложные пробуждения — почему условные переменные просыпаются «без уведомления» и как правильно ждать в Windows
· Го Комура · Windows, Многопоточность, Условные переменные, Синхронизация, C++, C#, Win32 API, Устранение неполадок
«Положили данные в очередь и будим ждущий рабочий поток. Полгода работало, потом в один день попыталось прочитать пустую очередь и упало.» «Мы отправляем уведомление, но время от времени поток так и не просыпается.» — Многопоточное рандеву выглядит так, будто работает, и является рассадником багов, которые появляются только редко. Расследования такого рода часто приземляются на код, который оборачивает wait условной переменной в if. А за этим сидит ложное пробуждение — явление возврата из wait без получения уведомления.
«Просыпается, хотя никто не уведомлял» звучит как дефект реализации, но это поведение, которое Win32, C++ и POSIX все прописывают в документации или стандартах, а Monitor .NET спроектирован из допущения, что «проснувшись, вы перепроверяете условие». Почему это поведение допускается? На каком слое оно происходит в Windows? И как писать ожидание, чтобы никогда в это не попасть? Рассчитанная на разработчиков, которые пишут бизнес-приложения и ПО управления оборудованием под Windows, эта статья разбирает, чем на самом деле является ложное пробуждение, по первичным источникам и сводит правильное ожидание к Win32 (C), C++ и C#.
1. Сначала вывод
waitусловной переменной может вернуться, даже когда уведомление не пришло. Официальная документация Win32 говорит, что условные переменные подвержены ложным пробуждениям (пробуждениям, не привязанным к явному пробуждению) и украденным пробуждениям (другой поток потребляет условие раньше разбуженного).1- Поэтому ожидание всегда нужно писать как «цикл while плюс перепроверка условия». Код, который проверяет один раз через
ifи затемwait, выглядит так, будто работает, и таит баг, который воспроизводится только редко.12 - Это не причуда, специфичная для Windows; POSIX и стандарт C++ говорят то же самое. Реализация, которая «абсолютно никогда ложно не просыпается», замедлила бы каждую операцию условной переменной, поэтому пробуждение допускается из допущения, что ждущий перепроверит.34
- В C++ форма предиката
wait(lock, pred)заставляет библиотеку выполнить цикл за вас. Эта форма фактически выполняетwhile (!pred()) wait(lock);. Это значение по умолчанию для нового кода.5 Monitor.Waitв C# нужна та же дисциплина. Условие может быть потреблено в интервале между пробуждением и повторным захватом блокировки, поэтому вы перепроверяете условие вwhileи возвращаетесь кWait.6- Обновляйте и проверяйте условие под одной и той же блокировкой. Если смотрите на условие вне блокировки и затем входите в
wait, уведомление может пройти сквозь щель — потерянное пробуждение.1 - Не воссоздавайте переходное уведомление условной переменной «будить того, кто ждёт прямо сейчас» импульсом на событии.
PulseEventв частности может пропустить уведомление в миг, когда APC режима ядра кратко снимает ожидание, и сама Microsoft говорит прямым текстом: «это ненадёжно, не используйте, используйте вместо этого условную переменную».7
Дальше по порядку разбираются механизмы, которые поддерживают этот вывод.
2. Что такое ложное пробуждение — проснуться не значит, что условие держится
Условная переменная — примитив синхронизации для «усыпить поток, пока не будет держаться какое-то условие, и разбудить его, когда будет». В Win32 это структура CONDITION_VARIABLE вместе с SleepConditionVariableCS / SleepConditionVariableSRW (ожидание) и WakeConditionVariable / WakeAllConditionVariable (уведомление). API ожидания атомарно освобождает удерживаемую вами блокировку (критическую секцию или SRW-блокировку) и засыпает, а при пробуждении заново захватывает блокировку, прежде чем вернуться.1
Вопрос в том, что на самом деле значит факт «вернулся из wait». Наивно хочется думать «уведомление пришло = условие держится», но в реальности есть три случая, в которых wait возвращается.
| Случай | Уведомление | Условие при возврате |
|---|---|---|
| Подлинное пробуждение | Да | Часто удовлетворено, но не гарантировано |
| Ложное пробуждение | Никакого, адресованного вам | Всё ещё неудовлетворено |
| Украденное пробуждение | Да | Другой поток потребил его первым; неудовлетворено |
flowchart TB
accTitle: Три случая, в которых wait возвращается
accDescr: Ожидание условной переменной может вернуться не только от подлинного уведомления, но и от ложного пробуждения без уведомления и от украденного пробуждения, где уведомление пришло, но условие потребили первым, поэтому каждый случай нуждается в перепроверке условия
w["Вернулся из wait"] --> a["Подлинное уведомление"]
w --> b["Ложное пробуждение (нет уведомления)"]
w --> c["Украденное пробуждение (условие уже потреблено)"]
a --> r["Перепроверить условие, затем продолжить"]
b --> r
c --> r
Рис. 1: Есть три пути назад из wait, и вызывающий не может сказать, каким он прошёл, поэтому условие всегда нужно перепроверять.
Ложное пробуждение — этот второй случай: явление, когда API ожидания возвращается без привязки к явному уведомлению, которое должно было вас разбудить. Оно не ограничено ситуациями, в которых WakeConditionVariable нигде в системе никогда не вызывали. Например, под высокой нагрузкой, где уведомления приходят короткой очередью, реализация может разбудить лишние ждущие потоки пакетом, и со стороны, у которой нет соответствующего уведомления, это тоже ложное пробуждение. Страница условных переменных Microsoft Learn говорит это прямо: «Condition variables are subject to spurious wakeups (those not associated with an explicit wake) and stolen wakeups (another thread manages to run before the woken thread). Therefore, you should recheck a predicate (typically in a while loop) after a wait operation returns.»1
Важный пункт в том, что вызывающий не может сказать, через какой из трёх случаев он вернулся. Если нельзя сказать, доступна только одна стратегия: каждый раз при возврате проверяйте само условие, которого вы ждали, и засыпайте снова, если оно не держится. Вот реальное содержание железного правила «оборачивайте wait в while». С другой стороны, пока вы держите это правило, код корректен, каким бы из трёх случаев вас ни разбудили.
3. Почему спецификация это допускает — точное уведомление дорого
«Просыпаться без уведомления — просто неряшливая реализация, разве нет?» — справедливый вопрос. На самом деле теоретически возможно построить реализацию, которая никогда ложно не просыпается. Даже так POSIX, Windows и стандарт C++ все встали на сторону «это может случиться». Причина откровенно изложена в Rationale для pthread_cond_wait в POSIX (The Open Group Base Specifications).3
Первая причина — производительность. Пытаться реализовать уведомление, которое «надёжно будит ровно один поток», строго, особенно на многопроцессорных системах, добавляет лишнюю стоимость синхронизации к каждой операции условной переменной. Между уведомлением и пробуждением сидит планировщик, и в зависимости от момента прерываний и вытеснения нельзя избежать «другой поток выполняется раньше того, которого вы хотели разбудить». Платить всем цену полной герметизации этого хуже для того, чтобы условные переменные оставались быстрыми, чем принять, что «иногда вы можете разбудить лишних».
Вторая причина — наблюдение, что этот компромисс не ломает приложения — он на самом деле делает их устойчивее. Поскольку ложные пробуждения допускаются, корректный код всегда пишет цикл, который проверяет предикат (условие, которого ждут). Rationale POSIX говорит, что принуждение к этому циклу делает код самодокументирующимся и более устойчивым.3 Как только цикл есть, смысл уведомления понижается с «гарантии, что условие держится» до «подсказки, что условие могло измениться», и сторона ожидания становится терпимой к скромным изменениям проекта на стороне уведомляющего (будить слишком многих, будить пакетом и так далее).
Украденные пробуждения — дело ещё более структурное. Всегда есть временной зазор между тем, как уведомляющий вызывает WakeConditionVariable, и тем, как разбуженный поток заново захватывает блокировку и возвращается из wait. Если третий поток может взять блокировку в этом интервале, он может потребить условие (содержимое очереди и так далее) первым. Это зазор, который никакая полировка реализации не может стереть, потому что он идёт от самой формы инструмента условной переменной.
sequenceDiagram
accTitle: Временная шкала украденного пробуждения
accDescr: Производитель кладёт один элемент в очередь и будит ждущего потребителя A, но прежде чем A заново захватит блокировку, потребитель B захватывает блокировку и берёт один элемент, поэтому к моменту, когда A просыпается, очередь пуста
participant A as Потребитель A (ждёт)
participant P as Производитель
participant B as Потребитель B
P->>P: Добавить один элемент в очередь
P->>A: WakeConditionVariable
Note over A: Разбужен, ждёт повторного захвата блокировки
B->>B: Захватить блокировку и взять один элемент
A->>A: Заново захватить блокировку и вернуться из wait
Note over A: Очередь пуста (украдено)
A->>A: Перепроверить в цикле while и ждать снова
Рис. 2: «Украденное пробуждение», в котором третий поток потребляет условие во временном зазоре между уведомлением и пробуждением, может случиться при любой реализации.
Иными словами, даже если ОС полностью искоренила бы ложные пробуждения, пока существуют украденные пробуждения, всё равно нельзя писать «я проснулся = условие держится». Цикл перепроверки ждущего всё равно требуется, и учитывая это, дешевле допустить ложные пробуждения и держать реализацию быстрой — таково проектное суждение, которое условные переменные несут десятилетиями.
4. На каких слоях это проявляется в Windows
Это свойство показывает лицо, какой бы слой примитива синхронизации Windows вы ни использовали. Чтобы почувствовать факт, что от него нельзя уйти, против какого бы слоя API вы ни писали, посмотрим представительные слои.
Условные переменные Win32 (CONDITION_VARIABLE), как уже отмечено, на SleepConditionVariableCS / SleepConditionVariableSRW задокументированы как подверженные и ложным пробуждениям, и украденным пробуждениям, и от вас требуют перепроверять предикат в цикле while.2 Официальный образец использования (очередь производитель–потребитель) тоже пишет ожидание внутри цикла while.8
Ещё более низкоуровневый WaitOnAddress — более примитивный API ожидания, чем условная переменная: «ждать, пока значение по данному адресу не изменится» (Windows 8 и новее). Даже у этого почти донного API документация говорит: «гарантируется возврат, когда адрес сигнализирован, но также разрешено вернуться по другим причинам», и перечисляет как примеры раннего пробуждения условие нехватки памяти, отказ от предыдущего пробуждения для того же адреса и выполнение проверочной сборки. Поэтому собственный образец использования документации имеет форму «цикл while, который снова сравнивает значение».9
std::condition_variable C++ — то же самое. Документация MSVC о wait без предиката говорит, что он «блокируется, пока не будет сигнализирован вызовом notify_one / notify_all. Он также может проснуться ложно», и объясняет, что форма предиката wait(lock, pred) фактически выполняет следующий код.5
while (!Pred())
wait(Lck);
Иными словами, рекомендуемое в C++ ожидание в форме предиката — не что иное, как библиотека, которая снимает с вас «оборачивание в while», как описывает эта статья. cppreference точно так же говорит, что wait без предиката может быть разблокирован ложно.4
Monitor.Wait / Pulse .NET имеет собственную структуру очередей — очередь ожидания и очередь готовности, — но дисциплина не меняется. Поток, разбуженный Pulse / PulseAll, переходит в очередь готовности и возвращается из Wait в том порядке, в котором может заново захватить блокировку. То, что другой поток может потребить условие в интервале до повторного захвата блокировки, — то же, что в Win32, и документация тоже написана из допущения, что «разбуженный поток заново оценивает условие, из-за которого он вошёл в ожидание, и при необходимости снова вызывает Wait».610
flowchart TB
accTitle: Каждый слой требует перепроверки предиката
accDescr: Официальная документация требует перепроверять условие после пробуждения на каждом слое — C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE и низкоуровневый WaitOnAddress
cpp["C++ std::condition_variable"] --> rule["При пробуждении перепроверить условие (while)"]
net[".NET Monitor.Wait"] --> rule
win["Win32 CONDITION_VARIABLE"] --> rule
woa["WaitOnAddress"] --> rule
Рис. 3: Смените язык или каркас — и официальное требование всё равно одно и то же на каждом слое примитива ожидания: перепроверить после того, как проснулись.
5. Правильный способ ждать — пишите через while и предикат
Дальше — реализация. Принципов только три.
- Держите то, чего ждёте, как состояние (предикат), а не как «уведомление». Условие — разделяемое состояние, защищённое блокировкой: «очередь непуста?», «флаг установлен?» — а не «меня разбудили?».
- Всегда помещайте
waitвнутрь цикла while по условию. Каждый раз, когда просыпаетесь, проверяйте условие и засыпайте снова, если оно не держится. - Обновляйте и проверяйте условие под одной и той же блокировкой. Уведомляющий обновляет состояние и затем уведомляет.
flowchart TB
accTitle: Поток правильного цикла ожидания
accDescr: Захватите блокировку и проверьте условие; если оно не держится, освободите блокировку и спите; при пробуждении заново захватите блокировку и вернитесь к проверке условия. Продолжайте, удерживая блокировку, только когда условие держится
l["Захватить блокировку"] --> c{"Условие удовлетворено?"}
c -->|"Нет"| s["wait (освободить блокировку и спать)"]
s --> wk["Пробуждение (заново захватить блокировку)"]
wk --> c
c -->|"Да"| go["Продолжить, всё ещё удерживая блокировку"]
Рис. 4: Правильное ожидание — цикл, и между проверкой условия и обработкой нет щели (оба происходят, пока блокировка удерживается).
У этой формы есть легко пропускаемое преимущество. В момент, когда вы покидаете цикл while, установлено, пока вы всё ещё держите блокировку, что «условие держится». Цикл, который защищает от ложных пробуждений, как есть — гарантия, что между проверкой условия и его обработкой нет щели гонки.
Базовая форма в Win32 (C)
CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0; // shared state protected by cs
// Initialise once at startup (for static initialisation, cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);
// Waiter (consumer)
EnterCriticalSection(&cs);
while (queueCount == 0) { // always while, never if
SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// Here the lock is held and queueCount > 0 is guaranteed
--queueCount;
LeaveCriticalSection(&cs);
// Notifier (producer)
EnterCriticalSection(&cs);
++queueCount; // update the state under the lock
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv); // notifying after releasing the lock is fine
Уведомление (WakeConditionVariable) можно вызывать изнутри блокировки или снаружи, но документация говорит, что будить после освобождения блокировки обычно лучше, чтобы снизить переключения контекста.1 С другой стороны, само обновление состояния (++queueCount) всегда должно происходить под блокировкой. Не путайте эти два.
Базовая форма в C++ — сделайте ожидание с предикатом значением по умолчанию
std::mutex m;
std::condition_variable cv;
std::queue<Item> q;
// Waiter
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); }); // internally while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();
// Notifier
{
std::lock_guard<std::mutex> lk(m);
q.push(std::move(item));
}
cv.notify_one();
Поскольку wait в форме предиката выполняет цикл за вас, рукописный while не нужен. Когда вы чините существующий код, у которого всё ещё есть рукописный цикл, while (q.empty()) cv.wait(lk); — корректная форма, поэтому нет нужды спешить переписывать. Единственная некорректная форма — if (q.empty()) cv.wait(lk);.
Базовая форма в C#
private readonly object _gate = new();
private readonly Queue<Item> _queue = new();
// Waiter
lock (_gate)
{
while (_queue.Count == 0) // always while, never if
{
Monitor.Wait(_gate);
}
var item = _queue.Dequeue();
}
// Notifier
lock (_gate)
{
_queue.Enqueue(item);
Monitor.Pulse(_gate); // Monitor.Pulse can only be called inside the lock
}
Monitor.Wait / Pulse / PulseAll можно вызывать только изнутри блокировки (блока lock), что отличается от Win32. Вызов их вне блокировки бросает SynchronizationLockException.10
Ожидание с тайм-аутом — считайте оставшееся время от крайнего срока
Когда вы ждёте с тайм-аутом, передача «того же значения тайм-аута» на каждой итерации цикла растягивает ожидание каждый раз, когда случается ложное пробуждение. Правильная форма — сначала зафиксировать крайний срок и пересчитывать оставшееся время.
ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
ULONGLONG now = GetTickCount64();
if (now >= deadline) {
break; // timeout (condition still unsatisfied)
}
if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
GetLastError() != ERROR_TIMEOUT) {
break; // on failure other than timeout, stop waiting and leave
}
// Confirm ERROR_TIMEOUT finally via the while condition and the deadline check
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
flowchart TB
accTitle: Правильный поток ожидания с тайм-аутом
accDescr: Сначала зафиксируйте крайний срок и каждый раз, когда просыпаетесь, проверяйте условие и крайний срок; если время ещё есть, пересчитайте оставшееся время и вернитесь к ожиданию
d["Зафиксировать крайний срок"] --> c{"Условие удовлетворено?"}
c -->|"Да"| go["Перейти к обработке"]
c -->|"Нет"| t{"Крайний срок прошёл?"}
t -->|"Да"| to["Обработать тайм-аут"]
t -->|"Нет"| w["Посчитать оставшееся время и ждать"]
w --> c
Рис. 5: Ожидание с тайм-аутом не передаёт снова «ту же длительность ожидания»; оно пересчитывает оставшееся время от крайнего срока.
В C++ этот расчёт, крайний срок и всё остальное, можно оставить перегрузке wait_until (абсолютное время) плюс предикат. Даже когда она возвращается по тайм-ауту, она даёт вам итоговое значение предиката, так что вы также можете решить «тайм-аут или успели?» по предикату.5
6. Каталог шаблонов, которых стоит избегать
Проверка только один раз через if. Это звезда статьи. В момент, когда случается ложное пробуждение или украденное пробуждение, обработка идёт дальше при неудовлетворённом условии. Взятие из пустой очереди, касание неинициализированных данных, двойное освобождение — симптом становится «падением или порчей данных, которая появляется только иногда».
Проверка или обновление условия вне блокировки. Если ждущий смотрит на условие вне блокировки, решает «ещё нет» и в щели перед входом в wait уведомляющий обновляет состояние и отправляет уведомление, уведомление стреляет в условную переменную без ждущего и исчезает. Ждущий затем входит в wait и продолжает ждать уведомление, которое больше никогда не придёт. Это потерянное пробуждение, зеркальное отражение ложного пробуждения. Причина, по которой API ожидания условной переменной спроектирован так, чтобы «атомарно освободить блокировку и заснуть», как раз в том, чтобы закрыть эту щель.1 Этого не случается, пока вы держите дисциплину блокировок.
sequenceDiagram
accTitle: Временная шкала потерянного пробуждения
accDescr: Если ждущий проверяет условие вне блокировки, а уведомляющий обновляет состояние и уведомляет в щели перед входом в wait, уведомление отправляется условной переменной без ждущего и исчезает, и ждущий продолжает ждать уведомление, которое никогда не придёт
participant W as Ждущий
participant N as Уведомляющий
W->>W: Проверить условие вне блокировки (неудовлетворено)
N->>N: Обновить состояние и уведомить
Note over N: В этот момент нет ждущего
W->>W: Войти в wait
Note over W: Уведомление уже ушло, и он никогда не просыпается
Рис. 6: Если проверяете условие вне блокировки, уведомление проскальзывает в щель между проверкой и wait — «потерянное пробуждение».
Воссоздание «переходного уведомления» условной переменной импульсом на событии. Сами события (CreateEvent + SetEvent) — не антишаблон. Сигнал пробуждения в схеме, где один потребитель обрабатывает очередь, пока она не пуста, или инструкция остановки, которую однажды подняли и больше не опускают (событие с ручным сбросом), — корректные применения события; а когда хотите соединить его с другими целями ожидания через WaitForMultipleObjects или пересечь границу процесса, условная переменная — объект пользовательского режима, который нельзя разделить между процессами, — как раз та, которую использовать нельзя.1 Опасно пытаться воссоздать операциями над событием переходное уведомление условной переменной, которое «будит только потоки, ждущие в этот миг, и не оставляет состояния». Эта идея почти всегда ведёт к следующему пункту, PulseEvent.
Использование PulseEvent. Это API, который на событии с ручным сбросом «будит всех, кто сейчас ждёт, и сразу возвращает событие в несигнальное состояние», но сама Microsoft в документации говорит, что «эта функция ненадёжна и не должна использоваться. Она существует в основном для обратной совместимости. Используйте вместо этого условную переменную.» Причина в том, что ждущий поток может быть временно выведен из состояния ожидания APC режима ядра и вернуться к ожиданию после завершения APC. Если PulseEvent вызывают в этом кратком интервале, этот поток не входит в «тех, кто ждал в момент вызова» и не будится.7 APC ядра — то, что ОС использует внутри; приложение ими управлять не может.11 Эта проблема также является предупреждением статического анализа (C28648).12 Если ложное пробуждение — проблема «разбудить лишних», это проблема «проспать, когда должны были проснуться», и цикл while вас не спасёт — потому что само уведомление потеряно.
Отправка сначала только уведомления, не удерживая блокировку, до обновления состояния. Вызов WakeConditionVariable, пока состояние ещё устарело, и только затем взятие блокировки и обновление состояния — в таком порядке разбуженный поток при проверке всё ещё видит условие неудовлетворённым и снова засыпает. Если дальнейшего уведомления не придёт, он там и останется. Заметьте: если вы пишете «уведомить → обновить → освободить», всё ещё удерживая ту же блокировку, реального вреда нет, потому что ждущий не может проверить условие, пока не захватит блокировку заново. Даже так, чтобы читателям не приходилось каждый раз проверять это условие безопасности, безопаснее стандартизировать порядок «обновить состояние под блокировкой и уведомить после этого».
7. Как расследовать, когда вы с этим столкнулись
Баги, связанные с ложными пробуждениями, характеризуются тем, что «появляются только редко». Идя назад от симптома, они делятся на следующие два семейства.
Семейство 1: обработка идёт дальше при неудовлетворённом условии. Исключение или падение от взятия из пустой очереди, пропущенные результаты и так далее. Подозревайте ожидание без предиката. Это можно вычесать механически в ревью кода — ищите места, где у cv.wait( только один аргумент, и места, где SleepConditionVariableCS / Monitor.Wait обёрнуты в if, а не в while. Эта проверка не требует ждать воспроизведения и является ходом с самым высоким рычагом, который у вас есть.
Семейство 2: поток, который должен проснуться, не просыпается (зависание). Подозревайте потерянное пробуждение (проверка условия вне блокировки или уведомление вне блокировки до обновления состояния) и PulseEvent. Возьмите дамп зависшего процесса и посмотрите стек каждого потока — и вы можете опознать, какой поток застрял в каком API ожидания. Оттуда гонитесь по коду «кто должен был отправить это уведомление и в каком порядке».
flowchart TB
accTitle: Поток сортировки по симптому
accDescr: Если обработка идёт дальше при неудовлетворённом условии, вычешите ожидания без предиката поиском по коду; если поток не просыпается, опознайте место ожидания из дампа и подозревайте потерянное пробуждение или PulseEvent
s["Баг, который появляется только редко"] --> a["Обработка идёт дальше при неудовлетворённом условии"]
s --> b["Поток, который должен проснуться, не просыпается"]
a --> a1["Искать в коде ожидания без предиката"]
b --> b1["Опознать ждущие потоки из дампа"]
a1 -.-> a2["Сменить if на while или использовать ожидание с предикатом"]
b1 -.-> b2["Подозревать потерянное пробуждение или PulseEvent"]
Рис. 7: Является ли симптом «зайти слишком далеко» или «никогда не проснуться» разделяет и то, что вы подозреваете, и то, как расследуете.
Если хотите воспроизвести, стандартный ход — расширить окно гонки. Увеличьте дрожание момента, используя больше потоков, чем физических ядер, вставляя намеренный Sleep между ожиданием и уведомлением и гоняя и отладочную, и выпускную сборки. Когда подтверждаете, что «перестало воспроизводиться после того, как починили ожидание без предиката», сравнивайте под той же нагрузкой.
8. Итоги — контрольный список
- Путей назад из
waitтри — подлинное уведомление, ложное пробуждение и украденное пробуждение — и вызывающий не может их различить. Поэтому всегда пишите ожидание как цикл while по условию. - Ложное пробуждение — поведение, которое Win32, C++ и POSIX намеренно допустили как компромисс против производительности, и оно не уйдёт с исправлением ОС или сменой библиотеки.
Monitor.Wait.NET не предполагается просыпающимся без причины, но поскольку существуют украденные пробуждения и тайм-ауты, та же дисциплина while всё равно требуется. - В C++ по умолчанию берите форму предиката
wait(lock, pred). Библиотека выполняет цикл. - Обновляйте и проверяйте условие под одной и той же блокировкой. Отправляйте уведомление «после обновления состояния». Уведомление Win32/C++ может происходить после освобождения блокировки;
Pulseв C# — только внутри блокировки. - Для ожидания с тайм-аутом зафиксируйте крайний срок и пересчитывайте оставшееся время. В C++ —
wait_untilплюс предикат. - Не воссоздавайте переходное уведомление условной переменной импульсом на событии.
PulseEventв частности — то, о чём официальная документация прямым текстом говорит «не используйте, используйте вместо этого условную переменную». Сами события остаются правильным инструментом для инструкции остановки, соединения сWaitForMultipleObjectsи межпроцессной синхронизации. - В ревью механически ищите «ожидание без предиката» и «
if+ wait». Вы можете убить редко воспроизводящийся баг, не дожидаясь воспроизведения.
Ложное пробуждение, вопреки странности имени, сжимается до однострочного ключевого слова исправления — смените if на while. А за этой одной строкой лежит проектная идея инструмента условной переменной: «точное уведомление дорого, поэтому проверка — ответственность ждущего». Поймите это как механизм — и сможете применять ту же дисциплину без колебаний, когда меняется язык или каркас.
Похожие статьи
- Практические рекомендации по многопоточности: издание C++ — устранение аварий структурой через RAII и jthread
- Практические рекомендации по многопоточности: издание C — безопасное письмо в духе Win32 API
- Практические рекомендации по многопоточности: издание .NET — что решить, прежде чем добавлять ещё потоки
- Почему в Windows стоит предпочитать ожидание события, а не Sleep(1)
- Разделяемая память: подводные камни и практические рекомендации
- Глубины I/O в Windows (часть 2) — синхронный и асинхронный ввод-вывод: что на самом деле значит OVERLAPPED
Смежные области консультирования
KomuraSoft LLC занимается ревью многопоточного проектирования, расследованием первопричин (анализ дампа) падений и зависаний, которые «воспроизводятся только иногда», и миграцией унаследованного кода синхронизации (зависимого от событий и PulseEvent и подобного) на базу условных переменных. Начать с сортировки симптома — нормально; пожалуйста, обращайтесь.
- Техническая консультация и ревью проекта
- Исследование дефектов и анализ первопричин
- Разработка Windows-приложений
- Связаться с нами
Справочные ссылки
-
Microsoft Learn, Condition Variables. О том, что условная переменная — объект пользовательского режима, который атомарно освобождает блокировку и входит в ожидание; о том, что бывают ложные пробуждения (пробуждения, не привязанные к явному пробуждению) и украденные пробуждения (другой поток выполняется раньше разбуженного), так что после возврата из ожидания следует перепроверить предикат в цикле while; и о том, что уведомление возможно изнутри или снаружи блокировки, но будить после освобождения блокировки лучше для снижения переключений контекста. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). Об атомарном освобождении указанной критической секции и ожидании на условной переменной; о том, что разбуженный поток заново захватывает критическую секцию, прежде чем вернуться; о том, что при тайм-ауте возвращается ERROR_TIMEOUT; и о том, что бывают ложные пробуждения и украденные пробуждения, так что после возврата из ожидания следует перепроверить предикат (обычно в цикле while). ↩ ↩2
-
The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. О том, что ложные пробуждения из pthread_cond_wait / pthread_cond_timedwait могут происходить; о том, что возврат из wait ничего не говорит о значении предиката, поэтому предикат следует заново оценить; и о том, что Rationale говорит, что реализация, которая «будит ровно одного», может замедлить операции условной переменной особенно на многопроцессорных системах, и что допущение ложных пробуждений принуждает к циклу проверки предиката и делает приложения устойчивее. ↩ ↩2 ↩3
-
cppreference.com, std::condition_variable::wait. О том, что wait без предиката может быть разблокирован ложным пробуждением; и о том, что перегрузка с предикатом эквивалентна while (!pred()) wait(lock); и определена как цикл, который заново захватывает блокировку и проверяет предикат при каждом уведомлении или ложном пробуждении. ↩ ↩2
-
Microsoft Learn, condition_variable Class. О том, что wait без предиката заявлен как разблокирующийся по notify_one / notify_all и также способный проснуться ложно; о том, что форма предиката wait(lock, pred) фактически выполняет while (!Pred()) wait(Lck);; и о том, что wait_for / wait_until имеют то же свойство и перегрузку с предикатом. ↩ ↩2 ↩3
-
Microsoft Learn, Monitor.Wait Method. О том, что Wait освобождает блокировку и входит в очередь ожидания; о том, что после пробуждения Pulse / PulseAll не возвращается, пока блокировка не захвачена заново; и о том, что задуманное использование — чтобы разбуженный поток заново оценивал условие, из-за которого он вошёл в ожидание, и при необходимости снова вызывал Wait. ↩ ↩2
-
Microsoft Learn, PulseEvent function (winbase.h). О том, что ждущий поток может быть временно выведен из состояния ожидания APC режима ядра и вернуться после завершения APC, так что если PulseEvent вызывают в этом интервале, поток не освобождается; и о том, что PulseEvent поэтому ненадёжен и не должен использоваться в новых приложениях, вместо него используют условную переменную. ↩ ↩2
-
Microsoft Learn, Using Condition Variables. Об официальном образце, который реализует очередь производитель–потребитель одной критической секцией и двумя условными переменными (BufferNotEmpty и BufferNotFull). Ожидание выполняется внутри цикла, который проверяет предикат. ↩
-
Microsoft Learn, WaitOnAddress function (synchapi.h). О том, что функция, которая ждёт изменения значения адреса, гарантированно возвращается при сигнале, но также разрешено вернуться по другим причинам; о примерах раннего пробуждения, включая условие нехватки памяти, отказ от предыдущего пробуждения для того же адреса и выполнение проверочной сборки; и о том, что поэтому после возврата нужно снова сравнить значение, а официальный образец сам является циклом while. ↩
-
Microsoft Learn, Monitor.PulseAll Method. О том, что PulseAll перемещает потоки из очереди ожидания в очередь готовности, и следующий поток в очереди готовности захватывает блокировку, когда блокировка освобождается; и о том, что Pulse / PulseAll / Wait можно вызывать только изнутри блока синхронизации. ↩ ↩2
-
Microsoft Learn, Waits and APCs. О том, что APC ядра выполняются вытесняюще, и система внутри прерывает и возобновляет ожидание, не возвращаясь из API ожидания, так что переходный сигнал вроде KePulseEvent может быть пропущен в этом интервале. ↩
-
Microsoft Learn, C28648: PulseEvent is an unreliable function. О предупреждении статического анализа на использование PulseEvent; о том, что поток, который был вне ожидания из-за APC, не освобождается и может зависнуть навсегда; и об указаниях по замене на SetEvent или другой объект синхронизации. ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
DllMain и блокировка загрузчика — настоящая причина, почему говорят «ничего не делай в инициализации DLL»
Почему из DllMain нельзя вызывать LoadLibrary и синхронизироваться с другими потоками. Статья по первичным источникам объясняет, как блок...
Win32 Thread Pool API — параллелизм без создания потоков через CreateThreadpoolWork
По всему нативному коду разбросаны вызовы CreateThread? Статья разбирает Thread Pool API Win32, переработанный в Vista, — четыре объекта ...
Именованные каналы на практике — стандартный IPC Windows: от проектирования до безопасности
Практическое руководство по именованным каналам — стандартному межпроцессному взаимодействию Windows. Статья по первичным источникам разб...
Что на самом деле значит «Не отвечает» — как Windows решает, что приложение зависло, и как проектировать приложения, которые не зависают
«Не отвечает» в Windows — механизм, в котором ОС судит, что окно не извлекало сообщение 5 секунд, и подменяет его окном-призраком. Статья...
Приложения, которые ломаются после выхода из сна — как работают события питания Windows и как строить бизнес-приложения, которые это переживают
Открыли ноутбук — и соединения бизнес-приложения мертвы: причина в проекте, который не учитывал сон. Статья разбирает поток уведомлений W...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Ложное пробуждение — это баг ОС или библиотеки?
- Нет — это поведение, прописанное в спецификации. У SleepConditionVariableCS Win32, std::condition_variable C++ и pthread_cond_wait POSIX официальная документация или стандарт прямо говорят, что пробуждение, не привязанное к уведомлению, может произойти. Реализация, которая это запретила бы, теоретически возможна, но она замедлила бы каждую операцию условной переменной (особенно уведомление на многопроцессорных системах), поэтому компромисс — допустить это с пониманием, что «корректность сохраняется, если ждущий перепроверяет условие». Лекарство поэтому не ждать исправления ОС, а всегда писать ожидание внутри цикла while (или использовать ожидание в форме предиката).
- Оборачивание ожидания в цикл while бьёт по производительности?
- На практике цена пренебрежимо мала. Цикл while добавляет только одну лишнюю проверку условия каждый раз, когда вы просыпаетесь, и это дешёвое сравнение, пока вы уже держите блокировку. Сами ложные пробуждения редки, поэтому лишняя итерация цикла случается только в исключительных случаях. Цена оставить проверку как if, с другой стороны, — «баг, который воспроизводится только редко», в котором обработка идёт дальше при неудовлетворённом условии, — сравнивать нечего. То, что на самом деле доминирует в цене ожидания условной переменной, — конкуренция за блокировку и то, как часто вы уведомляете, а не есть ли while.
- Если использовать ожидание C++ в форме предиката, можно забыть о ложных пробуждениях?
- Для цикла ожидания да: cv.wait(lock, pred) фактически while (!pred()) wait(lock); поэтому и ложные пробуждения, и украденные пробуждения поглощаются автоматически. Новый код C++ по умолчанию должен брать перегрузку с предикатом. Вам по-прежнему нужно защищать обновления разделяемого состояния, которое читает предикат, тем же мьютексом, и уведомляющий по-прежнему должен обновить это состояние до вызова notify. Ожидание с предикатом снимает с вас цикл; оно не снимает с вас дисциплину блокировок.
- Та же проблема случается с Monitor.Wait в C#?
- Да. Поток, ждущий в Monitor.Wait, будится Pulse/PulseAll и затем заново захватывает блокировку, прежде чем вернуться из Wait, но в этом интервале другой поток может захватить блокировку первым и потребить условие (украденное пробуждение). Документация Microsoft написана из допущения, что разбуженный поток заново оценивает условие, из-за которого он ждал, и при необходимости снова вызывает Wait. Поэтому базовая форма в C# тоже while (!condition) Monitor.Wait(gate);. Одно ограничение, которое отличается от Win32, — Wait/Pulse можно вызывать только изнутри оператора lock.
- Случаются ли ложные пробуждения и когда вы ждёте событие через WaitForSingleObject?
- В обычном (не alertable) ожидании WAIT_OBJECT_0 возвращается только когда объект действительно становится сигнальным; «беспричинного пробуждения» того вида, который есть у условных переменных, нет. При этом «событие стало сигнальным» и «условие вашего приложения держится» — разные вещи. Если несколько потребителей будит одно и то же событие, поток, который берёт блокировку первым, потребляет условие, поэтому после пробуждения условие всё равно нужно перепроверить. Проекты, которые пытаются воссоздать переходное уведомление условной переменной «будить только того, кто ждёт в этот миг» событием, также склонны натыкаться на проблему надёжности PulseEvent, поэтому для ожидания условия внутри процесса условная переменная — более безопасный инструмент.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.