Cómo elegir entre los 3 temporizadores de .NET - PeriodicTimer/Timer/DispatcherTimer

· Actualizado el: · · C#, .NET, WPF, Temporizadores, Diseño

En el artículo anterior, Guía práctica para lograr en la medida de lo posible un soft real-time en un Windows normal - lista de verificación inicial, repasamos cómo evitar los bucles periódicos basados en Sleep y usar en su lugar un diseño orientado a eventos o waitable timers. Dicho en una frase, la conclusión de aquel artículo fue: si quiere reducir la fluctuación del período y los deadline miss, antes de elegir el tipo de temporizador hay que diseñar la propia “forma de esperar”.

Entonces, ¿qué hacer en el desarrollo de aplicaciones .NET más cotidiano? Aquí es fácil dudar entre PeriodicTimer, System.Threading.Timer y DispatcherTimer.

Los tres se llaman “temporizador”, pero:

  • un temporizador que espera el tick con await
  • un temporizador cuyo callback llega a través de ThreadPool
  • un temporizador que se ejecuta sobre el Dispatcher del hilo de UI

son bastante distintos en su naturaleza.

En la práctica, lo que más se suele confundir es esto:

  • pasar una lambda async a System.Threading.Timer cuando en realidad se trata de un procesamiento periódico asíncrono
  • tocar la pantalla directamente desde un temporizador de ThreadPool cuando lo que se necesita es actualizar la UI de WPF
  • meter procesamiento pesado en DispatcherTimer y volver lenta toda la pantalla
  • mezclar mentalmente el tema del “soft real-time” del artículo anterior con la ejecución periódica normal de una aplicación

En este artículo, partiendo principalmente de aplicaciones C# / .NET habituales a partir de .NET 6, organizamos PeriodicTimer, System.Threading.Timer y DispatcherTimer en un orden que ayude a dudar menos en el trabajo cotidiano.

Los escenarios que tenemos en mente son estos:

  • workers / servicios en segundo plano
  • aplicaciones de consola
  • procesamiento interno de ASP.NET Core
  • aplicaciones de escritorio WPF

En este artículo, DispatcherTimer se refiere principalmente a System.Windows.Threading.DispatcherTimer de WPF. WinUI / UWP tienen un DispatcherTimer con el mismo concepto.

En WinForms, lo natural como temporizador de UI es System.Windows.Forms.Timer. Este artículo centra la explicación del lado de la UI en WPF, pero también incluye WinForms dentro de su alcance. Si sustituye mentalmente DispatcherTimer por System.Windows.Forms.Timer, las advertencias de 4.3 y 5.2 se aplican tal cual. Dicho esto, hay dos diferencias:

  • System.Windows.Forms.Timer es un temporizador de un solo hilo cuyo Tick se produce a través del bucle de mensajes, y no admite la especificación de una prioridad (DispatcherPriority) como DispatcherTimer
  • Según la documentación de Microsoft, la precisión está limitada a unos 55 milisegundos. No es adecuado para períodos finos, así que en ese caso conviene considerar otra opción distinta de los temporizadores de UI

Cabe aclarar que aquí lo que tratamos es cómo escribir la ejecución periódica del lado de la aplicación. Cuando la precisión del período en sí misma es el tema central, hay que volver al artículo anterior sobre soft real-time.

Además, el código que aparece en este artículo está publicado en GitHub como un conjunto de ejemplos que se puede compilar y ejecutar (una biblioteca y una demo de consola para PeriodicTimer / System.Threading.Timer, junto con pruebas unitarias que verifican el plegado de ticks y el solapamiento de callbacks).

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

Índice

  1. Primero, la conclusión (en una frase)
  2. Primero, un resumen en una imagen
    • 2.1. Panorama general
    • 2.2. Tabla de decisión inicial
  3. Distinciones que conviene hacer primero
    • 3.1. ¿Tipo callback o tipo que espera el tick?
    • 3.2. ¿Se ejecuta en ThreadPool o en el hilo de UI?
    • 3.3. El procesamiento periódico y la garantía de precisión son cosas distintas
  4. Patrones típicos
    • 4.1. Para procesamiento periódico async: PeriodicTimer
    • 4.2. Para ejecutar un callback ligero en ThreadPool: System.Threading.Timer
    • 4.3. Para actualizar la UI de WPF: DispatcherTimer
    • 4.4. Para procesamiento periódico cercano al soft real-time, busque otra herramienta
  5. Antipatrones habituales
  6. Lista de verificación para la revisión
  7. Guía rápida de uso
  8. Resumen
  9. Referencias

1. Primero, la conclusión (en una frase)

  • Si quiere escribir de forma natural un procesamiento a intervalos regulares basado en await, empiece por PeriodicTimer
  • Si quiere lanzar periódicamente un callback ligero sobre ThreadPool, use System.Threading.Timer
  • Si quiere actualizar la pantalla en el hilo de UI de WPF, use DispatcherTimer
  • En System.Threading.Timer los callbacks pueden solaparse. Si mete procesamiento asíncrono sin cuidado, es fácil que se desordene
  • DispatcherTimer permite tocar la UI directamente, pero a cambio, si le mete procesamiento pesado, es fácil que detenga toda la UI
  • En el contexto del soft real-time del artículo anterior, estos tres no son los protagonistas de la espera de alta precisión

En resumen, lo primero que hay que mirar son estas tres cosas:

  1. En qué hilo / contexto quiere ejecutarlo
  2. Si quiere escribir el procesamiento principal de forma lineal con async / await
  3. Si puede tolerar el solapamiento de callbacks

Con solo separar estos tres puntos, es mucho más difícil dudar.

2. Primero, un resumen en una imagen

2.1. Panorama general

Árbol de decisión para elegir entre PeriodicTimer, System.Threading.Timer y DispatcherTimerDiagrama de flujo que, partiendo de querer hacer algo a intervalos regulares, pregunta primero si debe ejecutarse en el hilo de UI (en cuyo caso usar DispatcherTimer), luego si el procesamiento principal debe escribirse de forma natural con async/await (en cuyo caso usar PeriodicTimer), y por último si se desea ejecutar un callback ligero en ThreadPool (en cuyo caso usar System.Threading.Timer); si ninguna de las anteriores aplica, sugiere considerar otro diseño como Channel, BackgroundService, eventos o waitable timerNoNoNoQuiero hacer algoa intervalos regulares¿Quiere ejecutarloen el hilo de UI?DispatcherTimer¿Quiere escribir elprocesamiento principalde forma natural conasync / await?PeriodicTimer¿Quiere ejecutar uncallback ligero enThreadPool?System.Threading.TimerConsiderar otro diseñoChannel / BackgroundService / evento / waitable timer

En la práctica, esta bifurcación suele ser suficiente. Cuando dude, lo más difícil de errar es decidir primero que si es procesamiento asíncrono, use PeriodicTimer, y si es actualización de UI, use DispatcherTimer.

System.Threading.Timer es útil, pero tiene sus particularidades en cuanto al solapamiento de callbacks y la gestión de su ciclo de vida, por lo que resulta un poco delicado como primera opción.

2.2. Tabla de decisión inicial

Situación Primera elección Dónde se ejecuta Por qué encaja Primer punto de atención
Quiero ejecutar procesamiento async de HTTP / BD / E/S de archivos a intervalos regulares PeriodicTimer Dentro del flujo del método async actual Se puede escribir con base en await, y la detención y la cancelación son sencillas Se asume 1 temporizador con 1 consumidor. Los retrasos no se paralelizan automáticamente
Quiero ejecutar un heartbeat ligero, el envío de métricas o una verificación de expiración de caché en ThreadPool System.Threading.Timer ThreadPool Es ligero y de tipo callback. Fácil de integrar en un diseño existente basado en callbacks Se asume que el callback es reentrante. Puede solaparse. Hay que conservar la referencia
Quiero actualizar a intervalos regulares un reloj en pantalla o una UI ligera en WPF DispatcherTimer El Dispatcher de WPF (hilo de UI) Permite tocar la UI directamente. Admite prioridad No garantiza el instante exacto de disparo. El procesamiento pesado satura la UI
La precisión del período es el objetivo principal y se quiere evitar depender de Sleep No dar el protagonismo a estos tres - El objetivo deja de ser la ejecución periódica de la aplicación y pasa a ser el diseño de la precisión de espera Consultar el lado de eventos / waitable timer

Lo importante de esta tabla es fijarse en dónde se ejecuta y en cómo se escribe, más que en el nombre del temporizador. Cuando la elección del temporizador sale mal, casi siempre es porque no se prestó atención a “dónde corre”, más que al nombre de la API.

3. Distinciones que conviene hacer primero

3.1. ¿Tipo callback o tipo que espera el tick?

Separar esto aclara mucho el panorama de golpe.

  • System.Threading.Timer y DispatcherTimer son de tipo callback / evento
  • PeriodicTimer es de tipo que espera el tick con await

Es decir:

  • en el tipo callback, “el temporizador es quien llama”
  • en PeriodicTimer, “nosotros esperamos el siguiente tick”

esa es la diferencia.

Si el procesamiento principal es async y quiere leer el flujo “esperar → procesar → volver a esperar” como una única secuencia continua, PeriodicTimer resulta más natural.

Al contrario, en escenarios donde:

  • se quiere integrar en un diseño existente basado en callbacks
  • el procesamiento principal es corto y síncrono
  • simplemente se quiere un disparo periódico simple

System.Threading.Timer encaja bien.

PeriodicTimer es cómodo, pero no es omnipotente. No parte de la base de que se puedan lanzar varios WaitForNextTickAsync simultáneos sobre un mismo temporizador, y si se producen varios ticks mientras nadie está esperando, se pliegan en uno solo.

Es importante no malinterpretar esto como que “se pone al día automáticamente”.

3.2. ¿Se ejecuta en ThreadPool o en el hilo de UI?

Lo siguiente que hay que mirar es dónde se ejecuta.

El callback de System.Threading.Timer no se ejecuta en el hilo que lo creó, sino en ThreadPool. Por eso es adecuado para procesamiento en segundo plano, pero no parte de la base de que se pueda tocar la UI directamente.

DispatcherTimer, en cambio, es un temporizador de UI integrado en la cola del Dispatcher. En WPF se ejecuta en el mismo Dispatcher, de modo que dentro del manejador de Tick se puede actualizar la UI directamente.

Esta diferencia es bastante grande.

  • para tocar la UI desde un temporizador de ThreadPool hay que volver explícitamente a la UI
  • DispatcherTimer facilita tocar la UI, pero a cambio consume tiempo del hilo de UI

Es decir, la fortaleza de DispatcherTimer es que “se puede tocar la UI con seguridad”, pero eso también significa que “si le mete procesamiento pesado, arrastra consigo la entrada y el redibujado”.

3.3. El procesamiento periódico y la garantía de precisión son cosas distintas

Este punto es importante por su conexión con el artículo anterior.

Aunque la expresión “hacer algo a intervalos regulares” sea la misma,

  • por conveniencia de la aplicación, querer un procesamiento periódico cada pocos segundos
  • a nivel de 1 ms a varios ms, querer acercarse lo más posible al deadline

son problemas distintos.

System.Threading.Timer es un temporizador ligero y manejable, pero no es una herramienta dedicada a la precisión. DispatcherTimer también se ve afectado por las circunstancias de la cola del Dispatcher y por la prioridad.

PeriodicTimer, si solo se mira el nombre, parece que “el período debería ser exacto”, pero su fortaleza en la práctica no es tanto la precisión como la facilidad de escribir un flujo async.

Por eso, conviene separar desde el principio si lo que se quiere es:

  • escribir la ejecución periódica de la aplicación, o
  • afinar la precisión de la espera

Si estas dos cosas se mezclan, el debate sobre qué temporizador elegir termina yendo por un rumbo extraño.

4. Patrones típicos

4.1. Para procesamiento periódico async: PeriodicTimer

En workers, BackgroundService, procesos residentes de consola, etc., si quiere ejecutar procesamiento async a intervalos regulares, PeriodicTimer es lo más fácil de escribir como primera opción.

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);
    }
}

Lo bueno de esta forma es que:

  • el flujo del código se puede seguir como un único método async
  • es fácil pasar el CancellationToken tal cual hacia las capas inferiores
  • se reduce la gestión de ciclo de vida y de excepciones propia del modelo de callbacks

En particular, si el procesamiento principal consiste en:

  • llamar a un servicio HTTP
  • consultar una base de datos
  • leer un archivo
  • hacer await de otra API async

es decir, gira principalmente en torno a esperas de E/S, el ajuste es bastante bueno.

Hay dos puntos de atención:

  1. usarlo bajo la premisa de 1 temporizador con 1 consumidor
  2. decidir usted mismo la política a seguir cuando el tiempo de procesamiento supera el período

PeriodicTimer no se pone al día automáticamente en paralelo solo porque el procesamiento anterior se haya alargado. En ese sentido, es un temporizador pensado para “escribir de forma natural un bucle async a intervalos regulares”.

Si además le interesa la facilidad para probarlo, resulta discretamente útil poder usar el constructor que recibe un TimeProvider.

4.2. Para ejecutar un callback ligero en ThreadPool: System.Threading.Timer

Si solo quiere invocar periódicamente un callback corto, System.Threading.Timer es la opción directa.

Por ejemplo, en escenarios como:

  • emitir un heartbeat
  • recoger métricas ligeras
  • realizar una verificación breve de expiración
  • engancharse a un diseño existente basado en callbacks
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();
    }
}

En este ejemplo se incluye Interlocked.Exchange porque System.Threading.Timer no espera a que termine el callback anterior.

Esto es bastante importante:

  • el callback se ejecuta en ThreadPool
  • se asume que el callback es reentrante
  • si el procesamiento dura más que el intervalo, puede solaparse

Este “puede solaparse” se ha dejado en forma observable en las pruebas unitarias del ejemplo. Si se mete un procesamiento que tarda 300 ms en un temporizador con un período de 50 ms y se cuenta el máximo de ejecuciones simultáneas, el resultado es 2 o más. En la versión con la misma protección de Interlocked.Exchange de arriba, bajo las mismas condiciones el máximo de ejecuciones simultáneas se mantiene en 1, y en su lugar se omiten los callbacks que se disparan mientras ya hay uno en curso.

// Extracto de: tests/KomuraSoft.TimerSelection.Tests/ThreadPoolTimerOverlapTests.cs

// Sin protección: período 50ms frente a un procesamiento de 300ms. Espera hasta observar el solapamiento
bool overlapped = await WaitUntilAsync(
    () => Volatile.Read(ref maxObserved) >= 2,
    TimeSpan.FromSeconds(10));

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

// Con protección: bajo las mismas condiciones, el máximo de ejecuciones simultáneas se mantiene en 1
Assert.Equal(1, Volatile.Read(ref maxConcurrent));

Si el procesamiento no es ligero, es más prudente diseñar de alguna de estas formas:

  • omitir los disparos duplicados
  • encolarlos
  • inclinarse hacia PeriodicTimer

Otro punto discretamente importante es conservar la referencia. Aunque System.Threading.Timer esté en funcionamiento, si se pierde la referencia queda sujeto a la recolección de basura (GC). Además, incluso justo después de llamar a Dispose(), un callback que ya estaba en cola puede llegar a ejecutarse más tarde.

Es decir, System.Threading.Timer es:

  • ligero
  • rápido
  • simple

pero a cambio es un temporizador en el que uno mismo debe hacerse cargo con cuidado de las circunstancias del callback.

4.3. Para actualizar la UI de WPF: DispatcherTimer

Si quiere actualizar periódicamente un reloj en pantalla o una indicación de estado ligera en WPF, DispatcherTimer es la opción natural.

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);
    }
}

El DispatcherPriority.Background que se pasa al constructor indica con qué prioridad de la cola del Dispatcher se procesa el Tick. Como new DispatcherTimer() sin argumentos también usa Background por defecto, aquí simplemente se está explicitando el valor predeterminado, sin cambiar el comportamiento. Background (valor 4) es una prioridad que “se procesa después de que termine todo el procesamiento no inactivo”, y está por debajo de Input (5) o Render (7). Es decir, no hace que el Tick se ejecute a costa de desplazar el procesamiento de entrada o el redibujado. Esto encaja bien con usos como la indicación de un reloj, donde “no importa que se desvíe un poco, pero no se quiere estorbar la interacción”. Si quiere que el resultado del Tick se muestre en pantalla lo antes posible, también puede subir hasta Normal (9), pero en ese caso se refuerza aún más la premisa de mantener ligero el contenido del Tick.

Lo bueno de DispatcherTimer es que, como el Tick se procesa en el Dispatcher de WPF, se puede tocar la UI directamente.

Esto encaja bien con escenarios como:

  • la indicación de un reloj
  • la actualización ligera del estado de una conexión
  • el disparador para reevaluar un Command
  • la actualización ligera de valores numéricos mostrados en pantalla

Sin embargo, aquí también cambia el panorama.

Como DispatcherTimer se ejecuta en el hilo de UI, si mete procesamiento pesado en el manejador de Tick, arrastra consigo la entrada, el redibujado y el reposicionamiento, y todo se vuelve lento.

Además, DispatcherTimer no es una herramienta que garantice el disparo “exactamente en el instante indicado”. Se ve afectado por otros trabajos en la cola del Dispatcher y por la prioridad.

Por eso, en la práctica conviene tener presente al menos esto:

  • mantener ligero el contenido del Tick
  • delegar la E/S pesada o el uso intensivo de CPU a otro lugar
  • al cerrar, hacer explícito el fin del ciclo de vida con Stop() y anulando la suscripción

4.4. Para procesamiento periódico cercano al soft real-time, busque otra herramienta

Aquí es donde se conecta con el artículo anterior.

Lo que se trató en el artículo anterior sobre soft real-time no era “que funcione más o menos cada tantos segundos”, sino cómo reducir la fluctuación del período y los deadline miss.

En ese contexto, los temas centrales son:

  • no depender de una espera relativa basada en Sleep
  • usar un diseño orientado a eventos o waitable timers
  • separar el fast path del slow path
  • medir el retraso

Por eso conviene separar el problema desde el principio, así:

  • procesamiento periódico async habitual de una aplicación → PeriodicTimer
  • callback de ThreadPool → System.Threading.Timer
  • actualización de UI → DispatcherTimer
  • la precisión del período como protagonista → el mundo del artículo anterior

La pregunta “quiero que se ejecute lo más exactamente posible cada 1 ms, ¿qué temporizador de .NET conviene?” ya es, en más o menos la mitad, no un problema de elección de temporizador, sino un problema de método de espera y de diseño.

5. Antipatrones habituales

5.1. Pasar directamente una lambda async a System.Threading.Timer

Esto es bastante fácil de hacer sin querer.

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

A simple vista se ve limpio, pero TimerCallback es void. Es decir, esta lambda async se trata, en la práctica, como un async void.

Como consecuencia, se llega a un estado incómodo en el que:

  • el llamador no puede hacer await
  • no se puede esperar a que termine
  • la gestión de excepciones es difícil
  • también hay que pensar aparte en el solapamiento de callbacks

Vale la pena explicar con algo más de claridad por qué la gestión de excepciones es difícil. En un async Task, la excepción queda cargada en el Task, de modo que el llamador la recibe en el momento en que hace await. Un async void (o su equivalente) no tiene ese Task, así que la excepción lanzada se vuelve a lanzar directamente contra el SynchronizationContext que estaba vigente cuando se inició ese método. El callback de System.Threading.Timer se ejecuta en ThreadPool, donde no hay ningún SynchronizationContext. Como resultado, la excepción se convierte en una excepción no controlada en un hilo de ThreadPool y, por defecto, hace caer todo el proceso. A menos que se envuelva el interior del callback con su propio try / catch, nada la detiene desde fuera.

Si el procesamiento principal es async, conviene considerar primero PeriodicTimer, que resulta más fácil de leer.

5.2. Meter procesamiento pesado en el Tick de DispatcherTimer

Como DispatcherTimer permite tocar la UI directamente, resulta tentador escribir cualquier cosa ahí. Pero eso sigue siendo el hilo de UI.

  • procesamiento síncrono largo
  • cálculos pesados de CPU
  • E/S bloqueante
  • procesamiento que puede arrancarse por duplicado e incluye un await largo

si mete algo así, choca de frente con la entrada y el redibujado de la UI.

Es más estable mantener ligero el contenido del Tick, delegar el trabajo pesado al fondo y devolver a la UI solo el resultado necesario.

5.3. Pensar que PeriodicTimer recupera el retraso automáticamente

Este punto también se malinterpreta con facilidad.

PeriodicTimer es una herramienta excelente para escribir de forma limpia un bucle async a intervalos regulares, pero cuando el procesamiento anterior se alarga, no se pone al día ejecutándose en paralelo por sí solo.

Esto también se puede confirmar en las pruebas unitarias del ejemplo. Si deja un temporizador con período de 250 ms sin que nadie lo espere durante 1,5 segundos y luego espera, la primera espera se completa de inmediato gracias a lo acumulado, pero la segunda no se completa de inmediato. Es decir, los varios ticks ocurridos durante el abandono se pliegan en uno solo.

// Extracto de: tests/KomuraSoft.TimerSelection.Tests/PeriodicTimerBehaviorTests.cs

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250));
await Task.Delay(TimeSpan.FromMilliseconds(1500)); // Durante este tiempo, nadie está esperando

// La primera espera se completa de inmediato gracias al tick acumulado
ValueTask<bool> first = timer.WaitForNextTickAsync();
Assert.True(first.IsCompleted);
Assert.True(await first);

// La segunda espera no se completa de inmediato (no quedan ticks de varias veces)
ValueTask<bool> second = timer.WaitForNextTickAsync();
Assert.False(second.IsCompleted);

Como los ticks ocurridos mientras nadie espera pueden plegarse en uno solo, hay que decidir en el diseño:

  • si se salta el retraso
  • si basta con ver solo el más reciente
  • si se quiere procesar obligatoriamente todas las veces

5.4. Dejar para después la detención y la gestión del ciclo de vida

Con los temporizadores, es más fácil que ocurran accidentes al detenerlos que al ponerlos en marcha.

Esto es lo que se suele pasar por alto:

  • crear System.Threading.Timer como variable local sin conservar la referencia
  • dejar ambiguo el manejo de Dispose() sin detener antes System.Threading.Timer
  • no llamar a Stop() en DispatcherTimer ni anular la suscripción al Tick
  • que el temporizador siga prolongando el ciclo de vida del objeto incluso después de cerrar la pantalla

En particular, DispatcherTimer puede mantener con vida al objeto al que está enlazado el método. Si nota algo extraño como “esta Window debería estar cerrada, pero sigue por aquí”, conviene sospechar de esto.

6. Lista de verificación para la revisión

  • ¿Se puede explicar si ese procesamiento periódico debería escribirse como actualización de UI, callback de ThreadPool o bucle async?
  • ¿Se está forzando un procesamiento principal async dentro de un temporizador de tipo callback?
  • Si usa System.Threading.Timer, ¿puede tolerar el solapamiento de callbacks, o lo está protegiendo?
  • ¿Se ha metido procesamiento pesado, E/S bloqueante o procesamiento síncrono largo en el Tick de DispatcherTimer?
  • Si usa PeriodicTimer, ¿está decidida la política a seguir cuando hay retraso?
  • ¿Es clara la forma de detenerlo (Change / Dispose / Stop) y el flujo al cerrar la aplicación?
  • ¿Se está conservando correctamente la referencia a System.Threading.Timer?
  • ¿Hay anulación de suscripción y limpieza al cerrar la pantalla para DispatcherTimer?
  • ¿Se ha separado desde el principio si el problema es “la ejecución periódica de la aplicación” o “la precisión de la espera”?

7. Guía rápida de uso

Aquí van algunas pautas orientativas para el trabajo diario.

  • Quiero llamar a una API cada 30 segundos para actualizar la configuración → PeriodicTimer

  • Quiero enviar un heartbeat o métricas ligeras cada 5 segundos → System.Threading.Timer

  • Quiero mostrar un reloj o actualizar un estado ligero en WPF → DispatcherTimer

  • Quiero tocar la UI directamente en cada Tick → DispatcherTimer

  • El procesamiento periódico principal está lleno de await y quiero manejar de forma natural también la detención y las excepciones → PeriodicTimer

  • Quiero meter pequeños disparos basados en callback con bajo costo → System.Threading.Timer

  • El objetivo principal es la precisión del período o el control de la fluctuación a nivel de 1 a 5 ms → antes que estos tres, consulte el método de espera del artículo anterior

Dicho de forma bastante simplificada en una línea:

  • PeriodicTimer es el temporizador para async
  • System.Threading.Timer es el temporizador para callbacks de ThreadPool
  • DispatcherTimer es el temporizador para la UI

Con esta forma de recordarlo, es difícil equivocarse mucho.

8. Resumen

Lo verdaderamente importante al elegir un temporizador en .NET no es la diferencia de nombres, sino estos tres puntos:

  1. Dónde se ejecuta
  2. Qué flujo se quiere escribir
  3. Cómo manejar el solapamiento y el retraso

Como política, con esto basta para desenvolverse bien:

  1. Si es procesamiento periódico async: PeriodicTimer
  2. Si es un callback ligero en ThreadPool: System.Threading.Timer
  3. Si es actualización de UI en WPF: DispatcherTimer
  4. Si la precisión es el protagonista: consulte otro método de espera

Los temporizadores se confunden porque sus nombres se parecen. Pero sus roles no se parecen tanto.

  • PeriodicTimer es una herramienta para ordenar el flujo async
  • System.Threading.Timer es una herramienta para disparar callbacks periódicamente
  • DispatcherTimer es una herramienta para actualizar periódicamente en el hilo de UI

Con solo pensar estos tres por separado, el código queda bastante más tranquilo.

Al contrario, si se mezclan,

  • que algo que debería ser async termine pareciendo async void
  • que se toque la UI directamente y todo se caiga
  • que los callbacks se solapen y el estado se enturbie
  • que hasta el tema de la precisión del período se meta todo en el mismo saco

ocurren cosas bastante molestas y comunes como estas.

Primero, mire “dónde quiere que se ejecute”. Solo con eso, la elección del temporizador se vuelve bastante más tranquila.

9. Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Cuál es la diferencia entre PeriodicTimer y System.Threading.Timer?
La diferencia más importante es que PeriodicTimer es de tipo que espera el tick con await, mientras que System.Threading.Timer es de tipo callback. Con PeriodicTimer se puede escribir el flujo «esperar → procesar → volver a esperar» como un único método async, y es fácil pasar el CancellationToken hacia las capas inferiores. En cambio, el callback de System.Threading.Timer se ejecuta en ThreadPool y no espera a que termine el callback anterior, así que si el procesamiento dura más que el intervalo, puede solaparse. Para procesamiento periódico asíncrono conviene PeriodicTimer; para un disparo periódico ligero y síncrono basado en callback, conviene System.Threading.Timer.
¿PeriodicTimer se pone al día automáticamente cuando el procesamiento se retrasa?
No. Aunque el procesamiento anterior se alargue, no se pone al día ejecutándose en paralelo por sí solo. Si se producen varios ticks mientras nadie está esperando, se pliegan en uno solo. Por eso hay que decidir en el diseño si se salta el retraso, si basta con ver solo el más reciente, o si se quiere procesar obligatoriamente todas las veces. Tampoco se parte de la base de poder lanzar varios WaitForNextTickAsync simultáneos sobre un mismo temporizador.
¿No se debe pasar una lambda async a System.Threading.Timer?
Es mejor evitarlo. Como TimerCallback es void, la lambda async que se le pasa se trata, en la práctica, como un async void. El llamador no puede hacer await, no puede esperar a que termine, la gestión de excepciones se complica, y además hay que pensar aparte en el solapamiento de callbacks. Si el procesamiento principal es async, conviene considerar primero PeriodicTimer, que resulta más fácil de leer y más seguro.
¿Cuándo conviene usar DispatcherTimer?
Se usa cuando se quiere actualizar la UI periódicamente en WPF, por ejemplo para mostrar un reloj o una indicación de estado ligera. Como el Tick se procesa en el Dispatcher (el hilo de UI) de WPF, su punto fuerte es poder tocar la UI directamente dentro del manejador. Sin embargo, como se ejecuta en el hilo de UI, si mete procesamiento pesado o E/S bloqueante en el Tick, arrastra consigo también la entrada y el redibujado, y todo se vuelve lento. Tampoco garantiza el disparo exactamente en el instante indicado. Es estable mantener ligero el contenido del Tick, delegar el trabajo pesado al fondo y, al cerrar la pantalla, hacer explícito el fin del ciclo de vida con Stop() y anulando la suscripción.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog