{ Soham Kamani }

About Blog Github Twitter

🌙
☀️

Using Mutexes in Golang - A Comprehensive Tutorial With Examples

In this post, we’ll discuss why we use mutexes in Go, and how to implement them in practice.

banner image

Go is a language known for how simple it is to run concurrent routines (called goroutines). However, because of this, it’s easy to run into errors when concurrent goroutines have to access the same piece of data.

Mutexes are data structures provided by the sync package. They can help us prevent concurrent access to data that we want to change atomically.

Using A Mutex In Practice

Let’s take a look at an example, where concurrent goroutines can corrupt a piece of data:

func isEven(n int) bool {
	return n%2 == 0
}

func main() {
	n := 0

  // goroutine 1
	go func() {
		nIsEven := isEven(n)
    // we can simulate some long running step by sleeping
		time.Sleep(5 * time.Millisecond)
		if nIsEven {
			fmt.Println(n, " is even")
			return
		}
		fmt.Println(n, "is odd")
	}()

  // goroutine 2
	go func() {
		n++
	}()

	// just waiting for the goroutines to finish before exiting
	time.Sleep(time.Second)
}

Running this code, will give us the following unexpected output:

1  is even

This happens because goroutine 1 actually accesses n twice: once to check if it’s even, and again to print its value. In between these steps, goroutine 2 increments the value of n.

The value that is printed is now different from the value that was checked. This is known as a data race.

not using a mutex leads to overlapping reads and writes

Ideally, we should ensure that n should not be written to incase another goroutine is reading it, and vice versa. This is where sync.Mutex comes in:

func main() {
	n := 0
	var m sync.Mutex

	// now, both goroutines call m.Lock() before accessing `n`
	// and call m.Unlock once they are done
	go func() {
		m.Lock()
		defer m.Unlock()
		nIsEven := isEven(n)
		time.Sleep(5 * time.Millisecond)
		if nIsEven {
			fmt.Println(n, " is even")
			return
		}
		fmt.Println(n, "is odd")
	}()

	go func() {
		m.Lock()
		n++
		m.Unlock()
	}()

	time.Sleep(time.Second)
}

Calling m.Lock will “lock” the mutex. If any other goroutine calls m.Lock, it will block the thread until m.Unlock is called.

If a goroutine calls m.Lock before its first read/write access to the relevant data, and calls m.Unlock after its last, it is guaranteed that between this period, the goroutine will have exclusive access to the data.

using a mutex leads to exclusive reads and writes

This is why, when we run the above code, we get a consistent output:

0  is even

Reader/Writer Mutex - The sync.RWMutex Type

Sometimes, it’s ok for data to have concurrent reads, as long as the writes stay atomic. In this case, we can differentiate between a read lock and a write lock using the sync.RWMutex type:

package main

import (
	"fmt"
	"sync"
	"time"
)

func isEven(n int) bool {
	return n%2 == 0
}

func main() {
	n := 0
	var m sync.RWMutex

	// goroutine 1
	go func() {
		m.RLock()
		defer m.RUnlock()
		nIsEven := isEven(n)
		time.Sleep(5 * time.Millisecond)
		if nIsEven {
			fmt.Println(n, " is even")
			return
		}
		fmt.Println(n, "is odd")
	}()

  // goroutine 2
	go func() {
		m.RLock()
		defer m.RUnlock()
		nIsPositive := n > 0
		time.Sleep(5 * time.Millisecond)
		if nIsPositive {
			fmt.Println(n, " is positive")
			return
		}
		fmt.Println(n, "is not positive")
	}()

  // goroutine 3
	go func() {
		m.Lock()
		n++
		m.Unlock()
	}()

	time.Sleep(time.Second)
}

When running this code, we can observe that goroutines 1 and 2 can access n concurrently, but the read (1 and 2) and writes (3) are locked from each other, and one can only start if the other has completed.

read-write locks allow concurrent reads

This can make your application more performant if it reads this data multiple times, since reads can happen concurrently.

Adding Mutexes Into Structs

If you want to bundle a mutex along with its data, you can consider using struct composition.

If we wanted to do this for the previous example, we could create a struct to hold the number value, and include a mutex as well:

type intLock struct {
	val int
	sync.Mutex
}

func (n *intLock) isEven() bool {
	return n.val%2 == 0
}

We can then use the mutex within the struct itself to handle locking and unlocking:

func main() {
	n := &intLock{val: 0}

	go func() {
		n.Lock()
		defer n.Unlock()
		nIsEven := n.isEven()
		time.Sleep(5 * time.Millisecond)
		if nIsEven {
			fmt.Println(n.val, " is even")
			return
		}
		fmt.Println(n.val, "is odd")
	}()

	go func() {
		n.Lock()
		n.val++
		n.Unlock()
	}()

	time.Sleep(time.Second)
}

Running this code will give the same result as before.

This is useful for a couple of reasons:

  1. If you have multiple instances of data that needs exclusive access, bundling a mutex along with the data itself will make it less confusing, and more readable
  2. The data can be passed along as function arguments, and the mutex will be passed on by default

Unless you need a common mutex for multiple pieces of data, it’s better to include a mutex within a struct as a good practice.

Common Pitfalls

Although mutexes may seem like a great solution, it’s easy to fall into some common traps.

Whenever you call the Lock method, you must ensure that Unlock is eventually called. This may seem straightforward, but is easy to miss. Consider this example:

go func() {
	n.Lock()
	nIsEven := n.isEven()
	time.Sleep(5 * time.Millisecond)
	if nIsEven {
		fmt.Println(n.val, " is even")
		// mutex is never unlocked
		return
	}
	fmt.Println(n.val, "is odd")
	n.Unlock()
}()

Here, we call the Unlock method at the end of the function, instead of using defer. Now, if n is even, we will print the corresponding statement, and return from the goroutine, but Unlock would never be called.

If this happens, any goroutine looking to access the same lock will be blocked forever. Using defer to call the Unlock method can help you avoid this.

Always release the lock as soon as you’re done accessing the data, and never later - You’ll just be wasting resources.

For example, if you had to do some work after accessing n, using defer may not be appropriate:

go func() {
	n.Lock()
	defer n.Unlock()
	nIsEven := n.isEven()
	time.Sleep(5 * time.Millisecond)
	if nIsEven {
		fmt.Println(n.val, " is even")
		return
	}
	fmt.Println(n.val, "is odd")

	// some work after printing
	time.Sleep(5 * time.Millisecond)
}()

In this case, n.Unlock will be called after the last time.Sleep call, even though we can release the lock as soon as we finish printing the result. By using defer, we’ve potentially delayed other goroutines which require access to this lock.

In cases like these, it’s better to manually release the lock once you’re done accessing the data:

go func() {
	n.Lock()
	nIsEven := n.isEven()
	time.Sleep(5 * time.Millisecond)
	if nIsEven {
		fmt.Println(n.val, " is even")
		// unlock before returning
		n.Unlock()
		return
	}
	fmt.Println(n.val, "is odd")
	// unlock after printing `n`s value
	n.Unlock()

	// we can now release the lock 5ms earlier
	time.Sleep(5 * time.Millisecond)
}()

In summary, a mutex is a great tool for preventing out-of-order data access. There are multiple ways to use a mutex, and many pitfalls that can occur, so make sure to evaluate your use-case before deciding the right approach.

To know more about mutexes in Go, you can see the “sync” standard library documentation page.


Like what I write? Join my mailing list, and I'll let you know whenever I write another post. No spam, I promise!

Comments

Soham Kamani

Written by Soham Kamani, an author,and a full-stack developer who has extensive experience in the JavaScript ecosystem, and building large scale applications in Go. He is an open source enthusiast and an avid blogger. You should follow him on Twitter