Зачастую в нашей программе, возникает необходимость обновление какой-либо информации 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)
alex-khv
31.10.2019 20:42А если сделать лаг в одну секунду и предзагружать изображения? Получится вывод почти точно каждую секунду.
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()
не обязательно.
Evengard
01.11.2019 03:27+1Ох, вспомнилось как я синхронизировал видеопоток с аудиопотоком под Emscripten-ом на Сях… Только я когда отрицательную величину получал — я её сохранял и таки обнулял, чтоб в одной из следующих итераций вычесть из положительного значения. Ну и да, я использовал SDL_Delay — получалось точнее. Хотя по итогу всё равно дрифт получался, и пришлось синхронить по звуку…
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(); } }
Sitro23
01.11.2019 12:46Такой код накапливает ошибку постоянно (около 0.1 с каждые 10 с).
Gavamot Автор
01.11.2019 12:47-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
Так что погрешность погрешности — рознь.
Подход привязки к реальному времени я использовал в продакшене при реализации микширования звука нескольких голосовых звонков: звуковые потоки от нескольких абонентов плавают друг относительно друга, но они все подтягиваюся к реальному времени и взаимная погрешность не нарастает.
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 * * * * ?»
ostapbender
01.11.2019 13:53Можно еше поковырять
ThreadPool.RegisterWaitForSingleObject()
— он как раз для таких случаев подходит и избавляет от возни с вычислением миллисекунд.
Ну а по коду вот что. Во-первых, в тех местах, где
int
используется в качестве "столько-то миллисекунд", замените его на человечийTimeSpan
. Во-вторых, совсем правильно было бы написать метод, типаPerformPeriodicCallback(Action callback, TimeSpan interval, WaitHandle stopEvent)
.
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; }
Stawros
А чем таймеры не угодили?
shai_hulud
Таймеры дрейфуют.
https://stackoverflow.com/questions/6259120/system-threading-timer-call-drifts-a-couple-seconds-every-day
Stawros
Ну по вашей же ссылке упоминаются Multimedia Timers, про которые Microsoft пишет, что они «with the greatest resolution (or accuracy) possible for the hardware platform». На том же SO есть пример обвязки для C#.
shai_hulud
Вместо этих p/invoke таймеров я бы бахнул свой тред с spin-wait (на промежутки меньше 3 мс) и Stopwatch и sleep(0) на большее время.
rrust
ага, на 2 секунды в сутки, а вам абсолютно точно нужно делать снимки раз в секунду что интервал в 1.000023 сек не пойдет?
shai_hulud
Я не автор, но автор гнался за миллисекундами и «просто» обычный таймер с его дрейфом не подходит т.к. надо делать ручные коррекции.
a-tk
Эммм… А что тогда делает приведённое решение? Его ж будет из стороны в сторону носить по полной программе.
PS: По теме статьи можно предположить, что интервал должен выдерживаться в среднем, что порождает несколько иное решение.
rrust
и запускать по таймеру асинхронный процесс который может выполняться сколько ему угодно. Главное поставить защиту от повторного входа чтобы в некоторых случаях не получить много параллельных тредов.