Observable.Timer(): Как избежать дрейфа таймера?

В приложении С# (.NET 4.0) я использую Reactive Extensions (2.0.20823.0) для создания временных границ для группировки событий в агрегированные значения. Чтобы упростить запросы к результирующей базе данных, эти границы должны быть выровнены в полные часы (или секунды в примере ниже).

Использование Observable.Timer():

var time = DefaultScheduler.Instance;

var start = new DateTimeOffset(time.Now.DateTime, time.Now.Offset);

var span = TimeSpan.FromSeconds(1);

start -= TimeSpan.FromTicks(start.Ticks % 10000000);
start += span;

var boundary = Observable.Timer(start, span, time);

boundary.Select(i => start + TimeSpan.FromSeconds(i * span.TotalSeconds))
    .Subscribe(t => Console.WriteLine("ideal: " + t.ToString("HH:mm:ss.fff")));

boundary.Select(i => time.Now)
    .Subscribe(t => Console.WriteLine("actual: " + t.ToString("HH:mm:ss.fff")));

Вы можете видеть, что предполагаемое и фактическое время таймера гаснет довольно сильно:

ideal: 10:06:40.000
actual: 10:06:40.034
actual: 10:06:41.048
ideal: 10:06:41.000
actual: 10:06:42.055
ideal: 10:06:42.000
ideal: 10:06:43.000
actual: 10:06:43.067
actual: 10:06:44.081
ideal: 10:06:44.000
ideal: 10:06:45.000
actual: 10:06:45.095
actual: 10:06:46.109
ideal: 10:06:46.000
ideal: 10:06:47.000
actual: 10:06:47.123
actual: 10:06:48.137
ideal: 10:06:48.000
...

Я также использую HistoricalScheduler, и, конечно, проблем у меня нет. Я могу терпеть небольшие неточности, и мне не нужно заботиться о изменениях системных часов. Нет тяжелых операций, вызванных этими Observables.

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

Каким будет правильный способ периодического планирования Observable без систематического дрейфа таймера?

Ответы

Ответ 1

Вы можете использовать Observable.Generate:

var boundary = Observable.Generate(
    0, _ => true, // start condition
    i => ++i,     // iterate
    i => i,       // result selector
    i => start + TimeSpan.FromSeconds(i * span.TotalSeconds),
    time);

Это приведет к перепланированию на основе абсолютного времени каждой итерации.

Вот пример вывода:

actual: 01:00:44.003
ideal: 01:00:44.000
actual: 01:00:44.999
ideal: 01:00:45.000
actual: 01:00:46.012
ideal: 01:00:46.000
actual: 01:00:47.011
ideal: 01:00:47.000
actual: 01:00:48.011
ideal: 01:00:48.000
actual: 01:00:49.007
ideal: 01:00:49.000
actual: 01:00:50.009
ideal: 01:00:50.000
actual: 01:00:51.006
ideal: 01:00:51.000

Это не соответствует точно, я думаю, по причинам, объясняемым Гансом, но там нет дрейфа.

EDIT:

Вот некоторые комментарии RxSource

// BREAKING CHANGE v2 > v1.x - No more correction for time drift based on absolute time. This
//                             didn't work for large period values anyway; the fractional
//                             error exceeded corrections. Also complicated dealing with system
//                             clock change conditions and caused numerous bugs.
//
// - For more precise scheduling, use a custom scheduler that measures TimeSpan values in a
//   better way, e.g. spinning to make up for the last part of the period. Whether or not the
//   values of the TimeSpan period match NT time or wall clock time is up to the scheduler.
//
// - For more accurate scheduling wrt the system clock, use Generate with DateTimeOffset time
//   selectors. When the system clock changes, intervals will not be the same as diffs between
//   consecutive absolute time values. The precision will be low (1s range by default).

Ответ 2

Частота прерывания по умолчанию для Windows на большинстве компьютеров составляет 64 прерывания в секунду. Закругленный CLR до 15,6 миллисекунд. Это не счастливое число, если вы запрашиваете интервал в 1000 миллисекунд, нет интегрального делителя. Самые близкие совпадения: 64 x 15,6 = 998 (слишком короткий) и 65 x 15,6 = 1014 миллисекунды.

Это именно то, что вы видите, 41.048 - 40.034 = 1.014. 44,081 - 43,067 = 1,014 и т.д.

Фактически вы можете изменить скорость прерывания, вы можете pinvoke timeBeginPeriod() и запросить интервал в 1 миллисекунду. Вам понадобится timeEndPeriod() при завершении программы до reset it. Это не совсем разумная вещь, она имеет системные побочные эффекты и очень вредна для потребления энергии. Но решит вашу проблему.

Более здравый подход состоит в том, чтобы просто признать, что вы не можете точно сохранить время, добавив интервалы. 15,6 мс, который использует CLR, уже является приблизительным. Всегда перекалибруйте с помощью абсолютных часов. Подойдите ближе, запросив 998 мс вместо 1000. Etcetera.