Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri İşletim Sistemi Temelleri — #03

Thread Nedir? Kernel Thread, Stack, Thread-Local Storage ve .NET Thread'inin OS Karşılığı

Mert Özen 14 Kas 2026 11 dk 1 görüntülenme
Thread Nedir? Kernel Thread, Stack, Thread-Local Storage ve .NET Thread'inin OS Karşılığı

Thread, bir process'in address space'ini paylaşan ama kendi stack'i, register'ları ve thread-local storage'ı olan, scheduler'ın CPU'ya koyduğu birimdir; .NET'te her managed thread bire bir bir OS thread'idir. ps -T ile bir console app'in on iki thread'ini tanıyor, 1000 thread'in 12,7 GB virtual ama 20 MB resident memory tuttuğunu ve 100.000 bekleyen task'ın yedi thread'le çalıştığını ölçüyor, ThreadStatic ile AsyncLocal'ın await sonrası farkını gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; testler xUnit v3 4.0.3 ile yazıldı. Linux gözlemleri Ubuntu 24.04 tabanlı mcr.microsoft.com/dotnet/sdk:10.0 container'ında (Linux 6.x kernel) ps ve /proc ile yapıldı. Windows ve macOS araçları resmi dokümanlarına dayanarak anlatılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) eski bir modülü, kurumsal siparişlerin dışa aktarımını her istek için yeni bir Thread açarak yapıyor. Aynı anda bekleyen 1000 istek olduğunda bu thread'ler process'e ne kadar virtual memory ekler, ne kadar gerçek bellek? Aynı 1000 bekleyişi await eden 1000 Task ile yapsaydık kaç OS thread'i gerekirdi?

İkinci soru: bir method'un başında [ThreadStatic] bir alana ve bir AsyncLocal<string>'a "Ada" yazıp await Task.Delay(50) yapıyorsun. await'ten sonra ikisinde ne okursun? Bu yazıda cevapları gerçek ölçümle veriyoruz ve bir .NET thread'inin kernel'de neye karşılık geldiğine bakıyoruz.

Process ve Thread: Neyi Paylaşırlar?

Bir önceki yazıda process'i address space ve kaynaklar olarak tanımladık. Thread o process'in içinde kodu çalıştıran birimdir ve scheduler'ın CPU'ya koyduğu şey process değil, thread'dir. Aynı process'teki thread'ler address space'i, heap'i, açık dosyaları ve environment'ı paylaşır. Her thread'in kendine ait olanlar ise az ama önemli: kendi stack'i, kendi register set'i (sıradaki instruction'ı gösteren program counter ve stack pointer dahil), kendi thread-local storage alanı ve scheduler gözündeki kendi durumu.

Linux'ta kernel thread'i ayrı bir kavram olarak değil, kaynaklarını başkasıyla paylaşan bir task olarak görür. Process ve thread aynı clone syscall'ıyla oluşur; fark, hangi kaynakların paylaşılacağını söyleyen bayraklardadır. İkinci yazıda child process için CLONE_VM|CLONE_VFORK görmüştük; thread oluştururken runtime address space'i, dosya tablosunu ve signal handler'ları paylaşan bayraklarla CLONE_THREAD kullanır. Bu yüzden her thread'in kendi ID'si (TID) vardır ve ana thread'in TID'i process'in PID'iyle (process ID; kernel'in process'e verdiği numara) aynıdır. Windows'ta thread, process'in içinde ayrı bir kernel object'tir; mantık aynı.

Bir .NET Process'inin Thread'leri

Deney uygulamasına isimli bir thread açan ve bir işi thread pool'a gönderen bir komut ekledim. ThreadLab.Describe, managed thread ID'yi ve Linux'ta /proc/thread-self symlink'inden okunan OS thread ID'sini yazıyor:

case "threads":
{
    ThreadLab.Describe("main");
    Thread exporter = new(() =>
    {
        ThreadLab.Describe("OrderExporter");
        Thread.Sleep(TimeSpan.FromSeconds(int.Parse(args[1])));
    }) { Name = "OrderExporter" };
    exporter.Start();
    await Task.Run(() => ThreadLab.Describe("thread pool"));
    Console.WriteLine($"pid={Environment.ProcessId}, sleeping {args[1]} s");
    exporter.Join();
    break;
}
// Linux'ta /proc/thread-self, "<pid>/task/<tid>" hedefine giden bir symlink'tir
public static string OsThreadId() =>
    OperatingSystem.IsLinux()
        ? Path.GetFileName(new FileInfo("/proc/thread-self").LinkTarget ?? "?")
        : "n/a";
dotnet Bookstore.OsLab.dll threads 15
main           managed id=1   os tid=7533
OrderExporter  managed id=4   os tid=7542
thread pool    managed id=5   os tid=7543
pid=7533, sleeping 15 s

Üç şey görülüyor. Ana thread'in OS thread ID'si PID ile aynı: 7533. Managed ID'ler (1, 4, 5) runtime'ın kendi numaraları ve OS ID'leriyle ilgisi yok. Her managed thread'in arkasında bir OS thread'i var; .NET'te bu eşleşme bire birdir. Process çalışırken ikinci terminalden thread listesine ps -T ile bakalım:

ps -T -p 7533 -o pid,spid,stat,comm
    PID    SPID STAT COMMAND
   7533    7533 Sl   dotnet
   7533    7535 Sl   .NET SynchManag
   7533    7536 Sl   .NET EventPipe
   7533    7537 Sl   .NET DebugPipe
   7533    7538 Sl   .NET Debugger
   7533    7539 Sl   .NET Finalizer
   7533    7540 Sl   .NET Tiered Com
   7533    7541 Sl   .NET SigHandler
   7533    7542 Sl   OrderExporter
   7533    7543 Sl   .NET TP Worker
   7533    7544 Sl   .NET TP Gate
   7533    7545 Sl   .NET TP Worker

SPID sütunu TID'dir. Bizim açtığımız tek thread OrderExporter; Thread.Name OS'a da iletildi ve Linux'un 15 karakterlik thread adı sınırına sığdı. Diğerleri runtime'ın: Finalizer finalizer'ları çalıştıran thread, Tiered Com (adın 15 karakterde kesilmiş hâli) method'ları arka planda optimize ederek yeniden derleyen tiered compilation thread'i, SigHandler Unix signal'larını karşılayan thread, TP Worker'lar thread pool'un işçileri, TP Gate thread pool'un yeni thread gerekip gerekmediğine bakan yardımcı thread'i. EventPipe, DebugPipe ve Debugger diagnostics ve debugger desteği için; SynchManag ise Linux'ta Win32 senkronizasyon davranışını taklit eden PAL'a (Platform Adaptation Layer; runtime'ın Win32 çağrılarını Unix'te taklit eden katmanı) ait. Yani "tek thread'li" bir console app bile on iki OS thread'iyle çalışıyor. Thread'lere isim vermek bu yüzden önemli: bir thread dump'ında ya da top -H çıktısında hangisinin senin olduğunu adından tanırsın.

Windows'ta Sysinternals'ın PsList aracı pslist -d dotnet ile process'in thread'lerini ayrıntılı listeler (thread adları olmadan); Process Explorer'da da process'in özelliklerindeki Threads sekmesinde görülür. macOS'ta ps -M <pid> process'in thread'lerini listeler; Activity Monitor'de Threads sütunu sayıyı gösterir. Bu araçlar bu yazı için çalıştırılmadı.

Stack: Ne Kadar Büyük, Kim Belirler?

Her thread'in kendi stack'i olduğunu söyledik; local değişkenler, method çağrılarının dönüş adresleri ve parametreler orada durur. Peki bir thread açınca ne kadar bellek gider? Bookstore'un eski modülünü taklit eden ölçüm, 1000 thread'i bir ManualResetEventSlim üzerinde bekletiyor ve process'in virtual memory ile resident memory'sindeki artışı Process class'ından okuyor:

public static ThreadCost Measure(int count, int maxStackSize = 0)
{
    using ManualResetEventSlim release = new();
    using Process self = Process.GetCurrentProcess();
    long virtualBefore = self.VirtualMemorySize64;
    long residentBefore = self.WorkingSet64;

    Stopwatch sw = Stopwatch.StartNew();
    List<Thread> threads = new(count);
    for (int i = 0; i < count; i++)
    {
        // Her istek için bir thread açan eski usul sunucuyu taklit eder
        Thread t = new(() => release.Wait(), maxStackSize) { IsBackground = true, Name = $"order-{i}" };
        t.Start();
        threads.Add(t);
    }
    double elapsed = sw.Elapsed.TotalMilliseconds;

    self.Refresh();
    ThreadCost cost = new(count, self.Threads.Count, elapsed,
        (self.VirtualMemorySize64 - virtualBefore) / 1024 / 1024,
        (self.WorkingSet64 - residentBefore) / 1024 / 1024);

    release.Set();
    foreach (Thread t in threads)
    {
        t.Join();
    }
    return cost;
}
dotnet Bookstore.OsLab.dll thread-cost 1000
dotnet Bookstore.OsLab.dll thread-cost 1000 256
MALLOC_ARENA_MAX=1 dotnet Bookstore.OsLab.dll thread-cost 1000
MALLOC_ARENA_MAX=1 dotnet Bookstore.OsLab.dll thread-cost 1000 256
1000 threads (maxStackSize=default): 70.1 ms, os threads=1007, virtual +12762 MB, resident +20 MB
1000 threads (maxStackSize=256 KB): 81.8 ms, os threads=1007, virtual +5012 MB, resident +20 MB
1000 threads (maxStackSize=default): 70.7 ms, os threads=1007, virtual +8092 MB, resident +20 MB
1000 threads (maxStackSize=256 KB): 69.8 ms, os threads=1007, virtual +342 MB, resident +20 MB

İlk satır ürkütücü: 1000 thread için 12,7 GB virtual memory. Ama bu sayı iki farklı şeyin toplamı. Birincisi stack'ler: Linux'ta glibc'nin pthread_create(3) man sayfasına göre yeni thread'in varsayılan stack boyutu process başladığında geçerli olan RLIMIT_STACK limitinden, yani ulimit -s'ten gelir; container'da bu değer 8192 KB. .NET'in threading configuration dokümanı da aynı şeyi söylüyor: varsayılan stack Linux'ta 8 MB, Windows ve macOS'ta 1,5 MB. İkincisi glibc'nin malloc'u: çok thread'li programlarda thread'lerin birbirini beklememesi için ek malloc arena'ları açar ve her biri büyük bir adres aralığı reserve eder; mallopt(3)'e göre sayısı MALLOC_ARENA_MAX ile sınırlanabilir. Arena'ları bire indirince tablo netleşiyor: varsayılan stack'le 8092 MB, yani thread başına 8 MB; 256 KB stack'le 342 MB.

Asıl önemli sütun sonuncusu: dört durumda da resident memory artışı 20 MB, thread başına 20 KB civarı. Virtual memory bir söz, resident memory gerçek kullanım; stack'in yalnız dokunulan sayfaları fiziksel belleğe gelir. Bu ayrımı virtual memory yazısında ayrıntılı göreceğiz. Varsayılanı değiştirmek istersen iki yol var: tek bir thread için new Thread(work, maxStackSize), bütün runtime thread'leri için .NET 10 ile gelen DOTNET_Thread_DefaultStackSize (değer hex) ya da runtimeconfig'teki System.Threading.DefaultStackSize. Stack'i küçültmenin bedeli derin recursion'da StackOverflowException; .NET'te bu exception yakalanamaz ve process'i sonlandırır.

Kernel Thread, Green Thread ve M:N

Bire bir model (bir dil thread'i = bir kernel thread'i) basittir: blocking bir syscall yalnız o thread'i durdurur, scheduler gerçek paralelliği yönetir. Bedeli az önce gördüğümüz bellek ve her geçişte kernel'e gitmek. Tarihte bunun alternatifleri denendi. Green thread'ler (M:1) bütün dil thread'lerini tek bir kernel thread'i üzerinde, dilin kendi scheduler'ıyla çalıştırıyordu; blocking bir syscall herkesi durduruyordu. Bugün yaygın olan M:N: çok sayıda hafif thread az sayıda kernel thread'i üzerinde çalışır.

Go bunu goroutine'lerle yapar. Go FAQ'e (Go'nun resmi sık sorulan sorular sayfası) göre bir goroutine birkaç kilobaytlık bir stack ile başlar, stack gerektikçe büyür ve küçülür, bir goroutine syscall'da bloklanırsa runtime aynı OS thread'indeki diğerlerini başka bir thread'e taşır. Java'da JDK'nın (Java Development Kit; Java'nın geliştirme paketi) 2023'te çıkan 21 sürümüyle kalıcı hâle gelen virtual thread'ler (JEP 444) aynı fikrin JVM (Java Virtual Machine; Java'nın runtime'ı) karşılığı: virtual thread blocking bir işlemde taşıyıcı platform thread'inden ayrılır; JEP, synchronized blok veya native çağrı içindeyken bunun yapılamadığı pinning durumunu ayrıca anlatıyor.

.NET ise bekleme problemini dil seviyesinde çözdü: async/await. Bekleyen bir method thread tutmaz; derleyicinin ürettiği state machine heap'te durur ve I/O bitince thread pool'daki bir thread devam ettirir. .NET ekibi 2023'te green thread'leri bir deney olarak denedi; dotnet/runtimelab'deki sonuç notuna göre deney, yeni bir programlama modeli getirmenin yaratacağı kaygılar yüzünden askıya alındı ve mevcut async modelini geliştirmeye karar verildi. Aynı not, I/O bekleyen bir isteğin async C#'ta yaklaşık 100 byte, bloklanan bir OS thread'iyle ise kilobaytlar mertebesinde maliyeti olduğunu söylüyor. Bunu ölçelim:

public static async Task<ThreadCost> MeasureTasksAsync(int count)
{
    TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
    using Process self = Process.GetCurrentProcess();
    long virtualBefore = self.VirtualMemorySize64;
    long residentBefore = self.WorkingSet64;

    Stopwatch sw = Stopwatch.StartNew();
    Task[] waiters = new Task[count];
    for (int i = 0; i < count; i++)
    {
        // Bekleyen iş bir thread değil, heap'teki küçük bir state machine
        waiters[i] = WaitAsync(release.Task);
    }
    double elapsed = sw.Elapsed.TotalMilliseconds;

    self.Refresh();
    ThreadCost cost = new(count, self.Threads.Count, elapsed,
        (self.VirtualMemorySize64 - virtualBefore) / 1024 / 1024,
        (self.WorkingSet64 - residentBefore) / 1024 / 1024);

    release.SetResult();
    await Task.WhenAll(waiters);
    return cost;
}

private static async Task WaitAsync(Task signal) => await signal;
dotnet Bookstore.OsLab.dll task-cost 1000
dotnet Bookstore.OsLab.dll task-cost 100000
1000 awaiting tasks: 0.8 ms, os threads=7, virtual +0 MB, resident +3 MB
100000 awaiting tasks: 17.6 ms, os threads=7, virtual +0 MB, resident +17 MB

İlk sorunun cevabı burada. 1000 thread: 70 ms civarı, 1007 OS thread'i, gigabaytlarca virtual memory ve 20 MB resident. 100.000 bekleyen task: 17,6 ms, yedi OS thread'i, virtual memory'de değişiklik yok ve bekleyiş başına 170 byte civarı. Async'in API tarafını yayınlanmış C# Asenkron Programlama serisinde anlatmıştık; burada görülen, o API'nin OS seviyesindeki karşılığı.

Thread-Local Storage: ThreadStatic, ThreadLocal ve AsyncLocal

OS her thread'e kendi verisini koyabileceği bir alan verir: thread-local storage. Aynı değişken adı her thread'de farklı bir değeri gösterir. .NET'te bunun iki doğrudan karşılığı var: [ThreadStatic] attribute'ü taşıyan static alan ve ThreadLocal<T> (her thread için ilk değeri bir factory ile üretir). AsyncLocal<T> ise thread'e değil, mantıksal akışa bağlıdır; değeri ExecutionContext ile await'in devam ettiği thread'e taşınır:

public static class ContextDemo
{
    [ThreadStatic] private static string? s_threadStaticUser;
    private static readonly AsyncLocal<string?> s_asyncLocalUser = new();

    public static async Task<string> RunAsync()
    {
        s_threadStaticUser = "Ada";
        s_asyncLocalUser.Value = "Ada";
        int before = Environment.CurrentManagedThreadId;

        await Task.Delay(50);

        int after = Environment.CurrentManagedThreadId;
        return $"thread {before} -> {after}: ThreadStatic={s_threadStaticUser ?? "null"}, AsyncLocal={s_asyncLocalUser.Value ?? "null"}";
    }
}
dotnet Bookstore.OsLab.dll context
thread 1 -> 5: ThreadStatic=null, AsyncLocal=Ada

İkinci sorunun cevabı: await öncesinde kod 1 numaralı thread'de, sonrasında 5 numaralı thread pool thread'indeydi. [ThreadStatic] değer 1 numaralı thread'de kaldı, yeni thread'de null; AsyncLocal akışla birlikte geldi. Bu davranışlar xUnit testleriyle sabit: [ThreadStatic] değeri başka bir thread'de görünmüyor, AsyncLocal değeri Task.Run ile başka bir thread'e akıyor. Kural basit: kodda await varsa kullanıcı, tenant ya da correlation ID gibi istek bağlamını thread'e değil akışa bağla.

Üretimde Dikkat

Thread sayısını izle. /proc/<pid>/status içindeki Threads satırı, Windows'ta Task Manager'ın Threads sütunu ve dotnet-counters'ın thread pool sayaçları aynı soruya cevap verir. Zamanla durmadan artan thread sayısı neredeyse her zaman bir sızıntıdır: kapanmayan Timer'lar, bitmeyen arka plan döngüleri ya da her istekte açılan thread'ler. Debugging ve Problem Çözme serisindeki hang teşhisi yazısında anlattığımız thread pool starvation da bu sayaçlarda kendini gösterir.

Container'da thread'ler de sayılır. Linux'ta thread bir task olduğu için cgroup v2'nin pids controller'ı process'leri ve thread'leri birlikte sınırlar; container'ın pids.max limitine ulaşıldığında yeni thread açılamaz. Thread başına 8 MB'lık virtual stack ise genellikle sorun değildir, çünkü container memory limiti resident memory'ye bakar; ama 32 bit sistemlerde ya da virtual memory sınırı konmuş ortamlarda thread sayısını belirleyen bu olur. Container limitlerinin mekanizması serinin container yazısında.

Sık Düşülen Tuzaklar

Thread'i ucuz sanmak. Her istek için yeni thread açan kod düşük trafikte çalışır; yükte hem bellek hem de bir sonraki yazıda ölçeceğimiz context switch maliyeti onu yavaşlatır. Uzun süren, CPU yoğun bir işin gerçekten ayrı bir thread'e ihtiyacı varsa thread pool'u tıkamamak için TaskCreationOptions.LongRunning var; bekleme işleri ise async olmalı.

[ThreadStatic]'i async kodda kullanmak. Yukarıdaki çıktıdaki gibi değer await'ten sonra kaybolur ya da daha kötüsü, aynı thread'i daha önce kullanmış başka bir isteğin değerini görürsün. Thread pool thread'leri yeniden kullanılır; [ThreadStatic] bir alanı temizlemeden bırakmak bir isteğin verisini diğerine taşır.

Managed thread ID'yi OS thread ID'si sanmak. Environment.CurrentManagedThreadId runtime'ın numarasıdır; ps -T, top -H ya da bir native profiler'da gördüğün TID ile eşleşmez. İkisini eşlemek için thread'e isim vermek en kolay yol.

Küçük Bir Deneme

★☆☆ ulimit -s 2048 ile bir alt shell aç ve thread-cost 1000 komutunu orada çalıştır. Virtual memory artışının neden azaldığını MALLOC_ARENA_MAX=1 ile birlikte çalıştırarak açıkla.

★★☆ threads komutunda Task.Run yerine birkaç saniye süren 20 tane Task.Run(() => Thread.Sleep(2000)) başlat ve ps -T ile .NET TP Worker sayısının nasıl arttığını izle. Bu bloklayan kodun thread pool'a ne yaptığını gösterir.

★★★ Kendi recursive method'unla 256 KB stack'li bir thread'de ne kadar derine inebildiğini ölç, sonra aynı ölçümü varsayılan stack'le tekrarla. Sonucu stack frame başına byte olarak yorumla ve StackOverflowException'ın process'i nasıl sonlandırdığını gözle.

Bu yazıda bir console app'in on iki thread'i olduğunu, 1000 thread açmanın 1000 task'tan yaklaşık yüz kat uzun sürdüğünü gördük. Bu thread'lerin hepsi sınırlı sayıda core'u paylaşıyor. Bir sonraki yazıda bu paylaşımı yapan scheduler'a bakıyoruz: hangi thread ne zaman çalışır, context switch ne kadar sürer, Linux'un EEVDF'i (Earliest Eligible Virtual Deadline First; Linux 6.6 ile gelen scheduler) neyi değiştirdi ve container'da CPU limiti neden throttling'e dönüşür?