Go'da eşzamanlılık: goroutine ve channel pratiği
Go'nun eşzamanlılık modelini — goroutine ve channel'ı — uygulamalı kavramak. PHP'nin süreç modelinden gelince ne değişiyor?
Go öğrenmeye başladığımdan beri beklediğim konu buydu: concurrency. PHP’de paralel iş yürütmek için ya birden fazla process açarsınız ya da bir kuyruk sistemi kullanırsınız. Go’nun bu problemi dil düzeyinde nasıl ele aldığını görmek istiyordum.
Birkaç haftadır goroutine ve channel ile uğraşıyorum. Teorik anlayışla pratik kullanım arasındaki mesafeyi kapatmak zaman alıyor; ama zihin modelim oturmaya başladı.
Goroutine nedir?
Goroutine, Go’nun hafif eşzamanlı yürütme birimidir. Bir işletim sistemi thread’i değil — Effective Go’nun anlattığı gibi goroutine’ler birden fazla OS thread’i üzerine multiplekslenir; biri örneğin I/O beklerken bloklandığında diğerleri çalışmaya devam eder. Başlatmak için go anahtar kelimesi yeterli:
package main
import (
"fmt"
"time"
)
func selamlama(isim string) {
fmt.Printf("Merhaba, %s!\n", isim)
}
func main() {
go selamlama("Ali")
go selamlama("Ayşe")
go selamlama("Mehmet")
// Ana goroutine bitmeden diğerleri çalışsın:
time.Sleep(100 * time.Millisecond)
}
Bu örnek çalışıyor ama time.Sleep ile beklemek kötü bir pratik. Goroutine’lerin bitmesini sync.WaitGroup ile beklemek daha doğru:
package main
import (
"fmt"
"sync"
)
func isGor(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("İş %d tamamlandı\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go isGor(i, &wg)
}
wg.Wait()
fmt.Println("Tüm işler bitti.")
}
defer wg.Done() goroutine bitince WaitGroup sayacını düşürüyor. wg.Wait() ise sayaç sıfıra inene kadar bloklıyor.
Goroutine sızıntısı — sık yapılan bir hata
PHP’den gelince goroutine’lerin “otomatik kapandığını” sanmak kolay. Öyle değil. Bir goroutine hiç bitmeyecek bir kanaldan okuma veya yazma bekliyorsa sonsuza kadar çalışmaya devam eder — siz farkında olmasanız bile. Buna goroutine leak deniyor.
// Tehlikeli: goroutine asla bitmez
go func() {
result := <-ch // ch hiç kapatılmazsa burada takılır
fmt.Println(result)
}()
Bunun önüne geçmenin pratik yolu context.Context kullanmak veya kanalı doğru kapatmak. İlk birkaç haftada bu tuzağa birkaç kez düştüm; goroutine sayısını runtime.NumGoroutine() ile izlemek — o an var olan goroutine sayısını döndürüyor — sorunu fark ettirdi.
Channel: goroutine’ler arası iletişim
Channel, goroutine’ler arasında veri iletmek için kullanılan Go’ya özgü bir yapıdır. “Belleği paylaşarak iletişim kur” yerine “iletişim kurarak bellek paylaş” felsefesi var burada — Effective Go bunu tek cümleye indiriyor: “Do not communicate by sharing memory; instead, share memory by communicating.”
package main
import "fmt"
func topla(sayilar []int, sonuc chan<- int) {
toplam := 0
for _, n := range sayilar {
toplam += n
}
sonuc <- toplam // Channel'a gönder
}
func main() {
sayilar := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
sonuc := make(chan int)
// İki goroutine, listeyi ikiye bölerek topluyor
go topla(sayilar[:5], sonuc)
go topla(sayilar[5:], sonuc)
// İki sonucu al
a, b := <-sonuc, <-sonuc
fmt.Println("Toplam:", a+b) // 55
}
Channel’lar varsayılan olarak unbuffered’dır. Dil spesifikasyonunun ifadesiyle, kapasite sıfırsa ya da hiç verilmemişse kanal tamponsuzdur ve iletişim ancak gönderen ile alıcının ikisi de hazır olduğunda gerçekleşir. Bu senkronizasyonu garanti eder.
Buffered channel ise belirli sayıda değeri alıcı olmadan tutabilir:
ch := make(chan int, 3) // 3 değer tutabilir
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // Bu satır bloklardı — kanal dolu
Tamponlu kanal bir tür queue gibi davranıyor. Üretici-tüketici senaryolarında faydalı; ama tampon büyüklüğünü yanlış seçmek gizli tıkanmalara yol açabiliyor. Ben genelde önce tamponsuzu deniyor, gerçek bir performans sorunu görürsem tampon ekliyorum.
select: birden çok channel’ı dinlemek
select ifadesi, birden fazla channel’ı eş zamanlı dinlemeye yarıyor. Hangisi hazırsa oradan okuyor:
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "bir"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "iki"
}()
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println("ch1'den:", msg)
case msg := <-ch2:
fmt.Println("ch2'den:", msg)
}
}
}
select bloğuna default case eklenebiliyor. Spesifikasyon şöyle diyor: hiçbir iletişim ilerleyemiyorsa ve bir default case varsa o case seçilir; default yoksa select en az bir iletişim ilerleyebilene kadar bloklar. Bu, “varsa oku, yoksa geç” kalıbını uygulamak için kullanışlı.
PHP ile karşılaştırmak
PHP’de paralel iş yapmak için pcntl_fork(), Swoole gibi extension’lar ya da harici bir kuyruk sistemi gerekiyor. Bunların hiçbiri dil seviyesinde değil. Go’da goroutine ve channel, dilin kendisine yerleşik — standart kütüphane dışında hiçbir şey gereksiz.
Bu fark en çok şurada hissediliyor: Go’da eşzamanlı kod yazmak alışılmamış ama olağan. PHP’de eşzamanlılık, özel durumlara özgü hissettiriyor. Bir PHP dosyasını tarayıcı çağırdığında her istek kendi dünyasında çalışır, başkasının durumuna dokunmaz; bu izolasyon hem kolaylık hem de kısıtlamadır. Go’da ise birden fazla goroutine aynı belleğe erişebilir; bu güç ama dikkat gerektirir.
Hâlâ öğrenme sürecindeyim. Race condition, goroutine leak, channel’ların doğru kapatılması gibi konular dikkat gerektiriyor. Ama zihin modeli netleşti: goroutine ucuz, iletişim channel üzerinden, paylaşılan durum minimize edilmeli. Bu üç ilkeyi aklımda tuttuğumda kodun nereye gitmesi gerektiği çoğunlukla kendiliğinden ortaya çıkıyor.
Bir de concurrency ile parallelism ayrımını anlamak gerekiyor. Go’daki goroutine’ler eşzamanlı çalışır; aynı anda fiziksel olarak paralel çalışıp çalışmadıkları CPU çekirdek sayısına ve GOMAXPROCS ayarına bağlı — bu değer aynı anda kaç CPU’nun kod yürütebileceğini belirliyor. PHP’den gelince bu farkı görmek kolay değil — PHP’de “aynı anda birden fazla şey yap” kavramının karşılığı doğrudan süreç açmaktır. Go’da ise eşzamanlılık dil düzeyinde bir tasarım kararı; işi verimli sıralamak ile gerçekten paralel yürütmek ayrı katmanlar. Ayrımın kanonik anlatımı Rob Pike’ın “Concurrency is not parallelism” konuşması: eşzamanlılık bağımsız yürüyen süreçlerin bileşimi, paralellik ise hesaplamaların aynı anda yürütülmesi.
Kaynaklar
Bu konuda sorulanlar
Birbirine bağımlı birden fazla işi, ilk hata hepsini iptal edecek şekilde errgroup ile mi yürütmeliyim?
`errgroup.WithContext`'e geçip üstüne request-level bir timeout koyun ve ctx'i her HTTP/DB çağrısına geçirin; ihmal edilen ctx, iptali sessizce öldürür.
İç içe geçmiş servis çağrılarında context iptalini doğru şekilde nasıl yayarım?
İptalin yayılması için `r.Context()`'i her downstream çağrıya geçirin, zincirde `context.Background()` bırakmayın, side-effect'e `WithoutCancel` verin.
Goroutine sızıntılarını uzun süre çalışan bir Go servisinde nasıl tespit edip önlerim?
NumGoroutine()'i dışa açıp zirvede pprof goroutine dökümü alın, binlerce kopyalı park etmiş stack'in spawn noktasını ctx.Done()'a uydurun, goleak ekleyin.
Go'da GC baskısını sync.Pool ve escape analizi ile nasıl azaltırım?
Duraklamaların ardındaki düşman tahsis hızıdır: pprof ile en çok tahsis edeni öldürün, hot path'i sync.Pool ile havuzlayın, GOGC ayarını en sona bırakın.
Bu konudaki deneyler
Dile özgü serileştirme yerine dondurulmuş bir JSON zarfı; polyglot kuyruk standardı BabelQueue'ya dönüştü.
Şu an ne yapıyor
Dondurulmuş JSON zarfı dört dilde — PHP, Python, Go, Node.js — aynı baytları okutuyor; sidecar ya da broker eklentisi gerekmiyor. Polyglot kuyruk kuran ekipler spesifikasyonu ve SDK'ları bugün kullanabilir.
Diff'i makineden çıkarmadan bir modele denetleten, sağlayıcıdan bağımsız Go CLI; CommitBrief ürününe dönüştü.
Şu an ne yapıyor
Diff hiç makineden çıkmadan yerel kod incelemesi yapıyor; `Provider` arayüzü sayesinde Anthropic, OpenAI, Gemini ve Ollama aynı sözleşmeyle takılıyor. İmzalı binary'ler yayında — diff'ini bir bulut sağlayıcısına vermek istemeyen herkes kurabilir.
Yorumlar
Yorum yapmak için GitHub hesabınızla giriş yapmanız yeterli. Yorumlar GitHub Discussions üzerinde saklanır.