Die richtige Wahl unter .NETs drei Timern – PeriodicTimer/Timer/DispatcherTimer

· · C#, .NET, WPF, Timer, Design

Im vorherigen Artikel Praxisleitfaden, um unter normalem Windows so weit wie möglich Soft-Realtime zu erreichen – die Checkliste, die man zuerst prüfen sollte haben wir uns damit befasst, periodische Schleifen, die sich auf Sleep verlassen, zu vermeiden und stattdessen ereignisgesteuerte Ansätze sowie waitable Timer zu verwenden. Kurz gesagt lautete das Fazit dieses Artikels: Wenn Sie Jitter der Periode und verpasste Deadlines reduzieren wollen, sollten Sie nicht zuerst die Art des Timers, sondern die „Art des Wartens“ selbst gestalten.

Wie ist das nun in der alltäglicheren .NET-Anwendungsentwicklung? Hier verwirren sich leicht PeriodicTimer, System.Threading.Timer und DispatcherTimer.

Alle drei heißen „Timer“, aber

  • ein Timer, bei dem Sie den Tick per await abwarten
  • ein Timer, dessen Callbacks auf dem ThreadPool eintreffen
  • ein Timer, der auf dem Dispatcher des UI-Threads läuft

sind vom Charakter her recht unterschiedlich.

In der Praxis verwechselt man meist Folgendes.

  • Man übergibt System.Threading.Timer eine async-Lambda, obwohl die periodische Verarbeitung asynchron ist
  • Man greift von einem ThreadPool-Timer direkt auf den Bildschirm zu, obwohl es sich um ein WPF-UI-Update handelt
  • Man packt schwere Verarbeitung in einen DispatcherTimer und verlangsamt damit die ganze Oberfläche
  • Die vorherige „Soft-Realtime“-Diskussion und die periodische Ausführung einer gewöhnlichen App vermischen sich im Kopf

Dieser Artikel geht überwiegend von allgemeinen C#-/.NET-Anwendungen ab .NET 6 aus und ordnet PeriodicTimer / System.Threading.Timer / DispatcherTimer in einer Reihenfolge, die im Alltag der Praxis möglichst wenig Verwirrung stiftet.

Als Zielgruppe sind dabei etwa diese gemeint:

  • Worker / Hintergrunddienste
  • Konsolenanwendungen
  • Verarbeitung hinter den Kulissen in ASP.NET Core
  • WPF-Desktopanwendungen

Mit DispatcherTimer ist in diesem Artikel hauptsächlich WPFs System.Windows.Threading.DispatcherTimer gemeint. Auch WinUI / UWP besitzen einen DispatcherTimer mit demselben Grundgedanken.

Bei WinForms ist es natürlicher, als UI-Timer System.Windows.Forms.Timer zu betrachten. Dieser Artikel richtet die Erklärung auf der UI-Seite an WPF aus, schließt WinForms aber mit ein. Ersetzen Sie DispatcherTimer gedanklich durch System.Windows.Forms.Timer, gelten die Hinweise aus 4.3 und 5.2 unverändert. Dabei unterscheiden sich nur diese zwei Punkte:

  • System.Windows.Forms.Timer ist ein Single-Thread-Timer, bei dem Tick über die Nachrichtenschleife ausgelöst wird; eine Priorität wie bei DispatcherTimer (DispatcherPriority) gibt es nicht
  • Laut Microsoft-Dokumentation liegt die Genauigkeit bei rund 55 Millisekunden. Für feine Perioden eignet er sich daher nicht; in diesem Fall sollten Sie etwas anderes als einen UI-Timer in Betracht ziehen

Zu beachten ist außerdem: Hier geht es um die Frage, wie man die periodische Ausführung auf Anwendungsseite schreibt. Wenn die Genauigkeit der Periode selbst das Thema ist, kehren wir zum vorherigen Soft-Realtime-Artikel zurück.

Der in diesem Artikel gezeigte Code ist außerdem als vollständiges, build- und lauffähiges Beispielpaket auf GitHub veröffentlicht (Bibliotheken und Konsolen-Demos zu PeriodicTimer / System.Threading.Timer sowie Unit-Tests, die das Zusammenfallen von Ticks und die Überlappung von Callbacks überprüfen).

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

Inhaltsverzeichnis

  1. Zuerst das Fazit (in einem Satz)
  2. Erst einmal auf einen Blick
    • 2.1. Das Gesamtbild
    • 2.2. Die erste Entscheidungstabelle
  3. Was Sie zuerst unterscheiden sollten
    • 3.1. Callback-Typ oder Typ, der auf Tick wartet?
    • 3.2. Läuft es auf dem ThreadPool oder auf dem UI-Thread?
    • 3.3. Periodische Verarbeitung und Genauigkeitsgarantie sind getrennte Themen
  4. Typische Muster
    • 4.1. Für asynchrone periodische Verarbeitung: PeriodicTimer
    • 4.2. Für leichte Callbacks auf dem ThreadPool: System.Threading.Timer
    • 4.3. Für WPF-UI-Updates: DispatcherTimer
    • 4.4. Für eher soft-realtime-artige periodische Verarbeitung: ein anderes Werkzeug ansehen
  5. Häufige Antipatterns
  6. Checkliste fürs Review
  7. Grobe Entscheidungshilfe
  8. Zusammenfassung
  9. Quellen

1. Zuerst das Fazit (in einem Satz)

  • Wollen Sie eine Verarbeitung in festen Abständen natürlich auf await-Basis schreiben, greifen Sie zuerst zu PeriodicTimer
  • Wollen Sie leichte Callbacks periodisch auf dem ThreadPool auslösen, verwenden Sie System.Threading.Timer
  • Wollen Sie auf WPFs UI-Thread die Oberfläche aktualisieren, verwenden Sie DispatcherTimer
  • Bei System.Threading.Timer können sich Callbacks überlappen. Wird asynchrone Verarbeitung dort nachlässig hineingestopft, wird es schnell unübersichtlich
  • DispatcherTimer erlaubt den direkten Zugriff auf die UI, aber im Gegenzug bremst schwere Verarbeitung darin leicht die gesamte Oberfläche aus
  • Im Kontext des vorherigen Soft-Realtime-Artikels sind diese drei nicht die Hauptdarsteller für hochpräzises Warten

Kurz gesagt sollten Sie zuerst diese drei Dinge betrachten:

  1. Auf welchem Thread / in welchem Kontext soll es laufen?
  2. Wollen Sie den Verarbeitungskörper durchgängig mit async / await schreiben?
  3. Können Sie überlappende Callbacks tolerieren?

Allein durch die Trennung dieser drei Punkte wird die Entscheidung deutlich weniger verwirrend.

2. Erst einmal auf einen Blick

2.1. Das Gesamtbild

JaNeinJaNeinJaNeinEtwas in festen Abständen tunSoll es auf dem UI-Thread laufen?DispatcherTimerSoll der Verarbeitungskörperdirekt mitasync / await geschrieben werden?PeriodicTimerSoll ein leichterCallback auf demThreadPool laufen?System.Threading.TimerAnderes Design prüfenChannel / BackgroundService / Event / waitable Timer

In der Praxis reicht diese Verzweigung meist aus. Im Zweifel liegen Sie am seltensten falsch, wenn Sie zuerst so trennen: PeriodicTimer für asynchrone Verarbeitung, DispatcherTimer für UI-Updates.

System.Threading.Timer ist praktisch, hat aber mit der Überlappung von Callbacks und den Eigenheiten der Lebensdauerverwaltung einige Tücken, weshalb er als allererste Wahl etwas anspruchsvoll ist.

2.2. Die erste Entscheidungstabelle

Situation Erste Wahl Wo es läuft Warum es passt Erste Vorsicht
In festen Abständen async-Verarbeitung wie HTTP / DB / Datei-I/O ausführen PeriodicTimer Innerhalb des Ablaufs der aktuellen async-Methode Auf await-Basis schreibbar, Stoppen und Abbrechen sind natürlich Auf Basis von einem Timer, einem Konsumenten. Verzögerungen werden nicht automatisch parallelisiert
Leichte Heartbeats / Metrikversand / Ablaufprüfungen des Caches auf dem ThreadPool ausführen System.Threading.Timer ThreadPool Leichtgewichtig und Callback-basiert. Lässt sich leicht in ein bestehendes Callback-Design einfügen Callbacks müssen reentrant sein. Überlappung möglich. Referenz muss gehalten werden
Uhrzeitanzeige oder leichte UI-Updates in WPF in festen Abständen ausführen DispatcherTimer WPFs Dispatcher (UI-Thread) UI direkt anfassbar. Besitzt Prioritäten Exakte Auslösezeitpunkte nicht garantiert. Schwere Verarbeitung blockiert die UI
Die Genauigkeit der Periode ist der eigentliche Punkt, Sleep-basiertes Warten soll vermieden werden Diese drei nicht in den Vordergrund stellen Ziel ist nicht die periodische Ausführung der App, sondern das Design der Wartepräzision Event / waitable Timer betrachten

Wichtig an dieser Tabelle ist, nicht auf den Timer-Namen zu schauen, sondern auf Ausführungsort und Schreibweise. Wenn die Timer-Wahl schiefgeht, liegt es häufiger daran, dass man nicht auf „wo es läuft“ geachtet hat, als am API-Namen.

3. Was Sie zuerst unterscheiden sollten

3.1. Callback-Typ oder Typ, der auf Tick wartet?

Wird das getrennt, wird der Überblick auf einen Schlag klarer.

  • System.Threading.Timer und DispatcherTimer sind Callback-/Event-Typen
  • PeriodicTimer ist der Typ, bei dem Sie den Tick per await abwarten

Anders gesagt:

  • Callback-Typ: „Der Timer ruft mich an“
  • PeriodicTimer: „Ich warte auf den nächsten Tick“

Ist der Verarbeitungskörper async und wollen Sie „warten → verarbeiten → wieder warten“ als einen durchgehenden Ablauf lesen, ist PeriodicTimer die natürlichere Wahl.

Umgekehrt passt System.Threading.Timer, wenn:

  • Sie sich in ein bestehendes Callback-basiertes Design einfügen wollen
  • der Verarbeitungskörper kurz und synchron ist
  • Sie einfach einen periodischen Kick auslösen wollen

PeriodicTimer ist praktisch, aber kein Allheilmittel. Er ist nicht dafür ausgelegt, dass gegen denselben Timer gleichzeitig mehrere WaitForNextTickAsync-Aufrufe laufen, und wenn während der Zeit, in der Sie nicht warten, mehrere Ticks auftreten, werden diese zu einem einzigen zusammengefasst.

Wichtig ist, dies nicht als „holt automatisch auf“ misszuverstehen.

3.2. Läuft es auf dem ThreadPool oder auf dem UI-Thread?

Als Nächstes sollten Sie betrachten, wo die Ausführung stattfindet.

Der Callback von System.Threading.Timer läuft nicht auf dem erstellenden Thread, sondern auf dem ThreadPool. Das eignet sich zwar für Hintergrundverarbeitung, ist aber nicht dafür gedacht, die UI direkt anzufassen.

DispatcherTimer hingegen ist ein UI-Timer, der in die Dispatcher-Warteschlange integriert ist. In WPF läuft er auf demselben Dispatcher, sodass Sie im Tick-Handler die UI direkt aktualisieren können.

Dieser Unterschied ist erheblich.

  • Um von einem ThreadPool-Timer aus die UI anzufassen, müssen Sie explizit zur UI zurückwechseln
  • DispatcherTimer macht den Zugriff auf die UI leicht, verbraucht dafür aber entsprechend Zeit auf dem UI-Thread

DispatcherTimers Stärke ist also, dass man „die UI sicher anfassen“ kann, was gleichzeitig bedeutet, dass „schwere Verarbeitung darin auch Eingabe und Neuzeichnen mit hineinzieht“.

3.3. Periodische Verarbeitung und Genauigkeitsgarantie sind getrennte Themen

Dieser Punkt ist als Anknüpfung an den vorherigen Artikel wichtig.

Auch wenn die Formulierung „etwas in festen Abständen tun“ dieselbe ist,

  • als Bequemlichkeit der App alle paar Sekunden etwas periodisch zu tun, und
  • im Bereich von 1 ms bis wenigen ms so nah wie möglich an eine Deadline heranzukommen,

sind unterschiedliche Probleme.

System.Threading.Timer ist ein leichtgewichtiger, einfach zu handhabender Timer, aber kein dediziertes Werkzeug für Genauigkeit. Auch DispatcherTimer wird von den Umständen der Dispatcher-Warteschlange und von Prioritäten beeinflusst.

Auch PeriodicTimer wirkt allein vom Namen her so, als sei „die Periode besonders exakt“, aber seine praktische Stärke liegt weniger in der Präzision als in der Schreibbarkeit des async-Ablaufs.

Es ist daher sicherer, von vornherein zu trennen, ob Sie

  • die periodische Ausführung der App schreiben wollen, oder
  • die Wartepräzision verfeinern wollen.

Vermischen sich diese beiden, driftet die Diskussion über die Timer-Wahl allmählich in eine seltsame Richtung.

4. Typische Muster

4.1. Für asynchrone periodische Verarbeitung: PeriodicTimer

Wollen Sie in einem Worker, einem BackgroundService oder einem dauerhaft laufenden Konsolenprozess in festen Abständen asynchrone Verarbeitung ausführen, lässt sich das zuerst am einfachsten mit PeriodicTimer schreiben.

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

Die Vorzüge dieser Form:

  • Der Codeablauf lässt sich als eine einzige async-Methode gut verfolgen
  • Das CancellationToken lässt sich direkt nach unten weiterreichen
  • Weniger Callback-basierte Lebensdauer- und Ausnahmeverwaltung

Besonders gut passt das, wenn der Verarbeitungskörper

  • HTTP aufruft
  • eine DB abfragt
  • eine Datei liest
  • eine andere async-API awaitet

also im Wesentlichen auf I/O-Wartezeiten beruht.

Es gibt zwei Vorsichtspunkte.

  1. Auf Basis von einem Timer, einem Konsumenten verwenden
  2. Selbst die Strategie festlegen, wenn die Verarbeitungszeit länger ist als die Periode

PeriodicTimer parallelisiert nicht automatisch, um aufzuholen, nur weil die vorherige Verarbeitung länger gedauert hat. In diesem Sinne ist er ein Timer, um „eine asynchrone Schleife in festen Abständen natürlich zu schreiben“.

Wer auch die Testbarkeit im Blick hat: Es gibt auch einen Konstruktor, der einen TimeProvider entgegennimmt, was praktisch ist.

4.2. Für leichte Callbacks auf dem ThreadPool: System.Threading.Timer

Wollen Sie lediglich periodisch einen kurzen Callback aufrufen, ist System.Threading.Timer unkompliziert.

Zum Beispiel bei:

  • einem Heartbeat
  • dem Erfassen leichter Metriken
  • einer kurzen Ablaufprüfung
  • dem Einhängen in ein bestehendes Callback-basiertes Design
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();
    }
}

Dieses Beispiel enthält Interlocked.Exchange, weil System.Threading.Timer nicht auf den Abschluss des vorherigen Callbacks wartet.

Das ist ziemlich wichtig.

  • Callbacks laufen auf dem ThreadPool
  • Callbacks müssen reentrant sein
  • Ist die Verarbeitung länger als das Intervall, können sie sich überlappen

Diese „mögliche Überlappung“ lässt sich in den Unit-Tests des Beispiels tatsächlich beobachten. Bei einem Timer mit 50 ms Periode und einer Verarbeitung, die 300 ms dauert, steigt die maximale Zahl gleichzeitiger Ausführungen auf 2 oder mehr. In der Version mit derselben Interlocked.Exchange-Absicherung wie oben bleibt die maximale Zahl gleichzeitiger Ausführungen unter denselben Bedingungen bei 1, stattdessen werden während der Ausführung ausgelöste Callbacks übersprungen.

// Auszug aus tests/KomuraSoft.TimerSelection.Tests/ThreadPoolTimerOverlapTests.cs

// Ohne Absicherung: Periode 50 ms, Verarbeitung 300 ms. Warten, bis eine Überlappung beobachtet wird
bool overlapped = await WaitUntilAsync(
    () => Volatile.Read(ref maxObserved) >= 2,
    TimeSpan.FromSeconds(10));

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

// Mit Absicherung: Die maximale Zahl gleichzeitiger Ausführungen bleibt unter denselben Bedingungen bei 1
Assert.Equal(1, Volatile.Read(ref maxConcurrent));

Ist die Verarbeitung nicht leicht, ist es ruhiger, sie so zu gestalten, dass Sie:

  • doppelte Aufrufe überspringen
  • in eine Warteschlange einreihen
  • auf PeriodicTimer umsteigen

Ein weiterer, unauffällig wichtiger Punkt: die Referenz halten. System.Threading.Timer wird auch während des Laufens zum GC-Kandidaten, sobald keine Referenz mehr existiert. Auch direkt nach dem Aufruf von Dispose() kann ein bereits eingereihter Callback noch nachträglich ausgeführt werden.

System.Threading.Timer ist also

  • leichtgewichtig
  • schnell
  • einfach

aber im Gegenzug ein Timer, bei dem Sie sich selbst ordentlich um die Belange des Callbacks kümmern müssen.

4.3. Für WPF-UI-Updates: DispatcherTimer

Wollen Sie in WPF eine Uhr auf dem Bildschirm oder eine leichte Statusanzeige periodisch aktualisieren, ist DispatcherTimer naheliegend.

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

Das im Konstruktor übergebene DispatcherPriority.Background gibt an, mit welcher Priorität der Dispatcher-Warteschlange der Tick verarbeitet wird. Auch das parameterlose new DispatcherTimer() verwendet standardmäßig Background; hier wird also nur der Standardwert explizit gemacht, ohne das Verhalten zu ändern. Background (Wert 4) ist die Priorität „verarbeiten, nachdem alle anderen Nicht-Leerlauf-Vorgänge abgeschlossen sind“ und liegt niedriger als Input (5) oder Render (7). Das bedeutet: Tick wird nicht auf Kosten von Eingabeverarbeitung oder Rendering ausgeführt. Für eine Verwendung wie die Uhrzeitanzeige, bei der „eine kleine Abweichung nichts ausmacht, aber die Bedienung nicht gestört werden soll“, passt das gut. Wollen Sie das Ergebnis des Ticks so schnell wie möglich auf dem Bildschirm sehen, können Sie auch bis Normal (9) hochgehen — dann müssen Sie aber umso strenger darauf achten, den Inhalt des Ticks leicht zu halten.

Der Vorzug von DispatcherTimer ist, dass Tick auf WPFs Dispatcher verarbeitet wird und Sie deshalb die UI direkt anfassen können.

Das passt zum Beispiel gut zu:

  • einer Uhrzeitanzeige
  • einer leichten Aktualisierung der Verbindungsstatusanzeige
  • dem Auslöser für die Neubewertung eines Command
  • einer leichten Aktualisierung von auf dem Bildschirm angezeigten Zahlen

Aber auch hier gibt es einen Punkt, an dem sich die Lage ändert.

DispatcherTimer läuft auf dem UI-Thread, sodass schwere Verarbeitung im Tick-Handler direkt Eingabe, Rendering und Layout mit ausbremst.

Außerdem ist DispatcherTimer kein Werkzeug, das „exakt zum angegebenen Zeitpunkt“ garantiert. Er wird von anderer Arbeit in der Dispatcher-Warteschlange und von Prioritäten beeinflusst.

In der Praxis bleibt es also stabil, wenn Sie im Kopf behalten:

  • den Inhalt des Ticks leicht zu halten
  • schwere I/O oder CPU-Arbeit an anderer Stelle auszulagern
  • beim Schließen Stop() aufzurufen und die Ereignisbehandlung abzumelden, um die Lebensdauer explizit zu machen

4.4. Für eher soft-realtime-artige periodische Verarbeitung: ein anderes Werkzeug ansehen

Das ist der Anknüpfungspunkt an den vorherigen Artikel.

Worum es im vorherigen Soft-Realtime-Artikel ging, war nicht „ungefähr alle paar Sekunden reicht“, sondern wie man Jitter der Periode und verpasste Deadlines reduziert.

In diesem Kontext geht es um:

  • kein Sleep-basiertes relatives Warten
  • den Einsatz ereignisgesteuerter Ansätze und waitabler Timer
  • die Trennung von Fast Path und Slow Path
  • das Messen von Verzögerungen

Es ist daher am saubersten, das Problem von vornherein so zu trennen:

  • alltägliche asynchrone periodische Verarbeitung in einer App → PeriodicTimer
  • ThreadPool-Callback → System.Threading.Timer
  • UI-Update → DispatcherTimer
  • die Genauigkeit der Periode selbst steht im Vordergrund → die Welt des vorherigen Artikels

Die Frage „Ich will so exakt wie möglich alle 1 ms laufen — welcher .NET-Timer ist gut?“ ist zur Hälfte gar keine Timer-Wahl mehr, sondern eine Frage der Wartemethode und des Designs.

5. Häufige Antipatterns

5.1. System.Threading.Timer direkt eine async-Lambda übergeben

Das passiert ziemlich leicht.

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

Optisch wirkt das aufgeräumt, aber TimerCallback ist void. Das heißt, diese async-Lambda wird faktisch wie async void behandelt.

Dadurch entsteht ein schwer handhabbarer Zustand:

  • Die aufrufende Seite kann nicht awaiten
  • Der Abschluss lässt sich nicht abwarten
  • Das Ausnahme-Management wird schwierig
  • Die Überlappung von Callbacks muss zusätzlich bedacht werden

Warum das Ausnahme-Management schwierig wird, ist eine genauere Erklärung wert. Bei async Task liegt die Ausnahme im Task, sodass die aufrufende Seite sie beim await erhält. Bei async void (bzw. dessen Entsprechung) gibt es diesen Task nicht, weshalb eine geworfene Ausnahme direkt an den SynchronizationContext erneut geworfen wird, der beim Start dieser Methode gültig war. Der Callback von System.Threading.Timer läuft auf dem ThreadPool, wo es keinen SynchronizationContext gibt. Die Ausnahme wird dadurch zu einer unbehandelten Ausnahme auf einem ThreadPool-Thread, was standardmäßig den gesamten Prozess beendet. Sofern Sie den Inhalt des Callbacks nicht selbst mit try / catch umschließen, lässt sich das von außen nicht auffangen.

Ist der Verarbeitungskörper async, ist es lesbarer, zunächst PeriodicTimer in Betracht zu ziehen.

5.2. Schwere Verarbeitung in den Tick von DispatcherTimer legen

Da DispatcherTimer die UI direkt anfassen lässt, ist die Versuchung groß, alles Mögliche dort hineinzuschreiben. Das ist aber der UI-Thread.

  • lange synchrone Verarbeitung
  • schwere CPU-Berechnung
  • blockierende I/O
  • Verarbeitung mit langem await, die doppelt ausgelöst werden kann

führt zu einem direkten Zusammenstoß mit Eingabe und Rendering der UI.

Stabiler ist es, den Inhalt des Ticks leicht zu halten, schwere Arbeit in den Hintergrund auszulagern und nur das benötigte Ergebnis an die UI zurückzugeben.

5.3. Glauben, PeriodicTimer würde eine Verzögerung automatisch aufholen

Auch das ist ein leicht entstehendes Missverständnis.

PeriodicTimer ist als Werkzeug hervorragend, um eine asynchrone Schleife in festen Abständen sauber zu schreiben, aber er parallelisiert nicht automatisch, um aufzuholen, wenn die vorherige Verarbeitung länger gedauert hat.

Das lässt sich auch mit den Unit-Tests des Beispiels nachvollziehen. Lässt man einen Timer mit 250 ms Periode 1,5 Sekunden lang unbeobachtet und wartet dann, ist die erste Wartung sofort abgeschlossen — dank der aufgelaufenen Ticks —, die zweite jedoch nicht. Das bedeutet: Die mehreren Ticks während der unbeobachteten Zeit wurden zu einem einzigen zusammengefasst.

// Auszug aus tests/KomuraSoft.TimerSelection.Tests/PeriodicTimerBehaviorTests.cs

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250));
await Task.Delay(TimeSpan.FromMilliseconds(1500)); // In dieser Zeit wartet niemand

// Die erste Wartung ist dank des aufgelaufenen Ticks sofort abgeschlossen
ValueTask<bool> first = timer.WaitForNextTickAsync();
Assert.True(first.IsCompleted);
Assert.True(await first);

// Die zweite Wartung ist nicht sofort abgeschlossen (es sind keine weiteren Ticks übrig)
ValueTask<bool> second = timer.WaitForNextTickAsync();
Assert.False(second.IsCompleted);

Da Ticks während der unbeobachteten Zeit zu einem einzigen zusammengefasst werden können, müssen Sie im Design festlegen, ob

  • bei Verzögerung übersprungen wird
  • nur der aktuellste Zustand relevant ist
  • wirklich jede einzelne Ausführung verarbeitet werden muss

5.4. Stoppen und Lebensdauerverwaltung auf später verschieben

Bei Timern passieren eher beim Stoppen als beim Laufen Fehler.

Leicht zu übersehen sind unter anderem:

  • System.Threading.Timer als lokale Variable anzulegen, ohne die Referenz zu halten
  • System.Threading.Timer nicht zu stoppen und den Umgang mit Dispose() unklar zu lassen
  • DispatcherTimer weder mit Stop() anzuhalten noch das Tick-Abonnement zu entfernen
  • dass der Timer auch nach dem Schließen des Fensters die Lebensdauer eines Objekts verlängert

Insbesondere DispatcherTimer kann das Objekt, an das die Methode gebunden ist, am Leben halten. Bei dem eigenartigen Gefühl „Dieses Window müsste doch geschlossen sein, hängt aber irgendwie noch herum“ lohnt es sich, hier nachzusehen.

6. Checkliste fürs Review

  • Können Sie erklären, ob diese periodische Verarbeitung als UI-Update / ThreadPool-Callback / async-Schleife geschrieben werden sollte?
  • Wird ein async-Verarbeitungskörper gewaltsam in einen Callback-Timer gepresst, obwohl er das nicht sein sollte?
  • Verwenden Sie System.Threading.Timer: Verträgt der Code eine Überlappung von Callbacks, oder ist sie abgesichert?
  • Enthält der Tick von DispatcherTimer schwere Verarbeitung, blockierende I/O oder lange synchrone Verarbeitung?
  • Verwenden Sie PeriodicTimer: Ist die Strategie für den Fall einer Verzögerung festgelegt?
  • Sind Stoppmethode (Change / Dispose / Stop) und der Ablauf beim Beenden der App klar?
  • Wird die Referenz auf System.Threading.Timer ordentlich gehalten?
  • Gibt es für DispatcherTimer beim Schließen des Fensters ein Abmelden des Abonnements und ein Aufräumen?
  • Ist von vornherein getrennt, ob es sich bei dem Problem um „periodische Ausführung der App“ oder um „Wartepräzision“ handelt?

7. Grobe Entscheidungshilfe

Hier ein paar praktische Faustregeln.

  • Alle 30 Sekunden eine API aufrufen, um Einstellungen zu aktualisieren → PeriodicTimer

  • Alle 5 Sekunden einen Heartbeat oder leichte Metriken senden → System.Threading.Timer

  • In WPF eine Uhrzeitanzeige oder leichte Statusaktualisierungen anzeigen → DispatcherTimer

  • Bei jedem Tick direkt die UI anfassen → DispatcherTimer

  • Der Körper der periodischen Verarbeitung besteht überwiegend aus await, und Stoppen sowie Ausnahmen sollen natürlich behandelt werden → PeriodicTimer

  • Einen callback-basierten kleinen Kick kostengünstig einbauen → System.Threading.Timer

  • Periodengenauigkeit und Jitter im Bereich von 1–5 ms sind der eigentliche Kern → vor diesen dreien die Wartemethoden aus dem vorherigen Artikel ansehen

Ziemlich grob in je einem Satz gesagt:

  • PeriodicTimer ist der Timer für async
  • System.Threading.Timer ist der Timer für ThreadPool-Callbacks
  • DispatcherTimer ist der Timer für die UI

Mit dieser Eselsbrücke liegen Sie selten weit daneben.

8. Zusammenfassung

Was bei der Wahl eines .NET-Timers wirklich zählt, ist nicht der Unterschied im Namen, sondern diese drei Punkte.

  1. Wo es läuft
  2. In welchem Ablauf Sie es schreiben wollen
  3. Wie Sie mit Überlappung und Verzögerung umgehen

Als Strategie reicht allein das schon aus, um gut zurechtzukommen.

  1. Asynchrone periodische Verarbeitung: PeriodicTimer
  2. Leichte Callbacks auf dem ThreadPool: System.Threading.Timer
  3. WPF-UI-Updates: DispatcherTimer
  4. Genauigkeit im Vordergrund: eine andere Wartemethode ansehen

Timer verschwimmen leicht, weil die Namen ähnlich klingen. Ihre Rollen sind aber gar nicht so ähnlich.

  • PeriodicTimer ist ein Werkzeug, um den async-Ablauf zu gestalten
  • System.Threading.Timer ist ein Werkzeug, um Callbacks periodisch anzustoßen
  • DispatcherTimer ist ein Werkzeug für periodische Updates auf dem UI-Thread

Allein diese drei getrennt zu betrachten, macht den Code deutlich ruhiger.

Vermischt sich das hingegen, passiert recht gewöhnlicher, aber lästiger Ärger:

  • was async sein sollte, wird async void-artig
  • der Absturz durch direkten UI-Zugriff
  • Callbacks überlappen sich und der Zustand wird trüb
  • die Diskussion um Periodengenauigkeit wird mit hineingezogen

Beginnen Sie damit, zu betrachten, „wo Sie es laufen lassen wollen“. Allein das macht die Timer-Wahl deutlich gelassener.

9. Quellen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Windows-App-Entwicklung

Bei der Windows-Anwendungsentwicklung mit periodischer Ausführung, UI-Updates und Hintergrundverarbeitung wirkt sich die Timer-Wahl unmittelbar auf die Implementierungsqualität aus.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Was ist der Unterschied zwischen PeriodicTimer und System.Threading.Timer?
Der größte Unterschied ist, dass PeriodicTimer ein Typ ist, bei dem Sie den Tick per await abwarten, während System.Threading.Timer nach dem Callback-Prinzip arbeitet. Bei PeriodicTimer lässt sich „warten → verarbeiten → wieder warten“ als durchgehender Ablauf in einer einzigen async-Methode schreiben, und ein CancellationToken lässt sich leicht nach unten weiterreichen. Der Callback von System.Threading.Timer läuft dagegen auf dem ThreadPool und wartet nicht auf den Abschluss des vorherigen Callbacks, sodass sich Aufrufe überlappen können, wenn die Verarbeitung länger dauert als das Intervall. Für asynchrone periodische Verarbeitung eignet sich PeriodicTimer, für einen leichten, synchronen periodischen Callback-Kick System.Threading.Timer.
Holt PeriodicTimer automatisch auf, wenn die Verarbeitung in Verzug gerät?
Nein. Nur weil die vorherige Verarbeitung länger gedauert hat, führt PeriodicTimer nicht automatisch parallele Läufe aus, um aufzuholen. Auch wenn während der Zeit, in der Sie nicht warten, mehrere Ticks auftreten, werden diese zu einem einzigen zusammengefasst. Deshalb müssen Sie selbst im Design festlegen, ob bei Verzögerung übersprungen wird, nur der aktuellste Zustand relevant ist oder wirklich jede einzelne Ausführung verarbeitet werden muss. Außerdem ist PeriodicTimer nicht dafür ausgelegt, dass mehrere WaitForNextTickAsync-Aufrufe gleichzeitig gegen denselben Timer laufen.
Darf ich System.Threading.Timer keine async-Lambda übergeben?
Das sollten Sie besser vermeiden. Da TimerCallback vom Typ void ist, wird eine übergebene async-Lambda faktisch wie async void behandelt. Die aufrufende Seite kann dann nicht awaiten, den Abschluss nicht abwarten, das Ausnahme-Management wird schwieriger, und die Überlappung von Callbacks muss zusätzlich bedacht werden. Wenn der eigentliche Verarbeitungskörper async ist, ist es lesbarer und sicherer, zunächst PeriodicTimer in Betracht zu ziehen.
Wann sollte ich DispatcherTimer verwenden?
Verwenden Sie ihn, wenn Sie in WPF etwas wie eine Uhrzeitanzeige oder eine leichte Statusanzeige regelmäßig aktualisieren möchten. Da Tick auf dem Dispatcher (dem UI-Thread) von WPF verarbeitet wird, liegt die Stärke darin, dass Sie im Handler die UI direkt anfassen können. Weil der Timer aber auf dem UI-Thread läuft, verlangsamt schwere Verarbeitung oder blockierende I/O im Tick auch Eingabe und Rendering. Außerdem ist ein exaktes Auslösen zum angegebenen Zeitpunkt nicht garantiert. Stabil bleibt es, wenn Sie den Inhalt des Ticks leicht halten, schwere Arbeit in den Hintergrund auslagern und beim Schließen des Fensters mit Stop() und dem Abmelden der Ereignisbehandlung die Lebensdauer explizit machen.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog