Observable.Timer(): как избежать смещения таймера?

В приложении C# (.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 без систематического дрейфа таймера?

2 ответа

Решение

Вы можете использовать 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

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

РЕДАКТИРОВАТЬ:

Вот некоторые комментарии от 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).

По умолчанию частота прерываний часов 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 и так далее.

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

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

Другие вопросы по тегам