GoProgramlamaBackend Developer

**Go**'nun standart kütüphanesindeki HTTP sunucusunun, mevcut bağlantılar yavaş I/O üzerinde engellenirken yeni TCP bağlantılarını kabul etmesine olanak tanıyan spesifik tasarım kararı nedir, sınırsız OS iş parçacığı oluşturmadan?

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

Sorunun cevabı.

Go'nun net/http sunucusu, bir goroutine-bağlı-bağlantı modeli ile birlikte runtime'ın M:N zamanlama stratejisini kullanmaktadır. Sunucu bir TCP bağlantısı kabul ettiğinde, hemen o bağlantının tüm yaşam döngüsünü yönetmek için hafif bir goroutine oluşturur, bu da ana kabul döngüsünün hemen geri dönmesini ve bir sonraki bağlantıyı hemen almasını sağlar. Bu goroutineler, Go zamanlayıcısı tarafından sınırlı bir OS iş parçacığı havuzuna çoklama yapılmaktadır, bu da engelleyici I/O gerçekleştiren goroutineleri park eder ve çalıştırılabilir olanları mevcut iş parçacıklarına yeniden zamanlar. Bu mimari, sunucunun yalnızca bir avuç çekirdek iş parçacığı kullanarak yüz binlerce eşzamanlı bağlantıyı sürdürmesine olanak tanır ve geleneksel bağlantı-başına iş parçacığı sahip sunucularının bellek yükünü önler.

Hayattan bir durum

50,000 IoT cihazından eşzamanlı olarak veri alabilen bir gerçek zamanlı telemetri geçidi inşa etmemiz gerekiyordu.

Problem tanımı: İlk prototipimiz Python ile Twisted kullanarak gerekli eşzamanlılığı sağladı fakat karmaşık geri çağırma zincirleri ve derinlemesine iç içe geçmiş hata işleme nedeniyle hızlı bir şekilde bakım yapılması zor hale geldi. Kodun basitleşmesi amacıyla Java iş parçacığı-bağlantı yaklaşımını denediğimizde, işletim sisteminin iş parçacığı sınırına yaklaşık 32,000 bağlantıda ulaştık ve her iş parçacığı 1MB'den fazla sanal bellek tükettiği için JVM OutOfMemoryError: yeni yerel iş parçacığı oluşturulamıyor hatasıyla çöktü.

Dikkate alınan farklı çözümler:

Explicit durum makineleri ile Asyncio: Python'un asyncio ışın döngüsüne geçmeyi değerlendirerek coroutineler ile birlikte tek bir olay döngüsü kullanmayı düşündük. Bu, iş parçacıklarına göre bellek ayak izini önemli ölçüde azaltacaktı, ancak tüm protokol ayrıştırma mantığımızı async/await sözdizimine yeniden yazmayı gerektirecekti ve CPU yoğun işlemlerle olay döngüsünü yanlışlıkla engelleme riski getiriyordu. Asenkron sınırlar boyunca hata ayıklama yığın izlerini incelemek de geliştirme ekibimiz için son derece zorlayıcı olduğunu kanıtladı.

JVM örneklerinin yatay parçalanması: Yük dengeleyici arkasında her biri 5,000 iş parçacığı yöneten on daha küçük Java örneği çalıştırmayı düşündük. Bu yaklaşım işlem başına iş parçacıklarının sınırını aştı fakat önemli operasyonel karmaşıklıklar yarattı, ek donanım kaynakları gerektirdi ve küme genelinde paylaşılan durum ile bağlantı tutarlılığı yönetimini karmaşık hale getirdi. Bu mikro kümenin operasyonel bakım yükü, Java ile kalmanın getirdiği faydaları gölgede bıraktı.

Go'nun goroutine-bağlı-bağlantı modeli: Geçidi Go'da yeniden uygulamaya karar verdik ve standart kütüphanenin net/http ve net paketlerinden faydalandık. Sunucunun Serve yöntemi, her kabul edilen TCP bağlantısı için otomatik olarak hafif bir goroutine oluşturur ve Go runtime'ının zamanlayıcısı bu goroutineleri sınırlı bir OS iş parçacığı havuzuna şeffaf bir şekilde çoklar. Bu, yüz binlerce bağlantıya ölçeklenebilecek basit, senkron görünümlü I/O kodu yazmamızı sağladı.

Seçilen çözüm ve neden: Go uygulamasını seçtik çünkü olay odaklı sistemlerin ölçeklenebilirliğini, iş parçacığı programlamanın sadeliği ile birleştiriyordu. Runtime, zamanlama ve engellemeyen I/O karmaşıklığını otomatik olarak yönetiyor ve geliştiricilerimizin eşzamanlılık ilkeleri yerine iş mantığına odaklanmasına olanak tanıyordu. Ayrıca, goroutine'nin 2KB'lik başlangıç yığın boyutu, teorik olarak bellek bütçemiz içinde milyonlarca bağlantıyı yönetebileceğimiz anlamına geliyordu.

Sonuç: Üretim sistemi, tek bir 8 çekirdekli sunucuda 75,000 eşzamanlı kalıcı bağlantıyı başarıyla yönetti ve 4GB'dan az RAM tükettik. CPU kullanımı, I/O gecikmesini verimli bir şekilde gizleyen zamanlayıcı sayesinde %35-40 civarında sabit kaldı ve parçalanmış Java örnekleri kümesini yönetmenin operasyonel yükünü ortadan kaldırdık.

Adayların sıkça gözden kaçırdığı şeyler

Go zamanlayıcısı, binlerce goroutine aynı kanal alımında engellendiğinde nasıl bir gürültü sorunu önler?

Go zamanlayıcısı, kanallar için birinci gelen birinci gider (FIFO) bekleme kuyruğunu kullanır, semafor tarzı tümünü uyandırma yerine. Bir gönderici bir kanala yazdığında, zamanlayıcı alım kuyruğundan tam olarak bir bekleyen goroutine'yi (en uzun bekleyen) uyandırır. Bu, yalnızca bir goroutine'in değeri tüketmesini ve birden fazla goroutine'in uyanıp kilit için rekabet etmesi ve sadece birinin geri uyumasını engelleyerek gürültü problemine neden olmasını önler. Adaylar genellikle kanal işlemlerinin koşul değişkenleri gibi tüm bekleyenlere yayınlandığını yanlış varsayıyorlar.

GOMAXPROCS değerini fiziksel CPU çekirdek sayısından fazla artırmak, I/O ile sınırlı bir Go HTTP sunucusunun performansını neden kötüleştirebilir?

Go'nun zamanlayıcısı 1.14 sürümünden bu yana kesintili olsa da, çekirdeklerden (M) daha fazla OS iş parçacığı olması, çekirdek düzeyindeki bağlam değiştirme yükünü artırır. I/O ile sınırlı sunucular için, aşırı iş parçacıkları zamanlayıcının daha fazla zamanını çalışma kuyruklarını yönetmekten ve iş parçacığı devretmelerinden harcamasına ve kullanıcı kodunu çalıştırmaktan azaltmasına yol açabilir. Ayrıca, her OS iş parçacığı, (iş parçacığı yerel depolama ve çekirdek yığınları için bellek gibi) çekirdek kaynaklarını tüketir ve gereksiz paralellikten fazla ölçeklendiğinde işletim sistemine baskı yapabilir.

Go'nun net/http sunucusu, goroutine kabul oranı geçici olarak bağlantı varış oranının gerisinde kaldığında TCP SO_BACKLOG kuyruğunu nasıl yönetir?

Sunucu, çekirdek tarafından kontrol edilen dinleme geri çağırma kuyruğuna güvenmektedir (net.ListenConfig'in Backlog veya sistem varsayılanları). Eğer goroutineler yavaş oluşturulursa veya işleyiciler dinleyiciden bağlantı kabul etmekte yavaş kalırsa, çekirdek gelen SYN'leri kuyrukta bekletir. Kuyruğun dolması durumunda, çekirdek yeni bağlantıları TCP RST ile reddeder. Go'nun Accept() döngüsü kendi goroutinelerinde çalışır ve ideal olarak işleyici goroutinelerini hızlı bir şekilde başlatmalıdır. Ancak, işleyici oluşturma gecikirse (örneğin, GC duraklamaları veya ara yazılımda mutex içeriği nedeniyle), bağlantılar düşebilir. Adaylar genellikle Go'nun kullanıcı alanında bağlantı kuyruklama uygulamadığını gözden kaçırır; tamamen çekirdek kuyruklamasına dayanır ve SOMAXCONN veya ListenConfig.Backlog değerlerini ayarlamak ani yükselişlerin emilimi için kritik öneme sahiptir.