Go'da Concurrency: Channels, Goroutines ve Senkronizasyon Mekanizmaları
23 Kasım 2024 • ☕️☕️ 10 dk okuma • 🏷 bilgisayar, yazılım, algoritma
Yazar tarafından şu dillere çevrildi: English
Go (Golang), Google tarafından 2007 yılında tasarlanmaya başlayan ve 2009’da açık kaynak olarak yayınlanan, özellikle yüksek performanslı ve ölçeklenebilir yazılımlar geliştirmeye odaklanmış modern bir programlama dilidir. Basit ve anlaşılır sözdizimi sayesinde hem deneyimli hem de yeni başlayan geliştiriciler tarafından kolayca öğrenilebilir. Aynı zamanda, hafif ve hızlı bir şekilde derlenebilen bir dil olduğu için C benzeri bir performans elde edilebilir.
Go’nun en güçlü özelliklerinden biri de dil seviyesinde gömülü olarak gelen concurrency (eşzamanlılık) desteğidir. Concurrency kavramı, modern uygulamalarda yüksek verimlilik ve ölçeklenebilirlik sağlamak açısından kritik öneme sahiptir. Go’da concurrency kullanımını mümkün kılan temel yapı taşı olan goroutine’ler, geleneksel iş parçacıklarına (thread) göre çok daha hafif ve kolay yönetilebilir bir yaklaşım sunar. Ayrıca, eşzamanlı çalışan parçaların kendi aralarındaki iletişim ve senkronizasyonunu basitleştirmek için channel gibi mekanizmalar sağlanır. Bu sayede, paylaşımlı veri yönetimi yerine “iletişim kurarak paylaşım” yaklaşımına dayanan güvenilir ve temiz bir tasarım uygulanabilir.
1. Concurrency Nedir? Parallelism ile Farkları
Öncelikle sıkça karıştırılan iki terimi ayırt etmekte fayda var: concurrency ve parallelism.
- Concurrency (Eşzamanlılık): Bir programın birden fazla işi aynı zaman aralığında ilerletebilme (interleaving) yeteneğidir. İşler birbiri ardına kısa zaman dilimlerinde parçalar halinde çalıştırılır ve bu şekilde kullanıcıya aynı anda birden fazla işin yürütüldüğü hissi verilir. İşletim sistemi çekirdeği veya dilin çalışma zamanı (runtime), hangi işin ne zaman çalıştırılacağına (schedule) karar verir.
- Parallelism (Paralellik): Fiziksel olarak aynı anda birden fazla işin yürütülmesidir. Örneğin çok çekirdekli bir CPU’da, birden fazla çekirdek aynı anda farklı işlemler üzerinde çalışabilir.
Go concurrency’i çok kolay bir şekilde kullanılabilir hale getirir ancak bu concurrency özelliği aynı makinede her zaman “paralel” çalışacağı anlamına gelmez. Go runtime, hangi goroutine’in hangi çekirdekte veya ne zaman çalıştırılacağını belirler. Doğru şekilde konfigüre edildiğinde ve çok çekirdekli bir işlemci üzerinde çalıştığında, Go kodunuz hem concurrency hem de parallelism sağlayabilir.
2. Goroutine Nedir?
Go’da concurrency’nin temel yapı taşı goroutine’lerdir. Goroutine, Go runtime içinde çalışan hafif (lightweight) bir iş parçacığı olarak düşünülebilir. Go’da bir goroutine başlatmak oldukça basittir. Herhangi bir fonksiyonun başına go anahtar kelimesi getirerek o fonksiyonu ayrı bir goroutine’de çalıştırabilirsiniz.
package main
import (
"fmt"
"time"
)
func helloWorld() {
fmt.Println("Hello World!")
}
func main() {
// Run the helloWorld function in a new goroutine
go helloWorld()
// Let's wait a bit in the main goroutine so that the other goroutine can run as well.
time.Sleep(time.Second)
fmt.Println("The main goroutine has ended.")
}
Yukarıdaki örnekte helloWorld()
fonksiyonunu go helloWorld()
ifadesiyle çağırdığımızda, bu fonksiyon ana goroutine’den bağımsız bir şekilde çalışmaya başlar. Ana goroutine sona erer ermez, program sonlanacağı için diğer goroutine’in de çalışmasına imkân kalmaz. Bu nedenle, küçük de olsa bir time.Sleep()
ekleyerek diğer goroutine’in sonuç üretmesini bekliyoruz.
Önemli Not: Gerçek projelerde bekleme için time.Sleep()
kullanmak yerine, genellikle senkronizasyon mekanizmalarından biri olan sync.WaitGroup
veya benzer yapıları kullanmak daha doğru bir yaklaşımdır.
3. Channel’lar (Kanallar)
Channel, Go’da goroutine’ler arasında veri aktarımını güvenli ve senkronize bir şekilde yapmaya yarayan veri yapılarıdır. Channel’ların en büyük avantajı, paylaşılan değişkenler kullanarak kilit (lock) yönetimi yapmak yerine, verinin kendisini goroutine’ler arasında transfer etmeye olanak vermesidir. Bu yaklaşım “paylaşma yerine iletişim” (communicating by sharing vs. sharing by communicating) felsefesinin güzel bir örneğidir.
3.1 Kanal Oluşturma ve Veri Gönderme/Alma
Bir kanal aşağıdaki gibi oluşturulur:
ch := make(chan int) // Creates a channel of type int (unbuffered channel)
Veri gönderimi ve alımı <-
operatörü ile gerçekleştirilir:
- Gönderim:
ch <- x
- Alım:
y := <-ch
Örnek bir kullanım:
package main
import (
"fmt"
)
func main() {
ch := make(chan string)
go func() {
// Sending data
ch <- "Data from Goroutine"
}()
// Receiving data
data := <-ch
fmt.Println(data)
}
Yukarıdaki örnekte, isimlendirilmemiş (anonymous) bir fonksiyonu goroutine olarak çalıştırıyoruz ve bu fonksiyon, ch
kanalına bir string gönderiyor. Ana goroutine ise bu string’i data := <-ch
ifadesiyle okuyor.
3.2 Buffered Channels
Standart olarak oluşturulan channel’lar “unbuffered” (tamponlanmamış) olarak adlandırılır ve gönderici ile alıcının aynı anda işlemi gerçekleştirmesini bekler. Yani kanalın her gönderimi bir alıcı tarafından okunana kadar bloklanır.
Eğer belirli bir kapasiteye sahip bir kanal oluşturursanız, bu bir “buffered channel” olur. Bu kanal belirli sayıda mesajı okuyucu olmadan da tutabilir.
ch := make(chan int, 5) // A buffered channel with a capacity of 5
Bu durumda ch
kanalına en fazla 5 adet int değerini alıcı beklemese bile gönderebilirsiniz. Ancak 6. gönderim, kapasite dolduğu için, bir değer alana kadar bloklanacaktır.
3.3 Kanalın Kapatılması (close)
Kanalı close(ch)
fonksiyonu ile kapatabilirsiniz. Kapatılan bir kanala tekrar veri gönderilmeye çalışılırsa “panic” oluşur. Kapalı kanaldan veri okumaya devam edebilirsiniz, ancak okunabilecek veri kalmadığında “zero value” (int için 0, string için "" vb.) dönecektir.
package main
import (
"fmt"
)
func main() {
ch := make(chan int, 3)
ch <- 10
ch <- 20
close(ch)
// Since the channel is closed, we can continue to read data,
// but as soon as we try to write, a panic occurs.
for val := range ch {
fmt.Println(val)
}
}
Yukarıdaki örnekte range
ifadesi kapatılmış kanaldaki mevcut tüm değerleri okur, yeni veri olmadığında döngüyü sonlandırır.
4. Senkronizasyon Mekanizmaları
Concurrency ile ilgilenirken birden fazla goroutine’in aynı kaynağa erişmesi, paylaşımlı bir veriyi güncellemesi veya okuması gerekebilir. Bu durumlarda veri bütünlüğünü (data consistency) korumak için senkronizasyon yöntemlerine ihtiyaç duyarız.
4.1 sync.WaitGroup
Birden fazla goroutine’in tamamlanmasını beklemek için kullanılan en temel yapı sync.WaitGroup
‘dır. Aşağıdaki örnekte, üç farklı fonksiyonu üç ayrı goroutine’de çalıştırıyoruz ve hepsinin bitmesini WaitGroup
kullanarak bekliyoruz:
package main
import (
"fmt"
"sync"
"time"
)
func doWork(num int, wg *sync.WaitGroup) {
defer wg.Done() // When the function finishes, the counter is decremented by calling Done().
fmt.Printf("Goroutine %d started\n", num)
time.Sleep(time.Millisecond * 500) // For example, let's wait 500 ms
fmt.Printf("Goroutine %d finished\n", num)
}
func main() {
var wg sync.WaitGroup
toplamGoroutine := 3
wg.Add(toplamGoroutine) // We are waiting for 3 goroutines
for i := 1; i <= toplamGoroutine; i++ {
go doWork(i, &wg)
}
// Waits until all goroutines are finished
wg.Wait()
fmt.Println("All goroutines completed")
}
Yukarıda:
wg.Add(n)
ile bekleyeceğimiz goroutine sayısını belirtiyoruz.- Her goroutine’in içinde
defer wg.Done()
diyerek, goroutine’in işi bittiğinde WaitGroup sayacını 1 azaltmasını sağlıyoruz. wg.Wait()
ifadesi, tüm goroutine’lerin (add ile eklenen) tamamlanmasını bekler.
4.2 sync.Mutex
ve sync.RWMutex
Paylaşımlı veriye aynı anda erişildiği durumlarda veri tutarlılığı sağlamak için sync.Mutex
(mutual exclusion lock) kullanılır. Bir “mutex” aynı anda yalnızca bir goroutine’in paylaşımlı kaynağa erişmesine izin verir.
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
func (sc *SafeCounter) Increment() {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.count++
}
func (sc *SafeCounter) Value() int {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.count
}
func main() {
var wg sync.WaitGroup
sc := SafeCounter{}
goroutineCount := 5
wg.Add(goroutineCount)
for i := 0; i < goroutineCount; i++ {
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
sc.Increment()
}
}()
}
wg.Wait()
fmt.Println("Total:", sc.Value())
}
Increment()
veValue()
metodlarındaLock()
veUnlock()
ile verilere tek seferde sadece bir goroutine’in erişmesini sağlıyoruz. Böylece veri tutarlılığı korunuyor.RWMutex
, okuma-yazma kilidi (read-write lock) olup, birden çok okuyucu goroutine’e aynı anda izin verir ancak bir yazıcı girince sadece yazıcıya izin verir. Böylece genellikle ağırlıklı olarak okuma işlemlerinin yapıldığı durumlarda performans kazandırır.
4.3 sync.Once
Bazen belirli bir fonksiyonun veya işlemin program boyunca yalnızca bir kez çalışmasını isteyebilirsiniz. Bu durum için sync.Once
yapısı kullanılır. Özellikle konfigürasyon, cache inicializasyonu gibi işlemler sadece bir kez yapılmak isteniyorsa oldukça faydalıdır.
package main
import (
"fmt"
"sync"
)
var once sync.Once
func initConfig() {
fmt.Println("The configuration is started.")
}
func main() {
var wg sync.WaitGroup
wg.Add(3)
for i := 0; i < 3; i++ {
go func(id int) {
defer wg.Done()
once.Do(initConfig)
fmt.Printf("Goroutine %d is running.\n", id)
}(i)
}
wg.Wait()
}
Bu örnekte initConfig()
fonksiyonu kaç tane goroutine çağırırsa çağırsın yalnızca bir kez çalıştırılacaktır.
5. Concurrency’de Dikkat Edilmesi Gerekenler
- Data Race (Veri Yarışı): Birden fazla goroutine aynı değişkeni aynı anda okuyup yazmaya çalışırsa veri yarışı oluşur. Bu durumda beklenmedik sonuçlar alabilirsiniz.
go run -race main.go
komutu ile Go programınızda veri yarışı test edebilirsiniz. - Kilitlerin Unutulması:
Mutex
kullanırken kilidi açmayı (unlock) unutursanız, diğer goroutine’ler sonsuza kadar bekleyebilir. Bu nedenledefer mu.Unlock()
gibi yaklaşımlar önerilir. - Ölçeklenebilirlik: Goroutine’ler çok hafif olsa da, aşırı sayıda goroutine kullanımı sistem kaynaklarının tükenmesine neden olabilir. Milyonlarca goroutine başlatmak teoride mümkün olsa da pratikte göz önünde bulundurulması gereken bellek ve işlemci limitleri vardır.
- Kapanmamış Kanallar: Özellikle uzun ömürlü servislerde, kanal kullanımını dikkatli yönetmek gerekir. İhtiyaç kalmayan kanallar doğru zamanda kapatılmazsa bekleyen goroutine’ler bloklanabilir veya “goroutine leak” (sızıntı) yaşanabilir.
- Zamanlama ve Yarış Koşulları: Concurrency, karmaşıklığı artırır. Kodun hangi sırayla çalışacağını tam olarak kestirmek güçtür. Bu nedenle test stratejileri, logging ve bellek izleme (profiling) önemlidir.
6. Örnek: İşleyiciler (Worker) ve Kanal Tabanlı Sıra
Aşağıda, 10 adet işi işlemesi için 3 adet “worker” (işleyici) goroutine başlatan, işleri bir kanaldan okuyan ve işleyen örnek bir kod bulunuyor:
package main
import (
"fmt"
"sync"
"time"
)
// Task represents an item or job to be processed
type Task struct {
ID int
}
// worker function processes tasks from the channel
func worker(id int, taskChan <-chan Task, wg *sync.WaitGroup) {
defer wg.Done()
for task := range taskChan {
fmt.Printf("Worker %d is processing task %d...\n", id, task.ID)
// Simulate some work
time.Sleep(time.Millisecond * 100)
fmt.Printf("Worker %d has completed task %d.\n", id, task.ID)
}
}
func main() {
taskCount := 10
workerCount := 3
// Create a channel to send tasks
taskChan := make(chan Task, taskCount)
// A WaitGroup will ensure that all tasks and workers finish
var wg sync.WaitGroup
// Start 3 workers
for i := 1; i <= workerCount; i++ {
wg.Add(1)
go worker(i, taskChan, &wg)
}
// Send 10 tasks to the channel
for i := 1; i <= taskCount; i++ {
taskChan <- Task{ID: i}
}
// Close the channel once all tasks have been sent
close(taskChan)
// Wait for all workers to finish
wg.Wait()
fmt.Println("All tasks have been processed, the program is now terminating.")
}
worker
fonksiyonu kendisine gelentaskChan
‘dan görevleri alarak (range ile) işliyor. Kanal kapatıldığında range döngüsü sonlanıyor ve fonksiyon çıkıyor. Ana fonksiyondawg.Add(1)
yaparak 3 worker başlatılıyor. Her worker tamamlandığındawg.Done()
çağırıyor. Görevler tamamlandığında, program sonlanıyor.
Program çalıştırıldığında çıktısı aşağıdaki gibi olacaktır.
Worker 3 is processing task 1...
Worker 1 is processing task 2...
Worker 2 is processing task 3...
Worker 2 has completed task 3.
Worker 2 is processing task 4...
Worker 3 has completed task 1.
Worker 3 is processing task 5...
Worker 1 has completed task 2.
Worker 1 is processing task 6...
Worker 1 has completed task 6.
Worker 1 is processing task 7...
Worker 2 has completed task 4.
Worker 2 is processing task 8...
Worker 3 has completed task 5.
Worker 3 is processing task 9...
Worker 2 has completed task 8.
Worker 2 is processing task 10...
Worker 3 has completed task 9.
Worker 1 has completed task 7.
Worker 2 has completed task 10.
All tasks have been processed, the program is now terminating.
Programın çalışır haline şuradan erişilebilir.
Bu yaklaşım, “worker pool” (işleyici havuzu) olarak bilinir ve genellikle büyük miktarda işi, belirli sayıda eşzamanlı çalışan işleyici ile paylaşmak için idealdir. Böylece kaynak kullanımını düzenler ve performans ile ölçeklenebilirlik sağlar.
Go basitlik ve verimlilik odaklı bir dil olarak concurrency konusunu dil seviyesinde mükemmel şekilde desteklemektedir.
- Goroutine‘ler, sistem iş parçacıklarına (thread) göre çok daha hafif ve kolay yönetilebilir yapılardır.
- Channel‘lar, veriyi paylaşmak yerine mesajlaşma paradigmasını benimser ve goroutine’ler arası iletişimi güvenli hale getirir.
- sync paketi (WaitGroup, Mutex, RWMutex, Once gibi yapılar) concurrency senaryolarında yaygın kullanılan senkronizasyon mekanizmalarını içerir.
Bu araçları doğru ve bilinçli kullanarak, yüksek performanslı ve ölçeklenebilir Go uygulamaları geliştirebilirsiniz. Ancak concurrency, beraberinde karmaşıklığı da getirir. Veri yarışı (race condition), kilitlenme (deadlock) gibi sorunlar çıkmaması için kodunuzu dikkatle tasarlamalı, test etmeli ve gerekli profil (profiling) araçlarını kullanmalısınız.
Unutmayın: Concurrency doğru kullanıldığında çok güçlü bir araçtır; yanlış kullanıldığında ise hataları takip etmesi ve gidermesi zor bir kabusa dönüşebilir. Go’nun basit concurrency modeli ve zengin standard kütüphanesi, bu yolda büyük kolaylıklar sunuyor.
Kaynaklar
- https://go.dev/
- https://medium.com/@gopinathr143/go-concurrency-patterns-a-deep-dive-a2750f98a102
- https://blog.stackademic.com/go-concurrency-visually-explained-channel-c6f88070aafa