Skip to content

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

  • IHostedService yaşam döngüsü ve BackgroundService
  • StartAsync'te uzun iş yapmanın açılışı bloklaması
  • ExecuteAsync istisna fırlatırsa ne olur
  • Singleton servisten scoped bağımlılık kullanmak: IServiceScopeFactory
  • Channel ile iş kuyruğu, sınırlı kapasite ve geri basınç
  • Denetleyicide Task.Run ile "ateşle ve unut" neden yanlıştır
  • PeriodicTimer ile 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:

  1. ExecuteAsync ilk await'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 gerekiyorsa await Task.Yield() ile denetimi geri verirsin.
  2. Döngü gövdesi try/catch içinde. .NET 6'dan beri varsayılan BackgroundServiceExceptionBehavior.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.
  3. PeriodicTimer tercih edilir: Timer'ın geri çağrımları örtüşebilirken (bir tur bitmeden diğeri başlar), WaitForNextTickAsync döngüsü doğal olarak sıralıdır ve iptal jetonuna uyar.
  4. İ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çenekUygun olduğu yerSı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)

  1. Açılışı bloklamak: ExecuteAsync'in ilk satırına Thread.Sleep(10000) koy ve uygulamayı başlat. İstek almaya ne zaman başladı? Sonra await Task.Yield() ekleyip tekrarla.
  2. İstisna davranışı: döngü içinde bilerek istisna fırlat ve try/catch olmadan çalıştır. Uygulamaya ne oldu? Sonra catch ekleyip farkı gör.
  3. Esir bağımlılık: BackgroundService'in kurucusuna scoped bir servis enjekte etmeyi dene ve hatayı oku. Sonra IServiceScopeFactory ile düzelt.
  4. Kuyruk: Channel tabanlı bir iş kuyruğu ve tüketicisi yaz; bir uç nokta işi kuyruğa atıp hemen 202 Accepted dönsün.
  5. Geri basınç: kanalı 5 kapasiteli ve Wait modunda kur, 50 istek gönder. Uç noktanın yanıt süreleri ne oldu? Modu DropWrite yapıp karşılaştır.
  6. 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.
  7. Zamanlanmış iş: PeriodicTimer ile 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?
  8. Ç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."