What is a race condition?
A race condition occurs when multiple threads try to access and modify the same data (memory address). E.g., if one thread tries to increase an integer and another thread tries to read it, this will cause a race condition. On the other hand, there won't be a race condition, if the variable is read-only. In golang, threads are created implicitly when Goroutines are used.
Let’s try to create a race condition. The easiest way to do it is by using multiple gouroutines, and at least one of the goroutines must be writing to a shared variable.
Following code demonstrates a simple way to create a race condition.
- A goroutine reads the variable named “sharedInt”
- Another goroutine writes to the same variable by increasing its value.
If you run this code, this will not result in a crash, but sometimes the reader goroutine will access an outdated copy of “sharedInt”. If you run the code with the built-in race condition checker, the go compiler will complain about the problem.
go run -race .
A small note about the Golang race condition checker: if your code occasionally accesses shared variables, it might not be able to detect the race condition. To detect it, the code should run in heavy load, and race conditions must be occurring.
You can see the output of the race condition checker. It complains about unsynchronized data access.
How can we solve this problem? If the shared data is a single variable, we can use counters provided in the sync/atomic pack. In the following example, instead of accessing the shared variable directly, we can use the atomic.LoadInt64()/atomic.AddInt64() pair to access it. The race condition checker will not complain about the unsynchronized data access anymore.
This solves our problem when using primitive variables, but we need to access multiple variables and work with complex data structures in many cases. In these cases, it makes sense to control the access by using mutexes.
The following example demonstrates unsynchronized access to a map. When using complex data structures, race conditions could result in a crash. Therefore, if we run this example without race check enabled, the go runtime will complain about the concurrent access, and the process will exit.
fatal error: concurrent map read and map write
It is possible to fix this problem by controlling access to the critical section. In this example, the critical section is where we read and write to the “sharedMap”. In the example below, we call mutex.Lock() and mutex.Unlock() pair to control the access.
How does mutex work?
- Mutex is created in an unlocked state.
- When the first call to mutex.Lock() is made, mutex state changes to Locked.
- Any other calls to mutex.Lock() will block the goroutine until mutex.Unlock() is called
- So, only one thread can access the critical section.
For example, we can control access to the critical section by using a mutex. I added a context to cancel the goroutines after working for 2 seconds. You can ignore the code related to context if you are not familiar with it.
If we run the example code, go runtime will not complain about concurrent map read and map write anymore because only one goroutine can access the critical section at a time.
In the example, I used 15 reader goroutines and a single writer goroutine. The writer updates the “sharedMap” every 100 milliseconds. It could be better to use RWMutex (reader/writer mutual exclusion lock) in cases like this. It is similar to a mutex, but it also has another locking mechanism to let multiple readers access the critical section when it is safe. This could perform better when writes are rare, and reads are more common.
How does RWMutex works?
- In simpler terms, multiple readers can access the critical section if there are no writers. If a writer tries to access the critical section, all reads are blocked. This is more efficient when the writes are rare, and the reads are common.
- rwMutex.Lock() and rwMutex.Unlock() works similarly to the mutex lock-unlock mechanism.
- rwMutex.RLock() does not block any readers if the mutex is in an unlocked state. This lets multiple readers access the critical section at the same time.
- When rwMutex.Lock() is called; the caller is blocked until all readers call rwMutex.RUnlock(). At this point, any calls to RLock() start to block until rwMutex.Unlock() is called. This prevents any starvation from occurring.
- When rwMutex.Unlock() is called; all the callers of RLock() are unblocked and can access the critical section.
Mutex vs RWMutex Performance
I ran the examples five times and compared the averages. As a result, RWMutex performed 14.35% more read operations. But please keep in mind that this example is highly biased as there are 15 reader goroutines and a single writer goroutine.
In this blog post, I tried to go over the basics of unsynchronized data access, which results in race conditions. If you are not familiar with multi-threaded programming, it is easy to overlook this and run into problems.
In my personal experience, I would prefer making every goroutine/thread use its own variables in an isolated context and propagating the changes to an aggregator by using channels. Usually, it is easier to design single-threaded components that communicate over channels or queues. However, this approach is not suitable in every case, and mutexes can come in handy.
Thanks for reading. If you have any feedback or found a mistake, please let me know.