Race Condition

ud-205

Bu kısımda bir önceki yazıda da bahsettiğimiz race condition’u biraz inceleyelim: Go ve Concurrency

İlk olarak aşağıdaki kod ile başlayalım. Bu denemelerde kodları kendi Linux bilgisayarımda çalıştırıyor olacağım.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("CPU sayısı:", runtime.NumCPU())
	fmt.Println("Maks. aktif thread sayısı (GOMAXPROC):", runtime.GOMAXPROCS(0))
}

Çıktı:

CPU sayısı: 16
Maks. aktif thread sayısı (GOMAXPROC): 16

Bilgisayarımda 16 adet çekirdek var (logical). Peki GOMAXPROC nedir?

GOMAXPROCS

Resmi doküman üzerinden bir bakalım [1]:

The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit. This package’s GOMAXPROCS function queries and changes the limit.

goroutine’lerin işletim sistemleri thread’leri ile birebir aynı olmadığından ve multiplex edilebileceğinden bahsetmiştik. GOMAXPROCS aktif olarak go kodu yani goroutine çalıştıracak işletim sistemi thread sayısını belirtmektedir. GOMAXPROCS() fonksiyonu ile bu değeri değiştirebiliriz. Örnekteki gibi (0) ile çağırırsak değeri değiştirmeden bize güncel değeri döner.

Go’nun çeşitli sürümlerinde değişmekle beraber Go runtime çalışma sırasında çeşitli algoritmalara göre bu parametrenin varsayılan değerini belirlemektedir. Mesela Go 1.25 ile beraber artık runtime container-aware bir atama yapmaktadır. [2] Benim durumumda işlemci sayısı ile bu değerin aynı olduğunu görebiliyoruz.

Deneyler

Şimdi race condition içeren bir kod yazalım.

 1package main
 2
 3import (
 4	"fmt"
 5	"runtime"
 6	"sync"
 7)
 8
 9func main() {
10	fmt.Println("CPU sayısı:", runtime.NumCPU())
11	//runtime.GOMAXPROCS(0), değiştirmeden olanı dönüyor
12	fmt.Println("Maks. aktif thread sayısı (GOMAXPROC):", runtime.GOMAXPROCS(0))
13
14	counter := 0
15
16	const gs = 100
17	var wg sync.WaitGroup
18	//vgs adedi kadar bekle
19	wg.Add(gs)
20
21	for i := 0; i < gs; i++ {
22		go func() {
23			v := counter
24			v++
25			counter = v
26			wg.Done()
27		}()
28		fmt.Println("Goroutine sayısı:", runtime.NumGoroutine())
29	}
30	wg.Wait()
31	fmt.Println("counter:", counter, "beklenen:", gs)
32}

Burada gs adet goroutine oluşturuyoruz. counter isminde bir değişkenimiz var. Bu değişken goroutine’ler için birer global değişken durumunda, yani tüm goroutine’ler buna erişebiliyor. Her bir goroutine, kendi scope’unda bir v değişkenine (bu ortak değil) günce counter değerini alıyor, bir arttırıp geri yazıyor. En sonunda counter değerine bakıyoruz. Normal şartlarda bu değerin goroutine sayısı yani gs ile aynı olmasını bekleriz. Her bir goroutine başlattıktan sonra da güncel goroutine sayısını yazdırıyoruz.

Bir çalıştıralım.

Deney 01: counter: 100 beklenen: 100
Deney 02: counter: 100 beklenen: 100
Deney 03: counter: 100 beklenen: 100
Deney 04: counter: 100 beklenen: 100
Deney 05: counter: 100 beklenen: 100
Deney 06: counter: 100 beklenen: 100
Deney 07: counter: 98 beklenen: 100
Deney 08: counter: 100 beklenen: 100
Deney 09: counter: 100 beklenen: 100
Deney 10: counter: 100 beklenen: 100

Yukarıdaki kodu 10 kere çalıştırdım. 9’u beklediğimiz gibi geldi ama bir tanesinde sonuç 100 yerine 98 çıktı. Go bozuk mu? 🤔

Hayır değil, sorunlu olan bizim kodumuz! Çünkü kodumuzda race condition var. Bir önceki yazıda aşağıdaki görseli vermiştik:

../_images/data_race.png

Ref: https://github.com/ardanlabs/gotraining/blob/master/topics/go/concurrency/data_race/README.md

İşte bu kod ile tam burada gösterilen durumu yaratmaya çalıştık ve denemelerimizin birinde race condition’u görebildik (neyse ki).

Yukarıda Goroutine sayısı: loglarını vermedim ama bazı denemelerde 1 ile 16 arasında kalırken (benim çekirdek sayım), bazı denemelerde ise 101’e kadar gidiyor.

Eğer sayıyı 100 değil de 10000 yaparsak durumu çok daha rahat gözlemleyebiliriz.

Deney 01: counter: 9992 beklenen: 10000
Deney 02: counter: 9979 beklenen: 10000
Deney 03: counter: 9986 beklenen: 10000
Deney 04: counter: 9991 beklenen: 10000
Deney 05: counter: 9992 beklenen: 10000
Deney 06: counter: 9989 beklenen: 10000
Deney 07: counter: 9995 beklenen: 10000
Deney 08: counter: 9928 beklenen: 10000
Deney 09: counter: 9957 beklenen: 10000
Deney 10: counter: 9963 beklenen: 10000

mean = 9977, stddev = 21

Şimdi aynı deneyi sistemde bir yandan da sysbench cpu --threads=16 run ile bir CPU benchmark yaparken yapalım.

Deney 01: counter: 9922 beklenen: 10000
Deney 02: counter: 9830 beklenen: 10000
Deney 03: counter: 9947 beklenen: 10000
Deney 04: counter: 9834 beklenen: 10000
Deney 05: counter: 9836 beklenen: 10000
Deney 06: counter: 9652 beklenen: 10000
Deney 07: counter: 9907 beklenen: 10000
Deney 08: counter: 9642 beklenen: 10000
Deney 09: counter: 9836 beklenen: 10000
Deney 10: counter: 9700 beklenen: 10000

mean = 9810, stddev = 109

Yük altında sapmamız daha da arttı.


Şimdi runtime.GOMAXPROCS(1) ile aktif olarak çalışabilecek işletim sistemi thread sayısını 1 ile sınırlayalım ve aynı deneyi tekrarlayalım. Bunun için fmt.Println("Maks. aktif thread sayısı (GOMAXPROC):", runtime.GOMAXPROCS(0)) satırının üstüne runtime.GOMAXPROCS(1) ekleyelim. Her şeyi doğru yaptıysak kodun başındaki çıktı

Maks. aktif thread sayısı (GOMAXPROC): 1

olmalıdır. Şimdi deney zamanı

Deney 01: counter: 10000 beklenen: 10000
Deney 02: counter: 10000 beklenen: 10000
Deney 03: counter: 10000 beklenen: 10000
Deney 04: counter: 10000 beklenen: 10000
Deney 05: counter: 10000 beklenen: 10000
Deney 06: counter: 10000 beklenen: 10000
Deney 07: counter: 10000 beklenen: 10000
Deney 08: counter: 10000 beklenen: 10000
Deney 09: counter: 10000 beklenen: 10000
Deney 10: counter: 10000 beklenen: 10000

Ara çıktılardan bir örnek:

Goroutine sayısı: 3808
Goroutine sayısı: 3809
Goroutine sayısı: 3810
counter: 10000 beklenen: 10000

Görüldüğü üzere race condition’ımı görülmez oldu. Peki neden? Yüzde yüz emin olmamakla beraber benim yorumum şu şekilde: Biz aktif çalışacak goroutine sayısını 1 ile sınırladık. Dikkat ederseniz binlerce goroutine yine var ama dokümantasyona göre bu sınır aktif çalışacak goroutine sayısını belirliyor. Yani aslında bir t anında tek bir goroutine kritik bölgeye, değişkenin modifiye edildiği, erişiyor. Go çizelgeliyicisi de tipik olarak bir goroutine bloke olmadan diğerine geçmiyor. Preemption olduğu bir durumda ayarladığımız değer 1 olmasına rağmen hala race condition görünür hale gelebilirdi. Ama burada çizelgeleyici muhtemelen bir goroutine bitmeden diğerine geçmiyor. Burada gs sayısının 100000 yapsam da yanında CPU benchmark açsam da race condition görünmez oldu. Biz burada aslında değeri 1 ile set ederek paralelliği adeta kapattık, tek çekirdekli işlemci üzerinde çalışıyor gibi oldu. Ama preemption mekanizmasına göre hala kritik bölgelerde çizelgeleyici goroutine değiştirseydi tek çekridek gibi çalışıyor olmamıza rağmen race condition görebilirdik. Burada biraz şans, biraz da Go runtime çizelgeleyicisinin algoritmasından dolayı bunu görmüyoruz, goroutine’ler I/O vs bloke olmuyor çünkü.

runtime.Gosched()

Şimdi runtime paketindeki Gosched() fonksiyonuna bakalım. Açıklaması şu şekilde:

Gosched yields the processor, allowing other goroutines to run. It does not suspend the current goroutine, so execution resumes automatically.

Bu fonksiyon ile gönüllü olarak Go çizelgeleyicisini çağırıp, başka goroutine geçmesine imkan sağlıyoruz. Bizim durumumuzda çalışmayı bekleyen başka goroutine’ler olduğu için çizelgeleyici diğer goroutine’lere geçiyor ve bu sefer race condition’u çok net görüyoruz.

İlgili kısım şu şekilde:

for i := 0; i < gs; i++ {
	go func() {
		v := counter
		v++
		runtime.Gosched()
		counter = v
		wg.Done()
	}()
	fmt.Println("Goroutine sayısı:", runtime.NumGoroutine())
}

Yine runtime.GOMAXPROCS(1) ile yaptığımız deneyin sonuçları şu şekilde:

Deney 01: counter: 8 beklenen: 10000
Deney 02: counter: 7 beklenen: 10000
Deney 03: counter: 6 beklenen: 10000
Deney 04: counter: 6 beklenen: 10000
Deney 05: counter: 5 beklenen: 10000
Deney 06: counter: 6 beklenen: 10000
Deney 07: counter: 5 beklenen: 10000
Deney 08: counter: 6 beklenen: 10000
Deney 09: counter: 6 beklenen: 10000
Deney 10: counter: 13 beklenen: 10000, benchmark varken yaptım.

Görüldüğü gibi race condition çok net gözüküyor.

Race condition sadece multi-core işlemci problemi değildir.

time.Sleep()

Şimdi de time paketindeki Sleep() fonksiyonunu kullanalım. Döngümüz şu şekilde oldu:

for i := 0; i < gs; i++ {
	go func() {
		v := counter
		v++
		time.Sleep(time.Microsecond)
		counter = v
		wg.Done()
	}()
	fmt.Println("Goroutine sayısı:", runtime.NumGoroutine())
}

Bu da aslında benzer bir etki yaratacktır. Kritik bir noktada goroutine’ı “uyku”ya yatırıyoruz. Böyle bir durumda runtime.GOMAXPROCS(1) olsa bile go çizelgelyicisi goroutine’nin bloke olduğunu, beklediğini gördüğü için hemen başka goroutine’ı çalıştırıyor ve yine race condition gözlenebilir oluyor.

Deney 01: counter: 3263 beklenen: 10000
Deney 02: counter: 3250 beklenen: 10000
Deney 03: counter: 2523 beklenen: 10000
Deney 04: counter: 3541 beklenen: 10000
Deney 05: counter: 2882 beklenen: 10000
Deney 06: counter: 2915 beklenen: 10000
Deney 07: counter: 2850 beklenen: 10000
Deney 08: counter: 3048 beklenen: 10000
Deney 09: counter: 2960 beklenen: 10000
Deney 10: counter: 2559 beklenen: 10000

Tabi burada şöyle bir fark var aslında. Bir goroutine uyuduğu zaman bloklanıyor ve waiting durumunda kalıyor. Oysa runtime.Gosched() dediğimiz zaman o goroutine runnable durumda adeta fırlamaya hazır bekliyor. Bu ikisi farklı durum olsa da benzer etikler görüyoruz ama counter değeri dramatik farklı çıkabiliyor. Bu kapsamda artık değer niye daha küçük/büyük diye bakmıyoruz, amacımız race condition’u anlamak. Ama şunu söyleyebilirim time.Millisecond kadar uyutursak counter değerleri 10 civarında oluyor. Eğer time.Second dersek değer 1 oluyor çünkü süre uzadıkça daha çok goroutine aynı anda uykuda oluyor ve aynı anda uyanıyorlar ve adeta birbirlerinin yaptığını “eziyorlar”. Saniye uykuda tüm goroutine’ler yaratılmış ve uykuda bekliyor oluyor. Aynı anda uyanınca son uyananın yazdığı değer, 1 görünür oluyor. time.Microsecond durumunda ise ilk başlayanlar değerleri güncellemiş oluyor, az uyudukları için daha diğerleri başlamadan ve yenileri güncel değeri bir arttırıyor. Böyle olunca burada biraz daha beklenen değere yaklaşıyor.

go run -race

Go’nun aslında bu tarz durumları tespit edebileceği çeşitli araçları bulunuyor. Bunlardan biri de -race flag’i. Şimdi kodumuzu bu şekilde çalıştıralım.

Örnek bir çıktı:

Found 2 data race(s)

Bu durumda öncelikle programımızın biraz daha uzun süre çalıştığını fark edebilirsiniz (ilk çalıştırmada daha uzun sürebilir). Race condition’lar dinamik olarak yakalanmaya çalıştığı için bu istek bir şekilde çalışma süresini uzatıyor.

Bunun bir güzelliği sonuç normal gözükse bile race condition’un raporlanabilmesi Çünkü bu araç, sonuca bakarak değil sürece bakarak karar verir.

Maks. aktif thread sayısı (GOMAXPROC): 1
Goroutine sayısı: 2
Goroutine sayısı: 3
Goroutine sayısı: 4
Goroutine sayısı: 5
Goroutine sayısı: 6
Goroutine sayısı: 7
Goroutine sayısı: 8
Goroutine sayısı: 9
Goroutine sayısı: 10
Goroutine sayısı: 11
==================
WARNING: DATA RACE
Read at 0x00c0000121a8 by goroutine 8:
  main.main.func1()
      /home/ayazar/temp/go/x.go:25 +0x33

Previous write at 0x00c0000121a8 by goroutine 16:
  main.main.func1()
      /home/ayazar/temp/go/x.go:28 +0x45

Goroutine 8 (running) created at:
  main.main()
      /home/ayazar/temp/go/x.go:24 +0x206

Goroutine 16 (finished) created at:
  main.main()
      /home/ayazar/temp/go/x.go:24 +0x206
==================
counter: 10 beklenen: 10
Found 1 data race(s)
exit status 66

İstersek go build -race diyerek derleme sonrası çıkan çalıştırılabilir dosyaya da bu tespit özelliğini getirebiliyoruz.

İlgili kaynaklar: