Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri Networking ve HTTP Nasıl Çalışır — #03

TCP Nasıl Çalışır: 3-Way Handshake, Congestion Control ve HttpClient Socket Exhaustion

Mert Özen 8 Kas 2026 10 dk 6 görüntülenme
TCP Nasıl Çalışır: 3-Way Handshake, Congestion Control ve HttpClient Socket Exhaustion

TCP güvenilir bir byte akışı için 3-way handshake ile bağlantı kurar, slow start ile yavaş başlar ve kapanışta TIME_WAIT'te bekler; her istekte new HttpClient bu yüzden port'ları tüketir. 500 istekte 500 TIME_WAIT, TLS ile 6 kat yavaşlık, IHttpClientFactory, PooledConnectionLifetime ve handler varsayılanları gerçek çıktılarla.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10 Kestrel) ve Microsoft.Extensions.Http 10.0.14 ile derlenip macOS'ta çalıştırılmıştır; TCP (Transmission Control Protocol) anlatımı RFC (Request for Comments) 9293 esas alır.

Önce Tahmin Et

Bookstore'un sipariş servisi stok bilgisini katalog servisinden alıyor. Kodu yazan geliştirici kaynak sızıntısından korktuğu için her istekte yeni bir client oluşturup işi bitince dispose ediyor:

for (var i = 0; i < count; i++)
{
    using var client = new HttpClient();
    var info = await client.GetFromJsonAsync<ConnectionInfo>(url);
    ids.Add(info!.Id);
}

Kod 500 istek attıktan hemen sonra işletim sisteminin TCP tablosuna bakıyorsun. using var, her client dispose edildi. Tabloda bu servise ait kaç socket görürsün: sıfır mı, bir mi, beş yüz mü? Ve aynı 500 isteği tek bir paylaşılan client ile atsaydın süre ne kadar değişirdi?

TCP Ne Garanti Eder

IP (Internet Protocol) paketleri tek tek, birbirinden habersiz taşır: kaybolabilir, çoğalabilir, sırası karışabilir. TCP bunun üzerine iki uygulama arasında güvenilir, sıralı bir byte akışı kurar. Uygulama "şu 600 byte'ı gönder" der; TCP bunu segment'lere böler, her byte'a bir sequence number verir, karşı tarafın onayını (ACK) bekler, onay gelmeyen segment'leri yeniden gönderir (retransmission) ve alıcı tarafta segment'leri sıraya dizip uygulamaya kesintisiz bir akış olarak verir.

Bu garantinin bir bedeli var: iki tarafın da bağlantının durumunu (hangi byte'a kadar gönderildi, hangisine kadar onaylandı, pencere ne kadar) tutması gerekiyor. Bu durum bir handshake ile kurulur ve bir kapanış ile temizlenir. HTTP (Hypertext Transfer Protocol) sürümlerinden HTTP/1.1 ve HTTP/2 bu yüzden bağlantı yönelimlidir; her yeni bağlantı, bir istek gönderilmeden önce bir gidiş-dönüş demektir.

3-Way Handshake ve Kapanış

03-tcp-handshake-tr.png

Bağlantıyı açan taraf bir SYN (synchronize) gönderir ve kendi başlangıç sequence number'ını bildirir. Sunucu SYN-ACK ile hem bu numarayı onaylar hem kendi numarasını bildirir. İstemci son bir ACK ile sunucunun numarasını onaylar. Üç mesaj, bir RTT (round-trip time): istemci ilk veriyi ACK'le birlikte, yani bir RTT sonra gönderebilir. Başlangıç numaraları rastgele seçilir; aynı port'ları kullanan eski bir bağlantının geç kalmış segment'leri yeni bağlantıya karışmasın diye.

Kapanış dört mesajdır, çünkü iki yön bağımsız kapanır. Önce kapatan taraf FIN (finish) gönderir, karşı taraf onaylar; karşı taraf da işi bitince kendi FIN'ini gönderir ve son ACK gelir. Bağlantıyı önce kapatan taraf bundan sonra TIME_WAIT durumuna geçer ve 2 × MSL (Maximum Segment Lifetime) boyunca bekler. Bu bekleme iki şeyi korur: son ACK kaybolursa karşı tarafın yeniden gönderdiği FIN'e cevap verebilmek ve ağda hâlâ dolaşan eski segment'lerin aynı adres ve port dörtlüsüyle açılacak yeni bir bağlantıya karışmasını önlemek. macOS'ta MSL değeri:

$ sysctl net.inet.tcp.msl
net.inet.tcp.msl: 15000

15 saniye, yani TIME_WAIT 30 saniye sürer. Linux'ta bu süre 60 saniye olarak derlenmiştir. Bu bekleyen bağlantıların her biri istemci tarafında bir ephemeral port'u tutar. Tahmin sorusunun cevabı tam olarak burada.

Wireshark kuruluysa handshake'i kendi makinende görmek için loopback arayüzünü dinleyip tcp.port == 5491 && tcp.flags.syn == 1 filtresini kullanabilirsin; her yeni bağlantı bir SYN ve bir SYN-ACK satırı olarak görünür. Bu yazıdaki ölçümlerde paket yakalamak yerine işletim sisteminin socket tablosunu (netstat) ve uygulama tarafındaki bağlantı kimliklerini kullandım.

Flow Control ve Congestion Control: Yeni Bağlantı Neden Yavaş

TCP göndericiyi iki ayrı frenle yavaşlatır. Flow control alıcıyı korur: alıcı her ACK'te ne kadar daha veri alabileceğini (receive window) bildirir ve gönderici bundan fazlasını uçuşta tutmaz. Congestion control ise ağı korur: gönderici kendi tuttuğu bir congestion window (cwnd) kadar onaylanmamış veriyi uçuşta tutabilir ve bu pencereyi ağın tepkisine göre büyütüp küçültür.

Yeni bir bağlantı ağın kapasitesini bilmez, bu yüzden küçük başlar: buna slow start denir. Başlangıç penceresi bugün çoğu sistemde 10 segment civarı (RFC 6928, deneysel statüde ama yaygın), yani yaklaşık 14 KB. Her RTT'de onaylanan her segment için pencere bir segment büyür; pratikte pencere her RTT'de ikiye katlanır. 100 KB'lık bir JSON (JavaScript Object Notation) cevabını yeni bir bağlantıda göndermek yaklaşık 14 + 28 + 56 KB, yani üç RTT sürer; aynı cevap pencereyi zaten büyümüş, ısınmış bir bağlantıda tek RTT'de gidebilir. Bir paket kaybolursa gönderici bunu tıkanıklık işareti sayar ve pencereyi küçültür (RFC 5681). Linux'un varsayılanı CUBIC (RFC 9438), pencereyi kayıptan sonra daha agresif bir eğriyle yeniden büyütür.

Uygulama geliştiricisi için sonuç basit: yeni bağlantı yalnızca handshake yüzünden değil, slow start yüzünden de yavaştır. Uzun yaşayan, ısınmış bağlantıları yeniden kullanmak bu iki maliyetin ikisinden de kurtarır.

UDP ile Karşılaştırma

UDP (User Datagram Protocol) bunların hiçbirini yapmaz. Handshake yok, sıralama yok, yeniden gönderme yok, congestion control yok; her datagram bağımsız gider ve ya ulaşır ya ulaşmaz. Bu eksiklikler bazı işler için avantaj:

ÖzellikTCPUDP
Bağlantı kurulumu3-way handshake (1 RTT)yok
Güvenilirlik ve sıragaranti, retransmission ileyok; uygulama ilgilenir
Congestion controlişletim sistemindeyok; uygulama ilgilenir
Tipik kullanımHTTP/1.1, HTTP/2, veritabanı protokolleriDNS (Domain Name System) sorguları, QUIC (HTTP/3), video, oyun

DNS sorgusu tek bir soru ve tek bir cevap; bunun için bir handshake'in maliyetine katlanmaya gerek yok, kaybolan sorgu yeniden sorulur. QUIC ise TCP'nin garantilerini UDP üzerinde, işletim sistemi yerine uygulama tarafında yeniden kurar; bu sayede handshake'i TLS (Transport Layer Security) ile birleştirebilir ve TCP'nin değiştirilmesi yıllar süren davranışlarından kurtulur. Bu konu serinin dokuzuncu yazısında.

.NET: Her İstekte new HttpClient

Tahmin bölümündeki kodu kendi Kestrel sunucuma karşı çalıştırdım. Sunucudaki /conn endpoint'i, isteğin geldiği bağlantının Kestrel'deki kimliğini dönüyor; istemci bu kimlikleri bir HashSet'te topluyor. Sonra aynı 500 isteği tek bir paylaşılan client ile attım:

// Tek bir paylaşılan client: handler bağlantıları pool'da tutar ve yeniden kullanır
public static async Task<HashSet<string>> SharedClientAsync(HttpClient client, string url, int count)
{
    var ids = new HashSet<string>();
    for (var i = 0; i < count; i++)
    {
        var info = await client.GetFromJsonAsync<ConnectionInfo>(url);
        ids.Add(info!.Id);
    }
    return ids;
}
$ netstat -anp tcp | grep "127.0.0.1.5491" | grep -c TIME_WAIT
1
$ dotnet Bookstore.Net.Lab.dll conns new http://127.0.0.1:5491/conn 500
new    requests=500 distinct connections=500 elapsed=118 ms
$ netstat -anp tcp | grep "127.0.0.1.5491" | grep -c TIME_WAIT
501
$ dotnet Bookstore.Net.Lab.dll conns shared http://127.0.0.1:5491/conn 500
shared requests=500 distinct connections=1 elapsed=65 ms
$ netstat -anp tcp | grep "127.0.0.1.5491" | grep -c TIME_WAIT
502
$ netstat -anp tcp | grep "127.0.0.1.5491" | grep TIME_WAIT | head -3
tcp4       0      0  127.0.0.1.62447        127.0.0.1.5491         TIME_WAIT
tcp4       0      0  127.0.0.1.62448        127.0.0.1.5491         TIME_WAIT
tcp4       0      0  127.0.0.1.62449        127.0.0.1.5491         TIME_WAIT

500 istek, 500 ayrı bağlantı ve 500 yeni TIME_WAIT. Her HttpClient kendi SocketsHttpHandler'ını, dolayısıyla kendi connection pool'unu oluşturuyor; dispose edilince pool'daki bağlantıyı kapatıyor. Bağlantıyı kapatan taraf istemci olduğu için TIME_WAIT de istemcinin tarafında birikiyor ve her biri bir ephemeral port'u 30 saniye boyunca tutuyor. Paylaşılan client ise 500 isteğin hepsini tek bir bağlantıdan gönderdi; son satırdaki tek ek TIME_WAIT, onun program biterken kapanan bağlantısı. 30 saniye sonra tablo boşaldı:

t+20s TIME_WAIT=502
t+25s TIME_WAIT=0

Loopback'te ve şifresiz HTTP'de fark iki kat. Aynı denemeyi bir Linux container'ındaki Kestrel'e TLS 1.3 ile, 200 istekle tekrarladım; istemci, kendi yerel CA'mı (Certificate Authority) güvenilir kök olarak tanıyan bir handler kullanıyor:

$ LOCAL_CA=certs/ca.crt dotnet Bookstore.Net.Lab.dll conns new https://127.0.0.1:5592/conn 200
new    requests=200 distinct connections=200 elapsed=674 ms
$ LOCAL_CA=certs/ca.crt dotnet Bookstore.Net.Lab.dll conns shared https://127.0.0.1:5592/conn 200
shared requests=200 distinct connections=1 elapsed=111 ms

Altı kat. Her yeni bağlantı bir TCP handshake'i, bir TLS handshake'i ve sertifika doğrulaması demek; bu ölçüm hâlâ aynı makinenin içinde. 50 milisaniye RTT'li gerçek bir ağda her yeni bağlantı en az 100 milisaniye ekler. Yük altında ise asıl sorun hız değil, port'lar: macOS'un ephemeral aralığı 16.384 port; saniyede birkaç yüz isteği bu şekilde atan bir servis, aralığı 30 saniyelik TIME_WAIT'lerle doldurur ve yeni bağlantılar SocketException ile, "address already in use" veya "cannot assign requested address" mesajıyla düşmeye başlar. Buna socket exhaustion denir.

Doğru Kullanım: Paylaşılan Client ve IHttpClientFactory

Microsoft'un "HttpClient guidelines" dokümanı iki seçenek önerir: uzun yaşayan (static veya singleton) bir HttpClient ve PooledConnectionLifetime, ya da IHttpClientFactory'nin ürettiği kısa ömürlü client'lar. İkincisinde factory handler'ları pool'da tutar; client'ı ne kadar sık oluşturursan oluştur bağlantılar paylaşılır. Typed client ile:

// Typed client: handler'ın sahibi ve yenileyicisi factory, client'ın kendisi ucuz
public sealed class CatalogClient(HttpClient http)
{
    public Task<ConnectionInfo?> GetConnectionAsync() => http.GetFromJsonAsync<ConnectionInfo>("/conn");
}

public static class CatalogClientRegistration
{
    public static IServiceCollection AddCatalogClient(this IServiceCollection services, Uri baseAddress) =>
        services.AddHttpClient<CatalogClient>(client => client.BaseAddress = baseAddress)
            .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
            {
                PooledConnectionLifetime = TimeSpan.FromMinutes(2)
            })
            .SetHandlerLifetime(Timeout.InfiniteTimeSpan)
            .Services;
}
$ dotnet Bookstore.Net.Lab.dll factory http://127.0.0.1:5491
client #0 connection=0HNPTG1A9A64H
client #1 connection=0HNPTG1A9A64H
client #2 connection=0HNPTG1A9A64H

Üç ayrı CatalogClient instance'ı, tek bağlantı. Burada bağlantıların yenilenmesini factory'nin handler rotasyonuna değil PooledConnectionLifetime'a bıraktım ve bu yüzden handler lifetime'ını sonsuz yaptım. PooledConnectionLifetime neden önemli? HttpClient DNS'i yalnızca yeni bir bağlantı açarken çözer; hiç kapanmayan bir bağlantı, hedefin IP adresi değişse bile eski adrese gitmeye devam eder. Ömrü 2,5 saniye olan bir handler ile her saniye bir istek attım:

$ dotnet Bookstore.Net.Lab.dll lifetime http://127.0.0.1:5491/conn 2.5 7
t=0s connection=0HNPTG1A9A64E clientPort=63500
t=1s connection=0HNPTG1A9A64E clientPort=63500
t=2s connection=0HNPTG1A9A64E clientPort=63500
t=3s connection=0HNPTG1A9A64F clientPort=63513
t=4s connection=0HNPTG1A9A64F clientPort=63513
t=5s connection=0HNPTG1A9A64F clientPort=63513
t=6s connection=0HNPTG1A9A64G clientPort=63522

Bağlantı ömrünü doldurunca emekliye ayrıldı ve yenisi, yeni bir DNS çözümlemesi ve yeni bir kaynak port'uyla açıldı. Production'da bu süre dakikalar mertebesinde seçilir; dokümandaki örnek 2 dakika. Bunun DNS tarafını bir sonraki yazıda blue-green geçişi senaryosuyla açıyoruz. Varsayılanların kendisini de kodla okudum:

PooledConnectionLifetime    : -00:00:00.0010000
PooledConnectionIdleTimeout : 00:01:00
ConnectTimeout              : -00:00:00.0010000
MaxConnectionsPerServer     : 2147483647

-00:00:00.0010000, Timeout.InfiniteTimeSpan'in kendisi: varsayılan bir handler'da bağlantılar sonsuza kadar yaşar ve connect için ayrı bir timeout yoktur. Boşta kalan bir bağlantı ise bir dakika sonra kapatılır.

Üretimde Dikkat

Keep-alive süreleri iki tarafta uyumlu olmalı. Kestrel'in KeepAliveTimeout'u kaynak kodda 130 saniye; bu değer, yorumda da yazdığı gibi, istemcinin eski 2 dakikalık idle timeout'undan biraz uzun seçilmiş. Araya idle timeout'u daha kısa olan bir load balancer girerse sorun başlar: load balancer boşta kalan bağlantıyı sessizce kapatır, istemci bunu bilmeden pool'daki bağlantıyı kullanır ve ilk istek "connection reset" ile düşer. Kural: istemcinin PooledConnectionIdleTimeout'u, aradaki her bileşenin idle timeout'undan kısa olsun.

Kapanışı kimin yaptığına dikkat et. TIME_WAIT, bağlantıyı önce kapatan tarafta birikir. İstemciler bağlantıyı kapatıyorsa sorun istemcide, sunucu kapatıyorsa sunucuda görünür. Bir sunucuda on binlerce TIME_WAIT görüyorsan, istemcilerin keep-alive kullanmadığına veya sunucunun her cevaptan sonra Connection: close gönderdiğine bak.

Bağlantı sayısını izle. Paylaşılan bir handler'ın MaxConnectionsPerServer'ı sınırsız; ani bir yük, hedef servise binlerce bağlantı açabilir. Hedefin kapasitesi biliniyorsa bu değeri sınırla; resilience tarafı ilerideki External Service Integration ve Resilience serisinin konusu.

Sık Düşülen Tuzaklar

HttpClient'ı using ile kısa ömürlü kullanmak. IDisposable olması onu her istekte oluşturman gerektiği anlamına gelmez; her instance bir connection pool demek.

Static bir client'ı PooledConnectionLifetime olmadan kullanmak. Socket exhaustion çözülür ama DNS değişikliklerini hiç görmeyen bağlantılar elde edersin.

TIME_WAIT'i bir hata sanmak. TIME_WAIT TCP'nin doğru çalıştığının işareti; sorun sayısının patlaması, yani gereksiz yere açılıp kapanan bağlantılar.

Loopback ölçümüyle yetinmek. Yerel makinede yeni bağlantının maliyeti milisaniyenin altında; gerçek ağda her biri en az iki RTT ve slow start.

Kültüre bağlı parse. Bu yazının demosunda double.Parse("2.5") Türkçe kültürde 25 döndü ve bağlantı ömrü 25 saniye oldu; komut satırı argümanlarını CultureInfo.InvariantCulture ile parse et.

Küçük Bir Deneme

★☆☆ Kendi makinende bir ASP.NET Core uygulamasına for döngüsüyle 200 kez new HttpClient() ile istek at ve netstat ile TIME_WAIT sayısını izle. Kaç saniye sonra sıfırlanıyor?

★★☆ Kestrel'in KeepAliveTimeout'unu 5 saniyeye indir ve istemcide PooledConnectionIdleTimeout'u 30 saniye yap. İki istek arasında 10 saniye bekleyince ne oluyor? İstemci hatayı nasıl yaşıyor ve SocketsHttpHandler bunu otomatik olarak yeniden deniyor mu?

★★★ MaxConnectionsPerServer = 2 olan bir handler ile /slow?ms=500 endpoint'ine aynı anda 10 istek at. Toplam süre ne oluyor ve neden? Aynı denemeyi HTTP/2 ile tekrarla.

Bağlantı açarken ilk iş bir adres bulmaktı ve bu yazıda HttpClient'ın DNS'i yalnızca bağlantı açarken çözdüğünü gördük. Bir sonraki yazıda DNS'in kendisine iniyoruz: resolver zinciri, record tipleri, TTL (Time To Live) ve dig ile sorgulamak.