Почему в Windows стоит предпочитать ожидание события, а не Sleep(1)

· · Разработка Windows, Синхронизация, События, Таймер, Проектирование

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

В Windows конструкция «периодически проверять состояние» через Sleep(1) или ожидание с коротким тайм-аутом неизбежно попадает под влияние гранулярности системных часов и последующей задержки планирования. При обычных настройках базовым уровнем platform timer resolution чаще всего оказывается величина порядка 15,6 мс, поэтому даже с намерением «проверить снова через 1 мс» на практике ожидание легко получается заметно более грубым.

С другой стороны, если на самом деле вы ждёте не «время», а «событие» — появление работы, завершение ввода-вывода, запрос на остановку, изменение состояния, — заходить проверять через равные интервалы не нужно. Сторона, где происходит событие, подаёт сигнал (signal), а ожидающая сторона ждёт этот event — так честнее и по задержке, и по CPU, и по энергопотреблению.

В этой статье мы хотим ответить на четыре вопроса.

  • Почему Sleep(1) и короткие ожидания по таймеру оказываются менее точными, чем кажется?
  • Почему ожидание события менее подвержено этому ограничению?
  • В каких случаях стоит выбирать событие вместо таймера?
  • И всё же, когда стоит использовать таймер?

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

  • Если вы ждёте появления работы или завершения ввода-вывода, лучше ждать событие, а не таймер.
  • Timed wait в Windows неизбежно подвержены влиянию гранулярности системных часов.
  • Sleep(1) не означает «точное пробуждение через 1 мс».
  • Более того, даже после истечения тайм-аута поток сначала лишь переходит в состояние готовности — немедленное выполнение не гарантировано.
  • Поэтому конструкция «на самом деле ждём событие, но проверяем по таймеру» проигрывает и по задержке, и по энергопотреблению.
  • Использовать таймер стоит только тогда, когда условием действительно является само время — так получается чище.

В практических терминах это сводится примерно к следующему.

  • «Отправлять метрики каждые 5 секунд» -> задача для таймера
  • «Начать работу сразу, как только в очереди появилось задание» -> задача для event / semaphore / condition variable / WaitOnAddress
  • «Продолжить выполнение после завершения ввода-вывода» -> задача для completion / event
  • «Остановиться при получении запроса на остановку» -> задача для stop event / cancellation

2. В чём проблема

2.1 Timed wait привязан к гранулярности системных часов

Точность тайм-аута функций ожидания Windows зависит от разрешения системных часов. То же самое касается Sleep: указанное число миллисекунд не гарантируется как «ровно такая длительность».

Здесь важно то, что указание 1 мс вовсе не означает пробуждение ровно через 1 мс.

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

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

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

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

  1. Само определение тайм-аута зависит от гранулярности таймера
  2. Даже после тайм-аута момент начала выполнения зависит от планировщика

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

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

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

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

  • время выполнения Step() каждый раз добавляется к общему времени;
  • само время ожидания Sleep(1) подвержено влиянию гранулярности;
  • даже после пробуждения поток не гарантированно начинает работу сразу.

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

3.1 Условие завершения ожидания — не «истечение времени», а «сигнал»

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

Ожидание таймера устроено так:

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

Ожидание события устроено иначе:

  • сторона, где что-то произошло, подаёт сигнал;
  • как только сигнал получен, условие ожидания выполнено;
  • в момент пробуждения причина уже есть.
Наступления момента времениПоявления работыИзменения значенияЗавершения ввода-выводаЗапроса на остановкуОжидающий потокЧего вы на самом деле ждёте?timer / waitable timerevent / semaphore / condition variableWaitOnAddresscompletion / eventstop event / cancellation

3.2 Выбираем инструмент исходя из того, что мы ждём

Для первичного решения обычно достаточно этой таблицы.

Что нужно дождаться Плохой пример Первичный выбор
Появление работы в очереди TryPop через Sleep(1) event / semaphore
Завершение ввода-вывода Опрос состояния по таймеру event overlapped I/O / IOCP
Запрос на остановку Проверка stop-флага каждые 100 мс stop event / cancellation
Изменение значения в пределах одного процесса while (flag == 0) Sleep(1) WaitOnAddress
Наступление момента времени Насильно подгонять под event timer / waitable timer

3.3 Event — тоже не волшебство

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

Даже на ожидание события влияют:

  • задержка планировщика;
  • приоритет потока;
  • состояние энергопотребления CPU;
  • конкуренция за блокировки;
  • page fault;
  • DPC/ISR.

Тем не менее, как минимум удаётся исключить лишний вид ожидания — «спать до следующего тика таймера».

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

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

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

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

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

    Sleep(1);
}

Этот способ выглядит просто, но у него три проблемы.

  1. Поток регулярно просыпается, даже если очередь пуста
  2. Задержка зависит от гранулярности таймера
  3. Это также невыгодно с точки зрения энергопотребления

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 подаёт сигнал при поступлении

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

  • producer добавляет элемент в очередь;
  • сразу после добавления вызывает SetEvent;
  • consumer ожидает через WaitForSingleObject или WaitForMultipleObjects;
  • при пробуждении вычерпывает очередь.

5.2 Одновременное ожидание work и stop через WaitForMultipleObjects

Для простого worker’а такая форма легко читается.

HANDLE waits[2] = { _stopEvent, _workEvent };

for (;;)
{
    DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);

    if (rc == WAIT_OBJECT_0)
    {
        return;
    }

    if (rc != WAIT_OBJECT_0 + 1)
    {
        throw std::runtime_error("WaitForMultipleObjects failed.");
    }

    DrainQueue();
}

В этом примере важны три момента:

  • Sleep(1) исчез;
  • producer вызывает SetEvent при поступлении элемента;
  • worker одновременно ожидает stop и work.

5.3 В пределах одного процесса подходит и WaitOnAddress

Если в пределах одного процесса вам нужно лишь «дождаться изменения некоторого значения», хорошим вариантом также будет WaitOnAddress.

Как ориентир для выбора:

  • межпроцессные или обычные объекты ожидания -> event / semaphore / waitable object;
  • лёгкие изменения значений в пределах одного процесса -> WaitOnAddress.

6. Когда всё же стоит использовать таймер

6.1 Когда условием является само время

Разумеется, есть законные сценарии использования таймеров.

  • Отправка метрик каждые 5 секунд
  • Повтор попытки через 200 мс
  • Очистка кэша раз в минуту
  • Ожидание до определённого момента времени с последующим тайм-аутом

В этих случаях то, что вы хотите дождаться, — действительно время.

6.2 Используйте waitable timer

Если в Windows вы ждёте именно «само время», использование waitable timer делает намерение более понятным, чем небрежное наслаивание вызовов Sleep.

6.3 Не делайте timeBeginPeriod привычкой

Когда точность коротких timer wait начинает беспокоить, возникает соблазн добавить timeBeginPeriod(1). Но это не стоит делать стандартным решением по умолчанию.

Причин три.

  1. Есть издержки по энергопотреблению / производительности
  2. В современных версиях Windows поведение стало несколько сложнее
  3. Часто это не устраняет первопричину

7. Чек-лист для ревью

  • Не строите ли вы цикл проверки состояния через Sleep(1) / Thread.Sleep(1) / Task.Delay(1)?
  • Не опрашиваете ли вы по таймеру то, что на самом деле является появлением элемента в очереди, завершением ввода-вывода или запросом на остановку?
  • Позволяет ли архитектура стороне producer / completion подавать сигнал?
  • Можно ли ждать stop и work одновременно в рамках одного вызова wait?
  • Можно ли для изменений значения в пределах одного процесса использовать WaitOnAddress?
  • Там, где используется таймер, действительно ли то, что вы хотите дождаться, — это «время»?

8. Итог

Конструкция «периодически проверять состояние» через короткий timer wait в Windows неизбежно подвержена влиянию гранулярности таймера и планировщика. Из-за этого Sleep(1) и короткие тайм-ауты не так точны, как кажутся на первый взгляд.

С другой стороны, если то, что вы действительно хотите дождаться, — это «событие»: появление работы, завершение ввода-вывода, запрос на остановку, изменение состояния, — ожидание события подходит естественнее.

Если подытожить, всё сводится к одной строке:

Время — ждите таймером, событие — ждите event’ом.

Стоит только чётко провести эту границу, и это окупается сразу по нескольким направлениям:

  • задержку становится проще прогнозировать;
  • сокращаются лишние периодические пробуждения;
  • улучшается энергопотребление;
  • замысел кода становится понятнее.

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

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

Спящий режим, гибернация, Modern Standby и долго работающие приложения — как проектированием предотвратить «остановилось ночью»

Разбираем, почему долго работающее Windows-приложение оказывается «остановленным к утру», начиная с различий между спящим режимом S3, гиб...

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

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

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

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

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

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

Почему Sleep(1) в Windows не пробуждается точно через 1 миллисекунду?
Точность тайм-аута timed wait в Windows зависит от разрешения системных часов, и при обычных настройках базовым уровнем platform timer resolution чаще всего оказывается величина порядка 15,6 мс. Более того, даже когда время ожидания истекает, поток лишь переходит в состояние готовности (ready) — гарантии немедленно получить CPU и начать выполняться нет. На это влияют другие потоки, приоритет, состояния простоя 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

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

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