Почему в 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 есть как минимум два уровня неопределённости.
- Само определение тайм-аута зависит от гранулярности таймера
- Даже после тайм-аута момент начала выполнения зависит от планировщика
2.3 Sleep(1) не означает период в 1 мс
Глядя на Sleep(1), легко воспринять его как «цикл, вращающийся каждую 1 мс».
Но на самом деле читать это так нельзя.
while (!g_stop)
{
Step();
Sleep(1);
}
На деле этот цикл выглядит так:
- время выполнения
Step()каждый раз добавляется к общему времени; - само время ожидания
Sleep(1)подвержено влиянию гранулярности; - даже после пробуждения поток не гарантированно начинает работу сразу.
3. Почему ожидание события выгоднее
3.1 Условие завершения ожидания — не «истечение времени», а «сигнал»
Ожидание события выгоднее потому, что меняется сам смысл ожидания.
Ожидание таймера устроено так:
- даже если ещё ничего не произошло,
- поток пробуждается по истечении фиксированного времени,
- а уже после пробуждения проверяется, произошло ли что-то.
Ожидание события устроено иначе:
- сторона, где что-то произошло, подаёт сигнал;
- как только сигнал получен, условие ожидания выполнено;
- в момент пробуждения причина уже есть.
flowchart LR
start["Ожидающий поток"] --> q{"Чего вы на самом деле ждёте?"}
q -- "Наступления момента времени" --> timer["timer / waitable timer"]
q -- "Появления работы" --> event["event / semaphore / condition variable"]
q -- "Изменения значения" --> addr["WaitOnAddress"]
q -- "Завершения ввода-вывода" --> io["completion / event"]
q -- "Запроса на остановку" --> stop["stop 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);
}
Этот способ выглядит просто, но у него три проблемы.
- Поток регулярно просыпается, даже если очередь пуста
- Задержка зависит от гранулярности таймера
- Это также невыгодно с точки зрения энергопотребления
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).
Но это не стоит делать стандартным решением по умолчанию.
Причин три.
- Есть издержки по энергопотреблению / производительности
- В современных версиях Windows поведение стало несколько сложнее
- Часто это не устраняет первопричину
7. Чек-лист для ревью
- Не строите ли вы цикл проверки состояния через
Sleep(1)/Thread.Sleep(1)/Task.Delay(1)? - Не опрашиваете ли вы по таймеру то, что на самом деле является появлением элемента в очереди, завершением ввода-вывода или запросом на остановку?
- Позволяет ли архитектура стороне producer / completion подавать сигнал?
- Можно ли ждать
stopиworkодновременно в рамках одного вызова wait? - Можно ли для изменений значения в пределах одного процесса использовать
WaitOnAddress? - Там, где используется таймер, действительно ли то, что вы хотите дождаться, — это «время»?
8. Итог
Конструкция «периодически проверять состояние» через короткий timer wait в Windows неизбежно подвержена влиянию гранулярности таймера и планировщика.
Из-за этого Sleep(1) и короткие тайм-ауты не так точны, как кажутся на первый взгляд.
С другой стороны, если то, что вы действительно хотите дождаться, — это «событие»: появление работы, завершение ввода-вывода, запрос на остановку, изменение состояния, — ожидание события подходит естественнее.
Если подытожить, всё сводится к одной строке:
Время — ждите таймером, событие — ждите event’ом.
Стоит только чётко провести эту границу, и это окупается сразу по нескольким направлениям:
- задержку становится проще прогнозировать;
- сокращаются лишние периодические пробуждения;
- улучшается энергопотребление;
- замысел кода становится понятнее.
9. Справочные материалы
- Sleep function (Win32)
- Wait Functions
- WaitForSingleObject function
- Event Objects (Synchronization)
- Using Event Objects
- WaitOnAddress function
- WakeByAddressSingle function
- timeBeginPeriod function
- CreateWaitableTimerExW function
- SetWaitableTimer function
- Thread.Sleep Method (.NET)
- Results for the Idle Energy Efficiency Assessment
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Введение в ADR (Architecture Decision Record) — минимальный способ сохранить «почему мы спроектировали именно так» в небольшой команде
Код никогда не объясняет, почему он написан именно так. Разбираем, как использовать ADR (Architecture Decision Record) — одно решение, од...
Спящий режим, гибернация, Modern Standby и долго работающие приложения — как проектированием предотвратить «остановилось ночью»
Разбираем, почему долго работающее Windows-приложение оказывается «остановленным к утру», начиная с различий между спящим режимом S3, гиб...
Когда не стоит переводить Windows-приложение в веб: таблица решений и «разделение» как реальный выход
Запросов на перевод корпоративных Windows-приложений в веб становится всё больше, но для приложений с интеграцией оборудования, локальной...
Таблица решений: завершать работу приложения или продолжать при неожиданном исключении
Разбираем, когда после неожиданного исключения приложение стоит завершать, а когда можно продолжать работу — с точки зрения повреждения с...
Минимальный чек-лист безопасности при разработке Windows-приложений
Систематизируем в виде чек-листа базовые меры безопасности для бизнес-приложений на WPF / WinForms / WinUI / C++ / C#: права доступа, под...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Технические консультации и ревью дизайна
Эта тема касается проектирования ожидания, выбора примитивов синхронизации и компромисса между задержкой и энергопотреблением в soft real-time системах, поэтому она хорошо сочетается с технической консультацией и ревью архитектуры.
Разработка приложений для Windows
Замена опроса по таймеру на событийный подход в Windows-приложениях и службах напрямую влияет на качество реализации, что делает эту тему ключевой для разработки Windows-приложений.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Почему 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки