Welcome back to the series tutorials on Go Concurrency. If you missed previous tutorials please refer to: Go Concurrency with GoRoutines.
Today's topic is Mutex which is one of the most critical concepts in concurrent programming. When you use GoRoutine and Channels to perform concurrent programming, have you ever thought about what happens when two GoRoutines try to access the same piece of data in the same memory location? This is the so-called race condition and sometimes it may produce unexpected results which are often hard to debug.
Let's have a look at an example of concurrent programming without Mutex.
package main import ( "fmt" "sync" ) type calculation struct{ sum int } func main() { test := calculation{} test.sum = 0 wg := sync.WaitGroup{} for i:=0; i<500; i++{ wg.Add(1) go dosomething(&test, &wg) } wg.Wait() fmt.Println(test.sum) } func dosomething(test *calculation , wg *sync.WaitGroup) { test.sum++ wg.Done() }
In the above example, we declare a calculation struct called test and we spawn multiple GoRoutines with the for loop to increase the value of sum by 1. (If you are not familiar with GoRoutines and WaitGroup, please refer to the previous tutorials). We may expect that the value of sum after the for loop should be 500. However, this may not be true. Sometimes, you may obtain a result less than 500 (and never more than 500, of course). The reason behind this is that there is a certain probability that two GoRoutines are manipulating the same variable at the same memory location which causes this unexpected result. The solution to this problem is to use Mutex.
package main import ( "fmt" "sync" ) type calculation struct { sum int mutex sync.Mutex } func main() { test := calculation{} test.sum = 0 wg := sync.WaitGroup{} for i := 0; i < 500; i++ { wg.Add(1) go dosomething(&test, &wg) } wg.Wait() fmt.Println(test.sum) } func dosomething(test *calculation, wg *sync.WaitGroup) { test.mutex.Lock() test.sum++ test.mutex.Unlock() wg.Done() }
In the second example, we add a mutex attribute inside the struct which is a type of sync.Mutex. We then use the Lock() and Unlock() of mutex to protect test.sum when it is being modified concurrently, i.e. test.sum++.
Keep in mind that using mutex is not without its consequences as it will impact the performance of your application, and therefore we need to use it appropriately and efficiently. If your GoRoutines only read the shared data and not write to the same data, then race condition will not be a concern. In this case, you can use RWMutex instead of Mutex to improve performance time.
Using the defer keyword for the Unlock() is usually a good practice.
func dosomething(test *calculation) error{ test.mutex.Lock() defer test.mutex.Unlock() err1 :=... if err1 != nil { return err1 } err2 :=... if err2 != nil { return err2 } // ... do more stuff ... return nil }
In this case, we have multiple if err!=nil which may lead to early exiting of the function. By using defer, we are guaranteed to release the lock no matter how the function returns. Otherwise, we need to put Unlock() in every place that the function may possibly return. However, it does not mean we should use defer all the time. Let's have a look at another example.
func dosomething(test *calculation){ test.mutex.Lock() defer test.mutex.Unlock() // modify the variable which requires mutex protect test.sum =... // perform a time consuming IO operation http.Get() }
In this example, mutex will not release the lock until the time consuming function (which is http.Get() here) finishes. In this case we can just Unlock the mutex after the line test.sum=... because this is the only place where we manipulate the variable.
We hope you enjoyed this tutorial series of Go Concurrency. In our next tutorial, we will show a couple of actual use cases combining all these components we have introduced in the past weeks.
Stay tuned!