I/O Modelleri ve IPC: Blocking, Non-Blocking, epoll/kqueue/IOCP, Pipe ve Signal
await sırasında bekleyen bir thread yoktur: .NET, Linux'ta socket'leri epoll'a kaydeder ve tek bir .NET Sockets thread'i hepsini izler, Windows'ta aynı işi IOCP yapar. 1000 boşta bağlantının async'te 14, blocking'de 1014 OS thread'i tuttuğunu strace ile gösteriyor; Unix domain socket ve named pipe'ı deniyor ve shell ile başlatılan bir container'ın SIGTERM'i neden hiç almayıp 10 saniye sonra 137 ile öldüğünü ölçüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; testler xUnit v3 4.0.3 ile yazıldı. Ölçümler mcr.microsoft.com/dotnet/sdk:10.0 container'larında (Ubuntu 24.04, Linux 6.x kernel) ve Docker 29 ile alındı. Socket ve signal davranışı dotnet/runtime kaynak koduna, Windows bilgileri Microsoft Learn'e dayanır; Windows'taki davranışlar bu yazı için çalıştırılmadı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) stok servisi, depo terminallerinden gelen 1000 bağlantıyı açık tutuyor; terminaller çoğu zaman susuyor ve ara sıra bir byte gönderiyor. Servis her bağlantıda await socket.ReceiveAsync(...) ile bekliyor. Bu process'te kaç OS thread'i vardır: 1000 civarı mı, 100 civarı mı, 20'den az mı?
İkinci soru: servis container'da sh -c "dotnet Bookstore.Api.dll; echo done" gibi bir shell komutuyla başlatılıyor ve kodda SIGTERM'i (process'ten kapanmasını isteyen sinyal) yakalayıp açık siparişleri kapatan bir handler var. docker stop dediğinde handler çalışır mı, container kaç saniyede ve hangi exit code ile durur? Bu yazıda I/O'nun beklemesine ve process'lerin birbirleriyle konuşmasına bakıyoruz.
Blocking, Non-Blocking ve Async I/O
Bir socket'ten okumak istediğinde veri henüz gelmemiş olabilir. OS'un bu durumda üç cevabı var. Blocking I/O'da recv çağrısı veri gelene kadar döner; thread kernel'de uyur ve dördüncü yazıda gördüğümüz voluntary context switch'lerden biri olur. Non-blocking I/O'da socket bu modda açılır ve veri yoksa recv hemen EAGAIN hatasıyla döner; ne zaman tekrar deneyeceğini sana bırakır. Async I/O'da işi kernel'e verirsin ve iş bitince haber alırsın.
Bloklayan model basittir ama her bekleyen bağlantı bir thread demektir; üçüncü yazıda bin thread'in maliyetini ölçmüştük. Non-blocking tek başına işe yaramaz: binlerce socket'i sırayla deneyip EAGAIN almak CPU'yu boşa yakar. Eksik parça, "bu socket'lerden hangisi hazır?" sorusunu kernel'e soran bir mekanizma.
Readiness ve Completion: epoll, kqueue ve IOCP
Bu sorunun iki cevabı var. Readiness modelinde (Linux'ta epoll, macOS'ta kqueue) socket'leri bir izleme listesine kaydedersin ve tek bir çağrıyla "hangileri okunabilir?" diye beklersin; kernel hazır olanları söyler, okumayı sen yaparsın. Completion modelinde okuma işini buffer'ıyla birlikte baştan kernel'e verirsin; kernel veriyi buffer'a koyar ve iş bitince haber verir. Windows'ta bunu IOCP (I/O completion port; tamamlanan I/O işlerini bir port üzerinden bildiren mekanizma), Linux'ta io_uring yapar.

.NET socket'leri iki platformda da bu mekanizmaların üzerine kurar. dotnet/runtime kaynağına göre Linux'ta SocketAsyncEngine bir epoll instance'ı açar, ".NET Sockets" adlı bir thread epoll'dan gelen olayları alır ve okuma ile yazma işlerini thread pool'a verir. Engine sayısı x64'te her 30, arm64'te her 8 core için bir tanedir, en az bir. Windows'ta socket ve asenkron açılmış dosya handle'ları thread pool'un IOCP'sine bağlanır. Async API'nin kendisini C# Asenkron Programlama serisinin ilk yazısında anlatmıştık; burada onun OS tarafına bakıyoruz.
await Sırasında Thread Nerede?
Deney uygulamasına loopback üzerinde N bağlantı açan bir komut ekledim. Sunucu tarafı her bağlantıda bir byte bekliyor; ya eski usulde her bağlantıya bir thread açıp Receive ile bloklanarak ya da await ile:
byte[] buffer = new byte[1];
if (blocking)
{
// Eski usul: her bağlantıya bir thread, thread Receive içinde bloklanır
Thread t = new(() => server.Receive(buffer)) { IsBackground = true };
t.Start();
threads.Add(t);
}
else
{
// await: bekleyen bir thread yok, yalnız epoll'a kayıtlı bir socket var
waits.Add(server.ReceiveAsync(buffer));
}
dotnet Bookstore.OsLab.dll connections 1000 async
dotnet Bookstore.OsLab.dll connections 1000 blocking
dotnet Bookstore.OsLab.dll connections 1000 async --names
1000 idle connections (async ): os threads= 14, setup 764 ms
1000 idle connections (blocking): os threads= 1014, setup 1020 ms
1000 idle connections (async ): os threads= 14, setup 785 ms
.NET Debugger
.NET DebugPipe
.NET EventPipe
.NET Finalizer
.NET SigHandler
.NET Sockets
.NET SynchManag
.NET Tiered Com
.NET Timer
.NET TP Gate
.NET TP Worker x5
dotnet
Birinci sorunun cevabı: 14. 1000 bağlantı bekliyor ama bekleyen bir thread yok. Listede üçüncü yazıda gördüğümüz runtime thread'lerine ek olarak .NET Sockets var; 1000 socket'in hepsini o tek thread izliyor. Blocking modda ise bağlantı başına bir thread: 1014. strace ile tek bir bağlantının hayatına bakalım (client tarafının satırlarını çıkardım):
strace -f -qq -e trace=connect,accept4,recvfrom,epoll_ctl,epoll_pwait dotnet Bookstore.OsLab.dll connections 1 async
1285 accept4(47, {sa_family=AF_INET, sin_port=htons(50444), sin_addr=inet_addr("127.0.0.1")}, [64 => 16], SOCK_CLOEXEC) = 56
1285 recvfrom(56, 0xfffc82813b18, 1, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
1285 epoll_ctl(46, EPOLL_CTL_ADD, 56, {events=EPOLLIN|EPOLLOUT|EPOLLET, data={u32=1, u64=1}}) = 0
1284 epoll_pwait(46, [{events=EPOLLIN|EPOLLOUT, data={u32=1, u64=1}}], 1024, -1, NULL, 8) = 1
1287 recvfrom(56, "\1", 1, 0, NULL, NULL) = 1
Kabul edilen bağlantı fd 56 oldu. Thread pool thread'i (1285) önce okumayı denedi ve EAGAIN aldı: veri yok. Ancak o zaman socket'i epoll_ctl ile epoll listesine ekledi ve await thread'i bırakıp geri döndü. Client byte'ı gönderince .NET Sockets thread'i (1284) epoll'dan bu socket için EPOLLIN olayını aldı, iş thread pool'a gitti ve başka bir thread (1287) byte'ı okudu. "await sırasında hiçbir thread beklemez" cümlesinin OS'taki karşılığı bu: bekleyen tek şey epoll'un listesindeki bir kayıt. Bu strace arm64'te alındı; x64 Linux'ta aynı bekleme epoll_wait olarak görünür ve buffer adresi 7f ile başlar. 100 bağlantılık bir koşuda strace -c 1 epoll_create1, 200 epoll_ctl ve 375 epoll_pwait saydı.
Dosya I/O'su Neden Farklı?
epoll normal dosyalarla çalışmaz: epoll_ctl(2) man sayfasına göre normal bir dosyayı ya da directory'yi listeye eklemek EPERM ile reddedilir. Disk okumasının "hazır" olmasını beklemenin bir anlamı yoktur, okuma her durumda diski bekleyebilir. dotnet/runtime kaynağına göre Linux'ta FileStream.ReadAsync ve RandomAccess.ReadAsync, işi thread pool'a verip senkron bir pread yaptırır; await eden kod serbest kalır ama bir thread pool thread'i diskte bekler. Windows'ta FileOptions.Asynchronous ile açılmış dosyalar gerçek overlapped I/O ile IOCP'ye bağlanır. Linux'ta bu boşluğu io_uring dolduruyor; .NET 10 kaynağında io_uring kullanımı yok. Pratik sonuç: Linux'ta binlerce eşzamanlı dosya okuması thread pool'u şişirebilir, socket'lerde böyle bir sorun yok.
IPC: Pipe, Unix Domain Socket ve Named Pipe
İkinci yazıda child process'in stdout'unu okurken anonymous pipe kullanmıştık: kernel'de bir buffer, bir ucu yazan bir ucu okuyan iki fd. Aynı makinedeki iki bağımsız process için daha esnek bir araç Unix domain socket (UDS; aynı makinedeki process'ler arasında, dosya sistemindeki bir path üzerinden açılan socket): TCP socket'i gibi kullanılır ama network stack'inden geçmez ve adresi bir dosya yoludur. Bookstore'un stok servisi aynı makinedeki bir raporlama aracına böyle cevap veriyor:
// Process çökerse socket dosyası diskte kalır: bind'dan önce eskisini sil
File.Delete(path);
using (Socket listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified))
{
listener.Bind(new UnixDomainSocketEndPoint(path));
listener.Listen();
lines.Add($"bound {path}, file exists: {File.Exists(path)}");
...
}
lines.Add($"after Dispose, file exists: {File.Exists(path)}");
dotnet Bookstore.OsLab.dll uds /tmp/bookstore-stock.sock
dotnet Bookstore.OsLab.dll named-pipe bookstore-reports
bound /tmp/bookstore-stock.sock, file exists: True
reply: stock 9780441172719 = 12
after Dispose, file exists: False
server received: report:daily
/tmp/CoreFxPipe_bookstore-reports
Socket dosyası bind ile oluştu ve dotnet/runtime kaynağına göre Socket.Dispose bağlı olduğu dosyayı siler; ama process SIGKILL ile ölürse dosya kalır ve sonraki bind "address in use" hatası verir. Kestrel'in Microsoft Learn'deki dokümanı da aynı uyarıyı yapıyor. Kestrel'i ListenUnixSocket ile bir UDS'ten dinletip önüne nginx koymak, aynı makinedeki reverse proxy için TCP'ye göre daha hafif bir yol.
İkinci satır grubu ilginç: NamedPipeServerStream Windows'ta işletim sisteminin named pipe'ını kullanır, Linux'ta ise dotnet/runtime kaynağına göre temp klasöründe CoreFxPipe_ önekli bir Unix domain socket açar. Aynı API, iki OS'ta iki farklı mekanizma. Altıncı yazıdaki shared memory en hızlı yoldur ama senkronizasyonu sana bırakır; process'ler farklı makinelere dağılınca bir sonraki adım message queue'dur, onu yayınlanmış Messaging ve Event-Driven Architecture serisinde anlatmıştık.
Signal'lar: SIGTERM, SIGINT ve SIGKILL
Signal, kernel'in bir process'e PID'si (process ID; process'in kernel'deki numarası) üzerinden gönderdiği küçük, asenkron bir bildirimdir. Üçü servis geliştiricisini doğrudan ilgilendirir: SIGINT (interrupt; kesme isteği) terminalde Ctrl+C; SIGTERM "kapan" isteği, docker stop ve Kubernetes bunu gönderir; SIGKILL (kill; yakalanamayan öldürme sinyali) ise yakalanamaz ve process'i kernel öldürür. dotnet/runtime'da signal'lar önce bir native handler'a gelir, bir pipe'a yazılır ve üçüncü yazıda gördüğümüz .NET SigHandler thread'i onları managed koda taşır. PosixSignalRegistration ile bu akışa katılabilirsin:
TaskCompletionSource<PosixSignal> stop = new(TaskCreationOptions.RunContinuationsAsynchronously);
void OnSignal(PosixSignalContext context)
{
// Varsayılan davranışı (process'i hemen bitirmek) iptal et, kapanışı biz yönetelim
context.Cancel = true;
stop.TrySetResult(context.Signal);
}
using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSignal);
using PosixSignalRegistration interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnSignal);
Console.WriteLine($"pid={Environment.ProcessId} waiting for SIGTERM or SIGINT");
PosixSignal signal = await stop.Task;
Console.WriteLine($"[{sw.Elapsed.TotalSeconds,5:F1} s] {signal} received, finishing open orders");
await Task.Delay(cleanup);
Console.WriteLine($"[{sw.Elapsed.TotalSeconds,5:F1} s] shutdown complete");
Gerçek bir ASP.NET Core ya da Worker Service uygulamasında bunu elle yazmazsın: Generic Host SIGTERM'i yakalar ve IHostApplicationLifetime.ApplicationStopping ile hosted service'leri durdurur. Ayrıntıları yayınlanmış Kubernetes serisinin graceful shutdown yazısında ve Background Job'lar serisinin IHostedService yazısında anlatmıştık. Microsoft Learn'deki PosixSignalRegistration dokümanına göre SIGINT ve SIGQUIT (quit; çıkış ve dump isteği) Windows'ta da iptal edilebilir, SIGTERM ise yalnız Unix'te; Windows Service'in durdurulması ise service control manager üzerinden gelir ve Background Job'lar serisinin Worker Service yazısında var.
Container'da PID 1 Problemi
Şimdi ikinci soru. serve komutunu dört farklı şekilde container'da başlattım ve 6 saniye sonra docker stop -t 10 ile durdurdum. Her seferinde container içindeki process listesine, durma süresine ve exit code'a baktım:
docker run -d --name bookstore -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 dotnet Bookstore.OsLab.dll serve 1
docker run -d --name bookstore -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 sh -c "dotnet Bookstore.OsLab.dll serve 1; echo wrapper done"
docker run -d --name bookstore --init -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 sh -c "dotnet Bookstore.OsLab.dll serve 1; echo wrapper done"
docker run -d --name bookstore -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 sh -c "exec dotnet Bookstore.OsLab.dll serve 1"
| Başlatma | PID 1 | docker stop | Exit code | Handler çalıştı mı? |
|---|---|---|---|---|
dotnet ... (exec form) | dotnet | 1,1 s | 0 | evet, "shutdown complete" |
sh -c "dotnet ...; echo ..." | sh | 10,1 s | 137 | hayır, SIGTERM gelmedi |
aynısı + --init | docker-init | 0,1 s | 143 | hayır, process öldürüldü |
sh -c "exec dotnet ..." | dotnet | 1,1 s | 0 | evet |
Exec form'da dotnet PID 1'di; SIGTERM'i aldı, bir saniyelik temizliği yaptı ve 0 ile çıktı. Shell sarmalayıcısında PID 1 sh'ti ve signal'ı ne işledi ne de child'a iletti; Docker 10 saniye bekleyip SIGKILL gönderdi: 137. Sebep kernel'de. pid_namespaces(7) man sayfasına göre bir namespace'in init process'ine (PID 1), dışarıdan yalnız handler kurduğu signal'lar ulaşır; handler'ı yoksa SIGTERM sessizce düşer, yalnız SIGKILL ve SIGSTOP (stop; process'i duraklatan, yakalanamayan sinyal) zorla iletilir. sh SIGTERM için handler kurmadığından signal hiçbir şey yapmadı. dotnet PID 1 iken sorun yok, çünkü runtime SIGTERM için kendi handler'ını kurar; handler'sız bir komutla (proc-info) denediğimde de container 0,1 saniyede 143 ile kapandı.
--init bu senaryoyu düzeltmedi. Docker'ın init'i SIGTERM'i child'ı olan sh'e iletti; sh artık PID 1 olmadığı için varsayılan davranışla öldü ve init çıktı. Aynı man sayfasına göre init sonlanınca kernel namespace'teki bütün process'leri SIGKILL ile öldürür: dotnet temizlik yapamadan gitti. --init'in asıl işi zombie process'leri toplamak ve signal'ı tek bir child'a iletmek; araya bir shell koyduğunda signal yine yanlış process'e gider. Doğru çözüm, uygulamanın PID 1 ya da init'in doğrudan child'ı olması: Dockerfile'da exec form ENTRYPOINT ["dotnet", "Bookstore.Api.dll"], shell script şartsa son satırda exec. Dockerfile yazımının kendisi ilerideki Docker serisinin konusu.
Üretimde Dikkat
Kapanış süresini bir bütçe olarak düşün. Docker'ın varsayılanı 10 saniye, Kubernetes'in terminationGracePeriodSeconds'ı 30 saniye; .NET Generic Host'un kendi ShutdownTimeout'u bunların içinde kalmalı. Bütçeyi aşan uygulama SIGKILL ile ölür ve exit code 137 olur; bu, beşinci yazıdaki OOMKilled'la aynı koddur, ayırmak için orkestratörün sebep alanına bak.
Bağlantı başına thread'li eski kodu async'e taşımak bellek ve context switch kazancı getirir, ama aynı process'te bloklayan bir çağrı kalmışsa (senkron bir HTTP client, .Result) thread pool yine tıkanır. Bekleyen bağlantı sayısı thread sayısını değil, epoll listesinin uzunluğunu büyütmeli.
Sık Düşülen Tuzaklar
ENTRYPOINT'i shell form'da yazmak. ENTRYPOINT dotnet Bookstore.Api.dll Docker'da /bin/sh -c ile çalışır; SIGTERM uygulamaya ulaşmaz ve her deploy 10 saniyelik bir SIGKILL ile biter. Log'da "shutdown" satırı göremiyorsan önce container'daki PID 1'e bak.
SIGKILL'i yakalamaya çalışmak. SIGKILL ve SIGSTOP'a handler kurulamaz; kernel process'i durdurur ve ne finally blokları ne ProcessExit çalışır. Kalıcı olması gereken her şey kapanıştan önce değil, iş sırasında diske ya da veritabanına yazılmalıdır.
Linux'ta dosya I/O'sunu socket gibi sanmak. await File.ReadAllBytesAsync(...) Linux'ta bir thread pool thread'ini diskte bekletir; binlerce paralel dosya okuması, socket'lerde görmediğin bir thread artışına yol açar.
Küçük Bir Deneme
★☆☆ connections komutunu 10, 100 ve 5000 bağlantıyla iki modda çalıştır ve OS thread sayısını karşılaştır. 5000 blocking bağlantıda neyin önce bittiğine bak: bellek mi, fd limiti mi?
★★☆ DOTNET_SYSTEM_NET_SOCKETS_THREAD_COUNT=4 ile connections 1000 async --names çalıştır ve .NET Sockets thread sayısının değiştiğini doğrula; strace ile kaç ayrı epoll instance'ı oluştuğunu say.
★★★ Bookstore için küçük bir ASP.NET Core Minimal API yaz ve Kestrel'i ListenUnixSocket ile /tmp/bookstore-api.sock'tan dinlet. curl --unix-socket ile istek at; sonra process'i kill -9 ile öldürüp yeniden başlatmayı dene ve kalan socket dosyasının ne yaptığını gözle.
Bu yazıda I/O'nun nasıl beklendiğini ve process'lerin nasıl haberleştiğini gördük. Serinin son yazısında her şeyi bir araya getiriyoruz: container'ın OS gözüyle ne olduğu, namespace'ler, cgroup v2 ve "container sadece bir process'tir" cümlesinin gerçekte ne anlama geldiği.