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.
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:
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")
}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:
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")
}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:
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)
}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.
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:
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")
}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:
Add,gosatırından önce çağrılır.Add'i goroutine'in içine koyarsan, goroutine daha çalışmaya başlamadanmainWait'e ulaşabilir. Sayaç o anda sıfır olduğu içinWaithemen döner ve işler yarım kalır. Go 1.25'ten berigo vetbu hatayı "WaitGroup.Add called from inside new goroutine" uyarısıyla yakalar.Done,deferile çağrılır. Böylece fonksiyon erkenreturnetse ya da panik oluşsa bile sayaç azalır.Donehiç çağrılmazsaWaitsonsuza kadar bekler.WaitGroupfonksiyonlara 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,Waitsonsuza kadar beklerdi.go vetbu 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:
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")
}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.
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")
}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ı
GOMAXPROCSdeğ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 yokBu 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.Sleepgoroutine'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:
- 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.
- Global kuyruğa.
- Netpoller'a: Ağ işlemi tamamlanmış, uyanmayı bekleyen bir goroutine var mı?
- 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:
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())
}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.goGOMAXPROCS=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.
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)")
}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):
==================
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 66Raporu şöyle okursun:
Read at ... by goroutine 10: Bir goroutine,main.godosyası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:
- Paylaşımı ortadan kaldırmak: Her goroutine kendi verisiyle çalışır, sonuçlar iş bittikten sonra tek bir goroutine'de birleştirilir.
- Erişimi sıraya sokmak: Kritik bölgeyi
sync.Mutexile korumak ya dasync/atomicişlemleri kullanmak. Bunları Senkronizasyon: sync ve atomic dersinde derinlemesine göreceğiz. - 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:
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)
}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:
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()
}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:
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:
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)
}
}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 dacontextile bekle.time.Sleepile 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'igosatırından önce çağır ya da doğrudanwg.Gokullan.WaitGroup'u değer olarak geçirmek. Kopyanın sayacı azalır, asıl sayaç sıfırlanmaz veWaitsonsuza kadar bekler. İşaretçi geçir;go vetbu hatayı yakalar.Done'u unutmak veya fazladan çağırmak. Unutulursa program kilitlenir; fazla çağrılırsa sayaç negatife düşer vesync: negative WaitGroup counterpaniği oluşur.defer wg.Done()ya dawg.Gobu hataları önler.- Paylaşılan değişkene korumasız yazmak. "Benim makinemde doğru sonuç verdi" bir kanıt değildir. Testleri
-raceile çalıştır. - Birden çok goroutine'den aynı dilime
appendyapmak 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.
recoveryalnı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
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
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)
}[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.
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.
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)
}[]
İ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
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)
}[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ı.
[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
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)
}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.
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
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])
}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
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.maindö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.WaitGroupile bekle:Add'igo'dan önce çağır,Done'udeferile çağır,WaitGroup'u işaretçiyle geçir. Go 1.25'ten beriwg.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.
-raceile 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 := ikopyasına artık gerek yoktur.