Почему в Windows стоит предпочитать ожидание события, а не Sleep(1)
· Обновлено: · Го Комура · Разработка Windows, Синхронизация, События, Таймер, Проектирование
История изменений (1 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619712)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Почему в Windows стоит предпочитать ожидание события, а не Sleep(1). KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/03/16/006-windows-timer-vs-event-wait/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.21619712
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.21619713
В предыдущей статье — практическом руководстве по soft real-time в Windows речь шла о том, чтобы не отдавать периодический цикл на откуп Sleep.
Сейчас сузим тему до одного пункта: почему короткому timer wait лучше предпочесть event wait.
Эту статью можно читать отдельно. Вывод предыдущей — одна строка: «цикл, который просто периодически поглядывает на состояние и полагается на Sleep, не гарантирует ни длительность ожидания, ни момент пробуждения, поэтому его нельзя класть в основу периодической обработки». Если это уже в голове, дальше предыдущая статья не нужна.
В Windows конструкция «через равные промежутки посмотреть, не появилось ли что-то» через Sleep(1) или wait с коротким тайм-аутом неизбежно попадает под влияние гранулярности системных часов и последующей задержки планирования.
При обычных настройках базой чаще оказывается platform timer resolution порядка 15,6 мс, поэтому даже с намерением «через 1 мс глянуть ещё раз» на практике ожидание легко получается заметно грубее.
Если же то, что вы действительно хотите дождаться, — не «время», а «событие»: появление работы, завершение ввода-вывода, запрос на остановку, изменение состояния, — заходить проверять через равные интервалы не нужно. Сторона, где событие произошло, подаёт signal, а ожидающая сторона ждёт event — так честнее и по задержке, и по CPU, и по энергопотреблению.
flowchart TB
accTitle: Событийный способ ожидания
accDescr: Если ждать нужно не время, а событие, не опрашивайте состояние через равные интервалы: сторона, где событие произошло, подаёт signal, а ожидающая сторона ждёт event — так честнее и по задержке, и по CPU, и по энергопотреблению.
d1["Ждём событие"] --> d2["Сторона события подаёт signal"]
d2 --> d3["Ожидающая сторона ждёт event"]
d3 -.-> d4["Честнее по задержке, CPU и энергии"]
Рис. 1: Если ждёте событие, не поглядывайте через равные интервалы — пусть сторона, где оно произошло, сама вас известит.
В этой статье хочется ответить на четыре вопроса.
- Почему
Sleep(1)и короткий timer wait оказываются менее точными, чем кажется - Почему event wait меньше подвержен этому ограничению
- В каких случаях выбирать event, а не timer
- И всё же, когда timer использовать стоит
Термины, которые встречаются в статье
Сначала только те сокращения, которые дальше появляются без пояснения.
| Термин | Смысл |
|---|---|
| platform timer resolution / system clock resolution | Интервал, с которым ОС обновляет время. Определение тайм-аута timed wait тянется за этой гранулярностью |
| ISR (Interrupt Service Routine) | Обработка, которая бежит с наивысшим приоритетом в момент прерывания. Пока она выполняется, наш поток ждёт |
| DPC (Deferred Procedure Call) | Отложенная обработка с высоким приоритетом, которую ISR ставит «доделать потом». Вместе с ISR в этой статье достаточно читать это как источник задержки, связанный с обработкой прерывания |
| IOCP (I/O Completion Port) | Механизм Windows: уведомления о завершении асинхронного ввода-вывода собираются в очередь и принимаются выделенной группой потоков |
WaitOnAddress |
API синхронизации «ждать, пока не изменится значение по адресу в памяти». Только внутри одного процесса (разбираем в 5.3) |
| подать signal | Выполнить условие ожидающей стороны. Для event это вызов SetEvent |
1. Сначала вывод
- Если ждать появление работы или завершение ввода-вывода, лучше ждать event, а не timer.
- Timed wait в Windows неизбежно зависит от гранулярности системных часов.
Sleep(1)не означает «пробудиться ровно через 1 мс».- Даже после истечения тайм-аута поток сначала лишь становится ready: немедленное выполнение не гарантировано.
- Поэтому конструкция «на самом деле ждём событие, а проверяем по таймеру» проигрывает и по задержке, и по энергопотреблению.
- Timer стоит оставлять только там, где условием действительно является само время — так схема получается чище.
На языке практики это почти всегда сводится к следующему.
- «Отправлять метрики каждые 5 секунд» -> работа timer
- «Начать работу сразу, как только в очереди появилось задание» -> работа event / semaphore / condition variable /
WaitOnAddress - «Продолжить выполнение, когда закончился ввод-вывод» -> работа completion / event
- «Остановиться по запросу на остановку» -> работа stop event / cancellation
flowchart TB
accTitle: Как провести черту между timer и event
accDescr: Timer оставляем только там, где условием является само время; появление работы, завершение ввода-вывода и запрос на остановку ждём через event.
q1{"Ждём время или событие?"}
q1 -->|"само время"| t1["работа timer"]
q1 -->|"событие"| e1["работа, которая ждёт event"]
e1 -.-> rei["появление работы / завершение I/O / остановка"]
Рис. 2: Timer оставляем только там, где условием является само время; события ждём через event.
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. В чём проблема
2.1 Timed wait привязан к гранулярности системных часов
Точность тайм-аута wait-функций Windows зависит от system clock resolution.
То же самое касается Sleep: указанные миллисекунды не гарантируются как «ровно такая длительность».
Важно вот что: указали 1 мс — не значит, что проснётесь через 1 мс.
Как проверить гранулярность у себя
«Порядка 15,6 мс» — общее наблюдение, поэтому своё значение быстрее посмотреть самому. Способов два.
Первый — вызвать GetSystemTimeAdjustment. Во втором аргументе lpTimeIncrement возвращается интервал, с которым система обновляет time-of-day clock, в единицах 100 наносекунд. Если это уровень 15,6 мс, значение будет порядка 150 тысяч.
#include <windows.h>
#include <cstdio>
int main()
{
DWORD adjustment = 0;
DWORD increment = 0;
BOOL adjustmentDisabled = FALSE;
if (!GetSystemTimeAdjustment(&adjustment, &increment, &adjustmentDisabled))
{
std::printf("GetSystemTimeAdjustment failed. GetLastError=%lu\n", GetLastError());
return 1;
}
// increment в единицах 100 нс, поэтому смотрим его в мс
std::printf("time increment = %lu (100ns) = %.4f ms\n",
increment,
increment / 10000.0);
return 0;
}
Второй — запустить ClockRes из Sysinternals. Это маленький инструмент, который внутри вызывает тот же GetSystemTimeAdjustment и показывает разрешение системных часов, то есть максимальную timer resolution, доступную приложению. Если писать код не хочется, так быстрее.
В документации lpTimeIncrement описан как фиксированное значение, которое система задаёт при запуске, и во время работы оно не меняется. То есть это способ узнать «сырую» гранулярность этой среды, а не измерить эффект timeBeginPeriod. Про timeBeginPeriod — в 6.3.
flowchart TB
accTitle: Два способа узнать сырую гранулярность
accDescr: Гранулярность таймера в своей среде можно проверить вызовом GetSystemTimeAdjustment (интервал обновления в единицах 100 наносекунд) или запуском ClockRes из Sysinternals, который внутри вызывает тот же API.
g1["Нужна гранулярность этой машины"] --> g2["Вызвать GetSystemTimeAdjustment"]
g1 --> g3["Запустить ClockRes"]
g2 --> g4["Вернётся интервал в единицах 100 нс"]
g3 -.-> g5["Внутри вызывается тот же API"]
Рис. 3: Сырую гранулярность среды можно проверить двумя путями: вызвать API из кода или воспользоваться ClockRes.
2.2 Даже когда срок наступил, выполнение сразу не начинается
Дальше путает то, что поток не начинает выполняться в тот самый момент, когда истёк тайм-аут.
Как сказано и в описании Sleep, после окончания ожидания поток становится ready, но гарантии сразу получить CPU и начать выполняться нет.
На это влияют другие потоки, priority, idle-состояние CPU, DPC / ISR, конкуренция за блокировки.
То есть у короткого timer wait есть как минимум два уровня неопределённости.
- Само определение тайм-аута тянется за гранулярностью таймера
- Даже после тайм-аута момент старта зависит от планировщика
flowchart TB
accTitle: Два уровня неопределённости короткого timer wait
accDescr: У короткого timer wait определение тайм-аута зависит от гранулярности таймера, а после тайм-аута поток лишь становится ready, и старт выполнения решает планировщик — два уровня неопределённости.
s1["Начали короткий timer wait"] --> s2["Определение тайм-аута зависит от гранулярности"]
s2 --> s3["Поток лишь становится ready"]
s3 --> s4["Старт выполнения — на стороне планировщика"]
s3 -.-> s5["Влияние других потоков, DPC / ISR"]
Рис. 4: Заданное время ожидания уезжает в двух местах: при определении тайм-аута и при старте выполнения.
2.3 Sleep(1) не означает период 1 мс
Глядя на Sleep(1), легко принять его за «цикл, который крутится каждые 1 мс».
Так читать нельзя.
while (!g_stop)
{
Step();
Sleep(1);
}
На деле этот цикл выглядит так.
- каждый раз добавляется время выполнения
Step() - само ожидание
Sleep(1)тянется за гранулярностью - даже после пробуждения поток не обязан сразу поехать
flowchart TB
accTitle: Чем на самом деле является цикл Sleep(1)
accDescr: Цикл Sleep(1) не даёт период 1 мс: каждый раз добавляется время выполнения Step, само ожидание тянется за гранулярностью, и после пробуждения поток не обязан сразу поехать.
p1["Добавляется время Step"] --> p4["Периода 1 мс нет"]
p2["Ожидание тянется за гранулярностью"] --> p4
p3["После пробуждения сразу не поехать"] --> p4
Рис. 5: Три смещения накладываются, поэтому Sleep(1) не даёт период раз в 1 миллисекунду.
3. Почему ожидание события выгоднее
3.1 Условие конца ожидания — не «время вышло», а «signal»
Event wait выгоднее потому, что меняется сам смысл ожидания.
Timer wait устроен так:
- даже если ещё ничего не произошло,
- поток просыпается, когда истекло фиксированное время,
- и уже после пробуждения проверяет, случилось ли что-то.
Event wait устроен иначе:
- сторона, где что-то произошло, подаёт signal;
- как только пришёл signal, условие ожидания выполнено;
- в момент пробуждения причина уже есть.
На схеме видно, что сам способ закончить ожидание другой.
flowchart TB
subgraph TimerWait["timer wait: проснулись, потому что вышло время"]
T1["ждать"] --> T2["пробуждение по гранулярности timer"]
T2 --> T3{"Что-то уже случилось?"}
T3 -- "нет" --> T1
T3 -- "да" --> T4["обработать"]
end
subgraph EventWait["event wait: разбудили, потому что случилось"]
E1["ждать"] --> E2["сторона события подаёт signal"]
E2 --> E3["к пробуждению причина уже известна"]
E3 --> E4["обработать"]
end
Рис. 6: Холостой цикл есть только у timer wait; у event wait к моменту пробуждения причина уже определена.
Только на стороне timer wait есть цикл, который возвращается вхолостую. Именно он бьёт и по latency, и по энергопотреблению.
3.2 Инструмент выбираем по тому, чего ждём
Какой инструмент брать на практике? Для первого решения обычно хватает этой таблицы.
| Чего ждём | Плохой пример | Первый выбор |
|---|---|---|
| Появление работы в очереди | TryPop через Sleep(1) |
event / semaphore |
| Завершение ввода-вывода | Ходить смотреть состояние по timer | event overlapped I/O / IOCP |
| Запрос на остановку | Смотреть stop-флаг каждые 100 мс | stop event / cancellation |
| Изменение значения внутри одного процесса | while (flag == 0) Sleep(1) |
WaitOnAddress |
| Наступление момента времени | Насильно тянуть к event | timer / waitable timer |
3.3 Event — тоже не волшебство
Event wait выгоден в том смысле, что не обязан просыпаться по гранулярности таймера, но это не значит, что в момент signal поток стартует с абсолютно нулевой задержкой.
Даже на event wait влияют:
- scheduler latency
- приоритет потока
- power state CPU
- конкуренция за блокировки
- page fault
- DPC / ISR
Но как минимум лишний способ ждать — «спать до следующего timer tick» — можно убрать.
flowchart TB
accTitle: Какое ожидание event wait снимает и какое влияние остаётся
accDescr: Event wait всё равно подвержен scheduler latency, приоритету потока, DPC / ISR и другому, но лишний способ ждать до следующего timer tick снимается.
e1["Ждём через event wait"] --> e2["Влияние планировщика остаётся"]
e1 --> e3["Ожидание до timer tick снимается"]
e2 -.-> e4["Signal не даёт нулевую задержку"]
Рис. 7: Event — не волшебство, но необходимость просыпаться по гранулярности таймера исчезает.
4. Типичные антипаттерны
4.1 Опрос очереди через Sleep(1)
Это самый частый пример.
for (;;)
{
if (g_stop)
{
break;
}
WorkItem item;
if (TryPop(item))
{
Process(item);
continue;
}
Sleep(1);
}
Запись выглядит простой, но проблем три.
- Поток регулярно просыпается, даже если очередь пуста
- Latency тянется за гранулярностью таймера
- С точки зрения power это тоже невыгодно
flowchart TB
accTitle: Три проблемы опроса через Sleep(1)
accDescr: Опрос очереди через Sleep(1) даёт три проблемы: регулярное пробуждение даже при пустой очереди, latency, которая тянется за гранулярностью таймера, и потери по энергопотреблению.
a1["Поглядывать на очередь через Sleep(1)"] --> b1["Просыпаемся, даже если пусто"]
a1 --> b2["Latency тянется за гранулярностью"]
a1 --> b3["Проигрываем и по power"]
Рис. 8: У внешне простого цикла опроса есть три потери: пробуждения, задержка и энергия.
4.2 Наблюдение за состоянием через Thread.Sleep(1) / Task.Delay(1)
Тот же запах встречается и в C# / .NET.
while (!stoppingToken.IsCancellationRequested)
{
if (_queue.TryDequeue(out WorkItem? item))
{
await ProcessAsync(item, stoppingToken);
continue;
}
await Task.Delay(1, stoppingToken);
}
Снаружи это выглядит спокойно и асинхронно, но по сути архитектуры остаётся polling.
5. Как это исправить
5.1 Producer подаёт signal в момент появления работы
Если ждёте появления элементов в очереди, вместо опроса смените схему так, чтобы signal подавал producer.
- producer кладёт item в очередь;
- сразу после этого вызывает
SetEvent; - consumer ждёт через
WaitForSingleObjectилиWaitForMultipleObjects; - проснувшись, drain’ит очередь.
sequenceDiagram
accTitle: Схема, в которой signal подаёт producer
accDescr: Producer сразу после помещения item в очередь вызывает SetEvent, consumer ждёт через WaitForSingleObject и после пробуждения drain'ит очередь.
participant P as producer
participant Q as queue
participant C as consumer
P->>Q: положить item
P->>C: сразу SetEvent
C->>C: проснуться из wait
C->>Q: drain очереди
Рис. 9: О появлении знает producer, поэтому он и извещает — холостые пробуждения consumer исчезают.
5.2 Одновременно ждать work и stop через WaitForMultipleObjects
Для простого worker такая форма читается легко.
HANDLE waits[2] = { _stopEvent, _workEvent }; // index 0 = stop, index 1 = work
for (;;)
{
// bWaitAll = FALSE, поэтому возвращается index первого signal'нутого handle
DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
// Ошибка — это WAIT_FAILED ((DWORD)0xFFFFFFFF). Причину даёт только GetLastError
if (rc == WAIT_FAILED)
{
throw std::system_error(
static_cast<int>(GetLastError()),
std::system_category(),
"WaitForMultipleObjects failed.");
}
if (rc == WAIT_OBJECT_0) // stop
{
return;
}
if (rc == WAIT_OBJECT_0 + 1) // work
{
DrainQueue();
continue;
}
// Сюда при INFINITE-ожидании попадать не должны
// (WAIT_TIMEOUT или семейство WAIT_ABANDONED_0). Не проглатывать, а ронять
throw std::runtime_error("WaitForMultipleObjects returned an unexpected value.");
}
В этом примере важны три момента:
Sleep(1)исчез;- при появлении item producer вызывает
SetEvent; - worker одновременно ждёт
stopиwork.
Отдельно — как обращаться с возвращаемым значением: на практике это легко упустить.
- Если
bWaitAllравенFALSE, при успехе возвращается значение в диапазонеWAIT_OBJECT_0…WAIT_OBJECT_0 + nCount - 1, и index в массиве — это это значение минусWAIT_OBJECT_0. Если написать только две ветки== WAIT_OBJECT_0и!= WAIT_OBJECT_0 + 1, схема сломается в тот момент, когда handle станет три. - Если signal пришёл сразу у нескольких, возвращается меньший index. В примере выше
stopстоит на index 0 именно затем, чтобы не пропустить запрос на остановку. - Ошибка приходит не исключением, а возвращаемым значением
WAIT_FAILED((DWORD)0xFFFFFFFF). Причину не узнать, пока не вызватьGetLastError. Если всеrc != ожидаемоесвалить в одну кучу «неудача», пропадут причины вроде «handle уже закрыт» или «нет праваSYNCHRONIZE». - Если в набор ожидания подмешать mutex, может вернуться и семейство
WAIT_ABANDONED_0. В этом примере ждутся только event, поэтому такой возврат считаем неожиданным.
5.3 Внутри одного процесса кандидат и WaitOnAddress
Если внутри того же процесса нужно лишь «подождать, пока не изменится некоторое значение», WaitOnAddress тоже очень сильный вариант.
Отпадает возня: создать event, инициализировать его и следить, чтобы значение и синхронизация не разъехались.
Ощущение выбора примерно такое.
| Критерий | event / semaphore / waitable object | WaitOnAddress |
|---|---|---|
| Область ожидания | Можно и между процессами. Можно сделать именованным | Только внутри одного процесса |
| Сторона, которая будит | SetEvent / ReleaseSemaphore и т. п. |
WakeByAddressSingle / WakeByAddressAll |
| Подготовка | Нужны создание объекта ядра и управление handle | Достаточно переменной, которую ждём |
| Версии | Доступно давно | Windows 8 / Windows Server 2012 и новее |
| Линковка | Kernel32.lib |
Synchronization.lib |
При использовании есть три момента, которые не хочется упустить.
- Обязательно в паре с
WakeByAddressSingleилиWakeByAddressAll. Если сторона, которая переписала значение, это не вызовет, ожидающий поток не проснётся. Будить один поток — Single, все — All. WaitOnAddressможет вернуться и без signal. В документации прямо сказано, что ранний возврат возможен, например, при нехватке памяти. После возврата обязательно ещё раз прочитать значение и в цикле while проверить, что оно действительно изменилось.- Ждать можно размер 1 / 2 / 4 / 8 байт.
- Флаг делайте атомарным.
WakeByAddressSingleтолько будит ожидающий поток и сам по себе не делает предыдущую запись ни атомарной, ни видимой. Если обычную переменную читать и писать с обеих сторон, в C++ это гонка данных (неопределённое поведение), и в оптимизированной сборке обновление может так и не стать видимым — поток «разбудили», а он продолжает стоять. Пишущая сторона — release, читающая — acquire.
// Ждущая и будящая стороны — разные потоки, поэтому флаг обязательно атомарный.
// Если обычный ULONG читать и писать с обеих сторон, в C++ это гонка данных
// (неопределённое поведение), и в оптимизированной сборке значение может
// остаться в регистре, обновление не станет видимым, и поток продолжит
// стоять даже после пробуждения.
std::atomic<ULONG> g_ready{ 0 };
static_assert(std::atomic<ULONG>::is_always_lock_free,
"передаём в WaitOnAddress, поэтому нужен lock-free");
// Минимальная форма «ждать, пока g_ready не перестанет быть 0»
ULONG undesired = 0;
ULONG captured = g_ready.load(std::memory_order_acquire);
while (captured == undesired)
{
// Может вернуться рано, поэтому после возврата обязательно перечитать
WaitOnAddress(&g_ready, &undesired, sizeof(ULONG), INFINITE);
captured = g_ready.load(std::memory_order_acquire);
}
Сторона, которая меняет значение, сначала обновляет его, затем будит.
// Пишем с release. Тогда данные, подготовленные до этой строки (payload ниже),
// гарантированно видны стороне, которая читает с acquire. Порядок обеспечивает
// этот store, а не WakeByAddressSingle — тот только будит ожидающий поток
// и не делает предыдущую запись ни атомарной, ни видимой.
g_payload = ...; // данные, которые передаём вместе
g_ready.store(1, std::memory_order_release);
WakeByAddressSingle(&g_ready);
sequenceDiagram
accTitle: Парный ход WaitOnAddress
accDescr: Пишущая сторона готовит данные, пишет флаг с release и будит через WakeByAddressSingle; ждущая сторона в цикле while перечитывает значение с acquire, чтобы пережить ранний возврат.
participant W as пишущая сторона
participant F as атомарный флаг
participant S as ждущая сторона
S->>F: читать с acquire
S->>S: WaitOnAddress, пока не изменится
W->>F: писать с release
W->>S: WakeByAddressSingle
S->>F: проснувшись, перечитать
Рис. 10: Будит WakeByAddress; видимость значения обеспечивают release и acquire.
6. Когда timer всё же стоит использовать
6.1 Когда условием является само время
Разумеется, законные сценарии для timer есть.
- Отправлять метрики каждые 5 секунд
- Повторить попытку через 200 мс
- Чистить кэш раз в минуту
- Ждать до срока и затем сделать timeout
Здесь то, чего вы ждёте, — действительно время.
6.2 Используйте waitable timer
Если в Windows ждёте именно «само время», waitable timer яснее выражает намерение, чем небрежно наслоенные Sleep.
6.3 Не делайте timeBeginPeriod привычкой
Когда начинает беспокоить точность короткого timer wait, рука тянется добавить timeBeginPeriod(1).
Но первым выбором на каждый день это лучше не делать.
Причин три.
- Есть цена по power / performance
- В современных Windows поведение чуть сложнее
- Часто первопричина так и не устраняется
flowchart TB
accTitle: Почему timeBeginPeriod не стоит делать привычкой
accDescr: Даже если беспокоит точность короткого timer wait, timeBeginPeriod не стоит делать первым выбором на каждый день: есть цена по power и performance, в современных Windows поведение сложнее, и часто первопричина не устраняется.
t1["Беспокоит точность"] --> t2["Хочется добавить timeBeginPeriod"]
t2 --> t3["Не делать первым выбором на каждый день"]
t3 -.-> r1["Есть цена"]
t3 -.-> r2["Поведение чуть сложнее"]
t3 -.-> r3["Первопричина не устранена"]
Рис. 11: Прежде чем поднимать точность, вспомните три причины не делать это привычкой.
7. Чек-лист на ревью
- Не строите ли цикл «поглядеть на состояние» через
Sleep(1)/Thread.Sleep(1)/Task.Delay(1)? - Не опрашиваете ли по timer то, что на самом деле является появлением элемента в очереди, завершением ввода-вывода или запросом на остановку?
- Позволяет ли архитектура стороне producer / completion подать signal?
- Нельзя ли ждать
stopиworkодним вызовом wait? - Нельзя ли для изменения значения внутри одного процесса написать это через
WaitOnAddress? - Там, где стоит timer, действительно ли то, чего вы ждёте, — это «время»?
8. Итог
Конструкция «через равные промежутки посмотреть, не появилось ли что-то» через короткий timer wait в Windows неизбежно попадает под влияние гранулярности таймера и планировщика.
Поэтому Sleep(1) и короткий timeout — не такое точное ожидание, каким кажутся снаружи.
Если же то, что вы действительно хотите дождаться, — «событие»: появление работы, завершение ввода-вывода, запрос на остановку, изменение состояния, — естественнее event wait.
Свести можно к одной строке:
Время ждите timer, событие — event.
Одной этой черты достаточно, чтобы выиграли сразу несколько сторон:
- latency становится проще прогнозировать
- лишние periodic wakeup сокращаются
- с точки зрения power тоже становится лучше
- намерение кода читается яснее
flowchart TB
accTitle: Время — timer, событие — event
accDescr: Если чётко провести черту «время ждём timer, событие — event», latency становится проще прогнозировать, лишние periodic wakeup сокращаются, энергопотребление улучшается, а намерение кода читается яснее.
m1{"Чего ждём?"}
m1 -->|"время"| m2["ждать timer"]
m1 -->|"событие"| m3["ждать event"]
m2 --> m4["черта становится явной"]
m3 --> m4
m4 -.-> m5["latency проще прогнозировать"]
m4 -.-> m6["лишние wakeup сокращаются"]
Рис. 12: Одной строки-черты достаточно, чтобы изменились задержка, энергия и то, насколько код выдаёт своё намерение.
9. Справочные материалы
- Sleep function (Win32)
- Wait Functions
- WaitForSingleObject function
- WaitForMultipleObjects function
- Event Objects (Synchronization)
- Using Event Objects
- WaitOnAddress function
- WakeByAddressSingle function
- WakeByAddressAll function
- GetSystemTimeAdjustment function
- ClockRes - Sysinternals
- timeBeginPeriod function
- CreateWaitableTimerExW function
- SetWaitableTimer function
- Thread.Sleep Method (.NET)
- Results for the Idle Energy Efficiency Assessment
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Спящий режим, гибернация и Modern Standby: как не дать долгоживущему приложению остановиться ночью
Разбираем, почему долгоживущее Windows-приложение к утру оказывается остановленным: чем отличаются спящий режим S3, гибернация и Modern S...
Минимальный чек-лист безопасности при разработке Windows-приложений
Чек-лист базовых мер безопасности для бизнес-приложений на WPF / WinForms / WinUI / C++ / C#: права, подпись кода, обновления, секреты, H...
Три таймера .NET: как выбирать PeriodicTimer, Timer и DispatcherTimer
Чем отличаются PeriodicTimer, System.Threading.Timer и DispatcherTimer и как выбирать между ними для async-работы, callback на ThreadPool...
Зачем использовать .NET Generic Host и BackgroundService в десктопных приложениях
Как с помощью Generic Host и BackgroundService собрать запуск, периодическую работу, завершение, логи, конфигурацию и DI в Windows-инстру...
Практическое руководство по FileSystemWatcher — как не терять уведомления и что делать с дублями
Как пользоваться FileSystemWatcher и где он подводит: потерянные уведомления, дубли, ловушки определения готовности, повторное сканирован...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Технические консультации и ревью дизайна
Тема про проектирование ожидания, выбор примитивов синхронизации и компромисс между задержкой и энергопотреблением в soft real-time, поэтому она хорошо ложится на техническую консультацию и ревью архитектуры.
Разработка приложений для Windows
Замена опроса по таймеру на событийный подход в Windows-приложениях и службах напрямую связана с качеством реализации, поэтому это естественная тема для разработки Windows-приложений.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Почему Sleep(1) в Windows не пробуждается ровно через 1 миллисекунду?
- Точность тайм-аута timed wait в Windows зависит от разрешения системных часов, и при обычных настройках базой чаще оказывается platform timer resolution порядка 15,6 мс. Даже когда время ожидания истекло, поток лишь переходит в состояние готовности (ready): гарантии сразу получить CPU и начать выполняться нет. На это влияют другие потоки, приоритет, idle-состояние CPU, DPC/ISR и конкуренция за блокировки. У короткого timer wait есть как минимум два уровня неопределённости: само определение тайм-аута тянется за гранулярностью таймера, а старт выполнения после тайм-аута зависит от планировщика.
- В чём проблема опроса очереди через Sleep(1) или Task.Delay(1)?
- Проблем три: поток регулярно просыпается, даже когда очередь пуста; задержка тянется за гранулярностью таймера; с точки зрения энергопотребления это тоже невыгодно. Цикл с await Task.Delay(1) в C# выглядит спокойно, но по сути архитектуры остаётся polling. Исправление такое: producer вызывает SetEvent сразу после добавления элемента в очередь, а consumer ждёт через WaitForSingleObject или WaitForMultipleObjects. Если stop-событие и work-событие ждать одним вызовом wait, на запрос остановки тоже можно реагировать сразу.
- Как выбирать между ожиданием таймера и ожиданием события?
- Черта простая: время ждём таймером, событие — event. Если условие — само время, например отправка метрик каждые 5 секунд, это работа waitable timer. Появление работы в очереди лучше ждать через event или semaphore, завершение ввода-вывода — через event overlapped I/O или IOCP, запрос на остановку — через stop event или cancellation, изменение значения внутри одного процесса — через WaitOnAddress. Когда эта черта проведена явно, задержку проще прогнозировать, лишние периодические пробуждения сокращаются, а намерение кода читается лучше.
- Решит ли проблему timeBeginPeriod(1), если поднять точность таймера?
- Делать это первым выбором на каждый день не стоит. Причин три: есть цена по энергопотреблению и производительности; в современных Windows поведение стало чуть сложнее; и чаще всего первопричина так и не устраняется. Если на самом деле вы ждёте событие — появление работы или завершение ввода-вывода, — логичнее не поднимать точность таймера, а перейти на событийную схему, где сигнализирует сторона, у которой событие произошло: так честнее и по задержке, и по CPU, и по энергопотреблению.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.