Go'nun zamanlayıcısı, işletim sistemi müdahalesi olmadan aç kalmayı önlemek için hibrit bir işbirlikçi ve öncelikli çoklu görev modeli kullanır. 1.14 sürümünden itibaren, çalışma zamanı zaman dilimlerini aşan goroutine'leri çalıştıran thread'lere SIGURG sinyalleri göndererek asenkron önceden kesme noktaları enjekte eder (genellikle 10ms). Sinyal işleyici güvenli bir nokta tespit ettiğinde - örneğin goroutine bir fonksiyonu çağırmak veya yığına erişmek üzere olduğunda - zamanlayıcı durumu kaydeder ve başka bir çalıştırılabilir goroutine'e geçer. Bu mekanizma, işlev çağrıları olmayan sıkı CPU'ya bağımlı döngülerin bir İşlemci (P)'yi sonsuza kadar tek başına kullanmasını engeller.
Yüksek frekanslı ticaret platformumuz, piyasa dalgalanmaları sırasında felaket aktivite gecikmeleri yaşadı; burada karmaşık Monte Carlo simülasyonları gerçekleştiren tek bir analitik goroutine, sipariş işleme hatlarını yüzlerce milisaniye boyunca donduruyordu. Problem, goroutine'in hiçbir fonksiyon çağrısı yapmadan sıkı bir matematik döngüsü çalıştırmasından kaynaklanıyordu, bu da zamanlayıcının onu önceden kesmesini engelliyordu Go 1.14 öncesinde.
Bu çekişmeyi çözmek için üç farklı yaklaşım değerlendirdik. İlk seçenek, simülasyon döngülerine manuel olarak runtime.Gosched() çağrıları eklemekti. Bu yaklaşım hızlı bir hafifletme sağladı ancak önemli bakım yükü getirdi ve geliştiricilerin derin bir zamanlayıcı bilgisine sahip olmasını gerektirdi, bu da, yeniden yapılandırıldığında geri gidebilecek kırılgan bir kod oluşturdu.
İkinci çözüm, analitik iş yükünü CPU limitleri olan ayrı bir mikro hizmete izole etmekti. Bu, katı bir izolasyon ve bağımsız ölçekleme sağlasa da, ağ serileştirme yükü ve süreçler arası iletişimin ek gecikmesi, risk hesaplamaları için milisaniye altı gecikme gereksinimlerimizi ihlal ediyordu.
Sonunda, çalışma zamanını Go 1.20'ye yükseltmeyi ve GOMAXPROCS'u fiziksel CPU çekirdeklerine eşleyecek şekilde açıkça ayarlamayı seçtik. Bu yükseltme, sinyaller aracılığıyla asenkron önceden kesme sağladı ve zamanlayıcının kod modifikasyonları olmadan her 10ms'de CPU'ya bağımlı goroutine'i zorla bırakmasına izin verdi. Dağıtım sonrası metrikler, P99 gecikmesinin yoğun yük altında 8ms'de stabilize olduğunu gösterdi, zaman aşımı zincirleme etkilerini ortadan kaldırarak tek süreç mimarisinin sadeliğini korudu.
Fonksiyon çağrısı olmayan sıkı bir döngü, eski Go sürümlerinde neden planlama sorunlarına neden olurken, yeni sürümlerde neden böyle olmuyor?
Go 1.14'ten önce, zamanlayıcı yalnızca işbirlikçi önceden kesmeye güveniyordu; bu da goroutine'lerin yalnızca fonksiyon çağrıları, kanal işlemleri veya mutex çatışması durumunda gönüllü olarak temlik verdikleri anlamına geliyordu. Saf aritmetik işlemler gerçekleştiren sıkı bir döngü hiçbir güvenli noktaya ulaşmadı ve tamamlanmaya kadar İşlemci (P)'sini etkin bir şekilde tek başına kullanmaya devam etti. Modern Go, sinyalleri göndererek asenkron önceden kesme kullanıyor SIGURG, bir fonksiyon çağrısı olup olmadığına bakılmaksızın bir sonraki güvenli noktada bağlam değişikliğini tetikler.
Bir İşlemci (P) mevcut olduğunda zamanlayıcı, hangi goroutine'in sıradaki çalıştığını nasıl belirler?
Zamanlayıcı, önce mevcut P'nin yerel çalıştırma kuyruğunu kontrol eden bir iş çalma algoritması uygular, ardından diğer P'nin yerel kuyruğundan yarısını çalmaya çalışır ve rekabeti azaltmak için rastgele bir başlangıç indeksine başvurur. Yerel kuyruklar boşsa, yeni oluşturulan goroutine'lerin aç kalmaması için her 61 zamanlayıcı vuruşunda küresel çalıştırma kuyruğunu kontrol eder. Bu hiyerarşik seçim, senkronizasyon maliyetlerini en aza indirirken tüm mevcut Makine (M) thread'leri arasında yük dengelemesini sağlar.
Bir goroutine, dosya girişi gibi engelleyici bir sistem çağrısı gerçekleştirirken İşlemci (P) ne olur?
Bir goroutine bir sistem çağrısında engellenirse, Go çalışma zamanı derhal Makine (M) thread'ini P'sinden ayırır ve bu P'yi yeni veya boş bir M'ye atar; bu da diğer goroutine'lerin aynı işletim sistemi thread soyutlaması üzerinde çalışmaya devam etmesini sağlar. Orijinal M sistem çağrısına girer ve işlemin tamamlanması için çekirdeği bekler; döndüğünde, orijinal P'sini yeniden edinmeye çalışır veya P şimdi farklı bir thread'e bağlıysa kendini kenara çekmektedir. Bu M:N çoklama, I/O sırasında işletim sistemi thread'lerinin boşta kalmasını önleyerek binlerce goroutine arasında yüksek CPU kullanımını sürdürür.