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:
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: