.NET'te IHostedService ve BackgroundService: Yaşam Döngüsü, Graceful Shutdown ve .NET 10 Davranış Değişikliği
.NET 10'da BackgroundService.ExecuteAsync'in tamamı arka planda çalışır; ilk await'ten önceki kod artık startup'ı bloklamaz, erken çalışması gereken kod StartAsync'e ya da IHostedLifecycleService'e taşınır. Host kapanırken stoppingToken iptal edilir ve tüm durdurma işine varsayılan 30 saniyelik ShutdownTimeout verilir; token'ı taşımayan iş bu sürenin sonunda log bile bırakmadan kesilir.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; .NET 8 karşılaştırmaları aynı projenin net8.0 hedefiyle .NET 8.0.25 runtime'ında koşturuldu.
Önce Tahmin Et
Bookstore'un satış raporu service'i açılışta bir cache'i ısıtıyor ve bunu, ilk await'ten önce senkron olarak 3 saniye süren bir çağrıyla yapıyor. Aynı uygulamada Kestrel de HTTP (Hypertext Transfer Protocol) isteklerini dinliyor.
public sealed class SlowStartReportService(ILogger<SlowStartReportService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("ExecuteAsync entered, warming cache synchronously");
Thread.Sleep(3000); // ilk await'ten önce senkron, bloklayan iş
logger.LogInformation("cache warmed");
await Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { });
logger.LogInformation("ExecuteAsync leaving");
}
}
İki soru. Uygulama .NET 8'de ve .NET 10'da çalışırken "Application started" satırı ne zaman görünür: hemen mi, 3 saniye sonra mı? İkincisi: bir başka service stoppingToken'ı yok sayıp 20 saniye süren bir rapor üretiyorsa ve ShutdownTimeout 5 saniyeye ayarlıysa, process SIGTERM (terminate signal) aldığında rapor biter mi?
IHostedService ve BackgroundService: Sözleşme
.NET'in Generic Host'u, ASP.NET Core dahil, başlarken DI (Dependency Injection) container'ındaki her IHostedService'i başlatır, kapanırken de durdurur. Sözleşme iki method'dan ibaret: StartAsync(CancellationToken) ve StopAsync(CancellationToken). Host, StartAsync'ler bitmeden "başladım" demez; varsayılan olarak service'leri kayıt sırasıyla, birinin StartAsync'i bitmeden diğerine geçmeden başlatır. Bu yüzden StartAsync kısa olmalı. ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core'da Program.cs ve Hosting Modeli: WebApplication ve Environment'lar" yazısında host'un nasıl kurulduğunu görmüştük; burada onun içinde yaşayan arka plan işlerine bakıyoruz.
BackgroundService bu sözleşmenin uzun süren işler için yazılmış abstract hâli. Sen yalnız ExecuteAsync(CancellationToken stoppingToken)'ı yazarsın; dönen Task service'in tüm ömrünü temsil eder. StartAsync bu method'u başlatıp beklemeden döner, StopAsync ise stoppingToken'ı iptal edip ExecuteAsync'in bitmesini bekler. Ne kadar bekleyeceğini host'un kapanış bütçesi belirler.
.NET 8 ile gelen IHostedLifecycleService ise bu iki adımın etrafına dört hook daha ekliyor: StartingAsync, StartedAsync, StoppingAsync ve StoppedAsync. Sırayı görmek için hepsini loglayan bir service yazdım:
public sealed class LifecycleProbe(ILogger<LifecycleProbe> logger) : IHostedLifecycleService
{
public Task StartingAsync(CancellationToken ct) { logger.LogInformation("StartingAsync"); return Task.CompletedTask; }
public Task StartAsync(CancellationToken ct) { logger.LogInformation("StartAsync"); return Task.CompletedTask; }
public Task StartedAsync(CancellationToken ct) { logger.LogInformation("StartedAsync"); return Task.CompletedTask; }
public Task StoppingAsync(CancellationToken ct) { logger.LogInformation("StoppingAsync"); return Task.CompletedTask; }
public Task StopAsync(CancellationToken ct) { logger.LogInformation("StopAsync"); return Task.CompletedTask; }
public Task StoppedAsync(CancellationToken ct) { logger.LogInformation("StoppedAsync"); return Task.CompletedTask; }
}
Başlangıç ve Kapanış Sırası
LifecycleProbe ve baştaki SlowStartReportService'i bu sırayla kaydedip uygulamayı .NET 10'da çalıştırdım, 5 saniye sonra SIGTERM gönderdim:
builder.Services.AddHostedService<LifecycleProbe>();
builder.Services.AddHostedService<SlowStartReportService>();
04:54:47.274 info: Bookstore.Jobs.LifecycleProbe[0] StartingAsync
04:54:47.287 info: Bookstore.Jobs.LifecycleProbe[0] StartAsync
04:54:47.288 info: Bookstore.Jobs.SlowStartReportService[0] ExecuteAsync entered, warming cache synchronously
04:54:47.297 info: Microsoft.Hosting.Lifetime[14] Now listening on: http://127.0.0.1:17110
04:54:47.297 info: Bookstore.Jobs.LifecycleProbe[0] StartedAsync
04:54:47.297 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
04:54:50.290 info: Bookstore.Jobs.SlowStartReportService[0] cache warmed
04:54:52.157 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
04:54:52.157 info: Bookstore.Jobs.LifecycleProbe[0] StoppingAsync
04:54:52.160 info: Bookstore.Jobs.SlowStartReportService[0] ExecuteAsync leaving
04:54:52.160 info: Bookstore.Jobs.LifecycleProbe[0] StopAsync
04:54:52.160 info: Bookstore.Jobs.LifecycleProbe[0] StoppedAsync
Sıra şöyle: önce bütün service'lerin StartingAsync'i, sonra StartAsync'leri, sonra Kestrel'in dinlemeye başlaması, sonra StartedAsync'ler ve en son "Application started". Kapanışta ters yönde: StoppingAsync, ardından StopAsync'ler kayıt sırasının tersiyle, yani önce sonra kaydedilen SlowStartReportService durdu ("ExecuteAsync leaving"), sonra LifecycleProbe, en son StoppedAsync.

.NET 10 Davranış Değişikliği: ExecuteAsync Tamamen Arka Planda
Yukarıdaki log'a tekrar bak: "ExecuteAsync entered" 47.288'de, "Application started" 47.297'de, "cache warmed" ise 3 saniye sonra 50.290'da. Yani Thread.Sleep(3000) startup'ı hiç bloklamadı. Aynı projeyi net8.0 hedefiyle çalıştırınca:
04:54:52.269 info: Bookstore.Jobs.LifecycleProbe[0] StartingAsync
04:54:52.286 info: Bookstore.Jobs.LifecycleProbe[0] StartAsync
04:54:52.286 info: Bookstore.Jobs.SlowStartReportService[0] ExecuteAsync entered, warming cache synchronously
04:54:55.293 info: Bookstore.Jobs.SlowStartReportService[0] cache warmed
04:54:55.313 info: Microsoft.Hosting.Lifetime[14] Now listening on: http://127.0.0.1:17110
04:54:55.314 info: Bookstore.Jobs.LifecycleProbe[0] StartedAsync
04:54:55.314 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
.NET 8'de "Now listening" ve "Application started" 3 saniye gecikti. Sebep, eski BackgroundService.StartAsync'in ExecuteAsync'i doğrudan çağırması: method'un ilk await'e kadar olan senkron kısmı, host'un startup thread'inde çalışıyordu. Senkron bir veritabanı çağrısı, bir .Result ya da sadece await'siz başlayan uzun bir döngü bütün uygulamanın açılışını tutuyordu; daha kötüsü, bunu bilen çok azdı.
.NET 10 ile BackgroundService, ExecuteAsync'in tamamını arka planda çalıştırıyor. Microsoft Learn'deki breaking change notu değişikliğin gerekçesini açıkça yazıyor: eski davranış, kullanıcıların beklentisini karşılamayan yaygın bir tuzaktı. Bu bir davranış değişikliği; kodun derlenmesi değişmiyor, sadece zamanlaması.
Peki gerçekten startup'ı bloklaması gereken bir kodun varsa (örneğin migration'ı uygulamadan Kestrel'in istek almaya başlamaması gerekiyorsa)? Breaking change notu dört yol öneriyor: kodu constructor'a koymak, StartAsync'i override edip base.StartAsync'ten önce işi yapmak, IHostedLifecycleService'in StartingAsync gibi bir hook'unu kullanmak ya da BackgroundService yerine doğrudan IHostedService yazmak. En okunaklısı ikincisi:
public sealed class CatalogIndexService(ICatalogIndex index) : BackgroundService
{
public override async Task StartAsync(CancellationToken cancellationToken)
{
// buradaki kod hâlâ startup'ı bloklar: diğer service'ler ve Kestrel bekler
await index.LoadSnapshotAsync(cancellationToken);
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// .NET 10'da bunun tamamı arka planda çalışır
using PeriodicTimer timer = new(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
await index.RefreshAsync(stoppingToken);
}
}
1,5 saniye süren bir snapshot yüklemesiyle denediğimde Kestrel, snapshot yüklenene kadar dinlemeye başlamadı:
05:42:07.607 info: Bookstore.Jobs.FakeCatalogIndex[0] snapshot loaded
05:42:07.651 info: Microsoft.Hosting.Lifetime[14] Now listening on: http://127.0.0.1:17110
05:42:07.651 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
Bu değişikliğin bir yan etkisi daha var: ExecuteAsync'in ilk await'inden önce fırlayan bir exception .NET 8'de StartAsync'ten çıkıp host'u hiç başlatmıyordu; .NET 10'da arka planda yakalanıp aşağıda göreceğimiz BackgroundServiceExceptionBehavior kuralına tabi oluyor. Bunun gerçek çıktısını serinin son yazısında, sürüm geçişlerini konuşurken karşılaştıracağız.
HostOptions: Kapanış Bütçesi ve Hata Davranışı
Host'un davranışını HostOptions belirliyor. Varsayılan değerleri kendin görmek için:
var o = new HostOptions();
Console.WriteLine($"ShutdownTimeout={o.ShutdownTimeout}, StartupTimeout={o.StartupTimeout}, " +
$"BackgroundServiceExceptionBehavior={o.BackgroundServiceExceptionBehavior}, " +
$"ServicesStartConcurrently={o.ServicesStartConcurrently}, ServicesStopConcurrently={o.ServicesStopConcurrently}");
// ShutdownTimeout=00:00:30, StartupTimeout=-00:00:00.0010000, BackgroundServiceExceptionBehavior=StopHost, ServicesStartConcurrently=False, ServicesStopConcurrently=False
Üç değer özellikle önemli. ShutdownTimeout 30 saniye ve bu, tek bir service'e değil bütün kapanışa verilen bütçe; API dokümanı StoppingAsync ve StoppedAsync hook'larının da bu sürenin içinde olduğunu yazıyor. ServicesStartConcurrently ve ServicesStopConcurrently varsayılan olarak kapalı: service'ler sırayla başlar ve durur. Açarsan yavaş bir StartAsync diğerlerini bekletmez, ama sıraya güvenen bir kodun varsa (önce cache, sonra onu kullanan service) o güvence de gider.
BackgroundServiceExceptionBehavior'ın varsayılanı StopHost. Stok senkronizasyonu yapan ve ikinci turda exception fırlatan bir service ile denedim:
public sealed class CrashingService(ILogger<CrashingService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
int run = 0;
while (!stoppingToken.IsCancellationRequested)
{
run++;
logger.LogInformation("Stock sync run {Run}", run);
if (run == 2)
throw new InvalidOperationException("Supplier API returned 500");
await Task.Delay(500, stoppingToken);
}
}
}
04:55:04.080 info: Bookstore.Jobs.CrashingService[0] Stock sync run 1
04:55:04.093 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
04:55:04.595 info: Bookstore.Jobs.CrashingService[0] Stock sync run 2
04:55:04.604 fail: Microsoft.Extensions.Hosting.Internal.Host[9] BackgroundService failed System.InvalidOperationException: Supplier API returned 500 at Bookstore.Jobs.CrashingService.ExecuteAsync(CancellationToken stoppingToken) [...]
04:55:04.604 crit: Microsoft.Extensions.Hosting.Internal.Host[10] The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost. A BackgroundService has thrown an unhandled exception, and the IHost instance is stopping. To avoid this behavior, configure this to Ignore; however the BackgroundService will not be restarted. [...]
04:55:04.604 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
RunAsync returned, exit code 0
Tek bir service'in yakalanmamış exception'ı bütün uygulamayı, yani HTTP isteklerine cevap veren API'yi de kapattı. Üstelik process çıkış kodu 0: Kubernetes ya da systemd açısından uygulama "başarıyla" kapandı. Ignore seçeneği host'u ayakta tutar ama log mesajının da söylediği gibi service yeniden başlatılmaz; arka plandaki işin sessizce durduğu bir API elde edersin. Doğru cevap çoğu zaman ikisi de değil: döngünün her turunu kendi try/catch'i ile korumak ve exception'ı yalnız gerçekten kurtarılamaz durumlarda dışarı bırakmak. .NET 11 ile bu senaryoda RunAsync ve StopAsync'in exception fırlatması, dolayısıyla process'in sıfırdan farklı bir kodla çıkması geliyor.
Graceful Shutdown: stoppingToken'ı Sonuna Kadar Taşımak
Baştaki ikinci soru. ShutdownTimeout'u 5 saniyeye çektim ve token'ı yok sayan bir service yazdım:
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(5));
builder.Services.AddHostedService<StubbornService>();
public sealed class StubbornService(ILogger<StubbornService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Nightly report started");
await Task.Delay(TimeSpan.FromSeconds(20)); // stoppingToken taşınmıyor
logger.LogInformation("Nightly report finished");
}
}
04:55:27.666 info: Bookstore.Jobs.StubbornService[0] Nightly report started
04:55:27.679 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
04:55:30.507 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
04:55:35.512 RunAsync returned, exit code 0
SIGTERM 30.507'de geldi, host tam 5 saniye bekledi ve 35.512'de RunAsync döndü. "Nightly report finished" hiç yazılmadı; host bir uyarı da loglamadı. Rapor yarıda kaldı ve bunu söyleyen tek şey, olmayan bir log satırı.
Çözüm, stoppingToken'ı işin en alt katmanına kadar taşımak: Task.Delay'e, HttpClient çağrılarına, Entity Framework Core (EF Core) sorgularına. ASP.NET Core Web API serisinin "ASP.NET Core'da Async/Await ve CancellationToken: İptali Veritabanına Kadar Taşımak" yazısında request için yaptığımızın aynısı; burada request'in yerini host'un kapanışı alıyor. Token iptal edildiğinde işin yarım kalan kısmını tekrar başlatılabilir bırakmak ise ayrı bir tasarım konusu; uzun batch job'larda checkpoint ile bunu dokuzuncu yazıda yapacağız.
Kubernetes tarafında da bir bütçe var. Pod silinirken kubelet önce varsa preStop hook'unu çalıştırır, sonra container'a SIGTERM gönderir ve terminationGracePeriodSeconds (varsayılan 30 saniye) dolunca SIGKILL (kill signal) ile öldürür; preStop'un süresi de bu bütçeden düşer. Uygulamanın ShutdownTimeout'u bu sürenin içinde kalmalı. Varsayılanlar ikisi de 30 saniye olduğundan, preStop'ta birkaç saniye bekleyen bir pod'da host kendi süresini dolduramadan öldürülür. Uzun süren işler için ya terminationGracePeriodSeconds'ı büyüt ya da işi kısa parçalara böl.
Scoped Service Tüketmek: Her Tur Kendi Scope'unda
Hosted service'ler singleton olarak kaydedilir. Constructor'ına scoped bir service (bir repository, bir DbContext) koyarsan ne olur? Development ortamında scope doğrulaması açık olduğu için host hiç başlamaz:
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddHostedService<CaptiveReminderService>();
public sealed class CaptiveReminderService(OrderRepository repository, ILogger<CaptiveReminderService> logger)
: BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Using repository #{Id}", repository.InstanceId);
return Task.CompletedTask;
}
}
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: Microsoft.Extensions.Hosting.IHostedService Lifetime: Singleton ImplementationType: Bookstore.Jobs.CaptiveReminderService': Cannot consume scoped service 'Bookstore.Jobs.OrderRepository' from singleton 'Microsoft.Extensions.Hosting.IHostedService'.)
Production'da doğrulama varsayılan olarak kapalı ve uygulama açılır; bu sefer scoped nesne bir singleton'ın içine hapsolur ve uygulama yaşadıkça yaşar. DbContext için bu, change tracker'ın sürekli büyümesi ve thread-safe olmayan bir nesnenin paylaşılması demek. ASP.NET Core Web API serisindeki "ASP.NET Core'da Dependency Injection: Lifetime'lar ve Captive Dependency Tuzağı" yazısının tam konusu bu.
Doğrusu, IServiceScopeFactory'yi inject edip her iterasyonda yeni bir scope açmak:
public sealed class PendingOrderReminderService(
IServiceScopeFactory scopeFactory,
ILogger<PendingOrderReminderService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// her tur kendi scope'u: DbContext gibi scoped service'ler tur bitince dispose edilir
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
OrderRepository repository = scope.ServiceProvider.GetRequiredService<OrderRepository>();
int pending = await repository.CountPendingAsync(stoppingToken);
logger.LogInformation("Repository #{Id}: {Pending} pending orders", repository.InstanceId, pending);
}
}
}
04:55:51.306 info: Bookstore.Jobs.PendingOrderReminderService[0] Repository #1: 3 pending orders
OrderRepository #1 disposed
04:55:52.306 info: Bookstore.Jobs.PendingOrderReminderService[0] Repository #2: 3 pending orders
OrderRepository #2 disposed
04:55:53.306 info: Bookstore.Jobs.PendingOrderReminderService[0] Repository #3: 3 pending orders
OrderRepository #3 disposed
04:55:53.666 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
Her tur yeni bir OrderRepository aldı ve tur bitince dispose edildi. Scope'u tur başına açmak önemli; bütün ExecuteAsync için tek bir scope açmak, captive dependency'yi sadece bir kat aşağı taşır.
Üretimde Dikkat
Kapanışı ölç. Deploy sırasında "Application is shutting down" ile process'in gerçek çıkışı arasındaki süreyi log'larından çıkar; bu süre düzenli olarak ShutdownTimeout'a dayanıyorsa bir service token'ı taşımıyor demektir. Her ExecuteAsync döngüsünün çıkışında "stopped after N items" gibi bir log satırı bırakmak, yarıda kalan işi sonradan bulmanı sağlar.
Exit code'a güvenme. .NET 10'da bir BackgroundService çöktüğünde host durur ama process 0 ile çıkar; orchestrator bunu normal kapanış sanabilir. Container'ın yeniden başlatılması gerekiyorsa liveness probe'unu bu service'in sağlığına bağla ya da .NET 11'deki davranışı beklerken RunAsync'ten sonra durumu kontrol edip Environment.ExitCode'u kendin ayarla.
Startup süresini izle. .NET 10'a geçince açılış hızlanmış görünebilir, çünkü artık bekletilmeyen işler var; ama o işler hâlâ çalışıyor ve hazır olmadan ilk istekler gelebilir. Hazır olmayı readiness probe ile ifade et; health check'in temeli ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Health Check: Liveness, Readiness ve Veritabanı Kontrolü" yazısında.
Sık Düşülen Tuzaklar
ExecuteAsync içinde yakalanmamış exception. Varsayılan StopHost ile tek bir arka plan işinin hatası bütün API'yi kapatır. Her iterasyonu try/catch içine al; OperationCanceledException'ı kapanış sinyali olarak ayrı tut.
while (true) içinde beklemeyen döngü. await içermeyen ya da yalnızca hemen tamamlanan Task'ları bekleyen bir döngü bir thread'i yüzde yüz CPU (Central Processing Unit) ile kilitler ve iptali hiç görmez. Periyodik işte PeriodicTimer ya da token'lı Task.Delay kullan.
StopAsync'te asılı kalan iş. StopAsync'i override edip içinde token'sız bir şey beklersen, bütün kapanış bütçesini o service yer ve kendisinden sonra durması gereken service'ler hiç sıra bulamaz.
.NET 10'a geçince sıralamaya güvenmek. Eskiden "bu service'in ilk satırı startup'ta çalışır" varsayımıyla yazılmış kod artık arka planda çalışıyor; başka bir service o işin bittiğini varsayıyorsa yarış durumu doğar.
Küçük Bir Deneme
★☆☆ LifecycleProbe'u iki kez farklı isimlerle kaydet ve ServicesStartConcurrently = true ile çalıştır. Log sırası nasıl değişiyor?
★★☆ StubbornService'te Task.Delay'e stoppingToken'ı ver ve catch (OperationCanceledException) içinde "report cancelled at step N" yazdır. SIGTERM'den sonra kapanış ne kadar sürüyor?
★★★ CrashingService'i Ignore davranışıyla çalıştır ve service çöktükten sonra servis edilen bir health check endpoint'i yaz: service'in son başarılı turu 3 saniyeden eskiyse Unhealthy dönsün. Bunu Kubernetes liveness probe'una nasıl bağlardın?
Bir sonraki yazıda bu temeli bir Worker Service'e taşıyoruz: API'den ayrı bir process, Channel ile in-process job queue'su, dolunca 503 dönen bir backpressure, PeriodicTimer ile periyodik iş ve Windows Service ya da systemd altında barındırma.