← Back to list

.NET’te Threading ve Asynchrony Gerçekleri : “Şehir Efsanelerinden Thread-less I/O’ya”

Bazı insanlar sizin tepkilerinizden beslenir. Onları aç bırakın!

Ömür Uçum · 2026-05-10 15:27 · 6 claps · 6.0 min read
#dotnet #async #sync #thread-pool #performance
Open on Medium ↗
Wiki topics: 📚 · Books & Reading

.NET’te Threading ve Asynchrony Gerçekleri : “Şehir Efsanelerinden Thread-less I/O’ya”

Bazı insanlar sizin tepkilerinizden beslenir. Onları aç bırakın!

Yakın zamanda çalışma arkadaşlarımla yaptığımız bir istişare sonrasında, bu konunun detaylı bir şekilde ele alınması ve benchmark testleriyle desteklenmesi gerektiği kanaatine vardım. Her şeyi tek bir makalede toplamak hem dikkati dağıtacaktı hem de konunun hakkını veremeyecekti; bu yüzden seriyi üç ayrı başlığa böldüm: önce kavramsal temel, ardından ThreadPool’un içindeki tuzaklar, son olarak da gerçek ölçümlerle ispatladığımız production pattern’leri.

Niyetim “şunu kullanın, bunu kullanmayın” demekten çok, neden sorusunun cevabını paylaşabilmek. Çünkü gerçek mühendislik, doğru aracı seçmekten önce, o aracın nerede ve neden işe yaradığını anlamaktan geçiyor.

Serinin başlıkları:

  • Şehir Efsanelerinden Thread-less I/O’ya
  • ThreadPool’un Anatomisi ve Starvation Tuzakları (Anatomi” kelimesini bana sevdiren bir tartışmanın yadigârı.Ömer Erçelik, bu bölüm biraz da senin.)
  • Benchmark, Gözlemlenebilirlik ve Production Pattern’leri

Şimdi ilk bölüme geçelim ve en baştan başlayalım. Çünkü bir konuyu gerçekten anlamak için, önce o konu hakkında yanlış bildiklerimizi tanımamız gerekir

Şehir Efsaneleri vs. Gerçekler

Mesleğe yeni başlamış ya da sync — async ve thread konularına ilk kez girecek olan meslektaşlarımızın çoğunlukla duyduğu ilk cümleler;

  • “async koy ki paralel çalışsın”
  • “Task.Run ile thread açıyorum”
  • “await thread’i bekletir hemen Task.Run kullan”

Peki ama gerçekler nelerdir? Anlık kazanım sağladıkları yerler var mıdır? Yoksa hepsi ilerde devasa teknik borçlar oluşturacak şehir efsanelerimidir? Şimdi bunlar üzerinden değerlendirmelere başlayalım.

Varan 1: async yeni bir thread açmaz

async bir compiler aracıdır ve tek yaptığı metodu bir state machine dönüştürmek. Bununla ilgili Stephen Toub’un yazdığı makaleye göz atmanızı tavsiye ederim. Aşağıdaki demo da bunu ispat etmeye çalıştım.

class Program
{
    private static readonly HttpClient _httpClient = new HttpClient();

    static async Task Main()
    {
        Console.WriteLine("-----------------");
        Console.WriteLine("Async Thread Demo");
        Console.WriteLine("-----------------");

        await ThreadChanges();
        await NoThreadChange();
        await ReturnsToSameThread();

        Console.WriteLine("-----------------");
        Console.WriteLine("Demo Tamamlandı");
        Console.WriteLine("-----------------");
    }

    // 1. Senaryo: await sonrası ThreadPool'dan farklı thread alması bekleniyor
    static async Task ThreadChanges()
    {
        Console.WriteLine($"SyncContext.Current: {SynchronizationContext.Current?.ToString() ?? "Null"}\n");
        await FetchDataAsync();
        Console.WriteLine();
    }

    public static async Task<String> FetchDataAsync()
    {
        Console.WriteLine($" Await Öncesi Thread: {Environment.CurrentManagedThreadId, 3} " + $"(IsThreadPool: {Thread.CurrentThread.IsThreadPoolThread})");   
        var data = await _httpClient.GetStringAsync("https://www.httpbin.org/uuid");
        Console.WriteLine($" Await Sonrası Thread: {Environment.CurrentManagedThreadId, 3} " + $"(IsThreadPool: {Thread.CurrentThread.IsThreadPoolThread})");   
        return data;
    }

    // 2. Senaryo: await task tamamlanmış thread'in değişmedi beklenir
    static async Task NoThreadChange()
    {
        Console.WriteLine("----Tamamlanmış Task----");
        Console.WriteLine($" 2. Senaryo Thread: {Environment.CurrentManagedThreadId,3}");

        var data = await Task.FromResult("instant");

        Console.WriteLine($" Await sonrası Thread: {Environment.CurrentManagedThreadId,3}  (aynı kalmalı)\n");
    }

    // 3. Senaryo: await sonrası tekrar context'in threadine dönmesini bekliyoruz
    // Amaç WPF/WinForm UI thread davranışını taklit etmek. Tartışma konusuna sebep olan kısım
    static async Task ReturnsToSameThread()
    {
        Console.WriteLine("---WPF/WinForm UI Taklidi---");
        using var ctx = new SingleThreadSyncContext();
        var prevContext = SynchronizationContext.Current;
        try
        {
            await ctx.Run(async () =>
            {
                Console.WriteLine($" Başlangıç Thread: {Environment.CurrentManagedThreadId,3}  (SyncContext: {SynchronizationContext.Current?.GetType().Name})");

                await Task.Delay(100);

                Console.WriteLine($" Await sonrası Thread: {Environment.CurrentManagedThreadId,3}  (aynı thread'e dönmeli)");

                await Task.Delay(100).ConfigureAwait(false);

                Console.WriteLine($" Thread: {Environment.CurrentManagedThreadId,3}  (ThreadPool'da)");
            });
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(prevContext);
        }

        Console.WriteLine();
    }
}

Yukarıda ki demoya buradan erişebilirsiniz. Basit bir console uygulaması ile sonuçları görmek güzel olacaktır.

Varan 2: Task bir thread değildir

Task bir vaattir (promise). Gelecekte tamamlanacak bir iş için referans diyebiliriz. Peki thread mi kullanır? Duruma göre değişir. Kesin mi hayır.

// Bu Task, HİÇBİR thread kullanmıyor.
// Sadece "tamamlandı" olarak işaretli bir state machine.
Task<int> instantTask = Task.FromResult(42);

// Bu Task da thread kullanmıyor — bir timer üzerinde çalışıyor.
Task delayTask = Task.Delay(1000);

// Bu Task ise ThreadPool'dan bir worker alıyor.
Task<int> workerTask = Task.Run(() => CalculatePi(1_000_000));

3 task da aynı tipte ama altlarında bambaşka işler var. TASK ≠ THREAD

Synchronous Programming: Gönüllü Bloklanma

Async anlamadan önce sync’in maliyetini anlamak ve tercihi netleştirmek için önemlidir.

public string DownloadReport()
{
    using var client = new WebClient();
    // Bu satırda thread "Waiting" durumuna düşer.
    string content = client.DownloadString("https://api.example.com/report");
    return content;
}

Yukarıda ki klasik bir senaryoya örnek olabilir. Peki Network I/O sırasında thread neleri yapıyor:

  1. Kernel’e syscall yapar — “bu socket’ten veri bekle”
  2. OS scheduler thread’i Waiting state’ine alır — CPU’dan çıkarılır
  3. Veri geldiğinde scheduler thread’i tekrar Ready state’ine alır
  4. CPU bir context switch yapar ve thread devam eder

Aşağıda kendi makinemde yapmış olduğum örneğin çıktısını bulabilirsiniz. Örnek çıktının reposuna ise buradan ulaşarak kendi makinenizde sonuçları görebilirsiniz.

Test ortamı: Bu ölçümler kişisel iş makinemde (macOS, Apple Silicon, 10 core, .NET 8) yapıldı. Donanım ve işletim sistemi değiştikçe rakamlar da değişir; örneğin Linux veya Windows üzerinde context switch süresi ve thread başına bellek farklı çıkar.

Concurrency 1000'de süre farkı (181ms vs 102ms ≈ 1.8x). Aynı testi 5000 veya 10000 eşzamanlı iş ile çalıştırırsanız sync versiyon dramatik şekilde çuvallar; async ise hemen hemen aynı sürelerde tamamlanır. Ölçeklenebilirlik avantajının asıl belirginleştiği yer de burasıdır.

Asynchronous Programming: Thread-less I/

Şimdi sihrin gerçek kısmı. Bekleme süresinde thread’i bloklamak zorunda olduğumuzu kim söyledi? Aslında işletim sistemi bize çok daha akıllı bir yol sunuyor. Bunu gerçek hayattan bir örnek ile anlatmaya çalışayım.

Klasik bir restoranda 2 farklı servis yönteminin olduğunu düşünelim. Bunlardan birine sync sipariş diğerine de async sipariş diyeceğiz.

Sync Sipariş: Garson siparişini alıyor, mutfağa götürüyor, mutfağın önünde dikilip yemeği bekliyor, sonra getiriyor. Mutfakta yemek 20 dakika hazırlanırken garson o 20 dakika boyunca hiçbir şey yapmıyor sadece bekliyor. Restoranda 50 müşteri varsa 50 garson lazım.

Async Sipariş: Garson siparişini alıyor, mutfağa fişi bırakıyor, başka masalara dönüyor. Yemek hazır olduğunda mutfaktan zil çalıyor, müsait olan herhangi bir garson gelip yemeği alıyor ve masaya getiriyor. 50 müşteriyi 5–6 garson rahatlıkla yönetebilir.

İkinci sipariş yönteminde garson sayısı azalmadı ama her garson çok daha verimli kullanıldı. İşte async I/O da tam olarak bu mantığı kullanır. Peki .NET de ne oluyor. Bir HTTP isteği yaptığımız da süreç şu şekilde ilerliyor;

Async I/O akış şeması

Async I/O akış şeması

Buradaki kilit nokta 2–4 arasında. Bu aralıkta ki süre de isteğe özel ayrılmış bir thread yok. Veri yolda iken thread başka işler yapabiliyor. Bunun işletim sistemlerin de farklı farklı isimleri vardır. Windows’ta **IOCP, Linux’ta [epoll](https://www.man7.org/linux/man-pages/man7/epoll.7.html), MacOs’ta [kqueue](https://en.wikipedia.org/wiki/Kqueue)**

Peki “Thread’siz Bekleme” Ne Demek?

Bu yazıya başlarken belirttiğimiz şehir efsaneleri yavaş yavaş netleşiyor. Bir async metod await üzerinden beklerken sırf bunun için ayrılmış bir thread yoktur. Peki ama birinin beklemesi gerekiyor diye sorabiliriz. İşte o bekleyişi işletim sistemi yapıyor. OS hiç bir thread harcamadan binlerce bağlantıyı bekleyebilir. Çünkü OS için bunlar bir kayıttan ibarettir. İş olarak görmez. Kısacası;

Sync Model: N istek bekleniyor -> N thread “Waiting” state’inde hepsi kaynak harcıyor

Async Model: N istek bekleniyor -> O (sıfır) thread bekliyor. Sadece tamamlananlar işleniyor.

10.000 eşzamanlı bağlantı sync modelde çökebilirken async model de rahatlıklar çalışır. Sebep gayet net; bekleme aşaması thread tüketmez

Async vs. Multithreading: Zihinsel Ayrım

Amaçları çok zıt olan bu kavram da sık sık karıştırılır. Async verimlilik için kullanılırken ( aynı kaynakla daha fazla iş yapmak diyelim ) Multithreading ise aynı işi paralel yaparak daha erken bitirmek için kullanılır.

// ASYNC — I/O için
public async Task<List<User>> GetUsersAsync(IEnumerable<int> ids)
{
    var tasks = ids.Select(id => _repo.GetByIdAsync(id));
    return (await Task.WhenAll(tasks)).ToList();
    // 100 tane DB sorgusu, 1 thread ile başlatılır.
    // Hepsi aynı anda DB'de işlenir, sonuçlar geldikçe toplanır.
}

// MULTITHREADING
public double[] ProcessPixels(byte[] image)
{
    var result = new double[image.Length];
    Parallel.For(0, image.Length, i =>
    {
        result[i] = ComplexFilter(image[i]);
    });
    return result;
}

DB işlemlerini Parallel.For içine alıp deneme tercihini sizlere bırakıyorum ama tavsiye etmiyorum.

Bu iki kavramı karıştırmak yaygın bir hatadır ve sonucu da yaygındır. Anti pattern’lerle nasıl kullanılır onları da anlatmaya çalışacağım.

Yaygın Anti-Pattern’lere Önizleme

// Kötü 
public IActionResult GetUser(int id)
{
    var user = _repo.GetByIdAsync(id).Result;
    return Ok(user);
}

// Teknik borç yaratıp sorunları halının altına süpürmek :)
public async Task<IActionResult> GetUser(int id)
{
    var user = await Task.Run(() => _repo.GetById(id)); 
    return Ok(user);
}

// DOĞRU
public async Task<IActionResult> GetUser(int id)
{
    var user = await _repo.GetByIdAsync(id);
    return Ok(user);
}

Not: Yukarıdaki örneklerde _repo her sorgu için ayrı bir DbContext instance alıyor varsayımındayız. EF Core’da paylaşılan bir DbContext üzerinde Task.WhenAll ile paralel sorgu çalıştırmak InvalidOperationException üretir (DbContext thread-safe değildir). Pratik çözüm: IDbContextFactory<TContext> kullanmak ve her task’ın kendi context’ini almasını sağlamak. Katkılarından ötürü Dogukan’a teşekkür ederim

Üstteki iki örneğin neden ThreadPool starvation’a yol açtığını ve .Result kullanan bir API'nin neden CPU %5'te bile request reddetmeye başlayabileceğini Google Cloud tarafında denediğim çıktıları paylaşırken bir daha anlatmaya çalışacağım. GCP ortamında koştuğum örnek uygulamanın load testleri ve metrikleri k6 ve locust kullanarak yapılmıştır.

Başlangıçta da belirttiğim üzere bu konuyu direk tek bir makalede toplamak ve yazmak hem süreci uzatacağından hem de anlaşılır olmasına engel olacağından 3 farklı parta böldüm. Bu partı artık kapatıyorum. Umarım anlaşılır olmuştur.

Bu partın özeti;

async yeni thread açar => Hayır async derleyici dönüşümüdür, thread açmaz

Task bir thread’dir => Hayır Task bir promise’dir, thread olabilir veya olmayabilir

Async = paralel => Async = verimlilik; paralellik = multithreading

I/O için Task.Run kullan => I/O için native async API kullan

async kod daha hızlıdır => async kod daha ölçeklenebilirdir, latency’yi azaltmaz

Kaynak, Kod örnekleri ve Kullanılan

  1. https://k6.io/
  2. https://docs.locust.io/en/stable/
  3. https://devblogs.microsoft.com/dotnet/author/toub/
  4. https://devblogs.microsoft.com/dotnet/how-async-await-really-works/
  5. https://github.com/omrcm/article/tree/main/src/thread-demo
  6. https://github.com/omrcm/article/tree/main/src/sync-cost-bench
  7. https://learn.microsoft.com/en-us/windows/win32/fileio/i-o-completion-ports
  8. https://www.man7.org/linux/man-pages/man7/epoll.7.html
  9. https://en.wikipedia.org/wiki/Kqueue

메타데이터
post_id
a2eabf124d26
slug
nette-threading-ve-asynchrony-gerçekleri-şehir-efsanelerinden-thread-less-i-o-ya-a2eabf124d26
url
https://medium.com/@omurucum/nette-threading-ve-asynchrony-ger%C3%A7ekleri-%C5%9Fehir-efsanelerinden-thread-less-i-o-ya-a2eabf124d26
canonical_url
https://medium.com/@omurucum/nette-threading-ve-asynchrony-ger%C3%A7ekleri-%C5%9Fehir-efsanelerinden-thread-less-i-o-ya-a2eabf124d26
author_url
https://medium.com/@omurucum
status
ok
fetched_at
2026-06-09 15:37:30