Tarihçe: Go'nun erken sürümlerinde blokaj sistem çağrıları, çalışan OS iş parçacığını doğrudan engelleyerek, başka goroutine'leri çalıştırmasını önledi. Bu, yüksek eşzamanlılık altında hızlı iş parçacığı artışına yol açarak, bellek tükenmesine ve zamanlayıcı thrashing'e neden oldu, çünkü çalışma zamanı ilerlemeyi sürdürmek için sınırsız iş parçacığı başlattı.
Problem: Bir goroutine blokaj işlemi (örneğin, dosya I/O) çağırdığında, altındaki OS iş parçacığı çekirdek alanına girer ve syscall tamamlanana kadar başka goroutine'leri çalıştıramaz. Müdahale olmadan, zamanlayıcı yeni iş parçacıkları başlatmak zorunda kalır, bu da Go'nun hafif eşzamanlılık modelini ihlal eder ve bağlam değiştirme aşırılığı ve bellek baskısı nedeniyle performans düşüşüne neden olur.
Çözüm: Go çalışma zamanı bir devretme mekanizması kullanır. Bir goroutine blokaj syscall'ına girdiğinde, runtime.entersyscall kendi İşleyicisi (P) — mantıksal CPU kaynağı — ayırır ve iş parçacığını teslim eder. P hemen başka bir goroutine'i zamanlar ve açlık yaratmayı önler. Orijinal iş parçacığı syscall'ı yürütür. Tamamlandığında, runtime.exitsyscall orijinal P'yi yeniden kazanmayı dener; mevcut değilse, goroutine küresel çalıştırma kuyruğuna girer veya başka bir P'yi çalar, böylece sınırsız büyüme olmadan etkili iş parçacığı kullanımı sağlanır.
// Bu dosya işlemi, syscall devretme mekanizmasını şeffaf bir şekilde tetikler func ProcessLogFile(path string) error { // Bu noktada, runtime.entersyscall çağrılır // P başka bir goroutine'e devredilirken bu iş parçacığı bloklanır data, err := os.ReadFile(path) if err != nil { return err } // Dönüşte, runtime.exitsyscall yürütülür // Goroutine, mevcut bir P üzerinde yeniden zamanlanır processData(data) return nil }
Milyonlarca olayı saniyede işleyen yüksek hacimli bir günlük toplama hizmeti işletiyorduk. Her goroutine CPU yoğun bir ayrıştırma yaptıktan sonra os.WriteFile aracılığıyla atomik disk yazma gerçekleştirdi. Yük altında, hizmet, düşük yığın kullanımı ve verimli çöp toplama olmasına rağmen OOM çöküşleri sergiledi.
Problem analizi: pprof ve çalışma zamanı metrikleri, işlemin 50.000'den fazla OS iş parçacığı başlattığını ve her birinin disk I/O üzerinde engellendiğini ortaya koydu. Varsayılan iş parçacığı limiti (10000) aşıldı ve bu da goroutine açlığına ve mikro hizmet ağında yayılma zaman aşımına neden oldu.
Çözüm A: Semafor kontrollü işçi havuzu ile tamponlu I/O: Eş zamanlı disk erişimini yüz eş zamanlı işlemle sınırlamak için sabit bir işçi havuzu uygulamayı düşündük. Bu yaklaşım, öngörülebilir kaynak kullanımı ve geri basınç sağladı, ancak karmaşık akış kontrol mantığı, kapanış sırasında potansiyel kilitlenmeler oluşturdu ve Go'nun doğal eşzamanlılık modelini bozarak çalışma zamanının yönetmesi gereken manuel semafor yönetimini ekledi.
Çözüm B: Raw epoll aracılığıyla asenkron I/O: syscall.RawSyscall ile engellenmeyen dosya tanımlayıcıları kullanmayı ve netpoler'e entegre olmayı değerlendirdik. Soketler için verimli olsa da, Linux, tüm dosya sistemleri için gerçek asenkron dosya I/O'yu epoll ile desteklememekte ve disk işlemleri için karmaşık iş parçacığı havuzu yönetimi gerektirmektedir. Bu, çalışma zamanının syscall stratejisinin daha fazla aşırılıkla ve daha az güvenilirlik ile yeniden uygulanması anlamına geliyordu.
Çözüm C: Mimari ayarlama ile çalışma zamanına güvenin: Go'nun mevcut syscall işleme kapasitesinden yararlanmaya karar verdik. debug.SetMaxThreads değerini geçici olarak bir güvenlik valfi olarak arttırdık, syscall sıklığını azaltmak için bufio.Writer'a geçtik ve yeniden deneme mantığı için üstel geri adım uyguladık. Bu, blokaj çağrılarının oranını azaltarak çalışma zamanının entersyscall/exitsyscall mekanizmasının doğru çalışmasını sağladı ve iş parçacığı patlaması olmadan işleyişini sürdürdü.
Sonuç: İş parçacığı sayısı zirve yük altında 1,000'in altında stabilize oldu, OOM hataları tamamen sona erdi ve bağlam değiştirme aşırılığı nedeniyle %40 oranında bir artış sağladı. Hizmet artık, I/O bekleme sürelerinde goroutine'lerin mevcut iş parçacığı havuzuna çoğalmasına olanak tanıyarak, zamanlayıcının bu şekilde tasarlandığı gibi trafiğe tepki verirken ani artışları zarif bir şekilde yönetiyor.
1. Neden bir kanalda engelleme bir OS iş parçacığı tüketmezken, bir dosya okunmasında engelleme tüketir ve çalışma zamanı bu durumları nasıl ayırır?
Bir kanalda engelleme, tamamen kullanıcı alanında yönetilen bir goroutine durum değişikliğidir. Çalışma zamanı, goroutine'yi park eder (bekliyor olarak işaretler) ve derhal mevcut P'den başka bir goroutine çalıştırması için OS iş parçacığını tekrar zamanlamaya alır; bu iş parçacığı asla çekirdek alanına girmez. Tersine, bir dosya okuması syscall aracılığıyla çekirdek alanına girer. Çalışma zamanı, bu iş parçacığının belirsiz bir süre boyunca kullanılamayacağını zamanlayıcıya bildiren runtime.entersyscall çağrısını gerçekleştirir ve açlık yaratmayı önlemek için derhal P devretme işlemi başlatılır. Farklılık, kullanıcı alanında park etme (kanal) ile çekirdek alanında devretme (syscall) arasında bulunmaktadır.
2. runtime.LockOSThread() bir blokaj syscall'ından önce çağrıldığında ne tür bir felaket başarısızlık modu oluşur ve bu neden devretme mekanizmasını atlar?
runtime.LockOSThread(), goroutine'yi kilit süresi boyunca mevcut OS iş parçacığı ile ilişkilendirir. Eğer kilitlenmiş bir goroutine blokaj syscall'ı çağırırsa, iş parçacığı sıralama sözleşmesi gereği bu spesifik iş parçacığı ile bu spesifik goroutine'nin yürütülmesini gerektirdiğinden, kendi P'sini ayıramaz. P, syscall tamamlanana kadar zamanlayıcının havuzundan etkili bir şekilde kaldırılmış olur. Aynı anda birçok kilitli goroutine bloklandığında, uygulama tamamen paralellik kaybeder ve engellenen işlemler diğer goroutine'lere bağlıysa, mevcut Ps eksikliğinden ötürü potansiyel kilitlenmeler meydana gelebilir.
3. CGO yürütmesi entersyscall mekanizması ile nasıl etkileşir ve aşırı CGO çağrı desenleri neden blokaj syscall'ları ile benzer iş parçacığı tüketimine neden olur?
CGO çağrıları, çalışma zamanı tarafından blokaj işlemleri olarak ele alınır. Go, C kodunu aradığında, runtime.entersyscall çağrılır ve açlık yaratmayı önlemek için P serbest bırakılır. Ancak CGO, ayrı bir sistem yığınında çalışır ve OS iş parçacığının C yürütme bağlamına geçiş yapmasını gerektirir. Eğer C kodu blokaj işlemleri gerçekleştiriyorsa veya uzun süre çalışıyorsa, OS iş parçacığı meşguldür. Saf Go syscall’ları gibi, CGO çağrıları hızlı yeniden giriş desteği sağlamaz; bu, goroutine'nin aynı iş parçacığında kuyruklanmadan devam etmesine izin verir. Aşırı CGO çağrıları, her çağrının bir iş parçacığı-yığın kombinasyonunu tutması nedeniyle iş parçacığı havuzunu tüketebilir ve zamanlayıcı diğer goroutine'leri hizmet etmek için yeni iş parçacıkları başlatabilir, bu da işlenmemiş blokaj syscall'ları gibi iş parçacığı patlamasına yol açar.