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

Зачастую в нашей программе, возникает необходимость обновление какой-либо информации c определенным временным интервалом. В моем случаи это было обновление снапшотов (изображений) с ip камер. Зачастую бизнес логика приложения устанавливает перед нами определенные ограничения частоты обновления данных. Для это время составляет 1 секунда.
Решение в лоб — это установить Thread.Sleep(1000)/Task.Await(1000) после запроса снапшота.

static void Getsnapshot()
{
  var rnd = new Random()
  var sleepMs = rnd.Next(0, 1000);
  Console.WriteLine($"[{DateTime.Now.ToString("mm:ss.ff")}] DoSomethink {sleepMs} ms");
  Thread.Sleep(sleepMs);
}

while (true)
{
  Getsnapshot();
  Thread.Sleep(1000);
}

Но срок выполнения нашей операции — недетерминированная величина. Поэтому имитация взятия снапшота выглядит примерно так:

Запустим наше программу и запустим вывод

[15:10.39] DoSomethink 974 ms
[15:12.39] DoSomethink 383 ms
[15:13.78] DoSomethink 99 ms
[15:14.88] DoSomethink 454 ms
[15:16.33] DoSomethink 315 ms
[15:17.65] DoSomethink 498 ms
[15:19.15] DoSomethink 708 ms
[15:20.86] DoSomethink 64 ms
[15:21.92] DoSomethink 776 ms
[15:23.70] DoSomethink 762 ms
[15:25.46] DoSomethink 123 ms
[15:26.59] DoSomethink 36 ms
[15:27.62] DoSomethink 650 ms
[15:29.28] DoSomethink 510 ms
[15:30.79] DoSomethink 257 ms
[15:32.04] DoSomethink 602 ms
[15:33.65] DoSomethink 542 ms
[15:35.19] DoSomethink 286 ms
[15:36.48] DoSomethink 673 ms
[15:38.16] DoSomethink 749 ms

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

Например, нам нужно получить массив из 60 изображений за 1 минуту а мы получим только 49.

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

Например, нам нужно получить массив из 60 изображений за 1 минуту а мы получим 62.

Напрашивается очевидное решение. Замерить время до выполнения операции и после. И рассчитать их разницу.

while (true)
{
   int sleepMs = 1000; 
   var watch = Stopwatch.StartNew();
   watch.Start();
   Getsnapshot();
   watch.Stop();
   int needSleepMs = (int)(sleepMs - watch.ElapsedMilliseconds);
   Thread.Sleep(needSleepMs);
}

Запустим нашу программу теперь. Если Вам повезет вы увидите примерно следующие.

[16:57.25] DoSomethink 789 ms
[16:58.05] Need sleep 192 ms

[16:58.25] DoSomethink 436 ms
[16:58.68] Need sleep 564 ms

[16:59.25] DoSomethink 810 ms
[17:00.06] Need sleep 190 ms

[17:00.25] DoSomethink 302 ms
[17:00.55] Need sleep 697 ms

[17:01.25] DoSomethink 819 ms
[17:02.07] Need sleep 181 ms

[17:02.25] DoSomethink 872 ms
[17:03.13] Need sleep 128 ms

[17:03.25] DoSomethink 902 ms
[17:04.16] Need sleep 98 ms

[17:04.26] DoSomethink 717 ms
[17:04.97] Need sleep 282 ms

[17:05.26] DoSomethink 14 ms
[17:05.27] Need sleep 985 ms

Почему я написал если повезет? Потому что watch.Star() выполняется до DoSomethink() и watch.Stop() после DoSomethink(); Эти операции не мгновенны + сама среда выполнения не гарантирует точность времени исполнения программы (x). Поэтому будут существовать накладные расходы. Наша функция DoSomethink() выполняется от 0-1000 мс (y). Следовательно могут возникнуть ситуации когда x + y > 1000 в таких случаях

 int needSleepMs = (int)(sleepMs - watch.ElapsedMilliseconds);

будет принимать отрицательные значения и мы получить ArgumentOutOfRangeException так как метод Thread.Sleep() не должен принимать отрицательные значения.

В таких случаях имеет смысл установить время needSleepMs в 0;
На самом деле в реальности функция DoSomethink() может выполнятся сколь угодно долго и мы можем получить переполнение переменной при приведении к int. Тогда время нашего сна
может превысить sleepMs;

Можно исправить это следующим образом:

var needSleepMs = sleepMs - watch.ElapsedMilliseconds;
if (needSleepMs > 0 && watch.ElapsedMilliseconds <= sleepMs)
{
   needSleepMs = (int)needSleepMs;
}
else
{
  needSleepMs = 0; 
}
Thread.Sleep(needSleepMs);

В принципе все готово. Но использование подобного подхода даже в 1 месте вызывает дискомфорт для глаза программиста. А если таких мест в программе десятки то код превратится в нечитабельную кучу…

Чтобы исправить эту ситуацию инкапсулируем наш код в функцию. Тут можно убрать в отдельный класс либо и использовать как обычный метод к класс помойку Global и использовать как статический(мой вариант).

В нашем примере оставим для простоты оставим его в классе Programm

public static int NeedWaitMs(Action before, int sleepMs)
{
  var watch = Stopwatch.StartNew();
  watch.Start();
  before();
  watch.Stop();
  var needSleepMs = sleepMs - watch.ElapsedMilliseconds;
  if (needSleepMs > 0 && watch.ElapsedMilliseconds <= sleepMs) 
    return (int) needSleepMs;
  return 0;
 }

Наша функции на входе принимает ссылку на функцию которую необходимо выполнить и наше планируемое время ожидания. А возвращает время которое следует спать нашей программе.
Для удобства использования мы можем также передавать анонимные лямбда функции в нашу функцию.

Полный листинг программы приведен ниже:

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

namespace ConsoleApp2
{
    class Program
    {
        static void Getsnapshot()
        {
            var rnd = new Random();
            var sleepMs = rnd.Next(0, 1000);
            Console.WriteLine($"[{DateTime.Now.ToString("mm:ss.ff")}] DoSomethink {sleepMs} ms");
            Thread.Sleep(sleepMs);
        }

        static void Main(string[] args)
        {
            while (true)
            {
                var sleepMs = NeedWaitMs(Getsnapshot, 1000);
                Console.WriteLine($"[{DateTime.Now.ToString("mm:ss.ff")}] Need sleep {sleepMs} ms {Environment.NewLine}");
                Thread.Sleep(sleepMs);
            }
        }

        public static int NeedWaitMs(Action before, int sleepMs)
        {
            var watch = Stopwatch.StartNew();
            before();
            watch.Stop();
            var needSleepMs = sleepMs - watch.ElapsedMilliseconds;
            return needSleepMs > 0 ? (int) needSleepMs : 0;
        }
    }
}

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


  1. Stawros
    31.10.2019 17:37

    А чем таймеры не угодили?


    1. shai_hulud
      31.10.2019 17:52

      1. Stawros
        31.10.2019 18:21

        Ну по вашей же ссылке упоминаются Multimedia Timers, про которые Microsoft пишет, что они «with the greatest resolution (or accuracy) possible for the hardware platform». На том же SO есть пример обвязки для C#.


        1. shai_hulud
          31.10.2019 19:19

          Вместо этих p/invoke таймеров я бы бахнул свой тред с spin-wait (на промежутки меньше 3 мс) и Stopwatch и sleep(0) на большее время.


      1. rrust
        31.10.2019 18:34

        ага, на 2 секунды в сутки, а вам абсолютно точно нужно делать снимки раз в секунду что интервал в 1.000023 сек не пойдет?


        1. shai_hulud
          31.10.2019 19:17

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


      1. a-tk
        01.11.2019 22:15

        Эммм… А что тогда делает приведённое решение? Его ж будет из стороны в сторону носить по полной программе.

        PS: По теме статьи можно предположить, что интервал должен выдерживаться в среднем, что порождает несколько иное решение.


    1. rrust
      31.10.2019 17:53

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


  1. alex-khv
    31.10.2019 20:42

    А если сделать лаг в одну секунду и предзагружать изображения? Получится вывод почти точно каждую секунду.


  1. radium
    01.11.2019 00:51
    +3

    В этом фрагменте

        var needSleepMs = sleepMs - watch.ElapsedMilliseconds;
        if (needSleepMs > 0 && watch.ElapsedMilliseconds <= sleepMs) 
            return (int) needSleepMs;
        return 0;

    можно убрать сравнение watch.ElapsedMilliseconds <= sleepMs, так как если watch.ElapsedMilliseconds будет больше sleepMs, то needSleepMs будет меньше нуля, что уже отсекается условием needSleepMs > 0.

    По сути нам надо вернуть неотрицательное значение. Код
        var needSleepMs = sleepMs - watch.ElapsedMilliseconds;
        if (needSleepMs > 0) 
            return (int) needSleepMs;
        return 0;
    делает именно это. А дальше его можно сократить до return needSleepMs > 0 ? (int)needSleepMs : 0;.

    Если условие инвертировать, то желаемое поведение (получение неотрицательного значения) станет ещё более явным: return needSleepMs < 0 ? 0 : (int)needSleepMs;

    И второй момент — Stopwatch.StartNew() возвращает уже запущенный экземпляр и делать watch.Start() не обязательно.


    1. Gavamot Автор
      01.11.2019 06:05

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


      1. Chosen0ne
        01.11.2019 14:35

        Math.Max тоже подойдет, именно такая логика же здесь?


  1. Evengard
    01.11.2019 03:27
    +1

    Ох, вспомнилось как я синхронизировал видеопоток с аудиопотоком под Emscripten-ом на Сях… Только я когда отрицательную величину получал — я её сохранял и таки обнулял, чтоб в одной из следующих итераций вычесть из положительного значения. Ну и да, я использовал SDL_Delay — получалось точнее. Хотя по итогу всё равно дрифт получался, и пришлось синхронить по звуку…


  1. VanKrock
    01.11.2019 09:30
    +2

    На тасках это можно сделать немного проще

    class Program
    {
        static void Main(string[] args)
        {
            while(true)
            {
                Task.WaitAll(Task.Run(GetSnapshot), Task.Delay(1000));
            }
        }
    
        static void GetSnapshot()
        {
            var rnd = new Random();
            var sleepMs = rnd.Next(0, 1000);
            Console.WriteLine($"[{DateTime.Now:mm:ss.ff}] DoSomethink {sleepMs} ms");
            Task.Delay(sleepMs).Wait();
        }
    }
    


    1. Sitro23
      01.11.2019 12:46

      Такой код накапливает ошибку постоянно (около 0.1 с каждые 10 с).


      1. Gavamot Автор
        01.11.2019 12:47
        -1

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


        1. Sitro23
          01.11.2019 13:43

          Ваш код набегает на 0.01 с на 10 с. Хорошее решение!


        1. radium
          01.11.2019 13:58

          Можно не накапливать ошибку «плывя» по времени. Для этого нужно заложиться на источник текущего времени. Правда тут много подводных камней — см. habr.com/ru/post/146109

          Дело в том, что если решать задачу на разного рода sleep-ах / delay-ях — мы отвязаны от текущего времени и будем накапливать ошибку:
          00:00.00
          00:01.00
          00:02.01
          00:03.01
          ...
          00:58.42
          00:59.42
          01:00.43
          ...
          02:13.99
          02:15.00 <--- 14.99 + накопленная погрешность
          02:16.00
          02:17.01


          А если привязаться к реальному времени, то мы будем «болтаться» около реального значения с некоторой, естественной для не-RTOS операционки, погрешностью.
          00:00.12
          00:01.15
          00:02.07
          00:03.09
          ...
          00:58.14
          00:59.19
          01:00.04
          ...
          02:13.18
          02:14.23
          02:15.11
          02:16.14


          Так что погрешность погрешности — рознь.

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


    1. Gavamot Автор
      01.11.2019 12:52

      Спасибо за хороший пример.


  1. Bion007
    01.11.2019 12:46
    -2

    Тоже когда-то страдал Thread.Sleep(1000)/Task.Await(1000), таймерами и т.д. А потом открыл Quartz.NET www.quartz-scheduler.net/documentation/quartz-3.x/quick-start.html и с тех пор его и использую, он для данной задачи избыточен конечно, но зато CRON умеет и если нужно что-то дергать каждый чт в 10:00 — идеален, ну а если каждую секунду то «0/1 * * * * ?»


  1. ostapbender
    01.11.2019 13:53

    Можно еше поковырять ThreadPool.RegisterWaitForSingleObject() — он как раз для таких случаев подходит и избавляет от возни с вычислением миллисекунд.


    Ну а по коду вот что. Во-первых, в тех местах, где int используется в качестве "столько-то миллисекунд", замените его на человечий TimeSpan. Во-вторых, совсем правильно было бы написать метод, типа PerformPeriodicCallback(Action callback, TimeSpan interval, WaitHandle stopEvent).


  1. Sitro23
    01.11.2019 22:56

    А чем такой вариант плох?

    int prev = -1, ms;
    while (true)
    {
        ms = DateTime.Now.Millisecond;
        if (ms % 1000 == 0 && prev == 999)
            Getsnapshot();             
        prev = ms;
    }        
    


    1. a-tk
      02.11.2019 11:19

      Бесполезной тратой процессорного времени.


      1. Sitro23
        02.11.2019 21:35

        А если добавить Thread.Sleep(900) и асинхронно запускать получение снэпшота?