Synchronization Patterns in Go
By Noam YadgarThis article is a followup for a more basic guide. If you would like to read the previous article, click here
The Go programming language is all about concurrency. Spawning multiple goroutines truly reveals the power of Go programs. As it lets us divide our program into concurrent processes. But you may have guessed, opening this door comes with a whole set of new issues we software engineers, have to consider. Most of them are related to shared memory and synchronization. From identifying the issue and applying the pattern, here are some things we can do in Go, to write better programs.
Mutex (Mutual Exclusion)
To be fair, there are many places online that formally explain what Mutex is. So let’s not do it. Let me not explain what a Mutex is. Instead, let’s try to walk this path from the other side. We’ll derive the need for such a mechanism, and hopefully, this will build a better intuition for you.
Check out this code:
package main
import (
"fmt"
)
var counter = 0
func main() {
for i := 0; i < 1000; i++ {
go func() {
counter++
}()
}
fmt.Println(counter)
}
In this main
function, we are:
- Spawning 1000 goroutines
- Each is incrementing the
counter
variable by one (starting from zero) - Printing the
counter
value
We should see the output 1000
right?
go run main.go
973
Wrong… I think you know why: this is not synchronous code. Our 1000 goroutines are all running concurrently, which means that the line:
fmt.Println(counter)
may have been called, before all 1000 goroutines finished.
Let’s fix it, by using a sync.WaitGroup
fmt.Println(counter)
will wait for all goroutines to finish:
package main
import (
"fmt"
"sync"
)
var (
n = 1000
counter = 0
wg = &sync.WaitGroup{}
)
func main() {
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println(counter)
}
wg.Wait()
will block the main goroutine until all 1000 goroutines will
call wg.Done()
. Cool, let’s run it:
go run main.go
896
Wait, what?… Still? Shouldn’t counter
have the value of 1000? Why waiting for all goroutines is not enough?
Race Condition
My friend, this is the most classical form of a Race Condition
. You see, goroutines don’t know about each other, even if they’re all accessing the same resources (such as the counter
variable). Multiple goroutines are accessing the counter
variable simultaneously. Since counter++
is just syntactic sugar for counter = counter + 1
, some goroutines will read the same value from counter
and therefore, set the incremented value to be the same.
Solution
We need a mechanism that allows goroutines to get a hold of a certain “flag”, using an atomic operation
. Atomic, will ensure that only one goroutine can invoke the operation at a time. Whoever is holding this “flag”, will block others, until the flag is released. Technically speaking, a goroutine that will invoke the function: mutex.Lock()
, will be blocked until the mutex
“flag” is released. Once released (with mutex.Unlock()
), the blocked goroutine will be able to acquire the “flag” and proceed.
If we’ll put a Mutex lock in front of the line counter++
, we can ensure that only a single goroutine can access the counter
variable at a time.
package main
import (
"fmt"
"sync"
)
var (
n = 1000
counter = 0
wg = &sync.WaitGroup{}
mutex = &sync.Mutex{}
)
func main() {
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer mutex.Unlock()
defer wg.Done()
mutex.Lock()
counter++
}()
}
wg.Wait()
fmt.Println(counter)
}
go run main.go
1000
This program will return 1000
every time, guaranteed.
A word about sync/atomic
In the previous example, we’ve used a Mutex to ensure that only one goroutine can access a certain resource at a time. But using a Mutex comes with a cost. It’s a relatively heavy mechanism, and it’s not always necessary, as it’s designed for more generic use cases. For the specific case of reading and writing from/to primitive types, we can use the sync/atomic
package.
From the official documentation : *These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package. Share memory by communicating; don’t communicate by sharing memory.*
With that said, and for the sake of knowledge, let’s communicate by sharing memory anyway.
Here’s the previous example, but using the sync/atomic
package:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
var counter atomic.Int32
wg.Add(1000)
for i := 0; i < 1000; i++ {
go func() {
defer wg.Done()
counter.Add(1)
}()
}
wg.Wait()
fmt.Println(counter.Load())
}
Channels are the way to Go
Pun intended (apologies for the dad joke). Channels are an exclusive concept of Go, designed specifically for communication and synchronization between goroutines. Channels provide an elegant solution for the synchronization problem. By using channels, we’re directed towards simpler yet effective design-patterns.
The following example is effectively the same as the two previous examples, but using channels:
package main
import "fmt"
const n = 1000
var counter = 0
func main() {
c := make(chan int, n)
for i := 0; i < n; i++ {
go func() {
c <- 1
}()
}
for counter < n {
counter += <-c
}
fmt.Println(counter)
}
This process is arguably better than the previous examples:
- We don’t need a Mutex lock
- We don’t need a
WaitGroup
- goroutines are not reading and reassigning shared memory
Data flow - Blocking Mechanism
To have a better intuition, we can make a loose analogy between channels and pipes. The water company is pumping water through a pipe to your house. At the end of the pipe, there’s a faucet. If the faucet is closed, the water company will only be able to push water until the pipe is full. In other words, the flow of water will stop, if the pipe is full and the faucet is closed. If you open the faucet, water will flow, freeing space in the pipe, and the water company will be able to pump more water.
- Water company = goroutine
- Your house = another goroutine
- Pipe = channel
- Water = data
- Pump =
chan <- data
- Faucet =
<- chan
Semaphore
Channels are great and can probably replace most of the traditional synchronization mechanisms. However, sometimes turning back to a more traditional, language-agnostic, approach can be beneficial. A Semaphore is one that I find useful and easy to understand.
The Problem
goroutines aren’t free, writing super fast programs can be very exciting, so exciting that it’s sometimes easy to forget that our code is running in a limited resource environment.
Suppose we have a veryExpensive()
function with the following specs:
- It takes about 2 seconds to complete
- It uses about 2Mb of memory
- It runs in an environment with 1Gb of available memory
Invoking go veryExpensive()
about 500+ times can easily reach the memory limit. Therefore, it’s important to know how to control the number of running goroutines at a time. Check out this code:
package main
import (
"fmt"
"runtime"
"time"
)
const n = 10
func main() {
for i := 0; i < n; i++ {
go func() {
time.Sleep(time.Second)
}()
// print the number of running goroutines without the main goroutine
fmt.Printf("currently running: %d goroutines\n", runtime.NumGoroutine()-1)
}
}
We are spawning 10 goroutines, each takes a second to complete and, by using:runtime.NumGoroutine()-1
, we are printing the number of currently running goroutines. How many goroutines will run simultaneously at the most?
go run main.go
currently running: 1 goroutines
currently running: 2 goroutines
currently running: 3 goroutines
currently running: 4 goroutines
currently running: 5 goroutines
currently running: 6 goroutines
currently running: 7 goroutines
currently running: 8 goroutines
currently running: 9 goroutines
currently running: 10 goroutines
If you thought 10
(or more accurately n
), well done. A second for most computers is a pretty long time. Without restricting the number of goroutines, you can easily reach n
goroutines. In fact, n
can probably be a lot bigger before our for
loop will take longer than a single goroutine to complete. What if we don’t know the size of n
in advance? How can we make sure we’re not reaching the memory limit?
Solution
package main
import (
"context"
"fmt"
"runtime"
"time"
"golang.org/x/sync/semaphore"
)
const (
n = 10
goroutines = 2
)
var sm = semaphore.NewWeighted(goroutines)
func main() {
ctx := context.Background()
for i := 0; i < n; i++ {
sm.Acquire(ctx, 1)
go func() {
defer sm.Release(1)
time.Sleep(time.Second)
}()
// print the number of running goroutines without the main goroutine
fmt.Printf("currently running: %d goroutines\n", runtime.NumGoroutine()-1)
}
}
A Semaphore is very similar to a Mutex, but with a key difference:
To put it simply, while a Mutex has a single “flag”, a Semaphore has a number of “seats” (usually represented as a primitive type such as an integer counter). So if const goroutines
is set to 2
, the third goroutine that will invoke sm.Acquire(ctx, 1)
will be blocked, until one of the “seats” will be available. Technically speaking, only 2 goroutines can invoke sm.Acquire(ctx, 1)
and proceed at a time. The rest will have to wait until sm.Release(1)
will be invoked.
> go run main.go
currently running: 1 goroutines
currently running: 2 goroutines
currently running: 2 goroutines
currently running: 2 goroutines
currently running: 2 goroutines
currently running: 2 goroutines
currently running: 2 goroutines
currently running: 2 goroutines
currently running: 2 goroutines
currently running: 2 goroutines
The first version of this program will take about a second to end, while this version will take about 5 seconds. We are paying the price of performance for program stability and resiliency. In this kind of trade-off, it’s important to find a good balance.
There’s a subtle choice we can make here .In this example I’ve decided to call the sm.Acquire
function, right from the main goroutine. This blocks the main goroutine from adding more goroutines, but we can also choose to spawn the goroutine and block its process.
This is where I’m going to end, I hope you found this article interesting. If you found some mistakes or, you have any suggestions, you can contact me (email above)
Thank you for reading.