Go'da Eşzamanlı Servis: Goroutine ve Kanal Yönetimi

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ı (go...

Go'da Eşzamanlı Servis: Goroutine ve Kanal Yönetimi

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.
  • go anahtar kelimesinin ne yaptığına dair fikir sahibi olmak.
  • Temel context kullanımı (HTTP handler'larda r.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 iki select içinde de kontrol edilir; iptal geldiğinde goroutine bloke kalmaz.
  • jobs kanalı kapandığında ok false 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ış:

  • workers sayısı kadar goroutine iş kuyruğunu tüketir.
  • Üretici goroutine, işleri gönderir ve bitince jobCh'i kapatır; böylece işçilerin for döngüsü doğal olarak sonlanır.
  • wg.Wait() tamamlandığında resultCh kapatılır; toplayıcı döngü (for r := range resultCh) bu sayede biter.
  • resultCh tamponlu (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.Mutex veya sync/atomic ile korumaktır.
  • sync.Mutex'i kopyalamayın; go vet bunu 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.
  • panic sınırı gerekiyorsa recover kullanı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.
  • ch tamponlu tutulduğu için, bir return sonrası 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 etmek http: superfluous response.WriteHeader uyarı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.
  • select içinde default ile meşgul bekleme (busy-wait). default bloğu, kanal boşken döngünün sürekli dönmesine yol açar. İptal veya zaman aşımı için ctx.Done() ve time.After kullanı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.
  • recover ile 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. ctx taşı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ış:

  1. http.Server üzerinden yeni bağlantıları durdurun (Shutdown(ctx)).
  2. Kök context'i iptal ederek arka plan işlerine sinyal gönderin.
  3. sync.WaitGroup veya 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.

İLGİLİ İÇERİKLER

Bunları da Okumak İsteyebilirsiniz