Таймер в .NET с интервалом 1 мс. Windows

Для чего вообще может потребоваться таймер с малым периодом? Примером могут служить различные программные аудио- и видеоплееры. Классический подход при воспроизведении мультимедийных данных — раз в N единиц времени смотреть, что́ нужно подать на устройство вывода (видео-, звуковую карту и т.д.) в данный момент времени, и при необходимости отсылать новые данные (кадр, аудиобуфер) на это устройство. В таких случаях информация часто расположена достаточно плотно (особенно в случае с аудио), а временны́е отклонения в её воспроизведении хорошо заметны ушам, глазам и прочим человеческим органам. Поэтому N выбирается небольшим, измеряется в миллисекундах, и часто используется значение 1.

Я разрабатываю библиотеку для работы с MIDI — DryWetMIDI. Помимо взаимодействия с MIDI файлами, их трансформации и сопряжения с музыкальной теорией, библиотека предлагает API для работы с MIDI устройствами, а также средства для воспроизведения и записи MIDI данных. DryWetMIDI написана на C#, а мультимедийный API реализован для Windows и macOS. Вкратце воспроизведение в библиотеке работает так:

  1. все MIDI-события снабжаются временем, когда они должны быть воспроизведены, время измеряется в миллисекундах и отсчитывается от начала всех данных (т.е. от 0);

  2. указатель P устанавливается на первое событие;

  3. запускается счётчик времени C;

  4. запускается таймер T с интервалом 1 мс;

  5. при каждом срабатывании 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 мс. И, возможно, они правы.

Подготовка тестового кода

Создадим каркас наших тестов. Опишем интерфейс таймера:

using System;

namespace Common
{
    public interface ITimer
    {
        void Start(int intervalMs, Action callback);
        void Stop();
    }
}

Метод Start принимает первым параметром интервал таймера. Я решил проверить работу таймеров не только для интервала 1 мс, но также и для 10 и 100 мс. Вторым параметром будем передавать метод, который будет выполняться при срабатывании таймера.

Все наши проверки сделаем в одном классе:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;

namespace Common
{
    public static class TimerChecker
    {
        private static readonly TimeSpan MeasurementDuration = TimeSpan.FromMinutes(3);
        private static readonly int[] IntervalsToCheck = { 1, 10, 100 };

        public static void Check(ITimer timer)
        {
            Console.WriteLine("Starting measuring...");
            Console.WriteLine($"OS: {Environment.OSVersion}");
            Console.WriteLine("--------------------------------");

            foreach (var intervalMs in IntervalsToCheck)
            {
                Console.WriteLine($"Measuring interval of {intervalMs} ms...");
                MeasureInterval(timer, intervalMs);
            }

            Console.WriteLine("All done.");
        }

        private static void MeasureInterval(ITimer timer, int intervalMs)
        {
            var times = new List((int)Math.Round(MeasurementDuration.TotalMilliseconds) + 1);
            var stopwatch = new Stopwatch();
            Action callback = () => times.Add(stopwatch.ElapsedMilliseconds);

            timer.Start(intervalMs, callback);
            stopwatch.Start();

            Thread.Sleep(MeasurementDuration);

            timer.Stop();
            stopwatch.Stop();

            var deltas = new List();
            var lastTime = 0L;

            foreach (var time in times.ToArray())
            {
                var delta = time - lastTime;
                deltas.Add(delta);
                lastTime = time;
            }

            File.WriteAllLines($"deltas_{intervalMs}.txt", deltas.Select(d => d.ToString()));
        }
    }
}

Т.е.

  1. запускаем Stopwatch;

  2. в течение 3 минут складываем с него время при каждом срабатывании таймера в список;

  3. собираем интервалы между собранными временами;

  4. записываем полученные дельты в текстовый файл deltas_.txt.

Далее по этим наборам дельт строим графики, чтобы наглядно видеть, насколько точно срабатывает таймер. Содержимое каждого графика будет таким:

Типичный график интервалов между срабатываниями таймераТипичный график интервалов между срабатываниями таймера

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

Если не сказано явно, запуск тестов производится на виртуальных машинах Azure Pipelines из пула Microsoft с операционной системой Microsoft Windows Server 2019 (10.0.17763). Иногда будем смотреть на моей локальной машине с ОС Windows 10 20H2 (сборка 19042.1348). Windows 11 под рукой нет, быть может, кому-то будет интересно проверить там.

Я решил сделать тест каждого варианта в виде отдельного консольного приложения. Все эти приложения собрал вместе в солюшне в виде проектов. Все ссылки на код, данные и графики будут приведены в конце статьи. А мы начинаем наше исследование.

Бесконечный цикл

Нельзя обойти стороной наивный подход — таймер на основе бесконечного цикла с подсчётом интервала:

using Common;
using System;
using System.Diagnostics;
using System.Threading;

namespace InfiniteLoopTimer
{
    internal sealed class Timer : ITimer
    {
        private bool _running;

        public void Start(int intervalMs, Action callback)
        {
            var thread = new Thread(() =>
            {
                var lastTime = 0L;
                var stopwatch = new Stopwatch();

                _running = true;
                stopwatch.Start();

                while (_running)
                {
                    if (stopwatch.ElapsedMilliseconds - lastTime < intervalMs)
                        continue;

                    callback();
                    lastTime = stopwatch.ElapsedMilliseconds;
                }
            });

            thread.Start();
        }

        public void Stop()
        {
            _running = false;
        }
    }
}

Запустив тест с этим таймером

using Common;

namespace InfiniteLoopTimer
{
    internal class Program
    {
        static void Main(string[] args)
        {
            TimerChecker.Check(new Timer());
        }
    }
}

получим, разумеется, отличные результаты. Например, для 1 мс:

1 мс1 мс

И хоть результаты не могут не радовать, данный таймер, конечно же, не стоит использовать в реальных приложениях. Все мы знаем, что он будет попусту загружать CPU и расходовать батарею на портативных устройствах. Например, на моей машине с 4 ядрами получим такую загрузку процессора:

Загрузка процессора на бесконечном циклеЗагрузка процессора на бесконечном цикле

То есть даже больше одного ядра. Графики для других интервалов приводить не буду, там аналогичные картины (кто хочет, может посмотреть по ссылке в конце статьи).

Переходим к стандартным классам таймеров в .NET.

System.Timers.Timer

Используя System.Timers.Timer

using Common;
using System;

namespace SystemTimersTimer
{
    internal sealed class Timer : ITimer
    {
        private System.Timers.Timer _timer;

        public void Start(int intervalMs, Action callback)
        {
            _timer = new System.Timers.Timer(intervalMs);
            _timer.Elapsed += (_, __) => callback();
            _timer.Start();
        }

        public void Stop()
        {
            _timer.Stop();
        }
    }
}

получим такие результаты:

1 мс1 мс10 мс10 мс100 мс100 мс

Как видим, для малых интервалов 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, запустить таймер с заданным интервалом, и даже стандартные таймеры должны срабатывать с этим интервалом. Что ж, проверим.

System.Timers.Timer + timeBeginPeriod

Код нового таймера:

using Common;
using System;

namespace SystemTimersTimerWithPeriod
{
    internal sealed class Timer : ITimer
    {
        private System.Timers.Timer _timer;
        private uint _resolution;

        public void Start(int intervalMs, Action callback)
        {
            _timer = new System.Timers.Timer(intervalMs);
            _timer.Elapsed += (_, __) => callback();

            _resolution = NativeTimeApi.BeginPeriod(intervalMs);
            _timer.Start();
        }

        public void Stop()
        {
            _timer.Stop();
            NativeTimeApi.EndPeriod(_resolution);
        }
    }
}

Не буду здесь приводить код класса NativeTimeApi, кому интересно, посмотрит его в архиве с солюшном (ссылка в конце статьи). Запускаем тест:

1 мс1 мс10 мс10 мс100 мс100 мс

Увы, лучше не стало. Если немного погуглить, обнаружим, что мы не одиноки в своём горе:

Оказывается, начиная с версии Windows 10 2004 изменилось влияние функции timeBeginPeriod на стандартные таймеры. А именно, теперь она на них не влияет. По этой теме можно почитать интересную статью — Windows Timer Resolution: The Great Rule Change. К слову, выглядит, что проблема присутствует и на более ранних версиях Windows 10.

Кстати говоря, визуально результаты всё же стали немного другими. А именно, уменьшился разброс относительно заданного интервала. Это может быть случайностью, а может быть и влиянием функции timeBeginPeriod.

System.Threading.Timer

Для полноты картины нужно также посмотреть, а как обстоят дела с System.Threading.Timer. Код:

using Common;
using System;

namespace SystemThreadingTimer
{
    internal sealed class Timer : ITimer
    {
        private System.Threading.Timer _timer;

        public void Start(int intervalMs, Action callback)
        {
            _timer = new System.Threading.Timer(_ => callback(), null, intervalMs, intervalMs);
        }

        public void Stop()
        {
            _timer.Dispose();
        }
    }
}

Результаты:

1 мс1 мс10 мс10 мс100 мс100 мс

Ожидаемо никаких отличий от 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

Работа System.Threading.Timer с предварительным вызовом timeBeginPeriod (а вдруг с этим таймером сработает):

1 мс1 мс10 мс10 мс100 мс100 мс

Не сработало.

Multimedia timer

В Windows издревле существует API для создания мультимедийных таймеров. Использование их состоит в регистрации функции обратного вызова с помощью timeSetEvent и предварительном вызове timeBeginPeriod. Таким образом, опишем новый таймер:

using Common;
using System;

namespace WinMmTimer
{
    internal sealed class Timer : ITimer
    {
        private uint _resolution;
        private NativeTimeApi.TimeProc _timeProc;
        private Action _callback;
        private uint _timerId;

        public void Start(int intervalMs, Action callback)
        {
            _callback = callback;

            _resolution = NativeTimeApi.BeginPeriod(intervalMs);
            _timeProc = TimeProc;
            _timerId = NativeTimeApi.timeSetEvent((uint)intervalMs, _resolution, _timeProc, IntPtr.Zero, NativeTimeApi.TIME_PERIODIC);
        }

        public void Stop()
        {
            NativeTimeApi.timeKillEvent(_timerId);
            NativeTimeApi.EndPeriod(_resolution);
        }

        private void TimeProc(uint uID, uint uMsg, uint dwUser, uint dw1, uint dw2)
        {
            _callback();
        }
    }
}

Запустив тест, получим такие результаты:

1 мс1 мс10 мс10 мс100 мс100 мс

А вот это уже интересно. Проверим на локальной машине:

1 мс1 мс10 мс10 мс100 мс100 мс

Тут вообще красота. Таймер прекрасно держит заданный интервал, лишь изредка заметно отклоняясь от него (чего избежать невозможно на Windows, ибо эта ОС не является системой реального времени).

Итак, результаты радуют. Однако, в документации сказано, что функция timeSetEvent устаревшая:

This function is obsolete. New applications should use CreateTimerQueueTimer to create a timer-queue timer.

Вместо неё предлагается использовать функцию CreateTimerQueueTimer. Что ж, мы, как законопослушные разработчики идём пробовать.

Timer-queue timer

Вместо мультимедийных таймеров Microsoft рекомендует использовать таймеры, созданные на специальных очередях. Можно использовать дефолтную очередь, а можно и создавать свои. Мы будем использовать дефолтную. Код нашего таймера:

using Common;
using System;

namespace TimerQueueTimerUsingDefault
{
    internal sealed class Timer : ITimer
    {
        private IntPtr _timer;
        private NativeTimeApi.WaitOrTimerCallback _waitOrTimerCallback;
        private Action _callback;

        public void Start(int intervalMs, Action callback)
        {
            _callback = callback;
            _waitOrTimerCallback = WaitOrTimerCallback;

            NativeTimeApi.CreateTimerQueueTimer(
                ref _timer,
                IntPtr.Zero,
                _waitOrTimerCallback,
                IntPtr.Zero,
                (uint)intervalMs,
                (uint)intervalMs,
                NativeTimeApi.WT_EXECUTEDEFAULT);
        }

        public void Stop()
        {
            NativeTimeApi.DeleteTimerQueueTimer(IntPtr.Zero, _timer, IntPtr.Zero);
        }

        private void WaitOrTimerCallback(IntPtr lpParameter, bool TimerOrWaitFired)
        {
            _callback();
        }
    }
}

Здесь в параметр Flags функции CreateTimerQueueTimer мы передаём WT_EXECUTEDEFAULT. Чуть позже посмотрим и на другой флаг. А пока запустим тест:

1 мс1 мс10 мс10 мс100 мс100 мс

Выглядит многообещающе. Проверим на локальной машине:

1 мс1 мс10 мс10 мс100 мс100 мс

Как ни странно, в разных версиях Windows таймер работает по-разному. На моей Windows 10 результаты не лучше стандартных .NET таймеров.

Timer-queue timer + timeBeginPeriod

Интереса ради я проверил предыдущий таймер с предварительной установкой периода системного таймера на локальной машине:

1 мс1 мс10 мс10 мс100 мс100 мс

Внезапно на 10 мс неплохие результаты. Но для 1 мс всё так же плохо.

Timer-queue timer + WT_EXECUTEINTIMERTHREAD

В прошлый раз мы использовали опцию WT_EXECUTEDEFAULT при создании таймера. Попробуем установить другую — WT_EXECUTEINTIMERTHREAD. Результаты (по-прежнему используем локальную машину):

1 мс1 мс10 мс10 мс100 мс100 мс

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

Timer-queue timer + WT_EXECUTEINTIMERTHREAD + timeBeginPeriod

Без лишних слов:

1 мс1 мс10 мс10 мс100 мс100 мс

Глядя на графики, я всё-таки прихожу к выводу, что timeBeginPeriod как-то да влияет на таймеры. Коридор значений для интервала 1 мс явно становится уже.

Итоги

Буду честен, рассмотрены не все варианты. Вот тут в блоке Tip перечислены ещё такие:

Но и это ещё не всё. В .NET 6 появился PeriodicTimer. Зоопарк разных таймеров в .NET и Windows, конечно, весьма солидный.

Но все эти таймеры не подходят. Как я писал до ката: статья сконцентрирована на поиске такого решения, которое работало бы и под .NET Framework, и под .NET Core / .NET, и в разных версиях ОС, и являлось бы механизмом общего назначения. А потому вот причины отказа от упомянутых классов (по крайней мере для нужд мультимедиа):

А что же можно сказать о тех таймерах, что были проверены в статье? Нет смысла писать много букв, единственный надёжный вариант — мультимедийные таймеры. И хотя они давно объявлены устаревшими, только они соответствуют критериям, указанным до ката.

Всем спасибо. Как и обещал, привожу ссылки:

© Habrahabr.ru