Než začnu popisovat, jakým způsobem funguje můj scheduler, se kterým jsem vešel do 40 řádků kódu, popíšu, jaké jiné možnosti jsou a proč jsem je neshledal jako vhodné. Univerzální nejlepší řešení neexistuje, vše samozřejmě záleží na konkrétním projektu. Tento scheduler používám v Azure Cloud Service.
Alternativní přístupy
Naplánovaná úloha
Pokud se má ve Windows periodicky spouštět nějaký kód, je nesmysl dělat na to proces, který bude 99% času čekat a ve zbylém čase něco dělat. K tomu slouží Plánovač úloh. Jelikož jsem ale potřeboval běh v Azure Cloud Service, hledal jsem řešení čistě v prostředí .NET.
Azure Scheduler a Storage Queue
Dobrá volba je nakonfigurovat si Job Action ve Scheduleru, který periodicky vkládá zprávy do Storage Queue. Tyto zprávy pak odebírá Worker Role a vykoná kýženou úlohu.
Je velká škoda, že se automaticky nenastaví životnost zprávy na délku periody, se kterou se úloha vykonává. Když je totiž životnost zprávy delší než perioda, mohou se ve frontě vyskytnout dvě zprávy, které spustí ihned po sobě tutéž úlohu, a to nechceme. Pokud je perioda delší než 1 týden, což je výchozí životnost zprávy, nevadí to. Pokud je ale kratší, třeba jedna minuta, úloha se po updatu, který zabere čtvrt hodiny, vykoná 15 krát.
Výhoda Sheduleru spočívá v tom, že si drží přehled o tom, kolik úloh se vykovalo úspěšně a kolik neúspěšně. Dalším plusem je integrace retry policy.
System.Threading.Thread.Sleep
Spustit vlákno, vykonat úlohu, počkat, vykovat úlohu, zase počkat a tak pořád ve smyčce není ničím, co by vysloveně nefungovalo. Pokud ale chci úlohu vykovat každý den ve 3 hodiny ráno, nezávisle na tom, kdy se program spustí, už to chce nějakou logiku navíc.
System.Threading.Timer
Timer je užitečný pomocník, ale pokud se úloha vykovává déle, než je jeho perioda, zahltí nakonec celý virtuální stroj. Ne, že by se s tím nedalo něco dělat, ale opět to vyžaduje logiku navíc.
Požadavky
Co vlastně po takovém scheduleru chceme?
- Opakované spouštění asynchronní úlohy.
- Perioda nebude delší, než 24 hodin.
- Spouštění úlohy v konkrétním čase (např. vždy ve 3 hodiny ráno).
- Jistotu, že se další úloha nespustí dříve, než se předešlá dokončí.
- Zrušení naplánovaných úloh přes CancellationToken.
- Závislost pouze na platformě .NET.
Zdrojový kód
Scheduler je postavený na třídě Timer. Celý um spočívá ve dvou věcech.
- Podle toho, v kolik hodin se má úloha spustit, si spočítáme, za jak dlouho se má Timer vzbudit.
- Naplánujeme vždy jen jedno probuzení. Další naplánujeme až poté, co úloha skončí.
public class Scheduler {
private readonly Timer timer;
private readonly Func<Task> work;
private readonly CancellationToken cancellationToken;
private readonly TimeSpan period;
private readonly TimeSpan utcStartupTime;
public Scheduler(Func<Task> work, TimeSpan period, TimeSpan utcStartupTime, CancellationToken cancellationToken) {
if (work == null) throw new ArgumentNullException(nameof(work));
if (utcStartupTime.Ticks < 0 || utcStartupTime.Ticks > TimeSpan.TicksPerDay) throw new ArgumentOutOfRangeException(nameof(utcStartupTime));
if (period.Ticks < 0 || period.Ticks > TimeSpan.TicksPerDay) throw new ArgumentOutOfRangeException(nameof(period));
if (cancellationToken == null) throw new ArgumentNullException(nameof(cancellationToken));
this.work = work;
this.cancellationToken = cancellationToken;
this.period = period;
this.utcStartupTime = utcStartupTime;
timer = new Timer(Callback);
ScheduleTimeout();
ThreadPool.RegisterWaitForSingleObject(cancellationToken.WaitHandle, Cancellation, null, -1, true);
}
private void Callback(object state) {
work().Wait();
ScheduleTimeout();
}
private void ScheduleTimeout() {
if (cancellationToken.IsCancellationRequested) return;
var utcTimeOfDay = DateTime.UtcNow.TimeOfDay;
var dueTime = TimeSpan.FromTicks(((TimeSpan.TicksPerDay + utcStartupTime.Ticks - utcTimeOfDay.Ticks) % TimeSpan.TicksPerDay) % period.Ticks);
timer.Change(dueTime, Timeout.InfiniteTimeSpan);
}
private void Cancellation(object state, bool timedOut) {
timer.Change(Timeout.Infinite, Timeout.Infinite);
}
}
Použití
A jak se Scheduler používá? Předáme mu, co se má spouštět, jak často, v kolik hodin a kdy to celé má skončit. Můžeme si to vyzkoušet v konzolové aplikaci:
private static CancellationTokenSource cts = new CancellationTokenSource();
private static int i = 0;
public static void Main(string[] args) {
var sch = new Scheduler(Work, TimeSpan.FromSeconds(2), new TimeSpan(12, 0, 0), cts.Token);
cts.Token.WaitHandle.WaitOne();
}
private static async Task Work() {
Console.WriteLine($"{DateTime.Now.TimeOfDay} Work started.");
Interlocked.Increment(ref i);
Console.WriteLine(i);
await Task.Delay(TimeSpan.FromSeconds(5));
if (i == 5) cts.Cancel();
Console.WriteLine($"{DateTime.Now.TimeOfDay} Work completed.");
}
Úloha se má spouštět každé 2 sekundy ale tak, aby se spustila v poledne (což v tomto konkrétním případě znamená, že se bude spouštět v každé sudé sekundě). Trvá ale 5 sekund, takže se bude spouštět každých 6. Po 5. spuštění vyvolá signál, který zruší další spuštění a ukončí celou aplikaci.