И всё-таки, возможен ли 1мс таймер в Windows?

от автора

Вся суть
Вся суть

В комментариях к недавней статье оказалось что, во-первых, этот вопрос кому-то да и интересен, и, во-вторых, существует некоторое количество заблуждений на эту тему.

Вводные: нам нужен таймер, на Windows, с точностью порядка 1мс, драйвер при этом мы писать не хотим и решения при исполнении которых процессор попытается радикально ускорить глобальное потепление не приемлем.

Есть ли такое решение? Из коробки — нету, но при помощи нехитрых приспособлений наше досадное недоразумение превращается… в точный таймер, конечно же.

У нас есть некоторое количество досадных недоразумений системных API которые с каждой новой весией Windows всё сильнее ужимают с целью экономии батареи на ноутбуках, общий обзор можно посмотреть в статье по ссылке в самом начале, с графиками. В целом, можно сказать что сколько-нибудь удовлетворительный тайминг начинается примерно со 100мс, всё что ниже чем 15.6мс за гранью допустимого (по мнению ребят из Microsoft). Да и вообще, 640КБ ну точно хватит всем, правда?

Ну а временные промежутки меньше 1мс вообще немыслимы — большинство API даже не принимает таких значений, не говоря уже о корректной работе с ними.

Исходя из этого я буду строить своё решение вокруг трех недокументированных функций API Win32: NtQueryTimerResolution, NtSetTimerResolution, NtDelayExecution.
Связка из первых двух позволяет добиться разрешения системного таймера меньше 1мс, а третья — воспользоваться этим дополнительным разрешением для сна с точностью менее 1мс.

Итак, начнем: я пишу преимущественно на C#, но на любом многих ЯП можно написать всё точно то же самое.

Шаг 0: поднимем разрешение до максимального. Начиная с Win10 2004 это разрешение больше не является глобальным так что можно ни в чём себе не отказывать (с другой стороны — если процесс не поднял себе разрешение то оно будет 15.6мс вне зависимости от того что там в «глобальном» параметре).

[DllImport("ntdll.dll", SetLastError = true)] static extern int NtQueryTimerResolution(out int MinimumResolution, out int MaximumResolution, out int CurrentResolution); [DllImport("ntdll.dll", SetLastError = true)] static extern int NtSetTimerResolution(int DesiredResolution, bool SetResolution, out int CurrentResolution);  private static void AdjustTimerResolution() {     var queryResult = NtQueryTimerResolution(out var min, out var max, out var current);      if (queryResult != 0) return;      _systemTimerResolution = TimeSpan.FromTicks(current);      if (NtSetTimerResolution(max, true, out _) == 0)     {         _systemTimerResolution = TimeSpan.FromTicks(max);     } }

Шаг 1: создадим класс PreciseTimer. Полный код я привести, увы, не могу но общая структура такова: поток с максимальным приоритетом который крутится в while(true) цикле и следущие важные поля:

// Период срабатывания private TimeSpan _period; // Время прошедшее от последнего срабатывания private readonly Stopwatch _sw = Stopwatch.StartNew(); // Время оставшееся до следующего срабатывания public TimeSpan Remaining => _period - _sw.Elapsed; // Таймер уничтожен и должен быть остановлен private bool _disposed;

Приметка для людей которые не пишут на C#: Stopwatch это обертка над Win32 методамиQueryPerformanceFrequency и QueryPerformanceCounter, никакой дополнительной магии нету.

Шаг 2: выясним сколько же нам спать. И спим!

private static void TimerTick() {     // Реализацию выбора следующего таймера оставим пытливым читателям     PreciseTimer nextTimer = GetNextTimer();      while (!nextTimer._disposed)     {         var remaining = nextTimer.Remaining;          if (remaining > _systemTimerResolution)         {             // Если разрешение системного таймера позволяет - спим             SleepPrecise(remaining);             continue;         }                      // Когда разрешение уже не позволяет спать - спиним         while (nextTimer.Remaining > TimeSpan.Zero)         {             // YieldProcessor(), для X86 это инструкция REP NOP             Thread.SpinWait(1000);         }                   // Дождались: тикаем!         nextTimer.Tick();         break;     } }  // Функция unsafe потому что автор кода - ленивая жопа // Перед броском гнилым помидором подумайте: хотелось бы вам выделять память вручную? [DllImport("ntdll.dll", SetLastError = true)] static unsafe extern int NtDelayExecution(bool alertable, long* delayInterval);  private static unsafe void SleepPrecise(TimeSpan timeToSleep) {     // Посчитаем число целых периодов сна, округлим отбрасываем дробной части     var periods = (int)(timeToSleep.TotalMilliseconds / _systemTimerResolution.TotalMilliseconds);            if (periods == 0)         return;            // И спим!     var ticks = -(_systemTimerResolution.Ticks * periods);     NtDelayExecution(false, &ticks); }

Шаг 3: посмотрим что из этого вышло: запустим таймер на 1 минуту и запишем полученные промежутки времени. Код обвязки был использован тоже из статьи по линку в начале, но, к сожалению, там нет кода чтобы построить те великолепные графики, поэтому… не стреляйте в программиста, он рисует как умеет.

Тесты запускались на Ryzen 9 5950X под управлением Win11 версии 22000.469
Среднее значение для таймера в 1мс: 1.022мс, stddev = 0.018

Распределение тиков, таймер 1мс
Распределение тиков, таймер 1мс

Для таймера в 10мс: 10.022мс, stddev = 0.017

Распределение тиков, таймер 10мс
Распределение тиков, таймер 10мс

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

С загрузкой процессора вопрос интереснее, в целом можно утверждать что обнаружению она не поддается: все утилиты радостно рапортируют о 0% загрузке. Установив вручную Affinity на конкретное ядро процессора ничего интересного тоже не обнаружено:

А вы угадаете на каком ядре сейчас вовсю жарит 1мс таймер?
А вы угадаете на каком ядре сейчас вовсю жарит 1мс таймер?

Подводя итог: цель достигнута? Мне кажется что ответ «да».

Все сниппеты кода вдохновлены реальными событиями и использованы в продакшне.


ссылка на оригинал статьи https://habr.com/ru/post/651237/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *