user1772574
user1772574

Reputation: 68

Unexpected behaviour whilst testing race condition with WaitGroup

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

Answers (2)

Vorsprung
Vorsprung

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

yeputons
yeputons

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

Related Questions