go
Go dersleri
Go/Eşzamanlılık

Kanallar (Channels)

Tamponlu ve tamponsuz kanallar, select, zaman aşımları ve kilitlenmeler.

Ders 14 / 2130 dkOrta
Bu derste öğreneceklerin
  • Kanal oluşturma, gönderme ve alma
  • Tamponsuz ve tamponlu kanallar
  • close ve range ile okuma
  • Yönlü kanallar (chan<- ve <-chan)
  • select ifadesi ve default
  • time.After ile zaman aşımı
  • nil kanallar ve deadlock

Önceki derste goroutine başlatmayı ve paylaşılan belleğe korumasız erişimin nasıl yarış durumlarına yol açtığını gördün. Sonuçları dilimin ayrı hücrelerine yazmak işe yaradı; ama gerçek programlarda goroutine'lerin birbirine sürekli veri aktarması, birbirini beklemesi ve gerektiğinde birbirine "dur" demesi gerekir. Go'nun bu iş için sunduğu temel araç kanaldır (channel).

Kanalı, iki goroutine arasına döşenmiş tipli bir boru gibi düşünebilirsin: bir uçtan değer konur, diğer uçtan alınır. Önemli olan, borunun yalnızca veri taşımaması, zamanlamayı da koordine etmesidir. Tamponsuz bir kanalda gönderen, alıcı gelene kadar bekler; böylece ayrı bir kilit ya da WaitGroup kullanmadan iki goroutine'i buluşturabilirsin. Go dünyasında sıkça tekrarlanan "belleği paylaşarak iletişim kurma, iletişim kurarak belleği paylaş" ilkesi tam olarak bu fikri anlatır; ilkeyi Senkronizasyon dersinde ayrıntılı tartışacağız.

Bu derste kanal oluşturmayı, göndermeyi ve almayı; tamponsuz ve tamponlu kanallar arasındaki farkı; close ve range ile bir akışın sonunu işaretlemeyi ve yönlü kanal tipleriyle fonksiyonları güvenli hâle getirmeyi öğreneceksin. Ardından select ile birden çok kanalı aynı anda dinleyecek, time.After ile zaman aşımı koyacak, nil kanalları bilinçli bir araç olarak kullanacak ve korkulan deadlock hatasının nedenlerini tanıyacaksın. Son olarak generator ve done kanalı desenleriyle bu parçaları bir araya getireceğiz.

Kanal oluşturma, gönderme ve alma

Bir kanalın tipi chan T biçimindedir; T, kanaldan geçecek değerlerin tipidir. Kanallar make ile oluşturulur:

ch := make(chan int)          // tamponsuz int kanalı
jobs := make(chan string, 10) // 10 elemanlık tamponlu kanal
var results chan float64      // nil kanal: henüz oluşturulmadı

İki temel işlem ok operatörüyle (<-) yazılır. Okun yönü, verinin akış yönünü gösterir:

ch <- 42  // gönderme: 42 değeri kanala akar
v := <-ch // alma: kanaldan bir değer çıkar ve v'ye atanır
<-ch      // alma: gelen değer kullanılmadan atılır

Kanal değerleri, haritalar (map) gibi, çalışma zamanındaki bir veri yapısına referans taşır. Bir kanalı fonksiyona geçirdiğinde kopyalanan bu referanstır; iki taraf da aynı kanalı kullanır. Kanalın sıfır değeri nil'dir ve kanallar == ile karşılaştırılabilir.

İlk örnekte bir dilimi iki parçaya bölüp her parçanın toplamını ayrı bir goroutine'de hesaplıyoruz. Sonuçlar aynı kanal üzerinden geri geliyor:

main.go
package main

import (
	"fmt"
	"time"
)

func sum(nums []int, out chan int) {
	total := 0
	for _, n := range nums {
		total += n
	}
	time.Sleep(200 * time.Millisecond) // uzun bir hesap gibi
	out <- total                       // sonucu kanala gönder
}

func main() {
	nums := []int{3, 1, 4, 1, 5, 9, 2, 6}
	out := make(chan int)

	go sum(nums[:4], out)
	go sum(nums[4:], out)

	a, b := <-out, <-out // iki sonucu da bekle
	fmt.Println("toplam:", a+b)
}
Çıktı
toplam: 31

Bu programda WaitGroup yok, ama main erken bitmiyor. Neden? Çünkü <-out ifadesi, kanala bir değer gelene kadar main'i bloklar. Kanal hem veriyi taşıdı hem de bekleme işini üstlendi. Hangi parçanın önce bittiğini bilmiyoruz; a ile b'nin değerleri çalıştırmadan çalıştırmaya yer değiştirebilir. Ama toplama işlemi sıradan bağımsız olduğu için çıktı hep aynıdır.

Tamponsuz kanallar: buluşma noktası

make(chan T) ile oluşturulan kanalın tamponu yoktur. Tamponsuz bir kanalda gönderme ve alma bir buluşmadır (rendezvous): gönderen bir alıcı gelene kadar, alıcı da bir gönderen gelene kadar bekler. Değer, iki goroutine aynı anda hazır olduğunda doğrudan elden ele geçer. Bayrak yarışındaki sopa devri gibidir: koşucu sopayı yere bırakıp gidemez, bir sonraki koşucu elini uzatana kadar bekler.

make(chan string): tamponsuz, kapasite 0

gönderen goroutine                     alıcı goroutine
──────────────────                     ───────────────
ch <- "merhaba"
   │  alıcı yok, bekliyor...
   │                                   (500 ms sonra)
   │                                   msg := <-ch
   └───────────── "merhaba" ─────────▶ msg = "merhaba"
devam eder                             devam eder

Değer, iki taraf aynı anda hazır olduğunda elden ele geçer; arada depo yoktur.

Aşağıdaki program bu diyagramı birebir canlandırıyor. Çalıştırdığında ilk satırdan sonra yarım saniyelik bir duraklama göreceksin:

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string)
	done := make(chan struct{})

	go func() {
		fmt.Println("gönderen: mesajı gönderiyorum...")
		ch <- "merhaba" // alıcı gelene kadar burada bekler
		fmt.Println("gönderen: mesaj teslim edildi")
		close(done)
	}()

	time.Sleep(500 * time.Millisecond)
	fmt.Println("alıcı: 500 ms sonra almaya geldim")
	msg := <-ch
	<-done // gönderenin son satırını yazmasını bekle
	fmt.Println("alıcı: gelen mesaj:", msg)
}
Çıktı
gönderen: mesajı gönderiyorum...
alıcı: 500 ms sonra almaya geldim
gönderen: mesaj teslim edildi
alıcı: gelen mesaj: merhaba

Gönderen goroutine yarım saniye boyunca ch <- "merhaba" satırında bekledi; "teslim edildi" satırı ancak alıcı geldikten sonra yazıldı. done kanalını, gönderenin son satırını yazmasını beklemek ve çıktının sırasını sabitlemek için kullandık. close(done) ile verilen bu "bittim" sinyalini birazdan ayrıntılı göreceğiz.

Tamponsuz kanalın verdiği garanti çok değerlidir: bir gönderme tamamlandığında, değerin alıcıya ulaştığından emin olursun. Bu yüzden tamponsuz kanallar yalnızca veri taşımak için değil, iki goroutine'i belirli bir noktada senkronize etmek için de kullanılır.

Tamponlu kanallar: kuyruk

make(chan T, n) ile oluşturulan kanalın n elemanlık bir tamponu vardır ve FIFO (ilk giren ilk çıkar) bir kuyruk gibi davranır:

  • Gönderme, tamponda yer varsa hemen tamamlanır; tampon doluysa yer açılana kadar bekler.
  • Alma, tamponda değer varsa hemen tamamlanır; tampon boşsa bir değer gelene kadar bekler.

Bir apartmanın posta kutusu gibi düşünebilirsin: postacı kutu dolana kadar mektup atabilir, sen de kutu boşalana kadar mektup alabilirsin; ikinizin aynı anda orada olması gerekmez. Çalışma zamanı bu tamponu sabit boyutlu bir dairesel dizi (ring buffer) olarak tutar ve bir sonraki yazma ile okuma konumlarını iki indeksle izler:

ch := make(chan int, 3)             tampon: 3 hücreli dairesel dizi

ch <- 10; ch <- 20
           0        1        2
        ┌────────┬────────┬────────┐
        │   10   │   20   │        │   len = 2, cap = 3
        └────────┴────────┴────────┘
            ▲                 ▲
          okuma             yazma

ch <- 30                             (yazma indeksi başa sarar)
        ┌────────┬────────┬────────┐
        │   10   │   20   │   30   │   len = 3: dolu, sonraki gönderme bekler
        └────────┴────────┴────────┘

      okuma = yazma

v := <-ch                            (v = 10)
        ┌────────┬────────┬────────┐
        │        │   20   │   30   │   len = 2
        └────────┴────────┴────────┘
            ▲        ▲
          yazma    okuma

len(ch) tamponda bekleyen eleman sayısını, cap(ch) tamponun kapasitesini verir:

main.go
package main

import "fmt"

func main() {
	ch := make(chan string, 3)

	ch <- "a"
	ch <- "b"
	fmt.Println("len:", len(ch), "cap:", cap(ch))

	ch <- "c" // tampon doldu; yine de alıcı olmadan tamamlandı
	fmt.Println("len:", len(ch), "cap:", cap(ch))

	fmt.Println(<-ch, <-ch) // FIFO: önce giren önce çıkar
	fmt.Println("len:", len(ch), "cap:", cap(ch))
}
Çıktı
len: 2 cap: 3
len: 3 cap: 3
a b
len: 1 cap: 3

Bu programda tek bir goroutine var ve hiçbir işlem beklemedi, çünkü üç gönderme de tampona sığdı. Aynı kodu tamponsuz bir kanalla yazsaydın ilk gönderme satırı sonsuza kadar beklerdi; bunun nasıl bir deadlock hatasıyla sonuçlandığını dersin ilerleyen bölümünde göreceksin.

Tamponsuz kanallarda len ve cap her zaman 0'dır. len(ch)'yi bir karar vermek için kullanırken dikkatli ol: değeri okuduğun anda başka bir goroutine kanala yazmış ya da kanaldan okumuş olabilir. Bu bilgi ancak izleme amaçlı (ör. kuyruk doluluğunu raporlamak) güvenilirdir.

Tamponsuz: make(chan T)Tamponlu: make(chan T, n)
Gönderme ne zaman bekler?Bir alıcı hazır olana kadar, her zamanYalnızca tampon doluyken
Alma ne zaman bekler?Bir gönderen hazır olana kadar, her zamanYalnızca tampon boşken
Gönderme bittiğindeDeğer alıcıya ulaşmıştırDeğer yalnızca tampona girmiştir
Tipik kullanımSenkronizasyon, el sıkışma, sinyalÜretici ile tüketici hızını dengelemek, iş kuyruğu, semafor

close ve range ile okuma

Bir üretici, göndereceği değerlerin bittiğini alıcıya close(ch) ile bildirir. Kapatma, "bu kanala başka değer gelmeyecek" sinyalidir ve alıcı tarafında şu sonuçları doğurur:

  • Kapalı kanalın tamponunda değer kaldıysa alıcılar önce onları alır.
  • Tampon boşaldıktan sonra her alma işlemi beklemeden döner ve kanal tipinin sıfır değerini verir.
  • Değerin gerçekten gönderilmiş mi yoksa kapanış yüzünden mi geldiğini anlamak için virgül-ok (comma ok) biçimi kullanılır: v, ok := <-ch. ok false ise kanal kapalı ve boştur.
main.go
package main

import "fmt"

func main() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	close(ch)

	v, ok := <-ch
	fmt.Println(v, ok) // tamponda kalan değer
	v, ok = <-ch
	fmt.Println(v, ok)
	v, ok = <-ch
	fmt.Println(v, ok) // kanal kapalı ve boş: sıfır değer, false
	v, ok = <-ch
	fmt.Println(v, ok) // beklemez, aynı sonucu yine verir
}
Çıktı
1 true
2 true
0 false
0 false

range ile okumak

Her değeri tek tek ok ile kontrol etmek yerine for v := range ch yazabilirsin. Döngü, kanal kapanıp tampon boşalana kadar değer almaya devam eder ve sonra kendiliğinden biter:

main.go
package main

import (
	"fmt"
	"time"
)

func produce(n int, out chan int) {
	for i := 1; i <= n; i++ {
		time.Sleep(100 * time.Millisecond)
		out <- i * i
	}
	close(out) // gönderen, işi bitince kapatır
}

func main() {
	squares := make(chan int)
	go produce(5, squares)

	sum := 0
	for sq := range squares { // kanal kapanınca döngü biter
		fmt.Println("alındı:", sq)
		sum += sq
	}
	fmt.Println("toplam:", sum)
}
Çıktı
alındı: 1
alındı: 4
alındı: 9
alındı: 16
alındı: 25
toplam: 55

close çağrısını unutsaydın range beşinci değerden sonra bir sonrakini beklemeye devam ederdi. Üretici bitmiş, main ise bekliyor olurdu; hiçbir goroutine ilerleyemeyeceği için program deadlock hatasıyla çökerdi.

Kapatma kuralları

close güçlü ama keskin bir araçtır. Kapalı bir kanala göndermeye çalışmak programı çökertir:

main.go
package main

import "fmt"

func main() {
	ch := make(chan int, 1)
	close(ch)

	v, ok := <-ch
	fmt.Println("alındı:", v, ok)

	ch <- 1 // kapalı kanala gönderme: panik!
	fmt.Println("buraya asla ulaşılmaz")
}
Çıktı
alındı: 0 false
panic: send on closed channel

goroutine 1 [running]:
main.main()
	/tmp/kanal/main.go:12 +0xb0
exit status 2

Kanalın üç durumu (nil, açık, kapalı) ve üç işlemi için davranışların tamamı şu tabloda:

İşlemnil kanalAçık kanalKapalı kanal
Gönderme ch <- vSonsuza kadar beklerAlıcı ya da tamponda yer varsa tamamlanır, yoksa beklerPanik
Alma <-chSonsuza kadar beklerDeğer varsa alır, yoksa beklerÖnce kalan değerler, sonra beklemeden sıfır değer ve ok == false
close(ch)PanikKanalı kapatırPanik

Bu tablodan üç pratik kural çıkar:

  1. Kanalı gönderen taraf kapatır, alan taraf değil. Alıcı kanalı kapatırsa, hâlâ gönderme yapan bir goroutine paniğe sürüklenir.
  2. Birden fazla gönderen varsa hiçbiri kanalı tek başına kapatmaz. Hepsinin bittiğini bilen tek bir koordinatör (ör. WaitGroup'u bekleyip ardından close çağıran bir goroutine) kapatır. Bu deseni Context ve Eşzamanlılık Desenleri dersindeki fan-in örneğinde göreceksin.
  3. Her kanalı kapatmak zorunda değilsin. Kapatma bir kaynak temizliği değil, bir sinyaldir; artık kimsenin erişmediği bir kanalı çöp toplayıcı zaten temizler. Yalnızca alıcının "akış bitti" bilgisine ihtiyacı varsa (ör. range kullanıyorsa) kapat.

Yönlü kanallar

Bir fonksiyon bir kanala yalnızca yazacak ya da yalnızca ondan okuyacaksa, bunu parametrenin tipine yazabilirsin:

TipAnlamıİzin verilen işlemler
chan TÇift yönlüGönderme, alma, close
chan<- TYalnızca göndermeGönderme, close
<-chan TYalnızca almaAlma

Okun konumunu şöyle hatırlayabilirsin: chan<- yazımında ok kanalın içine doğru girer (içeri yazılır), <-chan yazımında ok kanaldan dışarı çıkar (dışarı okunur). Çift yönlü bir kanal, yönlü bir kanal tipine örtük olarak dönüşür; yönlü bir kanal ise çift yönlüye geri dönüştürülemez.

main.go
package main

import (
	"fmt"
	"strings"
)

// producer kanala yalnızca gönderebilir.
func producer(words []string, out chan<- string) {
	for _, w := range words {
		out <- w
	}
	close(out) // gönderme yönü kapatmaya da izin verir
}

// shout bir kanaldan okur, başka bir kanala yazar.
func shout(in <-chan string, out chan<- string) {
	for w := range in {
		out <- strings.ToUpper(w) + "!"
	}
	close(out)
}

func main() {
	words := make(chan string)
	loud := make(chan string)

	go producer([]string{"go", "kanal", "select"}, words)
	go shout(words, loud)

	for w := range loud {
		fmt.Println(w)
	}
}
Çıktı
GO!
KANAL!
SELECT!

main, çift yönlü words ve loud kanallarını oluşturup fonksiyonlara geçirdi; derleyici onları parametre tiplerine göre kısıtladı. shout fonksiyonunun içinde yanlışlıkla okuma kanalına yazmaya ya da onu kapatmaya kalksaydın program derlenmezdi:

func shout(in <-chan string, out chan<- string) {
	in <- "x"
	close(in)
	// ...
}
Çıktı
./main.go:6:2: invalid operation: cannot send to receive-only channel in (variable of type <-chan string)
./main.go:7:8: invalid operation: cannot close receive-only channel in (variable of type <-chan string)

Yönlü tipler, bir fonksiyonun sözleşmesini belgelemenin ve yanlış kullanımı daha derleme aşamasında yakalamanın bedava bir yoludur. Kanal döndüren fonksiyonlar da genellikle <-chan T döndürür: çağıran taraf değer okuyabilir ama kanala yazamaz veya onu kapatamaz. Aşağıda generator desenini anlatırken bunu sıkça göreceksin.

select ifadesi

Şimdiye kadar her seferinde tek bir kanalı bekledik. Peki iki farklı kaynaktan hangisi önce veri gönderirse onu almak istersen? select, switch'e benzer ama her case'i bir kanal işlemidir:

select {
case msg := <-emails:
	fmt.Println("e-posta:", msg)
case msg := <-sms:
	fmt.Println("SMS:", msg)
case alerts <- "kontrol":
	fmt.Println("uyarı gönderildi")
}

select şu kurallarla çalışır:

  1. select'e girildiğinde tüm case'lerdeki kanal ifadeleri ve gönderilecek değerler bir kez, yukarıdan aşağı değerlendirilir.
  2. Case'lerden hazır olan, yani beklemeden tamamlanabilecek bir işlem seçilir ve yalnızca o yürütülür.
  3. Hiçbiri hazır değilse, biri hazır olana kadar beklenir.
  4. Birden fazlası aynı anda hazırsa aralarından rastgele biri seçilir. Böylece hiçbir case sürekli öncelik alıp diğerlerini aç bırakamaz.
  5. Bir default case'i varsa ve hiçbir işlem hazır değilse, beklemek yerine hemen default çalışır.

Aşağıdaki programda iki kaynak farklı hızlarda mesaj üretiyor; select hangisi önce hazırsa onu alıyor:

main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	fast := make(chan string)
	slow := make(chan string)

	go func() {
		for i := 1; i <= 3; i++ {
			time.Sleep(200 * time.Millisecond)
			fast <- fmt.Sprintf("hızlı %d", i)
		}
	}()
	go func() {
		time.Sleep(500 * time.Millisecond)
		slow <- "yavaş 1"
	}()

	for range 4 {
		select {
		case msg := <-fast:
			fmt.Println("fast kanalından:", msg)
		case msg := <-slow:
			fmt.Println("slow kanalından:", msg)
		}
	}
}
Çıktı
fast kanalından: hızlı 1
fast kanalından: hızlı 2
slow kanalından: yavaş 1
fast kanalından: hızlı 3

Hızlı kaynak yaklaşık 200, 400 ve 600. milisaniyelerde, yavaş kaynak 500. milisaniyede gönderiyor. select her turda hangi kanal hazırsa onu aldığı için mesajlar geliş sırasıyla yazıldı. Bekleme sürelerini bilerek aralıklı seçtik; gerçek hayatta iki kaynağın hangisinin önce geleceğini genellikle bilemezsin.

Hazır case'ler arasında rastgele seçim

İki case aynı anda hazırsa select yazıldıkları sıraya bakmaz. Bunu kendin görmek için her turda iki kanalı da doldurup hangisinin seçildiğini sayalım:

main.go
package main

import "fmt"

func main() {
	a := make(chan int, 1)
	b := make(chan int, 1)
	countA, countB := 0, 0

	for range 1000 {
		a <- 1 // her turda iki kanal da hazır
		b <- 1
		select {
		case <-a:
			countA++
			<-b // diğer kanalı boşalt
		case <-b:
			countB++
			<-a
		}
	}
	fmt.Println("a seçildi:", countA, "kez")
	fmt.Println("b seçildi:", countB, "kez")
}
Çıktı
a seçildi: 507 kez
b seçildi: 493 kez

Sayılar her çalıştırmada değişir ama ikisi de 500 civarında olur. Bu yüzden select içindeki case'lerin sırasına bir öncelik anlamı yükleme; öncelik gerekiyorsa bunu açıkça kodlaman gerekir (ör. önce yalnızca yüksek öncelikli kanalı default'lu bir select ile kontrol etmek).

default ile bloklamayan işlemler

default, select'i beklemeyen bir kontrole dönüştürür. "Kanalda değer varsa al, yoksa beklemeden devam et" ya da "kanalda yer varsa gönder, yoksa vazgeç" demenin yolu budur:

main.go
package main

import "fmt"

func main() {
	events := make(chan string, 2)

	for i := 1; i <= 4; i++ {
		msg := fmt.Sprintf("olay %d", i)
		select {
		case events <- msg:
			fmt.Println("kuyruğa eklendi:", msg)
		default:
			fmt.Println("kuyruk dolu, atlandı:", msg)
		}
	}

	select {
	case e := <-events:
		fmt.Println("işlendi:", e)
	default:
		fmt.Println("işlenecek olay yok")
	}
}
Çıktı
kuyruğa eklendi: olay 1
kuyruğa eklendi: olay 2
kuyruk dolu, atlandı: olay 3
kuyruk dolu, atlandı: olay 4
işlendi: olay 1

Bu kalıp, yavaş bir tüketicinin hızlı bir üreticiyi yavaşlatmasını istemediğin ve verinin kaybolmasının kabul edilebilir olduğu durumlarda (metrikler, ilerleme bildirimleri, hata ayıklama logları) kullanışlıdır. Ama dikkat: bir döngü içinde yalnızca default'lu bir select ile sürekli "hazır mı?" diye sormak, işlemciyi boşuna meşgul eden bir meşgul bekleme (busy waiting) yaratır. Gerçekten beklemen gerekiyorsa default'u kaldır.

time.After ile zaman aşımı

time.After(d), d süresi dolduğunda o anki zamanı gönderecek bir <-chan time.Time döndürür. Bunu select içinde bir case olarak kullanmak, herhangi bir kanal işlemine zaman aşımı eklemenin en kısa yoludur:

main.go
package main

import (
	"fmt"
	"time"
)

// fetch, delay kadar sürede yanıt veren yavaş bir servisi taklit eder.
func fetch(delay time.Duration) <-chan string {
	ch := make(chan string, 1) // tampon: zaman aşımında goroutine takılı kalmasın
	go func() {
		time.Sleep(delay)
		ch <- fmt.Sprintf("%v sonra gelen yanıt", delay)
	}()
	return ch
}

func main() {
	timeout := 300 * time.Millisecond
	for _, delay := range []time.Duration{100 * time.Millisecond, 600 * time.Millisecond} {
		select {
		case res := <-fetch(delay):
			fmt.Println("başarılı:", res)
		case <-time.After(timeout):
			fmt.Println("zaman aşımı:", timeout, "içinde yanıt gelmedi")
		}
	}
}
Çıktı
başarılı: 100ms sonra gelen yanıt
zaman aşımı: 300ms içinde yanıt gelmedi

select'e girerken hem fetch(delay) hem de time.After(timeout) bir kez çağrılır ve iki kanal birlikte beklenir; önce hazır olan kazanır. İlk turda servis 100 ms'de yanıt verdiği için sonuç kanalı, ikinci turda ise 300 ms dolduğu için zaman aşımı kanalı önce hazır oldu.

fetch içindeki kanalın 1 elemanlık tamponlu olduğuna dikkat et. Tamponsuz olsaydı, zaman aşımından sonra kimse o kanalı okumayacağı için arka plandaki goroutine gönderme satırında sonsuza kadar bekler, yani sızardı. Bu klasik sızıntıyı ve context paketiyle daha kapsamlı çözümünü Context ve Eşzamanlılık Desenleri dersinde inceleyeceğiz.

for-select döngüsü

Uzun yaşayan goroutine'lerin çoğu aynı iskeleti izler: sonsuz bir for döngüsünün içinde bir select. Her turda goroutine ya yeni bir iş alır ya da durması gerektiğini söyleyen bir sinyal:

main.go
package main

import (
	"fmt"
	"time"
)

func worker(jobs <-chan int, quit <-chan struct{}, finished chan<- struct{}) {
	defer close(finished)
	for {
		select {
		case j := <-jobs:
			time.Sleep(100 * time.Millisecond) // işi yapıyormuş gibi
			fmt.Println("işçi: iş", j, "tamamlandı")
		case <-quit:
			fmt.Println("işçi: durma sinyali alındı, çıkıyorum")
			return
		}
	}
}

func main() {
	jobs := make(chan int)
	quit := make(chan struct{})
	finished := make(chan struct{})

	go worker(jobs, quit, finished)

	for j := 1; j <= 3; j++ {
		jobs <- j // tamponsuz: işçi işi alana kadar bekler
	}
	close(quit) // işçiye dur de
	<-finished  // gerçekten çıktığından emin ol
	fmt.Println("main: işçi durdu")
}
Çıktı
işçi: iş 1 tamamlandı
işçi: iş 2 tamamlandı
işçi: iş 3 tamamlandı
işçi: durma sinyali alındı, çıkıyorum
main: işçi durdu

Çıktının sırası neden garanti? jobs tamponsuz olduğu için main'in üçüncü gönderimi, ancak işçi üçüncü işi aldığında tamamlanır. quit kapatıldığında işçi zaten üçüncü işin içindedir; işi bitirip döngünün başına döndüğünde hazır olan tek case quit olur.

Bu iskelette quit kanalına hiçbir değer göndermedik, onu yalnızca kapattık. Kapalı bir kanaldan alma her zaman beklemeden döndüğü için close, o kanalı dinleyen bütün goroutine'lere aynı anda ulaşan bir yayın (broadcast) sinyalidir; tek bir değer göndermek ise yalnızca bir alıcıyı uyandırırdı. Sinyal kanallarında chan struct{} kullanmak gelenektir: struct{} sıfır bayt yer kaplar ve kodu okuyana "bu kanal veri değil, olay taşıyor" mesajını verir.

nil kanallar

Tabloda gördüğün gibi nil bir kanalda gönderme ve alma sonsuza kadar bekler. Bu ilk bakışta yalnızca bir tuzak gibi görünür ama select içinde kullanışlı bir araca dönüşür: nil kanal içeren bir case hiçbir zaman hazır olmaz, dolayısıyla asla seçilmez. Bir kanal değişkenine nil atayarak o case'i devre dışı bırakabilirsin.

Klasik örnek, iki kanalı ikisi de kapanana kadar okumaktır. Kapanmış bir kanal her turda beklemeden sıfır değer döndürdüğü için, onu nil yapmazsan select durmadan o case'i seçer ve döngü boşa döner:

main.go
package main

import (
	"fmt"
	"slices"
)

func send(values []int) <-chan int {
	ch := make(chan int)
	go func() {
		for _, v := range values {
			ch <- v
		}
		close(ch)
	}()
	return ch
}

func main() {
	a := send([]int{1, 3, 5})
	b := send([]int{2, 4})

	var got []int
	for a != nil || b != nil {
		select {
		case v, ok := <-a:
			if !ok {
				a = nil // a kapandı: bu case artık hiç seçilmez
				continue
			}
			got = append(got, v)
		case v, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			got = append(got, v)
		}
	}

	slices.Sort(got) // geliş sırası değişebilir; sıralayıp yazdır
	fmt.Println("alınan değerler:", got)
	fmt.Println("iki kanal da kapandı")
}
Çıktı
alınan değerler: [1 2 3 4 5]
iki kanal da kapandı

Döngü koşulu a != nil || b != nil olduğu için iki kanal da kapanıp nil yapıldığında döngü biter. a = nil ataması kanalın kendisini değil, yalnızca main içindeki yerel değişkeni değiştirir; üretici goroutine'in elindeki kanal etkilenmez. İki kaynaktan gelen değerlerin sırası çalıştırmadan çalıştırmaya değişebileceği için sonucu yazdırmadan önce sıraladık.

Kilitlenme (deadlock)

Tüm goroutine'ler bir kanal işlemini, bir kilidi ya da bir WaitGroup'u bekliyorsa ve hiçbiri ilerleyemiyorsa program kilitlenmiştir (deadlock). Go çalışma zamanı bu durumu fark eder ve programı sonlandırır. En basit örnek, tek goroutine'li bir programda tamponsuz bir kanala gönderme yapmaktır:

main.go
package main

import "fmt"

func main() {
	ch := make(chan int)
	ch <- 1 // alıcı yok ve hiç olmayacak
	fmt.Println(<-ch)
}
Çıktı
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/tmp/kanal/main.go:7 +0x38
exit status 2

Hata mesajının altındaki yığın izi, her goroutine'in nerede ve neyi beklediğini gösterir: [chan send], ana goroutine'in 7. satırda bir gönderme işleminde takıldığını söyler. Alma işleminde takılanlar [chan receive], select içinde bekleyenler [select], WaitGroup bekleyenler [sync.WaitGroup.Wait] olarak listelenir.

Kilitlenmelerin en sık nedenleri şunlardır:

  • Tamponsuz bir kanala, karşısında alıcısı olmayan bir goroutine'den göndermek (ya da gönderen olmayan bir kanaldan okumak).
  • range ile okunan bir kanalı kapatmayı unutmak.
  • Tamponlu bir kanalı, onu okuyacak kimse yokken doldurmak.
  • make ile oluşturulmamış, yani nil bir kanalı (ör. bir struct alanını) kullanmak.
  • WaitGroup.Done çağrısını unutmak.
  • İki goroutine'in döngüsel olarak birbirini beklemesi: A, B'nin göndermesini beklerken B de A'nın göndermesini bekler.

Desenler: generator ve done kanalı

Generator

Generator, bir değer dizisini bir kanal üzerinden üreten ve bu kanalı <-chan T olarak döndüren fonksiyondur. Çağıran taraf, değerlerin nasıl üretildiğini bilmeden onları range ile tüketir:

main.go
package main

import "fmt"

// fibonacci ilk n Fibonacci sayısını üreten bir kanal döndürür.
func fibonacci(n int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		a, b := 0, 1
		for range n {
			out <- a
			a, b = b, a+b
		}
	}()
	return out
}

func main() {
	for v := range fibonacci(10) {
		fmt.Print(v, " ")
	}
	fmt.Println()
}
Çıktı
0 1 1 2 3 5 8 13 21 34

Fonksiyon kanalı oluşturur, üretimi yapacak goroutine'i başlatır ve hemen döner. Kanalı kapatma sorumluluğu, göndermeyi yapan goroutine'dedir (defer close(out)). Tamponsuz kanal sayesinde üretici tüketicinin hızında çalışır: tüketici bir değer istemedikçe bir sonrakini hesaplamaz.

Değerler tek bir goroutine içinde sırayla üretilip tüketiliyorsa, Go 1.23 ile gelen iteratörler (iter.Seq) daha basit ve daha hızlı bir alternatiftir; kanal ve goroutine maliyeti yoktur. Kanal tabanlı generator'lar, üretimin gerçekten ayrı bir goroutine'de yapılması gerektiğinde (ör. üretim sırasında G/Ç beklemesi varsa ya da birden çok tüketici aynı kanalı paylaşacaksa) değerlidir.

done kanalı ile erken durdurma

Generator sonsuz bir dizi üretiyorsa ya da tüketici değerlerin hepsini istemiyorsa ne olur? Tüketici döngüden çıktığında üretici goroutine bir sonraki gönderme satırında sonsuza kadar bekler ve sızar. Çözüm, üreticiye "artık yeter" diyebileceğimiz bir done kanalı vermektir:

main.go
package main

import "fmt"

func naturals(done <-chan struct{}) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; ; i++ {
			select {
			case out <- i:
			case <-done:
				return // tüketici vazgeçti: temizlenip çık
			}
		}
	}()
	return out
}

func main() {
	done := make(chan struct{})
	nums := naturals(done)

	for v := range nums {
		fmt.Println("alındı:", v)
		if v == 5 {
			break
		}
	}

	close(done) // üreticiye dur de
	for range nums {
		// üretici çıkıp kanalı kapatana kadar kalan değerleri boşalt
	}
	fmt.Println("üretici durdu, sızıntı yok")
}
Çıktı
alındı: 1
alındı: 2
alındı: 3
alındı: 4
alındı: 5
üretici durdu, sızıntı yok

Üretici goroutine her turda iki şeyi aynı anda dener: değeri göndermek ya da done sinyalini almak. Tüketici break ile döngüden çıkıp close(done) çağırdığında <-done case'i hazır hâle gelir ve goroutine döner. Sondaki boş for range nums döngüsü, üreticinin gerçekten çıkıp kanalını kapattığını görmek için var; gerçek kodda çoğu zaman buna ihtiyaç duymazsın.

Bu desen o kadar yaygındır ki standart kütüphane onu genelleştirmiştir: context.Context arayüzünün Done() metodu tam olarak böyle, iptal edildiğinde kapanan bir kanal döndürür. Sonraki derslerde done kanalı yerine ctx.Done() göreceksin.

Sık yapılan hatalar

  • Kanalı alıcı tarafta kapatmak. Kanalı yalnızca gönderen ya da bütün gönderenleri koordine eden taraf kapatmalı; aksi hâlde gönderen goroutine send on closed channel paniğiyle çöker.
  • Bir kanalı iki kez kapatmak. close of closed channel paniği oluşur. Kapatmayı tek bir yerde topla; birden fazla goroutine kapatabiliyorsa sync.Once kullan.
  • range ile okunan kanalı kapatmayı unutmak. Döngü hiç bitmez: başka goroutine kalmadıysa program deadlock ile çöker, kaldıysa goroutine sessizce sızar.
  • Kilitlenmeyi tampon ekleyerek "düzeltmek". Tampon sorunu yalnızca yük artana kadar erteler; tasarımı düzelt.
  • Struct alanındaki kanalı make ile oluşturmayı unutmak. Sıfır değer nil'dir ve nil kanaldaki her gönderme ve alma sonsuza kadar bekler.
  • len(ch) ile karar vermek. "Tampon boş değilse oku" gibi bir kontrol güvenilmezdir, çünkü kontrol ile okuma arasında durum değişebilir. select ve default kullan.
  • Zaman aşımına uğrayan işin goroutine'ini sızdırmak. Sonucu kimse okumadığında gönderen goroutine sonsuza kadar bekler. Sonuç kanalını 1 elemanlık tamponlu yap ya da goroutine'e bir iptal sinyali ver.
  • select case sırasına öncelik anlamı yüklemek. Birden fazla case hazırsa seçim rastgeledir.
  • default ile meşgul bekleme. Döngüde sürekli default'a düşen bir select işlemciyi boşuna tüketir.

Alıştırmalar

Alıştırma·Sonuçları kanalla topla
Kolay

1'den 5'e kadar her n için ayrı bir goroutine başlat. Her goroutine n'nin küpünü hesaplasın ve sonucu result{n, cube} struct'ı olarak ortak bir kanala göndersin. main beş sonucu kanaldan alıp n sırasına göre yazdırsın. WaitGroup kullanma; beklemeyi kanal üstlensin.

1³ = 1
2³ = 8
3³ = 27
4³ = 64
5³ = 125
İpucu

Sonuçlar goroutine'lerin bitiş sırasıyla gelir. Her sonucu cubes[r.n-1] = r.cube gibi indeksle bir dilime yerleştirirsen yazdırma sırası geliş sırasından bağımsız olur. Tam olarak 5 kez <-results yapman yeterli.

Çözümü göster
main.go
package main

import "fmt"

type result struct {
	n, cube int
}

func main() {
	const count = 5
	results := make(chan result)

	for n := 1; n <= count; n++ {
		go func() {
			results <- result{n, n * n * n}
		}()
	}

	cubes := make([]int, count)
	for range count {
		r := <-results // geliş sırası belli değil
		cubes[r.n-1] = r.cube
	}

	for i, c := range cubes {
		fmt.Printf("%d³ = %d\n", i+1, c)
	}
}
Çıktı
1³ = 1
2³ = 8
3³ = 27
4³ = 64
5³ = 125

main, kanaldan tam olarak goroutine sayısı kadar değer aldığı için bütün goroutine'ler bitmeden ilerleyemez; ayrı bir WaitGroup'a gerek kalmaz. Sonuçları n ile birlikte göndermek, geliş sırasını önemsiz kılar.

Alıştırma·Ping-pong
Orta

İki goroutine, "ping" ve "pong", bir topu (bir int sayaç) paslaşsın. Ping kendi kanalından topu alınca adını ve topun değerini yazdırıp değeri bir artırarak pong'un kanalına göndersin; pong da aynısını ping'in kanalına yapsın. main oyunu ping'e 0 göndererek başlatsın. Top 6 değerine ulaştığında oyun bitsin, iki goroutine de temiz biçimde çıksın ve main "oyun bitti" yazdırsın.

ping 0
pong 1
ping 2
pong 3
ping 4
pong 5
oyun bitti
İpucu

İki oyuncu tek bir kanalı paylaşırsa ilk topu hangisinin alacağı belli olmaz; her oyuncuya ayrı bir giriş kanalı ver. Oyuncu fonksiyonunu player(name string, in <-chan int, out chan<- int) biçiminde yaz, for ball := range in ile oku ve defer close(out) kullan. Top sınıra ulaşan oyuncu döndüğünde kendi çıkış kanalını kapatır; bu da diğer oyuncunun range döngüsünü bitirir.

Çözümü göster
main.go
package main

import (
	"fmt"
	"sync"
)

const limit = 6

func player(name string, in <-chan int, out chan<- int) {
	defer close(out) // çıkarken rakibin range döngüsünü bitir
	for ball := range in {
		if ball == limit {
			return
		}
		fmt.Println(name, ball)
		out <- ball + 1
	}
}

func main() {
	toPing := make(chan int)
	toPong := make(chan int)

	var wg sync.WaitGroup
	wg.Go(func() { player("ping", toPing, toPong) })
	wg.Go(func() { player("pong", toPong, toPing) })

	toPing <- 0 // oyunu başlat
	wg.Wait()
	fmt.Println("oyun bitti")
}
Çıktı
ping 0
pong 1
ping 2
pong 3
ping 4
pong 5
oyun bitti

Kanallar tamponsuz olduğu için her an topu yalnızca bir oyuncu tutar ve satırlar kesin bir sırayla yazılır. Top 6 olduğunda onu ping alır, döner ve defer ile toPong kanalını kapatır. Pong'un range döngüsü biter, o da toPing'i kapatıp çıkar. İki goroutine de sonlandığı için wg.Wait() döner; hiçbir goroutine takılı kalmaz.

Alıştırma·En hızlı ayna
Orta

Aynı dosya üç farklı sunucuda (ayna) bulunuyor ve yanıt süreleri farklı: frankfurt 150 ms, istanbul 300 ms, virginia 450 ms. fastest(mirrors []mirror, timeout time.Duration) (string, error) fonksiyonunu yaz: tüm aynalara aynı anda istek atsın, ilk gelen yanıtı döndürsün; timeout süresi içinde hiç yanıt gelmezse hata döndürsün. Kaybeden aynaların goroutine'leri sonsuza kadar takılı kalmamalı. Fonksiyonu önce 250 ms, sonra 50 ms zaman aşımıyla çağır.

en hızlı: frankfurt
hata: 50ms içinde hiçbir aynadan yanıt gelmedi
İpucu

Sonuç kanalını make(chan string, len(mirrors)) ile oluştur. Böylece sen yalnızca ilk yanıtı okusan bile diğer goroutine'ler gönderimlerini tampona yapıp çıkabilir. Beklemeyi select içinde sonuç kanalı ve time.After(timeout) ile yap; hata için fmt.Errorf kullan.

Çözümü göster
main.go
package main

import (
	"fmt"
	"time"
)

type mirror struct {
	name  string
	delay time.Duration
}

func fastest(mirrors []mirror, timeout time.Duration) (string, error) {
	// Tampon, okunmayan yanıtların goroutine'leri bloklamasını önler.
	results := make(chan string, len(mirrors))
	for _, m := range mirrors {
		go func() {
			time.Sleep(m.delay) // ağ gecikmesi gibi
			results <- m.name
		}()
	}

	select {
	case name := <-results:
		return name, nil
	case <-time.After(timeout):
		return "", fmt.Errorf("%v içinde hiçbir aynadan yanıt gelmedi", timeout)
	}
}

func main() {
	mirrors := []mirror{
		{"istanbul", 300 * time.Millisecond},
		{"frankfurt", 150 * time.Millisecond},
		{"virginia", 450 * time.Millisecond},
	}

	for _, timeout := range []time.Duration{250 * time.Millisecond, 50 * time.Millisecond} {
		name, err := fastest(mirrors, timeout)
		if err != nil {
			fmt.Println("hata:", err)
			continue
		}
		fmt.Println("en hızlı:", name)
	}
}
Çıktı
en hızlı: frankfurt
hata: 50ms içinde hiçbir aynadan yanıt gelmedi

İlk çağrıda frankfurt 150 ms'de yanıt verir ve 250 ms'lik zaman aşımından önce kazanır. İkinci çağrıda 50 ms dolduğunda henüz hiçbir yanıt gelmemiştir. Sonuç kanalının kapasitesi ayna sayısına eşit olduğu için, fastest döndükten sonra gelen yanıtlar da tampona yazılabilir ve bütün goroutine'ler sonunda çıkar. Kanal tamponsuz olsaydı her çağrıda kaybeden goroutine'ler sonsuza kadar takılı kalırdı.

Alıştırma·Sıralı iki kanalı birleştir
Zor

merge(a, b <-chan int) <-chan int fonksiyonunu yaz. a ve b, küçükten büyüğe sıralı değerler gönderip sonunda kapanan kanallar. merge, iki akışı birleştirerek yine sıralı değerler gönderen ve iş bitince kapanan bir kanal döndürsün. Bu, birleştirmeli sıralamanın birleştirme adımının kanallarla yazılmış hâlidir. Bir kanalın diğerinden önce bitmesi ve kanallardan birinin hiç değer göndermemesi durumlarını da doğru işle.

[1 2 3 4 6 7 9 10 11]
[5 8]
İpucu

Her iki kanaldan birer "baş" değeri virgül-ok biçimiyle oku: va, okA := <-a. Döngü, en az bir kanalda değer kaldığı sürece (okA || okB) dönsün. okA true ise ve b bitmişse ya da va <= vb ise va'yı gönderip a'dan yeni bir değer oku; değilse aynısını b için yap. Test için değişken sayıda argüman alan bir fromSlice(nums ...int) <-chan int generator'ı işini kolaylaştırır.

Çözümü göster
main.go
package main

import "fmt"

func fromSlice(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

func merge(a, b <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		va, okA := <-a
		vb, okB := <-b
		for okA || okB {
			if okA && (!okB || va <= vb) {
				out <- va
				va, okA = <-a
			} else {
				out <- vb
				vb, okB = <-b
			}
		}
	}()
	return out
}

func collect(ch <-chan int) []int {
	var result []int
	for v := range ch {
		result = append(result, v)
	}
	return result
}

func main() {
	fmt.Println(collect(merge(fromSlice(1, 4, 6, 9), fromSlice(2, 3, 7, 10, 11))))
	fmt.Println(collect(merge(fromSlice(), fromSlice(5, 8))))
}
Çıktı
[1 2 3 4 6 7 9 10 11]
[5 8]

Her kanaldan her an yalnızca bir "baş" değer bekletilir; daha küçük olan gönderilir ve yalnızca onun kanalından yeni değer okunur. Bir kanal bittiğinde ok değeri false olur ve koşuldaki !okB kontrolü kalan değerlerin diğer kanaldan akmasını sağlar. va <= vb karşılaştırması eşit değerlerde önce a'yı seçtiği için birleştirme kararlıdır (stable). Toplam n + m değer için zaman karmaşıklığı O(n + m), ek alan O(1)'dir (tamponsuz kanallar değerleri biriktirmez). Eşleşen a <= b koşulunu < yapsaydın sonuç yine sıralı olurdu ama kararlılık kaybolurdu.

Kısa sınav

Kısa sınav

ch := make(chan int, 2) satırından sonra ch <- 1 ve ch <- 2 çalıştırılıyor. fmt.Println(len(ch), cap(ch)) ne yazdırır?

Kapalı ve tamponu boş bir chan string kanalından v, ok := <-ch ile okunursa ne olur?

func main() { ch := make(chan int); ch <- 1; fmt.Println(<-ch) } programı çalıştırılınca ne olur?

Bir select ifadesinde default case'i var ve o anda hiçbir kanal işlemi hazır değil. Ne olur?

for-select döngüsünde, kapanmış bir kanalı dinleyen case'in kanal değişkenine nil atanırsa ne olur?

Bir kanaldan yalnızca okuyabilen, ona yazamayan ve onu kapatamayan bir fonksiyonun imzası hangisidir?

Özet

  • Kanal, goroutine'ler arasında tipli değer taşıyan bir borudur: make(chan T) ile oluşturulur, ch <- v ile gönderilir, v := <-ch ile alınır; sıfır değeri nil'dir.
  • Tamponsuz kanal bir buluşma noktasıdır: gönderme ve alma birbirini bekler ve gönderme bittiğinde değer alıcıya ulaşmıştır. Tamponlu kanal bir FIFO kuyruktur ve yalnızca doluyken ya da boşken bekletir.
  • close(ch) "başka değer yok" sinyalidir. Kapalı kanaldan alma kalan değerleri, sonra sıfır değer ve ok == false döndürür; kapalı kanala gönderme ve tekrar kapatma panik oluşturur. Kanalı gönderen taraf kapatır.
  • for v := range ch, kanal kapanıp boşalana kadar okur. Yönlü tipler (chan<- T, <-chan T) yanlış kullanımı derleme zamanında yakalar.
  • select hazır olan case'i seçer, birden fazla hazırsa rastgele seçer; default beklemeyen işlemler, time.After zaman aşımları sağlar.
  • for-select döngüsü uzun yaşayan goroutine'lerin iskeletidir; kapatılan bir chan struct{} tüm dinleyicilere ulaşan bir durma sinyalidir.
  • nil kanaldaki işlemler sonsuza kadar bekler; select içinde bir case'i devre dışı bırakmak için kanal değişkenini nil yapabilirsin.
  • Bütün goroutine'ler takılırsa çalışma zamanı deadlock hatası verir; kısmi kilitlenmeler ise sessiz sızıntılara dönüşür. Generator'ları done kanalı (ya da context) ile durdurulabilir yaz.
Bu dersi bitirdin mi?
İlerlemen bu tarayıcıda saklanır.