Ложное пробуждение: почему 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; сначала сравним три случая пробуждения.
| Случай | Связь с явным уведомлением | Как читать возврат |
|---|---|---|
| Настоящее пробуждение | Уведомление есть | Условие часто выполнено, но сам факт возврата этого не гарантирует |
| Ложное пробуждение | Не связано с явным уведомлением, которое должно было разбудить этот поток | Возвращается, хотя условие по-прежнему не выполнено |
| Украденное пробуждение | Уведомление есть | Другой поток потребил условие первым, поэтому оно уже не выполнено |
flowchart TB
accTitle: Три случая, в которых wait возвращается
accDescr: wait на условной переменной возвращается не только по настоящему уведомлению, но и по ложному пробуждению без уведомления, и по украденному, когда уведомление пришло, но условие уже потребили, поэтому условие нужно перепроверять в каждом случае
w["Вернулись из wait"] --> a["Настоящее уведомление"]
w --> b["Ложное пробуждение (без уведомления)"]
w --> c["Украденное пробуждение (условие уже потреблено)"]
a --> r["Перепроверить условие, затем продолжать"]
b --> r
c --> r
Рис. 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 очередь уже пуста.
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
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
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, при возврате перепроверяют.
flowchart TB
accTitle: Поток правильного цикла ожидания
accDescr: Захватить блокировку и проверить условие; если не выполнено, отпустить блокировку и заснуть; при пробуждении снова захватить блокировку и вернуться к проверке условия. Продолжать, держа блокировку, только когда условие выполнено
l["Захватить блокировку"] --> c{"Условие выполнено?"}
c -->|"Нет"| s["wait (отпустить блокировку и заснуть)"]
s --> wk["Проснуться (снова захватить блокировку)"]
wk --> c
c -->|"Да"| go["Обрабатывать, всё ещё держа блокировку"]
Рис. 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);
flowchart TB
accTitle: Правильный поток ожидания с тайм-аутом
accDescr: Сначала задать дедлайн и при каждом пробуждении проверять условие и дедлайн; если дедлайн ещё не прошёл, пересчитать оставшееся время и вернуться в ожидание
d["Задать дедлайн"] --> c{"Условие выполнено?"}
c -->|"Да"| go["Перейти к обработке"]
c -->|"Нет"| t{"Дедлайн прошёл?"}
t -->|"Да"| to["Обработать тайм-аут"]
t -->|"Нет"| w["Посчитать оставшееся время и ждать"]
w --> c
Рис. 5: Ожидание с тайм-аутом не передаёт «то же самое время ожидания» снова; оно пересчитывает оставшееся время от дедлайна.
В C++ эту обработку можно оставить перегрузке wait_until с предикатом, которая принимает абсолютное время. Даже при тайм-ауте она возвращает итоговое значение предиката, поэтому решать можно по тому, выполнилось ли условие в конце.4
6. Шаблоны, которых избегать
6.1 Проверять условие только один раз через if
Когда случается ложное или украденное пробуждение, обработка идёт дальше при невыполненном условии. Это проявляется как редкий сбой или порча данных: взятие из пустой очереди, чтение неинициализированных данных, двойное освобождение. Меняйте на while или wait с предикатом из главы 5.
6.2 Проверять или обновлять условие вне блокировки
Это уже не «лишние пробуждения», а потерянное пробуждение: пропустить уведомление и спать вечно.
Если ждущая сторона смотрит на условие вне блокировки, решает «ещё нет», а уведомляющая сторона обновляет состояние и уведомляет до входа в wait, в этот миг ждущего нет. Ждущая сторона входит в wait после того, как уведомление уже ушло, и продолжает ждать, если нового уведомления не будет.
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: потерянное пробуждение.
Именно поэтому 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.
flowchart TB
accTitle: Поток сортировки по симптому
accDescr: Если симптом — обработка идёт при невыполненном условии, искать в коде wait без предиката; если симптом — поток не просыпается, найти место ожидания по дампу и подозревать потерянное пробуждение или PulseEvent
s["Ошибка, которая проявляется только иногда"] --> a["Обработка идёт при невыполненном условии"]
s --> b["Поток, который должен проснуться, не просыпается"]
a --> a1["Искать в коде wait без предиката"]
b --> b1["Найти ждущие потоки по дампу"]
a1 -.-> a2["Сменить if на while или wait с предикатом"]
b1 -.-> b2["Подозревать потерянное пробуждение или 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 отличают от беспричинных пробуждений, но ту же повторную проверку делают из-за украденных пробуждений и тайм-аутов.
При сбое начинайте с «места, которое идёт дальше, хотя условие не выполнено»; при зависании — с «места, которое пропускает уведомление». Этим различием и чек-листом пользуйтесь и при ревью существующего кода.
Похожие статьи
- Практические приёмы многопоточности в C++: как RAII и jthread убирают аварии из конструкции
- Многопоточность на C: практические рекомендации — безопасно по правилам Win32 API
- Практические рекомендации по многопоточности: .NET — что решить до добавления потоков
- Почему в Windows стоит предпочитать ожидание события, а не Sleep(1)
- Разделяемая память: подводные камни и практические рекомендации
- Глубины Windows I/O (часть 2) — синхронный и асинхронный I/O: что на самом деле значит OVERLAPPED
Смежные области консультирования
KomuraSoft LLC занимается ревью проекта многопоточного кода, расследованием причин (анализ дампов) сбоев и зависаний, которые «воспроизводятся только иногда», и переработкой устаревшего кода синхронизации (зависимость от событий, PulseEvent и подобного) на основу условных переменных. Можно начать с разбора симптома; обращайтесь.
- Техническая консультация и ревью проекта
- Расследование сбоев и анализ причин
- Разработка Windows-приложений
- Связаться с нами
Справочные ссылки
-
Microsoft Learn, Condition Variables. О том, что условная переменная — объект пользовательского режима, который атомарно отпускает блокировку и входит в ожидание; о том, что бывают ложные пробуждения (не связанные с явным wake) и украденные (другой поток выполняется раньше разбуженного), поэтому после возврата из wait предикат следует перепроверять в цикле while; и о том, что уведомлять можно и изнутри, и снаружи блокировки, но будить после отпускания блокировки лучше для снижения числа переключений контекста. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, SleepConditionVariableCS function (synchapi.h). О том, что функция атомарно отпускает указанную критическую секцию и ждёт на условной переменной; о том, что разбуженный поток перед возвратом снова захватывает критическую секцию; о том, что при тайм-ауте возвращается ERROR_TIMEOUT; и о том, что бывают ложные и украденные пробуждения, поэтому после возврата из wait предикат следует перепроверять (обычно в цикле while). ↩ ↩2
-
The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. О том, что ложные пробуждения из pthread_cond_wait / pthread_cond_timedwait возможны; о том, что возврат из wait ничего не говорит о значении предиката, поэтому предикат следует переоценить; и о том, что в Rationale сказано: реализация «будит ровно один» может замедлить операции условной переменной, особенно на многопроцессорных системах, а допуск ложных пробуждений заставляет цикл проверки предиката и делает приложения устойчивее. ↩ ↩2 ↩3 ↩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
-
Microsoft Learn, Monitor.Wait Method. О том, что Wait отпускает блокировку и входит в очередь ожидания; о том, что после пробуждения Pulse / PulseAll не возвращается, пока блокировка снова не захвачена; и о том, что предполагаемое использование — разбуженный поток заново оценивает условие, из-за которого он ждал, и при необходимости снова вызывает Wait. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, PulseEvent function (winbase.h). О том, что ждущий поток может быть временно снят с ожидания ядерным APC и вернуться после завершения APC, поэтому если PulseEvent вызывают в этом интервале, поток не освобождается; и о том, что PulseEvent поэтому ненадёжен, в новых приложениях его использовать не следует и вместо него нужна условная переменная. ↩ ↩2 ↩3
-
Microsoft Learn, WaitOnAddress function (synchapi.h). О том, что функция, ждущая изменения значения по адресу, гарантированно возвращается при сигнале, но ей разрешено вернуться и по другим причинам; о примерах раннего пробуждения — нехватка памяти, отказ от предыдущего wake по тому же адресу, работа на checked-сборке; и о том, что после возврата значение нужно снова сравнить, а официальный пример сам является циклом while. ↩ ↩2
-
cppreference.com, std::condition_variable::wait. О том, что wait без предиката может быть снят ложным пробуждением; и о том, что перегрузка с предикатом эквивалентна while (!pred()) wait(lock); и определена как цикл, который при каждом уведомлении или ложном пробуждении снова захватывает блокировку и проверяет предикат. ↩ ↩2
-
Microsoft Learn, Using Condition Variables. Об официальном примере, который реализует очередь производитель–потребитель одной критической секцией и двумя условными переменными (BufferNotEmpty и BufferNotFull). Ожидание выполняется внутри цикла, который проверяет предикат. ↩
-
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 и синхронизироваться с потоками. По первоисточникам: как блокировка загрузчика сериализует ...
Почему ломаются аргументы ── правила аргументов командной строки Windows
В Windows массива аргументов нет: в CreateProcess уходит одна строка, делит её принимающая сторона. Правила деления CommandLineToArgvW, C...
Что остаётся после падения родителя ── дерево дочерних процессов в Job Object
Почему после принудительного завершения UI остаётся помощник SDK и держит камеру или COM-порт. Как Job Object делает дерево процессов одн...
Пул потоков Win32 — параллелизм через CreateThreadpoolWork без своих потоков
Не плодите ли вы CreateThread по всему нативному коду? Разбираем API пула потоков Win32, переработанный в Vista: четыре объекта work, tim...
Именованные каналы на практике — от проектирования до безопасности IPC в Windows
Практический разбор именованных каналов — стандартного IPC в Windows. По первоисточникам: выбор байтового режима и режима сообщений, серв...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Ложное пробуждение — это ошибка ОС или библиотеки?
- Нет. Это поведение, явно описанное в спецификации. И 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.