2 puan yazan GN⁺ 2025-10-09 | 1 yorum | WhatsApp'ta paylaş
  • 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).next fonksiyonundaki 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.Receive fonksiyonunda 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:
    1. İki ADD komutu arasında asenkron preemption meydana gelir
    2. GC veya başka bir nedenle stack unwinding rutini çalışır
    3. Olağandışı stack pointer konumu izlenir ve hatalı fonksiyon adresi yorumlanır
    4. Runtime çöker

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

 
GN⁺ 2025-10-09
Hacker News görüşleri
  • Bunun gerçekten müthiş bir keşif olduğunu düşündüm ve assembly kodunu görür görmez hata ayıklama yolunu takip etmeye başladım; aslında bu yaklaşım yalnızca assembly’de mümkün değil, IR aşamasında da yapılabilir ama çeşitli nedenlerle öyle yapılmıyor. ARM assembly okuyabiliyor olmak büyük bir avantaj. Komut sayısını azaltmak için stack boyutunu push ya da pop ile 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 isterim
    • Genelde ARM’nin LDR Rd, =expr adlı 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ı
    • Immediate değeri RSP’ye ekleme gibi bu özel durumda, assembler’da bu bug’ın özel olarak ele alınmamış olması şaşırtıcı. Eğer yama yalnızca compiler tarafına uygulandıysa, aarch64 assembly’nin başka yerlerinde de aynı sorun kalmış olabilir
    • ARM assembly sözdizimindeki dolar işaretli bu garip ifade, standart AArch64 assembly değil. Yazıda ayrıca “stack yalnızca bir kez hareket ettirilmeli” kuralından da bahsedilse iyi olurdu
    • Java ya da .NET gibi runtime’larda safepoint’ler açıkça tanımlanır; böylece komut dizisinin ortasında context değişimi yaşanması önlenir
    • Doğru çözüm, compiler’ın sabiti iki parçada bir register’a yükleyip ardından tek bir add ile 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ün
  • Acele edenler için düzeltme commit bağlantısını paylaşıyorum: golang/go commit bağlantısı
    • Issue’ya bakınca, Go ekibinin doğal dil botu mu kullandığını, yoksa yorumlarda yalnızca backport anahtar kelimesini mi kontrol ettiğini merak ettim. İlgili yorum: github issue comment
  • Teknik açıdan son derece mükemmel bir blog yazısı; anlatım o kadar net ki anlaması kolay, hatta insan kendini daha akıllanmış hissediyor. x86 assembly’den sonra uzun zaman sonra yeniden assembly’ye baktım ama takip etmesi kolaydı. Ayrıca böyle bir ekibin bu tür sorunları çözebilecek yetkinliğe ve kalite kontrolüne sahip olduğuna dair güven de veriyor. Sunucu ölçeklendirmesi için Ampere Altra’yı da düşünmüştüm, ama alan sıkıntımız olmadığı için sonunda Epyc kullandık
  • Go’da tüm komutları single-step çalıştırıp her komutta GC interrupt üreten bir mod olsaydı, bu tür bug’ları bulmak daha kolay olurdu diye düşünüyorum
  • ARM64 sunucuların nerede kullanıldığını merak ediyorum. Geçen yıl AMD EPYC tabanlı Gen 12 sunucular çıkarılacağından bahsedilmişti ama ARM64’ten söz edilmemişti; şu anda ARM64 prodüksiyonda kullanılıyor
    • Ben Cloudflare çalışanı değilim ama bloglarını çok okuduğum için bildiğim kadarıyla, secure boot gibi konular da düşünülerek birkaç yıldır Ampere’i AMD ile birlikte konuşlandırıyorlardı. Operasyonel amaç muhtemelen edge verimliliği, ama başka kullanım alanları da olabilir. Daha fazla bilgi için edge sunucu tasarımı yazısı, Ampere Altra vs AWS Graviton2 ve Qualcomm ARM değerlendirmesi yazılarına bakılabilir
    • Cloudflare’ın bazı non-edge iş yüklerini public cloud’da barındırdığına dair bir şey hatırlıyorum; örneğin control plane gibi. O da olabilir
  • Son zamanlarda Cloudflare’ın %100 Rust ve x86(EPYC) kullandığını sanıyordum; Go ve ARM kullandıklarını görmek ilginç
  • Cloudflare blog yazılarının her zaman altyapı ya da ML sihri olmadan mühendisliğin özünü aktaran harika içerikler olduğunu düşünüyorum. Bir gün buraya başvurmak isterim. Compiler bug’ları sanıldığından daha yaygındır (eskiden gcc’de her yıl birkaç tane bulurdum), ama yazıdaki gibi çoğu büyük ölçekte ancak ortaya çıkan nadir vakalardır. Çoğu kişi o ölçeğe hiç ulaşmaz
    • Bugün neden başvurmuyorsun, merak ettim
  • Stack pointer’ın her zaman atomik olarak ayarlanması gerektiğini vurguluyor
    • Preemption’ı yazanlar muhtemelen x86’i temel almıştı (burada komut sabiti içinde taşıyabildiği için işlem atomik gerçekleşiyor) ve ARM portu sırasında yüksek seviyede otomatik bölünme yaşandığı için böyle bir bug ortaya çıktı. Kimsenin suçu değil ama iyi bir sonuç da değil
    • Benim de aklıma gelen ilk şey buydu
  • Makine thread’inin iki komutun arasında nasıl durdurulabildiğini pek anlayamadım. Bare metal’de bunun mümkün olup olmadığını merak ediyorum
    • go, GC bildirimi için interrupt kullanıyor
    • signals
  • “Oldukça eğlenceli bir problemdi” ifadesi hakkında, böyle temel bir sorunu çözmek elbette ferahlatıcı olmuştur ama çözülmemiş haldeyken hiç de eğlenceli olmadığını düşünüyorum. Bu tür bug’lar insanın bütün zihnini tüketen deneyimler oluyor. Standard library ya da compiler’ın sorunlu olabileceğini kimse düşünmediği için, geliştirici kültürü insanı sürekli kendi kodundan şüphe etmeye itiyor. Ben de bir kez standard library bug’ı bulmuştum; sorunun SDK tarafında olabileceğinden en son şüpheleniyorsun. Bu yüzden tamamen alakasız yerlerde zaman harcıyorsun. Üstelik bu örnekteki gibi bir race condition varsa yeniden üretmesi de zor oluyor; tam kayboldu sanıyorsun, sonra tekrar ortaya çıkıyor
    • Bu yorum, kendi benzer deneyimini eklerken bir yandan da yazarın aldığı keyfe itiraz ederek gereksiz yere etkisini azaltmış gibi hissettiriyor. İnsanlar farklı şeyleri eğlenceli bulabilir
    • Bazı insanlar, başkalarının eziyet sayacağı bu tür son derece sıra dışı hata ayıklama süreçlerinden gerçekten keyif alır. Birinin hayal kırıklığı, bir başkasının zevki olabilir
    • Bence yazarın kastettiği “eğlenceli (funny)” değil, “tatmin edici (satisfying)” idi. Ben de bir kez teslim tarihine yetişmeye çalışırken Ubuntu GCC ARM toolchain içindeki sscanf bug’ı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 ediciydi
    • Derin bir kusuru çözmek, çözüldüğü anda muazzam bir rahatlama veriyor. Compiler ya da CPU kaynaklı bug’ları çözdüğümde en büyük keyfi aldığım zamanlar çok oldu
    • Managed dillerde Unsafe benzeri şeyleri hiç kullanmadan segfault alıyorsam, bunu genelde sorunun benim kodumda olmayabileceğine dair bir işaret olarak görüyorum