GoProgramlamaGo Backend Developer

Go'nun ağ polaratörü, engelleyici G/Ç işlemlerinin işletim sistemi thread'lerini monoplize etmesini önlemek için goroutine zamanlayıcısı ile nasıl entegre oluyor?

Hintsage yapay zeka asistanı ile mülakatları geçin

Sorunun cevabı.

Sorunun geçmişi.

C10K problemi, 2000'lerin başındaki sunucu mimarilerine on bin eşzamanlı bağlantıyı verimli bir şekilde yönetme zorluğunu çıkardı. Geleneksel bir-thread-per-bağlantı modelleri, bağlam geçişleri ile belleği ve CPU'yu tüketiyordu. Go'nun yaratıcıları, engelleyici G/Ç kodunun netliğini korurken milyonlarca goroutine desteklemeyi amaçlıyordu; bu, goroutine beklemesini OS thread tüketiminden ayıran bir mekanizma gerektiriyordu.

Problem.

Bir goroutine engelleyici bir sistem çağrısı yaptığında—örneğin, bir ağ soketi üzerinde read()—altındaki OS thread'ini (M) sabitleme riski taşır. Müdahale olmadan, binlerce eşzamanlı bağlantı binlerce thread oluşturacak, M:N zamanlama avantajlarını ortadan kaldıracak ve sistem kaynaklarını tüketip tüketecektir.

Çözüm.

Go çalışma zamanı, doğrudan zamanlayıcıya entegre edilmiş bir ağ polaratörü (Linux'ta epoll, BSD'de kqueue ve Windows'ta IOCP kullanarak) kullanır. Bir goroutine, bir pollable tanımlayıcı üzerinde G/Ç başlattığında, çalışma zamanı onu _Gwaiting durumuna park eder ve dosya tanımlayıcısını OS-spesifik polaratöre kaydeder. Bir izleme thread'i hazır olmasını bekler; bildirim geldiğinde, polaratör goroutine'i _Grunnable durumuna geçirir ve onu mevcut bir P'ye (mantıksal işlemci) zamanlar. Bu, engelleyici işlemleri verimli park etme olaylarına dönüştürerek küçük bir GOMAXPROCS thread havuzunun büyük bir eşzamanlılık sunmasını sağlar.

// Gerçekten park eden, engellemeyen Go kodu func handleConn(conn net.Conn) { buf := make([]byte, 1024) n, err := conn.Read(buf) // Goroutine'i park eder, thread'i serbest bırakır if err != nil { log.Println(err) return } process(buf[:n]) }

Hayattan bir durum

20.000 sürekli TCP bağlantısını piyasa veri akışlarına bağlayan yüksek frekanslı bir ticaret geçidi inşa ediyorsunuz. Volatilite zirveleri sırasında, gecikmenin 100 mikro saniyenin altında kalması gerekmektedir. Java NIO yaklaşımıyla yapılan ilk testler, verim elde etti fakat karmaşık geri çağırma bakımı sorunuyla karşılaştı. Go'ya geçerken, ekip net.TCPConn kullanarak basit engelleyici kod yazdı. Ancak, 50k eşzamanlı bağlantı ile yük testinde süreç 10.000'den fazla OS thread oluşturdu, bu da OOM öldürmelerine ve gecikme garantilerinin yok olmasına neden oldu.

Çözüm A: Reaktör desenini manuel olarak yeniden uygulamak. Standart kütüphaneyi atlayın ve manuel bir epoll olay döngüsü oluşturmak için syscall sarmalayıcıları kullanın. Artıları: Bellek düzeni ve uyanma gecikmesi üzerinde maksimum kontrol. Eksileri: Go’nun artan kodlama modelinden ödün verir, platforma özgü karmaşıklık ekler ve savaşla test edilmiş çalışma zamanı kodunu tekrar eder, hata yüzeyini artırır.

Çözüm B: runtime.LockOSThread ile thread yükünü kabul etmek. Her bağlantıyı, zamanlama izolasyonunu garanti etmek için özel bir thread'e zorlayın. Artıları: Tahmin edilebilir thread bağlılığı. Eksileri: goroutines'ın temel ekonomik faydasını ihlal eder; bellek kullanımı ~8MB bağlantı başına şişer, bu da hedef ölçek için uygulanamaz hale getirir.

Çözüm C: Pollable olmayan G/Ç için denetim yapın ve netpolatöre güvenin. Engelleyici kodu tutun, ancak thread oluşturmayı zorlayan kaza sonucu engelleyici sistem çağrılarını (örneğin, dosya günlüğü veya DNS sorgulamaları) ortadan kaldırın. Artıları: Okunabilir lineer akışı sürdürür; çalışma zamanı optimizasyonlarından yararlanır; belleği ~2KB bağlantı başına azaltır. Eksileri: net.Conn işlemlerinin park olduğunu, os.File işlemlerinin ise thread'leri engellediğini derin bir anlayış gerektirir.

Ekip, Çözüm C'yi seçti ve thread patlamasının, sıcak yol üzerindeki piyasa verilerini yerel ext4 dosyalarına senkronize biçimde günlüğe kaydetmenin sonucunda meydana geldiğini fark etti. Düzenli dosya G/Ç işlemleri netpolatörü kullanamaz (Unix'te dosyalar her zaman "hazır" görünür), bu nedenle her günlük yazımı bir OS thread'ini engelledi. Ağ G/Ç'sini (pollable olan) ana goroutine'lerde tutarak bir kanal tamponu ile async bir dosya yazıcısı goroutine'i kullanacak şekilde yeniden yapılandırdılar.

Geçit artık sadece 16 OS thread ile 50.000 bağlantıyı sürdürüyor (bu, GOMAXPROCS ile eşleşiyor) ve ~85µs P99 gecikmesi sağlıyor. Bellek tüketimi 40GB (projeksiyon thread yığınları) den ~180MB toplam RSS'ye düştü.

Adayların Sıklıkla Göz Ardı Ettiği Noktalar

os.Stdin veya bir normal dosyadan okumanın neden bir OS thread'ini engellediği ve bunun CLI araçları eşzamanlılığını nasıl etkilediği nedir?

TCP soketleri, epoll aracılığıyla asenkron hazır olma bildirimlerini desteklerken, normal dosyalar ve borular Unix sistemlerinde G/Ç için her zaman "hazır" olarak bildirilir; çekirdek, dosya veri bulunabilirliği için engelleyici bir arayüz sağlamaz. Sonuç olarak, bir goroutine os.File.Read çağrısı yaptığında, Go çalışma zamanı onu park edemez—engelleyici sistem çağrısına ayrılmış bir gerçek OS thread tahsis etmek zorundadır. Girdi dosyası başına goroutine başlatan CLI araçlarında (örneğin, günlük işleyicileri), bu klasik iş parçacığı modellerine benzer şekilde thread kaçaklarına neden olur. Çözüm, veya semaforları kullanarak eşzamanlı dosya işlemlerini sınırlamak veya belirli işçi havuzları ile tamponlama yapmaktır.

Şebeke bölünmesi iyileştiğinde, netpolatör aynı anda binlerce goroutine'i nasıl uyandırır ve "gürültü yığılması" nasıl önlenir?

netpolatör (aracılığıyla epoll_wait) binlerce hazır tanımlayıcı döndüğünde, netpoll işlevi goroutineleri tüm P'lere (mantıksal işlemciler) global çalışma kuyrukları ve iş çalma algoritmaları kullanarak dağıtır, hepsini tek bir P'ye yerleştirmek yerine. Ayrıca, zamanlayıcı adalet kontrolleri uygular: her 10ms'lik yürütmeden sonra, iş parçacığı-bazlı görevlerin onları aç bırakmasını önlemek için çalıştırılabilir G/Ç goroutineleri için kontrol eder. Adaylar genellikle bağlantı başına FIFO kuyruklaması varsayarlar, zamanlayıcının, uyanma olaylarını yayarak ve önceden kesilme noktaları uygulayarak verimliliği dağıttığını atlarlar.

SetReadDeadline ile aktif Read çağrısı arasında hangi yarış durumu vardır ve zamanlayıcı tekerleği uygulamasının netpolatör ile atomik senkronizasyon gerektirmesi neden gereklidir?

netpolatör, G/Ç son tarihlerinin yönetimi için her P-ye özel bir zamanlayıcı tekerleği veya min-heap kullanır. goroutine A, goroutine B Read içinde engellendiği sırada SetReadDeadline çağrısı yaptığında, A, B'nin park durumunun bağlı olduğu zamana göre zamanlayıcıyı değiştirir. Atomik güncellemeler olmadan (içsel mutex'ler tarafından korunan net.conn içinde), zamanlayıcıyı eski son tarihin gözlemlenmesi ve yeni bir tarih ayarlanması durumunda bir yarış oluşabilir, bu da bir kaçırmayı (belirsiz bir takılma) veya yanlış bir zaman aşımını doğurabilir. Atomiklik, olduğundan önceki tutarlılığı sağlar: ya güncellenmiş son tarih epoll bekleme döngüsü tarafından gözlemlenir ya da önceki zamanlayıcı çalışır, ama asla son tarih sözleşmesini ihlal eden bir belirsiz ara durum olmaz.