Три таймера .NET: как выбирать PeriodicTimer, Timer и DispatcherTimer

· Обновлено: · · C#, .NET, WPF, Таймер, Проектирование

История изменений (1 обновлений, последнее 30 Aug 2026)

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

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619667)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Три таймера .NET: как выбирать PeriodicTimer, Timer и DispatcherTimer. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/03/12/002-periodictimer-system-threading-timer-dispatchertimer-guide/

DOI (зарегистрированный архив)
10.5281/zenodo.21619667
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619668

В прошлой статье Практическое руководство: soft real-time на обычной Windows мы разбирали, как не полагаться на периодический цикл через Sleep и вместо этого использовать событийный подход и waitable timer. Коротко: если нужно снизить дрожание периода и промахи мимо дедлайна, раньше выбора таймера проектируют сам способ ожидания — это вывод той статьи.

А как быть в более повседневной разработке .NET-приложений? Здесь легко запутаться в PeriodicTimer, System.Threading.Timer и DispatcherTimer.

Все они называются таймерами, но характер у них разный:

  • таймер, у которого тик ждут через await;
  • таймер, у которого callback прилетает через ThreadPool;
  • таймер, который работает на Dispatcher потока UI.
Названия похожи, характер разныйPeriodicTimer ждёт тик через await, System.Threading.Timer присылает callback на ThreadPool, DispatcherTimer работает на Dispatcher потока UI.Три таймераPeriodicTimer: ждать через awaitTimer: callback прилетает самDispatcherTimer: поток UI

Рис. 1: Названия у всех «таймер», но способ ожидания и место выполнения совсем разные.

На практике чаще всего смешивают вот это.

  • передают async-лямбду в System.Threading.Timer, хотя периодическая работа на самом деле асинхронная;
  • напрямую трогают экран из таймера на ThreadPool, хотя речь об обновлении UI в WPF;
  • кладут тяжёлую работу в DispatcherTimer и замедляют весь экран;
  • в голове смешиваются прошлый разговор про «soft real-time» и обычное периодическое выполнение в приложении.

Статья исходит в основном из обычных приложений на C# / .NET версии 6 и новее и разбирает PeriodicTimer / System.Threading.Timer / DispatcherTimer в том порядке, в котором в повседневной практике меньше путаницы.

Ожидаемая область применения — примерно такая.

  • worker / фоновые сервисы
  • консольные приложения
  • закулисная работа в ASP.NET Core
  • десктопные приложения на WPF

Под DispatcherTimer в этой статье в основном имеется в виду System.Windows.Threading.DispatcherTimer в WPF. В WinUI / UWP есть DispatcherTimer с тем же принципом. Для WinForms в качестве UI-таймера естественнее смотреть на System.Windows.Forms.Timer. Объяснение со стороны UI здесь смещено к WPF, но читатели на WinForms тоже в аудитории. Если читать DispatcherTimer как System.Windows.Forms.Timer, замечания в 4.3 и 5.2 остаются в силе. Отличаются только два пункта.

  • System.Windows.Forms.Timer — однопоточный таймер, у которого Tick идёт через цикл сообщений; приоритета вроде DispatcherPriority у DispatcherTimer нет
  • в документации Microsoft точность ограничена примерно 55 миллисекундами. Для короткого периода он не подходит; тогда смотрят на таймер не для UI

Отметим: здесь речь о том, как писать периодическое выполнение на стороне приложения. Когда сама точность периода становится главной темой, разговор возвращается к прошлой статье про soft real-time.

Границы этой статьиТема этой статьи — как писать периодическое выполнение на стороне приложения; если главная тема — сама точность периода, разговор возвращается к прошлой статье про проектирование способа ожидания.Как писать периодическую работуСама точность периодаО чём статья?Три таймера из этой статьиПрошлая статья про способ ожидания

Рис. 2: «Делать что-то с интервалом» звучит одинаково, но запись периодической работы и проектирование точности периода — разные задачи.

Код из статьи опубликован на GitHub как полный набор, который можно собрать и запустить: библиотеки и консольные демонстрации для PeriodicTimer / System.Threading.Timer, модульные тесты на свёртку тиков и наложение callback.

periodictimer-system-threading-timer-dispatchertimer-guide - komurasoft-blog-samples (GitHub)

Содержание

  1. Сначала вывод (коротко)
  2. Сначала — одна сводка
    • 2.1. Общая картина
    • 2.2. Первая таблица решений
  3. Что стоит различать в первую очередь
    • 3.1. Callback-тип или тип, который ждёт тик
    • 3.2. ThreadPool или поток UI
    • 3.3. Периодическая работа и гарантия точности — разные темы
  4. Типовые схемы
    • 4.1. Для async-периодической работы — PeriodicTimer
    • 4.2. Для лёгких callback на ThreadPool — System.Threading.Timer
    • 4.3. Для обновления UI в WPF — DispatcherTimer
    • 4.4. Для периодической работы ближе к soft real-time — другие инструменты
  5. Частые антипаттерны
  6. Чек-лист для ревью
  7. Краткая шпаргалка по выбору
  8. Итог
  9. Источники

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 18, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

1. Сначала вывод (коротко)

  • если хочется естественно писать работу с фиксированным интервалом на базе await — сначала PeriodicTimer
  • если хочется периодически запускать лёгкий callback на ThreadPool — System.Threading.Timer
  • если хочется обновлять экран в потоке UI WPF — DispatcherTimer
  • у System.Threading.Timer callback могут наложиться. Если небрежно засунуть туда асинхронную работу, легко получить беспорядок
  • DispatcherTimer позволяет трогать UI напрямую, но взамен тяжёлая работа внутри легко останавливает и сам UI
  • в контексте прошлой статьи про soft real-time эти три инструмента не главные герои высокоточного ожидания

Иначе говоря, сначала смотрят на три вещи.

  1. В каком потоке / контексте это должно выполняться
  2. Хотите ли вы записать тело последовательно через async / await
  3. Допустимо ли наложение callback

Одного этого разделения уже достаточно, чтобы заметно меньше путаться.

Три вопроса, с которых начинаютГде выполнять, писать ли тело последовательно через async/await, допустимо ли наложение callback — если разделить эти три вопроса, путаницы меньше.Где выполнятьВыбор таймера становится яснееПисать последовательно через asyncДопустимо ли наложение callback

Рис. 3: Раньше имени таймера разделяют эти три вопроса.

2. Сначала — одна сводка

2.1. Общая картина

Как выбрать один из трёх таймеровСначала — поток UI или нет; затем — писать ли тело через async/await; затем — нужен ли лёгкий callback на ThreadPool. Так определяются DispatcherTimer, PeriodicTimer и System.Threading.Timer; иначе смотрят Channel, BackgroundService, события или waitable timer.ДаНетДаНетДаНетХочется делать что-то с фиксированным интерваломХотите запускать в UI-потоке?DispatcherTimerХотите писать телообработки прямо черезasync / await?PeriodicTimerХотите гонять лёгкийcallback наThreadPool?System.Threading.TimerРассмотреть другой дизайнChannel / BackgroundService / событие / waitable timer

Рис. 4: Если резать по порядку «поток UI / async / лёгкий callback», три таймера определяются.

На практике этого разветвления обычно достаточно. Когда сомневаетесь, надёжнее всего сначала разрезать так: для асинхронной работы — PeriodicTimer, для обновления UI — DispatcherTimer.

System.Threading.Timer удобен, но из-за наложения callback и особенностей управления временем жизни он немного капризен как самый первый выбор.

2.2. Первая таблица решений

Ситуация Первый выбор Где выполняется Почему подходит На что смотреть сразу
С фиксированным интервалом крутить async-работу вроде HTTP / БД / файлового ввода-вывода PeriodicTimer В потоке текущего async-метода Пишется на базе await, остановка и отмена выглядят естественно Рассчитан на одну связку «таймер — потребитель». Отставание само не распараллеливается
На ThreadPool крутить лёгкий heartbeat / отправку метрик / проверку истечения кэша System.Threading.Timer ThreadPool Лёгкий, callback-стиль. Легко встроить в существующий дизайн на callback Callback рассчитан на реентерабельность. Возможно наложение. Нужно удерживать ссылку
С фиксированным интервалом обновлять часы или лёгкий UI в WPF DispatcherTimer Dispatcher WPF (поток UI) UI можно трогать напрямую. Есть приоритеты Точное время срабатывания не гарантировано. Тяжёлая работа забивает UI
Точность периода — суть, и не хочется полагаться на Sleep Не делать эти три инструмента главными - Цель — не периодическое выполнение приложения, а проектирование точности ожидания Смотреть в сторону событий / waitable timer

В этой таблице важно смотреть не на имя таймера, а на место выполнения и стиль записи. Когда выбор таймера оказывается неудачным, чаще не смотрели «где это выполняется», а не название API.

На что смотреть при выборе таймераАварии чаще бывают, когда выбирают по имени API; если смотреть место выполнения и желаемый стиль записи, промахнуться сложнее.Выбирать по имениНе смотрят, где выполняетсяСмотреть место и стиль записиСложнее промахнуться

Рис. 5: Большинство аварий — выбор по имени. Смотреть нужно «где выполняется» и «как хочется писать».

3. Что стоит различать в первую очередь

3.1. Callback-тип или тип, который ждёт тик

Если разделить это, картина сразу проясняется.

  • System.Threading.Timer и DispatcherTimer — callback / событийный тип
  • PeriodicTimer — тип, у которого тик ждут через await

То есть:

  • callback-тип — «таймер вызывает вас»;
  • PeriodicTimer — «вы сами ждёте следующий тик».

Если тело async и хочется читать «ждать → обработать → снова ждать» как один поток, PeriodicTimer естественнее.

И наоборот, в ситуациях, когда:

  • нужно встроиться в существующий дизайн на callback;
  • тело короткое и синхронное;
  • нужен просто периодический толчок,

подходит System.Threading.Timer.

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

Важно не понимать это как «само догонит отставание».

Callback-тип и тип, который ждёт тикSystem.Threading.Timer и DispatcherTimer — callback-тип, где таймер вызывает вас; PeriodicTimer — тип, где следующий тик ждут через await.callbackожидание tickКакой тип?Таймер вызывает васВы ждёте следующий tickTimer и DispatcherTimerPeriodicTimerждать → обработать → ждать снова

Рис. 6: «Вас вызывают» или «вы ждёте». Одного этого различия уже достаточно, чтобы картина прояснилась.

3.2. ThreadPool или поток UI

Следующее, на что смотрят, — где это выполняется.

Callback у System.Threading.Timer выполняется не на создавшем его потоке, а на ThreadPool. Это подходит для фоновой работы, но не рассчитано на прямое обращение к UI.

DispatcherTimer, напротив, — UI-таймер, встроенный в очередь Dispatcher. В WPF он работает на том же Dispatcher, поэтому UI можно обновлять прямо внутри обработчика Tick.

Разница довольно велика.

  • чтобы обратиться к UI из таймера на ThreadPool, нужно явно вернуться в UI;
  • DispatcherTimer упрощает обращение к UI, но за счёт этого расходует время потока UI.

Иначе говоря, сила DispatcherTimer — в «безопасном обращении к UI», но это одновременно значит: «тяжёлая работа внутри забирает с собой и ввод, и перерисовку».

Место выполнения и плата за негоCallback у System.Threading.Timer выполняется на ThreadPool, поэтому к UI нужно явно вернуться; DispatcherTimer позволяет трогать UI напрямую, но занимает время потока UI.Timer: выполняется на ThreadPoolК UI нужно явно вернутьсяDispatcherTimer: поток UIUI можно трогать напрямуюТяжёлая работа тормозит ввод и отрисовку

Рис. 7: Разница в месте выполнения оборачивается удобством доступа к UI и расходом времени потока UI.

3.3. Периодическая работа и гарантия точности — разные темы

Это важно как связь с прошлой статьёй.

Формулировка «делать что-то с фиксированным интервалом» звучит одинаково, но это разные задачи:

  • периодическая работа каждые несколько секунд как удобство на уровне приложения;
  • стремление максимально приблизиться к deadline на масштабе от 1 мс до нескольких мс.

System.Threading.Timer — лёгкий и удобный таймер, но не специализированный инструмент для точности. DispatcherTimer тоже подвержен влиянию очереди Dispatcher и приоритетов.

PeriodicTimer по одному имени может показаться «должен быть точным по периоду», но на практике его сила — удобство записи async-потока, а не precision.

Поэтому безопаснее сразу разделить:

  • писать периодическое выполнение на уровне приложения, или
  • добиваться точности ожидания.

Когда эти два вопроса смешиваются, разговор о выборе таймера постепенно уходит не туда.

Периодическое выполнение и точность ожиданияПериодическая работа каждые несколько секунд как удобство приложения и стремление приблизиться к deadline на миллисекундном масштабе — разные задачи; три таймера не являются специализированным инструментом точности.Делать что-то с интерваломПериодическая работа приложенияДобиться точности ожиданияЗдесь как раз три таймераК проектированию способа ожиданияТри таймера — не инструмент точности

Рис. 8: Слова «фиксированный интервал» одни и те же, но периодическое выполнение и гарантия точности — разные задачи.

4. Типовые схемы

4.1. Для async-периодической работы — PeriodicTimer

В worker, BackgroundService или фоновой консольной работе, если нужно крутить async-работу с фиксированным интервалом, сначала проще писать через PeriodicTimer.

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public sealed class CacheRefreshWorker : BackgroundService
{
    private readonly ILogger<CacheRefreshWorker> _logger;

    public CacheRefreshWorker(ILogger<CacheRefreshWorker> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("CacheRefreshWorker started.");

        await RefreshCacheAsync(stoppingToken);

        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
        try
        {
            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                await RefreshCacheAsync(stoppingToken);
            }
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("CacheRefreshWorker stopping.");
        }
    }

    private async Task RefreshCacheAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Refreshing cache...");
        await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
    }
}

Достоинства такой формы:

  • поток кода легко проследить как один async-метод;
  • CancellationToken легко передать дальше по цепочке;
  • меньше управления временем жизни и исключениями в стиле callback.

Особенно хорошо подходит, когда тело сосредоточено вокруг ожидания ввода-вывода:

  • вызов HTTP;
  • запрос к БД;
  • чтение файла;
  • await других async API.

Есть два замечания.

  1. Использовать таймер по схеме «один таймер — один потребитель»
  2. Самим решить, какова политика, когда работа занимает дольше периода

PeriodicTimer не распараллеливается автоматически, чтобы наверстать упущенное, только потому что предыдущая работа затянулась. В этом смысле это таймер для «естественной записи async-цикла с фиксированным интервалом».

Если важна ещё и тестируемость, незаметно полезен и конструктор, принимающий TimeProvider.

Периодический цикл на PeriodicTimerЖдут следующий тик через WaitForNextTickAsync, выполняют async-тело и снова ждут — один поток; отмена CancellationToken выводит из цикла.отмена tokenЖдать WaitForNextTickAsyncВыполнить async-телоВыйти из цикла и остановитьсяОтставание само не распараллеливается

Рис. 9: PeriodicTimer позволяет записать «ждать → обработать → снова ждать» как один async-метод.

4.2. Для лёгких callback на ThreadPool — System.Threading.Timer

Если нужно всего лишь периодически вызывать короткий callback, System.Threading.Timer подходит без изысков.

Например:

  • отправка heartbeat;
  • сбор лёгких метрик;
  • короткая проверка истечения срока;
  • подвешивание к существующему дизайну на callback.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public sealed class HeartbeatService : IHostedService, IDisposable
{
    private readonly ILogger<HeartbeatService> _logger;
    private Timer? _timer;
    private int _running;

    public HeartbeatService(ILogger<HeartbeatService> logger)
    {
        _logger = logger;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _timer = new Timer(OnTimer, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
        return Task.CompletedTask;
    }

    private void OnTimer(object? state)
    {
        if (Interlocked.Exchange(ref _running, 1) != 0)
        {
            return;
        }

        try
        {
            _logger.LogInformation("Heartbeat: {Now}", DateTimeOffset.Now);
        }
        finally
        {
            Volatile.Write(ref _running, 0);
        }
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
        return Task.CompletedTask;
    }

    public void Dispose()
    {
        _timer?.Dispose();
    }
}

Interlocked.Exchange добавлен в этом примере потому, что System.Threading.Timer не ждёт завершения предыдущего callback.

Это довольно важный момент.

  • callback выполняется на ThreadPool;
  • callback рассчитан на реентерабельность;
  • если работа длится дольше интервала, вызовы могут наложиться.

Это «могут наложиться» в модульных тестах образца можно наблюдать напрямую. Таймер с периодом 50 мс и работой на 300 мс даёт максимум одновременных выполнений 2 и больше. В варианте с той же защитой через Interlocked.Exchange при тех же условиях максимум одновременных выполнений остаётся 1, а callback, сработавший во время выполнения, пропускается.

// фрагмент из tests/KomuraSoft.TimerSelection.Tests/ThreadPoolTimerOverlapTests.cs

// без защиты: период 50 мс, работа 300 мс; ждём, пока не увидим наложение
bool overlapped = await WaitUntilAsync(
    () => Volatile.Read(ref maxObserved) >= 2,
    TimeSpan.FromSeconds(10));

Assert.True(overlapped, "timer callbacks did not overlap within the timeout.");

// с защитой: при тех же условиях максимум одновременных выполнений остаётся 1
Assert.Equal(1, Volatile.Read(ref maxConcurrent));

Если работа не лёгкая, спокойнее спроектировать так, чтобы:

  • пропускать повторный запуск при наложении;
  • складывать работу в очередь;
  • переходить на PeriodicTimer.
Наложение callback и защитаSystem.Threading.Timer не ждёт завершения предыдущего callback, поэтому если работа длится дольше интервала, вызовы могут наложиться. С защитой через Interlocked.Exchange одновременно выполняется один, а сработавший во время выполнения callback пропускается.Без защитыС защитойCallback срабатывает по интервалуПредыдущий ещё выполняется?Callback'и накладываютсяЭтот вызов пропускаетсяОдновременно выполняется один

Рис. 10: У таймера, который не ждёт завершения предыдущего вызова, наложение либо допускают, либо отсекают защитой — это решают сами.

Ещё один незаметно важный момент — удерживать ссылку. System.Threading.Timer, даже работая, становится кандидатом на сборку мусора, если на него не остаётся ссылок. Также сразу после Dispose() уже поставленные в очередь callback могут ещё выполниться позже.

Итак, System.Threading.Timer

  • лёгкий;
  • быстрый;
  • простой,

но взамен это таймер, за особенности callback которого приходится отвечать самим.

Время жизни System.Threading.TimerSystem.Threading.Timer даже во время работы становится кандидатом на GC, если ссылку не удерживают; сразу после Dispose уже поставленный в очередь callback может ещё выполниться.System.Threading.TimerДержать ссылкуБез ссылки попадёт в GCУбирать через DisposeОчередь callback может ещё сработать

Рис. 11: Плата за лёгкость — самим держать ссылку и учитывать callback после Dispose.

4.3. Для обновления UI в WPF — DispatcherTimer

Если в WPF нужно периодически обновлять часы на экране или лёгкое отображение состояния, естественный выбор — DispatcherTimer.

using System;
using System.Windows;
using System.Windows.Threading;

public partial class MainWindow : Window
{
    private readonly DispatcherTimer _clockTimer;

    public MainWindow()
    {
        InitializeComponent();

        _clockTimer = new DispatcherTimer(DispatcherPriority.Background)
        {
            Interval = TimeSpan.FromSeconds(1)
        };
        _clockTimer.Tick += ClockTimer_Tick;
        _clockTimer.Start();
    }

    private void ClockTimer_Tick(object? sender, EventArgs e)
    {
        ClockText.Text = DateTime.Now.ToString("HH:mm:ss");
    }

    protected override void OnClosed(EventArgs e)
    {
        _clockTimer.Stop();
        _clockTimer.Tick -= ClockTimer_Tick;
        base.OnClosed(e);
    }
}

DispatcherPriority.Background, который передают в конструктор, задаёт, с каким приоритетом Tick обрабатывается в очереди Dispatcher. new DispatcherTimer() без аргументов по умолчанию тоже берёт Background: здесь значение по умолчанию просто записано явно, поведение от этого не меняется. Background (значение 4) — приоритет «обработать, когда закончена вся прочая не-idle работа», ниже Input (5) и Render (7). То есть Tick не вытесняет обработку ввода и отрисовку. Для часов, где «немного съехать можно, мешать управлению нельзя», это хорошо совпадает. Если результат Tick нужно показать на экране как можно скорее, можно поднять до Normal (9), но тогда предпосылка «держать Tick лёгким» становится ещё жёстче.

Место DispatcherPriorityBackground — приоритет «обработать, когда закончена вся прочая не-idle работа», ниже Input и Render, поэтому Tick не вытесняет ввод и отрисовку. Если нужно быстрее показать на экране, можно поднять до Normal.Tick у DispatcherTimerКладётся с Background (значение 4)Обрабатывается после ввода и отрисовкиПодходит для часов без помех вводуЕсли срочно — поднять до Normal (9)Тогда Tick нужно держать ещё легче

Рис. 12: Приоритет Background по умолчанию кладёт Tick туда, где он не вытесняет ввод и отрисовку.

Достоинство DispatcherTimer в том, что Tick обрабатывается на Dispatcher WPF, поэтому UI можно трогать напрямую.

Это хорошо подходит, например, для:

  • отображения часов;
  • лёгкого обновления индикатора статуса подключения;
  • толчка к повторной оценке Command;
  • лёгкого обновления чисел на экране.

Но и здесь есть место, где картина меняется.

DispatcherTimer работает в потоке UI, поэтому тяжёлая работа в обработчике Tick напрямую замедляет ввод, отрисовку и перекомпоновку.

Кроме того, DispatcherTimer не гарантирует срабатывание «строго в указанное время». На него влияют другая работа в очереди Dispatcher и приоритеты.

Поэтому на практике стабильность держат, если помнить:

  • содержимое Tick делать лёгким;
  • тяжёлый ввод-вывод и CPU уводить отдельно;
  • при закрытии явно закрывать жизнь через Stop() и отписку.
Три пункта, которые стабилизируют DispatcherTimerДержать Tick лёгким, уводить тяжёлый ввод-вывод и CPU в фон, при закрытии экрана явно закрывать жизнь через Stop и отписку.Как вести DispatcherTimerДержать Tick лёгкимТяжёлую работу уводить в фонStop и отписка явно закрывают жизньПотому что занимает время потока UI

Рис. 13: Плата за прямой доступ к UI — держать Tick лёгким и явно закрывать время жизни.

4.4. Для периодической работы ближе к soft real-time — другие инструменты

Это точка соединения с прошлой статьёй.

В прошлой статье про soft real-time речь шла не о «в целом достаточно срабатывать примерно каждые столько-то секунд», а о том, как снизить дрожание периода и промахи мимо дедлайна.

В том контексте главными темами становятся:

  • не полагаться на относительное ожидание через Sleep;
  • использовать событийный подход и waitable timer;
  • разделять fast path и slow path;
  • измерять отставание.

Поэтому чище сразу разделить задачи:

  • обычная async-периодическая работа в приложении → PeriodicTimer
  • callback на ThreadPool → System.Threading.Timer
  • обновление UI → DispatcherTimer
  • сама точность периода — главная тема → мир прошлой статьи

Вопрос «хочу крутить как можно точнее каждую 1 мс — какой .NET-таймер лучше» примерно наполовину уже не выбор таймера, а вопрос способа ожидания и проектирования.

Сразу разделить задачу на четыреОбычная async-периодическая работа приложения — PeriodicTimer, callback на ThreadPool — System.Threading.Timer, обновление UI — DispatcherTimer, если главная тема — точность периода, это проектирование способа ожидания из статьи про soft real-time.Что нужно сделатьasync: PeriodicTimercallback: TimerUI: DispatcherTimerТочность: проектировать ожидание

Рис. 14: Раньше вопроса «какой таймер» чище сразу разделить, какая это из четырёх задач.

5. Частые антипаттерны

5.1. Передавать async-лямбду напрямую в System.Threading.Timer

Это довольно соблазнительно.

_timer = new Timer(async _ => await RefreshAsync(), null,
    TimeSpan.Zero, TimeSpan.FromSeconds(5));

На вид аккуратно, но TimerCallback имеет тип void. То есть эта async-лямбда по сути ведёт себя как async void.

В результате:

  • вызывающая сторона не может await;
  • нельзя дождаться завершения;
  • исключения сложнее вести;
  • наложение callback приходится продумывать отдельно.

Получается вязкая ситуация.

Почему исключения сложнее вести, стоит записать чуть яснее. У async Task исключение садится на Task, и вызывающая сторона получает его в момент await. У async void (и эквивалента) этого Task нет, поэтому брошенное исключение сразу перебрасывается в SynchronizationContext, который был действителен в момент старта метода. Callback у System.Threading.Timer выполняется на ThreadPool, и там SynchronizationContext нет. В итоге исключение становится необработанным на потоке ThreadPool и по умолчанию валит весь процесс. Снаружи его не поймать, если callback сами не обернуть в try / catch.

Куда уходит исключение из async-лямбды в callbackTimerCallback имеет тип void, поэтому переданная async-лямбда ведёт себя как async void; исключение перебрасывается в SynchronizationContext момента старта, но на ThreadPool его нет, поэтому это необработанное исключение, и по умолчанию падает весь процесс.На ThreadPool его нетИсключение в async-лямбдеУходит наружу как async voidЕсть ли SynchronizationContext?Необработанное исключение ThreadPoolПо умолчанию падает весь процессЛовится только своим try / catch

Рис. 15: Исключение из аккуратной на вид async-лямбды негде поймать, и оно валит процесс.

Если тело уже async, читается понятнее, если сначала рассмотреть PeriodicTimer.

5.2. Класть тяжёлую работу в Tick у DispatcherTimer

DispatcherTimer позволяет трогать UI напрямую, поэтому так и тянет писать туда всё подряд. Но это поток UI.

Если положить туда:

  • длинную синхронную работу;
  • тяжёлые вычисления на CPU;
  • блокирующий ввод-вывод;
  • работу с долгим await, которая может запускаться повторно поверх ещё не завершённой,

это напрямую столкнётся с вводом и отрисовкой UI.

Стабильнее держать содержимое Tick лёгким, уводить тяжёлую работу в фон и возвращать в UI только нужный результат.

5.3. Думать, что PeriodicTimer сам наверстает отставание

Это тоже легко понять неправильно.

PeriodicTimer отлично подходит, чтобы чисто записать async-цикл с фиксированным интервалом, но он не запускает выполнение параллельно, чтобы самостоятельно наверстать упущенное, если предыдущая работа затянулась.

Это можно увидеть и в модульных тестах образца. Таймер с периодом 250 мс оставляют на 1,5 секунды без ожидающего и затем ждут: первое ожидание завершается сразу за счёт накопившегося тика, второе — уже нет. Несколько тиков за время простоя свернулись в один.

// фрагмент из tests/KomuraSoft.TimerSelection.Tests/PeriodicTimerBehaviorTests.cs

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250));
await Task.Delay(TimeSpan.FromMilliseconds(1500)); // в это время никто не ждал

// первое ожидание завершается сразу за счёт накопившегося tick
ValueTask<bool> first = timer.WaitForNextTickAsync();
Assert.True(first.IsCompleted);
Assert.True(await first);

// второе ожидание сразу не завершается (нескольких tick в запасе нет)
ValueTask<bool> second = timer.WaitForNextTickAsync();
Assert.False(second.IsCompleted);

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

  • пропускать при отставании;
  • достаточно ли смотреть только на самое свежее состояние;
  • или обязательно обработать каждую итерацию.
Свёртка тиков, пока никто не ждалЕсли за время, пока никто не ждал, PeriodicTimer тикнул несколько раз, они сворачиваются в один; пропускать ли при отставании, смотреть ли только последнее или обрабатывать каждую итерацию — решает проектирование.Несколько tick, пока никто не ждалСворачиваются в одинЧто делать с отставанием?ПропускатьСмотреть только последнееОбрабатывать каждый раз

Рис. 16: Накопившиеся тики сворачиваются в один. Способ догнать отставание задаёт проектирование, а не таймер.

5.4. Откладывать остановку и управление временем жизни «на потом»

Таймеры чаще подводят не при запуске, а при остановке.

Легко упустить вот что:

  • System.Threading.Timer создан как локальная переменная, и ссылка на него не удерживается;
  • System.Threading.Timer не останавливают, а история с Dispose() остаётся размытой;
  • DispatcherTimer не останавливают через Stop(), подписку на Tick не снимают;
  • таймер продолжает удерживать объект живым даже после закрытия экрана.

Особенно DispatcherTimer может держать живым объект, к которому привязан метод-обработчик. Если возникает странное ощущение «это Window вроде бы закрыли, а оно всё ещё живо» — стоит заподозрить именно это.

Ловушки остановки и времени жизниTimer без удерживаемой ссылки и размытый Dispose оставляют способ остановки неясным; DispatcherTimer без Stop и отписки от Tick держит объект привязки живым, и Window, которое уже должны были закрыть, остаётся.Не держат ссылку на TimerСпособ остановки остаётся размытымDispose оставляют неяснымНет Stop и отписки от TickОбъект привязки остаётся живымWindow, которое уже должны были закрыть

Рис. 17: Таймеры чаще подводят при остановке, чем при запуске. Уборку времени жизни пишут сразу.

6. Чек-лист для ревью

  • можете ли вы объяснить, как следует записать эту периодическую работу — как обновление UI / callback ThreadPool / async-цикл?
  • не запихивается ли async-тело насильно в таймер callback-типа?
  • если используется System.Threading.Timer, выдерживает ли код наложение callback, или оно защищено отдельно?
  • не содержит ли Tick у DispatcherTimer тяжёлой работы, блокирующего ввода-вывода, длинной синхронной работы?
  • если используется PeriodicTimer, определена ли политика на случай отставания?
  • ясны ли способ остановки (Change / Dispose / Stop) и порядок действий при завершении приложения?
  • правильно ли удерживается ссылка на System.Threading.Timer?
  • есть ли отписка и уборка для DispatcherTimer при закрытии экрана?
  • разделено ли с самого начала, о чём речь — о «периодическом выполнении приложения» или о «точности ожидания»?

7. Краткая шпаргалка по выбору

Практические ориентиры.

  • каждые 30 секунд обращаться к API и обновлять конфигурацию → PeriodicTimer

  • каждые 5 секунд отправлять heartbeat или лёгкие метрики → System.Threading.Timer

  • в WPF показывать часы или лёгкое обновление статуса → DispatcherTimer

  • на каждый тик напрямую трогать UI → DispatcherTimer

  • тело периодической работы состоит сплошь из await, и хочется естественно вести остановку и исключения → PeriodicTimer

  • дёшево добавить небольшой толчок в стиле callback → System.Threading.Timer

  • главное — управление точностью периода и дрожанием на масштабе 1–5 мс → прежде этих трёх смотреть способы ожидания из прошлой статьи

Если сказать очень грубо, одной строкой на каждый:

  • PeriodicTimer — таймер для async;
  • System.Threading.Timer — таймер для callback на ThreadPool;
  • DispatcherTimer — таймер для UI.

При таком правиле сильно промахнуться сложно.

8. Итог

По-настоящему важно в выборе .NET-таймера не различие в названиях, а вот эти три момента.

  1. Где он выполняется
  2. В каком потоке кода вы хотите его писать
  3. Как вы обрабатываете наложение и отставание

В качестве политики этого уже достаточно, чтобы уверенно действовать.

  1. Async-периодическая работа — PeriodicTimer
  2. Лёгкие callback на ThreadPool — System.Threading.Timer
  3. Обновление UI в WPF — DispatcherTimer
  4. Если главное — точность, смотреть другие способы ожидания

Таймеры смешиваются в голове, потому что названия похожи. Но их роли не так уж похожи.

  • PeriodicTimer — инструмент, чтобы оформить async-поток;
  • System.Threading.Timer — инструмент, чтобы периодически толкать callback;
  • DispatcherTimer — инструмент, чтобы периодически обновлять в потоке UI.

Одного раздельного осмысления этих трёх уже достаточно, чтобы код стал заметно спокойнее.

Итог по ролям трёх таймеровPeriodicTimer оформляет async-поток, System.Threading.Timer периодически толкает callback, DispatcherTimer периодически обновляет в потоке UI.Роль таймераPeriodicTimer: asyncTimer: callbackDispatcherTimer: UIЕсли нужна точность — к проектированию ожидания

Рис. 18: Названия похожи, роли — нет. Эти три категории помогают не промахнуться.

И наоборот, когда они смешиваются, происходят вполне обычные неприятности:

  • то, что должно было быть async, превращается в подобие async void;
  • программа падает из-за прямого обращения к UI;
  • callback накладываются, и состояние мутнеет;
  • в разговор примешивается ещё и тема точности периода.

Начните с вопроса «где вы хотите это запускать». Одного этого уже достаточно, чтобы выбор таймера стал заметно спокойнее.

9. Источники

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

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

В чём разница между PeriodicTimer и System.Threading.Timer?
Главное различие: PeriodicTimer ждёт тик через await, а System.Threading.Timer — callback-тип. PeriodicTimer позволяет записать «ждать → обработать → снова ждать» как поток одного async-метода, и CancellationToken легко передать дальше. Callback у System.Threading.Timer выполняется на ThreadPool и не ждёт завершения предыдущего вызова, поэтому если работа длится дольше интервала, вызовы могут наложиться. Для асинхронной периодической работы подходит PeriodicTimer, для лёгкого синхронного периодического толчка через callback — System.Threading.Timer.
Догоняет ли PeriodicTimer отставание сам?
Нет. Если предыдущая работа затянулась, таймер не запускает выполнение параллельно, чтобы наверстать упущенное. Если за время, пока никто не ждал, прошло несколько тиков, они сворачиваются в один. Поэтому пропускать ли при отставании, достаточно ли смотреть только на последнее состояние или нужно обработать каждую итерацию — решает проектирование. Один таймер также не рассчитан на одновременный запуск нескольких WaitForNextTickAsync.
Нельзя ли передавать async-лямбду в System.Threading.Timer?
Лучше не стоит. TimerCallback имеет тип void, поэтому переданная async-лямбда по сути ведёт себя как async void. Вызывающая сторона не может её await, не может дождаться завершения, исключения сложнее вести, а наложение callback приходится продумывать отдельно. Если тело уже async, сначала стоит рассмотреть PeriodicTimer — так читается понятнее и безопаснее.
Когда стоит использовать DispatcherTimer?
Когда в WPF нужно периодически обновлять UI — например, часы или лёгкий статус. Tick обрабатывается на Dispatcher WPF (потоке UI), поэтому сильная сторона — UI можно трогать прямо в обработчике. Но именно потому, что он работает в потоке UI, тяжёлая работа или блокирующий ввод-вывод в Tick замедляют ввод и отрисовку вместе с ним. Точное срабатывание в указанное время не гарантируется. Стабильнее держать Tick лёгким, тяжёлую работу уводить в фон, а при закрытии экрана явно закрывать жизнь таймера через Stop() и отписку.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

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

Публичные ссылки

Вернуться в блог