HTTP/2: Binary Framing, Multiplexing, HPACK ve Kestrel'de HTTP/2
HTTP/2 semantiği değiştirmez; mesajları stream'lere ait binary frame'lere bölüp tek TCP bağlantısında iç içe taşır, header'ları HPACK ile sıkıştırır. curl ile pseudo-header'lar ve h2c, 20 eşzamanlı istekte 20 bağlantıya karşı tek bağlantı, Kestrel'in 100 stream limiti, HttpClient'ın varsayılan 1.1'i ve Server Push yerine 103 Early Hints.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10 Kestrel, Linux container'ında) ile, istekler curl 8.7.1 (nghttp2 1.69) ile denenmiştir. HTTP (Hypertext Transfer Protocol) sürüm 2 anlatımı RFC (Request for Comments) 9113, header sıkıştırma RFC 7541 (HPACK) esas alır.
Önce Tahmin Et
Bookstore'un sipariş servisi bir sayfa için katalog servisine aynı anda 20 istek atıyor ve her biri sunucuda 300 milisaniye sürüyor. HttpClient ile, TLS (Transport Layer Security) üzerinden. Üç senaryo var: istemcinin bağlantı sayısı sınırsız ve HTTP/1.1; tarayıcılar gibi host başına 6 bağlantı ve HTTP/1.1; ve HTTP/2. Her senaryoda kaç TCP (Transmission Control Protocol) bağlantısı açılır ve 20 isteğin hepsi kaç milisaniyede biter?
Bir de şu: HTTP/2'de istek sayısını 200'e çıkarsan süre değişir mi? Tek bağlantıda sınırsız istek gönderilebiliyorsa değişmemesi gerekir.
Neden HTTP/2: Binary Framing ve Stream'ler
Önceki iki yazıda HTTP/1.1'in üç sınırını gördük. Bir bağlantı aynı anda tek bir cevap taşıyabiliyor; tarayıcılar bu yüzden host başına 6 bağlantı açıyor ve kalan istekleri sıraya koyuyor. Cevaplar sırayla gelmek zorunda olduğu için yavaş bir cevap arkasındakileri bekletiyor (head-of-line blocking). Ve her istek User-Agent, Cookie, Accept gibi aynı header'ları metin olarak tekrar tekrar taşıyor.
HTTP/2 semantiği değiştirmez; method'lar, status code'lar ve header'ların anlamı HTTP (Hypertext Transfer Protocol) Semantics'te (RFC 9110) aynı kalır. Değişen kablodaki biçim. Mesajlar metin satırları yerine binary frame'lere bölünür. Her frame 9 byte'lık bir başlıkla başlar: uzunluk, tip, flag'ler ve hangi stream'e ait olduğunu söyleyen 31 bitlik stream identifier. Stream, bir istek ve onun cevabından oluşan mantıksal bir kanal; istemcinin açtığı stream'ler tek sayılardır (1, 3, 5…). Farklı stream'lerin frame'leri aynı TCP bağlantısında karışık gidebilir ve karşı taraf onları stream id'sine göre yeniden birleştirir:

HEADERS ve DATA dışında bağlantıyı yöneten frame'ler var: SETTINGS (iki tarafın limitlerini bildirdiği ilk frame), WINDOW_UPDATE (flow control), RST_STREAM (tek bir stream'i iptal etmek, bağlantıyı kapatmadan), PING ve GOAWAY (bağlantıyı düzgünce kapatmak). Bu sayede tek bir isteği iptal etmek, HTTP/1.1'deki gibi bütün bağlantıyı koparmayı gerektirmez.
curl ile HTTP/2'yi Görmek
TLS üzerinde HTTP/2, beşinci yazıda gördüğümüz ALPN (Application-Layer Protocol Negotiation) ile seçilir; ek bir tur gerekmez:
$ curl -sv --http2 --cacert certs/ca.crt https://127.0.0.1:5592/whoami
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / AEAD-AES256-GCM-SHA384 / [blank] / UNDEF
* ALPN: server accepted h2
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://127.0.0.1:5592/whoami
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: 127.0.0.1:5592]
* [HTTP/2] [1] [:path: /whoami]
* [HTTP/2] [1] [user-agent: curl/8.7.1]
* [HTTP/2] [1] [accept: */*]
> GET /whoami HTTP/2
> Host: 127.0.0.1:5592
> User-Agent: curl/8.7.1
> Accept: */*
>
< HTTP/2 200
< content-type: text/plain; charset=utf-8
< date: Sun, 08 Nov 2026 10:37:13 GMT
< server: Kestrel
< content-length: 6
[1] stream numarası. HTTP/1.1'in start line'ı HTTP/2'de pseudo-header'lara dönüşmüş: :method, :scheme, :authority (eski Host) ve :path. curl'ün > satırları okunabilirlik için HTTP/1.1 biçiminde gösteriliyor; kabloda giden şey HPACK ile sıkıştırılmış bir HEADERS frame'i. Header adlarının küçük harfle gelmesi de HTTP/2'nin kuralı.
TLS'siz HTTP/2'ye h2c (HTTP/2 cleartext) denir. Tarayıcılar h2c'yi desteklemez; servisler arası trafikte kullanılır ve istemcinin sunucunun HTTP/2 konuştuğunu önceden bilmesi gerekir (prior knowledge). Sunucumda yalnızca HTTP/2 konuşan 5493 ve yalnızca HTTP/1.1 konuşan 5491 port'ları var:
$ curl -s --http2-prior-knowledge -w '\n%{http_version}\n' http://127.0.0.1:5493/whoami
blue
2
$ curl -sS --http2-prior-knowledge http://127.0.0.1:5491/whoami
curl: (16) Remote peer returned unexpected data while we expected SETTINGS frame. Perhaps, peer does not support HTTP/2 properly.
İkinci komutta curl bağlantının başında HTTP/2 preface'ini ve SETTINGS frame'ini gönderdi, sunucu ise HTTP/1.1 bir hata cevabıyla döndü.
Multiplexing'i Ölçmek
Sunucuda 500 milisaniye bekleyen bir endpoint'e curl ile 6 paralel istek attım, önce HTTP/1.1 sonra HTTP/2 ile. Endpoint, isteğin geldiği bağlantının Kestrel'deki kimliğini dönüyor:
$ U='https://127.0.0.1:5592/conn?delay=500'
$ /usr/bin/time -p curl -s --http1.1 --parallel --parallel-max 6 --cacert certs/ca.crt "$U" "$U" "$U" "$U" "$U" "$U"
{"id":"0HNPTGGU6A21L","remotePort":55680,"protocol":"HTTP/1.1"}{"id":"0HNPTGGU6A21M","remotePort":55678,"protocol":"HTTP/1.1"}...
real 0.54
$ /usr/bin/time -p curl -s --http2 --parallel --parallel-max 6 --cacert certs/ca.crt "$U" "$U" "$U" "$U" "$U" "$U"
{"id":"0HNPTGGU6A21Q","remotePort":55748,"protocol":"HTTP/2"}{"id":"0HNPTGGU6A21Q","remotePort":55748,"protocol":"HTTP/2"}...
real 0.52
Süre aynı ama yol farklı: HTTP/1.1'de altı ayrı bağlantı (altı ayrı kaynak port'u, altı TCP ve altı TLS handshake'i), HTTP/2'de tek bağlantı. Tahmin sorusunu .NET ile ölçmek için eşzamanlı istekler atıp kaç farklı bağlantının cevap verdiğini sayan bir demo yazdım:
// N eşzamanlı istek atar ve bunlara kaç bağlantının hizmet ettiğini raporlar
public static async Task RunAsync(string url, int count, Version version, int maxConnections, string caPath, bool multipleHttp2 = false)
{
var handler = LocalCaTrust.CreateHandler(caPath);
handler.MaxConnectionsPerServer = maxConnections;
handler.EnableMultipleHttp2Connections = multipleHttp2;
using var client = new HttpClient(handler)
{
DefaultRequestVersion = version,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
var stopwatch = Stopwatch.StartNew();
var results = await Task.WhenAll(Enumerable.Range(0, count).Select(_ => client.GetFromJsonAsync<ConnectionInfo>(url)));
var protocols = string.Join(",", results.Select(r => r!.Protocol).Distinct());
var connections = results.Select(r => r!.Id).Distinct().Count();
Console.WriteLine($"exact {version}, maxConnections={maxConnections,-10} multipleHttp2={multipleHttp2,-5} -> protocol={protocols} connections={connections} elapsed={stopwatch.ElapsedMilliseconds} ms");
}
exact 1.1, maxConnections=2147483647 multipleHttp2=False -> protocol=HTTP/1.1 connections=20 elapsed=380 ms
exact 1.1, maxConnections=6 multipleHttp2=False -> protocol=HTTP/1.1 connections=6 elapsed=1278 ms
exact 2.0, maxConnections=2147483647 multipleHttp2=False -> protocol=HTTP/2 connections=1 elapsed=364 ms
exact 2.0, maxConnections=2147483647 multipleHttp2=False -> protocol=HTTP/2 connections=1 elapsed=663 ms
exact 2.0, maxConnections=2147483647 multipleHttp2=True -> protocol=HTTP/2 connections=2 elapsed=376 ms
Tahmin bölümünün cevapları satır satır: sınırsız HTTP/1.1 20 bağlantı açtı ve 380 milisaniyede bitti; bağlantı başına bir istek. 6 bağlantıyla sınırlanınca istekler dört dalgada (6 + 6 + 6 + 2) işlendi: 1.278 milisaniye, tarayıcının HTTP/1.1'de yaşadığı tam olarak bu. HTTP/2 tek bağlantıyla 364 milisaniye. Dördüncü satır ikinci sorunun cevabı: 200 istek 663 milisaniye sürdü, yani iki dalga. Kestrel'in kaynak kodunda Http2Limits.MaxStreamsPerConnection 100; sunucu bunu SETTINGS frame'iyle bildiriyor ve istemci 101'inci isteği bir stream boşalana kadar bekletiyor. EnableMultipleHttp2Connections = true verince istemci limit dolduğunda ikinci bir HTTP/2 bağlantısı açtı ve süre 376 milisaniyeye indi. gRPC gibi tek bir servise yoğun eşzamanlı çağrı yapan istemciler için bu ayar önemli.
HPACK, Flow Control ve Priority
HPACK (RFC 7541) header'ları üç yolla küçültür. Sık kullanılan 61 header adı ve değeri (:method: GET, accept-encoding: gzip, deflate gibi) her iki tarafın bildiği bir static table'da durur ve tek bir index'le gönderilir. Bağlantı boyunca gönderilen header'lar iki tarafın ortak tuttuğu bir dynamic table'a eklenir; aynı User-Agent veya Cookie ikinci istekten itibaren yine yalnızca bir index'tir. Geri kalan string'ler Huffman kodlamasıyla sıkıştırılır. HTTP/1.1'de her istekte yüzlerce byte tutan header'lar, HTTP/2'de çoğu zaman birkaç byte'a iner. Gzip yerine özel bir format seçilmesinin sebebi güvenlik: sıkıştırılmış ve şifrelenmiş veride uzunluk değişimlerinden sır çıkaran CRIME (Compression Ratio Info-leak Made Easy) saldırısı.
Flow control, TCP'nin flow control'ünün stream başına bir kopyası: her stream'in ve bütün bağlantının bir penceresi var ve alıcı WINDOW_UPDATE ile pencereyi açar. Böylece büyük bir indirme, aynı bağlantıdaki küçük isteklerin önünü tıkamaz. Kestrel'in varsayılanları kaynak kodda bağlantı için 1 MB, stream için 768 KB.
HTTP/2'nin ilk sürümündeki (RFC 7540) stream bağımlılık ağacına dayalı priority şeması karmaşıktı ve tutarsız uygulandı; RFC 9113 onu deprecate etti. Yerine RFC 9218'in extensible priorities şeması geldi: istemci Priority header'ı ile bir urgency (u=0…7) ve artımlı işlenebilirlik (i) bildiriyor. Aynı şema HTTP/3'te de kullanılıyor.
Server Push'un Sonu ve 103 Early Hints
HTTP/2 bir de server push getirmişti: sunucu, istemci istemeden önce ihtiyaç duyacağını düşündüğü CSS (Cascading Style Sheets) ve JavaScript dosyalarını gönderebiliyordu. Pratikte işe yaramadı; sunucu istemcinin cache'inde ne olduğunu bilmediği için çoğu zaman zaten var olan dosyaları itiyordu. Chrome 106'da server push varsayılan olarak kapatıldı ve Chromium tabanlı tarayıcılar takip etti. Önerilen alternatif 103 Early Hints: sunucu asıl cevabı hazırlarken önce bir 103 cevabı ve içinde Link: </css/site.css>; rel=preload gibi header'lar gönderir; tarayıcı dosyayı kendi cache'ine bakarak ister ya da istemez.
.NET: HttpClient ve Kestrel'de HTTP/2
Üçüncü yazıda okuduğumuz varsayılanları hatırla:
HttpClient.DefaultRequestVersion : 1.1
HttpClient.DefaultVersionPolicy : RequestVersionOrLower
HttpClient varsayılan olarak HTTP/1.1 ister ve daha düşüğüne razı olur; yani sunucu HTTP/2 destekliyor olsa bile HTTP/1.1 konuşur. HTTP/2 için client'ta DefaultRequestVersion = HttpVersion.Version20 vermen gerekir. Politika burada önemli: RequestVersionOrHigher ile 1.1 isteyip TLS üzerinden bağlanan bir client, ALPN sayesinde HTTP/2'ye yükselir; demoda HTTP/1.1'i zorlamak için RequestVersionExact kullanmamın sebebi bu. Şifresiz (h2c) bir adreste ALPN olmadığı için HTTP/2 yalnızca Version20 ve RequestVersionExact ile, yani prior knowledge ile kullanılabilir.
Kestrel'de bir endpoint'in varsayılan protokolü, kaynak koddaki ListenOptions.DefaultHttpProtocols sabitine göre Http1AndHttp2. TLS'li bir endpoint'te seçim ALPN ile yapılır. TLS'siz bir Http1AndHttp2 endpoint'i ise pratikte yalnızca HTTP/1.1 konuşur, çünkü hangi protokolün kullanılacağını söyleyecek bir handshake yok; h2c için endpoint'i HttpProtocols.Http2 olarak ayrıca açmak gerekir. Demo sunucumdaki 5493 bu yüzden ayrı bir port. gRPC tamamen HTTP/2'ye dayandığı için, servisler arası gRPC trafiğini TLS'siz çalıştırıyorsan bu ayrım doğrudan işini etkiler; gRPC'nin kendisi ilerideki gRPC ve GraphQL serisinde.
Üretimde Dikkat
HTTP/2, TCP'nin head-of-line blocking'ini çözmez. Stream'ler HTTP katmanında bağımsız ama hepsi aynı TCP byte akışında; tek bir paket kaybolunca TCP, kayıp paket yeniden gelene kadar arkasındaki bütün byte'ları bekletir ve o anda bütün stream'ler durur. Kayıplı mobil ağlarda HTTP/2'nin tek bağlantısı, HTTP/1.1'in altı bağlantısından kötü performans gösterebilir. Bu sorunun çözümü transport'u değiştirmek; bir sonraki yazının konusu.
HTTP/1.1 dönemi optimizasyonlarını gözden geçir. Domain sharding (dosyaları static1, static2 gibi alt alan adlarına dağıtıp 6 bağlantı limitini aşmak) HTTP/2'de ters teper: her alan adı ayrı bir DNS (Domain Name System) sorgusu, ayrı TCP ve TLS handshake'i ve ayrı bir congestion window demek. Aşırı bundling de aynı şekilde: tek dev bir JavaScript dosyası, küçük bir değişiklikte bütün cache'i geçersiz kılar; HTTP/2'de daha küçük ve ayrı cache'lenen parçalar çoğu zaman daha iyidir.
Servisler arası HTTP/2'de bağlantı başına stream limitini hesaba kat. Yukarıdaki ölçümde gördüğün gibi 100'ü aşan eşzamanlılıkta istekler kuyruğa girer; EnableMultipleHttp2Connections veya sunucuda MaxStreamsPerConnection ayarı bilinçli seçilmeli.
Sık Düşülen Tuzaklar
HttpClient'ın HTTP/2'yi kendiliğinden kullandığını sanmak. Varsayılan 1.1; sunucunun HTTP/2 desteği tek başına yetmez.
TLS'siz bir Http1AndHttp2 endpoint'inin h2c konuşacağını varsaymak. ALPN olmadan HTTP/2 seçilemez; h2c için yalnızca HTTP/2 konuşan bir endpoint gerekir.
Server push'a yatırım yapmak. Büyük tarayıcılarda kapalı; 103 Early Hints ve preload kullan.
HTTP/2'yi bütün head-of-line blocking'in ilacı sanmak. HTTP katmanındakini çözer, TCP katmanındakini çözmez.
Tek bağlantının sınırsız olduğunu düşünmek. Sunucunun stream limiti var; Kestrel'de 100.
Küçük Bir Deneme
★☆☆ Tarayıcında DevTools Network panelini aç, Protocol kolonunu ekle ve sık kullandığın birkaç sitede h2, h3 ve http/1.1 oranlarına bak.
★★☆ Kestrel'de Limits.Http2.MaxStreamsPerConnection'ı 10'a indir ve bu yazıdaki multiplex demosunu 50 istekle çalıştır. Süre nasıl değişiyor? EnableMultipleHttp2Connections açıkken kaç bağlantı açılıyor?
★★★ Bir IHttpClientFactory typed client'ını HTTP/2 kullanacak şekilde yapılandır (DefaultRequestVersion, DefaultVersionPolicy) ve bir integration testiyle cevabın Version'ının 2.0 olduğunu doğrula.
HTTP/2 uygulama katmanındaki sırayı çözdü ama TCP'nin sırasını değiştiremedi. Bir sonraki yazıda TCP'yi tamamen bırakan HTTP/3'e ve UDP (User Datagram Protocol) üzerinde çalışan QUIC'e (TCP'nin yerini alan transport protokolü) geçiyoruz.