Mert Özen Her Satırda Daha İleri
Backend C# Asenkron Programlama — #15

C# Timer'lar ve Bekleme: PeriodicTimer, WaitAsync ve Task.Delay

Mert Özen 7 Kas 2026 10 dk 1 görüntülenme
C# Timer'lar ve Bekleme: PeriodicTimer, WaitAsync ve Task.Delay

System.Threading.Timer'da üst üste binen callback'ler, .NET 6 PeriodicTimer ile çakışmasız periyodik iş, Thread.Sleep ile Task.Delay'in thread pool'a etkisi ve Task.Delay'in hassasiyeti, exception'ları yutan System.Timers.Timer, işlemi iptal etmeyen WaitAsync, backoff ve jitter ile retry, GC'nin topladığı timer.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Depodaki stokları her 100 ms'de bir senkronize eden bir timer kurduk. Senkronizasyon 250 ms sürüyor. Bir saniye boyunca çalıştırıyoruz. Aynı anda en fazla kaç senkronizasyon çalışır?

object gate = new();
int running = 0, peak = 0, calls = 0;

using (var timer = new Timer(_ =>
{
    lock (gate)
    {
        running++;
        peak = Math.Max(peak, running);
        calls++;
    }
    Thread.Sleep(250); // depoyla stok senkronizasyonu
    lock (gate)
        running--;
}, null, dueTime: 0, period: 100))
{
    await Task.Delay(1000);
}

await Task.Delay(300);
Console.WriteLine($"Callbacks: {calls}, running at the same time (peak): {peak}");

System.Threading.Timer: Üst Üste Binen Callback'ler

Çıktı:

Callbacks: 11, running at the same time (peak): 3

Aynı anda üç senkronizasyon. System.Threading.Timer her periyotta callback'i bir thread pool thread'inde başlatır ve öncekinin bitip bitmediğine hiç bakmaz. İş periyottan uzun sürerse çalışmalar üst üste biner: 250 ms'lik iş, 100 ms'de bir başlatılınca her an iki ya da üç kopyası çalışıyor. Stok senkronizasyonu gibi aynı veriye yazan bir işte bu, önceki yazılarda gördüğümüz race condition'ların ta kendisi. Bir saniyede 11 callback çalışmasının sebebi de dueTime: 0: ilki hemen, sonrakiler 100 ms arayla.

Klasik çözüm timer'ı tek atımlık kullanmaktı: period olarak Timeout.Infinite ver, callback'in sonunda timer.Change(100, Timeout.Infinite) ile bir sonrakini kur. Çalışır ama callback senkron kalır; içinde await kullanmak istersen async void'e düşersin. Bir de unutulan bir ayrıntı: callback'teki yakalanmamış bir exception thread pool thread'inde patlar ve process'i çökertir.

PeriodicTimer (.NET 6)

.NET 6 ile gelen PeriodicTimer farklı bir model sunar: timer callback çağırmaz, sen onu await edersin.

using System.Diagnostics;

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
var stopwatch = Stopwatch.StartNew();
int syncs = 0;

try
{
    while (await timer.WaitForNextTickAsync(cts.Token))
    {
        syncs++;
        Console.WriteLine($"Sync {syncs} starts at {stopwatch.ElapsedMilliseconds / 10 * 10} ms");
        await Task.Delay(250, cts.Token); // depoyla stok senkronizasyonu
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine($"Stopped after {syncs} syncs, never overlapping");
}
Sync 1 starts at 100 ms
Sync 2 starts at 350 ms
Sync 3 starts at 600 ms
Sync 4 starts at 850 ms
Stopped after 4 syncs, never overlapping

WaitForNextTickAsync, bir sonraki tik gelene kadar bekleyen bir ValueTask<bool> döndürür. İş döngünün içinde, senin kodunda yapıldığı için iki senkronizasyon asla üst üste binmez. 250 ms'lik iş sürerken kaçan tikler birikmez, tek bir tike indirgenir: iş bitince bir sonraki WaitForNextTickAsync hemen döner ve döngü 250 ms'de bir dönmeye başlar. Çıktıdaki 100, 350, 600, 850 dizisi bu. İptal için token'ı hem WaitForNextTickAsync'e hem de içerideki işe ver; timer'ı Dispose edersen bekleyen ya da sonraki çağrılar false döner ve döngü sessizce biter.

Arka planda periyodik iş yapan servisler için bugün tercih edilen pattern bu: bir BackgroundService'in ExecuteAsync'inde while (await timer.WaitForNextTickAsync(stoppingToken)). Callback yok, async void yok, üst üste binme yok, durdurma token'la.

Task.Delay ve Thread.Sleep

İkisi de "bir süre bekle" der ama bambaşka şeyler yapar. Thread.Sleep o thread'i süre boyunca bloklar; thread hiçbir işe yaramadan bekler. Task.Delay ise bir timer kurar ve süre dolunca tamamlanan bir task döndürür; await eden kodun thread'i bu arada serbesttir. Farkı thread pool'da görmek kolay:

using System.Diagnostics;

Console.WriteLine($"ThreadPool threads at start: {ThreadPool.ThreadCount}");

var stopwatch = Stopwatch.StartNew();
await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => Task.Run(() => Thread.Sleep(1000))));
Console.WriteLine($"100 x Thread.Sleep(1000):  {stopwatch.ElapsedMilliseconds / 100 * 100} ms, pool threads now {ThreadPool.ThreadCount}");

stopwatch.Restart();
await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => Task.Run(async () => await Task.Delay(1000))));
Console.WriteLine($"100 x await Task.Delay(1000): {stopwatch.ElapsedMilliseconds / 100 * 100} ms");
ThreadPool threads at start: 0
100 x Thread.Sleep(1000):  9500 ms, pool threads now 12
100 x await Task.Delay(1000): 1000 ms

Yüz tane bir saniyelik bekleme, Thread.Sleep ile 9,5 saniye sürdü. Thread pool çekirdek sayısının biraz üstüne çıktı ve işler aşağı yukarı onar onar sırayla bekledi; her uyuyan thread bir başkasının önünü tıkadı. Task.Delay ile aynı yüz bekleme bir saniyede bitti, çünkü hiçbiri thread tutmadı. Bir web sunucusunda bu fark, aynı makinenin kaç isteğe aynı anda cevap verebildiği demek. Async kodda Thread.Sleep'in yeri yok.

Task.Delay'in hassasiyeti işletim sisteminin saatine bağlıdır. Dokümantasyon, istenen süre sistem saatinin çözünürlüğünden kısaysa beklemenin o çözünürlük kadar süreceğini ve bunun Windows'ta yaklaşık 15 ms olduğunu söyler. Bu macOS makinede ölçtüğüm:

using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();
for (int i = 0; i < 100; i++)
    await Task.Delay(1);
Console.WriteLine($"100 x Task.Delay(1): {stopwatch.ElapsedMilliseconds} ms on {Environment.OSVersion.Platform}");
100 x Task.Delay(1): 125 ms on Unix

Yani Task.Delay(1) burada 1,25 ms civarında, Windows'ta 15 ms civarında sürer. Milisaniye hassasiyetinde zamanlama gerektiren bir iş için Task.Delay doğru araç değil; ama "en az şu kadar bekle" için mükemmel.

System.Timers.Timer ve Diğerleri

.NET'te adı Timer olan birkaç tip var ve karıştırılmaları çok olağan. System.Threading.Timer'ı ve PeriodicTimer'ı gördük. Üçüncüsü System.Timers.Timer: Interval, AutoReset property'leri ve bir Elapsed event'iyle daha "component" tarzı bir API. Elapsed de thread pool'da çalışır, yani üst üste binme sorunu burada da var. Ama asıl sürprizi exception'larda:

int fired = 0;

using var timer = new System.Timers.Timer(100) { AutoReset = true };
timer.Elapsed += (sender, e) =>
{
    int count = Interlocked.Increment(ref fired);
    if (count == 2)
        throw new InvalidOperationException("Warehouse API returned 500");
};
timer.Start();

await Task.Delay(550);
timer.Stop();
Console.WriteLine($"Elapsed fired {fired} times; the exception on the 2nd one vanished");
Elapsed fired 5 times; the exception on the 2nd one vanished

İkinci çalışma exception attı; ne process çöktü ne de bir log düştü, timer yoluna devam etti. Dokümantasyon bunu açıkça yazar: System.Timers.Timer, Elapsed handler'larından çıkan bütün exception'ları yakalayıp bastırır. System.Threading.Timer'da aynı exception process'i çökertirdi. İki uç da kötü; bu yüzden hangisini kullanırsan kullan, callback'in tamamını kendi try/catch'inle sar ve hatayı logla. WinForms'taki System.Windows.Forms.Timer ve WPF'teki DispatcherTimer ise UI thread'inde çalışır; UI'ı güncellemek için yapılmışlardır, sunucu kodunda yerleri yok.

WaitAsync ile Timeout (.NET 6)

Bir task'a "en fazla şu kadar beklerim" demenin .NET 6 öncesindeki yolu Task.WhenAny(task, Task.Delay(timeout)) ile hangisinin önce bittiğine bakmaktı. .NET 6, Task ve Task<T>'ye bunu tek çağrıda yapan WaitAsync'i ekledi. Ama yaptığı şeyi tam olarak bilmek gerekiyor:

using System.Diagnostics;

var stopwatch = Stopwatch.StartNew();

// 1) WaitAsync: beklemeyi bırak, ama işlem çalışmaya devam ediyor
Task<decimal> slowPrice = FetchPriceAsync("978-0132350884", CancellationToken.None);
try
{
    decimal price = await slowPrice.WaitAsync(TimeSpan.FromMilliseconds(300));
}
catch (TimeoutException)
{
    Console.WriteLine($"WaitAsync: gave up at ~{Round(stopwatch)} ms, task status: {slowPrice.Status}");
}
await slowPrice;
Console.WriteLine($"WaitAsync: the fetch still finished at ~{Round(stopwatch)} ms");

// 2) CancelAfter: işlemin kendisi iptal ediliyor
stopwatch.Restart();
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(300));
Task<decimal> canceledPrice = FetchPriceAsync("978-0132350884", cts.Token);
try
{
    await canceledPrice;
}
catch (OperationCanceledException)
{
    Console.WriteLine($"CancelAfter: canceled at ~{Round(stopwatch)} ms, task status: {canceledPrice.Status}");
}

static async Task<decimal> FetchPriceAsync(string isbn, CancellationToken cancellationToken)
{
    await Task.Delay(1000, cancellationToken); // yavaş bir tedarikçi
    return 37.90m;
}

static long Round(Stopwatch stopwatch) => stopwatch.ElapsedMilliseconds / 100 * 100;
WaitAsync: gave up at ~300 ms, task status: WaitingForActivation
WaitAsync: the fetch still finished at ~1000 ms
CancelAfter: canceled at ~300 ms, task status: Canceled

WaitAsync 300 ms'de TimeoutException attı, ama task'ın durumuna bak: hâlâ çalışıyor ve bir saniyede bitiyor. WaitAsync işlemi iptal etmez; sadece senin onu beklemeni bırakır. HTTP isteği gitmeye, veritabanı sorgusu çalışmaya devam eder. İşlemin kendisini durdurmak istiyorsan serinin CancellationToken yazısında gördüğümüz gibi token'ı işleme ver ve CancelAfter kullan; ikinci bölümde task Canceled durumuna geçti. WaitAsync, iptal token'ı almayan bir API'yi beklerken ya da sonucu artık umursamadığın bir işten kurtulman gerektiğinde işe yarar. WaitAsync(cancellationToken) overload'u da aynı mantıkla çalışır: token iptal edilince bekleme OperationCanceledException ile biter, işlem sürer.

Retry ve Backoff

Task.Delay'in en sık kullanıldığı gerçek yer, geçici hatalarda yeniden denemek. Tedarikçi API'si 503 dönüyorsa hemen tekrar denemek onu daha da boğar. Her denemede bekleme süresini ikiye katlamak (exponential backoff) ve üstüne biraz rastgelelik eklemek (jitter) standart çözüm:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var random = new Random(42);
int calls = 0;

decimal price = await RetryAsync(async ct =>
{
    calls++;
    await Task.Delay(10, ct);
    if (calls < 4)
        throw new HttpRequestException($"503 Service Unavailable (call {calls})");
    return 37.90m;
}, maxAttempts: 5, CancellationToken.None);

Console.WriteLine($"Price {price} after {calls} calls");

async Task<T> RetryAsync<T>(Func<CancellationToken, Task<T>> operation, int maxAttempts, CancellationToken cancellationToken)
{
    for (int attempt = 1; ; attempt++)
    {
        try
        {
            return await operation(cancellationToken);
        }
        catch (HttpRequestException ex) when (attempt < maxAttempts)
        {
            // 100, 200, 400 ms ... üstüne en fazla 100 ms jitter
            TimeSpan delay = TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt - 1) + random.Next(0, 100));
            Console.WriteLine($"{ex.Message}, retrying in {delay.TotalMilliseconds} ms");
            await Task.Delay(delay, cancellationToken);
        }
    }
}
503 Service Unavailable (call 1), retrying in 166 ms
503 Service Unavailable (call 2), retrying in 214 ms
503 Service Unavailable (call 3), retrying in 412 ms
Price 37.90 after 4 calls

Jitter'ın sebebi şu: aynı anda hata alan yüz istemci aynı formülle bekleseydi, hepsi tam aynı anda tekrar deneyip servisi bir kez daha yere sererdi. Rastgelelik denemeleri zamana yayar; çıktıdaki tekrarlanabilir sayılar, örnek için Random'a sabit seed verdiğimizden. when (attempt < maxAttempts) filtresi son denemedeki exception'ın olduğu gibi yukarı çıkmasını sağlıyor. Task.Delay'e token vermeyi unutma; yoksa uygulama kapanırken bile bekleme sürer. Gerçek projelerde bunu elle yazmak yerine Polly ya da Microsoft.Extensions.Http.Resilience gibi bir library kullanırsın, ama altında yatan bu.

Sık Düşülen Tuzaklar

Timer'a referans tutmamak. System.Threading.Timer bir değişkende tutulmuyorsa garbage collector onu toplayabilir ve timer sessizce durur:

int ticks = 0;
StartHeartbeat();

await Task.Delay(350);
Console.WriteLine($"Ticks before GC: {ticks}");

GC.Collect();
GC.WaitForPendingFinalizers();
int afterCollect = ticks;

await Task.Delay(350);
Console.WriteLine($"Ticks in the 350 ms after GC: {ticks - afterCollect}");

void StartHeartbeat()
{
    // bu timer'a kimse referans tutmuyor
    _ = new Timer(_ => Interlocked.Increment(ref ticks), null, 0, 100);
}
Ticks before GC: 4
Ticks in the 350 ms after GC: 0

GC'den önce dört tik, sonra hiç. Gerçek uygulamada GC.Collect çağırmazsın; bu yüzden hata, GC'nin ne zaman çalıştığına göre bazen saatler sonra ortaya çıkar. Timer'ı bir field'da tut ve işin bitince Dispose et.

PeriodicTimer'ı birden çok yerden beklemek. Tek bir consumer için tasarlanmıştır; önceki bekleme bitmeden ikinci bir WaitForNextTickAsync çağrısı exception atar:

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));

ValueTask<bool> first = timer.WaitForNextTickAsync();
try
{
    ValueTask<bool> second = timer.WaitForNextTickAsync();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine($"First tick: {await first}");

timer.Dispose();
Console.WriteLine($"After Dispose: {await timer.WaitForNextTickAsync()}");
InvalidOperationException: Operation is not valid due to the current state of the object.
First tick: True
After Dispose: False

Bir tiki birden çok işe dağıtman gerekiyorsa tek döngü kur ve o döngüden işleri başlat.

Timer callback'ine async lambda vermek. new Timer(async _ => await SyncAsync(), ...) derlenir, çünkü lambda async void olur. Exception'lar yakalanamaz, üst üste binme de cabası. Async periyodik iş için PeriodicTimer kullan.

Süre ölçmek için DateTime.Now kullanmak. Sistem saati yaz saati geçişinde ya da NTP düzeltmesinde ileri geri gidebilir. Geçen süre için Stopwatch kullan; bu yazıdaki bütün ölçümler öyle yapıldı.

WaitAsync'in işlemi iptal ettiğini sanmak. Etmez; yukarıdaki çıktıda gördük. Timeout'tan sonra arkada çalışmaya devam eden işler, connection pool'u ve thread pool'u sessizce meşgul eder.

Küçük Bir Deneme

★☆☆ Tahmin sorusundaki timer'ı tek atımlık hâle getir: period yerine Timeout.Infinite ver ve callback'in sonunda Change ile bir sonrakini kur. Peak değeri 1'e iniyor mu? Bir saniyede kaç callback çalışıyor?

★★☆ RetryAsync'e toplam bir süre sınırı ekle: denemeler ve beklemeler birlikte 2 saniyeyi geçerse vazgeçsin. Bunu CancelAfter ile mi, WaitAsync ile mi yaparsın? İkisinin farkı sonucu nasıl değiştirir?

★★★ PeriodicTimer ile her 5 saniyede bir çalışan bir "stok uyarısı" döngüsü yaz. Ctrl+C'ye basıldığında (Console.CancelKeyPress) döngü o anki işi bitirip temiz biçimde dursun. Bir de testini yazmaya çalış: 5 saniyeyi beklemeden nasıl test edersin?

Son alıştırmadaki soru, bu yazıdaki bütün örneklerin ortak derdini gösteriyor: zamanla ilgili kodu test etmek için gerçekten beklemek zorundaydık. Bu yazıyla C# 10 ve .NET 6 üzerine kurulu kemik yazılar bitti. Bundan sonraki iki yazı sürüm yazısı. İlkinde .NET 8'e geçiyoruz: zamanı soyutlayıp testte ileri saran TimeProvider, ConfigureAwaitOptions, Parallel.ForAsync ve CancelAsync. Her başlıkta önce .NET 6'da nasıl yazdığımızı, sonra .NET 8'in neyi değiştirdiğini göreceğiz.