Veri yarışı (data race), iki goroutine'in aynı değişkene eşzamanlı eriştiği ve bu erişimlerden en az birinin yazma olduğu durumdur; erişimlerin tamamı sync/atomic ile yapılmıyorsa program hatalıdır. Go'da veri yarışını önlemenin yolu erişimi sıraya koymaktır: kanal, sync.Mutex ya da sync/atomic. Tek bir sayacı, bayrağı ya da işaretçiyi kilitsiz güncellemek için Go 1.19 ile gelen atomic.Int64, atomic.Bool, atomic.Pointer[T] gibi tipler kullanılır. Yarışları bulmak için de testleri go test -race ile çalıştırırsınız.

Bu yazıda Go Memory Model belgesine göre veri yarışının tanımını ve neden "zararsız yarış" olmadığını, "önce olur" (happens before) ilişkisini, sync/atomic tiplerini ve karşılaştır-değiştir (compare-and-swap) döngüsünü, atomic.Value ile atomic.Pointer arasındaki farkı ve race detector'ün nasıl kullanıldığını anlatıyoruz. Örnekler Go 1.27.3 ile çalıştırıldı. Race detector cgo ve bir C derleyicisi gerektirdiği için onu bu makinede çalıştırmadık; ilgili bölüm resmî belgeye dayanıyor. Seri için Go öğrenme rehberi sayfasına bakabilirsiniz.

Veri yarışı nedir?

Bellek modeli belgesi veri yarışını, aynı bellek konumuna yapılan bir yazmanın aynı konuma yapılan başka bir okuma ya da yazmayla eşzamanlı gerçekleşmesi olarak tanımlar; ilgili bütün erişimler sync/atomic'in sağladığı atomik erişimlerse bu bir yarış sayılmaz. Belgenin tavsiye bölümü kısadır: birden çok goroutine'in aynı anda eriştiği veriyi değiştiren programlar bu erişimi sıraya koymalıdır; bunun için veriyi kanal işlemleriyle ya da sync ve sync/atomic paketlerindeki araçlarla koruyun. Belge, programınızın davranışını anlamak için bellek modelinin geri kalanını okumanız gerekiyorsa fazla zekice kod yazdığınızı da ekler.

En bilinen örnek korumasız bir sayaçtır. sayac++ tek bir işlem gibi görünse de okuma, artırma ve yazma adımlarından oluşur; iki goroutine aynı değeri okuyup aynı sonucu yazınca artışlardan biri kaybolur. Aşağıdaki program bu yüzden her çalıştırmada farklı bir sayı yazdırabilir; bizim denemelerimizde 1000 yerine 986 ile 997 arasında değerler çıktı:

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	sayac := 0
	var wg sync.WaitGroup
	for range 1000 {
		wg.Go(func() {
			sayac++ // VERİ YARIŞI: oku-artır-yaz atomik değil
		})
	}
	wg.Wait()
	fmt.Println(sayac)
}

// Çıktı (her çalıştırmada değişebilir):
// 991

Race detector belgesi tipik yarışları da sıralar: Go 1.22 öncesinde döngü sayacını kapanışta paylaşmak, iki goroutine'in yanlışlıkla aynı err değişkenine yazması (çözüm goroutine içinde := ile yeni değişken tanımlamak), korumasız global bir map'e eşzamanlı okuma ve yazma, int64 gibi basit tiplerdeki korumasız erişimler ve aynı kanaldaki eşitlenmemiş gönderme ile kapatma. Map'lerin eşzamanlı okuma ve yazmaya karşı güvenli olmadığını belge açıkça belirtir; korumalı map için sync.Mutex yazısına bakabilirsiniz.

Neden "zararsız" veri yarışı yoktur?

Bellek modeli, yarış içermeyen programlara güçlü bir garanti verir: bu programlar, tüm goroutine'ler tek bir işlemciye çoklanmış gibi, sıralı tutarlı (sequentially consistent) davranır. Belge bu özelliği DRF-SC olarak adlandırır. Yarış içeren programlarda ise bu garanti kalkar. Belgedeki örnekte bir goroutine önce a = 1, sonra b = 2 yazar; başka bir goroutine önce b'yi, sonra a'yı okur ve 2 ile 0 görebilir. Yani bir yazmanın etkisini görmeniz, ondan önce yapılan yazmaların etkisini de göreceğiniz anlamına gelmez.

Bu durum yaygın bazı kalıpları geçersiz kılar. Bir bayrağı senkronizasyonsuz okuyup true görünce verinin hazır olduğunu varsaymak (double-checked locking) yanlıştır. Bir bayrak değişene kadar boş döngüde beklemek de yanlıştır; belgeye göre yazmanın hiç görülmeyeceği ve döngünün hiç bitmeyeceği bile mümkündür. Daha da ciddisi, birden fazla makine kelimesi büyüklüğündeki değerlerde yarış tutarsız değerler üretebilir. Çoğu Go uygulamasında arayüz değerleri, map'ler, dilimler ve string'ler iç (işaretçi, uzunluk) ya da (işaretçi, tip) çiftlerine dayandığı için, bu değerlerdeki yarışlar belgeye göre keyfi bellek bozulmasına yol açabilir.

Go yine de C ve C++'tan farklı bir yol izler: yarışlı programlarda bile kelime büyüklüğündeki bir okumanın gerçekten yazılmış bir değeri görmesi gerekir ve "havadan" değerler ortaya çıkamaz. Belge bu kısıtların Go'yu bu açıdan Java ve JavaScript'e yaklaştırdığını, ancak yarışların yine de hata olduğunu ve araçlarla teşhis edilmesi gerektiğini vurgular. Bir uygulama, yarışı tespit ettiğinde raporlayıp programı durdurmakta da serbesttir.

"Önce olur" ilişkisi ve senkronizasyon noktaları

"Önce olur" ilişkisi ve senkronizasyon noktaları: go deyimi, Kanala gönderme, Kanalın kapatılması, Tamponsuz kanaldan alma

Bir goroutine'deki yazmanın başka bir goroutine'den güvenle okunabilmesi için ikisi arasında "önce olur" (happens before) ilişkisi kurulmalıdır. Bu ilişki, aynı goroutine içindeki sıralama ile goroutine'ler arasındaki eşitleme (synchronized before) olaylarının birleşiminden oluşur. Bellek modeli belgesi bu eşitleme olaylarını tek tek sayar:

OlayNeyden önce eşitlenir
go deyimiBaşlattığı goroutine'in çalışmaya başlaması
Kanala göndermeKarşılık gelen almanın tamamlanması
Kanalın kapatılmasıKapalı olduğu için sıfır değer döndüren alma
Tamponsuz kanaldan almaKarşılık gelen göndermenin tamamlanması
Kapasitesi C olan kanalda k'ıncı almak+C'inci göndermenin tamamlanması
n'inci Unlockm'inci Lock'un dönüşü (n < m)
once.Do(f) içinde f'nin bitişiHerhangi bir once.Do(f) çağrısının dönüşü
Atomik işlem A'nın etkisini gören BA, B'den önce eşitlenir

Goroutine'in sona ermesi ise hiçbir olaydan önce eşitlenmez; bu yüzden bir goroutine'in sonucunu görmek için mutlaka bu tablodaki mekanizmalardan birini kullanmalısınız. Aşağıdaki örnekte yazma, kanalın kapatılmasından önce sıralanmıştır; kapatma, onu gören almadan önce eşitlenir; alma da yazdırmadan önce sıralanmıştır. Bu zincir, yazdırmanın yazmayı görmesini garanti eder:

go
package main

import "fmt"

var mesaj string

func hazirla(bitti chan<- struct{}) {
	mesaj = "merhaba, dünya" // 1) yazma
	close(bitti)             // 2) kapatma, alma işleminden önce eşitlenir
}

func main() {
	bitti := make(chan struct{})
	go hazirla(bitti)
	<-bitti            // 3) kapanışı gören alma
	fmt.Println(mesaj) // 4) yazmanın etkisini görmesi garanti
}

// Çıktı:
// merhaba, dünya

sync/atomic tipleri

sync/atomic tipleri

sync/atomic, senkronizasyon algoritmaları yazmak için düşük seviyeli atomik bellek işlemleri sağlar. Paket belgesi bunların doğru kullanımının büyük dikkat gerektirdiğini ve özel, düşük seviyeli uygulamalar dışında senkronizasyonun kanallar ya da sync paketiyle yapılmasının daha iyi olduğunu söyler. Bir sayaç, bir durum bayrağı ya da bir yapılandırma işaretçisi gibi tek değerlik paylaşımlarda ise atomik tipler en sade çözümdür.

Go 1.19 sürüm notlarına göre pakete Bool, Int32, Int64, Uint32, Uint64, Uintptr ve Pointer tipleri eklendi. Bu tipler atomik API'yi kullanmayı kolaylaştırır ve değişkene yanlışlıkla atomik olmayan biçimde erişmenizi engeller. Hepsinin sıfır değeri kullanıma hazırdır ve hiçbiri ilk kullanımdan sonra kopyalanmamalıdır. Temel metotlar Load, Store, Swap ve CompareAndSwap'tır; tamsayı tiplerinde Add da vardır. Go 1.23 ile tamsayı tiplerine bit düzeyinde And ve Or eklendi.

go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var sayac atomic.Int64 // sıfır değeri 0, kullanıma hazır
	var wg sync.WaitGroup
	for range 1000 {
		wg.Go(func() {
			sayac.Add(1)
		})
	}
	wg.Wait()
	fmt.Println(sayac.Load())

	fmt.Println(sayac.Swap(5))               // eski değeri döndürür
	fmt.Println(sayac.CompareAndSwap(5, 10)) // 5 ise 10 yap
	fmt.Println(sayac.CompareAndSwap(5, 20)) // artık 5 değil
	fmt.Println(sayac.Load())
}

// Çıktı:
// 1000
// 1000
// true
// false
// 10

Belge her işlemin anlamını sıradan kodla tarif eder. Swap, eski değeri okuyup yenisini yazar ve eskisini döndürür. CompareAndSwap, değer beklenen eski değere eşitse yenisini yazar ve true döndürür, değilse hiçbir şey yapmadan false döndürür. Add, değeri artırıp yeni değeri döndürür. Fark şudur: bu adımlar atomik olarak, araya başka bir goroutine giremeden gerçekleşir. Bellek modeli açısından bir atomik işlemin etkisini gören başka bir atomik işlem, ondan sonra eşitlenmiş sayılır ve programdaki bütün atomik işlemler sıralı tutarlı bir sırada yürütülmüş gibi davranır. Belgeye göre bu anlam C++'ın sıralı tutarlı atomikleri ve Java'nın volatile değişkenleriyle aynıdır; Go 1.19 sürüm notları da Go'nun daha gevşek bellek sıralama biçimleri sunmadığını belirtir.

CompareAndSwap döngüsü

Add ile ifade edilemeyen güncellemeler, örneğin "en büyük değeri tut", karşılaştır-değiştir döngüsüyle yazılır: güncel değeri okursunuz, yeni değeri hesaplarsınız ve CompareAndSwap ile yazmayı denersiniz. Araya başka bir goroutine girip değeri değiştirdiyse CompareAndSwap false döner ve baştan denersiniz:

go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// enBuyugeGuncelle, v daha büyükse en yüksek değeri kilitsiz günceller.
func enBuyugeGuncelle(enb *atomic.Int64, v int64) {
	for {
		eski := enb.Load()
		if v <= eski {
			return
		}
		if enb.CompareAndSwap(eski, v) {
			return
		}
		// Başka goroutine araya girdi: yeniden dene.
	}
}

func main() {
	var enb atomic.Int64
	var wg sync.WaitGroup
	for _, v := range []int64{17, 3, 99, 42, 58} {
		wg.Go(func() { enBuyugeGuncelle(&enb, v) })
	}
	wg.Wait()
	fmt.Println(enb.Load())
}

// Çıktı:
// 99

Bu kalıp yalnızca tek bir değeri güncellediğiniz sürece işe yarar. İki ayrı atomik değişkeni birlikte tutarlı tutmanız gerekiyorsa, örneğin toplam ve adet, her biri ayrı ayrı atomik olsa da ikisinin arasındaki tutarlılık korunmaz; bu durumda mutex kullanın.

atomic.Bool ile tek seferlik geçiş

atomic.Bool'un sıfır değeri false'tur. CompareAndSwap(false, true) çağrısı, birçok goroutine arasından durumu yalnızca bir kez değiştiren goroutine'i seçmenin kilitsiz yoludur. Aşağıda beş goroutine kapatma işini üstlenmeye çalışıyor, yalnızca biri başarılı oluyor:

go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var kapandi atomic.Bool
	var ilkKapatan atomic.Int32
	var wg sync.WaitGroup
	for range 5 {
		wg.Go(func() {
			// Yalnızca false'tan true'ya ilk geçişi yapan kazanır.
			if kapandi.CompareAndSwap(false, true) {
				ilkKapatan.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println("kapandı:", kapandi.Load(), "kapatma sayısı:", ilkKapatan.Load())
}

// Çıktı:
// kapandı: true kapatma sayısı: 1

Paket belgesi, birçok mimaride kelime büyüklüğünde olmayan tamsayılarda atomik işlemlerin verimsiz ya da imkânsız olduğunu, bu yüzden yalnızca birkaç tamsayı boyutunun desteklendiğini ve bir Bool'un yerleşik bool'dan daha büyük olabileceğini not eder. Tek seferlik bir başlatma için ise sync.Once daha uygundur, çünkü diğer goroutine'leri başlatma bitene kadar bekletir.

atomic.Pointer ve atomic.Value ile yapılandırma değiştirmek

Okunması çok sık, değişmesi seyrek bir yapılandırmayı paylaşmanın bir yolu, onu değişmez bir nesne olarak tutup işaretçisini atomik olarak değiştirmektir. atomic.Pointer[T], *T tipinde atomik bir işaretçidir ve sıfır değeri nil işaretçidir. Okuyucular Load ile güncel nesneyi kilitsiz alır; yazar yeni bir nesne kurar ve Store ya da Swap ile tek adımda değiştirir. Eski nesneyi kullanan okuyucular işlerini bitirince çöp toplayıcı onu temizler:

go
package main

import (
	"fmt"
	"sync/atomic"
)

type Ayar struct {
	Surum int
	Mod   string
}

func main() {
	var guncel atomic.Pointer[Ayar] // sıfır değeri nil *Ayar
	fmt.Println(guncel.Load() == nil)

	guncel.Store(&Ayar{Surum: 1, Mod: "normal"})
	a := guncel.Load() // okuyucular kilitsiz okur
	fmt.Println(a.Surum, a.Mod)

	// Yazar: yeni nesne kur, tek seferde değiştir. Eski nesneye dokunma.
	eski := guncel.Swap(&Ayar{Surum: 2, Mod: "bakım"})
	fmt.Println("eski:", eski.Surum, "yeni:", guncel.Load().Mod)
}

// Çıktı:
// true
// 1 normal
// eski: 1 yeni: bakım

Bu desenin tek kuralı, yayımlanmış bir nesneyi bir daha değiştirmemektir. Load ile aldığınız işaretçinin gösterdiği alanlara yazarsanız, aynı nesneyi okuyan diğer goroutine'lerle yeniden veri yarışı oluşturursunuz.

Daha eski kodlarda aynı iş için atomic.Value kullanılır. Value herhangi bir değeri saklar ama bütün Store çağrılarının aynı somut tipi kullanması gerekir; farklı tiple ya da nil ile Store panic eder. Hiç Store yapılmamışsa Load nil döndürür:

go
package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var v atomic.Value
	fmt.Println(v.Load()) // Store yoksa nil
	v.Store("ilk")
	fmt.Println(v.Load().(string))
	v.Store(42) // farklı somut tip: panic
}

// Çıktı (ilk satırlar):
// <nil>
// ilk
// panic: sync/atomic: store of inconsistently typed value into Value

Paket belgesindeki "readMostly" örneği, Value'nun yazarlar arasında bir mutex ile birlikte kullanıldığı yazma-sırasında-kopyalama (copy-on-write) map desenini gösterir: yazar mevcut map'i kopyalar, kopyayı günceller ve atomik olarak yerine koyar. atomic.Pointer[T] aynı işi tip güvenli biçimde, tip doğrulaması gerektirmeden yapar.

Eski fonksiyonlar ve Go 1.27 go fix

Atomik tiplerden önce paket AddInt64, LoadInt64, CompareAndSwapInt32 gibi düz değişkenin adresini alan fonksiyonlar sunuyordu. Bu fonksiyonlar hâlâ geçerlidir; ancak değişken sıradan bir tamsayı olduğu için kodun bir yerinde yanlışlıkla atomik olmayan erişim yapmak mümkündür. Paket belgesi her fonksiyon için daha ergonomik ve hataya daha az açık olan tip metodunu önerir, 64 bitlik fonksiyonlarda bunu özellikle 32 bitlik platformları hedefliyorsanız vurgular. Go 1.19 sürüm notlarına göre Int64 ve Uint64 tipleri, yapılarda ya da ayrılmış verilerde 32 bitlik sistemlerde bile otomatik olarak 64 bit hizalanır:

go
package main

import (
	"fmt"
	"sync/atomic"
)

type istatistik struct {
	istek int64 // düz int64: her erişim atomic fonksiyonla yapılmalı
	hata  atomic.Int64
}

func main() {
	var s istatistik
	atomic.AddInt64(&s.istek, 3)
	fmt.Println(atomic.LoadInt64(&s.istek))

	s.hata.Add(2) // tip tabanlı API: yanlışlıkla düz erişim yapılamaz
	fmt.Println(s.hata.Load())
}

// Çıktı:
// 3
// 2

Go 1.27 sürüm notlarına göre go fix komutuna eklenen yeni modernleştiriciler arasında atomictypes da bulunuyor. Komutun genel kullanımı için go fmt, vet, doc ve fix araçları yazısına bakabilirsiniz.

Race detector: -race bayrağı

Go'nun yerleşik veri yarışı dedektörü, go komutuna -race bayrağı eklenerek kullanılır. Belgedeki kullanım biçimleri şunlardır:

bash
go test -race mypkg    # paketi test et
go run -race mysrc.go  # kaynak dosyayı çalıştır
go build -race mycmd   # komutu derle
go install -race mypkg # paketi kur

Dedektör bir yarış bulduğunda WARNING: DATA RACE başlığıyla bir rapor yazar. Raporda çakışan erişimlerin yığın izleri (hangi goroutine'in hangi satırda okuduğu ve önceki yazmayı hangi goroutine'in yaptığı) ve ilgili goroutine'lerin oluşturulduğu yerler bulunur. GORACE ortam değişkeniyle seçenek verilir: log_path raporun yazılacağı yeri, exitcode (varsayılan 66) yarış bulunduktan sonraki çıkış kodunu, halt_on_error ilk yarıştan sonra programın durup durmayacağını, strip_path_prefix dosya yollarından atılacak öneki belirler. -race ile derlerken go komutu ayrıca race derleme etiketini tanımlar; bu etiketle belirli testleri dedektör altında dışarıda bırakabilirsiniz (build constraint yazısı).

Belgede dikkat edilmesi gereken birkaç sınır vardır. Dedektör yalnızca çalışma sırasında gerçekten oluşan yarışları bulur; çalışmayan kod yollarındaki yarışları göremez. Bu yüzden testlerinizin kapsamı düşükse, -race ile derlenmiş bir programı gerçekçi bir yük altında çalıştırmak daha fazla yarış ortaya çıkarabilir. Maliyet programa göre değişir; belge tipik bir programda bellek kullanımının 5 ila 10 kat, çalışma süresinin 2 ila 20 kat artabileceğini belirtir. Dedektör cgo'nun etkin olmasını ve Darwin dışındaki sistemlerde kurulu bir C derleyicisini gerektirir; desteklenen platformlar arasında linux/amd64, linux/arm64, darwin/arm64 ve windows/amd64 bulunur. Windows'ta Go 1.21'den itibaren mingw-w64 çalışma zamanı kütüphanelerinin 8 veya üstü sürümünü içeren bir C derleyicisi gerekir.

Atomic mi, mutex mi, kanal mı?

Atomic mi, mutex mi, kanal mı? Tek sayaç ya da istatistik, Tek seferlik durum geçişi, Seyrek değişen yapılandırma, Birlikte tutarlı birden çok alan
DurumÖnerilen araçNeden
Tek sayaç ya da istatistikatomic.Int64Kilitsiz, kısa, kopyalanmaz
Tek seferlik durum geçişiatomic.Bool CompareAndSwapKazananı kilitsiz seçer
Seyrek değişen yapılandırmaatomic.Pointer[T]Okuyucular kilitsiz, nesne değişmez
Birlikte tutarlı birden çok alansync.MutexAtomikler alanlar arası tutarlılık sağlamaz
Map'e eşzamanlı erişimmap + sync.Mutex ya da RWMutexMap eşzamanlı yazmaya güvenli değil
Veri sahipliğini devretmekKanalAynı anda tek sahip
Tek seferlik başlatmasync.Once / OnceValueDiğerleri başlatmayı bekler

Sık yapılan hatalar

  • "Sadece bir int, zarar vermez" düşüncesi: Basit tiplerdeki yarışlar da derleyici optimizasyonları ve yeniden sıralama yüzünden hata üretir; race detector belgesi bunu ayrıca örnekler.
  • Atomik değişkene bir yerde düz erişmek: Eski fonksiyon API'sinde x++ ile atomic.AddInt64(&x, 1) karışırsa yarış geri gelir. Tip tabanlı API bunu engeller.
  • Boş döngüde bayrak beklemek: Bellek modeline göre döngünün bitmesi bile garanti değildir; kanal ya da sync araçlarıyla bekleyin.
  • İki atomik değeri tek işlem sanmak: Her biri ayrı ayrı atomiktir; birlikte tutarlılık için mutex gerekir.
  • Yayımlanmış nesneyi değiştirmek: atomic.Pointer ile paylaşılan nesneyi yerinde güncellemek yeni bir yarıştır.
  • atomic.Value'ya farklı tipler koymak: İlk Store'dan farklı somut tip panic üretir.
  • Atomik tipleri kopyalamak: atomic.Int64 içeren yapıları değerle geçirmeyin; belge kopyalamayı yasaklar.
  • Race detector'ü yalnızca bir kez çalıştırmak: Yalnızca o çalışmada gerçekleşen yarışları bulur; düzenli çalıştırın.

Sık Sorulan Sorular

Race detector temiz çıktı verdiyse programım yarışsız mıdır?

Hayır. Belgeye göre dedektör yalnızca çalışma sırasında gerçekleşen yarışları bulur. Çalışmayan kod yolları ya da o çalıştırmada denk gelmeyen zamanlamalar raporlanmaz. Kapsamı yüksek testler ve gerçekçi yük altında çalıştırma güveni artırır.

atomic işlemler mutex'ten her zaman hızlı mıdır?

Belgeler böyle bir karşılaştırma vermez. Doğru soru performans değil doğruluktur: tek bir değeri güncelliyorsanız atomik tip daha sade kod üretir, birden çok değerin birlikte tutarlı kalması gerekiyorsa mutex gerekir. Performans önemliyse benchmark ile ölçün.

-race bayrağını Windows'ta kullanabilir miyim?

Evet, windows/amd64 desteklenen platformlar arasındadır. Ancak cgo'nun etkin olması ve Go 1.21'den itibaren mingw-w64 çalışma zamanı kütüphanelerinin 8 veya üstü sürümünü içeren bir C derleyicisinin kurulu olması gerekir.

Go'da gevşek (relaxed) atomik işlemler var mı?

Hayır. Go 1.19 sürüm notları Go'nun yalnızca sıralı tutarlı atomikler sunduğunu, diğer dillerdeki daha gevşek biçimleri sunmadığını belirtir.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: Go öğrenme rehberi · sync.Mutex ve WaitGroup · Go goroutine · Go channel · Go test yazma · benchmark ve fuzz testi · Go sık karşılaşılan hatalar