I recently wrote an overview on concurrency, but I didn’t add a working example of mutexes. My goal in this post is to show how mutexes can be helpful when dealing with shared state and concurrent threads. If you haven’t worked with concurrency before, I recommend reading the concurrency post before moving on.
I want to make this demo as hands-on as possible, so I will be going through a process tutorial on how to work with mutexes in Go. Before we start, I want to introduce the problem statement that we’ll be working with.
The Problem Statement
Write a concurrent program which keeps track of the number of times a character has appeared in the string. To simplify the problem, assume that you only need to keep track of non-space characters.
The First Step
The first thing that comes to my mind is that I need some sort of data structure which allows me to associate each character with its respective count. This sounds awfully a lot like maps in Go. So, let’s create a custom type which allows us to work with maps concurrently!
type SafeMap struct {
data map[byte]int
m *sync.RWMutex
}
We have two types in our struct: map
and RWMutex
. The map is keyed by a byte which represents a character in the string and maps
to an integer which represents the count. The mutex allows threads to safely interact with the data inside the map without data race
issues.
Our Problem Solution
Now that we have an idea with what data structure we’ll be working with, we can come up with a general solution.
We already know that we need to count the number of times a character appears – we can do this with a for loop!
In order to make the program concurrent, we do our processing via a goroutine. The keyword go
signifies that the
function associated with the keyword should be executed independently and concurrently.
We need to make sure that any data access is done so safely inside the goroutine. In other words, we need to lock the
resource whenever we perform an update to it. We can see this happening via m.Lock()
. After the processing is done,
we can unlock the resource via m.Unlock()
.
The following code explains each main step of the process:
package main
import (
"fmt"
"sync"
"strings"
)
// our custom map structure which includes
// a mutex
type SafeMap struct {
data map[byte]int
m *sync.RWMutex
}
func main() {
// initialize the types we need
safemap := SafeMap{
data: make(map[byte]int),
m: &sync.RWMutex{},
}
myString := "This is an example string"
// remove all whitespace characters in the
// string
myString = strings.ReplaceAll(myString, " ", "")
// create a wait group to wait for
// all concurrent processes to finish
wg := &sync.WaitGroup{}
for i := 0; i < len(myString); i++ {
// add one job count
wg.Add(1)
// process via goroutine
go func(i int, safemap SafeMap, wg *sync.WaitGroup) {
// release one job count at the end of this
// function
defer wg.Done()
// lock safemap (write lock)
safemap.m.Lock()
if _, ok := safemap.data[myString[i]]; !ok {
// add the new character into our map
// since it doesn't exist
safemap.data[myString[i]] = 1
// unlock safemap
safemap.m.Unlock()
return
}
// character already exists in our map
// increase its count
safemap.data[myString[i]]++
// unlock safemap
safemap.m.Unlock()
}(i, safemap, wg)
}
// wait for all concurrent processes to finish
wg.Wait()
// print our results
for k, v := range safemap.data {
fmt.Printf("%s: %d\n", string(k), v)
}
}
I added a Wait Group in this program because each iteration of the for loop will start a new goroutine. All goroutine processes are counted as independent, and therefore, the main program will not wait for the computations to finish. Since we are relying on the results computed in the goroutines, I added a wait group to ensure that all processes are finished before we print out the results.
Feel free to play around with my code on the Go Playground!
Before I end the post, I want to show an example where we use both read and write locks.
A Database Example
Suppose that we’re writing a backend server application in which we have access to some sort of external database. Most server applications interacting with databases have separate functions to perform data creation, retrieval, update, and deletion operations.
Since server applications need to handle multiple database requests at the same time, we need to make sure all our database operations are threadsafe.
Consider the following implementations:
func (d database) Create() {
d.mutex.Lock()
defer d.mutex.Unlock()
// create new database row here
}
func (d database) Read() {
d.mutex.RLock()
defer d.mutex.RUnlock()
// retrieve data here
}
func (d database) Update() {
d.mutex.Lock()
defer d.mutex.Unlock()
// update database row here
}
func (d database) Delete() {
d.mutex.Lock()
defer d.mutex.Unlock()
// delete database row here
}
Did you notice that the Read operation uses a RLock instead of a regular Lock (Write)? As explained in the concurrency post, multiple threads can hold a read lock which speeds up efficiency for multiple read requests.
And… that’s it! I hope this has been a somewhat fun example to learn how to use mutexes in Go!