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

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

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

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

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

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

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

Комментарии (76)


  1. Albert2009ru
    06.02.2022 23:39
    -2

    Добрый день, я в C# совсем новичок и статья, действительно сильно выше моего уровня. Но чем не нравится:

    myTimer = new System.Timers.Timer(1);

    по майкрософтовской документации вроде тоже 1мс? Прошу не убивать, просто очевидного ответа из статьи я не нашел. Если поясните на пальцах, то буду благодарен :)


    1. Avael23
      06.02.2022 23:51
      +8

      Прочитайте статью, там написано с примерами и разъяснениями что минимальная задержка получаемая таким способом составляет 15,6 мс.


    1. Melanchall Автор
      06.02.2022 23:54
      +8

      Здравствуйте. Но ведь в статье есть раздел про System.Timers.Timer, где приведён график для интервала 1 мс. Вы можете указать в конструкторе 1, но по факту таймер будет тикать в среднем каждые 15.6 мс. Я также привёл выдержку из документации по классу, в которой явно говорится, почему так происходит. При указании интервала меньше интервала системного таймера (а он 15.6), таймер будет срабатывать с интервалом системного таймера.


      1. Albert2009ru
        07.02.2022 00:36

        Спасибо. Буду знать.


        1. Albert2009ru
          07.02.2022 12:34
          +1

          Вот за что минусуют? Я же сделал ремарку, что неопытен, плохо разбираюсь и т.п. Или статьи может читать только "белая кость" и задавать глупые вопросы строго воспрещено? Люди, будте терпимее и добрее. С уважением :)


          1. burz_ex
            09.02.2022 12:05

            Минусуют за вопрос, ответ на который явно описан в статье.


    1. sami777
      08.02.2022 00:45
      -1

      Все таимеры, идушие из "коробки" быстрее 10мс. не считают.


      1. Albert2009ru
        08.02.2022 10:11

        Добрый день, я C# решил освоить, как альтернативу Qt для эмбеддед. Т.к. сам я больше микроконтроллерами занимаюсь. В WindowsForms мне пока всё очень нравится. Ну и смотрю я на всё со "своей колокльни" - в микроконтроллере если таймер 1 мкс, то он и будет срабатывать с интервалом 1 мкс. Не надо каких-то утилит для того, чтобы это узнать - по таймеру "дёргаем ногой", допустим, к ноге осциллограф и вам железо покажет что 1 мкс - это 1 мкс. Отсюда и наивные вопросы :) Спасибо, что не прошли мимо. Хорошего дня.


        1. yury_m
          08.02.2022 16:11

          Потому что это разные операционные системы. В embedded Вы пользуетесь операционной системой реального времени, которая гарантирует выпонение задач в заданные промежутки времени. Windows - система не реального времени и она изначально не может ничего гарантировать выполнить строго в определенные момент времени. Вашу задачу всегда может вытеснить задача с бОльшим приоритетом, а потом другая задача, а потом другая. Windows заметит, что Ваша задача уже ожидает много времени и что она сделает? Просто повысит чуть приротитет Вашей задачи чтобы она смогла выполниться. Но это опять не гарантирует, что Ваша задача будет выпонена после этого сразу же.


          1. Albert2009ru
            08.02.2022 18:52

            Спасибо за объяснение. В МК для запуска таймера и т.п. ОС вообще не нужна. Да и не ОС это в "классическом" смысле.


  1. Avael23
    06.02.2022 23:50
    +4

    Спасибо за интересную статью еще и с таками подробностями, сам бы наверное никогда не взялся разбираться, а вот как именно работает та или иная реализация интересно.


    1. Melanchall Автор
      06.02.2022 23:56

      Спасибо :-)


  1. shai_hulud
    07.02.2022 00:10
    +9

    Автор был в полушаге от успеха с точным таймером. Надо только добавить советский ... Thread.Yield(); в конец тела бесконечного цикла и нагрузка CPU упадёт до 0-1%. На моем цпу точность такого таймера ~0.3 мс. Конечно она поплывёт если внезапно появится тяжелый процесс, как поплывут и другие таймеры.


    1. Melanchall Автор
      07.02.2022 00:46
      +2

      Здравствуйте. Уточните, пожалуйста, какой код вы используете? Если не сложно, прям код таймера для проверки. Я взял тот код, что привёл в статье, добавил Thread.Yield 2-мя способами. Вот так:

      while (_running)
      {
          if (stopwatch.ElapsedMilliseconds - lastTime < intervalMs)
              continue;
      
          callback();
          lastTime = stopwatch.ElapsedMilliseconds;
          Thread.Yield();
      }

      и вот так:

      while (_running)
      {
          if (stopwatch.ElapsedMilliseconds - lastTime >= intervalMs)
          {
              callback();
              lastTime = stopwatch.ElapsedMilliseconds;
          }
      
          Thread.Yield();
      }

      На локальной машине обоими способами загрузка процессора ровно такая же. Результаты пока в процессе.


      1. shai_hulud
        07.02.2022 00:54
        +4

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

        If(!Thread.Yield()) // true - control is passed to another thread
            Thread.Sleep(0);


        UPD: поправил ответ. Конечно второй вариант т.к. нет смысла yieldить после действия.


        1. mentin
          07.02.2022 01:16

          А Sleep(0) что-то меняет? По документации Sleep(0) не сильно отличается от Yield():

          If the value of the millisecondsTimeout argument is zero, the thread relinquishes the remainder of its time slice to any thread of equal priority that is ready to run. If there are no other threads of equal priority that are ready to run, execution of the current thread is not suspended.


          1. shai_hulud
            07.02.2022 01:32
            +1

            Да, они отличаются. Один передает свой оставшийся кусок времени следующему треду на данном процессоре, другой отдает любому другому треду.


        1. Melanchall Автор
          07.02.2022 01:25
          +2

          Во-первых, @mentin выше дал выдержку из документации.

          Во-вторых, код

          while (_running)
          {
              if (stopwatch.ElapsedMilliseconds - lastTime >= intervalMs)
              {
                  callback();
                  lastTime = stopwatch.ElapsedMilliseconds;
              }
          
              if (!Thread.Yield())
                  Thread.Sleep(0);
          }

          не даёт никаких улучшений по загрузке процессора на моей машине, всё так же больше одного ядра занято. То бишь, таймер ведёт себя по-разному на разном окружении, что не соответствует цели статьи – найти таймер, стабильно тикающий с заданным интервалом в разных конфигурациях системы.

          В-третьих, мультимедийные таймеры, которые являются лучшим выбором, работают на специальном высокоприоритетном системном потоке, что даёт меньше шансов на его прерывания и обеспечивает бо́льшую точность.


          1. shai_hulud
            07.02.2022 01:58
            +4

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

            Значит это время не нужно других тредам с тем же приоритетом. Как только они понадобятся потребление ЦПУ упадёт. Для energy efficient пропуска времени есть Thread.SpinWait который может выполняться как PAUSE instruction на x86 и хз как на друих архитектурах.

            Мультимедийные таймеры это Platform API, лучше не трогать такие API. Ну они не работают на магии, их поведение можно воспроизвести и получить стабильный результат вне зависимости от платформы на которой запущено приложение.


            1. ciuafm
              07.02.2022 16:14

              А разве нельзя после If(!Thread.Yield())

              Вызвать пару страниц nop ? Это позволит не греть процессор во время простоя а в случае если есть другие желающие - они будут вызваны... Кто-то знает как сделан системный Idle в Винде?


            1. vanbukin
              08.02.2022 10:28

              У PAUSE в зависимости от процессорной архитектуры - разная длина https://habr.com/ru/post/415053/


      1. cdriper
        07.02.2022 12:16

        Thread.Yield() это бомба замедленного действия и при определенным обстоятельствах (когда несколько процессов висят на таком коде), может произойти взырное потребление CPU.


        1. shai_hulud
          07.02.2022 13:13
          +1

          Не ставлю под сомнение. Но есть статья/инфа об этом?


          1. cdriper
            07.02.2022 13:16
            +2

            Статьи нет, но есть богатый личный опыт с Boost.Interprocess в котором раньше (не знаю как сейчас) на yield было построенно ожидание в куче мест. По итогу из-за этой библиотеки пришлось отказаться.


          1. a-tk
            08.02.2022 08:51
            +1

            Книга есть. Windows Internals. Под руками нету книги, чтобы в конкретную главу ткнуть, но в разделе про планирование потоков такое упоминание есть.

            Механизм примерно такой: фактически поток, который отдал выполнение сам, не теряет остаток кванта планирования. Но планировщик (работает в режиме ядра) получает задание посмотреть, кому отдать выполнение, и через маленький промежуток времени может вернуть выполнение тому же потоку. И если он в цикле отдаёт выполнение, то планировщик действительно может потреблять CPU.


    1. maxbl4
      07.02.2022 14:36
      +3

      Делал именно так, самый простой и самый точный способ. Бесконечный цикл и Thread.Yield(). Не знаю уж что у автора не так с Yield, это очень старая тема и лично для меня всегда работала. В итоге цикл с Yield давал погрешность меньше 1мс. Использовал этот таймер, чтобы играть тики/таки метронома. На слух невозможно услышать ошибку


      1. maxbl4
        07.02.2022 14:39

        Позже по аналогичному принципу код был портирован на JS для работы в браузере.

        while(true) {

        if (ещё рано) setTimeout(сам_себя, 0)
        }

        и тоже нормально игралось и не было какой-то космической нагрузки


      1. Melanchall Автор
        07.02.2022 14:48

        Не так с Thread.Yield то, что загрузка процессора очень высокая, меня такое не устраивает. Тоже не знаю, что у меня не так, но это факт, код я привёл. Точность, конечно, высокая. Но этого мало, чтобы отдавать такое решение пользователям. Я видел в других проектах (уж не вспомню, в каких) баг-репорты в GitHub, когда разработчики вставляли цикл в качестве таймера. В реальном мире люди быстро столкнутся с проблемами, обнаружив высокое использование ЦП и разрядку батареи.


        1. maxbl4
          07.02.2022 14:59

          for (var i = 0; i < 100000000; i++){    if (i % 1000000 == 0)        Console.WriteLine($"{i}");    Thread.Yield();}

          Вот такой код не жрёт CPU


          1. Melanchall Автор
            07.02.2022 15:12
            +1

            Мой компьютер с вами не согласен:

            Это я на другом компьютере проверял, там 8 логических ядер, поэтому процент ниже (на том, результаты с которого в статье 4 логических ядра).

            В этом как раз и суть проверки на разных машинах. Я мог бы проверить Timer-queue timer на виртуалке, увидеть отличные результаты и объявить в статье, что вот она долгожданная замена мультимедийным таймерам. Но это была бы ложь. Сильно влияет окружение, версия ОС, ещё что-то, наверное.


      1. Refridgerator
        07.02.2022 15:50
        +1

        Использовал этот таймер, чтобы играть тики/таки метронома
        У меня как-то была идея написания статьи, посвящённой реализации метронома. В ней предполагалось рассмотреть 3 подхода по мере увеличения точности:

        1) использование таймера (самый грубый метод),
        2) ручное заполнение аудио-буфера с расчётом необходимого количества пропущенных семплов (точность позиционирования на уровне частоты дискретизации),
        3) свёртка периодической sinc-функции с импульсом звука метронома (неограниченная точность позиционирования).

        Но поскольку тема довольно специфическая, дело до написания так и не дошло.


        1. Alexx999
          07.02.2022 16:07

          Как по мне то генерация непрерывного звукового потока на лету (это, наверное, вариант номер 2) - это вполне достаточная точность для всех возможных сценариев использования, всё равно же не услышишь ничего свыше частоты дискретизации. Отдельный плюс - на это не влияют никакие тайминги, размеры буферов и тому подобного - заполнил себе буфер а дальше пускай звуковая подсистема разбирается.


          1. Melanchall Автор
            07.02.2022 18:26

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


        1. maxbl4
          07.02.2022 16:21
          +2

          Я тоже сначала стал думать про точность, но потом поискал исследования с тестами какую точность может услышат человек. Если не путаю, там оказалось, что средний человек не слышит разнцу меньше 20мс. Я сразу расслабился и не стал тратить десятки часов на создание сверх точного метронома. Реализовал за два часа простой механизм и перешёл к следующей задаче :)


          1. Melanchall Автор
            07.02.2022 18:24
            +3

            Это вопрос перфекционизма :) Вот, кстати, данные очень разнятся по тому лагу, который может заметить человек. Наверное, тренированные профессиональные барабанщики, например, чувствуют лаг и менее 20 мс. И если вы делаете свой продукт не только для себя, стоит иметь это в виду.


          1. Refridgerator
            08.02.2022 05:59

            Собственно, это вопрос у меня возник после того, как один известный в узких кругах Фред-гитарист заявил, что «это не я слажал, это метроном кривой». Другой взялся проверять это утверждение и сравнивать метрономы — и сделал вывод, что некоторые таки могут врать. А сам я пользуюсь «железным», то есть специально разработанным для этой задачи устройством, который тактируется кварцевым генератором.


  1. 13_beta2
    07.02.2022 00:34
    +5

    Графики симпатичные, хотя и не каноничные. За них плюс. В остальном много сил потрачено на давно известные истины. Мультимедиа таймеры — "классический" способ получения высокой точности в Windows.


  1. CoolCmd
    07.02.2022 00:36
    +2

    я в своей проге вызываю NtQueryTimerResolution/NtSetTimerResolution и Sleep. результат проверяю вызывая QueryPerformanceCounter.
    работает на XP-10, если clockres.exe не врет. повышаю точность до 2,5 мс.
    это на C++


    Возвращаясь к примеру с VLC из начала статьи, подход с 5 мс выглядит разумным.

    visual studio 2022 за каким-то хреном повышает точность до 1 мс. зачем это ей? никаких отладчиков и прочих крутых штук не установлено, только редактор для веб-разработки. индусам в мелкософте нужно брать пример с VLC.


    1. Melanchall Автор
      07.02.2022 01:15

      Здравствуйте. А вы не пробовали замерить реальные дельты между срабатываниями таймера? Я не уверен, что информация от clockres соответствует действительному положению дел. Но не исключено, что ваш метод работает. Было бы интересно попробовать, как .NET таймеры ведут себя после вызова NtSetTimerResolution.


      1. CoolCmd
        07.02.2022 11:15

        А вы не пробовали замерить реальные дельты между срабатываниями таймера?

        не уверен, что тестировал на версии 2004+. на более ранних работало как нужно.


  1. KGeist
    07.02.2022 08:06
    +1

    Насколько я помню, timeBeginPeriod меняет точность таймеров (частоту тика) во всей системе в целом, не только в текущем процессе. Т.е.:

    1) сильнее начинает жрать батарею ноутбука, т.к. процессы просыпаются чаще

    2) часть программ может упасть с ошибками, если они ожидали resolution в 16 мс (видел багрепорты)


    1. vakhramov
      07.02.2022 09:34

      Во всей системе, да.

      Считаю, что отсутствие проверки разрешения таймера в остальных приложениях - недоработка. Обрабатываю UDP потоки (ethernet/ip - cip с разных устройств) для одного приложения, пока timeBeginPeriod(1) не сделал - тайминги плавали до +5мс, а это недопустимо для не вебчика.

      (Поставщик задержал железо, которое независимо от ОС может захватывать пакеты и прочие IO, пришлось костылять)

      Если приложение обрабатывает потоки с высоким разрешением, то других программ скорее всего не должно быть в ОС)


    1. a-tk
      07.02.2022 10:47

      Starting with Windows 10, version 2004, this function no longer affects global timer resolution.

      Источник: https://docs.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod#remarks


  1. onyxmaster
    07.02.2022 08:51
    +2

    Спасибо за статью. Хочу заметить, что эта задержка влияет не только на системные таймеры, но и на переключение потоков вообще (отсюда и такое влияние на батарею), в том числе на разрешение таймера GetTickCount (который просто считывает из TEB время переключения потока, записанного туда ОС), и который используется в DateTime.[Utc]Now. Кроме того, есть и влияние на IOCP-потоки, которые используются для завершения, например, асинхронных сетевых операций. Мы у себя принудительно выставляем таймер в 1мс, но у нас достаточно старая среда исполнения (WS2016), поэтому пока что это работает надёжно.


  1. a-tk
    07.02.2022 10:55

    Собственно, давайте глянем референсный сорс:

    https://referencesource.microsoft.com/#system/services/monitoring/system/diagnosticts/Stopwatch.cs,125

            public static long GetTimestamp() {
                if(IsHighResolution) {
                    long timestamp = 0;    
                    SafeNativeMethods.QueryPerformanceCounter(out timestamp);
                    return timestamp;
                }
                else {
                    return DateTime.UtcNow.Ticks;
                }   
            }

    Надо ещё посмотреть, чему равно свойство IsHighResolution. (но оно имеет значение false, если не получилось позвать QueryPerformanceFrequency) Это возможно в весьма экзотической ситуации. Но всё равно надо ещё посмотреть Frequency - мало ли, самый лучший таймер может иметь разрешение те же 15.6 мс.


  1. Nikkon-dev
    07.02.2022 11:27
    +7

    Оставь надежду всяк сюда входящий. В свое время намучились с переносом управляющей софтины с qnx на винду. Нужно было с железкой общаться по протоколу где пакеты разделялись задержками меньше 1мс. В итоге плюнули и добавили в железку буфер с разбором пакетов на 16ms. Заставить винду стабильно работать с таймерами меньше 16мс это та еще головная боль. А разбираться почему эта фигня перестает работать у оператора у черта на куличках это х10 головная боль.


  1. Alexx999
    07.02.2022 11:27
    +6

    Здравствуйте, тема знакомая - довелось столкнуться с таймингом в .NET. Правда, у меня вводные были чуть "веселее" - старое (надцать лет) и достаточно большое (не перепишешь) приложение на WinForms. Ну и, конечно же, там было всё на UI потоке потому что вперемешку кони, люди, контролы UI и железо. Ну и конечно же на одной машине обычно крутилось много всего поэтому нагрузка на систему должна быть настолько низкой насколько это вообще возможно.

    В целом, я прошел плюс-минус такой же список вариантов, сначала была миграция на Multimedia Timers (хоть они и deprecated) но я столкнулся с тем что они на некоторых системах не работают по мистическим причинам. Собственно, "иногда" колбек просто не вызывается, преимущественно ноутбуки - подозрение на power plan, но я глубоко не копал - всё равно наблюдался достаточно большой jitter. Итоговое решение - самописный гибридный таймер с динамической коррекцией "по ходу пьесы" для достижения требуемой точности.

    Так вот, для таймера в 1мс while() цикл и является оптимальным решением. Максимум что можно сделать - это вставить SpinWait() чтобы при этом греть воздух с чуть меньшим энтузиазмом. Любая передача кванта времени с большой вероятностью закончится пропусками тактов и с этим ничего не сделать, увы.

    Для таймеров чуть подольше (~5мс) уже можно делать Sleep/Yield с вполне приемлемым результатом.

    Из ещё интересного - по крайней мере в Win11 NtSetTimerResolution больше не system-wide, если процесс не вызывал этот метод - у него будет квант 16.6мс, при этом NtQueryTimerResolution будет репортить system-wide значение как и раньше.

    В общем, прогнал тесты по быстрому (одна минута) - вот результаты, просмотр глазами указывает на наличие пары мелких багов но в целом вроде бы приемлемо.


    1. Melanchall Автор
      07.02.2022 14:06

      Но как можно считать оптимальным решение, которое загружает так сильно CPU?

      Касательно Sleep на 5 мс. Sleep точно так же ориентируется на системный таймер. Sleep(5) будет по факту ждать 15.6 мс. Либо же я не понял, что имеется в виду.

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


      1. Alexx999
        07.02.2022 15:44
        +2

        "Оптимальным" решение считается на правах единственно-работающего - если нужен тайминг в 1мс то единственный вариант который предлагает Windows - это писать свой драйвер, загружать его и что-то делать уже оттуда. Но с новыми (или вернее уже старыми) требованиями к подписи драйверов для SecureBoot это затея из разряда "ещё тех". При этом из userspace планомерно удаляют все средства реализации высокочастотных таймеров, ну потому что "лишнее" и кушает батарейку. Ну а в результате приходится "экономить батарейку" методом кручения горячего цикла на ядре, ну не прелесть ведь?
        Но опять же, если использовать SpinWait() вместо пустого While() цикла то не взирая на то что полностью загружается одно ядро процессора по энергии оно не улетает в космос - что на ноутбуках что на ПК вентиляторы сидят тихо, для пользователя "всё нормально". И, скажем, на новых Intel 12 gen можно использовать энергоэффективное ядро под это дело.

        Дальше, системный таймер можно поставить для процесса в 0.5мс (на современных машинах) путем вызова NtQueryTimerResolution/NtSetTimerResolution, собственно мой код так и работает, возможно даже с излишним недоверием к системе - если до срабатывания таймера остается меньше чем двойное разрешение системного таймера то квант времени будет сжигаться а не отдаваться другому потоку.

        Увы, но код я сбросить не могу - он не моя собственность, да и зависимостей там на проверку больше чем я думал. Могу спросить, но шансы минимальные.


        1. Melanchall Автор
          07.02.2022 16:48

          Понял, спасибо. Я всё же склонен думать и о тех, у кого компьютеры слабые, а в системе помимо приложения с использованием моей библиотеки работают и другие программы, разработчики которых, вероятно, тоже решили не париться о загрузке целого ядра. У меня, например, крайне плохая древняя батарея, которая разряжается ультрабыстро, особенно с такими вот энергопотреблениями, которое даёт бесконечный цикл. Мне очень не хочется быть тем звеном в программной цепочке пользователя, которое будет создавать проблемы.

          Мультимедийный таймер для меня вляется оптимальным решением. Я верю, что вы столкнулись с мистикой по невыполнению коллбэка. И очень хочется понять, когда такое происходит. Быть может, покопаю на досуге.


          1. Alexx999
            07.02.2022 18:49

            У нас наименьший период который применяется на практике - 10мс и задача работы от батареи не рассматривается впринципе - нагрузка на процессор в пределах пары процентов даже на весьма чахлых атомах вполне приемлема.
            Если необходим конкретно 1мс таймер и работа от батареи - то можно слегка расслабить допуски и дожать оптимизацию путем сокращения кол-ва спинов при помощи связки NtQueryTimerResolution/NtSetTimerResolution/NtDelayExecution - я реализовал на коленке и у меня с такой оптимизацией нагрузка 1мс таймера на процессор на уровне "0.01%" (по версии Process Explorer) при сравнимой точности с предыдущей реализацией.


            1. Melanchall Автор
              07.02.2022 18:55

              Эта реализация на коленке как раз тот код, что вы не можете скинуть? Мне очень хочется проверить на своих машинах.


              1. Alexx999
                07.02.2022 20:30

                Да - это тот же код, просто с дополнительной оптимизацией на сверхмалые периоды.
                При этом надо понимать что всё равно будут вырожденные случаи вроде таймера в 0.45мс где ну точно-точно только while() цикл, или 0.95мс где хочешь не хочешь а надо будет дожигать половину периода при помощи SpinWait с соответствующей нагрузкой на процессор.


      1. a-tk
        08.02.2022 08:56

        Sleep(5) будет ждать НЕ МЕНЕЕ 5 секунд. Верхняя граница определяется как разрешением таймера, используемого для планирования, так и загрузкой CPU, prioriy boosting-ом и ещё много чем.


  1. cdriper
    07.02.2022 12:19

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


  1. iRumba
    07.02.2022 12:45

    Заранее извиняюсь, если скажу глупость, так как я не очень шарю в предметке, но, если различные таймеры хорошо себя ведут при интервале 100мс, то почему бы не воспользоваться этим? Если я правильно понимаю, MIDI файл - это набор нот. Что, если взять интервал (скажем в 100мс), забрать все ноты из этого интервала и на каждую повесить таймер? Ну тут сразу же напрашивается оптимизация, вместо таймера на каждуюу ноту, повесить таймер на каждое событие (это может быть набором нот, которые должны сыграть в одно время).

    Ну а в середине каждого следующего интервала планировать следующие 100мс. Конечно, потенциально это может повесить систему из-за пары лярдов таймеров, но насколько мне известно, даже в очень насыщенной мелодии за 100мс столько не наберется.

    В крайнем случае, чтобы предотвратить намеренные ддосы через перегруженные МИДИ файлы можно выбрать стратегию в зависимости от нагруженности миди файла перед началом воспроизведения.

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


    1. Melanchall Автор
      07.02.2022 13:25
      +2

      Интересная идея, но:

      1. MIDI-файл состоит не из нот, а событий (в том числе и событий нажатия/отпускания нот, конечно). Типичный MIDI-файл содержит несколько десятков тысяч событий, но и с несколькими сотнями не такая уж редкость.

      2. Существование большого числа таймеров выглядит плохой затеей. Таймер не должен блокировать текущий поток, а потому каждый таймер должен сидеть в отдельном. Итого с тонной таймеров имеет тонну потоков. А встроенные таймеры .NET работают именно на потоках из пула потоков. Я вообще думаю, что дефолтного размера пула не хватит, чтобы обслужить MIDI-файл, и возникнут серьёзные задержки.

      3. По графикам видно, что хоть на 100 мс среднее значение зачастую тоже 100, но отклонения могут быть большими (например, 121 мс). В целом я вижу такую корреляцию: выше интервал – выше отклонения. И вот данные разнятся, но я встречал информацию, что отклонения в 15 мс уже могут быть заметны на слух. У меня же стояла задача сделать воспроизведение максимально гладким и точным.

      4. Опыт других аудио- и видеоплееров (согласно отчётам Powercfg, которые я приводил) также показывает, что их разработчики остановились на варианте с одним таймером высокого разрешения. Это не доказательство правоты подхода, конечно, просто косвенный признак.


      1. iRumba
        07.02.2022 15:37

        1. Сложно представить, что значит несколько десятков тысяч на файл, предположу пальцем в небо, что это 10000 событий в минуту. Тогда это получится 166,6 событий в секунду, а это 17 событий за 100мс (в среднем, конечно же)

        2. Да, полагаю, что даже 17 таймеров могут неплохо нагрузить систему. А если учесть, что это среднее значение, разбег может быть и больше.

        А что, если попытаться совместить while и тайминг?

        Например, построить карту файла, выбрать группы сосредоточения событий (в качестве парамтера взять погрешность срабатывания) и между групп событий усыплять поток на nextStart - currentEnd - погрешность. В этом случае вы сохраните точность срабатывания событий и устраните недостаток нагрузки в простое. Может быть даже подняться на уровень абстракций выше и поиграться с await Task.Delay вместо Thread.Sleep (позволить среде контролировать ваши задачи).


    1. Alexx999
      07.02.2022 15:52
      +1

      Нюанс в том что таймеры в 100мс ведут себя хорошо на длительной дистанции и в среднем, но при этом каждый отдельный таймер будет иметь значительный skew. Плюс, есть такая штука как timer coalescing которая прям гарантирует что эти таймеры будут объединены в группы и вызываться будут пачками (скорее всего по 15.6мс).


  1. begin_end
    07.02.2022 14:01
    +1

    О, 11+ лет назад на Хабре мы уже задавались схожей проблемой для Windows XP, первый раз пробовал публиковаться…

    Тогда получалось, что измерять время можно с точностью до ±50 тактов CPU. А задавать точную задержку до 1 мс ±0,84%.
    Windows становится все более ОС нереального времени — интересно, как будет с таймерами и задержками через 10 лет, в какой-нибудь Windows 20… (хотя не факт, что к тому времени она не мутирует в огороженный гибрид макоси и андроида, и это все потеряет смысл)


  1. mkarev
    07.02.2022 17:26
    +1

    Классический подход при воспроизведении мультимедийных данных – раз в N единиц времени смотреть, что́ нужно подать на устройство вывода (видео-, звуковую карту и т.д.) в данный момент времени, и при необходимости отсылать новые данные (кадр, аудиобуфер) на это устройство

    Это антипаттерн. Все вменяемые audio API построены на механизме колбэков, которые дергаются из тредов с повЫшенным приоритетом.

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

    Высокое разрешение необходимо не для ровного плейбэка, а для достижения низкой задержки. Что, в частности, очень актуально для real-time музыкального ПО.

    Если сильно хочется таймер высокого разрешения на win32, то ничего лучше недокументированного API Вам не найти. Как уже писали выше это ф-ции NtSetTimerResolution и NtDelayExecution, но даже первой хватить, чтобы "стандартные" таймерные API стали более отзывчивыми. Также не забываем задать приоритет потоку таймера выше нормального, без этого NtSetTimerResolution может не спасти.

    Если хочется точности сверх 1мс, то таймеры можно комбинировать со спинлоками, вот очень интерсеная статья на данную тему: https://timur.audio/using-locks-in-real-time-audio-processing-safely


    1. Melanchall Автор
      07.02.2022 18:20

      Это антипаттерн.

      Если это антипаттерн, есть какие-то подводные камни, приведите их, пожалуйста. Кроме того, сомнительно, что VLC, Windows Media Player, плеер Chrome и много других программ используют антипаттерн.

      Все вменяемые audio API построены на механизме колбэков, которые дергаются из тредов с повЫшенным приоритетом.

      Таймер = дёрганье колбэков из потока. И да, потоку нужно в общем случае ставить повышенный приоритет. Если вы про какие-то другие колбэки, то объясните, пожалуйста. Кроме того, воспроизведение аудио != обработка воспроизводимого аудио. Во втором варианте да, напрашивается слушать колбэки от системы воспроизведения, устрйоства и т.д. Но реализацию самого воспроизведения не представляю без таймеров. Я могу заблуждаться, буду рад, если расскажите.

      Высокое разрешение необходимо не для ровного плейбэка, а для достижения низкой задержки. Что, в частности, очень актуально для real-time музыкального ПО.

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

      Если сильно хочется таймер высокого разрешения на win32, то ничего лучше недокументированного API Вам не найти. Как уже писали выше это ф-ции NtSetTimerResolution

      Но чем не устраивает вполне себе документированный мультимедийный таймер? За статью спасибо, но она опять же про обработку, а не воспроизведение.

      Расскажите, пожалуйста, как вам видится идеальная реализация плейбека. Я только за научиться чему-то новому и полезному. И хотя за всё время существования API в моей библиотеке никогда не было нареканий на механизм воспроизведения (а используют его часто, даже не одну игру с ним сделали типа Guitar Hero), разумеется, всегда есть куда совершенствоваться.


      1. mkarev
        07.02.2022 19:49

        Таймер = дёрганье колбэков из потока. И да, потоку нужно в общем случае ставить повышенный приоритет. Если вы про какие-то другие колбэки, то объясните, пожалуйста. Кроме того, воспроизведение аудио != обработка воспроизводимого аудио.

        Воспроизведение аудио - это по сути вывод последовательности дискретных отсчетов в ЦАП. ЦАП работает с фиксированной частотой дискретизации. В него невозможно запихать данных больше, чем размер буфера, стоящего перед ним. Устройство вывода звука "подпирает" источник данных и это нормально.

        сомнительно, что VLC, Windows Media Player, плеер Chrome и много других программ используют антипаттерн

        Я не говорил, что данные проекты используют антипаттерн. Тут скорее сделан ошибочный вывод того, как они работают на основе того факта, что их процесс в планировщеке ОС имеет бОльшее разрешение системного таймера. Без какого-либо анализа кодовой базы.

        Расскажите, пожалуйста, как вам видится идеальная реализация плейбека

        Много лет назад занимался разработкой нативной кодек SDK для мобильных платформ, с тех пор сохранились заметки на тему рендеров: https://docs.google.com/document/d/1T9T65-NN92e_xHzr4AV15LSHdhMAmTaJy4XQsirwaws/edit?usp=sharing

        См. главу 2 Вывод звука. Объяснения там, конечно, на уровне "сначала рисуем один овал, затем другой, а потом дорисовываем сову", но поверхностная суть просматривается.

        В случае win32 можно попробовать waveOut из https://docs.microsoft.com/en-us/windows/win32/api/mmeapi/

        Либо что-то еще, например DirectShow аудио рендер, но это уже совсем другая история (с)


        1. Melanchall Автор
          07.02.2022 20:34

          Спасибо, понял. Правда, не понимаю, как предложенная парадигма может применяться при воспроизведении MIDI. В Windows есть API взаимодействия с MIDI-устройствами в духе послать событие/принять событие. Тут не построить буфер, который затем можно двинуть в устройство. Кроме того, API моей библиотеки позволяет указывать колбэки на воспроизведение события или даже ноты, с блочным подходом это всё мимо будет.


          1. mkarev
            08.02.2022 15:00

            не построить буфер, который затем можно двинуть в устройство

            Как-то так я полагаю: https://docs.microsoft.com/en-us/windows/win32/multimedia/using-a-callback-function-to-manage-buffered-playback

            Производящий поток пушит данные в MIDI устройство вызовами midiOutLongMsg() или midiStreamOut(), а в колбэке MOM_DONE получаем уведомление, что железяка воспроизвела нотки и можно в нее пушить дальше. Но это не точно.


            1. Melanchall Автор
              08.02.2022 15:57

              Так сработает, но только в простом сценарии. Так можно сделать, отсылая system exclusive события (и я так делаю), либо же собирая буфер для одного события. Но если мы будем так отсылать несколько событий, то потеряем всякую возможность прикручивать в наш API дополнительные возможности.

              Как я и сказал, API моей библиотеки позволяет указывать колбэки на воспроизведение события. В этих колбэках можно что-то делать с событием, которое готовится к воспроизведению, например, вообще поменять событие. Есть колбэки на ноты (а это уже пара событий). Плюс есть обычные .NET-события на классе Playback, есть закольцовывание (причём можно не на всю последовательность событий, а на временной диапазон), прыжки по временной шкале плейбека с разными включаемыми штуками (типа слежения ухода с ноты/входа на ноту). Я оперирую только самыми простыми MIDI-функциями операционной системы, дабы дать себе простор для модификации алгоритма, встраивания новых возможностей и т.д. Сам генератор тиков (таймер), который драйвит плейбек, можно менять, хоть свой подставить. По умолчанию в Windows библиотека использует мультимедийный таймер. С этим были проблемы в Unity до недавних времён (косяк со стороны Unity, который они-таки закрыли), но сейчас и там всё замечательно.

              Более того, файлы MIDI могут содержать так называемые мета-события, которые не могут быть обработаны устройствами и существуют только в файлах. Приведённые по ссылке методы откинут ошибку и никакого MOM_DONE мы не получим. Я же такие события тоже "воспроизвожу". Ну т.е. в методы устройств они, разумеется, не передаются, но посредством событий от класса Playback можно на них как-то реагировать. По сути Playback по некоторым функциям приближается уже к секвенсору, но это в далёких планах :)


  1. dead_man
    07.02.2022 17:45

    спасибо за статью, очень интересно и грамотно всё расписано.

    есть вопрос по поводу реализации MIDI проигрывателя. я как-то делал свой, только для MS-DOS, и опирался там на время, которое прошло между последней обработкой событий и текущим. почему тут нельзя было реализовать аналогичным образом?


    1. Melanchall Автор
      07.02.2022 18:09

      Если я вас правильно понял, то, если на обработке какого-либо события будет задержка, поплывут все последующие события. Таймер и точные метки времени на каждом событии исключают такую ситуацию. Даже если будет лаг, он не повлияет на последующие события.


      1. dead_man
        07.02.2022 18:14

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


        1. Melanchall Автор
          07.02.2022 18:37

          Можете, пожалуйста, каким-нибудь псевдокодом показать вашу идею?


          1. dead_man
            07.02.2022 18:43

            без проблем)

            DateTime lastTime;
            
            Timer timer = new Timer();
            timer.Elapsed += Timer_Elapsed;
            timer.Interval = 15; // Мы знаем что меньше 15мс ставить бессмысленно
            lastTime = DateTime.UtcNow;
            timer.Start();
            
            private void Timer_Elapsed(object sender, ElapsedEventArgs e)
            {
                var dt = DateTime.UtcNow;
                int diff = (int)((dt - lastTime).TotalMilliseconds);
                
                // обрабатываем события за прошедшее время diff миллисекунд
                
                lastTime = dt;
            }
            


            1. Melanchall Автор
              07.02.2022 19:32

              Спасибо. Обычный таймер и интервал 15 мс не подходят. Меньше ставить не бессмысленно. Чем больше заданный интервал, тем большее максимальное реальное значение интервала вы можете получить.

              У меня в библиотеке можно указывать любой таймер, какой захотите для воспроизведения. И есть юнит-тесты, проверяющие точность воспроизведения с разными вариантами. И вот для обычного таймера пришлось поставить погрешность аж в 50 мс, настолько всё с ним плохо:

              [Test]
              public void CheckPlayback_RegularPrecisionTickGenerator()
              {
                  CheckPlayback_TickGenerator(() => new RegularPrecisionTickGenerator(), TimeSpan.FromMilliseconds(50));
              }

              Ну т.е. иногда может быть всё хорошо, иногда не очень, причём сильно не очень. Если бы я делал программное решение для себя, я бы не стал в такие дебри лезть. Но проект публичный, в мире .NET + MIDI уже узнаваемый, и мне очень хочется, чтобы у людей всё работало из коробки хорошо, работало в разных окружениях и с разными фреймворками.


  1. Interreto
    07.02.2022 17:58

    Любопытно, как это будет работать на .net core под macOS?


    1. Melanchall Автор
      07.02.2022 18:07
      +1

      Обязательно будет статья по macOS, ибо API воспроизведения в библиотеке реализован для Windows и macOS (через нативную прослойку под каждую платформу).


  1. mr_n78327832
    08.02.2022 00:36
    -1

    while(true){

    Thread.Sleep(1);

    }

    Так не будет работать?


    1. Alexx999
      08.02.2022 01:13

      По умолчанию в Windows сон потока происходит теми же 15.6мс квантами, так что скорее всего Sleep(1) приведет к засыпанию на 15.6мс. А начиная с Win10 2004 - это прямо гарантировано, если не повысить разрешение системного таймера для своего процесса.