GoSuda

Goroutin'in Temelleri

By hamori
views ...

Goroutine

Gopher'lara golang'ın avantajlarını anlatmam istendiğinde sıklıkla karşılaşılan bir konu eşzamanlılık (Concurrency) ile ilgili yazılardır. Bu içeriğin temelinde hafif ve basit bir şekilde işlenebilen goroutine bulunmaktadır. Bu konuda kısa bir yazı hazırladım.

Eşzamanlılık (Concurrency) vs Paralellik (Parallelism)

Goroutine'i anlamadan önce, sıklıkla karıştırılan iki kavramı açıklamak isterim.

  • Eşzamanlılık: Eşzamanlılık, birçok işi aynı anda işlemeyle ilgilidir. Bu, mutlaka işlerin fiziksel olarak aynı anda yürütüldüğü anlamına gelmez; aksine, birden fazla görevi küçük birimlere ayırıp sırayla yürüterek, kullanıcıya aynı anda birden fazla görevin işlendiği izlenimini veren yapısal ve mantıksal bir kavramdır. Tek çekirdekli bir sistemde bile eşzamanlılık mümkündür.
  • Paralellik: Paralellik, "birden fazla çekirdekte birden fazla işin aynı anda işlenmesi" anlamına gelir. Adından da anlaşılacağı gibi, işlerin paralel olarak ilerlemesi ve farklı görevlerin eş zamanlı olarak yürütülmesidir.

Goroutine, Go runtime scheduler aracılığıyla eşzamanlılığı kolayca uygulamamızı sağlar ve GOMAXPROCS ayarı aracılığıyla paralelliği doğal bir şekilde kullanır.

Yaygın olarak kullanılan ve yüksek verimliliğe sahip Java'nın Multi thread yapısı, paralelliğin tipik bir örneğidir.

Goroutine neden iyidir?

Hafiftir (lightweight)

Oluşturma maliyeti diğer dillere göre çok düşüktür. Burada akla "golang neden az kullanılır?" sorusu gelebilir; çünkü oluşturma konumu Go runtime'ı tarafından dahili olarak yönetilir. Bunun nedeni, yukarıda belirtildiği gibi hafif bir mantıksal thread olmasıdır. OS thread biriminden daha küçüktür ve başlangıç stack'i yaklaşık 2KB boyutunda bir alan gerektirir ve kullanıcının uygulamasına göre stack eklenerek dinamik olarak değişebilir.

Stack birimiyle yönetildiği için oluşturma ve kaldırma işlemleri çok hızlı ve düşük maliyetli bir şekilde yapılabilir, bu sayede milyonlarca goroutine çalıştırılsa bile yük oluşturmaz. Bu sayede Goroutine, runtime scheduler sayesinde OS kernel müdahalesini minimize edebilir.

Performanslıdır (performance)

Öncelikle Goroutine, yukarıda açıklandığı gibi OS kernel müdahalesi az olduğu için User-Level'da context switching yaparken OS thread biriminden daha düşük maliyetli olup, görevleri hızlı bir şekilde değiştirebilir.

Ayrıca, M:N modeli kullanılarak OS thread'lere atanarak yönetilir. OS thread havuzu oluşturularak, çok sayıda thread'e gerek kalmadan az sayıda thread ile işlem yapılabilir. Örneğin, bir sistem çağrısı gibi bir bekleme durumuna düşüldüğünde, Go runtime'ı OS thread üzerinde başka bir goroutine çalıştırarak OS thread'in boş kalmamasını ve CPU'yu verimli bir şekilde kullanarak hızlı işlem yapabilmesini sağlar.

Bu durum, Golang'ın özellikle I/O işlemlerinde diğer dillere göre daha yüksek performans göstermesini sağlar.

Sade (concise)

Eşzamanlılık gerektiğinde go anahtar kelimesiyle fonksiyonları kolayca işleyebilmek de büyük bir avantajdır.

Mutex, Semaphore gibi karmaşık Lock'lar kullanmak gerektiğinde, Lock'lar kullanıldığında kaçınılmaz olarak Deadlock durumları da göz önünde bulundurulmalıdır, bu da geliştirme öncesindeki tasarım aşamasından itibaren karmaşık adımlar gerektirir.

Goroutine, "belleği paylaşarak iletişim kurma, iletişim kurarak belleği paylaş" felsefesine uygun olarak Channel aracılığıyla veri iletimini önerir ve SELECT, Channel ile birleşerek verinin hazır olduğu kanaldan işlem yapma özelliğini de destekler. Ayrıca, sync.WaitGroup kullanılarak tüm goroutine'lerin bitmesi basitçe beklenebilir, bu da iş akışını kolayca yönetmeyi sağlar. Bu araçlar sayesinde thread'ler arasındaki veri rekabeti sorunları önlenir ve daha güvenli eşzamanlılık işleme mümkün olur.

Ayrıca, context kullanılarak User-Level'da yaşam döngüsü, iptal, zaman aşımı, son teslim tarihi ve istek kapsamı kontrol edilebilir, bu da belirli bir derecede kararlılık sağlar.

Goroutine'in Paralel Çalışması (GOMAXPROCS)

Goroutine'in eşzamanlılığının iyi olduğunu söyledim ama paralellik desteklemiyor mu diye merak edebilirsiniz. Günümüzde CPU çekirdeklerinin sayısı geçmişten farklı olarak iki haneyi aşmakta ve ev bilgisayarlarında da çekirdek sayısı azımsanmayacak kadar bulunmaktadır.

Ancak Goroutine paralel işlemleri de gerçekleştirir, bu da GOMAXPROCS'tur.

GOMAXPROCS ayarlanmazsa, sürüme göre farklı şekilde ayarlanır.

  1. 1.5 öncesi: Varsayılan değer 1, 1'den fazla gerektiğinde runtime.GOMAXPOCS(runtime.NumCPU()) gibi bir yöntemle ayar zorunludur.

  2. 1.5 ~ 1.24: Mevcut tüm mantıksal çekirdek sayısına değiştirilmiştir. Bu zamandan itibaren geliştiricinin büyük ölçüde kısıtlama ihtiyacı olmadığı sürece ayarlama yapmasına gerek kalmamıştır.

  3. 1.25: Konteyner ortamında popüler bir dil olarak, Linux üzerindeki cGroup'u kontrol ederek konteynere ayarlanmış CPU limitini kontrol eder.

    Bu durumda, mantıksal çekirdek sayısı 10 ve CPU limiti 5 ise, GOMAXPROCS daha düşük olan 5 olarak ayarlanır.

1.25'teki değişiklik çok büyük bir düzeltme içerir. Konteyner ortamında dilin kullanımının artmasıyla ilgilidir. Bu sayede gereksiz thread oluşturma ve context switching azaltılarak CPU throttling önlenebilir.

 1package main
 2
 3import (
 4	"fmt"
 5	"math/rand"
 6	"runtime"
 7	"time"
 8)
 9
10func exe(name int, wg *sync.WaitGroup) {
11	defer wg.Done()
12
13	fmt.Printf("Goroutine %d: 시작\n", name) // Goroutine %d: Başlangıç
14	time.Sleep(10 * time.Millisecond) // İş simülasyonu için gecikme
15	fmt.Printf("Goroutine %d: 시작\n", name) // Goroutine %d: Başlangıç
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Sadece 2 CPU çekirdeği kullan
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount)
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg)
26	}
27
28	fmt.Println("모든 goroutine이 끝날 때까지 대기합니다...") // Tüm goroutine'ler bitene kadar bekleniyor...
29	wg.Wait()
30	fmt.Println("모든 작업이 완료되었습니다.") // Tüm işlemler tamamlandı.
31
32}
33

Goroutine'in Scheduler'ı (M:N Modeli)

Yukarıdaki M:N modeli kullanılarak OS thread'lere atanarak yönetilir kısmına biraz daha ayrıntılı girersek, goroutine GMP modeli vardır.

  • G (Goroutine): Go'da çalışan en küçük iş birimi
  • M (Machine): OS Thread (Gerçek işin yapıldığı yer)
  • P (Processor): Go runtime'ının yönettiği mantıksal işlemci

P ayrıca yerel bir çalışma kuyruğuna (Local Run Queue) sahiptir ve atanmış G'leri M'ye atayan bir zamanlayıcı görevi görür. Basitçe goroutine,

GMP'nin çalışma süreci aşağıdaki gibidir:

  1. G (Goroutine) oluşturulduğunda P (Processor) 'nin yerel çalışma kuyruğuna atanır.
  2. P (Processor), yerel çalışma kuyruğundaki G (Goroutine) 'yi M (Machine) 'ye atar.
  3. M (Machine), G (Goroutine) 'nin block, complete, preempted gibi durumlarını döndürür.
  4. Work-Stealing (İş Çalma): Eğer P'nin yerel çalışma kuyruğu boşalırsa, diğer P'ler global kuyruğu kontrol eder. Orada da G (Goroutine) yoksa, diğer yerel P (Processor)'nin işlerini çalarak tüm M'lerin boş durmamasını sağlar.
  5. Sistem Çağrısı İşleme (Blocking): G (Goroutine) çalışırken bir Block oluşursa, M (Machine) bekleme durumuna geçer. Bu durumda P (Processor), Block olan M (Machine)'yi ayırır ve başka bir M (Machine) ile birleştirerek bir sonraki G (Goroutine)'yi çalıştırır. Bu sırada I/O işlemi sırasındaki bekleme süresinde bile CPU israfı olmaz.
  6. Bir G (Goroutine) uzun süre preempted durumda kalırsa, diğer G (Goroutine)'lere çalışma fırsatı verir.

Golang, GC (Garbage Collector)'yi de Goroutine üzerinde çalıştırarak, uygulamanın çalışmasını minimum düzeyde kesintiye uğratarak (STW) belleği paralel olarak temizleyebilir, bu da sistem kaynaklarının verimli kullanılmasını sağlar.

Son olarak, Golang dilin en güçlü avantajlarından biridir ve bunun dışında da birçok avantajı vardır, umarım birçok geliştirici Go'dan keyif alır.

Teşekkürler.