Mert Özen Her Satırda Daha İleri
Backend Background Job'lar ve Scheduled Job'lar — #03

Worker Service ve In-Process Job Queue: Channel, PeriodicTimer ve Windows Service/systemd

Mert Özen 8 Kas 2026 10 dk 2 görüntülenme
Worker Service ve In-Process Job Queue: Channel, PeriodicTimer ve Windows Service/systemd

Bounded Channel ile kurulan in-process job queue, dolduğunda TryWrite false döner; endpoint bunu Retry-After başlıklı bir 503'e çevirerek backpressure uygular. Periyodik iş için PeriodicTimer kaçırılan tick'leri biriktirmez, uzun süren bir turdan sonra en fazla bir kez hemen tetiklenir. AddWindowsService ve AddSystemd yalnız ilgili ortamda devreye girer; queue ise restart'ta boşalır.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve Microsoft.Extensions.Hosting 10.0.14 (WindowsServices ve Systemd paketleri dahil) ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un sipariş onay e-postaları artık bir queue'dan gidiyor. Queue'nun kapasitesi 5, e-postayı gönderen worker her birini 1 saniyede bitiriyor. Kampanya başlıyor ve aynı anda 10 sipariş onaylanıyor. 10 isteğin kaçı 202 Accepted alır, kaçı reddedilir? Reddedilenlere hangi status code'u dönmek doğru: 429 mu, 503 mü? Bir de: tam o sırada uygulama yeniden başlatılırsa queue'daki e-postalara ne olur?

Ne Zaman Ayrı Bir Worker Process?

Önceki yazıda BackgroundService'i API'nin (Application Programming Interface) içinde çalıştırdık. Bu çoğu zaman doğru başlangıç: aynı deploy, aynı configuration, aynı DI (Dependency Injection) container. Ama bazı işler API ile aynı process'te durmamalı. CPU (Central Processing Unit) yiyen bir rapor üretimi, API'nin response sürelerini bozuyorsa; arka plan işinin ölçeği API'ninkinden farklıysa (API 6 replica, stok senkronizasyonu tek kopya); ya da işin HTTP (Hypertext Transfer Protocol) ile hiçbir ilgisi yoksa ayrı bir process daha temizdir.

.NET bunun için bir şablon veriyor:

dotnet new worker -n Bookstore.StockSync --framework net10.0
using Bookstore.StockSync;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();

var host = builder.Build();
host.Run();

Fark küçük: WebApplication yerine Host.CreateApplicationBuilder, Kestrel yok, proje SDK'sı (Software Development Kit) Microsoft.NET.Sdk.Worker. Microsoft Learn'deki Worker Services sayfası bir ayrıntıya dikkat çekiyor: worker şablonu server GC'yi (garbage collection) varsayılan olarak açmıyor; uzun süren ve yoğun bellek kullanan bir worker için bunu proje dosyasında kendin değerlendirmelisin.

Bu yazıdaki örneklerin ikisini de göreceğiz: API içinde bir e-posta queue'su ve ayrı bir process olarak çalışan stok senkronizasyonu.

In-Process Job Queue: Bounded Channel

C# Asenkron serisinin "C# Channels ile Producer/Consumer: Backpressure, Bounded Channel ve Pipeline" yazısında Channel'ın mekaniğini işlemiştik. Burada onu bir uygulama service'inin arkasına saklıyoruz, böylece endpoint'ler Channel'ı değil, kendi diline uygun bir interface'i görüyor:

public sealed record EmailJob(int OrderId, string To, string Subject);

public interface IBackgroundTaskQueue
{
    bool TryEnqueue(EmailJob job);
    IAsyncEnumerable<EmailJob> DequeueAllAsync(CancellationToken ct);
    int Count { get; }
}

public sealed class EmailQueue : IBackgroundTaskQueue
{
    private readonly Channel<EmailJob> _channel;

    public EmailQueue(int capacity) =>
        _channel = Channel.CreateBounded<EmailJob>(new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait, // TryWrite dolu queue'da false döner
            SingleReader = true
        });

    public bool TryEnqueue(EmailJob job) => _channel.Writer.TryWrite(job);

    public IAsyncEnumerable<EmailJob> DequeueAllAsync(CancellationToken ct) => _channel.Reader.ReadAllAsync(ct);

    public int Count => _channel.Reader.Count;
}

İki karar var. Birincisi CreateBounded: kapasitesiz bir Channel, worker yetişemediğinde belleği sınırsız büyütür ve sorunu ancak process bellek yetersizliğinden öldüğünde fark edersin. İkincisi FullMode. Wait, dolu queue'da WriteAsync'i bekletir; DropOldest, DropNewest ve DropWrite ise bir işi sessizce atar. E-posta için bir işi sessizce atmak kabul edilemez, bekletmek de request thread'ini kilitler. Bu yüzden Wait modunu seçip WriteAsync yerine TryWrite kullanıyoruz: queue doluysa TryWrite beklemeden false döner ve kararı endpoint verir.

Endpoint ve Backpressure: Dolunca 503

builder.Services.AddSingleton<IBackgroundTaskQueue>(new EmailQueue(capacity: 5));
builder.Services.AddHostedService<EmailWorker>();

var app = builder.Build();

app.MapPost("/orders/{id:int}/confirmation-email", (int id, IBackgroundTaskQueue queue, HttpContext http) =>
{
    var job = new EmailJob(id, $"customer{id}@example.com", $"Order #{id} confirmed");
    if (queue.TryEnqueue(job))
        return Results.Accepted();

    // queue dolu: bekletmek yerine istemciye "sonra tekrar dene" de
    http.Response.Headers.RetryAfter = "10";
    return Results.Problem(statusCode: StatusCodes.Status503ServiceUnavailable,
        title: "Email queue is full");
});

Queue kapasitesini 5 yapıp aynı anda 10 istek attım:

$ seq 1 10 | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:17112/orders/100{}/confirmation-email | sort | uniq -c
   5 202
   5 503

Beş istek kabul edildi, beşi reddedildi. Reddedilen bir isteğin tam yanıtı:

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 01:58:06 GMT
Server: Kestrel
Retry-After: 10
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Email queue is full","status":503}

Neden 503 ve neden 429 değil? 429 Too Many Requests, istemcinin kendisinin sınırı aştığını söyler; bir rate limiter'ın kullanıcı başına kota uyguladığı durumun cevabıdır. Burada sorun tek bir istemci değil, sunucunun o anki kapasitesi; RFC (Request for Comments) 9110'un tanımıyla 503 Service Unavailable "sunucu geçici olarak aşırı yüklü" demek. İkisinde de Retry-After başlığı istemciye ne zaman tekrar deneyeceğini söyler. Body'deki type alanını ASP.NET Core'un ProblemDetails desteği otomatik dolduruyor.

Backpressure'ın asıl kazancı, aşırı yükün nerede görüneceğini senin seçmen. Kapasitesiz bir queue'da kampanya sabahı her şey "başarılı" görünür, e-postalar ise saatlerce gecikir ve process yeniden başlarsa topluca kaybolur. Bounded queue ile yük hemen, ölçülebilir bir 503 oranı olarak görünür ve istemci ya da önündeki bir gateway retry stratejisiyle bunu yönetebilir.

Worker: Queue'yu Tüketmek ve Kapanırken Kalanlar

public sealed class EmailWorker(IBackgroundTaskQueue queue, ILogger<EmailWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            await foreach (EmailJob job in queue.DequeueAllAsync(stoppingToken))
            {
                await Task.Delay(1000, stoppingToken); // SMTP sunucusunu taklit eder
                logger.LogInformation("Sent '{Subject}' for order {OrderId}", job.Subject, job.OrderId);
            }
        }
        catch (OperationCanceledException)
        {
            logger.LogWarning("Stopping with {Count} emails still in the queue", queue.Count);
        }
    }
}

Yukarıdaki yük testinin hemen ardından, iki e-posta gönderilmişken SIGTERM (terminate signal) gönderdim:

04:57:42.499 info: Bookstore.Api.EmailWorker[0] Sent 'Order #1004 confirmed' for order 1004
04:57:43.501 info: Bookstore.Api.EmailWorker[0] Sent 'Order #1001 confirmed' for order 1001
04:57:43.718 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
04:57:43.721 warn: Bookstore.Api.EmailWorker[0] Stopping with 3 emails still in the queue

İki e-posta gitti, üçü queue'da kaldı ve process'le birlikte kayboldu. En azından bunu söyleyen bir log satırı var; önceki yazıdaki Task.Run bunu bile söylemiyordu. Burada Count property'sini kullanabilmek için SingleReader = true seçmek de bilinçli: tek okuyucu varken Channel'ın reader'ı kaç item beklediğini sayabiliyor.

Kapanışta kalan işleri bitirmeye çalışmak mümkün: stoppingToken iptal edilince queue'yu boşaltana kadar, ShutdownTimeout'un elverdiği kadar okumaya devam edebilirsin. Ama bu yalnız deploy'u kurtarır; process çökerse ya da makine kapanırsa queue yine kaybolur. Bu sınırı yazının sonunda konuşacağız.

PeriodicTimer ile Periyodik İş

İkinci örnek ayrı bir worker process: tedarikçiden stok bilgisini periyodik olarak çeken Bookstore.StockSync. C# Asenkron serisinin "C# Timer'lar ve Bekleme: PeriodicTimer, WaitAsync ve Task.Delay" yazısındaki PeriodicTimer'ı bir BackgroundService'e koyuyoruz:

public sealed class StockSyncOptions
{
    public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(5);
}

public sealed class StockSyncWorker(
    ISupplierClient supplier,
    IOptions<StockSyncOptions> options,
    ILogger<StockSyncWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(options.Value.Interval);
        do
        {
            long start = Stopwatch.GetTimestamp();
            try
            {
                int updated = await supplier.SyncStockAsync(stoppingToken);
                logger.LogInformation("Stock sync updated {Count} books in {Elapsed:F0} ms",
                    updated, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // tek bir turun hatası worker'ı (ve host'u) düşürmesin
                logger.LogError(ex, "Stock sync failed, will retry on next tick");
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }
}

do...while bilinçli: ilk tur uygulama açılır açılmaz çalışsın, sonra her tick'te tekrarlansın. Her tur kendi try/catch'i ile korunuyor, çünkü önceki yazıda gördüğümüz gibi yakalanmayan bir exception varsayılan ayarla bütün host'u durdurur. Interval'i configuration'dan alıyoruz; demo için 1 saniye verdim ve sahte tedarikçi client'ının ikinci çağrıyı 2,5 saniyede, dördüncüyü hatayla bitirmesini sağladım:

04:57:02.041 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
04:57:02.347 info: Bookstore.StockSync.StockSyncWorker[0] Stock sync updated 121 books in 303 ms
04:57:05.547 info: Bookstore.StockSync.StockSyncWorker[0] Stock sync updated 122 books in 2502 ms
04:57:05.849 info: Bookstore.StockSync.StockSyncWorker[0] Stock sync updated 123 books in 302 ms
04:57:06.351 fail: Bookstore.StockSync.StockSyncWorker[0] Stock sync failed, will retry on next tick System.Net.Http.HttpRequestException: Supplier API timed out [...]
04:57:07.346 info: Bookstore.StockSync.StockSyncWorker[0] Stock sync updated 125 books in 301 ms
04:57:07.970 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...

Zaman damgalarına dikkat et. Tick'ler 1 saniyelik sabit bir ızgarada: x.047'ler. İkinci tur 2,5 saniye sürdü ve bu sürede iki tick kaçırıldı. Ama tur bittiğinde üçüncü tur hemen başladı (05.547) ve yalnız bir kez; kaçırılan tick'ler birikmedi. Sonraki tur yine ızgaraya döndü (06.047). PeriodicTimer'ın davranışı bu: tek bir tick'i saklar, fazlasını atar. Eski System.Threading.Timer ise önceki çalışmanın bitmesini beklemeden callback'i tekrar çağırır ve turlar üst üste biner; bunun gerçek çıktısını serinin son yazısında göreceğiz. Dördüncü turdaki hata loglandı ve worker bir sonraki tick'te devam etti.

Windows Service, systemd ve Container

Worker'ı bir makinede uzun süre çalıştıracaksan işletim sisteminin service yöneticisine bağlamak gerekir:

using Bookstore.StockSync;

var builder = Host.CreateApplicationBuilder(args);

// Windows'ta Service Control Manager, Linux'ta systemd altında çalışınca devreye girer;
// konsoldan çalıştırınca ikisi de hiçbir şey yapmaz
builder.Services.AddWindowsService(o => o.ServiceName = "Bookstore Stock Sync");
builder.Services.AddSystemd();

builder.Services.Configure<StockSyncOptions>(builder.Configuration.GetSection("StockSync"));
builder.Services.AddSingleton<ISupplierClient, FakeSupplierClient>();
builder.Services.AddHostedService<StockSyncWorker>();

var host = builder.Build();
host.Run();

İki çağrı da bağlam farkında. Package'ların XML (Extensible Markup Language) dokümantasyonuna göre AddWindowsService, process gerçekten bir Windows Service olarak çalışıyorsa host'un lifetime'ını Windows Service Control Manager'a bağlar ve Windows Event Log'a uygulama adıyla log yazmayı açar. AddSystemd, process systemd altında çalışıyorsa systemd'ye "başladım" ve "duruyorum" bildirimlerini gönderir ve console log formatını systemd'nin beklediği biçime çevirir; bildirimlerin çalışması için unit dosyasında Type=notify gerekir. İkisi de başka bir ortamda hiçbir şey yapmaz; yukarıdaki log'lar macOS'ta normal bir console uygulaması olarak çalışan aynı binary'den. Bu yüzden ikisini birden eklemek güvenli.

Bu Windows ve systemd entegrasyonlarını bu yazı için gerçek bir Windows Service ya da systemd unit'i altında çalıştırmadım; davranışları yukarıdaki dokümantasyona dayanıyor. Container'da ise ikisine de gerek yok: container'ın kendisi process yöneticisi. Kural basit: container başına tek process, SIGTERM'ü uygulamanın kendisi alsın, log'lar stdout'a gitsin. Container'ı bir shell script'i ile başlatırsan SIGTERM script'e gider ve .NET process'ine hiç ulaşmayabilir; o durumda graceful shutdown çalışmaz, orchestrator grace period dolunca SIGKILL (kill signal) gönderir.

Sınır: In-Memory Queue Ne Zaman Yetmez?

Bu yazıdaki queue, process'in belleğinde yaşıyor. Bunun üç sonucu var. Restart'ta ve deploy'da boşalır; yukarıda üç e-postayı böyle kaybettik. Ölçeklenmez; API'yi 3 replica'ya çıkardığında her replica'nın kendi queue'su olur ve bir replica'ya yığılan iş diğerlerine dağılmaz. Görünmez; queue'da ne olduğunu, hangi işin neden başarısız olduğunu ancak log'lardan çıkarabilirsin.

Bu sınırlar kabul edilebiliyorsa (kaybolabilen bildirimler, yeniden üretilebilen cache ısıtmaları, analitik event'leri) in-process queue en ucuz ve en hızlı çözüm. Kabul edilemiyorsa işi kalıcı bir yere yazman gerekir: işin kendisi bir job ise Hangfire ya da Quartz.NET'in veritabanı tabanlı store'u (beşinci ve altıncı yazılar), başka servislerin de tepki vereceği bir olay ise bir message broker (ilerideki Message Queue'larla Çalışmak serisi). İşi veritabanı değişikliğiyle aynı transaction'da kaydetmen gerekiyorsa outbox tablosu doğru araç; Messaging ve Event-Driven Architecture serisinin "Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak" yazısı bunun neden gerektiğini anlatıyor.

Üretimde Dikkat

Queue derinliğini metrik olarak yayınla. Count değerini bir gauge'a bağlamak, kampanya sabahı queue'nun dolmaya başladığını 503'ler başlamadan görmeni sağlar. 503 oranı da ayrı bir metrik olmalı; sıfırdan farklı olması kapasitenin yetmediğini söyler.

Kapasiteyi ölçerek seç. Kapasite, kabul edebileceğin en uzun gecikmeyi belirler: worker saniyede 10 e-posta gönderiyorsa 600'lük bir queue, en kötü durumda bir dakikalık gecikme ve restart'ta kaybolabilecek 600 iş demek. Kapasiteyi büyütmek 503'leri azaltır ama bu iki sayıyı büyütür.

Worker'ın paralelliğini bilinçli seç. Tek okuyuculu bir worker işleri sırayla yapar; dış servis yavaşsa birden fazla okuyucu (ya da Parallel.ForEachAsync) gerekebilir, ama o zaman dış servisin rate limit'ine dikkat etmelisin.

Sık Düşülen Tuzaklar

Channel.CreateUnbounded ile başlamak. Testte hiçbir şey olmaz; production'da worker yavaşladığı ilk gün bellek büyür ve sorun ancak process öldüğünde görünür.

Dolu queue'da request'i bekletmek. WriteAsync'i dolu bir queue'da beklemek, istemciyi ve Kestrel'in bağlantılarını da bekletir; backpressure'ı istemciye yansıtmak yerine sunucunun içine gömmüş olursun.

Queue'ya scoped nesne koymak. Queue'ya DbContext ya da entity değil, id gibi basit değerler koy; worker kendi scope'unda gerekeni yeniden yüklesin.

Container'ı shell script'iyle başlatıp SIGTERM'ü kaybetmek. ENTRYPOINT'i exec formunda yaz ya da script'in sonunda exec dotnet ... kullan.

Küçük Bir Deneme

★☆☆ Queue kapasitesini 5'ten 50'ye çıkar ve 100 eşzamanlı istek at. 202 ve 503 sayıları nasıl değişiyor, son e-posta kaç saniye sonra gidiyor?

★★☆ EmailWorker'ı kapanırken queue'yu boşaltmaya çalışacak şekilde değiştir: stoppingToken iptal edilince TryRead ile kalanları göndermeye devam et, ama ShutdownTimeout'tan önce bitir. Kaç e-posta kurtarabiliyorsun?

★★★ FakeSupplierClient'ın her çağrısını 1,5 saniye yap ve interval'i 1 saniyede bırak. Log'daki tur başlangıç zamanlarını incele: PeriodicTimer burada ne yapıyor, tur sayısı dakikada kaç oluyor?

Bir sonraki yazıda "her 5 dakikada bir"den daha karmaşık zamanlamalara geçiyoruz: Unix'in 5 alanlı cron'u, saniyeli 6 alanlı format, Quartz'ın ? L W # karakterleri, time zone seçimi ve DST (Daylight Saving Time) geçişlerinde atlanan ya da tekrarlanan çalışmalar.