Idle Thread’ler ve Go Scheduler

Ömer Burak Demirpolat
6 min readAug 15, 2022

--

Selamlar, bu yazı Go programlama dilinde scheduling, Goroutine’ler ve Idle Thread’ler üzerine olacak.

Aslında ben Go Scheduler tarafından yaratılan Idle Thread’ler nasıl, ne zaman kapanıyor, yoksa uygulama çalıştığı sürece hazır olarak bekliyor mu falan bunu basit bir program ile denemeye karar verdim. Idle Thread’ler Go Scheduler tarafından yönetiliyor, Go Scheduler konusu ise temeli Goroutine. Kısacası yazı bu doğrultuda ortaya çıktı.

Yazının tamamı benim öğrendiğim, bildiğim kadarıyla yazıldı. Yanlış olabilir, bazı şeyleri tamamen yanlış anlamış olabilirim. Bu konuyu daha detaylı aktarmış iki yazıyı şuraya ekleyeyim

Konu Idle Thread’ler Go Scheduler ve Goroutine’ler ile ilişkili olduğu için bu iki konu hakkında da bazı bilgiler içerecek.

Yazının devamı;

  • Go Scheduler nedir?
  • Go Scheduler Goroutine’leri nasıl yönetiyor?
  • Idle Thread yaratmak ve gözlemlemek.

Go Scheduler Nedir?

Öncelikle scheduling meselesine bakalım. Java’dan örnek vererek devam edeceğim. Bir Java programı başladığı zaman main sınıfı için işletim seviyesinde thread yaratılır ve komutlar bu thread tarafından yürütülür.

Bu Java uygulamasında aynı anda iki farklı iş yapmak istediğinizde örnek veriyorum bir dosya okuma işlemi ve bir network isteği, her iki işlem için de yeni thread yaratılır ve bu yaratılan thread’ler iş bittikten sonra kapatılır.

Go programlama dilinde bu durum biraz farklı yönetiliyor.

Bir Go programı çalıştırıldığında öncelikle donanımızdaki core sayısına göre sanal bir işleyici yaratılır, her bir sanal işleyici için thread ve Local Run Queue oluşturulur.

Kısacası Go programı çalıştığında elimizde (8 adet core’a göre) 8 adet sanal işleyici, 8 adet thread ve 8 adet Local Run Queue bulunuyor.

Local Run Queue bahsettiğim gibi 8 sanal işleyicimiz var ise her bir işleyici için yaratılıyor ve ilgili thread’de hangi Goroutine’in çalıştırılacağı bu kuyruğa göre belirleniyor.

Bir de Global Run Queue adında bir kuyruk bulunuyor. Bu kuyruk işleyici bağımsız ve toplamda bir adet (Local Run Queue ise işleyici başına).

Bir Goroutine ilk yaratıldığında Global Run Queue adındaki bu kuyrukta barındırılıyor. Burada henüz bir sanal işleyicinin ilgili Local Run Queue’suna atanmamış Goroutine’ler bulunuyor.

Peki hangi sanal işleyici bu yeni yaratılan Goroutine’i çalıştıracak.

8 adet yaratılmış sanal işleyicilerden bir tanesi Global Run Queue’dan Goroutine’i alır ve kendi üzerindeki Local Run Queue’ya ekler. Burada öncelik Local Run Queue’su boş olan sanal işleyicilerdir.

Şimdi bazı Go Scheduler özelindeki terimleri toparlayalım.

  • Sanal İşleyici, core başına yaratılır ve başlangıçta sadece 1 adet thread barındırır.
  • Local Run Queue, her bir sanal işleyici başına yaratılır ve bu sanal işleyiciye atanmış Goroutine’leri barındırır. Sanal işleyici sıradaki çalıştırılacak Goroutine’i bu kuyruktan seçer.
  • Global Run Queue, toplamda 1 adet bulunur. Yeni yaratılan Goroutine’ler önce bu kuyrukta bulunur ve daha sonra sanal işleyiciler bu kuyruktan aldıkları Goroutine’leri kendi üzerindeki Local Run Queue’ya aktarır.

Aşağıda 2 sanal işlemci ile oluşacak Go Scheduler yapısını görebilirsiniz.

Şimdi bir context switching konusundan bahsedelim.

Yukarıdaki örnekte görüldüğü üzere her bir sanal işlemciye ait bir thread var ve bu 1 adet thread T anında sadece yalnızca 1 adet Goroutine çalıştırabiliyor.

Thread’lerin Goroutine’leri hangi sıra ile alacağını, hangisinin önce veya sonra biteceğini biz bilemiyoruz .Thread üzerine atanan Goroutine’i çalıştırıyor fakat hangi Goroutine bu thread’e ne zaman atanacak bunu Go Scheduler yönetiyor.

Go Scheduler’ın Goroutine yönetimini tetikleyen bir kaç durum var.

Örneğin “go” keyword’u kullanarak yeni bir Goroutine yarattığınızda Go Scheduler bunu ilk olarak Global Run Queue’ya ekleyecek ve daha sonra boşta, uygun olan bir Local Run Queue bu Goroutine’i alacak.

Bunun haricinde system call yani işletim sistemi kernel’ına yaptığımız çağrılarda Go Scheduler’ın bir karar vermesi gerekiyor. Benim ilk başta bahsettiğim Idle Thread durumum da bu konu ile ilgili. Bu konuya bir örnek ile devam etmek istiyorum.

Aşağıdaki görüntüde Sanal İşlemci1 üzerindeki Thread1'in şu an Goroutine1'i çalıştırdığını görüyoruz.

Peki bu işlem esnasında Goroutine1 üzerinde bir system call gerçekleştiğini düşünelim. Varsayalım ki “open” isimli system çağrısını gerçekleştirdik, bir dosyanın içeriğini okumak istiyoruz. Herhangi bir sorun yok.

Go Scheduler bu durumda diğer Goroutine’leri yürütmek için yeni bir thread yaratıyor ve senkron olarak Thread1 üzerinde devam eden sistem çağrısı sanal işlemciyi tamamen bloklamıyor.

Thread1 Sanalİşlemci1 üzerinden ayrıldı ve Goroutine1'i yürütmeye devam ediyor. Bu durumda Local Run Queue’da bekleyen Goroutine’leri yürütmek için Thread2 adında yeni bir thread yaratıldı ve Thread2 boşta olduğu için Goroutine2'yi yürütmeye başladı.

Peki Goroutine1 bittiğinde ne olacak?

Benim Idle Thread konum burada başlıyor. Goroutine1'in işi bittiğinde Go Scheduler Goroutine1'i Thread1'den ayırıp Local Run Queue’ya ekliyor. Şimdi görüntümüz şu şekilde.

Goroutine1 tekrar Local Run Queue’ya eklenmiş gözüküyor.

Peki Thread1'in bir işi yok, ne olacak bu Thread1'e?

Go Scheduler bu tür senkron sistem çağrılarında yaratılan thread’leri tekrar kullanılabilme ihtimaline karşın kapatmıyor çünkü 2. bir senkron işlem gerçekleştiğinde yeni bir thread yaratmak ile var olan boşta bir thread’i kullanmak çok daha hızlı. Thread1 artık boş, idle bir thread ve sonraki kullanımlar için hazır.

Bu durum akılda şu soruyu yaratıyor, ya binlerce boş thread olursa ne olacak?

Bu durumu görebilmek için bir deneme yapalım.

func main() {
go f()
go f()
go f()
<-time.After(time.Second * 15)
}

func f() {
for i := 0; i < 20; i++ {
f, err := os.Open("go.mod")
if err != nil {
println("file can not open", err)
continue
}
f.Close()
}
}

f adlı fonksiyonu 3 farklı Goroutine tetikleyerek çalıştırıyorum.

f fonksiyonu ise görüldüğü üzere for döngüsü ile go.mod dosyasını açmak üzere 20 kez senkron system call yaratıyor.

Yukarıdaki kodu GOMAXPROCS ve GODEBUG değişkenleri ile çalıştırıyorum.

GOMAXPROCS=1 GODEBUG=schedtrace=250 go run main.go

GOMAXPROCS max sanal işlemci sayısını set etmemizi sağlıyor.

GODEBUG=schedtrace ise scheduler’ı trace etmemizi sağlayacak.

250ms döngüsünde aktif thread’leri, idle thread’leri görmemizi sağlayacak.

Kodun tamamlanıp uygulamanın kapanmasına yakın scheduler trace’i bize genelde şu ortalamada bir çıktı veriyor.

gomaxprocs=1 idleprocs=1 threads=6 spinningthreads=0 idlethreads=4 runqueue=0 [0]

Burada idle thread sayısının 4 olduğunu görüyoruz. Herhangi bir sorun yok, bu idle thread’lerin neden, nasıl yaratıldığını zaten bahsetmiştik.

Şimdi uygulamamızı biraz farklı hayal edelim.

Diyelim ki bizim uygulamamızda initialization esnasında bu f fonksiyonun 500 kez eş zamanlı çalışması gerekiyor.

Fakat kritik nokta şurası 500 kez bu f() fonksiyonu tetiklendikten sonra bir daha ihtiyacımız olmayacak. İşini bitirdiği zaman uygulamamızın init aşamaları bitiyor.

Yani bu 500 eş zamanlı f() fonksiyonuna sadece uygulama ayağa kalkarken ihtiyacımız var.

Kod şöyle:

func main() {
for i := 0; i < 500; i++ {
go f()
}
... code continue with http web server impl
... ...
}

func f() {
for i := 0; i < 20; i++ {
f, err := os.Open("go.mod")
if err != nil {
println("file can not open", err)
continue
}
f.Close()
}
}

Uygulamamızı çalıştırıp scheduler çıktılarına bakarsak şöyle bir şey göreceğiz.

gomaxprocs=1 idleprocs=1 threads=52 spinningthreads=0 idlethreads=500 runqueue=0 [0]

Bu çıktı 500 tane tetiklenen f() fonksiyonlarının tamamı bittikten sonra alındı.

Yani uygulama ayağa kalktıktan sonra bir daha kullanılmayacak 500 adet boşta thread’e sahibim.

İşte bu durumu önlemek istiyorum. Bunun için system call konusunu tekrar hatırlayalım. Burada biz Goroutine tetiklediğimizde bir sorun yok, Goroutine işini bitirdiğinde kapanacak ve Local Run Queue’dan yeni bir Goroutine alınıp yürütme sürdürülecek fakat ilgili Goroutine bir system call yarattığında mesela burada yaptığımız open fonksiyonu ile yeni thread yaratıldığını konuşmuştuk. Burada yeni thread yaratılmasını önlememiz gerekiyor.

Bunun için runtime LockOSThread adında bir fonksiyon bulunuyor.

Bu fonksiyon Goroutine’i ilk başta çalıştırıldığı thread’e yapışmasını ve sadece bu thread’de çalıştırılmasını sağlıyor. Yani bu durumda biz open system call’unu gerçekleştirdiğimizde yeni thread yaratılmayacak, dolayısı ile idle thread’de olmayacak.

Tabi eğer bu f() fonksiyonu sadece uygulama ayağa kalkarken 500 kez değil de sürekli devam ediyor olsaydı. Bu thread’ler idle thread olmayacaktı. Ben bu idle thread’leri bir daha kullanmayacağımdan eminim bu nedenle bu LockOSThread fonksiyonunu kullanıyorum.

func main() {
for i := 0; i < 50; i++ {
go f()
}
<-time.After(time.Second * 30)
}

func f() {
runtime.LockOSThread()
defer println("f done")
for i := 0; i < 5; i++ {
f, err := os.Open("go.mod")
if err != nil {
println("file can not open", err)
continue
}
f.Close()
}
}

Kodumu şu şekilde değiştirdim. Scheduler’un trace çıktısı ise artık şu şekilde.

Yine bu çıktı tüm f() fonksiyonları bittikten sonra alındı.

gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=1 runqueue=0

Idle thread sayımız 1, bu da benim istediğim sonuç.

Aslında tüm bu Idle Thread konusunu çözen çok basit bir fonksiyon, bu durumu çözmek için bu kadar yazıya/detaya gerek var mı bilmiyorum, tartışılır. Biraz arkada neler olup bittiğini de yazmak istedim. Okuduğunuz için teşekkürler.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet

Write a response