Ранее мы уже посмотрели, чем нам может помочь .NET в реализации задачи, обозначенной в теме статьи, в Windows. Сейчас рассмотрим её решение в macOS. В этой статье не будет длинных предисловий, для чего может быть нужен таймер с интервалом 1 мс. В своей библиотеке DryWetMIDI я использую таймер в роли “двигателя” для воспроизведения MIDI-данных, вы можете прочитать об этом во вступительном тексте предыдущей статьи. Данный механизм реализован сейчас для Windows и macOS. Статью по *nix, увы, ждать в ближайшее время не стоит. К слову, в прошлой статье я писал о том, что некоторые видеоплееры повышают разрешение системного таймера, а это свидетельствует о том, что они используют аналогичный моему подход с таймером для воспроизведения данных. В комментариях заметили (в принципе, весьма справедливо), что я делаю ложный вывод об использовании таймера этими продуктами, и правильный подход – отдавать драйверу буфер и ожидать вызов коллбэка на завершение воспроизведения буфера. Я написал на форум VLC, дабы уточнить, как в этом плеере устроено воспроизведение видео. Вот ответ: VLC 3.0 currently compare the media time against a media clock whose parameters are generated from the media timing information. When the clock says that the media time associated with a frame has been reached, it renders the frame. So in a sense, it's "like" timers yes, and not tickless or event-driven. Т.е. как минимум VLC всё же использует “таймероподобный” подход. К сожалению, я не являюсь обладателем компьютера под управлением macOS, поэтому все тесты были выполнены на виртуальных машинах Azure Pipelines пула Microsoft, версия ОС 11.0.0. Да, это не совсем честно, но что поделать. Если у кого-то есть желание и время выполнить проверки на реальном железе, я был бы очень благодарен. Кроме того, любопытно, были бы разными результаты на системах с процессорами Intel и Apple. Дабы статья всё-таки была самодостаточной без необходимости всё время прыгать к предыдущей, приведу ещё раз код для выполнения тестов различных таймеров. Тем более, есть некоторые отличия. Интерфейс таймера без изменений: А вот код для замера срабатывания таймера стал чуть длиннее. В прошлый раз я не озаботился сразу параллельным снятием нагрузки на процессор во время работы таймера и добавил соответствующие графики уже после публикации статьи. Здесь же загрузка CPU будет замеряться вместе с работой таймера: Т.е. вместе с запуском тестируемого таймера снимаем каждые 500 мс (чего вполне достаточно) загрузку процессора. И дабы исключить влияние многоядерности на результаты, значения нормированы к одному ядру. Это значит, что графики показывают загрузку, как если бы в системе был одноядерный процессор. Поэтому значение 100%, например, означает полную загрузку одного ядра, а не всего процессора. Реальная нагрузка в рамках всей системы была бы получена делением значений на Environment.ProcessorCount. Как и раньше, проверяем работу таймеров в течение 3 минут для интервалов 1, 10 и 100 мс. Графики дельт между срабатываниями таймера теперь будут расширены параллельными графиками загрузки CPU: Как и в прошлой статье, ссылки на код, данные и графики приведены в конце. И снова начнём с нестареющей классики – бесконечный цикл: Если мы запустим проверку таймера, получим такие результаты: Таймер ожидаемо точный. И ожидаемо плохой, ибо процессор пребывает в грусти от данной ему задачи. А мы грустим вместе с ним, особенно, глядя на пиковые значения дельт между срабатываниями таймера. Кстати, занимательно, как задержки срабатывания таймера совпадают с разгрузкой CPU. Рассмотрим теперь некоторые модификации данного таймера. В прошлой статье несколько людей высказались о том, что добавление Thread.Yield в цикл разгружает процессор, оставляя высокую точность. Что ж, тело цикла теперь такое: А вот результаты: Графики танцуют, поднимая настроение. Но ненадолго, ибо, очевидно, лучше не стало. А как на счёт обычного “сна” на период, равный интервалу таймера? Сделаем метод Start таким: Запускаем: Процессору полегчало, но точность стала заметно хуже. Среднее значение, особенно на интервалах 10 и 100 мс, поражает своим отклонением от запрошенного интервала. А на максимальные значения страшно смотреть – дельты в 250 мс для интервала 100 мс это нехорошо. Но что если поставить потоку приоритет повыше? Сделаем это при создании потока: Графики: Всё то же самое. Во многих отношениях то, что macOS основана на Unix, несёт множество приятных вещей. Например, то, что ОС реализует интерфейс POSIX. Этот интерфейс нам сейчас пригодится. Любопытства ради мы посмотрим, будут ли отличия в срабатывании таймера, если он будет основан не на Thread.Sleep, а на nanosleep. Разумеется, нам нужно написать нативную библиотеку для этой цели. Для этого я буду использовать обычный C: Здесь всё просто: создаём поток (снова привет POSIX), внутри которого крутим цикл с nanosleep. Затем собираем этот код в динамическую библиотеку InfiniteLoopTimerWithNanosleep.dylib с помощью gcc. Код на C# будет таким: Время запустить наш таймер: Как видим, спать можно хоть через Thread.Sleep, хоть через nanosleep, суть не меняется. macOS, также как и Windows, не является операционной системой реального времени. Однако, если мы заглянем в документ Mach Scheduling and Thread Interfaces про планирование потоков в ОС, в самом же начале увидим таблицу Thread priority bands. А в ней такую строку: Real-time threads | threads whose priority is based on getting a well-defined fraction of total clock cycles, regardless of other activity (in an audio player application, for example). Т.е. macOS позволяет задавать потоку приоритет реального времени, что в теории должно заставить работать наш цикл максимально точно. И дабы не присваивать себе эту находку, скажу, что мне про это подсказали в моём же вопросе на StackOverflow. Последнее обновление документа датируется августом 2013-го года, но механизмы, описанные там, актуальны до сих пор. Ниже упомянутой таблицы мы найдём информацию о том, как выставить потоку нужный приоритет: If you need real-time behavior, you must use the Mach thread_policy_set call. This is described in Using the Mach Thread API to Influence Scheduling. Мы напишем такую функцию: Самое важное здесь – поля структуры thread_time_constraint_policy. Описание этих полей я нашёл тут (внезапно, не в документации Apple): period: This is the nominal amount of time between separate processing arrivals, specified in absolute time units. A value of 0 indicates that there is no inherent periodicity in the computation. computation: This is the nominal amount of computation time needed during a separate processing arrival, specified in absolute time units. constraint: This is the maximum amount of real time that may elapse from the start of a separate processing arrival to the end of computation for logically correct functioning, specified in absolute time units. Must be (>= computation). Note that latency = (constraint - computation). Как я понимаю это описание: period: Период, с которым происходят вычисления (выполнение действий на тик таймера в нашем случае). computation: Предполагаемое время выполнения вычислений. constraint: Максимальное время выполнения вычислений. В моём коде установлены такие значения этих полей: period = 1 мс computation = 0.1 мс constraint = 0.5 мс Для демонстрации эти числа вполне сойдут. Для простоты период я задал костантный 1 мс. Функции по созданию таймера остаются прежние. Единственное изменение – вызов SetRealtimePriority в TimerThreadRoutine: Код на C# приводить не буду, он аналогичный предыдущему (за исключением имени нативной библиотеки). Так что сразу результаты: Приоритет реального времени творит чудеса. Процессор испытывает некоторую ненулевую нагрузку, но весьма небольшую, снижающуюся при увеличении периода таймера. К сожалению, в Windows таких возможностей нет (или я плохо искал). А мы переходим к встроенным в .NET классам таймеров. Первый такой пациент – System.Timers.Timer. Код нашего таймера: Проверяем: Разброс значений огромный, а загрузка процессора при этом не лучше, чем у отличного предыдущего варианта. Однозначно плохой таймер. Ещё один кандидат на звание лучшего таймера – System.Threading.Timer: Результаты проверки: В целом всё то же самое, что и в случае System.Timers.Timer. Нагрузка на процессор в случае интервала 1 мс даже выше – более 4%. В корзину. В macOS есть интересный механизм – run loops. Если кратко – это цикл обработки событий. Такой цикл автоматически создаётся для основного потока любого приложения. Но если мы создаём в рамках приложения дополнительный поток и хотим запустить в нём цикл обработки, нужно использовать соответствующий API. В документе (в разделе When Would You Use a Run Loop?) приводятся примеры, когда мы можем захотеть запустить такой цикл: •  Use ports or custom input sources to communicate with other threads.•  Use timers on the thread.•  Use any of the performSelector… methods in a Cocoa application.•  Keep the thread around to perform periodic tasks. Нам тут интересен второй пункт, мы как раз создаём таймер внутри потока. Для тестирования таймера на основе циклов обработки событий я немного поменяю подход. Не то чтобы это требовалось для демонстрации, но это, считаю, требуется в реальности. В дикой природе программного мира одновременно может быть запущено несколько таймеров даже внутри одного приложения. Если мы будем создавать на каждый таймер новый поток, мы, очевидно, будем увеличивать нагрузку на планировщик потоков ОС. А потому предлагаю создавать один неумирающий (в течение жизни приложения) поток, готовый к тому, чтобы запускать на нём таймеры. Приведу сначала код на C: Структура TimerSessionHandle будет как раз содержать информацию о глобальной “сессии” приложения. Данные сессии будут использоваться при создании новых таймеров. Внутри TimerSessionThreadRoutine запускается таймер с интервалом 60 секунд. Задача этого таймера – поддерживать жизнь в цикле обработки. Потому что если цикл не содержит ни одного источника событий (а таймер является таковым), то он завершается, а с ним завершится и наш поток. Можно было бы и другой источник событий поместить в цикл, но я выбрал такой вариант. После настройки цикла обработки и помещения в него фейкового таймера вызывается функция CFRunLoopRun, которая, собственно, запускает бесконечный цикл. Сами таймеры создаются с помощью функции CFRunLoopAddTimer. Время .NET: Запустим сиё чудо программной мысли: Столько интересного кода дали нам не такие уж интересные результаты. Графики ничем не лучше тех, которые мы видели для созданных нами ранее простейших таймеров на циклах со сном. Не будем сдаваться. Переведём наш поток в режим реального времени, как мы это делали ранее для nanosleep. Для этого добавим в TimerSessionThreadRoutine вызов SetRealtimePriority после добавления фейкового таймера: Код на C#, очевидно, тот же, так что сразу запустим проверку таймера: Глаз радуется таким картинам. Приоритет реального потока снова выручил нас. И хотя результаты аналогичны полученным для nanosleep с тем же приоритетом потока, вариант с циклами обработки мне видится предпочтительнее по одной простой причине – тут уже реализованы все необходимые механизмы управления таймерами. В случае с самописным циклом нам нужно разрабатывать, как минимум, такие вещи: контроль срабатывания нескольких таймеров в едином цикле; контроль времени сна в зависимости от времени выполнения полезной нагрузки. А если всё это уже готово в системных библиотеках, не лучше ли воспользоваться ими? Есть желание немного снизить нагрузку на процессор. В документе Minimize Timer Use предлагается устанавливать допуск (tolerance) для таймеров: Specify a tolerance for the accuracy of when your timers fire. The system will use this flexibility to shift the execution of timers by small amounts of time—within their tolerances—so that multiple timers can be executed at the same time. Using this approach dramatically increases the amount of time that the processor spends idling while users detect no change in system responsiveness. Нужно это, как следует из приведённого выше абзаца, для объединения срабатываний нескольких таймеров. Аналогичная группировка таймеров, к слову, есть и в Windows (можно прочитать в документе Timers, Timer Resolution, and Development of Efficient Code, раздел Timer Coalescing). Причём заявляется, что таймер будет срабатывать в диапазоне , где i – запрошенный интервал, а t – допуск: After you specify a tolerance for a timer, it may fire anytime between its scheduled fire date and the scheduled fire date, plus the tolerance. Для наших таймеров на циклах обработки (run loops) мы должны вызвать функцию CFRunLoopTimerSetTolerance. Документация от Apple, как я уже подмечал в своей статье Введение в CoreMIDI, отличается своей информативностью. В общем, такое дополнение таймера выглядит полезным, а потому добавим в StartTimer вызов CFRunLoopTimerSetTolerance перед добавлением таймера: Значение 0.001 измеряется в секундах, так что допуск равен 1 мс. Код таймера на .NET опять же без изменений. Смотрим результаты: Внезапно таймеру стало очень плохо. Совсем не похоже, что таймер срабатывает в пределах допуска. К тому же нагрузка на процессор подскочила, а допуск затевался для обратного эффекта. Меня не покидают мысли, что я сделал какую-то глупую ошибку, но совершенно не понимаю, где. На всякий случай скажу, что мы дорабатывали предыдущий таймер, так что у потока по-прежнему приоритет реального времени. По этим графикам, разумеется, таймер стал непригодным для наших целей, так что установку допуска я в своей библиотеке не делал. Если кратко: сделать точный таймер в macOS на .NET возможно. Стоит, конечно, взять в кавычки .NET, ибо, как и в Windows, приходится оперировать системными функциями. Но цель достигнута. В своей библиотеке я использую циклы обработки событий (run loops) для создания таймеров. Если кому-то очень хочется, можно написать свой аналогичный механизм с полным контролем над ним. Однако нативный код всё равно понадобится для установки приоритета реального времени. Заканчивая статью, хотелось бы задать безответный вопрос: если возможно сделать точный таймер что в Windows, что в macOS (вероятно, и в *nix), почему .NET не предоставляет встроенных средств для этого? Уверен, что инженеры Microsoft отличные специалисты в проектировании API, и есть уважительная причина отсутствия соответствующего класса в BCL. Но от меня эта причина ускользает. Всем спасибо. Как и обещал, привожу ссылки: код солюшна с тестовым кодом; код нативных библиотек; результаты с виртуальных машин Azure Pipelines: дельты; CPU; графики по результатам с виртуальных машин Azure Pipelines; код солюшна построения графиков.  Desktop software developer