Pull to refresh

Comments 83

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

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

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

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

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

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

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

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

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

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

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

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

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

Здравствуйте. Уточните, пожалуйста, какой код вы используете? Если не сложно, прям код таймера для проверки. Я взял тот код, что привёл в статье, добавил 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();
}

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

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

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


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

А 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

while(true) {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

UFO just landed and posted this here

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

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

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

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

Здравствуйте, тема знакомая - довелось столкнуться с таймингом в .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 значение как и раньше.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Starting with Windows 11, if a window-owning process becomes fully occluded, minimized, or otherwise invisible or inaudible to the end user, Windows does not guarantee a higher resolution than the default system resolution. See SetProcessInformation for more information on this behavior.

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

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

Классический подход при воспроизведении мультимедийных данных – раз в 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

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

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

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

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

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

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

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

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

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

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

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

сомнительно, что 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 аудио рендер, но это уже совсем другая история (с)

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

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

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

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

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

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

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

На самом деле, насколько я понимаю, MIDI события можно также стримить при помощи этого API https://docs.microsoft.com/en-us/windows/win32/multimedia/stream-buffers и при необходимости получать callback'и. Кажется, в случае именно воспроизведения, это больше подходит для использования, нежели таймеры.

Колбэк будет на буфер, а не на отдельные события. Со стриминговым API невозможно провернуть всё то, что я описал выше.

Судя по https://docs.microsoft.com/en-us/windows/win32/api/mmeapi/ns-mmeapi-midievent MEVT_F_CALLBACK The system generates a callback when the event is about to be executed, то есть именно перед выполнением очередного MIDI-события.
К сожалению, не в курсе, что у вас за приложение, поэтому, вероятно от меня ускользает смысл обработки каждого индивидуального события в реальном времени, вместо того, чтобы заранее подготавливать буферы событий небольшой продолжительности.

Не знал про такое, спасибо. Однако мне не подойдёт всё равно. Насколько я понял, колбэк будет вызван просто в качестве уведомления. Я же в своём API позволяю пользователю написать колбэк, который может менять событие, которое готовится к воспроизведению, или вовсе отменить его. Кроме того, можно менять скорость воспроизведения, и, как я и писал выше, есть колбэки на ноты = пары событий.

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

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

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

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

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

без проблем)

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;
}

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

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

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

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

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

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

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

Огромное спасибо всем, кто не прошёл мимо и оставил полезные комментарии! Я дополнил статью графиками загрузки CPU, а также проверил некоторые варианты модификации бесконечного цикла. Такие дополнения выделены так:

EDIT ────────

...

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

Sign up to leave a comment.

Articles