GoProgramlamaKıdemli Go Geliştirici

Go'nun çalışma zamanının fazla goroutine yığın belleğini yeniden kazanma mekanizmasını değerlendirin, serbest bırakmayı tetikleyen kullanım eşiğini ve serbest bırakılan bölgelerin nihai kaderini belirtin.

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

Sorunun cevabı.

Sorunun tarihi

Go 1.3'ten önce, çalışma zamanı, işlev çağrı sınırlarında bağlı parçalar halinde bölünen segmentli yığınlar kullanıyordu. Bu tasarım, yığın sınırı sık sık geçildiğinde ciddi "sıcak bölme" performans düşüşlerine neden oluyordu. Go 1.3, bu yapıyı büyüme sırasında daha büyük, tek bir sürekli bölgeye kopyalanan sürekli yığınlarla değiştirdi. Ancak, sürekli yığınların erken uygulamaları, yığın belleği yeniden yığın bellek havuzuna hiç serbest bırakmadı ve bu da başlangıç veya toplu işleme sırasında geçici olarak derin çağrı yığınları gerektiren goroutine’ler için kalıcı RSS büyümesine neden oldu. Go 1.5, goroutine yığınları için bellek yönetimi yaşam döngüsünü tamamlayarak, kullanılmayan yığın belleğini çöp toplama döngüleri sırasında geri kazanmak için otomatik yığın küçültme işlevi tanıttı.

Sorun

Küçültme mekanizması olmadan, geçici olarak derin tekrar eden bir goroutine (örneğin, derinlemesine iç içe geçmiş bir JSON belgesini işleme veya karmaşık bir bağımlılık ağacını yürütme) en yüksek yığın tahsisatını idame ettirerek, boş bir olay döngüsüne geri döndüğünde bile sonsuza kadar tutardı. Bu, özellikle yığınları yüksek yığınlı görevler ile boş durumlar arasında değişen işçi havuzları kullanan uzun süreli uygulamalarda bellek şişmesine yol açar. Meydana gelen zorluk, bir yığının gerçekten kullanılmadığını güvenli bir şekilde belirlemek ve aktif çerçeveleri yığın tabanlı işleme, yığın tahsis edilmiş işaretçileri veya çağrı kuralları için ABI gereksinimlerini ihlal etmeden daha küçük bir bellek alanına taşımaktır.

Çözüm

Go çalışma zamanı, kök setlerini tarama sırasında, GC işaretleme aşamasında yığınları küçültür. Her goroutine'in yığın kullanımını inceler; eğer kullanılan bölümün en yüksek su seviyesi(25%) mevcut yığın boyutunun dörtte biri altına düşerse, çalışma zamanı mevcut yığının boyutunun yarısı kadar yeni bir yığın tahsis eder (ancak 2KB'lik minimumdan daha küçük olamaz). Daha sonra çalışma zamanı hedef goroutine’i güvenli bir noktada asenkron olarak durdurur, canlı yığın çerçevelerini yeni daha küçük alanına kopyalar, yığın adreslerini referans alan tüm iç işaretçileri güncellemek için derleyici tarafından üretilen işaretçi haritalarını kullanır ve eski yığın belleğini çalışma zamanının mheap tahsisatçısına geri serbest bırakır.

Hayattan bir durum

Derinlemesine iç içe geçmiş (hatalı giriş saldırıları sırasında 10.000 seviyeye kadar) JSON yüklemelerinin ayrıştırılması ile her goroutine’in işlemesi gereken yüksek verimlilikte bir günlük işleme hizmeti yürüttük. İşlemeden sonra, bu goroutine’ler yeni bağlantıları beklemek için bir sync.Pool'a geri döndü. Hizmetin RSS belleğinin havuzlanan goroutine sayısıyla lineer bir şekilde arttığını gözlemledik, hatta boş dönemlerde bile bellek serbest bırakılmadı ve bu eninde sonunda 4GB sınırları olan konteynerleri OOM öldürmelerine neden oldu, oysa gerçek çalışma seti yalnızca 200MB idi.

Havuzlanan goroutine’leri belirli sayıda işlenmiş istekten sonra zorla öldürmeyi ve taze değiştiriciler başlatmayı düşündük. Bu, yeni goroutine’lerin başlangıçta minimum 2KB yığınlarıyla başlaması nedeniyle yığın belleğinin serbest bırakılmasını garanti edecekti. Ancak bu yaklaşım, sürekli goroutine oluşturma ve yok etme nedeniyle önemli CPU yükü getirdi, TCP bağlantı havuzlama optimizasyonlarını bozdu ve önbellek soğuk başlangıçları nedeniyle daha yüksek gecikme uçları yarattı.

Yığın büyümesini debug.SetMaxStack kullanarak katı bir limit ile uygulamak, derin tekrar eden olaylar sırasında aşırı tahsisatın önlenmesini sağlardı. Bu OOM'dan korusa da, meşru ama derin ayrıştırma görevlerinin runtime: goroutine stack exceeds 1000000000-byte limit hatası ile panik yapmasına neden oldu. Bu, müşteri verilerinin düşmesine ve hizmet hatalarına yol açarak güvenilirlik SLA’larımızı ihlal etti, bu nedenle üretimde kabul edilemez hale geldi.

Yığın taraması ve küçülmesini zorlamak için her 30 saniyede bir runtime.GC() ardından debug.FreeOSMemory() çağırmayı değerlendirdik. Bu, RSS'yi başarılı bir şekilde azalttı ama her çağrıda 5-10 ms’lik durdurma-gelişme süreleri getirdi ve bu da API katmanı için <2ms olan p99 gecikme gereksinimlerimizi ihlal etti; ayrıca zorunlu tam koleksiyonlar nedeniyle CPU kullanımını %15 artırdı.

Sonunda, Go'nun yerel yığın küçültme mekanizmasına güvenerek, Go 1.20+ sürümünü çalıştırdığımızdan emin olduk ve çöp toplama sıklığını artırmak için GOGC’yi 100 yerine 50 olarak ayarladık. Bu, manuel müdahale gerektirmeden yığın küçültme fırsatlarının sıklığını artırdı. Ayrıca ayrıştırıcıyı, yol izleme için açıkça yığın tahsis edilmiş bir yığınla birlikte yinelemeli bir yaklaşımla yeniden yapılandırdık ve maksimum tekrar derinliğini 10.000’den 100’e düşürdük. Bu kombinasyon, belleği sınırlı tutmak için doğal küçülmenin yeterince sık gerçekleşmesini sağladı.

Hizmet RSS'si yük altında yaklaşık 800MB’da stabilize oldu, önceki 3.8GB sınırından düştü. Goroutine yığın profilleri, havuzlanan işçilerin %95’inin istekler arasında minimum 2KB yığın boyutunu koruduğunu ve yalnızca aktif ayrıştırma sırasında artışların gerçekleştiğini gösterdi. OOM öldürmelerinin tamamı sona erdi ve p99 gecikmesi 1.5ms'in altında kaldı, çünkü manuel GC duraklamalarından ve goroutine dönüşümünden kaçındık.

Adayların sıkça kaçırdığı noktalar

Bir işlev geri döndüğünde ve yığın işaretçisi azaldığında yığın küçültmesi hemen mi gerçekleşir?

Hayır, çalışma zamanı yığın işaretçisi azalmalarını izleyerek anında serbest bırakmayı tetiklemez. Küçültme, yalnızca çizelge tüm goroutine yığınlarını tararken çöp toplama işaretleme aşamasında gerçekleştirilir. Çalışma zamanı, son GC'den bu yana yığın kullanımının en yüksek su seviyesini kontrol eder. Eğer bu en yüksek su seviyesi, mevcut fiziksel tahste %25'in altındaysa, yalnızca o zaman küçültme mantığı çalışır. Bu tembel değerlendirme, tüm goroutine'lerin kopyalama maliyetini işaretleme sırasında dünya zaten duraklatıldığında amortiye eder, ancak gerçek kopyalama, bireysel goroutine'i durdurmayı gerektirir.

Kesin küçültme oranı ve minimum boyut nedir, çalışma zamanı belleği OS'ye geri serbest bırakır mı?

Bir yığın küçültme gerektirdiğinde, çalışma zamanı mevcut yığının boyutunun yarısı kadar yeni bir yığın tahsis eder. Bu geometrik azaltma, bir goroutine'in bir eşik üzerinde ve altında hafif dalgalanması halinde sürekli büyümesini ve küçülmesini önler. Yeni boyut, platformun minimum yığın boyutuyla sınırlandırılmıştır, tipik olarak 64-bit sistemlerde 2KB’dir. Eski yığın bellek, doğrudan işletim sistemine değil, çalışma zamanının mheap'ine geri döner. İşletim sistemi bu fiziksel belleği yalnızca toplayıcı, yığın boşta durduğunda ve hedefi aştığında veya debug.FreeOSMemory() çağırıldığında geri alır.

Yığın küçültme sırasında goroutine durduruluyor mu, ve işaretçiler nasıl güncelleniyor?

Evet, küçültme, yığın büyümesi gibi, hedef goroutine’i güvenli bir noktada durdurmayı gerektirir. Çalışma zamanının, canlı çerçeveleri yeni bir bellek konumuna kopyalaması ve yığın tahsis edilmiş değişkenlere referans veren tüm işaretçileri güncellemesi gerekir. Derleyici, her çerçevedeki hangi kelimelerin işaretçiler olduğunu belirten işaretçi haritaları üretir. Küçültme sırasında, çalışma zamanı bu haritaları kullanarak interior işaretçileri yeni yığın adreslerine işaret etmek için bulur ve ayarlar. Bu işlem eşzamanlı değildir; kopyalama sırasında goroutine çalışamaz, ancak diğer goroutine'ler çalışmaya devam eder.