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
Dispatcherdel 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
asyncaSystem.Threading.Timercuando 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
DispatcherTimery 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.Timeres 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) comoDispatcherTimer- 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
- Primero, la conclusión (en una frase)
- Primero, un resumen en una imagen
- 2.1. Panorama general
- 2.2. Tabla de decisión inicial
- 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
- 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
- 4.1. Para procesamiento periódico async:
- Antipatrones habituales
- Lista de verificación para la revisión
- Guía rápida de uso
- Resumen
- Referencias
1. Primero, la conclusión (en una frase)
- Si quiere escribir de forma natural un procesamiento a intervalos regulares basado en
await, empiece porPeriodicTimer - 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.Timerlos callbacks pueden solaparse. Si mete procesamiento asíncrono sin cuidado, es fácil que se desordene DispatcherTimerpermite 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:
- En qué hilo / contexto quiere ejecutarlo
- Si quiere escribir el procesamiento principal de forma lineal con
async/await - 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
flowchart LR
accTitle: Árbol de decisión para elegir entre PeriodicTimer, System.Threading.Timer y DispatcherTimer
accDescr: Diagrama 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 timer
A["Quiero hacer algo<br/>a intervalos regulares"] --> B{"¿Quiere ejecutarlo<br/>en el hilo de UI?"}
B -- "Sí" --> C["DispatcherTimer"]
B -- "No" --> D{"¿Quiere escribir el<br/>procesamiento principal<br/>de forma natural con<br/>async / await?"}
D -- "Sí" --> E["PeriodicTimer"]
D -- "No" --> F{"¿Quiere ejecutar un<br/>callback ligero en<br/>ThreadPool?"}
F -- "Sí" --> G["System.Threading.Timer"]
F -- "No" --> H["Considerar otro diseño<br/>Channel / 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.TimeryDispatcherTimerson de tipo callback / eventoPeriodicTimeres de tipo que espera el tick conawait
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
DispatcherTimerfacilita 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
CancellationTokental 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
awaitde 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:
- usarlo bajo la premisa de 1 temporizador con 1 consumidor
- 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
awaitlargo
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.Timercomo variable local sin conservar la referencia - dejar ambiguo el manejo de
Dispose()sin detener antesSystem.Threading.Timer - no llamar a
Stop()enDispatcherTimerni 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
awaity 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:
PeriodicTimeres el temporizador para asyncSystem.Threading.Timeres el temporizador para callbacks de ThreadPoolDispatcherTimeres 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:
- Dónde se ejecuta
- Qué flujo se quiere escribir
- Cómo manejar el solapamiento y el retraso
Como política, con esto basta para desenvolverse bien:
- Si es procesamiento periódico async:
PeriodicTimer - Si es un callback ligero en ThreadPool:
System.Threading.Timer - Si es actualización de UI en WPF:
DispatcherTimer - 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.
PeriodicTimeres una herramienta para ordenar el flujo asyncSystem.Threading.Timeres una herramienta para disparar callbacks periódicamenteDispatcherTimeres 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
- El conjunto completo de código de ejemplo de este artículo (biblioteca, demo, pruebas unitarias) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/periodictimer-system-threading-timer-dispatchertimer-guide
- Artículo relacionado: 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
- Artículo relacionado: Tabla de decisión práctica para async/await en C# - Task.Run y ConfigureAwait
- Artículo relacionado: async y el hilo de UI en WPF/WinForms en una sola imagen
- Timers - .NET
- PeriodicTimer Class
- PeriodicTimer.WaitForNextTickAsync(CancellationToken) Method
- PeriodicTimer.Dispose Method
- PeriodicTimer Constructor
- Timer Class (System.Threading)
- Timer Constructor (System.Threading)
- Background tasks with hosted services in ASP.NET Core
- DispatcherTimer Class (System.Windows.Threading)
- DispatcherTimer Class (Microsoft.UI.Xaml)
- DispatcherTimer Constructor (por defecto, prioridad Background)
- DispatcherPriority Enum
- Timer Class (System.Windows.Forms) (la precisión es de unos 55 milisegundos)
- Async/Await - Best Practices in Asynchronous Programming (el manejo de excepciones de async void)
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Por qué usar .NET Generic Host y BackgroundService en una aplicación de escritorio
Resumimos cómo usar Generic Host y BackgroundService en herramientas y apps residentes de Windows para organizar el inicio, el procesamie...
Buenas prácticas de multithreading en la práctica — Edición .NET: qué decidir antes de aumentar los hilos
Reglas de diseño en .NET/C# para evitar fallos y bloqueos intermitentes con hilos: usar Task en lugar de hilos propios, reducir el estado...
Diseño de códigos en sistemas empresariales ── Cómo definir códigos de producto y cliente, y el dígito de control
Guía práctica para diseñar códigos de producto y cliente en sistemas empresariales: código significativo frente a secuencial, fórmulas de...
Práctica de CI/CD para aplicaciones WinForms / WPF — automatizar desde la compilación hasta la firma y la distribución con GitHub Actions
CI/CD para WinForms/WPF con GitHub Actions: build y pruebas, versión por tags, firma con signtool y tabla de decisión por formato de dist...
Suspensión, hibernación y Modern Standby: evitar con diseño que las apps de larga duración "se detengan de noche"
Analiza por qué las apps Windows de larga duración se detienen de noche, según las diferencias entre S3, hibernación y Modern Standby. Ex...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Hilo de UI y temporizadores
Hilo de UI de WPF / WinForms, flujos asíncronos, Dispatcher y diseño de temporizadores.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
En el desarrollo de aplicaciones Windows que incluyen ejecución periódica, actualización de la UI y procesamiento en segundo plano, la elección del temporizador incide directamente en la calidad de la implementación.
Consultoría técnica y revisión de diseño
Si está en la etapa de definir dónde separar las responsabilidades entre PeriodicTimer y DispatcherTimer, puede plantearlo como una consultoría técnica o una revisión de diseño.
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.