Bölüm 12 / 14
Arka plan işleri
BackgroundService, hosted service, zamanlanmış görevler.
12 dakikalık okuma
Bir web uygulaması yalnız isteklere cevap vermez: e-posta gönderir,
rapor üretir, bekleyen kayıtları temizler, dış sistemden veri çeker. Bu işler isteğin içinde
yapılırsa kullanıcı bekler; hiç yapılmazsa iş eksik kalır. 4. hattın davranışsal desenler
dersinde BackgroundService'in Template Method olduğunu ve
Channel tabanlı komut kuyruğunun "5. hatta yazacağın gövde" olduğunu
söylemiştik. Bu ders o iki sözü birden ödüyor — ve arka plan işlerinin gerçek zorluğunun
kod değil, kapanma, kapsam ve birden fazla örnek olduğunu gösteriyor.
01Ne öğreneceksin
IHostedServiceyaşam döngüsü veBackgroundServiceStartAsync'te uzun iş yapmanın açılışı bloklamasıExecuteAsyncistisna fırlatırsa ne olur- Singleton servisten scoped bağımlılık kullanmak:
IServiceScopeFactory Channelile iş kuyruğu, sınırlı kapasite ve geri basınç- Denetleyicide
Task.Runile "ateşle ve unut" neden yanlıştır PeriodicTimerile zamanlanmış görev; Hangfire/Quartz kararı- Çok örnekli dağıtımda çift çalışma ve dağıtık kilit
02IHostedService ve BackgroundService
6. derste host'un uygulamayı barındıran kapsayıcı olduğunu görmüştün. Host,
kendisine kayıtlı IHostedService uygulamalarını açılışta başlatır ve kapanışta
durdurur. Arayüz iki metottan ibarettir:
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken); // açılışta, SIRAYLA çağrılır
Task StopAsync(CancellationToken cancellationToken); // kapanışta, TERS sırayla
}
Kritik davranış: StartAsync metotları sırayla ve tamamlanmaları
beklenerek çağrılır. Burada uzun bir iş yaparsan uygulama o iş bitene kadar istek
almaya başlamaz. Bu, "uygulama ayağa kalkmıyor" şikâyetinin yaygın sebebidir.
StartAsync yalnız hazırlık içindir: kaynak ayırma, bağlantı kurma,
doğrulama.
BackgroundService bu arayüzün soyut taban sınıfıdır ve tam olarak
Template Method'dur: iskelet sabittir (başlat, çalıştır, durdur), sen yalnız
ExecuteAsync adımını doldurursun.
public sealed class TemizlikServisi(
IServiceScopeFactory kapsamFabrikasi,
ILogger<TemizlikServisi> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken durdurmaJetonu)
{
using var zamanlayici = new PeriodicTimer(TimeSpan.FromMinutes(15));
while (await zamanlayici.WaitForNextTickAsync(durdurmaJetonu))
{
try
{
using var kapsam = kapsamFabrikasi.CreateScope(); // her turda yeni kapsam
var servis = kapsam.ServiceProvider.GetRequiredService<ISiparisTemizleyici>();
var silinen = await servis.SuresiDolanlariSil(durdurmaJetonu);
log.LogInformation("Temizlik tamam: {Adet} kayıt", silinen);
}
catch (OperationCanceledException) when (durdurmaJetonu.IsCancellationRequested)
{
break; // normal kapanma
}
catch (Exception hata)
{
log.LogError(hata, "Temizlik turu başarısız; sonraki turda tekrar denenecek.");
}
}
}
}
builder.Services.AddHostedService<TemizlikServisi>();
Bu kısa gövdede dört karar var ve dördü de üretim pratiğidir:
ExecuteAsyncilkawait'e kadar açılışı bloklar. Metodun başında senkron uzun bir iş yaparsan host beklemede kalır. Döngüden önce ağır bir hazırlık gerekiyorsaawait Task.Yield()ile denetimi geri verirsin.- Döngü gövdesi
try/catchiçinde. .NET 6'dan beri varsayılanBackgroundServiceExceptionBehavior.StopHost'tur: yakalanmayan bir istisna tüm uygulamayı düşürür. Bu varsayılan bilinçlidir — sessizce ölen bir arka plan servisi, çalıştığı sanılan ama hiçbir iş yapmayan bir sisteme yol açar. Yine de tek bir turun hatası tüm servisi bitirmemelidir; hata turun içinde yakalanır ve loglanır. PeriodicTimertercih edilir:Timer'ın geri çağrımları örtüşebilirken (bir tur bitmeden diğeri başlar),WaitForNextTickAsyncdöngüsü doğal olarak sıralıdır ve iptal jetonuna uyar.- İptal jetonu her yere taşınır. Bu, 6. derste anlatılan düzgün kapanmanın arka plan tarafıdır: sinyal geldiğinde jeton işaretlenir, döngü çıkar, host bekler.
03Kapsam kuralı: IServiceScopeFactory
Yukarıdaki koddaki en önemli satır CreateScope()'tur ve sebebi 4. derste
kurulmuştu: BackgroundService singleton olarak kaydedilir. Kurucusuna
scoped bir servis (ör. DbContext) enjekte edersen esir bağımlılık oluşur —
o nesne uygulama boyunca yaşar, izleme (change tracking) verisi birikir, bağlantı bayatlar
ve iş parçacığı güvenliği kaybolur.
// YANLIŞ — scoped bağımlılık singleton'a hapsolur
public sealed class RaporServisi(AppDbContext db) : BackgroundService { ... }
// DOĞRU — kapsam iş biriminin sınırıdır
public sealed class RaporServisi(IServiceScopeFactory fabrika) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var kapsam = fabrika.CreateScope();
var db = kapsam.ServiceProvider.GetRequiredService<AppDbContext>();
// ... bu turun işi
} // kapsam kapanır, DbContext atılır
}
}
Kapsamın sınırı bir tasarım kararıdır: web isteğinde kapsam = istek; arka plan
işinde kapsam = bir iş birimi. Tek bir kapsamı bütün döngü boyunca açık tutmak,
singleton'a enjekte etmekle aynı sonucu verir. Bir turda binlerce kayıt işleniyorsa kapsam
partilere bölünür — aksi hâlde DbContext'in izlediği nesne sayısı büyür ve
bellek şişer.
Bir yan not: 11. dersteki IHttpContextAccessor burada
null döner — arka plan işinin isteği yoktur. Korelasyon kimliği, kullanıcı
bilgisi ve kiracı gibi bağlam verileri, işi kuyruğa koyarken mesajın içine
yazılmalıdır.
04İş kuyruğu: Channel ile üretici-tüketici
Sık karşılaşılan istek şudur: bir uç nokta hızlı yanıt versin, asıl iş arkada yapılsın. İlk akla gelen çözüm yanlıştır:
// YANLIŞ — ateşle ve unut
[HttpPost]
public IActionResult Olustur(SiparisOlusturDto istek)
{
_ = Task.Run(() => _servis.AgirIs(istek)); // dört ayrı sorun
return Accepted();
}
Dört sorun: (1) İstek kapsamı yanıt yazılınca kapanır; Task.Run
içindeki kod atılmış bir DbContext kullanmaya çalışır. (2) İstisna
kimsenin görmediği bir yerde kaybolur. (3) Uygulama kapanırken bu iş beklenmez,
yarıda kalır. (4) Yük denetimi yoktur; bin eşzamanlı istek bin arka plan işi
başlatır.
Doğrusu, kuyruk + tüketici ayrımıdır — 4. hattaki Command deseninin canlı
hâli. System.Threading.Channels bunun için tasarlanmıştır:
public interface IIsKuyrugu
{
ValueTask Ekle(Func<IServiceProvider, CancellationToken, ValueTask> is_, CancellationToken ct);
IAsyncEnumerable<Func<IServiceProvider, CancellationToken, ValueTask>> Oku(CancellationToken ct);
}
public sealed class IsKuyrugu : IIsKuyrugu
{
private readonly Channel<Func<IServiceProvider, CancellationToken, ValueTask>> _kanal =
Channel.CreateBounded<Func<IServiceProvider, CancellationToken, ValueTask>>(
new BoundedChannelOptions(500) { FullMode = BoundedChannelFullMode.Wait });
public ValueTask Ekle(Func<IServiceProvider, CancellationToken, ValueTask> is_, CancellationToken ct)
=> _kanal.Writer.WriteAsync(is_, ct); // kuyruk doluysa BEKLER: geri basınç
public IAsyncEnumerable<Func<IServiceProvider, CancellationToken, ValueTask>> Oku(CancellationToken ct)
=> _kanal.Reader.ReadAllAsync(ct);
}
public sealed class KuyrukTuketicisi(IIsKuyrugu kuyruk, IServiceScopeFactory fabrika,
ILogger<KuyrukTuketicisi> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var is_ in kuyruk.Oku(ct))
{
using var kapsam = fabrika.CreateScope();
try { await is_(kapsam.ServiceProvider, ct); }
catch (Exception hata) { log.LogError(hata, "Kuyruk işi başarısız."); }
}
}
}
Sınırlı (bounded) kanal seçimi bilinçlidir. Sınırsız kuyruk, tüketici üreticiden
yavaş kaldığında belleği sessizce doldurur ve süreç OutOfMemoryException ile
düşer. Sınırlı kanalda dolduğunda ne olacağı seçilir: Wait üreticiyi bekletir
(geri basınç — sistem kendini korur), DropOldest eskiyi atar,
DropWrite yeniyi reddeder. Ölçüt: iş kaybı kabul edilebilir mi?
Kabul edilmiyorsa Wait ya da doğrudan kalıcı bir kuyruk kullanılır.
Bu çözümün sınırı açıkça bilinmelidir: kanal süreç içindedir. Uygulama düşerse kuyruktaki işler kaybolur ve çok örnekli dağıtımda her örneğin kendi kuyruğu olur. İşin kaybolmaması gerekiyorsa — ödeme, fatura, bildirim — kalıcı bir kuyruk (RabbitMQ, Kafka, veritabanı tablosu) gerekir; bu, 19. hattın konusudur. Ara bir çözüm, işi önce veritabanına "bekliyor" durumuyla yazmak, arka plan servisinin oradan okumasıdır: süreç düşse bile kayıt durur.
05Zamanlanmış görevler ve çok örnek sorunu
Belirli aralıklarla ya da belirli saatte çalışan işler için üç seçenek vardır:
| Seçenek | Uygun olduğu yer | Sınırı |
|---|---|---|
PeriodicTimer + BackgroundService |
Basit aralıklı işler ("15 dakikada bir") | Cron ifadesi, kalıcılık ve tekrar denemesi yok |
| Hangfire | Kalıcı iş, panel, otomatik yeniden deneme | Veritabanı bağımlılığı, ek altyapı |
| Quartz.NET | Karmaşık takvim, cron, kümeleme | Yapılandırma yükü |
| Dış zamanlayıcı (cron / K8s CronJob) | İşin ayrı bir süreç olması istendiğinde | Uygulamadan ayrı dağıtım |
Asıl tuzak ölçeklemede ortaya çıkar. Uygulaman üç kopya (replica) hâlinde
çalışıyorsa, BackgroundService de üç kez çalışır: gece raporu üç kez
üretilir, temizlik üç kopya birbirinin kaydını silmeye çalışır, e-posta üç kez gider. Bu,
tek sunucuda test edilip üretimde fark edilen tipik bir hatadır. Üç çözüm:
- Dağıtık kilit — iş başlamadan önce paylaşılan bir kaynaktan (Redis, veritabanı satırı) kilit alınır; alamayan örnek turu atlar. Kilidin süresi ve yenilenmesi doğru kurgulanmalıdır, yoksa çöken bir örnek kilidi sonsuza kadar tutar.
- Tek örneğe ayırmak — arka plan işleri ayrı bir dağıtımda (tek kopya) çalışır; web katmanı serbestçe ölçeklenir. Üretimde en yaygın ve en anlaşılır çözüm budur.
- İşi idempotent yapmak — 11. dersteki kural burada da geçerlidir: iş iki kez çalışsa da sonuç değişmiyorsa sorun küçülür. "Gönderildi" damgasını atomik olarak koyup yalnız damgayı koyabilen örneğin göndermesi bu yaklaşımdır.
Zaman tarafında iki ayrıntı: takvim işleri UTC ile kurulur ve yerel saate yalnız görüntüleme sırasında çevrilir; yaz saati geçişlerinde yerel saate bağlı bir cron ifadesi ya iki kez çalışır ya hiç çalışmaz. Ve kaçırılan çalışma davranışı kararlaştırılır: uygulama gece 03:00'te kapalıysa, açıldığında o turu telafi edecek mi? Cevap "evet" ise son çalışma zamanı kalıcı olarak saklanmalıdır.
06Kapanma, gözlemlenebilirlik ve sınır
Düzgün kapanma arka plan işlerinde web isteklerinden daha kritiktir, çünkü bir
istek yarıda kalırsa istemci tekrar dener; yarıda kalan bir arka plan işi ise sessizce
kaybolur. Sıra 6. derste anlatıldığı gibidir: sinyal gelir, stoppingToken
işaretlenir, ExecuteAsync çıkmaya çalışır, host sınırlı bir süre
bekler. Bu süre varsayılan olarak 30 saniyedir ve ayarlanabilir:
builder.Services.Configure<HostOptions>(o =>
{
o.ShutdownTimeout = TimeSpan.FromSeconds(45);
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;
});
Süre dolarsa iş yarıda kesilir. Bu yüzden uzun süren bir iş, iptal jetonunu
düzenli aralıklarla kontrol eden ve ilerlemesini kaydeden parçalara bölünür:
100 kaydı işleyip durumu yazmak, 100 000 kaydı tek işlemde yapmaya çalışıp kapanışta
hepsini kaybetmekten iyidir. Kapsayıcı ortamlarda ek bir ayrıntı: SIGTERM
ile zorla sonlandırma arasındaki süre platform tarafından da sınırlanır
(terminationGracePeriodSeconds), uygulamanın beklediği süre ondan uzun
olmamalıdır — 17. hatta bu ayarı göreceksin.
Gözlemlenebilirlik. Arka plan işleri sessiz çalışır; bu yüzden çalışmadıklarında
da sessizdirler. Üç ölçüm asgari şarttır: son başarılı çalışma zamanı, tur
süresi ve işlenen/başarısız kayıt sayısı. Son başarılı çalışma zamanı bir
sağlık kontrolüne bağlanır — "son turdan bu yana 3 katından fazla süre geçtiyse
sağlıksız" — ve 18. hattaki sağlık uçlarıyla dış izlemeye açılır. Log kayıtlarına
BeginScope ile iş kimliği eklenirse (5. ders) tek bir turun tüm satırları
birlikte okunabilir.
Sınır: ne zaman gerçek kuyruk sistemine geçilir? Ölçüt üç sorudur: iş kaybı kabul
edilebilir mi, işler örnekler arasında paylaştırılmalı mı, yeniden deneme ve ölü mektup
kutusu (dead-letter) gerekiyor mu? Üçünden birine "hayır/evet" cevabı veriyorsan süreç içi
kanal yetmez ve RabbitMQ, Kafka ya da bulut kuyruğu gerekir — 19. hattın konusu.
BackgroundService o kurgularda da kaybolmaz: kuyruk tüketicisi genellikle yine
bir BackgroundService'tir, yalnız kanal yerine kalıcı kuyruktan okur.
07Mini alıştırma — Sessiz işi görünür kıl
Yaklaşık 20 dakika · dotnet run (web API)
- Açılışı bloklamak:
ExecuteAsync'in ilk satırınaThread.Sleep(10000)koy ve uygulamayı başlat. İstek almaya ne zaman başladı? Sonraawait Task.Yield()ekleyip tekrarla. - İstisna davranışı: döngü içinde bilerek istisna fırlat ve
try/catcholmadan çalıştır. Uygulamaya ne oldu? Sonracatchekleyip farkı gör. - Esir bağımlılık:
BackgroundService'in kurucusuna scoped bir servis enjekte etmeyi dene ve hatayı oku. SonraIServiceScopeFactoryile düzelt. - Kuyruk:
Channeltabanlı bir iş kuyruğu ve tüketicisi yaz; bir uç nokta işi kuyruğa atıp hemen202 Accepteddönsün. - Geri basınç: kanalı 5 kapasiteli ve
Waitmodunda kur, 50 istek gönder. Uç noktanın yanıt süreleri ne oldu? ModuDropWriteyapıp karşılaştır. - Kaybolan iş: kuyrukta iş varken uygulamayı Ctrl+C ile durdur. Kaç iş kayboldu? İşi önce veritabanına "bekliyor" olarak yazan bir sürüm yaz ve yeniden başlattığında kaldığı yerden devam ettiğini göster.
- Zamanlanmış iş:
PeriodicTimerile 10 saniyede bir çalışan bir tur yaz; her turun başlangıç ve bitiş zamanını logla. Tur süresi aralıktan uzun olursa ne oluyor? - Çift çalışma: uygulamayı iki farklı portta aynı anda başlat. Zamanlanmış iş kaç kez çalıştı? Veritabanında bir kilit satırıyla yalnız birinin çalışmasını sağla.
08Kendini sına
IHostedService ve BackgroundService nasıl çalışır?
IHostedService host'un açılışta başlattığı, kapanışta
durdurduğu bileşendir; iki metodu vardır. StartAsync'ler sırayla ve
tamamlanmaları beklenerek çağrılır, bu yüzden orada uzun iş yapmak uygulamanın istek
almaya başlamasını geciktirir — StartAsync yalnız hazırlık içindir.
StopAsync'ler ters sırayla çağrılır. BackgroundService bu
arayüzün soyut taban sınıfıdır ve Template Method desenidir: iskelet sabittir, sen yalnız
ExecuteAsync adımını doldurursun. ExecuteAsync da ilk
await'e kadar açılışı bloklar; gerekiyorsa await Task.Yield()
ile denetim geri verilir. .NET 6'dan beri varsayılan davranış
BackgroundServiceExceptionBehavior.StopHost'tur: yakalanmayan istisna tüm
uygulamayı düşürür, bu yüzden tur gövdesi try/catch ile sarılır ve hata
loglanır. Aralıklı işlerde Timer yerine PeriodicTimer tercih
edilir, çünkü turlar örtüşmez ve iptal jetonuna uyar.
Arka plan servisinde DbContext gibi scoped bir servisi nasıl kullanırsın?
BackgroundService singleton olarak kaydedilir; kurucusuna
scoped servis enjekte etmek esir bağımlılıktır — nesne uygulama boyunca yaşar,
değişiklik izleme verisi birikir, bağlantı bayatlar ve iş parçacığı güvenliği kaybolur.
Doğrusu kurucudan IServiceScopeFactory almak ve her iş birimi için
CreateScope() ile yeni kapsam açmaktır; kapsam using ile
kapandığında scoped servisler atılır. Kapsamın sınırı bir tasarım kararıdır: web isteğinde
kapsam istektir, arka plan işinde bir iş birimidir; tek kapsamı bütün döngü boyunca açık
tutmak singleton'a enjekte etmekle aynı sonucu verir, bu yüzden büyük işler partilere
bölünür. Ayrıca IHttpContextAccessor burada null döner — korelasyon kimliği,
kullanıcı ve kiracı bilgisi işin mesajının içine yazılır.
Denetleyicide Task.Run ile işi arkaya atmak neden yanlış? Doğrusu nedir?
Dört sorunu vardır: istek kapsamı yanıt yazılınca kapanır ve arkadaki
kod atılmış bir DbContext kullanmaya çalışır; istisna kimsenin görmediği bir
yerde kaybolur; uygulama kapanırken bu iş beklenmez ve yarıda kalır; yük denetimi yoktur,
bin istek bin iş başlatır. Doğrusu üretici-tüketici ayrımıdır: uç nokta işi bir kuyruğa
koyar ve hemen 202 Accepted döner, bir BackgroundService
kuyruktan okuyup her iş için kendi kapsamını açarak çalıştırır — bu, Command deseninin
canlı hâlidir. Süreç içi kuyruk System.Threading.Channels ile kurulur ve
sınırlı seçilir: sınırsız kanal, tüketici yavaş kaldığında belleği doldurur.
Doluluk davranışı işin kaybının kabul edilebilirliğine göre seçilir —
Wait geri basınç uygular, DropOldest/DropWrite iş
atar. Kanalın sınırı da bilinmelidir: süreç içindedir, uygulama düşerse kuyruk kaybolur;
iş kaybı kabul edilemiyorsa kalıcı kuyruk ya da "bekliyor" durumlu veritabanı kaydı
gerekir.
Uygulama birden çok kopya çalışırken zamanlanmış iş ne olur, nasıl çözersin?
Her kopya kendi BackgroundService'ini çalıştırır, yani
iş kopya sayısı kadar tekrarlanır: rapor üç kez üretilir, e-posta üç kez gider, temizlik
kopyaları birbirinin kaydını silmeye çalışır. Tek sunucuda test edilip üretimde fark
edilen tipik bir hatadır. Üç çözüm vardır: dağıtık kilit (Redis ya da veritabanı
satırı üzerinden; kilidin süresi ve yenilenmesi doğru kurulmalıdır, yoksa çöken bir örnek
kilidi tutar), arka plan işlerini tek kopyalı ayrı bir dağıtıma almak (en yaygın ve
anlaşılır çözüm; web katmanı serbestçe ölçeklenir) ve işi idempotent yapmak (iki
kez çalışsa da sonuç değişmesin; "gönderildi" damgasını atomik koyup yalnız koyabilen
örneğin göndermesi). Zaman tarafında takvim işleri UTC ile kurulur — yerel saate bağlı
cron, yaz saati geçişinde ya iki kez çalışır ya hiç çalışmaz — ve kaçırılan çalışmanın
telafi edilip edilmeyeceğine karar verilip son çalışma zamanı kalıcı saklanır.
09Sınav köşesi
Yazılıda nasıl sorulur
(1) IHostedService arayüzü ve BackgroundService ilişkisi.
(2) BackgroundService hangi tasarım desenidir. (3)
StartAsync'te uzun iş yapılırsa ne olur. (4) Arka plan servisinde
DbContext nasıl kullanılır; IServiceScopeFactory neden gerekir.
(5) Task.Run ile ateşle-unut yaklaşımının sorunları. (6)
Channel nedir; sınırlı ve sınırsız kanal farkı. (7)
PeriodicTimer ile Timer farkı. (8) Zamanlanmış iş için
Hangfire/Quartz ne zaman gerekir. (9) Çok örnekli dağıtımda çift çalışma sorunu ve
çözümleri. (10) Kod yazma: her 15 dakikada bir çalışan, kapanmaya duyarlı bir servis
yazınız.
Sözlüde nasıl anlatılır — 2 dakikalık taslak
Düzen: (1) Model — "arka plan işleri host'a kayıtlı IHostedService'lerdir;
pratikte BackgroundService taban sınıfını kullanırım, bu bir Template
Method'tur — iskelet sabit, ben ExecuteAsync'i doldururum."
(2) Kapsam — "servis singleton'dır, bu yüzden DbContext'i kurucudan almam;
IServiceScopeFactory ile her iş birimi için kapsam açarım." (3) Kuyruk —
"isteği hızlı bitirmek için işi Channel tabanlı sınırlı bir kuyruğa atar,
tüketiciyi ayrı bir BackgroundService yaparım; Task.Run ile
ateşle-unut yapmam, kapsam kapanır ve istisna kaybolur." (4) Kapanma — "iptal jetonunu
her yere taşırım, işi ilerleme kaydeden parçalara bölerim; kapanış süresi sınırlıdır ve
dolarsa iş kesilir." (5) Kıdem vuruşu — "en sık kaçırılan nokta ölçeklemedir: üç kopya
çalışıyorsa iş üç kez çalışır. Ya arka plan işlerini tek kopyalı ayrı dağıtıma alırım,
ya dağıtık kilit kurarım, ya da işi idempotent yaparım. Ayrıca son başarılı çalışma
zamanını ölçüp sağlık kontrolüne bağlarım — arka plan işi çalışmadığında da sessizdir."
