Go'nun ARM64 derleyicisinde bir hatanın nasıl keşfedildiği
(blog.cloudflare.com)- Cloudflare, arm64 platformunda çalışan Go derleyicisinde ortaya çıkan nadir bir yarış durumu (race condition) hatasını büyük ölçekli trafik izleme sırasında keşfetti
- Bu hata, stack unwinding sürecinde servislerin beklenmedik şekilde panic durumuna girmesi veya bellek erişim hataları oluşması şeklinde ortaya çıktı
- Kök neden analizi sırasında, Go runtime'ın asenkron preemption'ı (zorunlu kesme) ile derleyicinin ürettiği iki stack pointer ayarlama komutu arasında sorun oluştuğu doğrulandı
- Minimal yeniden üretim koduyla bu hatanın Go runtime'ın kendisindeki bir sorun olduğu kanıtlandı ve bunun stack pointer'ın eksik biçimde değiştirildiği tek komutluk bir yarış penceresi yarattığı ortaya kondu
- İlgili sorun go1.23.12, go1.24.6, go1.25.0 sürümlerinde yamalandı; yeni yaklaşım, anında güvenli şekilde değiştirilemeyen stack pointer işlemlerini kaçınarak yarış durumunu temelden engelliyor
Cloudflare'in bulduğu Go ARM64 derleyici hatasının analizi
Cloudflare'in veri merkezleri dünya genelinde 330'dan fazla şehirde saniyede 84 milyon HTTP isteğini işliyor; bu kadar büyük trafik ortamları, çok nadir hataların bile sıkça görünür hale gelmesi gibi bir özelliğe sahip. Bu yazı, arm64 platformunda Go derleyicisinin ürettiği kodda ortaya çıkan yarış durumu sorununu gerçek bir vaka üzerinden ayrıntılı biçimde inceliyor.
Garip panic davranışının incelenmesi
- Cloudflare ağı içinde Magic Transit ve Magic WAN gibi ürünlerin trafiğini kernel üzerinde yapılandıran servisler çalışıyor
- arm64 makinelerde nadir ama tekrarlayan fatal panic mesajları izleme sistemi tarafından tespit edildi
- İlk analizde, stack unwinding sırasında bütünlük ihlali saptandı (panic/recover desenini kullanan eski kodlarda panic'ler sık görülüyordu)
- Geçici olarak panic/recover yapısı kaldırılarak panic sıklığı azaltıldı, ancak daha sonra şüpheli fatal panic'ler daha sık görülmeye başladı
- Bunun üzerine, basit desen takibinin ötesinde daha derin bir kök neden analizine ihtiyaç olduğu düşünüldü
Go runtime ve scheduler veri yapıları özeti
- Go, hafif kullanıcı alanı scheduler'ı ile M:N scheduling yapısını kullanır (çok sayıda goroutine'in az sayıdaki kernel thread'ine eşlenmesi)
- Scheduler'ın temel yapıları
g (goroutine),m (machine/kernel thread),p (processor)etrafında şekillenir - Stack unwinding başarısızlığı veya bellek erişim hataları, genellikle stack pointer'ın ya da return address'in anormal biçimde değişmesi durumunda ortaya çıkar
Stack unwinding sırasındaki hatanın yapısal nedeni
- Birden çok backtrace analizi, olayların hepsinin
(*unwinder).nextfonksiyonundaki stack unwinding sürecinde oluştuğunu gösterdi - Bir vakada return address null olduğu için anormal stack kabul edilip fatal hata ile sonlandırıldı; başka bir vakada ise stack frame içindeki Go scheduler yapısı
m'nin alanına (incgo) erişilirken segmentation fault oluştu - Çökme, gerçek hatanın meydana geldiği noktadan oldukça uzakta gerçekleştiği için kök nedeni izlemek zordu
Gözlenen örüntüler ve Go Netlink kütüphanesi bağlantısı
- Stack trace'ler incelendiğinde, çökme olaylarının tamamının Go Netlink kütüphanesindeki
NetlinkSocket.Receivefonksiyonunda preemption gerçekleştiği anlarda yoğunlaştığı görüldü - Bunun ardından iki hipotez oluşturuldu
- Sorunun, Go Netlink'in unsafe.Pointer kullanımından kaynaklanan bir hata olması
- Sorunun, Go runtime'ın asenkron preemption ve stack unwinding mekanizmasında yer alması
- Kod denetimi yapıldı ancak doğrudan bellek bozulması gibi bir iz bulunamadı; bu nedenle sorunun merkezinde runtime ve stack yönetim stratejisinin olduğu düşünüldü
Asenkron preemption ve yarış durumu
- Go 1.14 ile gelen asenkron preemption özelliği, uzun süre çalışan goroutine'ler için OS thread'ine sinyal (
SIGURG) göndererek zorla scheduling noktası oluşturur - Bu preemption, stack frame pointer'ını ayarlayan iki assembly komutu arasına denk gelirse, stack pointer ara durumda kalabilir
- Garbage collection, panic işleme veya stack trace üretimi için stack unwinding yapılırken yanlış konum okunur ve hatalı fonksiyon adresleri ya da veriler yorumlanabilir
Minimal yeniden üretim kodunun hazırlanması
- Stack frame ayırma boyutu ayarlanarak, stack'in açıkça değiştirildiği bir fonksiyon (
big_stack) ve sürekli garbage collection çağıran kod yazılarak yarış durumu yeniden üretildi - Gerçekte assembly kodunda stack pointer'ın iki ADD komutuyla ayarlandığı ve bu ikisinin arasında asenkron preemption olduğunda stack unwinding sırasında çökme yaşandığı görüldü
- Bu kusur yalnızca standart kütüphane koduyla bile yeniden üretilebildi; böylece bunun Go derleyicisinin ürettiği kodda gömülü, tek instruction ölçeğinde bir zafiyet olduğu kanıtlandı
ARM64 derleyici seviyesindeki yarış penceresinin nedeni
- ARM64 mimarisindeki sabit uzunluklu komutlar ve immediate değer sınırlamaları nedeniyle stack pointer ayarlaması için iki veya daha fazla komut gerekebilir
- Go'nun iç intermediate representation'ı (IR), bu immediate uzunluğunu bilmez; bölünmüş komutlar yalnızca gerçek makine koduna dönüştürülürken eklenir
- Bu nedenle stack frame geri verme işlemi (
ADD RSP, RSP) iki komutla yapılır ve preemption'a açık tek instruction'lık bir pencere oluşur - Unwinder, stack pointer'ın doğruluğuna mutlak olarak ihtiyaç duyar; komutların ortasında durulursa yanlış değer yorumlaması ve fatal başarısızlık ortaya çıkabilir
- Gerçek çökme akışı şu şekilde gelişir:
- İki
ADDkomutu arasında asenkron preemption meydana gelir - GC veya başka bir nedenle stack unwinding rutini çalışır
- Olağandışı stack pointer konumu izlenir ve hatalı fonksiyon adresi yorumlanır
- Runtime çöker
- İki
Hata düzeltmesi ve temel iyileştirme
- Cloudflare ekibi, minimal yeniden üretim kodu ve ayrıntılı analizle birlikte sorunu Go'nun resmi deposuna bildirdi; sorun hızlıca yamalanıp yayımlandı
- go1.23.12, go1.24.6, go1.25.0 ve sonrasında, önce geçici bir register içinde tam offset hesaplanıyor, ardından stack pointer tek komutla değiştiriliyor; böylece preemption kaynaklı zafiyet ortadan kaldırılıyor
- Artık stack pointer her zaman geçerli durumda kaldığı için yarış durumu yapısal olarak engellenmiş oluyor
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET
Sonuç ve çıkarımlar
- Bu hata, belirli bir mimarideki derleyici kod üretimi ile eşzamanlılık yönetiminin (asenkron preemption) beklenmedik biçimde çakıştığı bir örnek
- Yalnızca büyük ölçekli ortamlarda ortaya çıkan son derece nadir bir instruction seviyesinde yarış durumunun, gerçek saha verileri ve sistematik çıkarımla izlenebilmiş olması dikkat çekici
- Güncel Go ortamı ve ARM64 mimarisi tabanlı servisler çalıştırıyorsanız, ilgili Go sürümlerine yükseltme yapmak önemli
1 yorum
Hacker News görüşleri
pushya dapopile ayarlamayı da düşündüm, ancak GC’nin tam olarak neyi kontrol ettiğini bilmediğim için emin değilim. Başkalarının görüşlerini duymak isterimLDR Rd, =expradlı sözde komutu kullanılır. Doğrudan oluşturulamayan sabitler için, PC-relative bir konuma sabit yerleştirilir ve PC temel alınarak register’a yüklenir. Bu sayede “SP’ye sabit ekleme” işlemi 2 yürütülen komuta indirgenebilir ve toplamda 8 bayt kod ile 4 bayt veri alanı (17 bitlik sabit için), yani 12 bayt gerekir. İlgili belge: LDR pseudo-instruction açıklamasıaddile SP’yi atomik biçimde ayarlaması gibi görünüyor. Elbette bu bir ek komut demek, ama atomiklik sağlanmış olur. Alternatif olarak, hesabı geçici bir register’da yapıp sonra geri taşımak da mümkünbackportanahtar kelimesini mi kontrol ettiğini merak ettim. İlgili yorum: github issue commentsscanfbug’ını yakalamıştım; o sırada eğlenceli değildi ama sorunu tam olarak saptayıp regression test de yazdıktan sonra gerçekten çok tatmin ediciydiUnsafebenzeri şeyleri hiç kullanmadan segfault alıyorsam, bunu genelde sorunun benim kodumda olmayabileceğine dair bir işaret olarak görüyorum