A Closer Look at Go’s sync Package
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
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 elementPut(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: