A Closer Look at Go’s sync Package

Teiva Harsanyi
5 min readNov 26, 2019
This is not an analogy for the sync package quality :)

Let’s take a look at the Go package in charge to provide synchronization primitives: sync.

sync.Mutex

sync.Mutex is probably the most widely used primitive of the sync package. It allows a mutual exclusion on a shared resource (no simultaneous access):

It must be pointed out that a sync.Mutex cannot be copied (just like all the other primitives of sync package). If a structure has a sync field, it must be passed by pointer.

sync.RWMutex

sync.RWMutex is a reader/writer mutex. It provides the same methods that we have just seen with Lock() and Unlock() (as both structures implement sync.Locker interface). Yet, it also allows concurrent reads using RLock() and RUnlock() methods:

A sync.RWMutex allows either at least one reader or exactly one writer whereas a sync.Mutex allows exactly one reader or writer.

Let’s run a quick benchmark to compare these methods:

BenchmarkMutexLock-4       83497579         17.7 ns/op
BenchmarkRWMutexLock-4 35286374 44.3 ns/op
BenchmarkRWMutexRLock-4 89403342 15.3 ns/op

As we can notice, read locking/unlocking a sync.RWMutex is faster than locking/unlocking a sync.Mutex. On the other end, calling Lock()/Unlock() on a sync.RWMutex is the slowest operation.

In conclusion, a sync.RWMutex should rather be used when we have frequent reads and infrequent writes.

sync.WaitGroup

sync.WaitGroup tends also to be used quite frequently. It’s the idiomatic way for a goroutine to wait for the completion of a collection of goroutines.

sync.WaitGroup holds an internal counter. If this counter is equal to 0, the Wait() method returns immediately. Otherwise, it is blocked until the counter is 0.

To increment the counter we have to use Add(int). To decrement it we can either use Done() (that will decrement by 1) or the same Add(int) method with a negative value.

In the following example, we will spin up eight goroutines and wait for their completion:

Each time we create a goroutine, we increment the wg‘s internal counter with wg.Add(1). We could have also called wg.Add(8) outside of the for-loop.

Meanwhile, every time a goroutine completes, it decreases the wg‘s internal counter using wg.Done().

The main goroutine continues its execution once the eight wg.Done() statements have been executed.

sync.Map

sync.Map is a concurrent version of Go map where we can:

  • Add elements with Store(interface{}, interface{})
  • Retrieve elements with Load(interface) interface{}
  • Remove elements with Delete(interface{})
  • Retrieve or add an element if it did not exist before with LoadOrStore(interface{}, interface{}) (interface, bool). The returned bool is true if the key was present in the map before.
  • Iterate on the elements with Range
Go Playground: https://play.golang.org/p/BO8IDVIDwsr
one
three
1: one
2: two

As you can see, the Range method takes a func(key, value interface{}) bool function. If we return false, the iteration is stopped. Interesting fact, the worst-case time-complexity remains O(n) even if we return false after a constant time (more info).

When shall we use sync.Map instead of a sync.Mutex on top of a classic map?

  • When we have frequent reads and infrequent writes (in the same vein to sync.RWMutex)
  • When multiple goroutines read, write, and overwrite entries for disjoint sets of keys. What does it mean concretely? For example, if we have a sharding implementation with a set of 4 goroutines and each goroutine in charge of 25% of the keys (without collision). In this case, sync.Map is also the preferred choice.

sync.Pool

sync.Pool is a concurrent pool, in charge to hold safely a set of objects.

The public methods are:

  • Get() interface{} to retrieve an element
  • Put(interface{}) to add an element
1
3
2

It worth noting that there is no guarantee in terms of ordering. The Get method specifies that it takes an arbitrary item from the pool.

It is also possible to specify a creator method:

Every time Get() is called, it will return an object (in this case a pointer) created by the function passed in pool.New.

When shall we use sync.Pool? There are two use-cases:

The first one is when we have to reuse shared and long-live objects like a DB connection for example.

The second one is to optimize memory allocation.

Let’s consider the example of a function that writes into a buffer and persists the result to a file. With sync.Pool, we can reuse the space allocated for the buffer by reusing the same object across the different function calls.

The first step is to retrieve the buffer previously allocated (or to create one if it’s the first call but this is abstracted). Then, the deferred action is to put the buffer back in the pool.

Last point to mention with sync.Pool. Since a pointer can be put into the interface value returned by Get() without any allocation, it is preferable to put pointers than structures in the pool.

This way, we can efficiently reuse the allocated memory as well as relieving the garbage collector if the variable was escaped to the heap.

sync.Once

sync.Once is a simple and powerful primitive to guarantee that a function is executed only once.

In this example, there will be only one goroutine displaying the output message:

We have used the Do(func()) method to specify the part that must be called only once.

sync.Cond

Let’s finish by the primitive which is, most likely, the less frequently used: sync.Cond.

It is used to emit a signal (one-to-one) or broadcast a signal (one-to-many) to goroutine(s).

Let’s consider a scenario where we have to indicate to one goroutine that the first element of a shared slice has been updated.

Creating a sync.Cond requires a sync.Locker object (either a sync.Mutex or a sync.RWMutex):

Then, let’s write the function in charge to display the first element of the slice:

As you can see, we can access the internal mutex using cond.L. Once the lock is acquired, we call cond.Wait() that is going to block as long as we don’t receive any signal.

Let’s get back to the main goroutine. We’ll create a pool of printFirstElement by passing a shared slice and the sync.Cond previously created. Then, we call a get() function, store the result in s[0] and emit a signal:

This signal will unblock one of the goroutine created that will display s[0].

Nevertheless, if we take a step back we could argue that our code might break one of the most fundamental principles of Go:

Do not communicate by sharing memory; instead, share memory by communicating.

Indeed, in this example, it would have been better to use a channel to communicate the value returned by get().

Yet, we also mentioned that sync.Cond can also be used to broadcast a signal.

Let’s just modify the end of the previous example by calling Broadcast() instead of Signal():

In this scenario, all of the goroutines are going to be triggered.

As we know, channel elements are caught by only one goroutine. The only way to simulate a broadcast is to close a channel but this cannot be repeated. Thus, this is undeniably an interesting feature, despite being quite controversial.

Update 27/11:

There is another use case that is worth mentioning with sync.Cond, if not the most important one:

Go Playground of the example: https://play.golang.org/p/ap5qXF5DAg5
Follow me on Twitter @teivah

--

--

Teiva Harsanyi
Teiva Harsanyi

Written by Teiva Harsanyi

Software Engineer @Google | The Coder Cafe newsletter (https://thecoder.cafe) | 📖 100 Go Mistakes author | 改善