Таймер в .NET с интервалом 1 мс. 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.

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

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

Интерфейс таймера без изменений:

using System;

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

А вот код для замера срабатывания таймера стал чуть длиннее. В прошлый раз я не озаботился сразу параллельным снятием нагрузки на процессор во время работы таймера и добавил соответствующие графики уже после публикации статьи. Здесь же загрузка CPU будет замеряться вместе с работой таймера:

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 TimeSpan CpuMeasurementInterval = TimeSpan.FromMilliseconds(500);
        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($"CPUs: {Environment.ProcessorCount}");
            Console.WriteLine("--------------------------------");

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

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

        private static void CheckInterval(ITimer timer, int intervalMs)
        {
            var times = new List((int)Math.Ceiling(MeasurementDuration.TotalMilliseconds));
            var cpuUsage = new List((int)Math.Ceiling(MeasurementDuration.TotalMilliseconds / CpuMeasurementInterval.TotalMilliseconds));
            
            var stopwatch = new Stopwatch();
            
            var cpuTimer = new System.Timers.Timer(CpuMeasurementInterval.TotalMilliseconds);
            var cpuStep = 0;

            var cpuUsageStopwatch = new Stopwatch();
            var startCpuUsage = TimeSpan.Zero;
            var startTime = 0L;

            cpuTimer.Elapsed += (_, _) =>
            {
                if (cpuStep++ > 0)
                {
                    var cpuUsedMs = (float)(Process.GetCurrentProcess().TotalProcessorTime - startCpuUsage).TotalMilliseconds;
                    var totalMsPassed = cpuUsageStopwatch.ElapsedMilliseconds - startTime;
                    var cpuUsageTotal = cpuUsedMs / totalMsPassed;
                    cpuUsage.Add(cpuUsageTotal * 100);
                }

                startTime = cpuUsageStopwatch.ElapsedMilliseconds;
                startCpuUsage = Process.GetCurrentProcess().TotalProcessorTime;

            };
            
            Action callback = () => times.Add(stopwatch.ElapsedMilliseconds);

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

            Thread.Sleep(MeasurementDuration);

            timer.Stop();
            stopwatch.Stop();
            cpuTimer.Stop();
            
            File.WriteAllLines($"cpu_{intervalMs}.txt", cpuUsage.ToArray().Select(u => u.ToString("0.##")));

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

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

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

Т.е. вместе с запуском тестируемого таймера снимаем каждые 500 мс (чего вполне достаточно) загрузку процессора. И дабы исключить влияние многоядерности на результаты, значения нормированы к одному ядру. Это значит, что графики показывают загрузку, как если бы в системе был одноядерный процессор. Поэтому значение 100%, например, означает полную загрузку одного ядра, а не всего процессора. Реальная нагрузка в рамках всей системы была бы получена делением значений на Environment.ProcessorCount.

Как и раньше, проверяем работу таймеров в течение 3 минут для интервалов 1, 10 и 100 мс. Графики дельт между срабатываниями таймера теперь будут расширены параллельными графиками загрузки CPU:

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

Как и в прошлой статье, ссылки на код, данные и графики приведены в конце.

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

И снова начнём с нестареющей классики — бесконечный цикл:

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

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

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

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

Рассмотрим теперь некоторые модификации данного таймера.

Thread.Yield

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

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

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

А вот результаты:

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

Графики танцуют, поднимая настроение. Но ненадолго, ибо, очевидно, лучше не стало.

Thread.Sleep

А как на счёт обычного «сна» на период, равный интервалу таймера? Сделаем метод Start таким:

public void Start(int intervalMs, Action callback)
{
    var thread = new Thread(() =>
    {
        _running = true;

        while (_running)
        {
            Thread.Sleep(intervalMs);
            callback();
        }
    });

    thread.Start();
}

Запускаем:

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

Процессору полегчало, но точность стала заметно хуже. Среднее значение, особенно на интервалах 10 и 100 мс, поражает своим отклонением от запрошенного интервала. А на максимальные значения страшно смотреть — дельты в 250 мс для интервала 100 мс это нехорошо.

Thread.Sleep + highest priority

Но что если поставить потоку приоритет повыше? Сделаем это при создании потока:

{ Priority = ThreadPriority.Highest }

Графики:

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

Всё то же самое.

nanosleep

Во многих отношениях то, что macOS основана на Unix, несёт множество приятных вещей. Например, то, что ОС реализует интерфейс POSIX. Этот интерфейс нам сейчас пригодится. Любопытства ради мы посмотрим, будут ли отличия в срабатывании таймера, если он будет основан не на Thread.Sleep, а на nanosleep. Разумеется, нам нужно написать нативную библиотеку для этой цели. Для этого я буду использовать обычный C:

#include 
#include 
#include 

typedef struct
{
    pthread_t thread;
    char active;
    int intervalMs;
    void (*callback)(void);
} TimerInfo;

void* TimerThreadRoutine(void* data)
{
    TimerInfo* timerInfo = (TimerInfo*)data;
    timerInfo->active = 1;

    while (timerInfo->active == 1)
    {
        struct timespec req;
        req.tv_sec = 0;
        req.tv_nsec = timerInfo->intervalMs * 1000000;
        nanosleep(&req, NULL);
        
        timerInfo->callback();
    }

    return NULL;
}

void StartTimer(int intervalMs, void (*callback)(void), TimerInfo** info)
{
    TimerInfo* timerInfo = malloc(sizeof(TimerInfo));
    timerInfo->callback = callback;
    timerInfo->intervalMs = intervalMs;
	
    timerInfo->active = 0;
    pthread_create(&timerInfo->thread, NULL, TimerThreadRoutine, timerInfo);
    while (timerInfo->active == 0) { }
	
    *info = timerInfo;
}

void StopTimer(TimerInfo* timerInfo)
{
    timerInfo->active = 0;
}

Здесь всё просто: создаём поток (снова привет POSIX), внутри которого крутим цикл с nanosleep. Затем собираем этот код в динамическую библиотеку InfiniteLoopTimerWithNanosleep.dylib с помощью gcc.

Код на C# будет таким:

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

namespace InfiniteLoopTimerWithNanosleep
{
    internal sealed class Timer : ITimer
    {
        private delegate void TimerCallback();

        [DllImport("InfiniteLoopTimerWithNanosleep.dylib", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
        private static extern void StartTimer(int intervalMs, TimerCallback callback, out IntPtr info);

        [DllImport("InfiniteLoopTimerWithNanosleep.dylib", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
        private static extern void StopTimer(IntPtr info);

        private IntPtr _timerInfo;
        private Action _callback;
        private TimerCallback _timerCallback;

        public void Start(int intervalMs, Action callback)
        {
            _callback = callback;
            _timerCallback = OnTimerTick;
            StartTimer(intervalMs, _timerCallback, out _timerInfo);
        }

        public void Stop()
        {
            StopTimer(_timerInfo);
        }

        private void OnTimerTick()
        {
            _callback();
        }
    }
}

Время запустить наш таймер:

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

Как видим, спать можно хоть через Thread.Sleep, хоть через nanosleep, суть не меняется.

nanosleep + real-time priority

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.

Мы напишем такую функцию:

void SetRealtimePriority()
{
    mach_timebase_info_data_t timebase;
    mach_timebase_info(&timebase);

    struct thread_time_constraint_policy constraintPolicy;

    constraintPolicy.period = 1000 * 1000 * timebase.denom / timebase.numer;
    constraintPolicy.computation = 100 * 1000 * timebase.denom / timebase.numer;
    constraintPolicy.constraint = 500 * 1000 * timebase.denom / timebase.numer;
    constraintPolicy.preemptible = FALSE;

    thread_port_t threadId = pthread_mach_thread_np(pthread_self());
    thread_policy_set(threadId, THREAD_TIME_CONSTRAINT_POLICY, (thread_policy_t)&constraintPolicy, THREAD_TIME_CONSTRAINT_POLICY_COUNT);
}

Самое важное здесь — поля структуры 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:

void* TimerThreadRoutine(void* data)
{
    TimerInfo* timerInfo = (TimerInfo*)data;
	
    SetRealtimePriority();
    timerInfo->active = 1;

    while (timerInfo->active == 1)
    {
        struct timespec req;
        req.tv_sec = 0;
        req.tv_nsec = timerInfo->intervalMs * 1000000;
	   nanosleep(&req, NULL);
        
        timerInfo->callback();
    }

    return NULL;
}

Код на C# приводить не буду, он аналогичный предыдущему (за исключением имени нативной библиотеки). Так что сразу результаты:

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

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

К сожалению, в Windows таких возможностей нет (или я плохо искал). А мы переходим к встроенным в .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 мс

Разброс значений огромный, а загрузка процессора при этом не лучше, чем у отличного предыдущего варианта. Однозначно плохой таймер.

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. Нагрузка на процессор в случае интервала 1 мс даже выше — более 4%. В корзину.

Run loop timer

В 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:

#include 
#include 
#include 
#include 

typedef struct
{
    pthread_t thread;
    char active;
    CFRunLoopRef runLoopRef;
} TimerSessionHandle;

typedef struct
{
    void (*callback)(void);
    CFRunLoopTimerRef timerRef;
} TimerInfo;

void EmptyCallback(CFRunLoopTimerRef timer, void *info)
{
}

void* TimerSessionThreadRoutine(void* data)
{
    TimerSessionHandle* sessionHandle = (TimerSessionHandle*)data;

    CFRunLoopTimerContext context = { 0, NULL, NULL, NULL, NULL };
    CFRunLoopTimerRef timerRef = CFRunLoopTimerCreate(
        NULL,
        CFAbsoluteTimeGetCurrent() + 60,
        60,
        0,
        0,
        EmptyCallback,
        &context);

    CFRunLoopRef runLoopRef = CFRunLoopGetCurrent();
    CFRunLoopAddTimer(runLoopRef, timerRef, kCFRunLoopDefaultMode);

    sessionHandle->active = 1;
    sessionHandle->runLoopRef = runLoopRef;

    CFRunLoopRun();
    return NULL;
}

void OpenTimerSession(void** handle)
{
    TimerSessionHandle* sessionHandle = malloc(sizeof(TimerSessionHandle));

    sessionHandle->active = 0;
    pthread_create(&sessionHandle->thread, NULL, TimerSessionThreadRoutine, sessionHandle);
    while (sessionHandle->active == 0) { }
    
    *handle = sessionHandle;
}

void TimerCallback(CFRunLoopTimerRef timer, void *info)
{
    TimerInfo* timerInfo = (TimerInfo*)info;
    timerInfo->callback();
}

void StartTimer(int intervalMs, TimerSessionHandle* sessionHandle, void (*callback)(void), TimerInfo** info)
{
    TimerInfo* timerInfo = malloc(sizeof(TimerInfo));
    timerInfo->callback = callback;
    
    double seconds = (double)intervalMs / 1000.0;
    
    CFRunLoopTimerContext context = { 0, timerInfo, NULL, NULL, NULL };
    timerInfo->timerRef = CFRunLoopTimerCreate(
        NULL,
        CFAbsoluteTimeGetCurrent() + seconds,
        seconds,
        0,
        0,
        TimerCallback,
        &context);
    CFRunLoopAddTimer(sessionHandle->runLoopRef, timerInfo->timerRef, kCFRunLoopDefaultMode);
    
    *info = timerInfo;
}

void StopTimer(TimerSessionHandle* sessionHandle, TimerInfo* timerInfo)
{
    CFRunLoopRemoveTimer(sessionHandle->runLoopRef, timerInfo->timerRef, kCFRunLoopDefaultMode);
}

Структура TimerSessionHandle будет как раз содержать информацию о глобальной «сессии» приложения. Данные сессии будут использоваться при создании новых таймеров.

Внутри TimerSessionThreadRoutine запускается таймер с интервалом 60 секунд. Задача этого таймера — поддерживать жизнь в цикле обработки. Потому что если цикл не содержит ни одного источника событий (а таймер является таковым), то он завершается, а с ним завершится и наш поток. Можно было бы и другой источник событий поместить в цикл, но я выбрал такой вариант.

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

Время .NET:

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

namespace RunLoopTimer
{
    internal sealed class Timer : ITimer
    {
        private delegate void TimerCallback();

        [DllImport("RunLoopTimer.dylib", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
        private static extern void OpenTimerSession(out IntPtr handle);

        [DllImport("RunLoopTimer.dylib", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
        private static extern void StartTimer(int intervalMs, IntPtr sessionHandle, TimerCallback callback, out IntPtr info);

        [DllImport("RunLoopTimer.dylib", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
        private static extern void StopTimer(IntPtr sessionHandle, IntPtr info);

        private readonly IntPtr _sessionHandle;

        private IntPtr _timerInfo;
        private Action _callback;
        private TimerCallback _timerCallback;

        public Timer()
        {
            OpenTimerSession(out _sessionHandle);
        }

        public void Start(int intervalMs, Action callback)
        {
            _callback = callback;
            _timerCallback = OnTimerTick;
            StartTimer(intervalMs, _sessionHandle, _timerCallback, out _timerInfo);
        }

        public void Stop()
        {
            StopTimer(_sessionHandle, _timerInfo);
        }

        private void OnTimerTick()
        {
            _callback();
        }
    }
}

Запустим сиё чудо программной мысли:

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

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

Real-time priority

Не будем сдаваться. Переведём наш поток в режим реального времени, как мы это делали ранее для nanosleep. Для этого добавим в TimerSessionThreadRoutine вызов SetRealtimePriority после добавления фейкового таймера:

CFRunLoopAddTimer(runLoopRef, timerRef, kCFRunLoopDefaultMode);
SetRealtimePriority();

Код на C#, очевидно, тот же, так что сразу запустим проверку таймера:

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

Глаз радуется таким картинам. Приоритет реального потока снова выручил нас. И хотя результаты аналогичны полученным для nanosleep с тем же приоритетом потока, вариант с циклами обработки мне видится предпочтительнее по одной простой причине — тут уже реализованы все необходимые механизмы управления таймерами. В случае с самописным циклом нам нужно разрабатывать, как минимум, такие вещи:

  • контроль срабатывания нескольких таймеров в едином цикле;

  • контроль времени сна в зависимости от времени выполнения полезной нагрузки.

А если всё это уже готово в системных библиотеках, не лучше ли воспользоваться ими?

Tolerance

Есть желание немного снизить нагрузку на процессор. В документе 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, i+t], где 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 перед добавлением таймера:

CFRunLoopTimerSetTolerance(timerInfo->timerRef, 0.001);
CFRunLoopAddTimer(sessionHandle->runLoopRef, timerInfo->timerRef, kCFRunLoopDefaultMode);

Значение 0.001 измеряется в секундах, так что допуск равен 1 мс. Код таймера на .NET опять же без изменений. Смотрим результаты:

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

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

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

Итоги

Если кратко: сделать точный таймер в macOS на .NET возможно. Стоит, конечно, взять в кавычки .NET, ибо, как и в Windows, приходится оперировать системными функциями. Но цель достигнута.

В своей библиотеке я использую циклы обработки событий (run loops) для создания таймеров. Если кому-то очень хочется, можно написать свой аналогичный механизм с полным контролем над ним. Однако нативный код всё равно понадобится для установки приоритета реального времени.

Заканчивая статью, хотелось бы задать безответный вопрос: если возможно сделать точный таймер что в Windows, что в macOS (вероятно, и в *nix), почему .NET не предоставляет встроенных средств для этого? Уверен, что инженеры Microsoft отличные специалисты в проектировании API, и есть уважительная причина отсутствия соответствующего класса в BCL. Но от меня эта причина ускользает.

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

© Habrahabr.ru