Вы пишите код на платформе .NET под Windows и вам нужно выполнять некоторые действия каждую миллисекунду. Возможно ли это? Какие есть варианты и насколько они надёжны? Разберёмся, что можно использовать, и какие есть гарантии по точности срабатывания. Статья сконцентрирована на поиске такого решения, которое работало бы и под .NET Framework, и под .NET Core / .NET, и в разных версиях ОС, и являлось бы механизмом общего назначения (а не только для программ с GUI, например). Для чего вообще может потребоваться таймер с малым периодом? Примером могут служить различные программные аудио- и видеоплееры. Классический подход при воспроизведении мультимедийных данных – раз в N единиц времени смотреть, что́ нужно подать на устройство вывода (видео-, звуковую карту и т.д.) в данный момент времени, и при необходимости отсылать новые данные (кадр, аудиобуфер) на это устройство. В таких случаях информация часто расположена достаточно плотно (особенно в случае с аудио), а временны́е отклонения в её воспроизведении хорошо заметны ушам, глазам и прочим человеческим органам. Поэтому N выбирается небольшим, измеряется в миллисекундах, и часто используется значение 1. Я разрабатываю библиотеку для работы с MIDI – DryWetMIDI. Помимо взаимодействия с MIDI файлами, их трансформации и сопряжения с музыкальной теорией, библиотека предлагает API для работы с MIDI устройствами, а также средства для воспроизведения и записи MIDI данных. DryWetMIDI написана на C#, а мультимедийный API реализован для Windows и macOS. Вкратце воспроизведение в библиотеке работает так: все MIDI-события снабжаются временем, когда они должны быть воспроизведены, время измеряется в миллисекундах и отсчитывается от начала всех данных (т.е. от 0); указатель P устанавливается на первое событие; запускается счётчик времени C; запускается таймер T с интервалом 1 мс; при каждом срабатывании T: a) если время воспроизведения текущего события (на которое указывает P) меньше или равно текущему времени, взятому из C, послать событие на устройство; если нет – ждать следующего тика таймера; b) сдвинуть P вперёд на одно событие и вернуться на a. Итак, нам нужен таймер, срабатывающий с интервалом 1 мс. В этой статье мы посмотрим, что нам предлагает Windows для решения данной задачи, и как мы можем использовать это в .NET. К слову, можно легко проверить, что привычные нам программные продукты для воспроизведения аудио и видео используют таймер с малым интервалом. В Windows есть встроенная утилита Powercfg, позволяющая получать данные по энергопотреблению, и в частности, какие программы запрашивают повышение разрешения (= понижение интервала) системного таймера. Например, запустив Google Chrome и открыв любое видео в YouTube, выполните команду powercfg /energy /output C:\report.html /duration 5 В корне диска C будет создан файл с отчётом report.html. В отчёте увидим такую запись: Platform Timer Resolution:Outstanding Timer Request A program or service has requested a timer resolution smaller than the platform maximum timer resolution. Requested Period	10000 Requesting Process ID	2384 Requesting Process Path\Device\HarddiskVolume3\Program Files (x86)\Google\Chrome\Application\chrome.exe Браузер запросил новый период системного таймера 10000. Единицы этого значения – сотни наносекунд (как бы это ни было странно). Если перевести в миллисекунды, то получим как раз 1. Или же при воспроизведении аудиофайла в Windows Media Player: Platform Timer Resolution:Outstanding Timer Request A program or service has requested a timer resolution smaller than the platform maximum timer resolution. Requested Period	10000 Requesting Process ID	11876 Requesting Process Path\Device\HarddiskVolume3\Program Files (x86)\Windows Media Player\wmplayer.exe Любопытно, что, например, VLC использует интервал 5 мс: Platform Timer Resolution:Outstanding Timer Request A program or service has requested a timer resolution smaller than the platform maximum timer resolution. Requested Period	50000 Requesting Process ID	25280 Requesting Process Path\Device\HarddiskVolume3\Program Files\VideoLAN\VLC\vlc.exe Есть подозрение (непроверенное), что частота таймера зависит от частоты кадров видео. А быть может, разработчики видеоплеера просто посчитали наглостью всегда запрашивать 1 мс. И, возможно, они правы. Создадим каркас наших тестов. Опишем интерфейс таймера: Метод Start принимает первым параметром интервал таймера. Я решил проверить работу таймеров не только для интервала 1 мс, но также и для 10 и 100 мс. Вторым параметром будем передавать метод, который будет выполняться при срабатывании таймера. Все наши проверки сделаем в одном классе: Т.е. запускаем Stopwatch; в течение 3 минут складываем с него время при каждом срабатывании таймера в список; собираем интервалы между собранными временами; записываем полученные дельты в текстовый файл deltas_<interval>.txt. Далее по этим наборам дельт строим графики, чтобы наглядно видеть, насколько точно срабатывает таймер. Содержимое каждого графика будет таким: Справа сверху будет отображаться процент “хороших” результатов – дельт, попадающих в 10-процентную окрестность вокруг заданного интервала. Число 10 выбрано навскидку, но, как мы увидим, оно вполне помогает понять разницу между таймерами. Если не сказано явно, запуск тестов производится на виртуальных машинах Azure Pipelines из пула Microsoft с операционной системой Microsoft Windows Server 2019 (10.0.17763). Иногда будем смотреть на моей локальной машине с ОС Windows 10 20H2 (сборка 19042.1348). Windows 11 под рукой нет, быть может, кому-то будет интересно проверить там. Я решил сделать тест каждого варианта в виде отдельного консольного приложения. Все эти приложения собрал вместе в солюшне в виде проектов. Все ссылки на код, данные и графики будут приведены в конце статьи. А мы начинаем наше исследование. EDIT ──────── В комментариях было высказано много интересных идей и предложений. Не все из них меняют итоги статьи, но для полноты картины упомянуть стоит. Кроме того, будут добавлены графики загрузки процессора. Такие вставки будут оформлены, как этот абзац. ──────────── Нельзя обойти стороной наивный подход – таймер на основе бесконечного цикла с подсчётом интервала: Запустив тест с этим таймером получим, разумеется, отличные результаты. Например, для 1 мс: И хоть результаты не могут не радовать, данный таймер, конечно же, не стоит использовать в реальных приложениях. Все мы знаем, что он будет попусту загружать CPU и расходовать батарею на портативных устройствах. Например, на моей машине с 4 ядрами получим такую загрузку процессора: То есть примерно одно ядро. Графики для других интервалов приводить не буду, там аналогичные картины (кто хочет, может посмотреть по ссылке в конце статьи). EDIT ──────── Как ни странно, именно бесконечный цикл вызвал в комментариях наибольшую активность. Было предложено несколько вариантов, рассмотрим каждый. Во-первых, не один человек уверенно высказался о том, что вызов метода Thread.Yield должен снизить загрузку процессора. Что ж, напишем новый таймер: Точность будет высокая (смотрел на своём локальном компьютере) но вот загрузка процессора не меняется Результаты аналогичные и для виртуалок Azure Pipelines и для второго компьютера. Т.е. совершенно точно нельзя назвать такой таймер хорошим. Во-вторых, в комментариях были упомянуты NtSetTimerResolution / NtDelayExecution. Это недокументированные функции системной библиотеки ntdll.dll. Я модифицировал бесконечный цикл простейшим образом с использованием этих функций, сделав такой таймер: Такая реализация обладает некоторыми недостатками (например тем, что срабатывания будут "плыть" в зависимости от времени выполнения callback), но для демонстрации вполне годится. Запустив, получим такие результаты на локальной машине: На виртуалках Azure Pipelines результаты тоже отличные, хотя среднее значение и не совпадает с запрошенным интервалом. Например, для интервала 1 мс: То бишь такой таймер действительно неплох. ──────────── Переходим к стандартным классам таймеров в .NET. Используя System.Timers.Timer получим такие результаты: EDIT ──────── Загрузка CPU, разумеется околонулевая, ибо таймер работает на пуле потоков: ──────────── Как видим, для малых интервалов 15.6 мс – наилучший средний показатель. Как известно, это стандартное разрешение системного таймера Windows, о чём можно подробно прочитать в документе от Microsoft под названием Timers, Timer Resolution, and Development of Efficient Code (кстати, очень интересный и полезный материал, рекомендую к прочтению): The default system-wide timer resolution in Windows is 15.6 ms, which means that every 15.6 ms the operating system receives a clock interrupt from the system timer hardware. А в документации по классу явно сказано: The System.Timers.Timer class has the same resolution as the system clock. This means that the Elapsed event will fire at an interval defined by the resolution of the system clock if the Interval property is less than the resolution of the system clock. Так что результаты не выглядят удивительными. Документ выше датируется 16 июня 2010 года, однако не утерял своей актуальности. В нём также сказано: The default timer resolution on Windows 7 is 15.6 milliseconds (ms). Some applications reduce this to 1 ms, which reduces the battery run time on mobile systems by as much as 25 percent. Эта важная информация явно говорит о том, что понижение интервала системного таймера напрямую влияет на энергопотребление и время работы устройств от батареи. Возвращаясь к примеру с VLC из начала статьи, подход с 5 мс выглядит разумным. Но всё же в некоторых ситуациях необходим период 1 мс, в частности для воспроизведения аудио- и MIDI-данных. В числе прочего в документе написано, как можно повысить разрешение системного таймера: Applications can call timeBeginPeriod to increase the timer resolution. The maximum resolution of 1 ms is used to support graphical animations, audio playback, or video playback. Т.е., согласно приведённому тексту, можно вызвать функцию timeBeginPeriod, запустить таймер с заданным интервалом, и даже стандартные таймеры должны срабатывать с этим интервалом. Что ж, проверим. Код нового таймера: Не буду здесь приводить код класса NativeTimeApi, кому интересно, посмотрит его в архиве с солюшном (ссылка в конце статьи). Запускаем тест: Увы, лучше не стало. Если немного погуглить, обнаружим, что мы не одиноки в своём горе: timeBeginPeriod not working on Intel Comet Lake CPU (i5 10400H) The timeBeginPeriod() function no longer changes the resolution of SetThreadpoolTimer() and CreateTimerQueueTimer() in Windows 10 2004 WIndows 10 timeBeginPeriod(1) not always working Оказывается, начиная с версии Windows 10 2004 изменилось влияние функции timeBeginPeriod на стандартные таймеры. А именно, теперь она на них не влияет. По этой теме можно почитать интересную статью – Windows Timer Resolution: The Great Rule Change. К слову, выглядит, что проблема присутствует и на более ранних версиях Windows 10. Кстати говоря, визуально результаты всё же стали немного другими. А именно, уменьшился разброс относительно среднего значения. Это может быть случайностью, а может быть и влиянием функции timeBeginPeriod. EDIT ──────── Загрузка процессора: ──────────── Для полноты картины нужно также посмотреть, а как обстоят дела с System.Threading.Timer. Код: Результаты: EDIT ──────── Процессор нагружен аналогично предыдущему таймеру: ──────────── Ожидаемо никаких отличий от System.Timers.Timer, так как в документации нам явно говорят об этом: The Timer class has the same resolution as the system clock. This means that if the period is less than the resolution of the system clock, the TimerCallback delegate will execute at intervals defined by the resolution of the system clock… Работа System.Threading.Timer с предварительным вызовом timeBeginPeriod (а вдруг с этим таймером сработает): Не сработало. EDIT ──────── Загрузка процессора: ──────────── В Windows издревле существует API для создания мультимедийных таймеров. Использование их состоит в регистрации функции обратного вызова с помощью timeSetEvent и предварительном вызове timeBeginPeriod. Таким образом, опишем новый таймер: Запустив тест, получим такие результаты: А вот это уже интересно. Проверим на локальной машине: Тут вообще красота. Таймер прекрасно держит заданный интервал, лишь изредка заметно отклоняясь от него (чего избежать невозможно на Windows, ибо эта ОС не является системой реального времени). EDIT ──────── Дополним результаты графиками загрузки процессора. На локальной машине: На виртуалке Azure Pipelines: Нагрузка на процессор минимальная. ──────────── Итак, результаты радуют. Однако, в документации сказано, что функция timeSetEvent устаревшая: This function is obsolete. New applications should use CreateTimerQueueTimer to create a timer-queue timer. Вместо неё предлагается использовать функцию CreateTimerQueueTimer. Что ж, мы, как законопослушные разработчики идём пробовать. Вместо мультимедийных таймеров Microsoft рекомендует использовать таймеры, созданные на специальных очередях. Можно использовать дефолтную очередь, а можно и создавать свои. Мы будем использовать дефолтную. Код нашего таймера: Здесь в параметр Flags функции CreateTimerQueueTimer мы передаём WT_EXECUTEDEFAULT. Чуть позже посмотрим и на другой флаг. А пока запустим тест: Выглядит многообещающе. Проверим на локальной машине: Как ни странно, в разных версиях Windows таймер работает по-разному. На моей Windows 10 результаты не лучше стандартных .NET таймеров. EDIT ──────── Любопытно, будет ли отличаться загрузка процессора в разных средах. На локальной машине: На виртуалке Azure Pipelines: Процессор нагружается одинаково при том, что точность разная. Не буду приводить графики загрузки CPU в дальнейших разделах, там всё то же самое. ──────────── Интереса ради я проверил предыдущий таймер с предварительной установкой периода системного таймера на локальной машине: Внезапно на 10 мс неплохие результаты. Но для 1 мс всё так же плохо. В прошлый раз мы использовали опцию WT_EXECUTEDEFAULT при создании таймера. Попробуем установить другую – WT_EXECUTEINTIMERTHREAD. Результаты (по-прежнему используем локальную машину): И хотя ничего нового, любопытно, что у таймеров на очередях очень малый разброс значений. Практически все дельты попадают в чёткий диапазон. Без лишних слов: Глядя на графики, я всё-таки прихожу к выводу, что timeBeginPeriod как-то да влияет на таймеры. Коридор значений для интервала 1 мс явно становится уже. Буду честен, рассмотрены не все варианты. Вот тут в блоке Tip перечислены ещё такие: System.Windows.Forms.Timer; System.Web.UI.Timer; System.Windows.Threading.DispatcherTimer. Но и это ещё не всё. В .NET 6 появился PeriodicTimer. Зоопарк разных таймеров в .NET и Windows, конечно, весьма солидный. Но все эти таймеры не подходят. Как я писал до ката: статья сконцентрирована на поиске такого решения, которое работало бы и под .NET Framework, и под .NET Core / .NET, и в разных версиях ОС, и являлось бы механизмом общего назначения. А потому вот причины отказа от упомянутых классов (по крайней мере для нужд мультимедиа): System.Windows.Forms.Timer – выполняется на UI-потоке, привязка к приложениям с GUI; System.Web.UI.Timer – только .NET Framework, да и Web.UI в имени пространства имён не вяжется с решением общего назначения; System.Windows.Threading.DispatcherTimer – для WPF; PeriodicTimer – только с .NET 6. А что же можно сказать о тех таймерах, что были проверены в статье? Нет смысла писать много букв, единственный надёжный вариант – мультимедийные таймеры. И хотя они давно объявлены устаревшими, только они соответствуют критериям, указанным до ката. EDIT ──────── Видя результаты работы таймера, основанного на функциях NtSetTimerResolution / NtDelayExecution  должен признать, что это также отличный вариант достичь точности в 1 мс. Более того, таким образом можно достичь и большей точности, чего невозможно сделать с мультимедийными таймерами. Большое спасибо @Alexx999 и всем неравнодушным к теме! ──────────── Всем спасибо. Как и обещал, привожу ссылки: код солюшна с тестовым кодом; результаты с виртуальных машин Azure Pipelines; результаты с локальной машины; графики по результатам с виртуальных машин Azure Pipelines; графики по результатам с локальной машины.  Desktop software developer