Reputation: 68
I had a task to simulate race conditions in Go. However, I've run into a case, that I am unable to explain. The code snippet below
package main
import (
"fmt"
"sync"
)
var value, totalOps, totalIncOps, totalDecOps int
func main() {
fmt.Println("Total value: ", simulateRacing(10000))
fmt.Print("Total iterations: ", totalOps)
fmt.Print(" of it, increments: ", totalIncOps)
fmt.Print(", decrements: ", totalDecOps)
}
// Function to simulate racing condition
func simulateRacing(iterationsNumber int) int {
value = 0
// Define WaitGroup
var waitGroup sync.WaitGroup
waitGroup.Add(2)
go increaseByOne(iterationsNumber, &waitGroup)
go decreaseByOne(iterationsNumber, &waitGroup)
waitGroup.Wait()
return value
}
// Function to do N iterations, each time increasing value by 1
func increaseByOne(N int, waitGroup *sync.WaitGroup) {
for i := 0; i < N; i++ {
value++
// Collecting stats
totalOps++
totalIncOps++
}
waitGroup.Done()
}
// Same with decrease
func decreaseByOne(N int, waitGroup *sync.WaitGroup) {
for i := 0; i < N; i++ {
value--
// Collecting stats
totalOps++
totalDecOps++
}
waitGroup.Done()
}
In my understanding, it should produce consistent (deterministic) result each time, since we are doing the same number of increments and decrements, with a WaitGroup making sure both functions will execute.
However, each time output is different, with only increments and decrements counters staying the same. Total value: 2113 Total iterations: 17738 of it, increments: 10000, decrements: 10000 and Total value: 35 Total iterations: 10741 of it, increments: 10000, decrements: 10000
Maybe you can help me to explain this behaviour? Why total iterations counter and value itself is non-deterministic?
Upvotes: 1
Views: 69
Reputation: 34457
because the operations on the variables value, totalOps, totalIncOps and totalDecOps are not locked
Adding a mutex should help. The Go race detector feature would find this fault
var m sync.Mutex
func increaseByOne(N int, waitGroup *sync.WaitGroup) {
for i := 0; i < N; i++ {
m.Lock()
value++
// Collecting stats
totalOps++
totalIncOps++
m.Unlock()
}
waitGroup.Done()
}
// Same with decrease
func decreaseByOne(N int, waitGroup *sync.WaitGroup) {
for i := 0; i < N; i++ {
m.Lock()
value--
// Collecting stats
totalOps++
totalDecOps++
m.Unlock()
}
waitGroup.Done()
}
An alternative to the above would be to use Sync.Atomic for the counters
Upvotes: 1
Reputation: 9248
That's a classical example of race condition. value++
is not an atomic operation, so there are no guarantees that it will work correctly or deterministically when called from multiple threads without synchronization.
To give some intuition, value++
is more or less equivalent to value = value + 1
. You can think of it as three operations, not one: load value
from memory to a CPU register, increase value in the register (you cannot modify memory directly), store the value back to the memory. Two threads may load the same value simultaneously, increase it, get the same result, and then write it back, so it effectively increases value
by 1, not two.
As order of operations between threads is non-deterministic, result is also non-deterministic.
The same effect happens with totalOps
. However, totalIncOps
and totalDecOps
are only ever modified/read by a single thread, so there is no race here and their end values are deterministic.
Upvotes: 2