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

Goroutine'ler

Eşzamanlılık ve paralellik, go anahtar kelimesi, WaitGroup ve yarış durumları.

Ders 13 / 2125 dkOrta
Bu derste öğreneceklerin
  • Eşzamanlılık (concurrency) ve paralellik farkı
  • go anahtar kelimesi
  • sync.WaitGroup ve wg.Go
  • Go zamanlayıcısı (G-M-P modeli)
  • GOMAXPROCS
  • Yarış durumu (race condition) ve -race bayrağı
  • Döngü değişkeni yakalama (Go 1.22 değişikliği)

Bir restoran mutfağı düşün. Tek bir aşçı çorbayı ocağa koyar, çorba pişerken salatayı doğrar, arada fırındaki ekmeğe göz atar. Herhangi bir anda yalnızca bir işe elini sürer ama hiçbir yemek diğerinin bitmesini beklemez. Mutfağa ikinci bir aşçı geldiğinde ise iki iş gerçekten aynı anda yapılabilir. Modern yazılımlar da böyle çalışır: bir web sunucusu binlerce bağlantıyla ilgilenir, bir veri işleme programı diskten okurken ağdan yanıt bekler, bir masaüstü uygulaması arayüzü dondurmadan arka planda hesap yapar.

Go, eşzamanlılığı (concurrency) sonradan eklenmiş bir kütüphane olarak değil, dilin çekirdeğine yerleştirilmiş bir özellik olarak sunar. Bir fonksiyon çağrısının önüne go yazman, onu bağımsız çalışan hafif bir yürütme birimine, yani bir goroutine'e dönüştürmeye yeter. Bu kolaylık beraberinde sorumluluk getirir: goroutine'lerin bitmesini doğru biçimde beklemek, aynı veriye güvenli erişmek ve çalışma zamanının (runtime) onları nasıl zamanladığını anlamak gerekir.

Bu derste eşzamanlılık ile paralellik arasındaki farkı netleştirecek, goroutine başlatıp sync.WaitGroup ile bekleyeceksin. Go zamanlayıcısının G-M-P modelini ve GOMAXPROCS ayarını öğreneceksin. Ardından yarış durumlarını (race condition) kendi gözünle görecek, -race bayrağıyla yakalayacak ve Go 1.22 ile değişen döngü değişkeni kuralının neden bu kadar önemli olduğunu anlayacaksın. Kanallar ve kilitler bu modülün sonraki derslerinde; burada temeli sağlamlaştırıyoruz.

Eşzamanlılık ve paralellik

Bu iki kelime günlük dilde neredeyse eş anlamlı kullanılır, ama yazılımda farklı şeyleri anlatır. Go'nun tasarımcılarından Rob Pike, "Concurrency is not Parallelism" başlıklı konuşmasında farkı şöyle özetler:

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

Yani eşzamanlılık, birçok işle aynı dönemde ilgilenebilecek biçimde programı yapılandırmaktır. Paralellik ise birçok işin fiziksel olarak aynı anda yürütülmesidir. Eşzamanlılık bir tasarım özelliği, paralellik bir yürütme özelliğidir. Mutfak örneğine dönersek: tek aşçının işleri parça parça ve iç içe ilerletmesi eşzamanlılık, iki aşçının iki işi aynı saniyede yapması paralelliktir.

Eşzamanlı ama paralel değil (tek çekirdek)
çekirdek 1: [A][B][A][C][B][A][C]      görevler parça parça, sırayla ilerliyor
            ──────────────────────▶ zaman

Eşzamanlı ve paralel (iki çekirdek)
çekirdek 1: [A  ][A  ][C  ][A  ]
çekirdek 2: [B  ][C  ][B  ][C  ]       aynı anda iki görev yürütülüyor
            ──────────────────────▶ zaman

İşin güzel yanı şu: iyi yapılandırılmış eşzamanlı bir program, donanım izin verdiğinde kendiliğinden paralel çalışır. Sen programı bağımsız parçalar hâlinde tasarlarsın; bu parçaların kaç çekirdeğe dağıtılacağına Go çalışma zamanı karar verir. Tek çekirdekte doğru çalışan eşzamanlı bir program sekiz çekirdekte daha hızlı biter ama sonucu değişmez. Sonuç değişiyorsa, bu derste göreceğimiz yarış durumlarından biriyle karşı karşıyasın demektir.

Eşzamanlılık (concurrency)Paralellik (parallelism)
Temel soruProgramı nasıl yapılandırıyorum?İşler aynı anda mı yürütülüyor?
DonanımTek çekirdek yeterliBirden fazla çekirdek gerekir
En çok kazandırdığı yerBekleme ağırlıklı işler (ağ, disk)CPU ağırlıklı işler (hesaplama)
Go'daki karşılığıgoroutine'ler, kanallar, selectÇalışma zamanının goroutine'leri birden çok thread'e dağıtması

Bu fark pratikte de önemlidir. Bir web sunucusunun zamanının büyük kısmı ağdan veri beklemekle geçer; eşzamanlılık bu bekleme sürelerini değerlendirir ve tek çekirdekte bile büyük kazanç sağlar. Bir görüntüye filtre uygulamak gibi hesaplama yoğun işlerde ise hızlanma ancak paralellikle gelir.

go anahtar kelimesi

Goroutine, aynı adres alanını paylaşan diğer goroutine'lerle birlikte bağımsız olarak çalışan bir fonksiyondur. Başlatmak için bir fonksiyon ya da metot çağrısının önüne go yazarsın:

go sendEmail(user) // isimli fonksiyon

go func() { // anonim fonksiyon
	cleanupTempFiles()
}()

go ifadesi çağrıyı başlatır ve hemen döner; çağıran taraf fonksiyonun bitmesini beklemez. Fonksiyonun dönüş değerleri varsa atılır. Bir goroutine'den sonuç almak istiyorsan kanal ya da korunan bir paylaşılan değişken kullanman gerekir. Bir ayrıntı daha: fonksiyonun argümanları, go satırı çalıştığı anda çağıran goroutine içinde değerlendirilir; yeni goroutine'de yalnızca fonksiyonun gövdesi çalışır. Bu, Fonksiyonlar dersinde gördüğün defer kuralıyla aynıdır.

main bitince program biter

Her Go programı tek bir goroutine ile başlar: main fonksiyonunu çalıştıran ana goroutine. main döndüğü anda süreç sonlanır ve diğer goroutine'lerin işini bitirmesi beklenmez. Aşağıdaki programı çalıştır ve ne olduğuna bak:

main.go
package main

import (
	"fmt"
	"time"
)

func greet(name string) {
	time.Sleep(100 * time.Millisecond) // biraz iş yapıyormuş gibi
	fmt.Println("Merhaba,", name)
}

func main() {
	go greet("Ayşe")
	go greet("Mehmet")
	fmt.Println("main bitti")
}
Çıktı
main bitti

İki goroutine başladı ama main onlardan önce bitti ve program selamları yazdıramadan kapandı. Goroutine'ler "arka planda bir yerde" yaşamaya devam etmez; süreç biterken hepsi onunla birlikte yok olur.

Akla gelen ilk çözüm, main'in sonuna bir bekleme eklemektir:

main.go
package main

import (
	"fmt"
	"time"
)

func greet(name string) {
	time.Sleep(100 * time.Millisecond)
	fmt.Println("Merhaba,", name)
}

func main() {
	go greet("Ayşe")
	time.Sleep(300 * time.Millisecond) // "herhalde bu sürede biter"
	fmt.Println("main bitti")
}
Çıktı
Merhaba, Ayşe
main bitti

Bu kez selam yazıldı, ama yaptığımız şey bir senkronizasyon değil, bir tahmin. İş 400 ms sürseydi selam yine kaybolurdu; 1 ms sürseydi programı boşuna 300 ms bekletmiş olurduk.

Goroutine'ler neden ucuz?

İşletim sistemi iş parçacıkları (thread) pahalıdır: her birine genellikle megabayt mertebesinde, sabit boyutlu bir stack alanı ayrılır ve aralarındaki geçiş işletim sistemi çekirdeğine uğramayı gerektirir. Goroutine'ler ise Go çalışma zamanı tarafından kullanıcı alanında yönetilir:

  • Küçük bir stack ile başlar. Güncel çalışma zamanında en küçük goroutine stack'i 2 KB'dir. Go 1.19'dan beri başlangıç boyutu, programdaki goroutine'lerin ortalama stack kullanımına göre ayarlanır.
  • Stack gerektiğinde büyür. Yer yetmediğinde çalışma zamanı daha büyük bir alan ayırıp içeriği oraya kopyalar; az kullanılan stack'ler çöp toplama sırasında küçültülebilir. 64-bit sistemlerde bir goroutine'in stack'i varsayılan olarak 1 GB'a kadar büyüyebilir.
  • Geçiş maliyeti düşüktür. Bir goroutine'den diğerine geçmek, çalışma zamanının birkaç register'ı kaydedip geri yüklemesinden ibarettir; işletim sistemi çekirdeğine gidilmez.

Bu yüzden bir Go programında on binlerce, hatta yüz binlerce goroutine çalıştırmak olağandır. Aşağıdaki program 100.000 goroutine başlatıyor; her biri sonucunu dilimin (slice) kendisine ait hücresine yazıyor:

main.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	const n = 100_000
	results := make([]int, n)

	var wg sync.WaitGroup
	for i := range n {
		wg.Go(func() {
			results[i] = i % 7 // her goroutine yalnızca kendi hücresine yazar
		})
	}
	wg.Wait()

	sum := 0
	for _, r := range results {
		sum += r
	}
	fmt.Println("goroutine sayısı:", n)
	fmt.Println("toplam:", sum)
}
Çıktı
goroutine sayısı: 100000
toplam: 299995

wg.Go ve wg.Wait'in ne yaptığını birazdan ayrıntılı göreceğiz; şimdilik "goroutine başlat" ve "hepsi bitene kadar bekle" diye oku. Bu program sıradan bir dizüstü bilgisayarda bir saniyenin çok altında biter. Aynı işi 100.000 işletim sistemi thread'iyle yapmaya kalkmak ise çoğu sistemde pratik değildir.

sync.WaitGroup ile beklemek

sync.WaitGroup, bir grup goroutine'in bitmesini beklemek için kullanılan, içinde bir sayaç taşıyan küçük bir yapıdır. Bir kargo deposundaki "yoldaki paket" tabelası gibi düşünebilirsin: her yeni gönderide sayı bir artar, her teslimatta bir azalır; depo sorumlusu sayı sıfırlanmadan kapıyı kilitlemez.

MetotNe yapar?
wg.Add(n)Sayacı n artırır. Goroutine başlatılmadan önce çağrılır.
wg.Done()Sayacı bir azaltır, wg.Add(-1) ile aynıdır. Goroutine bitince çağrılır.
wg.Wait()Sayaç sıfır olana kadar çağıran goroutine'i bloklar.
wg.Go(f)Go 1.25 ile geldi: sayacı artırır, f'i yeni bir goroutine'de çalıştırır, f dönünce sayacı azaltır.

WaitGroup'un sıfır değeri (zero value) kullanıma hazırdır; var wg sync.WaitGroup yazman yeterli.

Add, Done ve Wait

Önce klasik yazımı görelim. Bu kalıbı, wg.Go gelmeden önce yazılmış sayısız kod tabanında göreceksin:

main.go
package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // fonksiyon nasıl biterse bitsin sayacı azalt
	time.Sleep(time.Duration(id) * 200 * time.Millisecond)
	fmt.Printf("işçi %d bitti\n", id)
}

func main() {
	var wg sync.WaitGroup
	for id := 1; id <= 3; id++ {
		wg.Add(1) // goroutine'i başlatmadan önce
		go worker(id, &wg)
	}
	fmt.Println("main: işçiler başlatıldı, bekleniyor...")
	wg.Wait()
	fmt.Println("main: hepsi bitti")
}
Çıktı
main: işçiler başlatıldı, bekleniyor...
işçi 1 bitti
işçi 2 bitti
işçi 3 bitti
main: hepsi bitti

Çalıştırdığında satırların yaklaşık 200 ms arayla belirdiğini göreceksin. Üç işçi aynı anda başladı; her biri farklı süre uyuduğu için bitiş sırası belli. Gerçek programlarda bitiş sırası genellikle öngörülemez, burada sırayı bilerek farklı seçtiğimiz bekleme süreleri belirliyor. Toplam süre 1,2 saniye değil, en uzun işin süresi olan yaklaşık 600 ms'dir.

Bu kalıpta üç kurala dikkat etmelisin:

  1. Add, go satırından önce çağrılır. Add'i goroutine'in içine koyarsan, goroutine daha çalışmaya başlamadan main Wait'e ulaşabilir. Sayaç o anda sıfır olduğu için Wait hemen döner ve işler yarım kalır. Go 1.25'ten beri go vet bu hatayı "WaitGroup.Add called from inside new goroutine" uyarısıyla yakalar.
  2. Done, defer ile çağrılır. Böylece fonksiyon erken return etse ya da panik oluşsa bile sayaç azalır. Done hiç çağrılmazsa Wait sonsuza kadar bekler.
  3. WaitGroup fonksiyonlara işaretçiyle geçirilir. worker(id, wg) diye değer olarak geçirseydin fonksiyon bir kopyanın sayacını azaltırdı; asıl sayaç hiç sıfırlanmaz, Wait sonsuza kadar beklerdi. go vet bu hatayı "passes lock by value" uyarısıyla yakalar.

wg.Go: Go 1.25 ile gelen kısayol

Add(1), go ve defer Done() üçlüsü o kadar yaygındır ki Go 1.25 bu kalıbı tek bir metoda indirdi. Aynı program wg.Go ile şöyle yazılır:

main.go
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var wg sync.WaitGroup
	for id := 1; id <= 3; id++ {
		wg.Go(func() {
			time.Sleep(time.Duration(id) * 200 * time.Millisecond)
			fmt.Printf("işçi %d bitti\n", id)
		})
	}
	fmt.Println("main: işçiler başlatıldı, bekleniyor...")
	wg.Wait()
	fmt.Println("main: hepsi bitti")
}
Çıktı
main: işçiler başlatıldı, bekleniyor...
işçi 1 bitti
işçi 2 bitti
işçi 3 bitti
main: hepsi bitti

Ne Add ne de Done yazdık, dolayısıyla onları yanlış yere koyma ya da unutma ihtimali de ortadan kalktı. İşi yapan kodun artık *sync.WaitGroup parametresi almasına gerek yok; bekleme mantığı, işin kendisinden ayrıldı.

wg.Go'ya verilen fonksiyonun parametresi ve dönüş değeri yoktur (func()). Bir değere ihtiyacın varsa onu closure (kapanış) ile yakalarsın; yukarıda id döngü değişkenini doğrudan kullandık. Bunun neden güvenli olduğunu dersin sonunda göreceksin. Belgelere göre wg.Go'ya verilen fonksiyon panik oluşturmamalıdır. Yeni kodda wg.Go'yu tercih et; Add/Done kalıbını ise eski kodlarda ve goroutine'i senin yerine başka bir kodun başlattığı durumlarda kullanırsın.

Sonuçları deterministik biçimde toplamak

Goroutine'lerden sonuç toplarken iki şeye dikkat etmen gerekir: veriye güvenli yazmak ve çıktının sırasını kontrol etmek. En basit ve güvenli yol, her goroutine'e sonuç dilimindeki kendi indeksini vermektir. Dilimin farklı elemanları bellekte farklı değişkenlerdir; bu yüzden aynı anda yazılmaları bir yarış oluşturmaz.

main.go
package main

import (
	"fmt"
	"strings"
	"sync"
	"time"
)

// countWords yavaş bir işlemi (ör. ağdan belge indirmeyi) taklit eder.
func countWords(text string) int {
	time.Sleep(100 * time.Millisecond)
	return len(strings.Fields(text))
}

func main() {
	docs := []string{
		"Go basit ve okunabilir bir dildir",
		"goroutine'ler hafiftir",
		"eşzamanlılık bir tasarım biçimidir paralellik ise yürütme",
	}

	counts := make([]int, len(docs))
	var wg sync.WaitGroup
	for i, doc := range docs {
		wg.Go(func() {
			counts[i] = countWords(doc) // her goroutine kendi hücresine yazar
		})
	}
	wg.Wait()

	total := 0
	for i, c := range counts {
		fmt.Printf("belge %d: %d kelime\n", i, c)
		total += c
	}
	fmt.Println("toplam:", total, "kelime")
}
Çıktı
belge 0: 6 kelime
belge 1: 2 kelime
belge 2: 7 kelime
toplam: 15 kelime

Üç belge sırayla işlenseydi yaklaşık 300 ms sürerdi; goroutine'lerle toplam süre yaklaşık 100 ms'dir, çünkü bekleme süreleri üst üste biner. Sonuçları Wait'ten sonra, tek bir goroutine'den ve indeks sırasıyla yazdırdığımız için çıktı her çalıştırmada aynıdır. Goroutine'lerin içinden doğrudan fmt.Println çağırsaydık satırların sırası çalıştırmadan çalıştırmaya değişebilirdi.

Go zamanlayıcısı: G-M-P modeli

100.000 goroutine çalıştırdık ama bilgisayarında büyük olasılıkla 8-16 çekirdek var. Bu goroutine'leri çekirdeklere kim, nasıl dağıtıyor? Cevap, Go çalışma zamanının içindeki zamanlayıcı (scheduler). Go, M:N zamanlama kullanır: çok sayıda goroutine'i daha az sayıda işletim sistemi thread'i üzerinde çoğullar. Zamanlayıcının modeli üç harfle anlatılır:

  • G (goroutine): Çalıştırılacak iş. Kendi stack'ini, kaldığı yeri ve durumunu (çalışıyor, çalışmaya hazır, bekliyor) tutan küçük bir yapıdır.
  • M (machine): Bir işletim sistemi thread'i. Kodu gerçekten işlemci üzerinde yürüten şey budur.
  • P (processor): Go kodu çalıştırmak için gereken bağlam ve "izin". Her P'nin, çalışmaya hazır goroutine'lerden oluşan yerel bir çalıştırma kuyruğu vardır. Bir M, Go kodu çalıştırabilmek için bir P'yi elinde tutmak zorundadır. P sayısı GOMAXPROCS değerine eşittir.
                   ┌──────────────────────────────┐
                   │ Global kuyruk:   G8   G9     │
                   └──────────────────────────────┘

   ┌──────── P0 ────────┐              ┌──────── P1 ────────┐
   │ çalışan: G1        │              │ çalışan: G5        │
   │ yerel kuyruk:      │              │ yerel kuyruk:      │
   │   G2 → G3 → G4     │              │   G6               │
   └─────────┬──────────┘              └─────────┬──────────┘
             │                                   │
        M0 (OS thread)                      M1 (OS thread)
             │                                   │
        CPU çekirdeği                       CPU çekirdeği

   M2 (OS thread): G7 ile birlikte bir sistem çağrısında bloklu, elinde P yok

Bu resimde GOMAXPROCS 2'dir: iki P, dolayısıyla aynı anda en fazla iki goroutine Go kodu yürütebilir. G1 ve G5 şu anda çalışıyor; G2, G3, G4 ve G6 sıralarını bekliyor. M2 ise bir sistem çağrısında bloklu olduğu için elinde P tutmuyor.

Zamanlayıcı ne zaman devreye girer?

Bir goroutine şu durumlarda işlemciyi bırakır ve P, kuyruktaki sıradaki goroutine'e geçer:

  • Bloklandığında. Bir kanal işlemi, bir kilit beklemesi veya time.Sleep goroutine'i "bekliyor" durumuna geçirir. Thread bloklanmaz, P başka bir goroutine çalıştırır. Beklenen olay gerçekleşince goroutine yeniden bir çalıştırma kuyruğuna konur.
  • Ağ G/Ç'si (I/O) beklediğinde. Go'nun ağ katmanı, soketleri işletim sisteminin olay mekanizmasıyla (Linux'ta epoll, macOS'ta kqueue) izleyen bir netpoller kullanır. Soketi bekleyen goroutine park edilir; bir thread'i boşuna meşgul etmez.
  • Bloklayan bir sistem çağrısına girdiğinde. Burada thread gerçekten bloklanır. Çağrı kısa sürede bitmezse çalışma zamanı P'yi o M'den alıp başka bir M'ye verir; böylece diğer goroutine'ler çalışmaya devam eder. Çağrı bitince goroutine yeniden bir P bulup kuyruğa girer.
  • Çok uzun süre kesintisiz çalıştığında. Go 1.14'ten beri zamanlayıcı, yaklaşık 10 ms'den uzun süre çalışan bir goroutine'i, fonksiyon çağrısı içermeyen sıkı bir döngüde olsa bile, sinyal yoluyla asenkron olarak kesebilir (asynchronous preemption). Böylece tek bir goroutine bir P'yi tekeline alamaz.

İş çalma (work stealing)

Yeni başlatılan bir goroutine genellikle onu başlatan P'nin yerel kuyruğuna eklenir. Yerel kuyruk doluysa (256 goroutine), kuyruğun yarısı global kuyruğa aktarılır. Peki bir P'nin yapacak işi kalmazsa? Boş oturmak yerine sırayla şunlara bakar:

  1. Kendi yerel kuyruğuna. Adalet için arada bir (her 61 zamanlama turunda bir) önce global kuyruğa da göz atar; böylece global kuyruktaki işler aç kalmaz.
  2. Global kuyruğa.
  3. Netpoller'a: Ağ işlemi tamamlanmış, uyanmayı bekleyen bir goroutine var mı?
  4. Diğer P'lere: Rastgele seçtiği bir P'nin yerel kuyruğundaki goroutine'lerin yarısını çalar.
Önce:                                     Sonra:
P0 kuyruk: G2 G3 G4 G5 G6 G7              P0 kuyruk: G5 G6 G7
P1 kuyruk: (boş)                          P1 kuyruk: G2 G3 G4
           ▲                                         ▲
           └─ P1'in işi bitti, P0'ın                 └─ yük dengelendi
              kuyruğunun yarısını çaldı

Bu tasarım sayesinde iş, merkezi bir kilit darboğazına takılmadan çekirdeklere yayılır. Sen yalnızca go yazarsın; dağıtımı çalışma zamanı üstlenir.

GOMAXPROCS

GOMAXPROCS, aynı anda Go kodu yürütebilecek işletim sistemi thread'lerinin üst sınırıdır; G-M-P modelindeki P sayısıdır. Sistem çağrısında bloklanmış thread'ler bu sınıra dahil değildir. Bu yüzden bir Go sürecinin thread sayısı GOMAXPROCS'tan çok daha fazla olabilir.

Varsayılan değer, sürecin kullanabileceği mantıksal CPU sayısıdır. Go 1.25 ile bu hesap container'lara uyumlu hâle geldi: Linux'ta çalışma zamanı, cgroup üzerinden tanımlanan CPU sınırını (ör. Kubernetes'te bir container'a verilen CPU limit) da hesaba katıyor ve bu değerler değiştikçe GOMAXPROCS'u periyodik olarak güncelliyor. Bu davranış, go.mod dosyasındaki go satırı 1.25 veya daha yeni olan modüllerde varsayılan olarak açıktır. Daha önce container'larda bu ayarı düzeltmek için go.uber.org/automaxprocs gibi kütüphaneler kullanılırdı.

Kendi makinendeki değerleri görmek için:

main.go
package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("mantıksal CPU:", runtime.NumCPU())
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0: değiştirmeden sorgula
	fmt.Println("goroutine sayısı:", runtime.NumGoroutine())
}
Çıktı
mantıksal CPU: 8
GOMAXPROCS: 8
goroutine sayısı: 1

İlk iki satır makineden makineye değişir. runtime.GOMAXPROCS(n) ayarı değiştirir ve önceki değeri döndürür; n 1'den küçükse yalnızca mevcut değeri döndürür. Ayarı programa dokunmadan ortam değişkeniyle de verebilirsin:

GOMAXPROCS=1 go run main.go

GOMAXPROCS=1 iken program hâlâ eşzamanlıdır: goroutine'ler oluşturulur, iç içe ilerler, birbirini bekler. Ama artık paralel değildir; herhangi bir anda yalnızca bir goroutine Go kodu yürütür. Rob Pike'ın ayrımını kendi makinende deneyimlemenin en kolay yolu budur.

Peki elle ne zaman ayarlamalısın? Çok nadiren. Varsayılan değer çoğu program için doğrudur. Performans ölçümlerinde paralelliği bilinçli olarak kısıtlamak ya da eski Go sürümleriyle container içinde çalışırken CPU sınırına uymak için değiştirildiğini görebilirsin. Değeri çekirdek sayısının üzerine çıkarmak ise CPU yoğun işleri hızlandırmaz; yalnızca aynı çekirdekler için yarışan daha fazla thread ve daha fazla bağlam geçişi demektir.

Yarış durumu (race condition)

Goroutine'ler aynı adres alanını paylaşır. Bu, veri paylaşmayı kolaylaştırır ama tehlikeli de kılar. Şu programa bak: 100 goroutine ortak bir sayacı her biri 1000 kez artırıyor. Beklenen sonuç 100.000.

main.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	counter := 0
	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() {
			for range 1000 {
				counter++ // korumasız erişim: YARIŞ DURUMU
			}
		})
	}
	wg.Wait()
	fmt.Println("sayaç:", counter, "(beklenen: 100000)")
}
Çıktı
sayaç: 90185 (beklenen: 100000)

Bu çıktı yalnızca bir örnektir: sonuç her çalıştırmada farklıdır ve çok çekirdekli bir makinede neredeyse hiçbir zaman 100.000 değildir. Tek çekirdekli bir ortamda ya da şanslı bir çalıştırmada doğru sonucu görebilirsin; bu, programın doğru olduğu anlamına gelmez.

Artırmalar neden kayboluyor?

counter++ tek bir adım gibi görünür, ama işlemci düzeyinde üç ayrı adımdır: değeri oku, bir ekle, sonucu geri yaz. İki goroutine bu adımları iç içe geçirirse artırmalardan biri kaybolur:

başlangıçta counter = 41

goroutine A                goroutine B
───────────                ───────────
oku:   41
                           oku:   41
topla: 42
                           topla: 42
yaz:   counter = 42
                           yaz:   counter = 42

sonuç: counter = 42   (iki artırma yapıldı, 43 olmalıydı)

Buna veri yarışı (data race) denir: iki veya daha fazla goroutine aynı değişkene aynı anda erişir, bu erişimlerden en az biri yazmadır ve erişimler arasında hiçbir senkronizasyon yoktur. Yarış durumu daha geniş bir kavramdır: programın doğruluğunun işlemlerin zamanlamasına bağlı olması. Veri yarışları, yarış durumlarının en yaygın ve en sinsi türüdür.

Veri yarışları yalnızca yanlış sayılara yol açmaz. Go'nun bellek modeli belgesine göre dilim, string veya arayüz gibi birden fazla makine kelimesinden oluşan değerler üzerindeki yarışlar, hiçbir yazmaya karşılık gelmeyen tutarsız değerler üretebilir ve bellek bozulmasına kadar gidebilir. Haritalarda (map) ise çalışma zamanı bazı eşzamanlı yazmaları tespit eder ve programı fatal error: concurrent map writes hatasıyla durdurur. Ayrıntılar için Haritalar dersindeki eşzamanlı erişim uyarısına bakabilirsin.

-race bayrağı ile yarış yakalamak

Yarışları kod okuyarak bulmak zordur; program çoğu zaman "çalışıyor gibi" görünür. Go araç zinciri bu iş için yerleşik bir yarış dedektörü (race detector) içerir. -race bayrağını go run, go build ve go test komutlarına ekleyebilirsin:

go run -race main.go
go test -race ./...

Dedektör, programı her bellek erişimini izleyen ek kodla derler. Yukarıdaki sayaç programını -race ile çalıştırdığında şuna benzer bir rapor alırsın. Adresler, goroutine numaraları ve dosya yolları sende farklı olacaktır; aynı değişken için birden fazla rapor da görebilirsin (burada ikinci raporu ... ile kısalttık):

Çıktı
==================
WARNING: DATA RACE
Read at 0x00c000012188 by goroutine 10:
  main.main.func1()
      /home/sen/yaris/main.go:14 +0x38

Previous write at 0x00c000012188 by goroutine 7:
  main.main.func1()
      /home/sen/yaris/main.go:14 +0x48

Goroutine 10 (running) created at:
  sync.(*WaitGroup).Go()
      /usr/local/go/src/sync/waitgroup.go:238 +0x6c

Goroutine 7 (finished) created at:
  sync.(*WaitGroup).Go()
      /usr/local/go/src/sync/waitgroup.go:238 +0x6c
==================
...
sayaç: 52538 (beklenen: 100000)
Found 2 data race(s)
exit status 66

Raporu şöyle okursun:

  • Read at ... by goroutine 10: Bir goroutine, main.go dosyasının 14. satırında (counter++) değişkeni okudu.
  • Previous write at ... by goroutine 7: Başka bir goroutine aynı adrese, senkronizasyon olmadan, daha önce yazmıştı. Çakışan iki erişim yan yana gösterilir.
  • created at: Yarışa katılan goroutine'lerin hangi satırda oluşturulduğu. Karmaşık programlarda sorunun kaynağını bulmanın en hızlı yolu budur.
  • Found 1 data race(s): Program yarış tespit edildiğinde sıfırdan farklı bir çıkış koduyla (66) sonlanır; bu da CI ortamlarında testlerin başarısız sayılmasını sağlar.

Dedektörün bir maliyeti de var. Go belgelerine göre tipik bir programda bellek kullanımı 5-10 kat, çalışma süresi 2-20 kat artabilir; bu yüzden genellikle test ve geliştirme sırasında açılır. Yalnızca belirli 64-bit platformlarda (ör. linux/amd64, linux/arm64, darwin/arm64, windows/amd64) desteklenir ve bazı platformlarda cgo'nun etkin olmasını gerektirir. Bu sitedeki programlar Go Playground'da çalıştığı ve Playground -race desteklemediği için bu komutu kendi makinende denemelisin.

Yarışı düzeltmek

Veri yarışını gidermenin üç temel yolu vardır:

  1. Paylaşımı ortadan kaldırmak: Her goroutine kendi verisiyle çalışır, sonuçlar iş bittikten sonra tek bir goroutine'de birleştirilir.
  2. Erişimi sıraya sokmak: Kritik bölgeyi sync.Mutex ile korumak ya da sync/atomic işlemleri kullanmak. Bunları Senkronizasyon: sync ve atomic dersinde derinlemesine göreceğiz.
  3. Sahipliği devretmek: Veriyi goroutine'ler arasında kanallarla taşımak, böylece aynı anda yalnızca bir goroutine'in ona dokunmasını sağlamak. Bu da Kanallar dersinin konusu.

Şimdilik bildiğimiz araçlarla ilk yolu uygulayalım. Her goroutine kendi yerel sayacını artırır ve bitince sonucu kendisine ait hücreye yazar:

main.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	const workers = 100
	partial := make([]int, workers) // her goroutine'in kendi hücresi

	var wg sync.WaitGroup
	for w := range workers {
		wg.Go(func() {
			local := 0
			for range 1000 {
				local++ // yalnızca bu goroutine'in gördüğü değişken
			}
			partial[w] = local
		})
	}
	wg.Wait()

	counter := 0
	for _, p := range partial {
		counter += p
	}
	fmt.Println("sayaç:", counter)
}
Çıktı
sayaç: 100000

Artık sonuç her çalıştırmada 100.000'dir ve -race hiçbir uyarı vermez. Aynı sorunu bir kilitle çözmek istersen kod şuna benzer; ayrıntılarını senkronizasyon dersine bırakıyoruz:

var mu sync.Mutex

wg.Go(func() {
	for range 1000 {
		mu.Lock()
		counter++ // aynı anda yalnızca bir goroutine buraya girebilir
		mu.Unlock()
	}
})

Döngü değişkeni yakalama: Go 1.22 değişikliği

Goroutine'leri closure'larla birlikte kullanırken yıllarca en sık yapılan hata, döngü değişkenini yakalamaktı. Go 1.22 öncesinde bir for döngüsünün değişkeni tüm döngü boyunca tek bir değişkendi; her adımda yalnızca değeri güncellenirdi. O değişkeni yakalayan bütün closure'lar da aynı değişkeni paylaşırdı. Önce goroutine'siz, sade bir örnekle farkı görelim:

main.go
package main

import "fmt"

func main() {
	var prints []func()
	for i := 0; i < 3; i++ {
		prints = append(prints, func() { fmt.Print(i, " ") })
	}
	for _, p := range prints {
		p()
	}
	fmt.Println()
}
Çıktı
0 1 2

Go 1.22 ve sonrasında çıktı 0 1 2 olur. Aynı kod, go.mod dosyasında go 1.21 yazan bir modülde derlenseydi çıktı şu olurdu:

Çıktı
3 3 3

Çünkü eski kuralda üç closure da aynı i değişkenini gösterir; çağrıldıklarında döngü çoktan bitmiş ve i 3 olmuştur. Goroutine'lerde durum daha da kötüydü: döngü içinde go func() { fmt.Println(i) }() yazan kod hem beklenmedik değerler yazdırır hem de ana goroutine i'yi güncellerken diğerleri onu okuduğu için bir veri yarışı içerirdi. Bu yüzden eski kodlarda şu iki kalıbı sıkça görürsün:

// Go 1.22 öncesi kalıp 1: gölgeleme ile her adımda yeni bir kopya
for i := 0; i < 3; i++ {
	i := i
	go func() { fmt.Println(i) }()
}

// Go 1.22 öncesi kalıp 2: değeri argüman olarak geçirme
for i := 0; i < 3; i++ {
	go func(n int) { fmt.Println(n) }(i)
}

Go 1.22 ile her döngü adımı kendi değişkenini oluşturur. Bu kural hem üç bileşenli for i := 0; i < n; i++ döngülerinde hem de for i, v := range s döngülerinde geçerlidir. Değişiklik, go.mod dosyasında go 1.22 veya daha yeni bir sürüm bildiren modüllere uygulanır; eski sürümü bildiren modüller eski davranışı korur, böylece mevcut kod habersizce bozulmaz. Yeni kodda i := i satırına gerek yoktur.

Artık döngü değişkenlerini goroutine'lerde gönül rahatlığıyla kullanabilirsin:

main.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	names := []string{"Ali", "Zeynep", "Can"}
	greetings := make([]string, len(names))

	var wg sync.WaitGroup
	for i, name := range names {
		wg.Go(func() {
			// i ve name her adımda yeni değişkenlerdir (Go 1.22+)
			greetings[i] = fmt.Sprintf("%d: Merhaba %s", i, name)
		})
	}
	wg.Wait()

	for _, g := range greetings {
		fmt.Println(g)
	}
}
Çıktı
0: Merhaba Ali
1: Merhaba Zeynep
2: Merhaba Can

Sık yapılan hatalar

  • main'in goroutine'leri beklemeden bitmesi. Başlattığın her goroutine'in ne zaman ve nasıl biteceğini bil; WaitGroup, kanal ya da context ile bekle.
  • time.Sleep ile senkronizasyon. Bekleme süresi bir tahmindir; yük altında yetmeyebilir, boşta ise programı gereksiz yavaşlatır.
  • wg.Add'i goroutine'in içinde çağırmak. Wait, Add'den önce çalışıp erken dönebilir. Add'i go satırından önce çağır ya da doğrudan wg.Go kullan.
  • WaitGroup'u değer olarak geçirmek. Kopyanın sayacı azalır, asıl sayaç sıfırlanmaz ve Wait sonsuza kadar bekler. İşaretçi geçir; go vet bu hatayı yakalar.
  • Done'u unutmak veya fazladan çağırmak. Unutulursa program kilitlenir; fazla çağrılırsa sayaç negatife düşer ve sync: negative WaitGroup counter paniği oluşur. defer wg.Done() ya da wg.Go bu hataları önler.
  • Paylaşılan değişkene korumasız yazmak. "Benim makinemde doğru sonuç verdi" bir kanıt değildir. Testleri -race ile çalıştır.
  • Birden çok goroutine'den aynı dilime append yapmak veya aynı haritaya yazmak. Önceden boyutlandırılmış dilimde indeksle yaz ya da erişimi bir kilitle koru.
  • Goroutine içindeki panikleri hafife almak. Bir goroutine'de yakalanmayan panik tüm programı çökertir. recover yalnızca paniğin oluştuğu goroutine içinde işe yarar; ayrıntılar Hata Yönetimi dersinde.
  • GOMAXPROCS'u performans için rastgele büyütmek. Çekirdek sayısının üzerindeki değerler CPU yoğun işleri hızlandırmaz.

Alıştırmalar

Alıştırma·Paralel kareler
Kolay

1'den 10'a kadar her sayının karesini ayrı bir goroutine'de hesapla. Sonuçları bir dilimde topla, ardından karelerin hepsini tek satırda ve toplamlarını ayrı bir satırda yazdır. Çıktı her çalıştırmada aynı olmalı:

[1 4 9 16 25 36 49 64 81 100]
toplam: 385
İpucu

squares := make([]int, 10) ile dilimi önceden oluştur. i indeksindeki goroutine (i+1)*(i+1) değerini yalnızca squares[i]'ye yazsın. Toplamı wg.Wait()'ten sonra hesapla.

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

import (
	"fmt"
	"sync"
)

func main() {
	squares := make([]int, 10)

	var wg sync.WaitGroup
	for i := range squares {
		wg.Go(func() {
			n := i + 1
			squares[i] = n * n
		})
	}
	wg.Wait()

	total := 0
	for _, s := range squares {
		total += s
	}
	fmt.Println(squares)
	fmt.Println("toplam:", total)
}
Çıktı
[1 4 9 16 25 36 49 64 81 100]
toplam: 385

Her goroutine farklı bir indekse yazdığı için yarış yoktur. Toplam, bütün goroutine'ler bittikten sonra tek bir goroutine'de hesaplanır. Go 1.22 kuralı sayesinde i her adımda yeni bir değişkendir; closure onu güvenle yakalar.

Alıştırma·Hatalı programı düzelt
Kolay

Aşağıdaki program üç şehir için "hazır" mesajı toplamak istiyor ama bazen boş bir dilim, bazen eksik ya da karışık sıralı bir liste yazdırıyor. İki ayrı hatayı bul ve programı, çıktısı her zaman [Ankara hazır İzmir hazır Bursa hazır] olacak şekilde düzelt.

main.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var results []string
	var wg sync.WaitGroup
	for _, city := range []string{"Ankara", "İzmir", "Bursa"} {
		go func() {
			wg.Add(1)
			defer wg.Done()
			results = append(results, city+" hazır")
		}()
	}
	wg.Wait()
	fmt.Println(results)
}
Çıktı
[]
İpucu

Birinci hata wg.Add'in yerinde; main, Wait'e ulaştığında goroutine'ler henüz Add çağırmamış olabilir. Programı Çalıştır düğmesiyle denediğinde go vet uyarısı da bu satırı gösterecek. İkinci hata, aynı dilime birden çok goroutine'den append yapılması. Dilimi önceden boyutlandırıp indeksle yazmayı dene.

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

import (
	"fmt"
	"sync"
)

func main() {
	cities := []string{"Ankara", "İzmir", "Bursa"}
	results := make([]string, len(cities))

	var wg sync.WaitGroup
	for i, city := range cities {
		wg.Go(func() {
			results[i] = city + " hazır"
		})
	}
	wg.Wait()
	fmt.Println(results)
}
Çıktı
[Ankara hazır İzmir hazır Bursa hazır]

wg.Go, sayacı goroutine başlamadan önce artırdığı için Wait'in erken dönmesi imkânsız hâle gelir. Sonuç dilimi make ile önceden boyutlandırıldı ve her goroutine yalnızca kendi indeksine yazıyor; append kaynaklı yarış ortadan kalktı. Sıra da artık goroutine'lerin bitiş sırasına değil, indekse bağlı.

Alıştırma·Asal sayıları parçalara bölerek say
Orta

[2, 200000) aralığındaki asal sayıları say. Aralığı 4 eşit parçaya böl, her parçayı ayrı bir goroutine'de işle ve her parçanın sonucunu ayrı bir satırda, ardından genel toplamı yazdır. Asal kontrolü için basit bir isPrime fonksiyonu (√n'e kadar deneme bölmesi) yeterli. Parça sınırlarında hiçbir sayının atlanmadığından ya da iki kez sayılmadığından emin ol.

İpucu

Parça boyutunu size := (hi - lo) / parts olarak hesapla. p. parça lo + p*size ile başlasın; son parçanın bitişi ise kalan sayıları kaçırmamak için doğrudan hi olsun. Her goroutine sayımını yerel bir değişkende yapıp sonucu counts[p]'ye yazsın.

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

import (
	"fmt"
	"sync"
)

func isPrime(n int) bool {
	if n < 2 {
		return false
	}
	for d := 2; d*d <= n; d++ {
		if n%d == 0 {
			return false
		}
	}
	return true
}

func main() {
	const lo, hi, parts = 2, 200_000, 4
	size := (hi - lo) / parts
	counts := make([]int, parts)

	var wg sync.WaitGroup
	for p := range parts {
		start := lo + p*size
		end := start + size
		if p == parts-1 {
			end = hi // kalan sayıları son parçaya ekle
		}
		wg.Go(func() {
			local := 0
			for n := start; n < end; n++ {
				if isPrime(n) {
					local++
				}
			}
			counts[p] = local
		})
	}
	wg.Wait()

	total := 0
	for p, c := range counts {
		fmt.Printf("parça %d: %d asal\n", p, c)
		total += c
	}
	fmt.Println("toplam:", total)
}
Çıktı
parça 0: 5133 asal
parça 1: 4459 asal
parça 2: 4256 asal
parça 3: 4136 asal
toplam: 17984

Parçalar yarı açık aralıklardır ([start, end)); bir parçanın bitişi bir sonrakinin başlangıcıdır, bu yüzden hiçbir sayı iki kez sayılmaz. Tam bölünmeyen aralıklarda artan sayılar son parçaya eklenir. Her sayı için deneme bölmesi O(√n) sürdüğünden toplam iş O(n√n) mertebesindedir; goroutine'ler bu işi çekirdeklere yayar ama toplam işi azaltmaz. Küçük sayılar daha hızlı kontrol edildiği ve asallar büyüdükçe seyrekleştiği için parçaların yükü eşit değildir. Gerçek iş yüklerinde parçaları bu dengesizliği hesaba katarak bölmek gerekebilir.

Alıştırma·Paralel birleştirmeli sıralama
Zor

Birleştirmeli sıralamayı goroutine'lerle paralelleştir. parallelMergeSort(s []int) []int fonksiyonu dilimi ikiye bölsün, iki yarıyı eşzamanlı sıralasın ve sonuçları birleştirsin. Her bölünmede yeni goroutine açmak çok küçük dilimlerde maliyetli olacağından, uzunluğu bir eşiğin (ör. 2048) altındaki dilimleri doğrudan slices.Sort ile sırala. Sabit tohumlu (rand.NewPCG(1, 2)) 200.000 rastgele sayı üret; sonucun sıralı olduğunu ve slices.Sort sonucuyla aynı olduğunu doğrula.

İpucu

Her seviyede iki goroutine başlatmana gerek yok: sol yarıyı wg.Go ile yeni bir goroutine'de, sağ yarıyı ise mevcut goroutine'de sırala, sonra wg.Wait() ile solun bitmesini bekle. Sol yarının sonucunu closure dışındaki bir değişkene yazmak güvenlidir, çünkü onu yalnızca Wait'ten sonra okursun. Orijinal dilimi değiştirmemek için sıralamadan önce slices.Clone kullan.

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

import (
	"fmt"
	"math/rand/v2"
	"slices"
	"sync"
)

const threshold = 2048 // bu uzunluğun altında goroutine açma

func merge(a, b []int) []int {
	out := make([]int, 0, len(a)+len(b))
	i, j := 0, 0
	for i < len(a) && j < len(b) {
		if a[i] <= b[j] {
			out = append(out, a[i])
			i++
		} else {
			out = append(out, b[j])
			j++
		}
	}
	out = append(out, a[i:]...)
	return append(out, b[j:]...)
}

func parallelMergeSort(s []int) []int {
	if len(s) <= threshold {
		c := slices.Clone(s)
		slices.Sort(c)
		return c
	}
	mid := len(s) / 2
	var left []int
	var wg sync.WaitGroup
	wg.Go(func() {
		left = parallelMergeSort(s[:mid]) // sol yarı yeni goroutine'de
	})
	right := parallelMergeSort(s[mid:]) // sağ yarı bu goroutine'de
	wg.Wait()
	return merge(left, right)
}

func main() {
	r := rand.New(rand.NewPCG(1, 2))
	data := make([]int, 200_000)
	for i := range data {
		data[i] = r.IntN(1_000_000)
	}

	sorted := parallelMergeSort(data)

	expected := slices.Clone(data)
	slices.Sort(expected)

	fmt.Println("uzunluk:", len(sorted))
	fmt.Println("sıralı mı:", slices.IsSorted(sorted))
	fmt.Println("slices.Sort ile aynı mı:", slices.Equal(sorted, expected))
	fmt.Println("en küçük:", sorted[0], "en büyük:", sorted[len(sorted)-1])
}
Çıktı
uzunluk: 200000
sıralı mı: true
slices.Sort ile aynı mı: true
en küçük: 0 en büyük: 999998

İki goroutine aynı dilimin farklı yarılarını yalnızca okur, bu yüzden yarış yoktur. left değişkenine yeni goroutine yazar, ana goroutine ise onu wg.Wait() döndükten sonra okur; Wait bu sırayı garanti eder. Toplam iş, sıralı birleştirmeli sıralamadaki gibi O(n log n) karşılaştırmadır ve ek alan O(n)'dir. Paralellik, bölünmeleri farklı çekirdeklere dağıtarak geçen süreyi kısaltır. Eşik sayesinde oluşturulan goroutine sayısı yaklaşık n / 2048 ile sınırlı kalır; eşik olmasaydı her eleman için bir goroutine açılır ve kazanç, goroutine maliyetinin altında kalırdı. Son birleştirme adımı tek goroutine'de çalıştığı için hızlanma, çekirdek sayısıyla doğru orantılı artmaz.

Kısa sınav

Kısa sınav

main içinde go fmt.Println("A") satırından hemen sonra fmt.Println("B") çağrılıyor ve main bitiyor. Hangisi doğrudur?

GOMAXPROCS=1 ile çalışan ve 10 goroutine başlatan bir program için hangisi doğrudur?

Döngüde go func() { wg.Add(1); defer wg.Done(); work() }() yazılmış, döngüden sonra wg.Wait() çağrılıyor. Sorun nedir?

Bir goroutine uzun süren, bloklayan bir sistem çağrısına girdiğinde Go zamanlayıcısı ne yapar?

go.mod dosyasında go 1.26 yazan bir modülde, for i := 0; i < 3; i++ döngüsü her adımda func() int { return i } closure'unu bir dilime ekliyor. Döngüden sonra closure'ların döndürdüğü değerler toplanırsa sonuç kaç olur?

Testlerini go test -race ./... ile çalıştırdın ve hiçbir uyarı çıkmadı. Bu ne anlama gelir?

Özet

  • Eşzamanlılık programın yapısıyla, paralellik işlerin aynı anda yürütülmesiyle ilgilidir. İyi yapılandırılmış eşzamanlı bir program, çekirdek varsa kendiliğinden paralel çalışır.
  • go f(x) fonksiyonu yeni bir goroutine'de başlatır ve hemen döner; argümanlar o anda değerlendirilir. main döndüğünde program, diğer goroutine'leri beklemeden sonlanır.
  • Goroutine'ler küçük ve büyüyebilen stack'lerle başlar; yüz binlercesini çalıştırmak olağandır. Yine de her goroutine'in nasıl biteceğini bilmelisin.
  • sync.WaitGroup ile bekle: Add'i go'dan önce çağır, Done'u defer ile çağır, WaitGroup'u işaretçiyle geçir. Go 1.25'ten beri wg.Go(f) bu kalıbın tamamını tek çağrıda yapar.
  • Zamanlayıcı G-M-P modeliyle çalışır: goroutine'ler P'lerin yerel kuyruklarında bekler, M'ler (thread'ler) P tutarak onları yürütür; boşta kalan P'ler iş çalar, uzun çalışan goroutine'ler kesilir.
  • GOMAXPROCS, P sayısıdır ve varsayılan olarak kullanılabilir CPU sayısına eşittir; Go 1.25'ten beri Linux'ta container CPU sınırı da dikkate alınır. Nadiren elle değiştirilmelidir.
  • Veri yarışı; aynı değişkene eşzamanlı, en az biri yazma olan ve senkronize edilmemiş erişimdir. -race ile test et; paylaşımı kaldırarak, kilitle ya da kanallarla düzelt.
  • Go 1.22'den beri her döngü adımı kendi değişkenini oluşturur; goroutine ve closure'larda i := i kopyasına artık gerek yoktur.
Bu dersi bitirdin mi?
İlerlemen bu tarayıcıda saklanır.