GoProgramlamaKıdemli Go Backend Mühendisi

Go'nun çalışma zamanının zamanlayıcı işleme mekanizmasını, global zamanlayıcı kilit darboğazını ortadan kaldırmak için per-P yığınları arasında nasıl dağıttığını belirtin ve eşzamanlı zamanlayıcı modifikasyonlarını ele alan kilitsiz algoritmayı tanımlayın.

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

Sorunun Cevabı

Tarihçe: Go 1.14'ten önce, runtime merkezi bir kilitle korunan tek bir global zamanlayıcı yığını tutuyordu. Zamanlayıcı oluşturan veya değiştiren tüm goroutine'ler bu kilit için rekabet ediyordu ve bu, binlerce eşzamanlı bağlantıyı yöneten yüksek verimli ağ sunucularında ciddi bir ölçeklenebilirlik darboğazı oluşturuyordu.

Sorun: Çekirdek sayıları arttıkça, global zamanlayıcı kilidi bir serileştirme noktası haline geldi. Bir goroutinue time.AfterFunc çağırdığında veya mevcut bir zamanlayıcıyı değiştirdiğinde, global kilidi almak, 4-yığın yapısını güncellemek ve muhtemelen özel zamanlayıcı iş parçacığını uyandırmak zorundaydı. Bu serileştirilmiş erişim, zamanlayıcı işlemlerinin CPU çekirdekleri ile yatay olarak ölçeklenmesini engelleyerek yük altında kuyruk gecikmesini kötüleştirdi.

Çözüm: Go 1.14, zamanlayıcı sistemini per-P (işlemci) zamanlayıcı yığınları kullanacak şekilde yeniden tasarladı. Her mantıksal işlemci, kendi 64-yığını (4-yığın varyantı) zamanlayıcılar ile yönetir. Bir zamanlayıcı oluşturulduğunda veya sıfırlandığında, runtime zamanlayıcının durum kelimesi üzerinde atomik karşılaştır ve değiştir (compare-and-swap) işlemleri kullanarak kilitsiz bir algoritma yürütür (zamanlayıcılar runtime.timer yapıları ile temsil edilir). Eğer bir zamanlayıcı, sahibinden farklı bir P tarafından değiştirilirse, runtime, onu yığınlar arasında bloğa almadan taşımak için atomik bir güncelleme kullanır. timerproc artık planlayıcının findRunnable döngüsüne entegre edilmiştir, bu da her P'nin küresel senkronizasyonsuz yerel yığınını taramasına olanak tanır.

// Zamanlayıcı değişikliğinin kavramsal temsilidir func resetTimer(t *timer, when int64) { // Atomik kullanarak kilitsiz durum geçişi for { old := atomic.Load(&t.status) if old == timerWaiting || old == timerRunning { // Atomik olarak çalmaya ya da güncellemeye çalışın if atomic.CompareAndSwap(&t.status, old, timerModifying) { t.when = when // Yerel P'nin yığınında yeniden dengeleme yapın atomic.Store(&t.status, timerWaiting) break } } } }

Hayattan Bir Durum

Sorun Tanımı: Go ile yazılmış yüksek frekanslı bir ticaret geçidi, piyasa açılırken 10ms'yi aşan gecikme zirveleri yaşadı, düşük CPU kullanımı olmasına rağmen. Profil oluşturma, tüm mutex rekabetinin %40'ının runtime.timer işlemlerinden kaynaklandığını, özellikle SetReadDeadline kullanılarak bağlantı okuma son tarihlerinin uzatılması konusunda çıktığını ortaya koydu. Operasyon ekibi başlangıçta ağ gecikmesinden şüphelendi, ancak Go'nun yürütme izleyicisi global zamanlayıcı kilidini suçlu olarak belirledi.

Düşünülen Farklı Çözümler:

Bir yaklaşım, standart kütüphane dışında bir kullanıcı alanı zamanlama tekerleği uygulamaktı. Bu, zamanlayıcıları zaman aşımına göre ayrıştırılmış kovalar içerisine yerleştirerek, sabit boyutlu bir dairesel tampon kullanıyordu. Bu runtime kilit rekabetini ortadan kaldırsa da, önemli bir karmaşıklık getirdi: ticaret ekibinin tekerleğin ilerlemesi için ayrı bir goroutine sürdürmesi, uzun zaman aşım süreleri için taşma kollarını yönetmesi ve runtime'ın garantileri olmadan bellek emniyetini sağlaması gerekecekti. Ayrıca, tekerleğin hassasiyeti alt milisaniye ticaret gereksinimleri için yetersizdi ve uygulama bakım yükü riskini artırıyordu.

Başka bir önerilen çözüm de, time.Timer nesnelerini saldırgan bir şekilde havuzlayıp yeniden kullanmaktı. Bu, GC baskısını azaltmasına rağmen, Reset() veya Stop() çağrılarında global zamanlayıcı kilidindeki temel rekabeti gidermedi. Ekip ayrıca, zaman aşımında bağlantının hemen sonlandırılmasını gerektiren borsa düzenlemelerine uyum sağlamadığından time.Ticker kullanarak toplu son tarih kontrolleri yapmayı da düşündü.

Seçilen Çözüm ve Sonuç: Ekip, Go 1.15'e (per-P zamanlayıcı iyileştirmelerini içeren) geçiş yaptı ve doğrudan SetReadDeadline çağrılarını özelleştirilmiş bir bağlantı sarmalayıcı ile değiştirdi; bu sarmalayıcı, mutlak son tarihleri resetlemek yerine time.AfterFunc geri çağrıları ile son tarih uzatmalarını yönetti. Bu değişiklik, zamanlayıcı girişlerini mevcut tüm Ps arasında dağıtarak mutex rekabetini ihmal edilebilir seviyelere düşürdü. Sonuç, pik ticaret hacminde p99 gecikmesinde %95'lik bir azalmaydı (12ms'den 0.6ms'ye), geçidin 100.000 eşzamanlı bağlantıyı planlayıcı bozulması olmadan yönetmesini sağladı.

Adayların Sıkça Gözden Kaçırdığı Noktalar

Çalışma zamanı, bir goroutine'in Ps'ler arasında taşınması durumunda zamanlayıcı geçişini nasıl yönetir ve zamanlayıcılar basitçe goroutine'i takip edemez mi?

Zamanlayıcılar, oluşturuldukları veya son sıfırlama yapıldıkları P'ye bağlıdır, goroutine'e değil. Bir goroutine iş çalması sırasında Ps arasında taşındığında, zamanlayıcı, her bağlam geçişinde atomik aşırı yükten kaçınmak için orijinal P'nin yığınında kalır. Eğer zamanlayıcı ateş ederse, runtime ilişkili goroutine'in artık farklı bir P'de çalıştığını görür ve geri çağrıyı o P'nin çalışma kuyruğuna ekler. Bu ayrım hayati öneme sahiptir çünkü zamanlayıcı yığınlarının yığın-invariant bakımını gerektirmesi; zamanlayıcıların goroutine'ler ile göç etmesine izin vermek, her çalıntı sırasında kaynak ve hedef P zamanlayıcı yığınlarını kilitlemeyi gerektirir ve bu, per-P tasarımının ortadan kaldırdığı rekabeti yeniden getirir.

Dört durumlu atomik durum makinesinin (timerIdle, timerWaiting, timerRunning, timerModifying) zamanlayıcı uygulamasındaki hangi özel yarış durumu gereklidir?

Durum makinesi, bir zamanlayıcının, geri çağrısı çalışmadan önce yürütme için seçildikten hemen sonra daha sonra bir zamana sıfırlandığı "kayıp uyanma" yarışını önler. Atomik durumlar olmadan, P A bir zamanlayıcıyı yığından seçebilir (bunu çalışır olarak işaretleyerek), bu sırada P B onu sıfırlayabilir. Dört durum, bir Reset işleminin timerModifying veya timerRunning durumunu görmesini ve zamanlayıcıyı değiştirmek için güvenli olana kadar döngüye girmesini sağlar. Adaylar çoğu zaman timerModifying'in durum değişiklikleri sırasında geçici bir spin-kilit işlevi gördüğünü, böylece geri çağrının eski verilerle yürütülmesini veya tamamen atlanmasını önlediğini gözden kaçırır.

Çalışma zamanı neden zamanlayıcılar için standart bir ikili yığın yerine 64-yığın yapısını sürdürür ve bu durum önbellek satırı optimizasyonu ile nasıl ilişkilidir?

64-yığın (4-yığın) ağaç derinliğini yaklaşık olarak log₄(n) seviyesine kadar azaltır, bu da sift-up ve sift-down işlemleri sırasında işaretçi takiplerini ve önbellek kaçırmalarını en aza indirir. Standart bir ikili yığında her karşılaştırma, iki çocuğun yüklenmesini gerektirir (potansiyel olarak iki önbellek satırı); 4-yığın, dört çocuğu bir kerede yükler ve modern x86_64 mimarilerinde bir 64 baytlık önbellek satırına sığar. Bu yapı kasıtlı bir uzlaşmadır: her seviyede karşılaştırma sayısını artırsa da, önbellek kaçırmalarını önemli ölçüde azaltır. Bu, her P için binlerce zamanlayıcı yönetilirken zamanlayıcı yığını işlemlerinin gecikmesini yönetir.