Go, eşzamanlılığı dilin çekirdeğine yerleştiren az sayıdaki genel amaçlı dilden biridir. go anahtar kelimesiyle başlatılan hafif iş parçacıkları (goroutine) ve bunlar arasındaki iletişimi sağlayan kanallar, servis mimarisinde hem basitlik hem de ölçeklenebilirlik sunar. Ancak bu yapı taşlarını doğru kurgulamak, sadece go func() yazmaktan çok daha fazlasını gerektirir: yaşam döngüsü yönetimi, hata yayılımı, iptal sinyalleri ve kaynak sınırları.
Bu rehber, orta seviye Go geliştiricilerinin eşzamanlı bir servisi öngörülebilir biçimde kurmasına odaklanır. Örnekler Go 1.21 ve sonrasındaki standart kütüphane API'lerini (özellikle context ve sync paketlerini) temel alır; bu sürümlerde context.WithCancelCause ve sync.OnceFunc gibi yardımcılar mevcuttur. Daha eski sürümlerde bu fonksiyonlar bulunmayabilir.
Önkoşullar
- Go temelleri: fonksiyonlar, arayüzler,
defer, hata yönetimi. goanahtar kelimesinin ne yaptığına dair fikir sahibi olmak.- Temel
contextkullanımı (HTTP handler'lardar.Context()görmüş olmak yeterli).
1. Goroutine'in Maliyeti ve Sınırı
Bir goroutine başlangıçta birkaç kilobayt yığınla başlar ve ihtiyaç duydukça büyür. Bu, on binlerce eşzamanlı işi mümkün kılar; ancak "sınırsız goroutine" bir tasarım değildir. Her goroutine bir kaynak tüketir: bellek, dosya tanıtıcısı, ağ bağlantısı, dış servis kotası.
Pratik kural: Bir goroutine'in nasıl biteceğini yazmadan onu başlatmayın. Sızıntıya açık en yaygın kalıp, bir kanala yazan ama o kanalı kimsenin okumadığı goroutine'dir.
// Sızıntı riski: kanal tamponsuz ve okuyucu yoksa bu goroutine sonsuza kadar bloke olur.
func leaky() {
ch := make(chan int)
go func() {
ch <- 42 // okuyucu yoksa burada kalır
}()
// ch okunmuyor
}
Bu fonksiyon, ch üzerinden kimse okumadığı için goroutine'i askıda bırakır. Çözüm, ya kanalı tüketmek ya da goroutine'e bir iptal yolu vermektir.
2. İptal Sinyali: context
context.Context, bir isteğin veya işin iptal edilmesini, zaman aşımına uğramasını ya da bir son teslim tarihine bağlanmasını taşır. Eşzamanlı servislerde iptal, goroutine sızıntısını önlemenin standart yoludur.
func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
for {
select {
case <-ctx.Done():
return // iptal edildi, temiz çıkış
case j, ok := <-jobs:
if !ok {
return // kanal kapandı
}
select {
case results <- j * 2:
case <-ctx.Done():
return
}
}
}
}
Burada iki noktaya dikkat edin:
ctx.Done()her ikiselectiçinde de kontrol edilir; iptal geldiğinde goroutine bloke kalmaz.jobskanalı kapandığındaokfalse olur ve döngü sonlanır. Kanalı kapatan taraf, onu yazan taraftır, okuyan taraf değil.
3. İşçi Havuzu (Worker Pool)
Sınırsız goroutine yerine sabit sayıda işçi kullanmak, dış kaynakları (veritabanı, API) korur ve yük altında davranışı öngörülebilir kılar.
func runPool(ctx context.Context, jobs []int, workers int) []int {
jobCh := make(chan int)
resultCh := make(chan int, len(jobs))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
worker(ctx, jobCh, resultCh)
}()
}
go func() {
defer close(jobCh)
for _, j := range jobs {
select {
case jobCh <- j:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(resultCh)
}()
var out []int
for r := range resultCh {
out = append(out, r)
}
return out
}
Davranış:
workerssayısı kadar goroutine iş kuyruğunu tüketir.- Üretici goroutine, işleri gönderir ve bitince
jobCh'i kapatır; böylece işçilerinfordöngüsü doğal olarak sonlanır. wg.Wait()tamamlandığındaresultChkapatılır; toplayıcı döngü (for r := range resultCh) bu sayede biter.resultChtamponlu (len(jobs)) tutulur; aksi halde toplayıcı başlamadan işçiler bloke olabilir.
Bu kalıbın sırası önemlidir: kanalları kapatmak ile WaitGroup'u beklemek birbirine bağlıdır. Yanlış sıralama ya panik (close sonrası yazma) ya da kilitlenme üretir.
4. Paylaşılan Durum ve Veri Yarışları
Goroutine'ler arasında bellek paylaşımı yarış koşullarına (data race) açıktır. Go'nun yarış dedektörü bunu tespit etmek için vardır:
go test -race ./...
go run -race ./cmd/server
Kurallar:
- Paylaşılan bir değişkeni birden fazla goroutine okuyor/yazıyorsa ve aralarında senkronizasyon yoksa bu bir yarıştır.
- Tercih edilen yol: veriyi kanallarla taşımak, paylaşılan belleği ise yalnızca gerekli olduğunda
sync.Mutexveyasync/atomicile korumaktır. sync.Mutex'i kopyalamayın;go vetbunu yakalar.
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
defer ile kilidi bırakmak, erken dönüşlerde kilit sızmasını engeller.
5. Hata Yayılımı
Goroutine içindeki bir hata, çağıran fonksiyona otomatik olarak yayılmaz. Panik ise tüm süreci düşürür. İki pratik yaklaşım:
- İşçiler hatayı bir kanal üzerinden toplayıcıya iletir.
panicsınırı gerekiyorsarecoverkullanılır, ancak bu son çare olmalıdır;recover'ı gizli hata yutma aracı olarak kullanmayın.
func workerErr(ctx context.Context, jobs <-chan int, errCh chan<- error) {
defer func() {
if r := recover(); r != nil {
errCh <- fmt.Errorf("panic: %v", r)
}
}()
// ... işleme
}
recover yalnızca aynı goroutine içinde çalışır; başka bir goroutine'in paniğini yakalayamaz.
6. Servis Örneği: HTTP İsteğine Bağlı Eşzamanlı İş
Gerçek servislerde eşzamanlılık genellikle istek yaşam döngüsüne bağlanır. İstek iptal edildiğinde (istemci bağlantıyı kapatırsa) alt işlerin de durması gerekir.
func (s *Server) handleAggregate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
type result struct {
val string
err error
}
ch := make(chan result, 2)
go func() { v, err := s.callUpstreamA(ctx); ch <- result{v, err} }()
go func() { v, err := s.callUpstreamB(ctx); ch <- result{v, err} }()
var vals []string
for i := 0; i < 2; i++ {
select {
case <-ctx.Done():
http.Error(w, "iptal edildi", http.StatusGatewayTimeout)
return
case res := <-ch:
if res.err != nil {
http.Error(w, "upstream hatası", http.StatusBadGateway)
return
}
vals = append(vals, res.val)
}
}
// ... yanıtı yaz
}
Notlar:
r.Context()istemci bağlantısı kapandığında otomatik iptal olur.chtamponlu tutulduğu için, birreturnsonrası kalan goroutine bloke olmaz; tampon yeterli değilse sızıntı riski vardır.http.Errorçağrısından sonra yanıt gövdesine yazmaya devam etmekhttp: superfluous response.WriteHeaderuyarısına yol açar; erken dönüşler bu yüzden önemlidir.
7. Yaygın Hatalar
- Kanalları yanlış tarafta kapatmak. Kanalı kapatan, onu yazan taraftır. Okuyucu tarafında
closeçağırmak, yazıcı goroutine'de panik üretir. selectiçindedefaultile meşgul bekleme (busy-wait).defaultbloğu, kanal boşken döngünün sürekli dönmesine yol açar. İptal veya zaman aşımı içinctx.Done()vetime.Afterkullanın.WaitGroup.Add'i goroutine içinde çağırmak.Add,Wait'ten önce ve goroutine başlamadan önce çağrılmalıdır; aksi halde yarış oluşur.context.Background()'u uzun ömürlü işlerde iptal yolu olmadan kullanmak. İptal edilemeyen iş, kapanış (shutdown) sırasında süreci askıda bırakır.- Kanal tamponunu keyfi büyütmek. Büyük tampon, sorunu gizler; geri basınç (backpressure) kaybolur.
8. Güvenlik Etkileri
Eşzamanlılık hataları güvenlik açığına da dönüşebilir:
- Yarış koşulları yetki kararlarını bozabilir. Örneğin bir kullanıcının yetkisini kontrol eden kod ile o kullanıcının oturum durumunu güncelleyen kod senkronize değilse, kontrol ile kullanım arasında durum değişebilir (TOCTOU benzeri durumlar). Paylaşılan durumu kilitle korumak burada güvenlik meselesidir.
- İptal edilmeyen goroutine'ler kaynak tüketerek DoS'a katkı sağlar. Saldırgan çok sayıda istek açıp bağlantıyı kapatırsa, iptal edilmeyen alt işler birikerek servisi tüketebilir.
recoverile hataları yutmak. Panik sonrası sessizce devam etmek, tutarsız durumda işlem yapılmasına yol açabilir. Kurtarma noktasında hatayı loglayın ve isteği güvenli biçimde sonlandırın.- Zaman aşımı olmayan dış çağrılar.
ctxtaşımayan HTTP istemcileri, yavaş bir karşı taraf yüzünden goroutine'leri süresiz tutar.
9. Kapanış (Graceful Shutdown)
Servis kapanırken çalışan goroutine'lerin bitmesi beklenmelidir. Tipik akış:
http.Serverüzerinden yeni bağlantıları durdurun (Shutdown(ctx)).- Kök context'i iptal ederek arka plan işlerine sinyal gönderin.
sync.WaitGroupveya benzeri bir mekanizmayla işlerin tamamlanmasını bekleyin.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("sunucu hatası: %v", err)
}
}()
// ... sinyal bekleme (SIGINT/SIGTERM)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("kapanış hatası: %v", err)
}
cancel() // arka plan işlerini iptal et
Shutdown, mevcut isteklerin bitmesini bekler; cancel() ise istek dışı arka plan goroutine'lerine iptal sinyali gönderir. İkisinin sırası, isteklere bağlı arka plan işlerinin yarıda kesilmemesi için önemlidir.
Sonuç
Eşzamanlı bir Go servisini ayakta tutan şey, goroutine sayısı değil, her goroutine'in nasıl biteceğinin tanımlı olmasıdır. context ile iptal, kanallarla veri akışı, sync ile korunan paylaşılan durum ve WaitGroup ile kapanış beklemesi bu dörtlünün etrafında kurulan yapı, hem sızıntıları hem de yarış kaynaklı hataları büyük ölçüde ortadan kaldırır. Yeni bir eşzamanlı blok yazmadan önce sorun: bu goroutine hangi koşulda durur, hatası nereye gider, iptal edildiğinde kaynaklarını bırakır mı? Bu üç soruya net yanıt veremiyorsanız, tasarım henüz tamamlanmamıştır.