Почему в 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, и по энергопотреблению.

Событийный способ ожиданияЕсли ждать нужно не время, а событие, не опрашивайте состояние через равные интервалы: сторона, где событие произошло, подаёт signal, а ожидающая сторона ждёт event — так честнее и по задержке, и по CPU, и по энергопотреблению.Ждём событиеСторона события подаёт signalОжидающая сторона ждёт eventЧестнее по задержке, 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
Как провести черту между timer и eventTimer оставляем только там, где условием является само время; появление работы, завершение ввода-вывода и запрос на остановку ждём через event.само времясобытиеЖдём время или событие?работа timerработа, которая ждёт eventпоявление работы / завершение 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.

Два способа узнать сырую гранулярностьГранулярность таймера в своей среде можно проверить вызовом GetSystemTimeAdjustment (интервал обновления в единицах 100 наносекунд) или запуском ClockRes из Sysinternals, который внутри вызывает тот же API.Нужна гранулярность этой машиныВызвать GetSystemTimeAdjustmentЗапустить ClockResВернётся интервал в единицах 100 нсВнутри вызывается тот же API

Рис. 3: Сырую гранулярность среды можно проверить двумя путями: вызвать API из кода или воспользоваться ClockRes.

2.2 Даже когда срок наступил, выполнение сразу не начинается

Дальше путает то, что поток не начинает выполняться в тот самый момент, когда истёк тайм-аут.

Как сказано и в описании Sleep, после окончания ожидания поток становится ready, но гарантии сразу получить CPU и начать выполняться нет. На это влияют другие потоки, priority, idle-состояние CPU, DPC / ISR, конкуренция за блокировки.

То есть у короткого timer wait есть как минимум два уровня неопределённости.

  1. Само определение тайм-аута тянется за гранулярностью таймера
  2. Даже после тайм-аута момент старта зависит от планировщика
Два уровня неопределённости короткого timer waitУ короткого timer wait определение тайм-аута зависит от гранулярности таймера, а после тайм-аута поток лишь становится ready, и старт выполнения решает планировщик — два уровня неопределённости.Начали короткий timer waitОпределение тайм-аута зависит от гранулярностиПоток лишь становится readyСтарт выполнения — на стороне планировщикаВлияние других потоков, DPC / ISR

Рис. 4: Заданное время ожидания уезжает в двух местах: при определении тайм-аута и при старте выполнения.

2.3 Sleep(1) не означает период 1 мс

Глядя на Sleep(1), легко принять его за «цикл, который крутится каждые 1 мс». Так читать нельзя.

while (!g_stop)
{
    Step();
    Sleep(1);
}

На деле этот цикл выглядит так.

  • каждый раз добавляется время выполнения Step()
  • само ожидание Sleep(1) тянется за гранулярностью
  • даже после пробуждения поток не обязан сразу поехать
Чем на самом деле является цикл Sleep(1)Цикл Sleep(1) не даёт период 1 мс: каждый раз добавляется время выполнения Step, само ожидание тянется за гранулярностью, и после пробуждения поток не обязан сразу поехать.Добавляется время StepПериода 1 мс нетОжидание тянется за гранулярностьюПосле пробуждения сразу не поехать

Рис. 5: Три смещения накладываются, поэтому Sleep(1) не даёт период раз в 1 миллисекунду.

3. Почему ожидание события выгоднее

3.1 Условие конца ожидания — не «время вышло», а «signal»

Event wait выгоднее потому, что меняется сам смысл ожидания.

Timer wait устроен так:

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

Event wait устроен иначе:

  • сторона, где что-то произошло, подаёт signal;
  • как только пришёл signal, условие ожидания выполнено;
  • в момент пробуждения причина уже есть.

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

event wait: разбудили, потому что случилосьсторона события подаёт signalждатьк пробуждению причина уже известнаобработатьtimer wait: проснулись, потому что вышло времянетдапробуждение по гранулярности timerждатьЧто-то уже случилось?обработать

Рис. 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» — можно убрать.

Какое ожидание event wait снимает и какое влияние остаётсяEvent wait всё равно подвержен scheduler latency, приоритету потока, DPC / ISR и другому, но лишний способ ждать до следующего timer tick снимается.Ждём через event waitВлияние планировщика остаётсяОжидание до timer tick снимаетсяSignal не даёт нулевую задержку

Рис. 7: Event — не волшебство, но необходимость просыпаться по гранулярности таймера исчезает.

4. Типичные антипаттерны

4.1 Опрос очереди через Sleep(1)

Это самый частый пример.

for (;;)
{
    if (g_stop)
    {
        break;
    }

    WorkItem item;
    if (TryPop(item))
    {
        Process(item);
        continue;
    }

    Sleep(1);
}

Запись выглядит простой, но проблем три.

  1. Поток регулярно просыпается, даже если очередь пуста
  2. Latency тянется за гранулярностью таймера
  3. С точки зрения power это тоже невыгодно
Три проблемы опроса через Sleep(1)Опрос очереди через Sleep(1) даёт три проблемы: регулярное пробуждение даже при пустой очереди, latency, которая тянется за гранулярностью таймера, и потери по энергопотреблению.Поглядывать на очередь через Sleep(1)Просыпаемся, даже если пустоLatency тянется за гранулярностьюПроигрываем и по 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’ит очередь.
Схема, в которой signal подаёт producerProducer сразу после помещения item в очередь вызывает SetEvent, consumer ждёт через WaitForSingleObject и после пробуждения drain'ит очередь.consumerqueueproducerconsumerqueueproducerположить itemсразу SetEventпроснуться из waitdrain очереди

Рис. 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_0WAIT_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

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

  1. Обязательно в паре с WakeByAddressSingle или WakeByAddressAll. Если сторона, которая переписала значение, это не вызовет, ожидающий поток не проснётся. Будить один поток — Single, все — All.
  2. WaitOnAddress может вернуться и без signal. В документации прямо сказано, что ранний возврат возможен, например, при нехватке памяти. После возврата обязательно ещё раз прочитать значение и в цикле while проверить, что оно действительно изменилось.
  3. Ждать можно размер 1 / 2 / 4 / 8 байт.
  4. Флаг делайте атомарным. 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);
Парный ход WaitOnAddressПишущая сторона готовит данные, пишет флаг с release и будит через WakeByAddressSingle; ждущая сторона в цикле while перечитывает значение с acquire, чтобы пережить ранний возврат.ждущая сторонаатомарный флагпишущая сторонаждущая сторонаатомарный флагпишущая стороначитать с acquireWaitOnAddress, пока не изменитсяписать с releaseWakeByAddressSingleпроснувшись, перечитать

Рис. 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). Но первым выбором на каждый день это лучше не делать.

Причин три.

  1. Есть цена по power / performance
  2. В современных Windows поведение чуть сложнее
  3. Часто первопричина так и не устраняется
Почему timeBeginPeriod не стоит делать привычкойДаже если беспокоит точность короткого timer wait, timeBeginPeriod не стоит делать первым выбором на каждый день: есть цена по power и performance, в современных Windows поведение сложнее, и часто первопричина не устраняется.Беспокоит точностьХочется добавить timeBeginPeriodНе делать первым выбором на каждый деньЕсть ценаПоведение чуть сложнееПервопричина не устранена

Рис. 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 тоже становится лучше
  • намерение кода читается яснее
Время — timer, событие — eventЕсли чётко провести черту «время ждём timer, событие — event», latency становится проще прогнозировать, лишние periodic wakeup сокращаются, энергопотребление улучшается, а намерение кода читается яснее.времясобытиеЧего ждём?ждать timerждать eventчерта становится явнойlatency проще прогнозироватьлишние wakeup сокращаются

Рис. 12: Одной строки-черты достаточно, чтобы изменились задержка, энергия и то, насколько код выдаёт своё намерение.

9. Справочные материалы

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

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

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

Технические консультации и ревью дизайна

Тема про проектирование ожидания, выбор примитивов синхронизации и компромисс между задержкой и энергопотреблением в soft real-time, поэтому она хорошо ложится на техническую консультацию и ревью архитектуры.

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

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

Почему 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

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

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