Práce na pozadí v .NET začíná u BackgroundService: třídy s jedinou metodou ExecuteAsync, kterou Generic Host spustí spolu s aplikací a zruší, když se aplikace zastaví. Na „každých pět minut udělej tohle“ stačí BackgroundService s PeriodicTimer. Na „udělej tohle brzy, ale nenech kvůli tomu čekat HTTP požadavek“ stačí omezený Channel, který krmí BackgroundService. Po knihovně sáhneš ve chvíli, kdy práce musí přežít restart nebo se řídit kalendářem: Quartz.NET pro rozvrhy ve stylu cronu, Hangfire pro úlohy uložené v databázi s opakováním a dashboardem. Ať použiješ cokoli, o tom, jestli to v produkci funguje, rozhodují dvě stejné věci - co se stane, když úloha vyhodí výjimku, a co se stane, když se procesu řekne, aby se zastavil, zrovna když jednu zpracovává.
Tento článek postupně staví každou z těch variant, od té s nejméně mechanikou po tu s nejvíc.
Hostitelský model#
Každá moderní aplikace v .NET - webová, worker nebo konzolová s hostem - běží na Generic Host a host spouští hosted služby. Rozhraní je IHostedService: StartAsync při startu aplikace, StopAsync při jejím zastavení. BackgroundService je základní třída, kterou místo toho používá téměř každý, protože z toho udělá jednu dlouho běžící metodu s cancellation tokenem.
$ dotnet new worker -n Reports.Workervar builder = Host.CreateApplicationBuilder(args);builder.Services.AddHostedService<CleanupWorker>();builder.Build().Run();V aplikaci ASP.NET Core je to stejný řádek, builder.Services.AddHostedService<CleanupWorker>(), a worker běží uvnitř webového procesu vedle zpracování požadavků. To není kompromis. Pro většinu malých aplikací je to správné místo, protože worker sdílí konfiguraci, logování i služby s webovou aplikací a nasazuje se jen jeden proces.
Hosted služby startují v pořadí registrace a ve starších verzích byl start sekvenční: kód v ExecuteAsync před prvním await běžel na startovní cestě a zdržoval vše, co bylo zaregistrováno po něm. .NET 10 změnil BackgroundService tak, že celé ExecuteAsync běží na pozadí. Na starších verzích drž synchronní část krátkou nebo metodu začni await Task.Yield(). .NET 8 také přidal HostOptions.ServicesStartConcurrently a IHostedLifecycleService s háčky StartingAsync, StartedAsync, StoppingAsync a StoppedAsync pro služby, které potřebují jemnější kontrolu.
Worker běžící na časovači#
Klasický tvar je smyčka s PeriodicTimer:
public sealed class CleanupWorker( IServiceScopeFactory scopes, ILogger<CleanupWorker> log) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); do { try { await using var scope = scopes.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService<ShopDb>(); var removed = await db.Carts .Where(c => c.UpdatedAt < DateTime.UtcNow.AddDays(-7)) .ExecuteDeleteAsync(stoppingToken); log.LogInformation("Removed {Count} stale carts", removed); } catch (Exception ex) when (ex is not OperationCanceledException) { log.LogError(ex, "Cart cleanup failed"); } } while (await timer.WaitForNextTickAsync(stoppingToken)); }}Na čem záleží:
- Scopy. Hosted služba je singleton. Scoped služby, například
DbContextz EF Core, do ní nejde vložit přímo - host to při startu odmítne - a stejně by se neměly držet po celou dobu jejího života, protože change tracker roste donekonečna. Pro každou jednotku práce vytvoř scope přesIServiceScopeFactory, jako výše. - `PeriodicTimer` se nepřekrývá. Pokud jeden běh trvá déle než perioda, další tik počká; běhy se nikdy nenavrší na sebe. To obvykle chceš a neplatí to pro
System.Threading.Timer, který střílí podle rozvrhu bez ohledu na to, jestli předchozí callback skončil. - `WaitForNextTickAsync` při zrušení vyhazuje výjimku. Když se host zastavuje, token se zruší a čekání vyhodí
OperationCanceledException, která smyčku ukončí.BackgroundServiceto bere jako běžné zastavení. - Výjimky chytej v každé iteraci.
tryuvnitř smyčky znamená, že jeden neúspěšný běh se zaloguje a další se přesto provede. Bez něj první výjimka worker ukončí - viz níže.
Tohle pokryje překvapivě hodně: zahřívání cache, úklid, dotazování externího API, odesílání e-mailů z fronty, přepočet žebříčku. Úlohy na pozadí na malém serveru probírá, které z nich vůbec patří do aplikace.
Fronty v procesu s Channels#
Pro práci spuštěnou požadavkem - odeslat uvítací e-mail, zmenšit nahraný obrázek, zavolat pomalý webhook - by se měl požadavek vrátit okamžitě a práce proběhnout za ním. System.Threading.Channels je k tomu vestavěná fronta, která skoro nealokuje:
public sealed class EmailQueue{ private readonly Channel<int> _channel = Channel.CreateBounded<int>( new BoundedChannelOptions(500) { FullMode = BoundedChannelFullMode.Wait }); public ValueTask EnqueueAsync(int userId, CancellationToken ct) => _channel.Writer.WriteAsync(userId, ct); public IAsyncEnumerable<int> ReadAllAsync(CancellationToken ct) => _channel.Reader.ReadAllAsync(ct);}public sealed class EmailWorker(EmailQueue queue, IServiceScopeFactory scopes, ILogger<EmailWorker> log) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var userId in queue.ReadAllAsync(stoppingToken)) { try { await using var scope = scopes.CreateAsyncScope(); var sender = scope.ServiceProvider.GetRequiredService<IWelcomeMailer>(); await sender.SendAsync(userId, stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { log.LogError(ex, "Welcome mail for {UserId} failed", userId); } } }}Zaregistruj EmailQueue jako singleton a worker jako hosted službu; endpoint zavolá EnqueueAsync a vrátí se.
Kanál udělej omezený. Neomezený kanál přijímá práci rychleji, než ji dokážeš zpracovat, dokud nedojde paměť; omezený s FullMode = Wait místo toho producenty zpomalí, což je poctivé chování. Do fronty dávej malé věci - ID, ne entitu nebo soubor - aby spotřeba paměti fronty zůstala předvídatelná.
Buď si vědom slabiny: fronta v paměti zemře s procesem. Cokoli v ní čeká, když se aplikace restartuje nebo spadne, je pryč. U uvítacího e-mailu je to často přijatelné; u potvrzení platby ne. Právě ta otázka - „můžu o to přijít?“ - je bod, kdy potřebuješ perzistenci, tedy Hangfire, databázovou tabulku, kterou dotazuješ, nebo server fronty. Fronty úloh s Valkey popisuje variantu se serverem fronty.
Chyby a co shodí host#
Od .NET 6 zastaví výjimka, která unikne z ExecuteAsync, celý host. Nastavení je HostOptions.BackgroundServiceExceptionBehavior a jeho výchozí hodnota je StopHost:
builder.Services.Configure<HostOptions>(o =>{ o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;});Ta výchozí hodnota je správná a alternativa Ignore je obvykle špatná. S Ignore se worker potichu zastaví, zatímco aplikace dál obsluhuje požadavky, a nic ti neřekne, že úklid přestal běžet před třemi týdny. Se StopHost proces skončí, výjimka se zaloguje a platforma ho restartuje - viditelně a s automatickým uzdravením, pokud byla chyba přechodná.
Skutečné řešení je try/catch v každé iteraci, jak je ukázáno výše: jeden špatný záznam má stát jednu iteraci, ne celý proces. Selhání ukončující proces si nech pro věci, které jsou opravdu fatální, třeba chybějící konfiguraci.
Chytání všeho má vlastní způsob selhání: worker, který selže v každé iteraci, pokaždé zaloguje chybu a nikdy se nezastaví. Nikdo log nečte a úloha je fakticky týdny mrtvá. Zaznamenávej čas posledního úspěšného běhu do singletonu a zpřístupni ho přes health check, který hlásí nezdravý stav, když je poslední úspěch starší než třeba tři periody. Pak ti monitorovací kontrola, nebo prostě health endpoint, který po nasazení stejně voláš, řekne, že úloha uvízla, dřív než zákazník. Monitoring, který ti něco řekne popisuje, na co upozorňovat a co nechat být.
Na RE:NODE se proces, který skončí, restartuje a crash watcher počítá restarty, které nikdo nevyžádal: tři za hodinu vyvolají varování na stránce serveru a automaticky otevřou ticket, šest vede k pozastavení. Worker, který vyhodí výjimku při každém startu, si tedy rychle všimnou - ale lepší je chytat po jednotlivých položkách a logovat, než se na to spoléhat.
Vypnutí: co se děje při zastavení a nasazení#
Když se hostu řekne, aby se zastavil - nasazení, restart, zastavení z panelu - zruší stoppingToken a čeká, až skončí každá hosted služba, nejdéle do HostOptions.ShutdownTimeout. Výchozí hodnota je od .NET 6 30 sekund. Poté host čekat přestane a proces skončí, v případě nutnosti uprostřed úlohy.
Každá úloha proto musí zvládnout, že ji někdo přeruší:
- Předávej token všude. Databázová volání, HTTP volání a čekání, která dostanou
stoppingToken, po zrušení rychle skončí. Smyčka, která ho ignoruje, běží až do timeoutu a pak ji stejně useknou. - Dělej práci idempotentní. Úloha přerušená v půlce poběží znovu. Odeslání stejného e-mailu dvakrát nebo dvojí stržení platby musí být vyloučeno už návrhem: zaznamenej, co se udělalo, a před provedením to zkontroluj.
- Drž jednotky malé. Úloha, která zpracuje 10 000 řádků v jedné transakci, při přerušení přijde o všechno. Dávky po pár stovkách, každá commitnutá, ztratí nanejvýš jednu dávku.
Pokud proces spouštíš sám, spouštěcí příkaz by měl předat signál zastavení přímo procesu .NET - exec dotnet App.dll místo shellového obalu - jinak ho host nikdy neuslyší a nemůže se čistě vypnout. Řádné vypnutí a health checky popisuje stranu signálů obecně.
Quartz.NET pro rozvrhy#
Když se má práce provést v určitý čas - každou noc ve 03:00, první pondělí v měsíci - místo v intervalu, použij plánovač. Quartz.NET je ten zavedený:
$ dotnet add package Quartz.Extensions.Hostingbuilder.Services.AddQuartz(q =>{ var key = new JobKey("nightly-report"); q.AddJob<NightlyReportJob>(o => o.WithIdentity(key)); q.AddTrigger(t => t.ForJob(key).WithCronSchedule("0 0 3 * * ?"));});builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);[DisallowConcurrentExecution]public sealed class NightlyReportJob(ShopDb db) : IJob{ public async Task Execute(IJobExecutionContext context) { // build and send the report, honouring context.CancellationToken }}Všimni si cron výrazu. Cron v Quartz má jako první pole sekundy a v jednom z polí dne vyžaduje ?, takže 0 0 3 * * ? znamená „03:00:00 každý den“ - ne pětipolovou unixovou syntaxi, kterou zná většina lidí. Cron výrazy vysvětleně popisují unixovou podobu; než rozvrhu uvěříš, přečti si dokumentaci samotného Quartz k jeho variantě.
[DisallowConcurrentExecution] brání tomu, aby se pomalý běh překryl s dalším. WaitForJobsToComplete způsobí, že vypnutí počká na běžící úlohy, v rámci timeoutu vypnutí hostu. Rozvrhy se vyhodnocují v časovém pásmu serveru, dokud na triggeru nenastavíš jiné přes InTimeZone, což záleží dvakrát do roka při změně času.
Ve výchozím stavu drží Quartz svůj rozvrh v paměti, takže běh, který se měl uskutečnit, zatímco aplikace nejela, se prostě zmešká. Pokud na zmeškaných bězích záleží, Quartz podporuje databázové úložiště úloh s ošetřením misfire; v tu chvíli ho porovnej s Hangfire.
Hangfire pro trvalé úlohy#
Hangfire ukládá úlohy do databáze, takže přežijí restarty, při selhání se automaticky opakují a jde je prohlížet a znovu spouštět z webového dashboardu:
builder.Services.AddHangfire(c => c.UseSqlServerStorage( builder.Configuration.GetConnectionString("Hangfire")));builder.Services.AddHangfireServer();var app = builder.Build();app.UseHangfireDashboard("/jobs");// enqueue from anywhere, via IBackgroundJobClientjobs.Enqueue<IWelcomeMailer>(m => m.SendAsync(userId, CancellationToken.None));// recurring, via IRecurringJobManagerrecurring.AddOrUpdate<NightlyReport>("nightly-report", r => r.RunAsync(), Cron.Daily());Jak to funguje: Enqueue serializuje volání metody a jeho argumenty do úložiště a serverová komponenta úložiště dotazuje a úlohy spouští. Důsledky:
- Argumenty musí být malé a serializovatelné. Předej ID, ne entitu; úloha si při běhu načte čerstvá data.
- Neúspěšné úlohy se opakují automaticky, ve výchozím stavu desetkrát s rostoucími prodlevami. Proto musí být úlohy idempotentní.
- Úložiště je skutečná závislost. Úložiště pro SQL Server udržují autoři Hangfire; úložiště pro PostgreSQL a MySQL pocházejí z komunitních balíčků; úložiště v Redis je součástí komerční edice Pro. Než se rozhodneš, ověř balíček pro svou databázi.
- Dashboard je ve výchozím stavu jen lokální. Požadavky odjinud než z localhostu se odmítnou, dokud nepřidáš autorizační filtr. Neopravuj to povolením všem; dashboard umí úlohy mazat a znovu spouštět.
Jádro Hangfire je open source pod licencí LGPL; dávky a některé další funkce jsou v placené edici Pro.
Volba a kde se práce provádí#
| Potřeba | Použij |
|---|---|
| Každých N minut, ztráta běhu při restartu nevadí | BackgroundService + PeriodicTimer |
| Fire-and-forget z požadavku, ztráta přijatelná | Channel + BackgroundService |
| V daných časech, kalendářově | Quartz.NET |
| Musí přežít restarty, opakování, viditelnost | Hangfire, nebo fronta na Valkey |
Kde se práce provádí, je druhé rozhodnutí. Jeden server spouští jeden proces, takže na jednom aplikačním plánu žije práce na pozadí uvnitř webové aplikace jako hosted služby - což je u většiny aplikací v pořádku. Na samostatný worker proces, na vlastním serveru, ji rozděl, když začne práce na pozadí soupeřit s požadavky o CPU nebo paměť: na RE:NODE je CPU tvrdé omezení na zakoupený podíl, takže těžká noční úloha uvnitř webového procesu zpomaluje web, dokud běží. Hangfire a fronty opřené o databázi to pozdější rozdělení usnadňují, protože webová aplikace a worker sdílejí jen databázi. Plány aplikací zahrnují dva sloty pro databáze, což stačí na data aplikace a úložiště úloh.
Příklad z praxe: malý obchod na jednom aplikačním plánu. Opuštěné košíky se uklízejí každou hodinu BackgroundService s PeriodicTimer - ztráta jednoho běhu kvůli restartu nestojí nic. Potvrzovací e-maily k objednávkám jdou přes Hangfire s úložištěm v SQL Serveru nebo PostgreSQL, protože ztracené potvrzení je ticket pro podporu a Hangfire ho zopakuje, pokud je e-mailový relay krátce nedostupný. Noční report prodejů je opakovaná úloha Hangfire místo samostatného nastavení Quartz, protože Hangfire už tam je a jednomu plánovači se rozumí snáz než dvěma. Všechno běží ve webovém procesu. Pokud report někdy zhrubne natolik, že ve 03:00 zpomalí web, přesune se server Hangfire beze změny na druhý plán a webová aplikace prostě přestane volat AddHangfireServer().
Záložka Schedules v panelu je jiný nástroj: spouští uspořádané úkoly podle cron výrazu - příkaz konzole, zálohu nebo akci napájení. Je to správné místo pro noční restart nebo zálohu před rizikovou úlohou, ne pro aplikační logiku. Plánované úlohy, které stojí za to mít vypisuje ty užitečné.
FAQ#
Mají úlohy na pozadí běžet ve webové aplikaci, nebo v samostatném procesu?
Ve webové aplikaci, dokud s ní nezačnou soupeřit o CPU nebo paměť. Jeden proces se snáz nasazuje a monitoruje. Rozděl, když zátěž úlohy viditelně zpomaluje požadavky, a používej trvalé úlohy, aby se každý z procesů dal restartovat nezávisle.
Proč můj BackgroundService přestane běžet bez jakékoli chyby?
Buď jednou vyhodil výjimku s BackgroundServiceExceptionBehavior nastaveným na Ignore, nebo smyčka skončila normálně - často kvůli podmínce while, která přestala platit. Loguj na začátku a na konci ExecuteAsync, abys viděl, kdy se zastaví.
Můžu do BackgroundService vložit DbContext?
Ne přímo; je scoped a služba je singleton. Vlož IServiceScopeFactory a vytvářej scope pro každou jednotku práce.
Jak spustit úlohu přesně jednou napříč několika instancemi?
Časovač v procesu běží jednou na instanci. Použij Hangfire nebo Quartz se sdíleným databázovým úložištěm, které obě mezi servery koordinují, nebo před během vezmi zámek v databázi.
Je Task.Run v controlleru úloha na pozadí?
Ne. Běží na thread poolu bez ošetření vypnutí, bez hlášení chyb a bez scope, a při restartu se ztratí. Dej práci radši do fronty zpracovávané hosted službou. Paměť a garbage collection v .NET vysvětluje, proč práce, která zachytí objekty požadavku, také drží paměť déle, než čekáš.




Komentáře
Zcela anonymně: žádný účet, žádný e-mail, žádné cookie. Ukládáme jen jméno, které napíšeš, text a čas - nic dalšího. Počet odkazů je omezený a značkování se nevykresluje.