Johannes Gaiser

Johannes Gaiser

Fullstack-Entwickler und Teeliebhaber

01.08.2025 | 10 min Lesezeit

Job Scheduler in .NET

Zeitgesteuerte und wiederkehrende Aufgaben mit Quartz.NET

Job Scheduler in .NET blog image

In meinem letzten Projekt bestand die Aufgabe darin, Maschinenaufträge im Voraus zu planen und zum gewünschten Zeitpunkt automatisch auszuführen. Der Benutzer konnte dafür einen konkreten Startzeitpunkt festlegen, zu dem die Software den Auftrag an die Maschine übermitteln sollte.

Zusätzlich gab es regelmäßig auszuführende Aufgaben wie z.B. Datenbank-Backups. Diese sollten in wiederkehrenden Intervallen automatisch angestoßen werden.

Die Anforderungen waren somit zweigeteilt: Einerseits sollten einzelne Aufgaben zeitgenau ausgeführt werden, andererseits mussten periodische Aufgaben zuverlässig in definierten Abständen laufen.

Timer in .NET

Die .NET-Plattform bietet mehrere Möglichkeiten, um Code zeitgesteuert oder periodisch auszuführen. Für einfache Anwendungsfälle reichen die eingebauten Timer-Klassen meist aus. Seit .NET 6 vereinfacht der PeriodicTimer das regelmäßige Ausführen von Code nochmals erheblich. Besonders in Kombination mit einem BackgroundService in ASP.NET Core lassen sich Aufgaben effizient im Hintergrund abwickeln.

Ein Beispiel: Ein Job, der alle 10 Minuten ausgeführt wird, lässt sich mit wenigen Zeilen realisieren:

Einfacher Timer
public class Every10Minutes : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var periodicTimer = new PeriodicTimer(TimeSpan.FromMinutes(10));
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await periodicTimer.WaitForNextTickAsync(stoppingToken);

                // Simulate some work
                Console.WriteLine("Next 10 Minutes are over: " + DateTime.Now);
            }
            catch (OperationCanceledException)
            {
                // Handle cancellation gracefully
                Console.WriteLine("Service has been cancelled.");
                break;
            }
        }
    }
}

Für einfache, fest definierte Hintergrundaufgaben ist das völlig ausreichend.

Komplexer wird es jedoch, wenn mehrere Jobs mit unterschiedlichen Zeitplänen parallel verwaltet werden sollen - insbesondere, wenn diese dynamisch zur Laufzeit gestartet, gestoppt oder verändert werden müssen. In solchen Fällen empfiehlt sich der Einsatz eines spezialisierten Job-Schedulers, der die Planung, Koordination und Ausführung zeitgesteuerter Aufgaben deutlich vereinfacht.

Was ist ein Job Scheduler?

Ein Job-Scheduler ist ein spezialisiertes Werkzeug zur Planung, Verwaltung und Ausführung zeitgesteuerter Aufgaben. Im Gegensatz zu einfachen Timer-Lösungen bietet ein Scheduler deutlich mehr Funktionalität und Flexibilität, insbesondere bei komplexen oder dynamischen Anforderungen.

Typische Merkmale eines Job-Schedulers sind:

  • Verwaltung mehrerer Jobs mit unterschiedlichen Zeitplänen
  • Übergabe von Kontext- oder Konfigurationsdaten an den Job
  • Persistente Speicherung von Job-Daten - auch über Neustarts hinweg
  • Unterstützung komplexer Zeitmuster, etwa durch Cron-Ausdrücke, Feiertagsregeln oder Ausnahmeregelungen

Alternativen im .NET-Umfeld

Neben klassischen Timer-Ansätzen existieren im .NET-Ökosystem mehrere spezialisierte Job Scheduler, die sich in Funktionsumfang, Komplexität und Einsatzzweck deutlich unterscheiden. Im Folgenden stelle ich drei verbreitete Lösungen vor.

Coravel

Coravel ist eine leichtgewichtige, elegante Bibliothek für einfaches Scheduling, Queueing und Caching - ganz ohne Datenbank oder externen Persistenzmechanismus. Der Fokus liegt auf minimaler Konfiguration und schneller Integration in bestehende .NET-Projekte.

Ein großer Pluspunkt ist die Möglichkeit, Jobs direkt als Lambda-Ausdrücke zu definieren. Das macht Coravel besonders attraktiv für kleinere Aufgaben oder Projekte, bei denen schnelle Integration und geringe Komplexität im Vordergrund stehen.

Coravel Job Scheduling
scheduler.Schedule(
    () => Console.WriteLine("Hello World!")
)
.EveryTenMinutes();

Allerdings stößt Coravel schnell an Grenzen, sobald es um komplexere Zeitpläne, Wiederholungsstrategien oder exakte Startzeitpunkte geht. Persistenz über Neustarts hinweg ist ebenfalls nicht vorgesehen.

Hangfire

Hangfire ist ein Framework für Hintergrundverarbeitung mit breitem Funktionsumfang. Es eignet sich besonders gut für robuste Anwendungen mit vielen wiederkehrenden oder zustandsbehafteten Aufgaben.

Zu den zentralen Features gehören:

  • Persistenz durch unterstützte Datenbanken (z.B. SQL Server, Redis)
  • Retry-Mechanismen bei Fehlern
  • Job-Chaining und Continuations
  • Ein integriertes Dashboard zur Überwachung von Jobs

Auch Hangfire erlaubt die Verwendung von Lambda-Ausdrücken für einfache Jobdefinitionen:

Hangfire Job Scheduling

RecurringJob.AddOrUpdate("myJob", () => Console.Write("Hello World!"), "* */10 * ? * * *");

Hangfire ist darauf spezialisiert, wiederkehrende Aufgaben zuverlässig auszuführen, sie dauerhaft zu speichern und bei Fehlern automatisch zu wiederholen. Die Ausführung einmaliger Jobs zu einem bestimmten Zeitpunkt wird dabei nicht direkt unterstützt - allerdings lassen sich sogenannte Delayed Jobs erstellen, bei denen ein Job nach einem bestimmten Zeitintervall ausgeführt wird.

Quartz.NET

Quartz.NET ist ein leistungsfähiger und dennoch schlanker Job Scheduler für .NET. Im Gegensatz zu Hangfire basiert Quartz nicht auf Queues, sondern auf leichtgewichtigen Timern. Ähnlich wie Hangfire unterstützt auch Quartz die Persistenz von Jobs in verschiedene Datenbanken.

Eine Besonderheit von Quartz.NET ist die strikte Trennung von Job-Logik und Zeitsteuerung: Ein Job wird über das Interface IJob definiert, während ein separater Trigger steuert, wann und wie oft dieser Job ausgeführt wird. Trigger können sowohl einfache Intervalle als auch komplexe Cron-Ausdrücke oder exakte Zeitpunkte abbilden.

Diese Architektur macht Quartz besonders flexibel bei der Umsetzung komplexer oder präziser Zeitpläne. Gleichzeitig ist die Definition und Verwaltung von Jobs damit etwas aufwendiger als bei anderen Lösungen wie Coravel, bei denen etwa einfache Lambdas ausreichen.

Quartz.NET im Detail

Im Projekt fiel die Wahl auf Quartz.NET, da es sowohl zeitgenaue Einzel-Ausführungen als auch wiederkehrende Aufgaben mit komplexen Zeitplänen zuverlässig abbilden kann.

Quartz.NET basiert auf zwei zentralen Bausteinen: dem Job und dem Trigger. Ein Job enthält die auszuführende Logik und wird durch die Implementierung des Interfaces IJob definiert. Der Trigger steuert, wann und wie oft dieser Job ausgeführt wird - sei es einmalig zu einem festen Zeitpunkt oder regelmäßig anhand von Intervallen oder Cron-Ausdrücken.

Die Implementierung eines Jobs erfolgt über die Methode Execute, in der die gewünschte Logik hinterlegt wird:

Job Definition
public class MyJob : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        // Auszuführender Code
        Console.WriteLine("Job wird ausgeführt");
    }
}

Um einen Job nach einem bestimmten Zeitplan auszuführen, erstellt man zunächst ein JobDetail, das die Job-Klasse referenziert, sowie einen Trigger, der den zeitlichen Ablauf steuert:

Quartz Job Scheduling
IJobDetail jobDetail = JobBuilder.Create<MyJob>()
    .WithIdentity("myJob", "group1")
    .Build();

var trigger = TriggerBuilder.Create()
    .WithIdentity("myTrigger", "group1")
    .WithSimpleSchedule(x => x
        .WithIntervalInMinutes(10)
        .RepeatForever())
    .Build();

await scheduler.ScheduleJob(jobDetail, trigger);

In diesem Beispiel wird der Job alle 10 Minuten wiederholt ausgeführt.

Dauerhaft registrierter Job

Jobs können dauerhaft im Scheduler registriert und anschließend über ihre Identität beliebig oft getriggert werden. Das ist besonders hilfreich, wenn ein Job mehrfach zu verschiedenen Zeitpunkten ausgeführt werden soll - etwa mit individuell erzeugten Triggern.

Dauerhaft registrierter Job

var job = JobBuilder.Create<MyJob>()
    .WithIdentity("my-job", "group1")
    .StoreDurably()
    .Build();

await scheduler.AddJob(job, true);

var trigger = TriggerBuilder.Create()
    .WithIdentity("myTrigger", "group1")
    .ForJob("my-job", "group1")
    .WithSimpleSchedule(x => x
        .WithIntervalInMinutes(10)
        .RepeatForever())
    .Build();

await scheduler.ScheduleJob(trigger);

Im Projekt ist die Ausführung eines Maschinenauftrags ein dauerhaft registrierter Job. Für jeden neuen Benutzerauftrag wird dynamisch ein passender Trigger erstellt, der diesen Job zu dem gewünschten Zeitpunkt ausführt.

Um im Job erkennen zu können, welcher konkrete Auftrag ausgeführt werden soll, lässt sich dem Trigger Kontext mitgeben - etwa eine Auftrags-ID oder andere Metadaten. Der Job kann diese Informationen zur Laufzeit aus dem Kontext auslesen und entsprechend reagieren.

Kontext mitgeben

Sowohl Jobs als auch Trigger können eine JobDataMap erhalten, über die Kontextdaten übergeben werden. Diese Map funktioniert wie ein Schlüssel-Wert-Speicher und kann beliebig viele Einträge enthalten.

Die Inhalte der JobDataMap werden bei Bedarf in der Datenbank persistiert, sodass sie auch nach einem Neustart verfügbar bleiben. Während der Ausführung kann ein Job die JobDataMap sowohl lesen als auch beschreiben - die geänderten Werte stehen dann bei der nächsten Ausführung automatisch wieder zur Verfügung.

Job und Trigger Kontext

var jobData = new JobDataMap();
data.Put("whatDoesTheJobSay", "Hello World!");
data.Put("id", 1234);

var job = JobBuilder.Create<MyJob>()
    .UsingJobData(jobData);

var triggerData = new JobDataMap();
triggerData.Put("idFromTrigger", 5678);

var trigger = TriggerBuilder.Create()
    .UsingJobData(triggerData);

Trigger-Zeitplanung

Die zeitliche Steuerung eines Jobs erfolgt in Quartz.NET über den Trigger. Dieser bestimmt, wann und wie oft ein Job ausgeführt wird. So lässt sich zum Beispiel ein Job definieren, der in 15 Minuten startet, dann alle zehn Minuten wiederholt wird und sich nach zehn Durchläufen automatisch beendet:

Einfacher Trigger
var trigger = TriggerBuilder.Create()
    .WithIdentity("myTrigger", "group1")
    .ForJob("myJob", "group1")
    .StartAt(DateTimeOffset.Now.AddMinutes(15))
    .WithSimpleSchedule(x => x
        .WithIntervalInMinutes(10)
        .WithRepeatCount(10))
    .Build();

await scheduler.ScheduleJob(trigger);

Für komplexe Zeitpläne bietet Quartz.NET sogenannte Cron Trigger, deren Syntax der von klassischen Cronjobs entspricht. Damit lassen sich präzise Ausführungszeiten definieren - zum Beispiel ein Job, der an jedem Werktag um genau 12:00 Uhr ausgeführt wird:

Cron Trigger
TriggerBuilder.Create()
    .WithCronSchedule("0 0 12 ? * MON-FRI"); // Täglich um 12 Uhr, Montag bis Freitag

Die Cron-Syntax ist äußerst flexibel - sie unterstützt z.B. Monats- oder Wochentage, Intervalle oder Ausnahmen. Zur Unterstützung bei der Erstellung und Interpretation solcher Ausdrücke sind Tools wie der Cron Expression Generator & Explainer hilfreich.

Neben einfachen und Cron-basierten Triggern gibt es auch den DailyTimeIntervalTrigger, mit dem sich wiederkehrende Jobs innerhalb eines täglichen Zeitfensters definieren lassen. Zum Beispiel: Montag bis Freitag, zwischen 8:30 Uhr und 20:00 Uhr, alle 45 Minuten:

Daily Time Interval Trigger
var trigger = TriggerBuilder.Create()
    .StartAt(DateBuilder.TodayAt(8, 30, 0))
    .WithDailyTimeIntervalSchedule(x => x
        .OnMondayThroughFriday()
        .InTimeZone(TimeZoneInfo.Utc)
        .StartingDailyAt(TimeOfDay.HourAndMinuteOfDay(8, 30))
        .EndingDailyAt(TimeOfDay.HourAndMinuteOfDay(20, 0))
        .WithIntervalInMinutes(45))

Eigene Zeitlogik

Für maximale Flexibilität lässt sich ein eigener Trigger definieren, indem das Interface ITrigger implementiert wird. So kann das Ausführungsverhalten individuell gesteuert werden - unabhängig von den vordefinierten Zeitmodellen wie Cron oder Intervalltriggern. Zentral ist dabei die Methode GetNextFireTimeUtc(). Sie wird nach jeder Ausführung aufgerufen und gibt den Zeitpunkt für den nächsten Lauf zurück:

Eigener Trigger
public class MyTrigger : ITrigger {
    // andere Methods

    private TimeSpan NextInterval { get; set; } = TimeSpan.FromMinutes(1);

    public DateTimeOffset? GetNextFireTimeUtc()
    {
        var nextFireTime = DateTimeOffset.UtcNow.Add(NextInterval);
        NextInterval = NextInterval.Multiply(2);
        return nextFireTime;
    }
}

In diesem Beispiel wächst das Ausführungsintervall nach jedem Lauf exponentiell - beginnend bei einer Minute, dann zwei, vier usw. Ein solches Verhalten kann beispielsweise für Retry-Mechanismen mit Backoff genutzt werden.

Trigger Kalender

Ein weiteres praktisches Feature von Quartz.NET ist die Verwendung von Kalendern in Kombination mit Triggern. Kalender definieren Zeiträume, in denen ein Trigger nicht feuern darf - etwa an Feiertagen, Wochenenden oder während Wartungsfenstern.

Über das Interface ICalendar lassen sich eigene Regeln implementieren. So kann man z.B. verhindern, dass ein Job an Weihnachten ausgeführt wird:

Kalender Interface
public class MyCalendar : ICalendar
{
    public bool IsTimeIncluded(DateTimeOffset timeUtc)
    {
        // Beispiel: Weihnachten ausschließen
        return !(timeUtc.Month == 12 && timeUtc.Day == 25);
    }

    public string Description => "My custom calendar";
}

Der Kalender wird anschließend dem Scheduler hinzugefügt und kann über den Trigger referenziert werden. Dadurch wird sichergestellt, dass der Job nur an erlaubten Tagen läuft:

Kalender hinzufügen
scheduler.AddCalendar("myCalendar", new MyCalendar(), false, false);

var trigger = TriggerBuilder.Create()
    .ModifiedByCalendar("myCalendar");

Mit Trigger-Kalendern lassen sich komplexe Ausnahmeregelungen elegant abbilden - ohne dass man sie manuell in jedem Zeitplan berücksichtigen muss.

Persistenz und Skalierung

Ein wesentlicher Vorteil von Quartz.NET ist die Option, Jobs und Trigger dauerhaft in einer Datenbank zu speichern. Damit bleiben geplante Aufgaben auch nach einem Neustart der Anwendung oder des Servers erhalten - und können sogar über mehrere Instanzen hinweg konsistent verwaltet werden.

Für einfache Szenarien steht alternativ ein In-Memory-Store zur Verfügung, der alle Informationen im Arbeitsspeicher hält. Dieser eignet sich insbesondere für temporäre oder unkritische Jobs, bei denen Persistenz nicht erforderlich ist.

In meinem Projekt werden die meisten Trigger dynamisch und datenabhängig erzeugt. Die relevanten Informationen liegen in einer Datenbank, und bei Bedarf werden daraus aktuelle Trigger generiert. So bleibt das System flexibel und jederzeit auf dem neuesten Stand - ohne unnötig viele persistente Einträge zu erzeugen.

Fazit

Job Scheduler wie Quartz.NET sind leistungsfähige Werkzeuge, um zeitgesteuerte und wiederkehrende Prozesse in .NET-Anwendungen zuverlässig umzusetzen. Sie bieten:

  • flexible Zeitplanung (z.B. mit Cron-Ausdrücken oder benutzerdefinierten Triggern),
  • dauerhafte Persistenz über Datenbanken,
  • und Unterstützung für komplexe Szenarien wie Feiertage oder Betriebszeiten

Weiterführende Links:

Weiter lesen