Pull to refresh

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

Programming *.NET *Development for Windows *
✏️ Technotext 2021

Вы пишите код на платформе .NET под Windows и вам нужно выполнять некоторые действия каждую миллисекунду. Возможно ли это? Какие есть варианты и насколько они надёжны? Разберёмся, что можно использовать, и какие есть гарантии по точности срабатывания. Статья сконцентрирована на поиске такого решения, которое работало бы и под .NET Framework, и под .NET Core / .NET, и в разных версиях ОС, и являлось бы механизмом общего назначения (а не только для программ с GUI, например).

Для чего вообще может потребоваться таймер с малым периодом? Примером могут служить различные программные аудио- и видеоплееры. Классический подход при воспроизведении мультимедийных данных – раз в 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<long>((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<long>();
            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_<interval>.txt.

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

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

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

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

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

EDIT ────────

В комментариях было высказано много интересных идей и предложений. Не все из них меняют итоги статьи, но для полноты картины упомянуть стоит. Кроме того, будут добавлены графики загрузки процессора. Такие вставки будут оформлены, как этот абзац.

────────────

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

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

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 ядрами получим такую загрузку процессора:

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

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

EDIT ────────

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

Во-первых, не один человек уверенно высказался о том, что вызов метода Thread.Yield должен снизить загрузку процессора. Что ж, напишем новый таймер:

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

namespace InfiniteLoopTimerWithThreadYield
{
    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)
                    {
                        callback();
                        lastTime = stopwatch.ElapsedMilliseconds;
                    }

                    if (!Thread.Yield())
                        Thread.Sleep(0);
                }
            });

            thread.Start();
        }

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

Точность будет высокая (смотрел на своём локальном компьютере)

1 мс
1 мс

но вот загрузка процессора не меняется

Загрузка CPU
Загрузка CPU

Результаты аналогичные и для виртуалок Azure Pipelines и для второго компьютера. Т.е. совершенно точно нельзя назвать такой таймер хорошим.

Во-вторых, в комментариях были упомянуты NtSetTimerResolution / NtDelayExecution. Это недокументированные функции системной библиотеки ntdll.dll. Я модифицировал бесконечный цикл простейшим образом с использованием этих функций, сделав такой таймер:

using System;
using System.Runtime.InteropServices;
using System.Threading;
using Common;

namespace InfiniteLoopTimerWithNtDelayExecution
{
    internal sealed class Timer : ITimer
    {
        [DllImport("ntdll.dll", SetLastError = true)]
        private static extern void NtSetTimerResolution(uint DesiredResolution, bool SetResolution, ref uint CurrentResolution);

        [DllImport("ntdll.dll")]
        private static extern bool NtDelayExecution(bool Alertable, ref long DelayInterval);

        private Thread _thread;
        private bool _running;

        public void Start(int intervalMs, Action callback)
        {
            var res = (uint)(intervalMs * 10000);
            NtSetTimerResolution(res, true, ref res);

            _thread = new Thread(() =>
            {
                _running = true;

                while (_running)
                {
                    var interval = -intervalMs * 10000L;
                    NtDelayExecution(false, ref interval);
                    callback();
                }
            }) { Priority = ThreadPriority.Highest };

            _thread.Start();
        }

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

Такая реализация обладает некоторыми недостатками (например тем, что срабатывания будут "плыть" в зависимости от времени выполнения callback), но для демонстрации вполне годится. Запустив, получим такие результаты на локальной машине:

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

На виртуалках Azure Pipelines результаты тоже отличные, хотя среднее значение и не совпадает с запрошенным интервалом. Например, для интервала 1 мс:

1 мс
1 мс

То бишь такой таймер действительно неплох.

────────────

Переходим к стандартным классам таймеров в .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 мс

EDIT ────────

Загрузка CPU, разумеется околонулевая, ибо таймер работает на пуле потоков:

1 мс
1 мс

────────────

Как видим, для малых интервалов 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.

EDIT ────────

Загрузка процессора:

1 мс
1 мс

────────────

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 мс

EDIT ────────

Процессор нагружен аналогично предыдущему таймеру:

1 мс
1 мс

────────────

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

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

EDIT ────────

Загрузка процессора:

1 мс
1 мс

────────────

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, ибо эта ОС не является системой реального времени).

EDIT ────────

Дополним результаты графиками загрузки процессора. На локальной машине:

1 мс
1 мс

На виртуалке Azure Pipelines:

1 мс
1 мс

Нагрузка на процессор минимальная.

────────────

Итак, результаты радуют. Однако, в документации сказано, что функция 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 таймеров.

EDIT ────────

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

1 мс
1 мс

На виртуалке Azure Pipelines:

1 мс
1 мс

Процессор нагружается одинаково при том, что точность разная. Не буду приводить графики загрузки CPU в дальнейших разделах, там всё то же самое.

────────────

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, и в разных версиях ОС, и являлось бы механизмом общего назначения. А потому вот причины отказа от упомянутых классов (по крайней мере для нужд мультимедиа):

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

EDIT ────────

Видя результаты работы таймера, основанного на функциях NtSetTimerResolution / NtDelayExecution должен признать, что это также отличный вариант достичь точности в 1 мс. Более того, таким образом можно достичь и большей точности, чего невозможно сделать с мультимедийными таймерами. Большое спасибо @Alexx999 и всем неравнодушным к теме!

────────────

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

Tags:
Hubs:
Total votes 71: ↑71 and ↓0 +71
Views 18K
Comments 83
Comments Comments 83

Posts