Menu

Synchronization Patterns in Go

By Noam Yadgar
#software-engineering #go #concurrency #asynchronous #goroutines #channels #sync #mutex #semaphore #atomic

This 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.