Starting Out with Go: Understanding Concurrency Basics
Tags: go, webdev, beginners
Published: 2024-02-27 12:26:07

Concurrency in Go is primarily built around "goroutines" and channels, which make handling complex systems both easy (well, _easier_) and more efficient. What follows is an overview of key concurrency patterns in Go, with some simple examples to illustrate the concepts.

## Contents

1. [Introduction](#introduction)
2. [What are Goroutines?](#what-are-goroutines)
3. [What Makes Goroutines Special?](#what-makes-goroutines-special)
    - [Low Footprint](#low-footprint)
    - [Dynamic Stack Allocation](#dynamic-stack-allocation)
4. [Communication Patterns using Channels](#communication-patterns-using-channels)
    - [One-to-One (Producer-Consumer Pattern)](#one-to-one-producer-consumer-pattern)
    - [One-to-Many (Fan-out)](#one-to-many-fan-out)
    - [Many-to-One (Fan-in)](#many-to-one-fan-in)
    - [Pipeline](#pipeline)
5. [Conclusion](#conclusion)

## What are Goroutines?

Goroutines are lightweight threads managed by the Go runtime. They are used to run functions concurrently. You start a goroutine by prefixing a function call with the `go` keyword. This allows the function to run independently in a new goroutine.

```go
package main

import (
	"fmt"
	"time"
)

func myAsyncFunction() {
	for a := 0; a < 10; a++ {
		// do something intersting
		fmt.Println(a)
	}
}

func main() {
	go myAsyncFunction() // <-- spawns the goroutine

	time.Sleep(time.Second) // Wait for the goroutine to finish
}
```

In this example, we create a new goroutine by calling `go myAsyncFunction()`. The `myAsyncFunction` function will run concurrently with the main goroutine.

We're using a simple `time.Sleep` to wait for the goroutine to finish before the program exits, otherwise it won't actually output anything because the program would finish before the `myAsyncFunction` goroutine had a chancee to execute. In a real-world application, you would use something a little more sophisticated method to ensure that the main goroutine has finished executing, such as a `sync.WaitGroup` or a channel, as we'll see later.

## What Makes Goroutines Special?

Goroutines are more cost-effective than traditional threads, allowing you to spawn thousands or even millions of concurrent operations.

### Low Footprint

Traditional operating system (OS) threads can require a significant amount of memory per thread, often in the range of megabytes. But goroutines start with a much smaller size, normally in the region a few kilobytes. This smaller size means that the Go runtime can create many more goroutines on the same amount of memory compared to traditional threads.

### Dynamic Stack Allocation

This is a really cool feature of goroutines. Basically, the Go runtime will automatically grow and shrink the stack size of goroutines as needed. This means that goroutines use only as much memory as required for their execution, without preallocating large amounts of memory. You can imagine that this _really_ makes a difference when you're running thousands of concurrent operations.

## Communication Patterns using Channels

So you have all your processes neatly packaged away in their own goroutines, but how do you get them to talk to each other? Channels are the pipes that connect concurrent goroutines, allowing them to communicate with each other and bring order to the chaos of concurrent programming.

Next, we'll look at some different patterns for using channels to synchronise and communicate between goroutines.

1. [One-to-One (Producer-Consumer Pattern)](#one-to-one-producer-consumer-pattern)
2. [One-to-Many (Fan-out)](#one-to-many-fan-out)
3. [Many-to-One (Fan-in)](#many-to-one-fan-in)

### One-to-One (Producer-Consumer Pattern)

Imagine a channel as a stack of data. Goroutines can push data onto the stack, and other goroutines can pop data off the stack. Let's see what that looks like in code:

```go
package main

import "fmt"

func fortyTwoProducer(ch chan int) {
	ch <- 42 // Send a value into the channel
}

func main() {
	ch := make(chan int) // Create a new channel

	go fortyTwoProducer(ch) // Start the producer goroutine

	value := <-ch // Receive a value from the channel
	fmt.Println(value)
}
```

In this example, we create a new channel with `make(chan int)`. We then start a goroutine that sends the value `42` into the channel. The main goroutine then receives the value from the channel and prints it.

This is pretty cool, but what if we want to do asynchronous work in the goroutine? We need to setup a way to "listen" to that channel. Let's create a new goroutine that acts as a receiver. And we'll change our `fortyTwoProducer` to send the values from 0 to 9.

```go
package main

import "fmt"

func producer(ch chan int) {
	for a := 0; a < 10; a++ {
		ch <- a
	}
	close(ch)
}

func receiver(ch chan int) {
	for v := range ch {
		fmt.Println(v)
	}
}

func main() {
	ch := make(chan int) // Create a new channel
	go producer(ch) // Start the producer goroutine
	receiver(ch)
}
```

If I've done that correctly, this will give the following output:

```
0
1
2
3
4
5
6
7
8
9
```

What's going on here is quite interesting. This is, by default, an unbuffered channel. This literally means that there is no buffer to hold the values of the channel. Therefore, when the producer sends a value into the channel, it has to be received immediately by a receiver. If there is no receiver, the producer will block until there _is_ a receiver. And the producer cannot send another value until the first value has been received.

We can also create a buffered channel, which will allow the producer to send multiple values into the channel without blocking. One application of this might be rate limiting, effectively ensuring that a fast producer cannot overwhelm a slow receiver. Let's see what that looks like:

```go
package main

import "fmt"

func producer(ch chan int) {
	for a := 0; a < 10; a++ {
		fmt.Println("Sending: ", a)
		ch <- a
	}
	close(ch)
}

func receiver(ch chan int) {
	for v := range ch {
		fmt.Println("Receiving: ", v)
	}
}

func main() {
	ch := make(chan int, 5) // Create a new buffered channel
	go producer(ch)         // Start the producer goroutine
	receiver(ch)
}
```

Which gives us this output:

```go
Sending:  0
Sending:  1
Sending:  2
Sending:  3
Sending:  4
Sending:  5
Sending:  6
Receiving:  0
Receiving:  1
Receiving:  2
Receiving:  3
Receiving:  4
Receiving:  5
Receiving:  6
Sending:  7
Sending:  8
Sending:  9
Receiving:  7
Receiving:  8
Receiving:  9
```

I'm sure you're wondering why it's not giving the output in the order that we sent the values. It should have given us 0-4, then waited until at least one was received before continuing, but it didn't. This is because the exact order in which the goroutines execute is determined by the Go runtime and in this case the producer produced extra values. This is a great example of how concurrency can lead to non-deterministic behaviour.

Channels aren't just for sending and receiving values between a pair goroutines. They can be used to connect multiple goroutines in various configurations, not limited to one-to-one communication. Here are some common patterns:

### One-to-Many (Fan-out)

A single goroutine sends messages on a channel that multiple goroutines are reading from. Each message sent is received by only one of the listening goroutines, effectively distributing the messages among multiple workers. This pattern is useful for parallelising work.

To demonstrate this, we'll create three more receiver goroutines, all listening to the same channel.

```go
package main

import (
	"fmt"
	"time"
)

func producer(ch chan int) {
	for a := 0; a < 10; a++ {
		fmt.Println("Sending: ", a)
		ch <- a
	}
	close(ch)
}

func receiverOne(ch chan int) {
	for v := range ch {
		fmt.Println("One receiving: ", v)
	}
}

func receiverTwo(ch chan int) {
	for v := range ch {
		fmt.Println("Two receiving: ", v)
	}
}

func receiverThree(ch chan int) {
	for v := range ch {
		fmt.Println("Three receiving: ", v)
	}
}

func receiverFour(ch chan int) {
	for v := range ch {
		fmt.Println("Four receiving: ", v)
	}
}

func main() {
	ch := make(chan int)
	go producer(ch)
	go receiverOne(ch)
	go receiverTwo(ch)
	go receiverThree(ch)
	go receiverFour(ch)
	time.Sleep(time.Second)
}
```

Note how we're executing the recievers as separate goroutines this time. If we didn't do that, `receiverOne` would block until it had received all the values.

This will give the the following output:

```
Sending:  0
Sending:  1
Sending:  2
Sending:  3
Sending:  4
One receiving:  0
Four receiving:  2
Four receiving:  4
Two receiving:  1
Sending:  5
Sending:  6
Sending:  7
Three receiving:  3
Three receiving:  7
Four receiving:  6
One receiving:  5
Sending:  8
Sending:  9
Two receiving:  8
Three receiving:  9
```

### Many-to-One (Fan-in)

Multiple goroutines send messages to a single channel, which is read by a single goroutine. This pattern is often used to aggregate results from multiple concurrent operations.

```go
package main

import (
	"fmt"
	"time"
)

func producerOne(ch chan int) {
	ch <- 1
}

func producerTwo(ch chan int) {
	ch <- 2
}

func producerThree(ch chan int) {
	ch <- 3
}

func producerFour(ch chan int) {
	ch <- 4
}

func receiver(ch chan int) {
	for v := range ch {
		fmt.Println("Receiving: ", v)
	}
}

func main() {
	ch := make(chan int)
	go producerOne(ch)
	go producerTwo(ch)
	go producerThree(ch)
	go producerFour(ch)
	go receiver(ch)
	time.Sleep(time.Second)
	close(ch)
}
```

Giving the following output:

```
Receiving:  1
Receiving:  3
Receiving:  2
Receiving:  4
```

#### Pipeline

Channels can be used to create a pipeline of goroutines, where each goroutine in the pipeline processes data and passes it to the next stage via a channel.

```go
package main

import (
	"fmt"
	"time"
)

func producer(ch chan int) {
	for a := 0; a < 10; a++ {
		fmt.Println("Sending: ", a)
		ch <- a
	}
	close(ch)
}

func receiver(ch chan int) {
	for v := range ch {
		fmt.Println("Receiving: ", v)
	}
}

func doubler(in <-chan int, out chan<- int) {
	for v := range in {
		out <- v * 2
	}
	close(out)
}

func main() {
	ch := make(chan int)
	go producer(ch)

	ch2 := make(chan int)
	go doubler(ch, ch2)

	go receiver(ch2)
	time.Sleep(time.Second)
}
```

This is similar to the one-to-one example, but here we've created a second channel `ch2` to connect the producer and receiver. We've also added a `doubler` function that reads from `ch` and writes to `ch2`. So now we've got a pipeline where the producer sends values to the doubler, which then sends the doubled values to the receiver, outputting:

```
Sending:  0
Sending:  1
Sending:  2
Receiving:  0
Receiving:  2
Receiving:  4
Sending:  3
Sending:  4
Sending:  5
Receiving:  6
Receiving:  8
Receiving:  10
Sending:  6
Sending:  7
Sending:  8
Receiving:  12
Receiving:  14
Receiving:  16
Sending:  9
Receiving:  18
```

Wow, I'm blown away by how easy it is to create an asynchronous pipeline in Go. Although this example looks simple, it's hiding a huge amount of complexity. Go is ensuring that we're able to handle asynchronous input safely and efficiently. Go Go!

## Conclusion

This was just a brief introduction to how goroutines can communicate with each other via channels. There are many more patterns and techniques that can be used to build concurrent programs in Go, and concurrency is a world of ~~pain~~ fun and learning.

I hope you've enjoyed reading this, if you have any questions or comments, please feel free to reach out to me on [Twitter/X](https://twitter.com/AndyJessop33500). I'd love to hear from you.