Go yarış dedektörü, veri yarışlarını çalışma zamanında tespit etmek için gerçekleşen önce ilişkisini (happens-before) kullanan ThreadSanitizer adlı dinamik analiz aracına dayanmaktadır. Her goroutine, mantıksal zamanını temsil eden bir gölge vektör saatine sahiptir; senkronizasyon nesneleri, örneğin mutexler, kanallar ve WaitGroups, onlarla etkileşime geçen son goroutine'i izleyen kendi vektör saatlerini tutar. Bir goroutine, bir mutex alırken veya bir kanaldan bir şey alırken gibi senkronizasyon olayları gerçekleştirdiğinde, çalışma zamanı nesnenin vektör saatini goroutine'in saatine birleştirir ve bir gerçekleşen öncelik ilişkisi kurar. Ardından, her bellek erişimi, önceki erişimleri kaydeden bir gölge bellek durumu ile kontrol edilir; eğer yeni bir erişim ne önceki erişimden (vektör saat karşılaştırması ile) sonra ne de aynı lokasyondaki önceki erişimle eşzamanlı değilse ve en az biri yazma işlemi ise, dedektör bir yarış rapor eder. Bu yaklaşım, olayların kısmi sıralamasını hassas bir şekilde izlediği için neredeyse sıfır yanlış pozitif elde eder, ancak önemli bir bellek aşımına (gölge bellek için 10 kat kadar) ve gerekli muhasebe nedeniyle performans düşüşüne neden olur.
Bir finansal ticaret platformu, yüksek hacimli piyasa saatlerinde ara sıra fiyat hesaplama hataları yaşadı ve birim testler tutarsız bir şekilde geçiyordu. Mühendislik ekibi, bir goroutine'in bir paylaşılan haritada fiyat tıplerini güncellediği ve diğerinin eşzamanlı olarak hareketli ortalamaları hesapladığı sırada veri yarışlarından şüphelendi. Hatanın taklit edilmesi, eşzamanlı harita erişimlerinin belirsiz zamanlaması nedeniyle normal hata ayıklama koşullarında neredeyse imkansız oldu.
Aşağıdaki kod parçası, üretimde tespit edilen sorunlu deseni gösterir:
type PriceCache struct { prices map[string]float64 } func (pc *PriceCache) Update(symbol string, price float64) { pc.prices[symbol] = price // Senkronize edilmemiş yazma işlemi } func (pc *PriceCache) Get(symbol string) float64 { return pc.prices[symbol] // Eşzamanlı senkronize edilmemiş okuma - VERİ YARIŞI }
İlk çözüm, her harita erişimi etrafında kaba bir mutex eklemeyi düşünmekti; bu güvenliği garanti etse de, profil oluşturma %40'lık bir throughput azalması öngörüyordu ve bu, gecikmeye duyarlı ticaret için kabul edilemezdi. Ayrıca, bu yaklaşım karmaşık ticaret mantığı içinde öncelik tersliği veya kilitlenme senaryoları oluşturma riski taşımaktaydı.
İkinci öneri, fiyat üreticileri ve tüketicileri arasında saf kanal tabanlı iletişim kullanarak mimarinin yeniden yapılandırılmasını içeriyordu; bu, alışılmış olmasına rağmen, iki bin satırlık kritik yol kodunun yeniden yazılmasını gerektiriyordu ve acil dağıtım penceresinde yeni hatalar getirme riski taşıyordu. Bu yeniden yapılandırma için tahmin edilen iki haftalık zaman çizelgesi, düzeltme için piyasa penceresini aşıyor ve siyasi olarak uygulanamaz hale getiriyordu.
Ekip, nihayetinde go build -race ile yeniden inşa ederek hizmeti yarış dedektörü altında çalıştırmaya karar verdi. Performansta on kat yavaşlama ve daha büyük test örnekleri gerektiren artan bellek ayak izi olmasına rağmen, dedektör hemen bir okumanın paylaşılan haritada senkronize edilmemiş bir güncellemeyle yarıştığı belirli bir satırı tespit etti. Düzeltme, doğrudan harita erişimini, okumalara koruma sağlayan ve yalnızca tıklama güncellemeleri sırasında eşzamanlı yazma kilitlerine izin veren bir sync.RWMutex ile değiştirmeyi içeriyordu, aşağıdaki gibi:
type PriceCache struct { prices map[string]float64 mu sync.RWMutex } func (pc *PriceCache) Update(symbol string, price float64) { pc.mu.Lock() pc.prices[symbol] = price pc.mu.Unlock() } func (pc *PriceCache) Get(symbol string) float64 { pc.mu.RLock() defer pc.mu.RUnlock() return pc.prices[symbol] }
Doğrulamanın ardından, üretim hizmeti orijinal throughput’unu koruyarak hesaplama hatalarını ortadan kaldırdı. Sonuç olarak, ekip, gelecekteki gerilemeleri dağıtımdan önce yakalamak için entegrasyon testlerinin tamamında yarış etkin inşaları zorunlu kıldı. Bu proaktif önlem, sonraki çeyrek boyunca üretime ulaşan üç ek yarış durumunu önledi.
Yarış dedektörü, neden 64-bit mimarileri gerektiriyor ve programın normalde kullanacağı bellekten önemli ölçüde daha fazla bellek tüketiyor?
Go yarış dedektörü, gölge bellek kullanarak her bellek konumunun tarihsel durumunu ve bu konumlara erişen goroutinelerin vektör saatlerini izler. 64-bit sistemlerde, çalışma zamanı, uygulama belleğinin her 8-baytlık kelimesi için meta verileri saklayan özel bir gölge bellek bölgesini haritalar ve bu genellikle yerel bellekte dört ila sekiz kat artışa yol açar. Bu mimari gereklilik, ThreadSanitizer'ın tasarımına dayanmaktadır ve yalnızca 64-bit mimarilerin sağladığı geniş adres alanıyla uygulanabilen sabit bellek haritalama tekniklerine dayanır; 32-bit sistemler, gerekli gölge bellek aralığını adres alanını tüketmeden karşılama kapasitesine sahip değildir.
Yarış dedektörü, sync/atomic paketinden atomik işlemlerle nasıl başa çıkar ve atomikler ile atomik olmayan erişimlerin karıştığı durumlarda hala neden yarış bildirebilir?
Yarış dedektörü, sync/atomic işlemlerini, vektör saatlerini güncelleyerek gerçekleşen önce ilişkisi (happens-before) kuran senkronizasyon ilkesizleri olarak değerlendirmektedir; ancak, aynı zamanda paylaşılan bir bellek konumuna yapılan tüm erişimlerin, izlediği gerçekleşen önce ilişkisine katılması gerektiğini katı bir şekilde zorlamaktadır. Eğer bir goroutine, atomic.StoreInt64 ile atomik bir yazma işlemi gerçekleştirirken, diğeri düz bir okuma yapıyorsa (value := variable), düz okuma bir senkronizasyon olayı olarak işlenmediği için, okuma atomik yazmadan sonra sıralandığı için tespit edilen bir yarış yaratır. Bu davranış, Go'nun bellek modelini pekiştirir; bu, atomik ve atomik olmayan işlemler arasında herhangi bir gerçekleşen önce garantisi sağlamaz; atomik kendisi güvenli olmasına rağmen, adaylar genellikle atomiklerin "koruduğunu" yanlış bir şekilde düşünmektedirler. Atomik olmayan okumaların yarış tespiti ile korunmuş olacağını düşünmektedirler.
Standart kütüphanenin, yarışları tespit etmek için -race bayrağı ile yeniden inşa edilmesi neden gereklidir ve kullanıcı kodu ile standart kütüphane (stdlib) arasındaki sınırdaki yarışlar için sonuçları nelerdir?
Yarış dedektörü, çalışma zamanı izleme fonksiyonlarına her bellek erişimi ve senkronizasyon olayı öncesinde çağrılar ekleyerek, derleme zamanı enstrümantasyonu yoluyla çalışır; Go ile dağıtılan önceden derlenmiş standart kütüphane ikilileri bu enstrümantasyondan yoksundur. Sonuç olarak, eğer bir kullanıcı goroutine'i, json.Unmarshal uygulaması içindeki bir iç harita yazımıyla yarışıyorsa, dedektör, yarışın standart kütüphane tarafını gözlemleyemez ve bu nedenle sessiz kalır. Tam kapsam elde etmek için, -race ile araç zincirini ve uygulamayı yeniden inşa etmek gerekir; tüm kod yollarının—net/http veya encoding/json gibi alanlara geçiş yapanların—enstrümante edilmesini sağlamak, aksi takdirde dedektör yalnızca kısmi garantiler sağlar ve senkronize edilmemiş kullanıcı verisinin eşzamanlı olarak erişilen standart kütüphane yapılarına akmasıyla ilgili hataları kaçırabilir.