Goroutine ve Go Scheduler Rehberi Part 2

Mehmet Can Tas
8 min readApr 8, 2022

Bir önceki makaleye ulaşmak için aşağıdaki linke tıklayabilirsiniz :

Merhaba,

Bir önceki makalede Concurrency, Parallelism, Thread ve Goroutine kavramlarından bahsettik. Bu makalede ise çeşitli senaryolar ile Goroutine ve Go scheduler’ın çalışma zamanında nasıl davranışlar sergilediğine yakından bakacağız. Elbette her dilde olduğu gibi Go’da da Concurrency konusu dipsiz bir kuyu. Bundan dolayı olabildiğince fazla bilgiyi bu makalede aktardıktan sonra spesifik bilgileri farklı makalelerde ele almayı planlıyorum.

Go Scheduler Nedir?

Go scheduler birçok makalede GMP scheduler olarak da belirtilir. Burada G, M ve P harflerinin neyi temsil ettiğini aşağıdaki görsele bakarak öğrenebilirsiniz. Bu makalede bütün senaryoları çizimler üzerinden göstereceğimden ötürü bu harflerin ne anlama geldiğini bilmeniz önemli.

Go Scheduler

Görselden de anlayacağınız üzere aslında Go hiç Thread kullanmıyor gibi bir durum söz konusu değil. Go scheduler M:N Thread modellemesini kullanarak Goroutine’leri işletim sistemi Thread’leri üzerinde çalıştırıyor. M:N modellemesini kısaca açıklayacak olursak M adet Goroutine N adet Thread üzerinde çalışabiliyor. Bu da Go’nun concurrency işlemlerinde daha verimli ve performanslı olmasını sağlayan konulardan birisi. Go Scheduler her bir Goroutine için bir Thread yaratmak yerine eğer elinde önceden yarattığı bir Thread varsa onu tekrar kullanır bu sayede de Thread yaratma esnasında oluşan maliyeti minimuma indirir. Elbette Go bazı durum ve koşullar altında çalışma zamanında yeni bir Thread yaratma ihtiyacı duyabilir bu çoğu zaman kaçınılmaz bir durum oluyor ancak hangi durumlarda neden Thread yarattığına da makalenin ilerleyen kısımlarında değiniyor olacağız.

M:N Thread Modelling

Go scheduler’ın ana görevi çalışmaya hazır olan Goroutine’leri işletim sistemi Thread’leri üzerinde çalıştırmaktır. Her bir Thread ise mutlaka bir Processor’e (P) bağlı olmak zorundadır. Go scheduler Go runtime’ın bir parçası olduğundan dolayı user space dediğimiz ortamda işlemlerini gerçekleştirir ve kernel ile bir işi varsa bunu user space içerisinde yarattığı Thread’ler aracılığıyla gerçekleştirir.

Go scheduler aşağıdaki 4 durumda scheduling kararlarını verebilir :

  • go anahtar kelimesinin kullanılması
  • Garbage collection
  • Sistem çağrıları
  • Senkronizasyon ve orkestrasyon (channel, mutex vb. yapıların kullanımı)

Go scheduler hakkında temel birkaç bilgiye sahip olduğumuza göre hepimizin Go’da yazabileceği en basit program üzerinden Go Scheduler’ın çalışma mantığını inceleyelim.

func main() {    fmt.Println(“Hello world”)}

Yukarıdaki kodu hepimiz ilk başladığımızda ya da yeni bir proje açtığımızda mutlaka yazmışızdır. Peki bu kod arka planda tam olarak ne yapıyor?

Main fonksiyonu çalıştığında oluşan tablo

Şimdi sırasıyla neler oluyor listeleyelim :

  • Runtime bir adet Processor (P) yaratıyor.
  • Sonrasında scheduler devreye girip bir adet işletim sistemi Thread’i (M) oluşturuyor.
  • runtime.main metodu main.main fonksiyonunu (func main()) çağırarak yeni bir Goroutine yaratıyor. Yaratılan Goroutine P’nin lokal kuyruğuna ekleniyor
  • M çalışıyor ve P’ye bağlanıyor.
  • M Goroutine’i çalıştırmak için gerekli olan bilgileri (G’nin stack ve scheduling bilgileri) P’den alıyor
  • G, M üzerinde çalışıyor.
  • Tüm işlemler bittikten sonra G sonlanıyor runtime.main defer ve panic varsa çalıştırıyor ve sonunda runtime.exit metodu çalıştırılarak işlem sonlandırılıyor.

Şimdi aşağıdaki kodu inceleyelim :

func main() {

go func(){

fmt.Println("Hello goroutine")
}() fmt.Println(“Hello world”)}

Bu kodu çalıştırdığımızda beklentimiz “Hello goroutine” ve “Hello world” mesajlarını ekrana yazdırması. Ancak bu kodu çalıştırdığınızda “Hello goroutine” mesajının bazen yazdırılıp bazen yazdırılmadığını gözlemleyebilirsiniz.

Bunun sebebi ise, Go programlama dilinde de diğer programlama dillerine benzer olarak bir main goroutine (main thread) bulunmaktadır. Bu goroutine sonlandığında tüm Goroutine’ler otomatik olarak sonlanır. Yukarıdaki kodda main goroutine (main fonksiyonu) herhangi bir şekilde bloklanmadığından dolayı akış tamamlanıp fonksiyon sonlanıyor. Dolayısıyla bu kod içerisinde “Hello goroutine” mesajını ekrana yazdıracak goroutine schedule edilip çalıştırılacak zaman aralığını bulamayabiliyor. Elbette siz time.Sleep() fonksiyonu ile belli bir zaman aralığı için main goroutine’i bloklayıp “Hello goroutine” mesajını ekrana yazdırtabilirsiniz. Ancak bu yine de goroutine’in kesin olarak çalışacağını garanti etmez sadece çalışma ihtimalini arttırmış oluruz.

Çalıştırmak istediğiniz Goroutine’lerin kesin olarak çalışmasını istiyorsanız sync paketinin altında bulunun WaitGroup yapısını kullanabilirsiniz. WaitGroup içerisinde bulunan Add() metodu ile kaç adet Goroutine çalıştırılacağını barındırır. Done() metodu ise Add() metodu ile eklediğimiz değeri -1 olarak günceller. son olarak Wait() metodu ile Add() metodunda verdiğimiz değer 0 (sıfır) olana kadar main Goroutine’i bloklamış oluruz.

Örnek kod :

func main() {wg := sync.WaitGroup{}wg.Add(1)go func() {defer wg.Done()fmt.Println("Hello goroutine")}()wg.Wait()fmt.Println("Hello world")}

Yukarıdaki kodu ne kadar çalıştırırsanız çalıştırın “Hello goroutine” mesajı ekrana yazdırılacaktır.

WaitGroup nasıl çalışıyor?

Go fork-join isimli concurrency modelini uygulamaktadır. Kısaca bahsedecek olursak, fork-join modelinde uygulamanız tek bir akış üzerinde ilerlerken farklı goroutine/thread ile bir metot çağırımında farklı branch’lere bölünür (fork). Uygulama akışı devam ettiği sürece bölünen bu branch’lerin tekrar ana akışa dahil (join) olması gerekir.

Detaylı bilgi için : https://en.wikipedia.org/wiki/Fork%E2%80%93join_model

Fork-join modelini yukarıdaki ilk kod örneği (WaitGroup olmayan) ile görselleştirelim :

WaitGroup ise bizlere program akışımızda join point adı verilen tekrar katılım noktaları oluşturmamızı sağlıyor. Add() metodu ile verdiğimiz sayıyı branch sayısı olarak düşünecek olursanız bütün branch’lerdeki işlerin tamamlanmasını bekliyor ve Wait() metodu ile bu branch’lerin tekrar ana akışa dahil olabilecekleri bir join point oluşturuyor ve Add() metodunda verilen değer 0 (sıfır) olana kadar ana akış bloklanıyor. Bu sayede tüm branch’ler işini bitirip tekrar ana akışa dahil olabiliyor.

Oluşturulan Process ve Thread’lerin işi bittiğinde ne oluyor?

Go ile ilgili en çok duyduğunuz şey belki de “Go kaynak kullanımı açısından oldukça avantajlı bir programlama dili” cümlesi olabilir. Bunun birçok sebebi var elbette ancak konumuz dahilindekileri açıklamaya çalışalım.

Go Scheduler oluşturduğu Process ve işletim sistemi Thread’lerini işleri bittikten sonra çöpe atmak yerine Idle Processors ve Idle Threads listelerinde tutuyor. Bu da bir Processor ya da Thread ihtiyacı oluştuğunda tekrardan Thread ve Processor oluşturma maliyetinin önüne geçerek zaten hazırda bekleyen Thread’leri ya da Processor’leri kullanarak verimliliği arttırıyor.

Neden Processor’e (P) ihtiyacımız var?

Go’nun ilk sürümünde Processor kavramı yoktu bunun yerine Global Run Queue adı verilen çalıştırılabilir tüm Goroutine’lerin tek bir yerde bulunduğu bir yapı mevcuttu. Bu yapı bir Mutex tarafından korumaktaydı. Boşa düşen Thread’ler çalıştırılabilecek Goroutine’leri Global Run Queue’dan alabilmek için öncelikle Mutex’in sahipliğini ele geçirip (lock) sonrasında tekrar bırakarak (unlock) diğer Thread’lerinde Mutex üzerinden Goroutine’lere erişmesine izin veriyordu.

Bu durum özellikle uygulamaların ölçeklenebilir olmasına büyük engel oluşturuyordu. Çünkü her bir Thread Mutex’in unlock duruma geçmesini beklediği için bir darboğaz oluşuyordu.

Go 1.1 sürümünde Dmitry Vyukov tarafından Processor’ler Go Scheduler’a dahil edildi. 1.1 sürümünden sonra her Processor kendi Local Run Queue ismi verilen çalıştırılabilir Goroutine’leri barındırabileceği bir yapıya sahip oldu. Local Run Queue’lar FIFO (First in first out) olarak çalışmaktadır.

Her Processor maksimum 256 adet Goroutine barındırabilir. Bu sayı aşıldığında ise sonraki Goroutine’ler tekrar Global Run Queue’ya gönderilir. Bu sayının amacı ise ilerde bahsedeceğimiz Work-stealing algoritmasında false sharing adı verilen sorunun önüne geçmek olarak düşünebiliriz.

https://groups.google.com/g/golang-nuts/c/dISOUpCp-uE

Bir Goroutine bir Channel tarafından bloklandığında ne olur?

Channel hakkında bilgi sahibi değilseniz : https://www.youtube.com/watch?v=CsIptIMfbO4&t=1222s

salesChan := make(chan int)....salesChan <- calculatedEmployeeSale

Örnek olark yukarıdaki kodu incelediğimizde salesChan isimli channel’a bir veri gönderen bir goroutine olduğunu düşünelim. Oluşturduğumuz channel unbuffered olduğu için başka bir goroutine bu channel’a gönderilen veriyi okuyana kadar veriyi gönderen Goroutine bloklanır.

Bu durumda bloklanan Goroutine başka bir listeye alınır. Go Scheduler scheduling işlemleri için her bir Thread’in içerisinde yer alan g0 adındaki Goroutine’i kullanır ve bu Goroutine ile çalıştırılacak bir sonraki Goroutine’i belirler.

Channel’a gönderdiğimiz veri başka bir Goroutine tarafından okunduğunda :

someVariable := <- salesChan

Bloklanan Goroutine runnable durumuna geçer. Her bir Processor sadece 256 adet Goroutine barındırabilir demiştik ancak bunun haricinde 1 adet LIFO (Last in first out) buffer’ı bulunmaktadır. Bunun sebebi ise başka bir Thread tarafından Goroutine’in çalınmaması ya da Global Run Queue’ya gitmesini engellemek. Bu alana giren Goroutine ise çalıştırılacak bir sonraki Goroutine olarak işaretlenir.

Runnable duruma geçen Goroutine’imiz bu alana girdikten sonra, Thread çalıştırdığı Goroutine ile işi bittiğinde görev g0 isimli Goroutine’e geçer ve runnable durumdaki LIFO buffer’ında bulunan Goroutine’i çalıştırır.

Eğer bu buffer alanı dolu ise o zaman runnable duruma geçen Goroutine Global Run Queue’ya gönderilir.

Burada bilmeniz gereken 2 önemli şey var :

  • Bir Goroutine yalnızca running durumundayken sonlandırılabilir.
  • Eğer Goroutine running duruma hiç geçemezse (bloklanmışsa) o zaman Goroutine sonlanamaz

Async Preemption

Go scheduler 1.14 sürümünde cooperative preemption adı verilen bir yapıyı kullanmaya başladı. Bunun öncesinde ise Goroutine’leri bloklayabilmek için her bir fonksiyonun içerisinde belirli bölümlere o Goroutine’i durdurabilecek çeşitşli direktifler koyarak bu işlem gerçekleşiyordu. Ancak bu da hem uygulamanın boyutunu hem de fonksiyonların performansını etkiliyordu.

Bunun yerine Go Scheduler sysmon adı verilen bir background thread kullanarak Goroutine’leri kontrol ediyor. Her bir Goroutine 10ms boyunca herhangi bir sistem çağrısı vb. bir işlem yapmadan çalışıyorsa bu durumda diğer Goroutine’lerin çalışabilecek zaman aralığını oluşturmak için bir sinyal gönderiliyor. Bunun haricinde eğer Garbage Collector 2 dakika boyunca çalışmazsa GC’i çalıştırmak için bir sinyal gönderiyor. 10ms limitini aşan Goroutine’ler ise Global Run Queue’ya gönderiliyor.

Preemption işlemi sysmon isimli Thread ile başlıyor. Her bir Thread kendi içerisinde gelen sinyalleri kontrol edip işleyecek bir Goroutine barındırır. Sysmon SIGURG isimli bir sinyali Thread’e yollar ve bu sinyal sonrasında gerekli direktifler fonksiyona eklenir. Goroutine bu direktifleri çalıştırdığında waiting durumuna geçer ve görevi g0 adındaki Goroutine’e bırakır. g0 ise çalıştırılacak bir sonraki Goroutine’i belirler ve çalıştırır.

Bahsettiğimiz bu süreci görselleştirecek olursak :

Work-stealing algoritması

Work-stealing algoritması Go 1.1 sürümünde Dmitry Vyukov tarafından eklendi. Temel mantığı ise çalıştıracak Goroutine bulamayan Thread’lerin başka Processor’lerin Local Run Queue’larından ya da Global Run Queue’dan iş çalması olarak düşünebilirsiniz.

Bir Thread bağlı olduğu Processor’ün Local Run Queue’sunda çalıştıracak bir Goroutine bulamadığında ilk olarak Global Run Queue’yu kontrol eder. Eğer orada da Goroutine bulamazsa bu sefer başka Netpoller adı verilen Go içerisinde network isteklerimizin gerçekleştiği yapıya ait Local Run Queue’daki Goroutine’leri çalmaya çalışır. Eğer orada da yoksa en son seçenek olarak başka Processor’lerdeki Local Run Queue’lardan çalışmaya hazır Goroutine’leri çalar. Bu işlemleri yaparken Thread spinning adı verilen bir durumda olur. Bu durum aslında Thread’in başka bir yerden çalıştırabileceği Goroutine’leri aradığını temsil eder.

Work-stealing algorithm

Bu konuyla alakalı Go Türkiye Youtube kanalında 1 saatlik detaylı bir sunum yaptım. Burada bahsetmediğim bazı konuları (network istekleri vb.) aşağıdaki videoda bulabilirsiniz. Umarım faydalı bir makale olmuştur. İyi kodlamalar👋

İletişim adresleri :

--

--