Tarihçe Go 1.19'dan önce, çalışma zamanı yalnızca çöp toplama kontrolü için GOGC sunuyordu, bu ise yığın tetikleyicisini canlı bellekle orantılı olarak ölçeklendiriyordu. Bu, cgroups'un mutlak bellek sınırları koyduğu kapsayıcı dağıtımları için yetersiz çıktı. Geliştiriciler, çalışma zamanının bir tavan kavramına sahip olmadığı için OOM kill sorunları ile karşılaştılar.
Sorun Bir Go süreci bir kapsayıcı içinde katı bir bellek sınırı (örneğin, Docker veya Kubernetes aracılığıyla 512 MiB) ile çalışırken, varsayılan GOGC=100 yığın büyüklüğünün çöp toplamasını tetiklemeye başlamadan önce iki katına çıkmasına izin veriyordu. Kapsayıcı sınırının farkında olmadan, çalışma zamanı bellek ayırmaya devam ediyor ve çekirdek OOM killer'ı devreye sokana kadar devam ediyordu, bu da sürecin çökmesine neden oluyordu ve hayatta kalmayı önceliklendirmiyordu.
Çözüm Go 1.19, çalışma zamanı tarafından uygulanan bir yumuşak bellek sınırı olan GOMEMLIMIT'i tanıttı. Katı bir üst sınır olmanın aksine, tahsisatları durdurmaz ama çöp toplama zamanlamasını değiştirir. Yığın boyutu (yığınlar, küresel veriler ve çalışma zamanı maliyeti dahil) sınırın yaklaşması durumunda, çalışma zamanı daha saldırgan bir çöp toplama tetikleme noktası hesaplar. Formül şudur: eğer bir sonraki çöp toplama döngüsü sınırı aşacaksa, hemen tetikle. Bu, gerekirse çöp toplama döngülerini %100 CPU'ya sürükleyebilir, verimliliği istikrar için feda eder.
import "runtime/debug" // Yumuşak sınırı 400 MiB olarak ayarla // Değer byte cinsindendir; 0, sınırı devre dışı bırakır debug.SetMemoryLimit(400 << 20) // Alternatif olarak ortam değişkeni ile GOMEMLIMIT=400MiB
Kriz Veri işleme hattımız, ayrıştırma sırasında belleği 600 MiB'a kadar yükselterek büyük CSV dosyalarını tüketiyordu. 512 MiB sınırıyla Kubernetes üzerinde dağıtıldığında, podlar her saat başı OOMKilled durumu ile ölmüştü. Varsayılan GOGC, sınırlı ortam için yığın oranını çok yüksek tutuyordu.
Çözüm 1: Saldırgan GOGC Ayarı Daha önceki toplamalara zorlamak için GOGC=20 ayarlamayı düşündük. Bu, pik belleği yaklaşık 480 MiB'a düşürdü. Ancak, CPU kullanımı %10'dan %40'a sürekli olarak yükseldi, düşük bellek baskısı olduğunda bile. Bu kaynak israfına neden oldu ve gereksiz yere gecikmeyi azalttı.
Çözüm 2: Manuel GC Tetikleme Yüksek tahsisatlar rapor edildiğinde runtime.GC() çağrısını yapan bir bellek izleyicisi uyguladık. Bu kırılgandı; anket yükü gerektiriyor ve genellikle ani zirveler sırasında çok geç tetikleniyordu ya da çok erken tetiklenerek çarpılmalara neden oluyordu. Ayrıca, çalışma zamanının sağlayabileceği incelikli zamanlamayı göz ardı ediyordu.
Çözüm 3: GOMEMLIMIT Entegrasyonu Dağıtım manifestosuyla GOMEMLIMIT=400MiB (yığın zirveleri için boşluk bırakma) ayarladık. Çalışma zamanı, bellek büyüdükçe çöp toplama sıklığını otomatik olarak artırdı. Boş zamanlarda, çöp toplama nadiren gerçekleşti; CSV ayrıştırması sırasında, toplama neredeyse sürekli olarak çalıştı ama belleği 400 MiB'ta tuttu. Ancak baskı altında iken CPU takasına izin verdik.
Karar ve Sonuç Çözüm 3'ü seçtik çünkü bu, manuel ölçüm gerektirmeden kapsayıcı sözleşmesine saygı gösterdi. Hizmet istikrara kavuştu: 30 gün boyunca sıfır OOM kill. GC CPU kullanımı ortalama %8'di (statik GOGC ile %40'a kıyasla) ve ağır ayrıştırma sırasında yalnızca %25'e ulaştı ki bu, kazanılan güvenilirlik için kabul edilebilirdi.
GOMEMLIMIT, hesaplamalarında goroutine yığın belleğini nasıl hesaplıyor? Birçok kişi GOMEMLIMIT'in yalnızca yığın nesneleri izlediğini varsayıyor. Gerçekte, sınır, Go çalışma zamanı tarafından haritalanan tüm belleği kapsamında barındırıyor: yığın, goroutine yığınları, çalışma zamanı meta verileri ve CGO tahsisatları. Çalışma zamanı, zaman zaman kullandığı bellek tahminini sys metriği aracılığıyla günceller. Eğer binlerce goroutine yığınlarını aynı anda büyütürse, bu sınırı etkiler ve yığın küçük olsa bile çöp toplamayı tetikleyebilir. Adaylar, bunun yalnızca bir "toplam bellek" sınırı olduğunu sık sık göz ardı ederler, yığınla sınırlı değildir.
Canlı yığın kalıcı olarak GOMEMLIMIT'i aştığında tahsisat gecikmesi ne olur? Adaylar genellikle GOMEMLIMIT'in tahsisi engelleyen katı bir tavan olduğunu düşünürler. Gerçekte, bu yumuşak bir hedeftir. Bir GC döngüsünden sonra canlı yığın zaten sınırı aşarsa (örneğin, büyük kaçınılmaz bir veri kümesi yükleniyorsa), çalışma zamanı bir sonraki GC tetikleyicisini mevcut yığın boyutuna eşit olarak ayarlar ve bu durum, her tahsisatta çöp toplamanın çalıştırılmasına neden olur. Bu "GC boğulması", canlılığı verimlilikten öncelikli hale getirir. Program dramatik bir şekilde yavaşlar ancak sınırdan dolayı panik yapmaz veya çökmez; yine de işletim sistemi sınırı aşılırsa OOM alabilir, ancak GOMEMLIMIT bunu mümkün olduğunca geri kazanım çabasını maksimize ederek önlemeye çalışır.
GOMEMLIMIT, bellek kullanımı sınırın çok altında görünse bile neden performans düşüşüne neden olabilir? Bu, temizleyici ve zamanlama sezgileriyle ilgilidir. Sınıra yakın olduğunda, çalışma zamanı çöp toplamayı sadece daha sık yapmakla kalmaz, aynı zamanda fiziksel belleği OS'ye daha agresif bir şekilde geri döndürür, MADV_DONTNEED aracılığıyla. Uygulama bir dişli tahsisat modeli varsa (zirve sonra boş), temizleyici sayfaları serbest bırakabilir, bir sonraki zirve tekrar yaygınlaştırmak zorunda kalır. Bu "sayfa hatası fırtınası" gecikme zirveleri olarak ortaya çıkar. Adaylar, GOMEMLIMIT'in GC sıklığı üzerinde bir alt sınır belirlemesini sağlayarak GOGC ile etkileşime girdiği gerçeğini göz ardı ederler; bu sınır, bellek güvenli görünse bile sınırı aşan büyümenin öngörülmesi durumunda GOGC'yi geçersiz kılabilir.