Gocurrency

Futurice
Futurice
Published in
11 min readMay 26, 2023

How to get started with Golang’s concurrency, how it works, and why it works the way it does? In this topic breakdown, we will take a look at concurrency and how Golang uniquely approaches this topic with examples.

Concurrency

Concurrency refers to the ability of a computer system to perform multiple tasks simultaneously. In modern software development, concurrency is essential because it allows programs to handle multiple user requests, perform background tasks, and process data in parallel, resulting in faster and more efficient processing.

Go is well-suited for concurrency because of its lightweight Goroutines and built-in channel type. Goroutines are lightweight threads that can be created easily and have low overhead, allowing for the efficient creation of thousands or even millions of concurrent processes. Channels are built-in data structures that facilitate communication between Goroutines, enabling safe and efficient synchronization of data access.

ch := make(chan string, 10)

Above is a snippet of how to create a channel that accepts the string primitive and is initialized with an initial buffer capacity of 10, if you omit or provide 0 the channel would be unbuffered.

In Go, channels are used to communicate and synchronize data between Goroutines. When you create a channel, you have the option to specify its buffer capacity. The buffer capacity determines how many values can be stored in the channel before it blocks, meaning the sender has to wait for the receiver to read from the channel before it can send another value.

If you specify a buffer capacity of zero or omit the buffer size when creating the channel, the channel becomes unbuffered. An unbuffered channel can only hold one value at a time. When a sender sends a value to an unbuffered channel, it blocks until a receiver reads the value from the channel. Similarly, when a receiver reads from an unbuffered channel, it blocks until a sender sends a value to the channel.

In other words, an unbuffered channel ensures that both the sender and the receiver are ready and available to communicate with each other at the time of communication. This ensures that the values are synchronized and exchanged in a safe and synchronized manner.

On the other hand, if you specify a buffer capacity greater than zero, the channel becomes buffered. A buffered channel can hold multiple values, up to its buffer capacity. When a sender sends a value to a buffered channel, it will not block as long as the buffer is not full. Similarly, when a receiver reads from a buffered channel, it will not block as long as the buffer is not empty. This can lead to increased performance and reduced contention in some cases, but it also introduces potential risks of data races and synchronization issues when multiple Goroutines are trying to access the same channel.

The basic idea behind Go concurrency is that each Goroutine performs a small, well-defined task, and channels are used to coordinate their activities. This allows programs to be written in a way that maximizes parallelism and minimizes contention, resulting in faster and more efficient processing.

Goroutines & Channels in Go

A Goroutine is a lightweight thread of execution that is managed by the Go runtime. They are different from traditional threads in that they are designed to be concurrent, which means they allow multiple tasks to be executed simultaneously and independently. Goroutines are much lighter than traditional threads, as they require only a few kilobytes of memory compared to several megabytes of memory required by traditional threads. Goroutines are also more cost effective than traditional threads, as creating and managing them is much cheaper. Furthermore, Goroutines communicate through channels, which are built-in data structures that enable safe and efficient synchronization of data access.

Goroutine in practice

package gocurrency

import (

“fmt”

“time”

)

func BuildRace() {

car1 := “Ferrari”

car2 := “Lamborghini”

// Create a Goroutine for each car

go race(car1)

go race(car2)

// Wait for the race to finish

time.Sleep(5 * time.Second)

fmt.Println(“Race over!”)

}

func race(car string) {

for i := 0; i < 5; i++ {

fmt.Println(car, “is racing…”)

time.Sleep(1 * time.Second)

}

}

In the example above we can see that spinning up a new Goroutine is as easy as adding the go keyword in front of a function. What happens is given that race(car1) and race(car2) are on different Goroutines, they run independently of one another, the sleep timer for 5 seconds is in place to wait for two Goroutines which now take a combined time of 5 seconds to execute given that each iteration waits for 1 second and runs 5 times. If the two race calls were not on their own Goroutines, the code would have taken a whopping 10s to execute.

Note: the BuildRace function is a Goroutine of its own.

Channels

A channel is a built-in data structure that allows Goroutines to communicate and synchronize their activities. Channels provide a way to send and receive values between Goroutines in a safe and efficient way, without the need for locks or other synchronization primitives.

Channels can be used to communicate between Goroutines by sending and receiving values. The `<-` operator is used to send and receive values on a channel. For example, to send a value on a channel, you would write `channel <- value`. To receive a value from a channel, you would write `value := <-channel`.

package gocurrency

import “fmt”

func SimpleChannel() {

// Create an unbuffered channel of type int

c := make(chan int)

// Start a Goroutine that sends a value on the channel

go func() {

c <- 82

}()

// Receive the value from the channel

value := <-c

fmt.Println(“Received value:”, value)

}

In this example, we create a channel of type int using the make function. We then start a Goroutine that sends the value 82 on the channel using the `c <- 82` syntax. Finally, we receive the value from the channel using the `value := <-c` syntax.

When you run this program, you’ll see that the value 82 is received from the channel and printed to the console.

Benefits of Goroutines/Channels

  • Firstly, Goroutines are extremely lightweight, requiring only a few kilobytes of memory compared to the several megabytes required by traditional threads. This means that Go programs can create and manage a large number of Goroutines without incurring significant memory overhead, leading to improved performance and scalability.
  • Secondly, using Goroutines in Go also simplifies synchronization between concurrent activities. Unlike traditional thread synchronization mechanisms like locks and semaphores, Goroutines can be synchronized using channels, which are simpler and more intuitive to use. Channels help to avoid race conditions and deadlocks by ensuring that data is safely shared between Goroutines.
  • In addition, channels also simplify debugging in Go programs by providing a clear and intuitive way to track data flow between Goroutines. By using channels to communicate between Goroutines, developers can more easily understand and debug concurrency issues in their programs.

Go Concurrency Patterns

Buffered Channels

package gocurrency

import (

“fmt”

“math/rand”

“time”

)

func BufferedChannel() {

// Create a buffered channel with a capacity of 2

cars := make(chan string, 2)

// Start two Goroutines that add cars to the channel

go addCar(“Ferrari”, cars)

go addCar(“Lamborghini”, cars)

// Wait for the cars to be added to the channel

time.Sleep(2 * time.Second)

close(cars)

// Start a Goroutine that simulates the race

go startRace(cars)

// Wait for the race to finish

time.Sleep(6 * time.Second)

fmt.Println(“Race over!”)

}

func addCar(name string, cars chan string) {

cars <- name

fmt.Println(name, “added to the race!”)

}

func startRace(cars chan string) {

for {

// Receive a car from the channel

car, open := <-cars

if !open {

break

}

fmt.Println(car, “is racing…”)

// Simulate the race by waiting for a random duration

time.Sleep(time.Duration(1+rand.Intn(5)) * time.Second)

}

fmt.Println(“All cars have finished the race!”)

}

In the example above, we instantiate a channel with a buffer capacity of 2 and what this means is that the channel can receive two strings in the case above before requiring that a read operation is done on the channel to free up space. We write twice to the channel and then close the channel and this was done to let anything reading from the channel know when to stop attempting a read this is the only way `fmt.Println(“All cars have finished the race!”)` would be reached because it can only be reached if we break out of the for loop. We sleep for 6 seconds to allow for the read operation to fully occur.

Reading channels with for range

func startRaceWithRange(cars chan string) {

for car := range cars {

fmt.Println(car, “is racing…”)

// Simulate the race by waiting for a random duration

time.Sleep(time.Duration(1+rand.Intn(5)) * time.Second)

}

fmt.Println(“All cars have finished the race!”)

}

In the example, rather than receive the value and an open bool value, we let range handle retrieving the value and internally checking to see if the channel is open, since we close the channel, after the second value is read the result would look like

Lamborghini added to the race

Ferrari added to the race!

Ferrari is racing…

Lamborghini is racing…

All cars have finished the race!

Race over!!

Working with select statement

A select statement lets a Goroutine wait on multiple communication operations, it blocks operation until at least one of its cases can run, and it executes that case, if multiple cases can run at the same time, it chooses one at random.

package gocurrency

import (

“fmt”

“math/rand”

“time”

)

func SelectRace() {

// Create two channels for the cars

ferrari, lamborghini := make(chan string), make(chan string)

// Start two Goroutines that add cars to the channels

go addCarWithChan(“Ferrari”, ferrari)

go addCarWithChan(“Lamborghini”, lamborghini)

// Start a Goroutine that simulates the race

go startRaceWithSelect(ferrari, lamborghini)

// Wait for the race to finish

time.Sleep(5 * time.Second)

fmt.Println(“Race over!”)

}

func addCarWithChan(name string, c chan<- string) {

for i := 0; i < 5; i++ {

time.Sleep(time.Duration(rand.Intn(5)) * time.Second)

c <- name

fmt.Println(name, “added to the race!”)

}

close(c)

}

func startRaceWithSelect(ferrari <-chan string, lamborghini <-chan string) {

for {

select {

case car, ok := <-ferrari:

if !ok {

fmt.Println(“Ferrari channel closed!”)

ferrari = nil

break

}

fmt.Println(car, “is racing…”)

case car, ok := <-lamborghini:

if !ok {

fmt.Println(“Lamborghini channel closed!”)

lamborghini = nil

break

}

fmt.Println(car, “is racing…”)

}

if ferrari == nil && lamborghini == nil {

break

}

time.Sleep(time.Second)

}

fmt.Println(“All cars have finished the race!”)

}

Let’s break this down, we created two channels for the two different cars and modified the addCar function to include a channel. The addCarWithChan and the startRaceWithSelect function are in their own Goroutines and would run concurrently until the channel is closed or the wait time is reached. An example of the result is as follows

Ferrari is racing..

Ferrari added to the race!

Lamborghini is racing…

Lamborghini added to the race!

Ferrari is racing…

Ferrari added to the race!

Ferrari is racing…

Ferrari added to the race!

Race over!.

This might look a bit strange as how is Ferrari racing(5th line) without being added to the race first and what happened to the line “All cars have finished the race!”. First off given that addCarWithChan function pushes the value to the channel before printing to screen means that startRaceWithSelect function which is also running concurrently can read that value even before the next line is printed, remember select is blocking which means that once it’s picked up, everything in the case block must be executed before the next operation occurs. As for the line “All cars have finished the race!” the timer simply exhausted the 5 seconds before the entire operation could be completed.

WaitGroups

A WaitGroup is a synchronization mechanism that allows a program to wait for a collection of Goroutines to finish executing before proceeding to the next step in the program.

A WaitGroup maintains a counter that is incremented by each Goroutine that is launched and decremented by each Goroutine that finishes. The program waits for the counter to reach zero, indicating that all Goroutines have been completed, before proceeding to the next step.

The WaitGroup type provides three methods:

  1. Add(delta int): Adds delta, which can be a negative value, to the WaitGroup counter.
  2. Done(): Decrements the WaitGroup counter by one.
  3. Wait(): Blocks the program until the WaitGroup counter is zero.

package gocurrency

import (

“fmt”

“sync”

“time”

)

func SimpleWaitGroup() {

var wg sync.WaitGroup

for i := 1; i <= 3; i++ {

wg.Add(1) // increment WaitGroup counter

go func(num int) {

defer wg.Done() // decrement WaitGroup counter when done

fmt.Printf(“goroutine %d\n”, num)

}(i)

time.Sleep(time.Duration(1 * time.Second))

}

wg.Wait() // blocks until WaitGroup counter is zero

fmt.Println(“All goroutines have finished executing.”)

}

In this example, `wg.Wait()` blocks the function from proceeding to “All goroutines have finished executing.” until all the added wait groups are done.

Mutexes

Mutexes are a powerful synchronization mechanism that can be used to protect shared resources in concurrent programs.

Imagine that you are organizing a car race with multiple cars that will be running simultaneously on a track. You need to ensure that the cars do not collide with each other and that they stay within their lanes.

To achieve this, you can use a mutex to protect the shared resource, which in this case is the track. The mutex will allow only one car to access the track at a time, ensuring that no two cars collide with each other.

package gocurrency

import (

“fmt”

“sync”

)

var trackMutex sync.Mutex

var track [3]int

func Mutexes() {

var wg sync.WaitGroup

for i := 0; i < 3; i++ {

wg.Add(1)

go func(car int) {

defer wg.Done()

for j := 0; j < 5; j++ {

trackMutex.Lock()

track[car]++

fmt.Printf(“Car %d is on lap %d\n”, car, track[car])

trackMutex.Unlock()

}

}(i)

}

wg.Wait()

}

In this example, we have a shared resource, which is the track represented as an array of integers. Each element of the array represents the number of laps completed by a particular car. We also have a mutex, trackMutex, which we use to protect the shared resource.

The main function creates a WaitGroup and launches three Goroutines, each representing a car in the race. The Add() method is used to increment the WaitGroup counter by one before launching each Goroutine.

Inside each Goroutine, a loop is executed five times, representing five laps around the track. The Goroutine first acquires the lock on the mutex by calling Lock(), ensuring that only one Goroutine can access the shared resource at a time. It then updates the lap count for its corresponding car and prints a message indicating which car is on which lap. Finally, the Goroutine releases the lock on the mutex by calling Unlock(), allowing other Goroutines to access the shared resource.

The main function waits for all three Goroutines to finish executing by calling wg.Wait().

Conclusion

I hope that this blog will get you started with Golang’s Concurrency, helped to explain how it works, and why it does the way it does. You can find the complete codebase here from Github.

About the author

Darrel Idiagbor is a senior full-stack developer who has worked across many industries over the last 6 years, with professional experience ranging from entertainment and financial technology to education, and web3 space. While most of his experience has been in frontend, he has worked with backend as well and actively looks for projects that enable the growth of his backend and cloud expertise. He holds various AWS and Microsoft Azure certifications and overall enjoys challenging himself.

--

--

Futurice
Futurice

We are an outcome-focused digital transformation company. With a culture of innovation, we create digital products and services that make you future capable.