In this shot, we’ll go over what a race condition is, review a code example that creates a race condition, and learn how to fix that race condition with the help of Mutexes
.
A race condition in Go occurs when two or more goroutines have shared data and interact with it simultaneously. This is best explained with the help of an example.
Suppose two functions that increment an integer by 1 are launched in two separate goroutines and act on the same variable. If they are executed sequentially, then the initial integer will increase by 2. However, now that they are in a race condition, this can’t be guaranteed.
Each function has three tasks:
So, let’s say that Function 1 reads the variable, but before it can increment and write it back, Function 2 also reads the variable. Both of the functions now increment the variable and write it back, but the variable value will only increase by 1 because both functions initially read the same value.
The image below should make it clearer.
Please note that this is only one of the possible scenarios. It might just happen that the second goroutine reads the variable after the first one has already incremented and written back to it. In this case, the value would increase by two. So, when there is a race condition, the final output is not fixed and can vary each time you run the program.
Note: We cannot have a race condition if we only have a single goroutine.
Now that you have an idea about what race conditions are, let’s look at a code example.
package mainimport ("fmt""runtime""sync")func main() {counter := 0const num = 15var wg sync.WaitGroupwg.Add(num)for i := 0; i < num; i++ {go func() {temp := counterruntime.Gosched()temp++counter = tempwg.Done()}()}wg.Wait()fmt.Println("count:", counter)}
We declare a variable counter
, which is what all our goroutines will try to modify simultaneously. Then, we launch 15 anonymous self-executing functions in their separate goroutines. Each function reads the value from counter
, increments it, and then writes it back. Additionally, we use a WaitGroup, which explains everything apart from runtime.Gosched()
.
Before I explain what that does, comment line 20 and run the code a few times. You will see that the output is 15 every time. This should not be the case, given that there is a race condition.
Now, change the initial value of num
to 100 and run it multiple times. You’ll see that different values appear this time, but they’re still close to 100 and not very far apart. Next, change the value to 1000 and run the program. You should start seeing even greater differences in the values you get as output. Uncomment line 20 and repeat the steps with num
as 15, 100, and 1000. If you did everything correctly, you should see a different value each time in all three cases, and the values will be much farther apart than before.
Please note that you should not try this on some online IDEs, as they are not suitable for simultaneously running multiple goroutines.
This is a race condition because we see different values for 100 and 1000. Let’s go over what runtime.Gosched()
does. Go tries to avoid these race conditions on its own and succeeds when the number is relatively small, like 15, but has a tough time keeping up as we increase the number. Go uses something called cooperative multithreading (source). If you read the link, it says,
“In cooperative models, once a thread is given control, it continues to run until it explicitly yields control or it blocks.”
runtime.Gosched()
basically says go ahead and run another goroutine if you want. In other words, it yields the processor, allowing other goroutines to run. It increases the randomness so that the probability of goroutines interfering with each other and reading-writing in different order increases.
Note: You can also confirm the existence of a race condition by executing the program with the
race
flag:go run -race main.go
.
Now that we’ve created a race condition, let’s go ahead and fix it. We will use a Mutex
to prevent the race condition. We know the data race occurs due to multiple goroutines accessing a shared variable. We can avoid this by locking access to our counter
variable when one of the goroutines reads it, and then unlocking it when it is done writing the incremented value. This way, nobody else can use that variable when a particular goroutine is updating it. This is exactly what Mutex
does.
Below is the update we make to our code:
// const num = 15
var wg sync.WaitGroup
wg.Add(num)
var mu sync.Mutex
for i := 0; i < num; i++ {
go func() {
mu.Lock()
temp := counter
runtime.Gosched()
temp++
counter = temp
mu.Unlock()
wg.Done()
}()
}
wg.Wait()
Run go run -race main.go
and you’ll see that no race condition gets reported this time. Since there are no race conditions now, the value of counter
will be 15 each time.
For more information on race conditions, you can take a look at the official documentation.
Free Resources