Viltrum 1.x: Motoru Çöpe Atıp Yeniden Yazdım
Temmuz'da Viltrum'u ilk yazdığımda şunu demiştim: "HTTP/2 yok. TLS yok. İyi ki yok; odak dağılmasın."
Üç ay geçti. Şu an v1.5'teyiz. TLS var, WebSocket var, statik dosya ve upload var. HTTP/2 hâlâ yok, o konuda sözümü tuttum.
Ama asıl hikaye bu özellikler değil. Arada motoru bir kere komple çöpe attım.
0.x neydi
0.x serisi Temmuz'dan Ekim'e kadar sürdü. Her bağlantıya bir spawn, kendi HTTP/1.1 parse'ım, hijack ile upgrade, WebSocket, mbedtls üstünde TLS (sertifikayı SIGHUP ile yeniden yükleyen), form, ufak bir HTTP client. v0.13'te hot path'i epey sıktım.
Sonra baktım: ne kadar sıksam da model aynı. Bağlantı başına spawn, istek başına allocation. Üstüne kat çıkmak yerine temeli değiştirmek gerekiyordu.
15 Ekim'de 1.0'ı çıkardım ve CHANGELOG'a şunu yazdım: TLS, WebSocket, statik dosya, form ve client 1.0'da yok. Hepsini sildim. Kullanıcı açısından geri adım gibi görünüyordu, öyleydi de. Ama yeni çekirdeğe taşınmamış hiçbir şeyi tutmak istemedim.
1.0'ın tek kuralı
Çekirdek ilk günden bir kuralla yazıldı: framework içinde istek başına sıfır allocation. Ve bu kural bir niyet cümlesi değil, CI'da bir test:
// The acceptance gate of the whole rewrite: once buffers have grown to their working
// size, serving a request allocates nothing on the GC heap.
fn test_hot_path_allocates_nothing() {
mut w := new_worker(make_router(), Options{}, 0)
mut c := new_conn()
for name, raw in shapes {
req := raw.bytes()
for _ in 0 .. 200 {
run(mut w, mut c, req)
}
before := http1.allocated_bytes()
for _ in 0 .. 10000 {
run(mut w, mut c, req)
}
assert http1.allocated_bytes() - before == 0, '${name} allocates'
}
}
200 istekle buffer'lar ısınıyor, sonraki 10.000 istekte GC heap'i bir bayt bile büyürse test kırmızı. Her PR bu kapıdan geçiyor.
Bunu mümkün kılan birkaç karar var:
- Her CPU'ya bir worker. Her birinin kendi
SO_REUSEPORTlistener'ı ve kendi epoll döngüsü var. Bağlantı hangi worker'a düştüyse ömrü boyunca orada kalıyor. İstek yolunda worker'lar birbirini beklemiyor. - Bağlantı başına sabit buffer. 8 KiB okuma, 16 KiB yazma. Büyük mesajda büyüyor, bağlantı kapanınca küçülüyor.
- Request string'leri kopya değil, view.
c.path(),c.param(),c.body()bağlantının buffer'ını gösteriyor ve handler dönene kadar geçerli. Saklamak istiyorsan.clone(). - Pipelining tek send. Bir okumada kaç tam istek geldiyse hepsine cevap veriliyor, cevaplar tek
sendile gidiyor.
Kapının göremediği iki hata
Testi yazdım, yeşil oldu, rahatladım. Fazla erken rahatlamışım.
Birincisi: V'nin GC pin'leri. V 0.5.2 -prod ve Boehm GC ile derlerken, scope'ta pointer tutan by-value bir struct ya da array varken yapılan her fonksiyon çağrısının etrafına bir tarama koyuyor. Pointer sayısı 32'yi geçince bir root listesi calloc'luyor ve GC'nin global kilidini alıyor. Allocation kapısı bunu göremiyor, çünkü teknik olarak heap büyümüyor. Ama her worker aynı kilitte sıraya giriyor.
JSON encoder 40 elemanlı bir sayfada bunu eleman başına bir kere yapıyordu. 8 thread'de istek başına 400 µs CPU, tek thread'de 50 µs. Düzeltince 12 thread'lik bir Ryzen 5 3600'da json-comp 19,9 bin istek/sn'den 51,6 bine çıktı. Şimdi CI'da scripts/gcpin_check.sh var, hot path'te bir pin görürse build'i düşürüyor.
İkincisi: fd başına Conn. fd numaraları process genelinde. SO_REUSEPORT ile her worker herhangi bir fd numarasını alabiliyor. Her worker gördüğü her fd numarası için bir Conn saklıyordu. Bağlantı sürekli açılıp kapanınca her worker neredeyse her fd için bir Conn ayırıyor, her biri 24 KiB buffer'la. 64 worker'da bu gigabaytlar ediyor ve her allocation yine GC kilidini alıyor.
HttpArena'nın short-lived profilinde (bağlantı başına 10 istek) 158 bin istek/sn, %373 CPU ve 2,2 GiB bellek ölçtüler. Worker başına bir free list ile 8 çekirdekli sunucumda aynı senaryo 223 bin istek/sn ve 233 MiB'ye indi. Bugün o profil HttpArena'da 2,70 milyon istek/sn.
İkisinden çıkardığım ders: "allocation yok" testi gerekli ama yetmiyor. Kilit ve bellek davranışını ayrıca ölçmen gerekiyor.
Bekleyen cevaplar: timer'lar
1.1'de c.after(ms, cb, arg) geldi. Handler hemen dönüyor, worker'ı tutmuyor; ms milisaniye sonra aynı worker'da cb cevabı yazıyor. Altında worker başına bir min-heap var. Epoll döngüsü bir sonraki timer'a kadar bekliyor.
Bu hafta burada güzel bir hata buldum. epoll_wait timeout'u tam milisaniye alıyor. Ben de erken ateşlememek için kalan süreyi yukarı yuvarlıyordum. 0,2 ms sonra cevaplanması gereken istek 1 ms sonra gidiyordu. Yükte bu birikiyor: /delay/10 isteğinde 10 ms'nin üstüne 1,0 ile 1,7 ms ekleniyordu.
Düzeltme epoll_pwait2. Linux 5.11'den beri var ve timeout'u nanosaniye cinsinden alıyor. Kernel desteklemezse ya da seccomp reddederse eski yola dönüyor. Kendi sunucumda fazlalık 0,3-0,4 ms'ye indi, istek/sn %7-13 arttı. Bedeli istek başına %15-25 daha fazla CPU, çünkü timer'lar artık daha küçük gruplarla ateşleniyor. Bu değişiklik v1.5.1'de çıktı.
TLS: el sıkışma mbedtls'te, kayıtlar bende
Viltrum'un bir kuralı var: üçüncü parti bağımlılık yok. Vendor'lanmış C kodu da yok. Sadece V'nin kendisiyle gelen şeyler. TLS bu yüzden V'nin içinde gelen mbedtls 3.6 üstünde.
1.2'de TLS aynı epoll worker'larına geldi. Non-blocking el sıkışma, WANT_READ / WANT_WRITE epoll'a çevriliyor, sertifika dosyası değişince (SIGHUP yok, dosyaya bakarak) yeniden yükleniyor, yarım yazılmış bir sertifika çifti kullanımdakinin yerine asla geçmiyor.
Sonra profil çıkardım: 10 KB'lık TLS echo'da CPU'nun üçte ikisi mbedtls'in GCM'indeydi. mbedtls bir kaydı cipher katmanından 16 baytlık bloklarla geçiriyor.
1.3.2'de TLS 1.3 el sıkışması bitince kayıtları ben yapmaya başladım: engine/aesgcm.h içinde AES-NI ve PCLMULQDQ ile AES-GCM. Intel'in yayınladığı yöntemler, kod benim. Sekiz counter bloğu yan yana, GHASH sekiz blokta tek reduction, tag'ler şifre çözülmeden önce sabit zamanda kontrol ediliyor. Doğruluk için GCM spesifikasyonunun test vektörleri ve 1200 rastgele girdide mbedtls ile karşılaştırma var.
El sıkışma, TLS 1.2, ChaCha20 ve AES-NI olmayan CPU'lar mbedtls'te kaldı.
Sonuç: 10 KB echo başına CPU 283-357 µs'den 135-171 µs'ye indi.
gzip: kendi DEFLATE'im
json-comp profilinde gzip, profilin yarısıydı. V'nin içinde gelen miniz streaming bir sıkıştırıcı: her çağrıda yaklaşık 97 KB'lık pencere ve hash durumunu temizliyor, girdiyi bir ring buffer'dan geçiriyor. 1 KB'lık bir JSON için bu çok fazla hazırlık.
libdeflate eklemek masaya geldi. Kural belliydi: bağımlılık yok. O zaman libdeflate'in fikirlerini kendim yazarım.
http1/deflate.v gövdeyi olduğu yerde sıkıştırıyor. 64 KB'a kadar parçalar, parçaya göre boyutlanmış hash tablosu, tek lazy adımlı hash zincirleri, parça başına en küçük çıkan blok tipi (dynamic, fixed ya da stored), slice-by-8 CRC-32. Durum worker başına bir kere ayrılıyor, çağrı başına sıfır allocation.
Arama derinliğini json-comp'un puan formülüne göre ayarladım. HttpArena orada istek/sn'yi (en küçük boyut / benim boyutum)² ile çarpıyor, yani her bayt önemli. Profilin üç JSON gövdesinde gövde başına 52 µs, miniz level 6'da 126 µs. Boyut 1263 B'ye 1232 B, yani biraz daha büyük ama çok daha hızlı.
WebSocket ve topic'ler
1.3'te WebSocket döndü: app.ws(pattern, handler), RFC 6455, aynı worker'larda, ws:// ve wss://. Mesajlar okuma buffer'ına view olarak geliyor, echo yolunda allocation yok.
Topic'ler (subscribe / publish) için her worker'a bir inbox koydum: mutex arkasında bir kuyruk ve epoll setinde bir eventfd. Publish mesajı bir kere kopyalayıp her worker'ın kuyruğuna bırakıyor, her worker kendi abonelerine yazıyor. Bağlantının buffer'ları kendi worker'ından hiç çıkmıyor.
Ne yok
- HTTP/2. Karar bekliyor. Önce h2c, sonra TLS üstünde ALPN ile h2 olur, ama henüz başlamadım.
- HTTP client. 0.x'te vardı, 1.x çekirdeğine taşınmadı.
- Linux dışında serve. Motor epoll. macOS ve Windows'ta testler ve örnekler CI'da derleniyor,
handle_rawile handler test edebiliyorsun, amalistenhata dönüyor. recovergerçek panic yakalamıyor. V panic'i yakalayamıyor, process çıkıyor. systemdRestart=alwaysgibi bir supervisor arkasında çalıştır.
Dene
git clone https://github.com/Tuntii/viltrum.git && cd viltrum
bash scripts/install.sh
v run examples/hello
# başka terminal
curl http://127.0.0.1:8080/
Repo: github.com/Tuntii/viltrum. Yol haritası ROADMAP.md'de, neyi yapmayacağımız da orada yazıyor.
Temmuz'daki yazının sonunda "beğenmezsen issue aç" demiştim. Hâlâ geçerli. Bir de şunu ekleyeyim: motoru yeniden yazmak korkutucu görünüyor ama korkutucu olan şey testsiz yazmak. Kapı varken silmek kolay.
Bir sonraki yazıda bu rakamların nereden geldiğini anlatacağım: HttpArena, gürültülü bir VM'de A/B ölçmek ve dün kaybettiğim madalyalar.