Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows

· Обновлено: · · Windows, Многопоточность, Условные переменные, Синхронизация, C++, C#, Win32 API, Устранение неполадок

История изменений (первая версия, опубликована 22 Aug 2026)
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.22176645)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows. KomuraSoft LLC. https://comcomponent.com/ru/blog/windows-condition-variable-spurious-wakeup/

DOI (зарегистрированный архив)
10.5281/zenodo.22176645
DOI (последняя зарегистрированная версия)
10.5281/zenodo.22176646

«Данные положили, потом уведомили — а рабочий поток читает пустую очередь и падает.» «Уведомили вроде точно, а иногда поток так и не возвращается из ожидания.» Оба случая — сбои рандеву, но места, куда смотреть, разные.

Центр этой статьи — один принцип: возврат из wait условной переменной не гарантирует, что условие, которого вы ждали, выполнено. Когда понятны ложное пробуждение (spurious wakeup), которое возвращается без уведомления, и украденное пробуждение (stolen wakeup), при котором условие потребляют после уведомления, становится ясно, почему нужен while, а не if. Потерянное пробуждение (lost wakeup), когда уведомление пропускают, разбираем отдельно от этих двух.

Что нужно узнать или в чём застряли Куда читать
Почему wait просыпается без уведомления и чем это отличается от украденного пробуждения Что гарантировано в момент возврата, Почему спецификация это допускает
Правильный код для Win32, C++ и C# Базовая форма по языкам
Тайм-аут задали, а ожидание всё равно растягивается Ожидание от дедлайна
Уведомили, а поток не возвращается Потерянное пробуждение и PulseEvent
Расследовать сбой или зависание, которое бывает только иногда Процедура расследования по симптомам

Статья рассчитана на разработчиков, которые пишут под Windows бизнес-приложения и ПО управления оборудованием. Механизм подтверждаем по первичным источникам и связываем с реализациями на Win32 (C), C++ и C#.

1. Сначала вывод

В коде ожидания нужно держать три правила.

Принцип Что должен делать код
Ждать состояние, а не уведомление Условие вроде «очередь не пуста», а не «меня разбудили»
Каждый раз при возврате снова проверять условие while (!условие) wait(...); в C++ — перегрузка с предикатом wait(lock, pred)
Проверку и обновление защищать одной и той же блокировкой Состояние обновлять до уведомления, чтобы уведомление не проскочило в щель между проверкой и wait

Win32, C++ и POSIX по спецификации допускают пробуждения, не связанные с явным уведомлением. Плюс даже когда уведомление пришло, другой поток может потребить условие первым (украденное пробуждение), поэтому повторная проверка обязательна. Monitor.Wait в C# используют с той же дисциплиной, учитывая украденные пробуждения.12345

Ещё одно предостережение: не воспроизводите кратковременное уведомление условной переменной импульсом события. Особенно PulseEvent может пропустить уведомление, и Microsoft прямо пишет: в новых приложениях не использовать, вместо этого брать условную переменную.6

Дальше — как устроены пробуждения (главы 2–4), правильная реализация (глава 5), шаблоны, которых избегать, и расследование (главы 6–7), затем чек-лист (глава 8).

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 17, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Что такое ложное пробуждение — «проснулся» не значит «условие выполнено»

2.1 Условная переменная отпускает блокировку, ждёт и перед возвратом берёт её снова

Условная переменная — примитив синхронизации, который заставляет поток ждать, пока не выполнится некоторое условие. В Win32 используют такую структуру и такие API.1

Роль Тип или API Win32
Представляет условную переменную CONDITION_VARIABLE
Отпускает блокировку и ждёт SleepConditionVariableCS / SleepConditionVariableSRW
Будит ждущие потоки WakeConditionVariable / WakeAllConditionVariable

API ожидания атомарно отпускает критическую секцию или SRW-блокировку, которую держит поток, и входит в ожидание. После пробуждения он снова захватывает эту блокировку и только потом возвращается вызывающему.1

Но повторный захват блокировки и возврат — это другое, чем выполнение условия, которого ждало приложение.

2.2 Отличайте настоящее уведомление, ложное пробуждение и украденное пробуждение

Тайм-ауты оставляем главе 5; сначала сравним три случая пробуждения.

Случай Связь с явным уведомлением Как читать возврат
Настоящее пробуждение Уведомление есть Условие часто выполнено, но сам факт возврата этого не гарантирует
Ложное пробуждение Не связано с явным уведомлением, которое должно было разбудить этот поток Возвращается, хотя условие по-прежнему не выполнено
Украденное пробуждение Уведомление есть Другой поток потребил условие первым, поэтому оно уже не выполнено
Три случая, в которых wait возвращаетсяwait на условной переменной возвращается не только по настоящему уведомлению, но и по ложному пробуждению без уведомления, и по украденному, когда уведомление пришло, но условие уже потребили, поэтому условие нужно перепроверять в каждом случаеВернулись из waitНастоящее уведомлениеЛожное пробуждение (без уведомления)Украденное пробуждение (условие уже потреблено)Перепроверить условие, затем продолжать

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

Ложное пробуждение не сводится к случаю, когда уведомление никуда в системе не отправляли. Если уведомления приходят короткой пачкой, реализация может по своим причинам пакетно разбудить лишние ждущие потоки. С точки зрения потока, у которого нет соответствующего явного уведомления, это тоже ложное пробуждение.

Microsoft Learn пишет, что условные переменные подвержены и ложным, и украденным пробуждениям, и просит после возврата из wait снова проверять предикат, обычно в цикле while. Предикат здесь — условие ожидания вроде «очередь не пуста».1

2.3 Решать, идти ли дальше, нужно по текущему условию, а не по причине пробуждения

Вызывающий не может отличить, какой из трёх путей его вернул. Поэтому форма такая: каждый раз при возврате проверять само условие и, если оно не выполнено, ждать снова.

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

3. Почему спецификация это допускает — точное уведомление дорого

3.1 Чтобы не платить за строгое уведомление на каждой операции

Реализация, которая никогда не даёт ложных пробуждений, теоретически возможна. Почему POSIX, Windows и C++ всё равно их допускают — в производительности и в проекте, где ждущая сторона перепроверяет условие. Rationale POSIX для pthread_cond_wait тоже объясняет это решение.3

Строгая реализация уведомления «надёжно разбудить ровно один поток» добавляет лишнюю стоимость синхронизации к каждой операции условной переменной, особенно на многопроцессорных системах. Между уведомлением и пробуждением стоит планировщик, плюс прерывания и вытеснение. Редкое лишнее пробуждение и повторная проверка на стороне ожидания оставляют реализацию быстрее.3

У цикла повторной проверки есть ещё польза: намерение явно видно в коде, и код становится устойчивее. Если считать уведомление не «гарантией, что условие выполнено», а подсказкой, что условие могло измениться, код переносит изменения на стороне уведомления вроде лишних пробуждений или пакетного пробуждения.3

3.2 Даже без ложных пробуждений украденные остаются

Украденное пробуждение происходит в щели по времени между уведомлением и повторным захватом блокировки. Даже если производитель положил в очередь один элемент и разбудил потребителя A, если потребитель B заберёт этот элемент до того, как A снова возьмёт блокировку, к моменту возврата A очередь уже пуста.

Шкала времени украденного пробужденияПроизводитель кладёт в очередь один элемент и будит ждущего потребителя A, но до того как A снова возьмёт блокировку, потребитель B захватывает блокировку и забирает этот один элемент, поэтому к моменту пробуждения A очередь пустаПотребитель BПроизводительПотребитель A (ждёт)Потребитель BПроизводительПотребитель A (ждёт)Разбужен, ждёт повторного захвата блокировкиОчередь пуста (украдено)Добавить в очередь один элементWakeConditionVariableЗахватить блокировку и взять один элементСнова захватить блокировку и вернуться из waitПерепроверить в цикле while и ждать снова

Рис. 2: Украденное пробуждение, при котором третий поток потребляет условие в щели между уведомлением и пробуждением, возможно при любой реализации.

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

4. На каких слоях это проявляется в Windows

4.1 Дисциплина повторной проверки общая; причины пробуждения различают

API или библиотека Почему нужна повторная проверка
Условные переменные Win32 Ложные и украденные пробуждения явно описаны в документации2
WaitOnAddress Разрешено вернуться раньше по причинам, кроме сигнала по указанному адресу7
std::condition_variable в C++ wait без предиката может проснуться ложно48
Monitor.Wait в .NET Другой поток может потребить условие между пробуждением и повторным захватом блокировки5

Официальный пример очереди производитель–потребитель в Win32 тоже пишет ожидание в цикле while.9

WaitOnAddress, доступный начиная с Windows 8, — низкоуровневый API, который ждёт, пока значение по заданному адресу изменится. Как примеры раннего возврата без сигнала официальная документация приводит нехватку памяти, отказ от предыдущего wake по тому же адресу и работу на checked-сборке. Пример использования тоже цикл while, который снова сравнивает значение.7

4.2 wait с предикатом в C++ берёт цикл на себя

Документация MSVC объясняет, что wait(lock, pred) по сути выполняет следующее.4

while (!Pred())
    wait(Lck);

cppreference также прямо пишет, что wait без предиката может вернуться ложно. С формой с предикатом этот цикл повторной проверки можно оставить библиотеке.8

4.3 В C# смотрите на украденные пробуждения и повторный захват блокировки

Monitor.Wait / Pulse используют очередь ожидания и очередь готовности. Поток, разбуженный Pulse / PulseAll, переходит в очередь готовности и выходит из Wait только после повторного захвата блокировки. То, что в этом интервале другой поток может потребить условие первым, совпадает с Win32.510

Для Monitor.Wait в .NET, вместо того чтобы предполагать беспричинное пробуждение в духе условных переменных, думайте отдельно: та же дисциплина while нужна из-за украденных пробуждений и тайм-аутов. Документация тоже предполагает использование, при котором поток заново оценивает условие, из-за которого он ждал, и при необходимости снова вызывает Wait.5

На каждом слое нужно перепроверять предикатНа каждом слое, C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE и низкоуровневый WaitOnAddress, официальная документация требует после пробуждения снова проверить условиеC++ std::condition_variableПри пробуждении перепроверить условие (while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

Рис. 3: Какой бы ни был язык или платформа, каждый слой примитива ожидания официально требует повторной проверки после пробуждения.

5. Правильный способ ждать — писать через while и предикат

Общая форма: проверить условие и продолжать только когда оно выполнено

Ждут не «пришло ли уведомление», а разделяемое состояние, защищённое блокировкой. Счётчик очереди или флаг проверяют и обновляют внутри той же блокировки, при невыполнении вызывают wait, при возврате перепроверяют.

Поток правильного цикла ожиданияЗахватить блокировку и проверить условие; если не выполнено, отпустить блокировку и заснуть; при пробуждении снова захватить блокировку и вернуться к проверке условия. Продолжать, держа блокировку, только когда условие выполненоНетДаЗахватить блокировкуУсловие выполнено?wait (отпустить блокировку и заснуть)Проснуться (снова захватить блокировку)Обрабатывать, всё ещё держа блокировку

Рис. 4: Правильное ожидание — это цикл, и между проверкой условия и обработкой нет щели (и то и другое идёт, пока блокировка удерживается).

При такой форме в момент выхода из цикла вы подтвердили, что условие выполнено, всё ещё держа блокировку. Состояние потребляете как есть, поэтому между проверкой условия и обработкой нет щели, куда мог бы вклиниться другой поток.

Базовая форма в Win32 (C)

CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0;   // разделяемое состояние, защищённое cs

// Инициализировать один раз при старте (для статической инициализации cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);

// Ждущая сторона (потребитель)
EnterCriticalSection(&cs);
while (queueCount == 0) {                       // всегда while, никогда if
    SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// Здесь блокировка удерживается и гарантировано queueCount > 0
--queueCount;
LeaveCriticalSection(&cs);

// Уведомляющая сторона (производитель)
EnterCriticalSection(&cs);
++queueCount;                                    // состояние обновлять внутри блокировки
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv);                      // уведомлять после отпускания блокировки можно

Различайте обновление состояния и место вызова уведомления. ++queueCount всегда делают внутри блокировки. WakeConditionVariable, напротив, можно вызывать и изнутри, и снаружи. Microsoft пишет, что обычно лучше будить после отпускания блокировки, чтобы снизить число переключений контекста.1

Базовая форма в C++ — wait с предикатом по умолчанию

std::mutex m;
std::condition_variable cv;
std::queue<Item> q;

// Ждущая сторона
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); });   // внутри while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();

// Уведомляющая сторона
{
    std::lock_guard<std::mutex> lk(m);
    q.push(std::move(item));
}
cv.notify_one();

Новый код по умолчанию берёт wait с предикатом. Существующий while (q.empty()) cv.wait(lk); тоже правильная форма, поэтому переписывать его только потому, что цикл написан вручную, не нужно. Проблема — if (q.empty()) cv.wait(lk);, который не перепроверяет.

Форма с предикатом берёт на себя только цикл. Дисциплина защищать разделяемое состояние, которое читает предикат, тем же мьютексом и на стороне уведомления, и обновлять состояние до уведомления, по-прежнему нужна.

Базовая форма в C#

private readonly object _gate = new();
private readonly Queue<Item> _queue = new();

// Ждущая сторона
lock (_gate)
{
    while (_queue.Count == 0)          // всегда while, никогда if
    {
        Monitor.Wait(_gate);
    }
    var item = _queue.Dequeue();
}

// Уведомляющая сторона
lock (_gate)
{
    _queue.Enqueue(item);
    Monitor.Pulse(_gate);              // Pulse у Monitor можно вызывать только внутри блокировки
}

Monitor.Wait / Pulse / PulseAll вызывают внутри синхронизированного блока, который держит нужную блокировку. Снаружи блокировки они бросают SynchronizationLockException. Не путайте это с Win32, где уведомление можно вынести за блокировку.10

Ожидание с тайм-аутом — оставшееся время считать от дедлайна

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

ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
    ULONGLONG now = GetTickCount64();
    if (now >= deadline) {
        break;                          // тайм-аут (условие по-прежнему не выполнено)
    }
    if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
        GetLastError() != ERROR_TIMEOUT) {
        break;                          // при сбое, кроме тайм-аута, прекратить ожидание и выйти
    }
    // ERROR_TIMEOUT получает окончательную проверку в условии while и в проверке дедлайна
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
Правильный поток ожидания с тайм-аутомСначала задать дедлайн и при каждом пробуждении проверять условие и дедлайн; если дедлайн ещё не прошёл, пересчитать оставшееся время и вернуться в ожиданиеДаНетДаНетЗадать дедлайнУсловие выполнено?Перейти к обработкеДедлайн прошёл?Обработать тайм-аутПосчитать оставшееся время и ждать

Рис. 5: Ожидание с тайм-аутом не передаёт «то же самое время ожидания» снова; оно пересчитывает оставшееся время от дедлайна.

В C++ эту обработку можно оставить перегрузке wait_until с предикатом, которая принимает абсолютное время. Даже при тайм-ауте она возвращает итоговое значение предиката, поэтому решать можно по тому, выполнилось ли условие в конце.4

6. Шаблоны, которых избегать

6.1 Проверять условие только один раз через if

Когда случается ложное или украденное пробуждение, обработка идёт дальше при невыполненном условии. Это проявляется как редкий сбой или порча данных: взятие из пустой очереди, чтение неинициализированных данных, двойное освобождение. Меняйте на while или wait с предикатом из главы 5.

6.2 Проверять или обновлять условие вне блокировки

Это уже не «лишние пробуждения», а потерянное пробуждение: пропустить уведомление и спать вечно.

Если ждущая сторона смотрит на условие вне блокировки, решает «ещё нет», а уведомляющая сторона обновляет состояние и уведомляет до входа в wait, в этот миг ждущего нет. Ждущая сторона входит в wait после того, как уведомление уже ушло, и продолжает ждать, если нового уведомления не будет.

Шкала времени потерянного пробужденияЕсли ждущая сторона проверяет условие вне блокировки, а уведомляющая сторона в щели до входа в wait обновляет состояние и уведомляет, уведомление уходит на условную переменную без ждущих и исчезает, и ждущая сторона продолжает ждать уведомления, которое уже не придётУведомляющая сторонаЖдущая сторонаУведомляющая сторонаЖдущая сторонаВ этот момент ждущих нетУведомление уже ушло, поэтому больше не проснётсяПроверить условие вне блокировки (не выполнено)Обновить состояние и уведомитьВойти в wait

Рис. 6: Проверка условия вне блокировки даёт уведомлению пройти насквозь в щели между проверкой и wait: потерянное пробуждение.

Именно поэтому API ожидания условной переменной делает отпускание блокировки и вход в wait атомарными: чтобы закрыть эту щель. Держите дисциплину защищать проверку и обновление условия одной и той же блокировкой и входить в API ожидания, держа блокировку, — и это потерянное пробуждение предотвращается.1

6.3 Воспроизводить кратковременное уведомление импульсом события

Использовать CreateEvent и SetEvent само по себе не антипаттерн. У событий есть законные применения вроде следующих.

Назначение Когда использовать событие
Разбудить одного потребителя Схема, в которой разбуженный потребитель обрабатывает очередь, пока она не опустеет
Сигнал остановки Инструкция остановки, которую подняли и больше не опускают, через событие с ручным сбросом
Ждать вместе с другими объектами ожидания Включить в WaitForMultipleObjects
Рандеву между процессами Применение, с которым условная переменная, которую нельзя разделять между процессами, не справится

Условная переменная — объект пользовательского режима, который нельзя разделять между процессами. В зависимости от применения заменить событие условной переменной даже нельзя.1

Опасно пытаться событием воспроизвести поведение условной переменной «разбудить только тех, кто ждёт в этот миг, и не оставлять сигнального состояния». Эта идея ведёт к проблеме PulseEvent, которая ниже.

6.4 Использовать PulseEvent

PulseEvent — API, который на событии с ручным сбросом будит потоки, ждущие в этот миг, и сразу возвращает событие в несигнальное состояние. Microsoft, однако, прямо пишет, что он ненадёжен и существует в основном ради обратной совместимости, поэтому новые приложения не должны его использовать и должны брать условную переменную.6

Причина в том, что ждущий поток может быть временно снят с ожидания ядерным APC и вернуться в ожидание после завершения APC. Если PulseEvent вызывают в этом интервале, поток не входит в «тех, кто ждал в момент вызова», и его не будят. Ядерные APC — внутреннее поведение ОС, которое приложение не контролирует.611

Эта проблема также предупреждение статического анализа C28648.12 Ложное пробуждение — проблема «просыпается слишком часто»; это проблема «не просыпается, когда должно». Поскольку теряется само уведомление, одного while вокруг wait недостаточно.

6.5 Уведомлять без блокировки, до обновления состояния

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

Различайте, однако, два следующих случая.

Порядок Результат
Уведомить вне блокировки, затем взять блокировку и обновить состояние Есть щель, в которой разбуженный поток перепроверяет до обновления и засыпает
Уведомить, обновить, затем отпустить — всё внутри той же блокировки Ждущая сторона не может проверить, пока снова не захватит блокировку, поэтому этот порядок на практике не вредит

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

7. Как расследовать, когда это встретилось

7.1 Отделяйте «идёт дальше, хотя условие не выполнено» от «не просыпается»

Симптом Что проверять сначала Как расследовать
Взятие из пустой очереди, сбой, пропавшие результаты Перепроверяется ли условие после wait Искать вокруг cv.wait( без предиката, SleepConditionVariableCS и Monitor.Wait
Поток, который должен проснуться, не возвращается, процесс зависает Есть ли потерянное пробуждение или PulseEvent По дампу смотреть стек каждого потока и от API ожидания, на котором он застрял, идти к уведомляющей стороне

Найти cv.wait(lk) без предиката не проблема, если он обёрнут в правильный while. Соберите кандидатов поиском, затем смотрите, не стоит ли вокруг только if и проверяется ли условие и обновляется ли оно под той же блокировкой. Эти места можно инспектировать, не дожидаясь воспроизведения.

При зависании найдите место ожидания по дампу, затем по коду проследите, кто должен был уведомить и в каком порядке. Проверяйте проверки условия вне блокировки, уведомления вне блокировки до обновления состояния и PulseEvent.

Поток сортировки по симптомуЕсли симптом — обработка идёт при невыполненном условии, искать в коде wait без предиката; если симптом — поток не просыпается, найти место ожидания по дампу и подозревать потерянное пробуждение или PulseEventОшибка, которая проявляется только иногдаОбработка идёт при невыполненном условииПоток, который должен проснуться, не просыпаетсяИскать в коде wait без предикатаНайти ждущие потоки по дампуСменить if на while или wait с предикатомПодозревать потерянное пробуждение или PulseEvent

Рис. 7: Симптом «зашло слишком далеко» или «так и не проснулось» решает и куда смотреть, и как расследовать.

7.2 Сравнивать до и после правки в одинаковых стрессовых условиях

Чтобы воспроизвести редкую ошибку, расширьте окно гонки и увеличьте разброс по времени. Способы: больше потоков, чем физических ядер, диагностический Sleep между wait и уведомлением, прогон и debug-, и release-сборок.

Когда сравниваете, чтобы заключить «после правки wait перестало воспроизводиться», давайте одинаковую нагрузку до и после правки.

8. Итог — чек-лист

Уведомление — подсказка, что условие могло измениться; основание продолжать — само условие, проверенное внутри блокировки.

Пункт Правильная форма
После возврата из wait Не пытаться отличить настоящее уведомление, ложное пробуждение и украденное; перепроверить условие
Как писать wait Оборачивать в while. Новый код C++ по умолчанию берёт wait(lock, pred) с предикатом
Разделяемое состояние Проверку и обновление защищать одной блокировкой, состояние обновлять до уведомления
Где уведомлять В Win32/C++ можно уведомлять после отпускания блокировки. Pulse в C# вызывают внутри блокировки
Тайм-ауты Считать оставшееся время от дедлайна. В C++ — wait_until с предикатом
Использование событий Отличать законные применения от кратковременных импульсов и не опираться на PulseEvent

Ложные пробуждения — спецификация, которую Win32, C++ и POSIX допустили намеренно. Ответ — дисциплина на стороне ожидания, а не ожидание патча ОС или смена библиотеки. Monitor.Wait в .NET отличают от беспричинных пробуждений, но ту же повторную проверку делают из-за украденных пробуждений и тайм-аутов.

При сбое начинайте с «места, которое идёт дальше, хотя условие не выполнено»; при зависании — с «места, которое пропускает уведомление». Этим различием и чек-листом пользуйтесь и при ревью существующего кода.

Похожие статьи

Смежные области консультирования

KomuraSoft LLC занимается ревью проекта многопоточного кода, расследованием причин (анализ дампов) сбоев и зависаний, которые «воспроизводятся только иногда», и переработкой устаревшего кода синхронизации (зависимость от событий, PulseEvent и подобного) на основу условных переменных. Можно начать с разбора симптома; обращайтесь.

Справочные ссылки

  1. Microsoft Learn, Condition Variables. О том, что условная переменная — объект пользовательского режима, который атомарно отпускает блокировку и входит в ожидание; о том, что бывают ложные пробуждения (не связанные с явным wake) и украденные (другой поток выполняется раньше разбуженного), поэтому после возврата из wait предикат следует перепроверять в цикле while; и о том, что уведомлять можно и изнутри, и снаружи блокировки, но будить после отпускания блокировки лучше для снижения числа переключений контекста. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). О том, что функция атомарно отпускает указанную критическую секцию и ждёт на условной переменной; о том, что разбуженный поток перед возвратом снова захватывает критическую секцию; о том, что при тайм-ауте возвращается ERROR_TIMEOUT; и о том, что бывают ложные и украденные пробуждения, поэтому после возврата из wait предикат следует перепроверять (обычно в цикле while). ↩ ↩2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. О том, что ложные пробуждения из pthread_cond_wait / pthread_cond_timedwait возможны; о том, что возврат из wait ничего не говорит о значении предиката, поэтому предикат следует переоценить; и о том, что в Rationale сказано: реализация «будит ровно один» может замедлить операции условной переменной, особенно на многопроцессорных системах, а допуск ложных пробуждений заставляет цикл проверки предиката и делает приложения устойчивее. ↩ ↩2 ↩3 ↩4

  4. Microsoft Learn, condition_variable Class. О том, что wait без предиката снимается notify_one / notify_all и также может проснуться ложно; о том, что wait(lock, pred) с предикатом по сути выполняет while (!Pred()) wait(Lck);; и о том, что wait_for / wait_until имеют то же свойство и перегрузки с предикатом. ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, Monitor.Wait Method. О том, что Wait отпускает блокировку и входит в очередь ожидания; о том, что после пробуждения Pulse / PulseAll не возвращается, пока блокировка снова не захвачена; и о том, что предполагаемое использование — разбуженный поток заново оценивает условие, из-за которого он ждал, и при необходимости снова вызывает Wait. ↩ ↩2 ↩3 ↩4

  6. Microsoft Learn, PulseEvent function (winbase.h). О том, что ждущий поток может быть временно снят с ожидания ядерным APC и вернуться после завершения APC, поэтому если PulseEvent вызывают в этом интервале, поток не освобождается; и о том, что PulseEvent поэтому ненадёжен, в новых приложениях его использовать не следует и вместо него нужна условная переменная. ↩ ↩2 ↩3

  7. Microsoft Learn, WaitOnAddress function (synchapi.h). О том, что функция, ждущая изменения значения по адресу, гарантированно возвращается при сигнале, но ей разрешено вернуться и по другим причинам; о примерах раннего пробуждения — нехватка памяти, отказ от предыдущего wake по тому же адресу, работа на checked-сборке; и о том, что после возврата значение нужно снова сравнить, а официальный пример сам является циклом while. ↩ ↩2

  8. cppreference.com, std::condition_variable::wait. О том, что wait без предиката может быть снят ложным пробуждением; и о том, что перегрузка с предикатом эквивалентна while (!pred()) wait(lock); и определена как цикл, который при каждом уведомлении или ложном пробуждении снова захватывает блокировку и проверяет предикат. ↩ ↩2

  9. Microsoft Learn, Using Condition Variables. Об официальном примере, который реализует очередь производитель–потребитель одной критической секцией и двумя условными переменными (BufferNotEmpty и BufferNotFull). Ожидание выполняется внутри цикла, который проверяет предикат. ↩

  10. Microsoft Learn, Monitor.PulseAll Method. О том, что PulseAll переносит потоки из очереди ожидания в очередь готовности, и следующий поток из очереди готовности захватывает блокировку, когда она отпускается; и о том, что Pulse / PulseAll / Wait можно вызывать только из синхронизированного блока. ↩ ↩2

  11. Microsoft Learn, Waits and APCs. О том, что ядерные APC выполняются вытесняюще, и система внутренне прерывает и возобновляет ожидание, не возвращаясь из API ожидания, поэтому кратковременный сигнал вроде KePulseEvent в этом интервале можно пропустить. ↩

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. О том, что статический анализ предупреждает об использовании PulseEvent; о том, что поток, который из-за APC был вне ожидания, не освобождается и может зависнуть навсегда; и об указаниях заменить его на SetEvent или другой объект синхронизации. ↩

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Ложное пробуждение — это ошибка ОС или библиотеки?
Нет. Это поведение, явно описанное в спецификации. И SleepConditionVariableCS в Win32, и std::condition_variable в C++, и pthread_cond_wait в POSIX — в официальной документации или в стандарте прямо сказано, что wait может вернуться без связи с уведомлением. Реализация, которая это запретила бы, теоретически возможна, но замедлила бы каждую операцию условной переменной (особенно уведомление на многопроцессорных системах). Поэтому это допускают с оговоркой: корректность сохраняется, если ждущая сторона снова проверяет условие. Исправлять это нужно не ожидая патча ОС, а всегда оборачивая wait в цикл while (или вызывая wait с предикатом).
Цикл while вокруг wait не ударит по производительности?
На практике цена незаметна. while добавляет только одну проверку условия при каждом пробуждении — это дешёвое сравнение, пока вы держите блокировку. Сами ложные пробуждения редки, поэтому лишний оборот цикла бывает лишь в исключительных случаях. Цена оставить if — ошибка, которая «воспроизводится только иногда»: обработка идёт дальше при ложном условии. Сравнивать нечего. На стоимость ожидания условной переменной влияют конкуренция за блокировку и частота уведомлений, а не наличие while.
Если в C++ вызвать wait с предикатом, про ложные пробуждения можно не думать?
Для цикла ожидания — да: cv.wait(lock, pred) по сути выполняет while (!pred()) wait(lock); поэтому и ложные, и украденные пробуждения поглощаются сами. В новом коде C++ перегрузку с предикатом стоит брать по умолчанию. Но обновления разделяемого состояния, которое читает предикат, по-прежнему нужно защищать тем же мьютексом, а уведомляющая сторона по-прежнему должна сначала обновить состояние и только потом вызывать notify. wait с предикатом берёт на себя цикл, а не дисциплину блокировок.
С Monitor.Wait в C# та же проблема есть?
Да. Поток, ждущий в Monitor.Wait, будится через Pulse/PulseAll и выходит из Wait только после повторного захвата блокировки. За это время другой поток может взять блокировку первым и потребить условие (украденное пробуждение). Документация Microsoft как раз предполагает, что разбуженный поток заново оценивает условие, из-за которого он ждал, и при необходимости снова вызывает Wait. Поэтому и в C# базовая форма — while (!условие) Monitor.Wait(gate);. Отличие от Win32: Wait и Pulse можно вызывать только из оператора lock.
Ложные пробуждения бывают и при ожидании события через WaitForSingleObject?
В обычном (не alertable) ожидании WAIT_OBJECT_0 возвращается только когда объект действительно перешёл в сигнальное состояние. «Беспричинного» пробуждения, как у условных переменных, нет. Но «событие стало сигнальным» и «условие вашего приложения выполнено» — разные вещи. Если несколько потребителей будит одно и то же событие, поток, который первым взял блокировку, потребит условие — после пробуждения его всё равно нужно перепроверить. А схема, которая пытается событием воспроизвести кратковременное уведомление условной переменной («разбудить только тех, кто ждёт в этот момент»), почти неизбежно упирается в ненадёжность PulseEvent. Для ожидания выполнения условия внутри процесса безопаснее условная переменная.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог