Go’s two biggest concurrency ideas, explained with a kitchen instead of a computer-science lecture.

Concurrency means doing more than one thing at a time. It is where many languages get complicated fast. Go was designed to make it feel almost boring, thanks to two ideas: goroutines and channels. Here is what they are, step by step, with pictures.

By the end you will know:

  • What a goroutine is and how to start one with a single word.
  • Why a program can finish before its goroutines do, and how to prevent that.
  • How channels pass values safely between goroutines, and the difference between unbuffered and buffered ones.
  • How to share the work between several workers, and what to do about race conditions and deadlocks.

You should know basic Go: functions, slices and for loops. Each program below is complete and runs with go run main.go.

The kitchen problem

Imagine you are cooking dinner. The pasta takes 2 seconds to boil, and chopping vegetables takes 1 second (a very fast kitchen, so the numbers stay small). A program that does one thing after another is like a cook who stands and watches the pot until the water boils, and only then starts chopping. Everything is correct, but slow.

A smarter cook starts the pasta, and while it boils chops the vegetables. The total time drops from 3 seconds to 2, because the waiting is spent doing something useful.

Concurrency: use the waiting time for something else.

In Go, the pot is a goroutine: a small task that runs alongside the rest of your program.

A goroutine is just “run this separately”

You start a goroutine by putting the word go in front of a function call. That single word is the whole syntax for “start this, but do not wait for it”. Goroutines are very cheap, so a Go program can run thousands of them.

main.go
package main

import (
	"fmt"
	"time"
)

func boilPasta() {
	fmt.Println("pasta: boiling…")
	time.Sleep(2 * time.Second)   // pretend this takes 2 seconds
	fmt.Println("pasta: done!")
}

func chopVegetables() {
	fmt.Println("veg: chopping…")
	time.Sleep(1 * time.Second)   // pretend this takes 1 second
	fmt.Println("veg: done!")
}

func main() {
	go boilPasta()     // start it, but don't wait for it
	chopVegetables()   // main carries on straight away
}

Run it and you will see something like this:

pasta: boiling…
veg: chopping…
veg: done!

Two surprises here. First, the order of the first two lines can change between runs, because both tasks are running at the same time. Second, “pasta: done!” never appears. Why?

The program ends when main ends, whether or not goroutines are finished.

main is itself a goroutine, and it is the boss. It finishes after 1 second (when the chopping is done), and the moment main returns, Go shuts the whole program down. The pasta goroutine needed 2 seconds, so it is cut off.

This is the key problem of concurrency: starting a task and walking away is easy. Knowing when it has finished, and getting its result back safely, is the hard part. That is what channels are for.

Channels: a safe pipe between goroutines

A channel is a pipe with a type. One goroutine puts a value in, another takes it out, and Go makes sure this is safe even though they run at the same time. In the kitchen, it is the timer that dings when the pasta is really done, instead of you guessing.

The arrow <- means “send” or “receive”, depending on which side of the channel it is on.

Here is the pasta program fixed with a channel used as a “done” signal:

func main() {
	done := make(chan bool)   // a channel that carries true/false values

	go func() {
		boilPasta()
		done <- true          // send: "I'm finished"
	}()

	chopVegetables()
	<-done                    // receive: wait here until the signal arrives
	fmt.Println("dinner is ready")
}
  1. make(chan bool) creates the channel.
  2. done <- true sends a value. Think of the arrow as pointing into the channel.
  3. <-done receives a value. The arrow points out of the channel. The receiver waits here until something arrives.

Now main chops vegetables for 1 second, then waits for the signal, so it only exits after the pasta is done. The whole meal takes 2 seconds, and “dinner is ready” prints last. Channels are also used for sending data, not just signals:

func sum(numbers []int, out chan int) {
	total := 0
	for _, n := range numbers {
		total += n
	}
	out <- total                      // send the answer into the channel
}

func main() {
	numbers := []int{1, 2, 3, 4, 5, 6}
	out := make(chan int)

	go sum(numbers[:3], out)          // first half
	go sum(numbers[3:], out)          // second half

	a, b := <-out, <-out              // receive two answers
	fmt.Println(a + b)                // 21
}
Two goroutines each sum half the numbers and send their answers into the same channel.

Buffered channels, close and range

A normal channel has no storage: the sender waits until a receiver is ready, like handing an object to someone hand to hand. A buffered channel has a fixed number of slots, like a small mailbox. You choose the size when you create it: make(chan int, 2).

A buffer lets the sender run ahead, but only up to the size of the mailbox.

When a sender has no more values, it should close the channel. A receiver can then use range to keep reading until the channel is closed:

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

	go func() {
		for i := 1; i <= 3; i++ {
			ch <- i
		}
		close(ch)                     // "no more values are coming"
	}()

	for n := range ch {               // keeps receiving until the channel is closed
		fmt.Println(n)
	}
}
1
2
3

Putting it to work: a worker pool

This is the pattern you will see in real Go programs. You have many jobs and a few workers. The jobs go into one channel, each worker takes the next job when it is free, and the answers come out in another channel.

Workers share one queue of jobs. Whoever is free takes the next one.
func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		time.Sleep(time.Second)               // pretend work
		fmt.Println("worker", id, "finished job", j)
		results <- j * 2
	}
}

func main() {
	jobs := make(chan int, 5)
	results := make(chan int, 5)

	for w := 1; w <= 3; w++ {                 // start 3 workers
		go worker(w, jobs, results)
	}

	for j := 1; j <= 5; j++ {                 // hand out 5 jobs
		jobs <- j
	}
	close(jobs)                               // no more jobs

	for i := 0; i < 5; i++ {                  // collect 5 answers
		fmt.Println("result:", <-results)
	}
}

Look at the function signature: jobs <-chan int means the worker may only receive from jobs, and results chan<- int means it may only send to results. Go checks this for you, which prevents silly mistakes.

Each job takes 1 second. One worker would need 5 seconds; three workers finish all five jobs in about 2 seconds, because they work in two rounds (3 jobs, then 2). The order of the lines will vary, since the workers run at the same time.

When goroutines share a variable

Channels keep things safe. But sometimes goroutines do share a variable, and then you can get a race condition. Here, 1,000 goroutines each add 1 to a counter. You would expect 1000:

func main() {
	counter := 0
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++             // read, add 1, write back: NOT one single step
		}()
	}

	wg.Wait()
	fmt.Println(counter)          // often less than 1000!
}

Often it prints a smaller number. counter++ looks like one step, but it is really three: read the value, add 1, write it back. If two goroutines read at the same time, they both write the same new value, and one update disappears:

Both goroutines read 5 before either writes. The second write overwrites the first.

The fix is a mutex (a lock). Only one goroutine can hold it at a time, so the read-add-write happens as one unbroken step:

var mu sync.Mutex

go func() {
	defer wg.Done()
	mu.Lock()                     // only one goroutine may enter at a time
	counter++
	mu.Unlock()
}()

Now it always prints 1000. Remember to import "sync" for WaitGroup and Mutex. A WaitGroup is a counter of unfinished goroutines: Add(1) before starting one, Done() when it ends, and Wait() blocks until the count reaches zero.

Deadlocks

A deadlock happens when every goroutine is waiting for something that can never happen. The simplest one is sending on a channel that nobody will ever read:

func main() {
	ch := make(chan int)
	ch <- 1                       // waits for a receiver... but there is none
	fmt.Println(<-ch)
}
// fatal error: all goroutines are asleep - deadlock!

The send waits for a receiver, but the only goroutine that could receive is the one that is stuck sending. Go notices that nothing can move and stops with an error instead of hanging silently. The fix is to make sure that for every send there is a receiver in another goroutine (or use a buffered channel).

Common mistakes

What happensWhyFix
A goroutine’s output never appearsmain returned first and ended the programWait with a channel or a sync.WaitGroup
all goroutines are asleep - deadlock!A send or receive has nobody on the other sideAdd the matching goroutine, or use a buffered channel
for range ch never finishesThe channel was never closedCall close(ch) from the sending side when done
panic: send on closed channelSomething sent after closeOnly the sender should close, and only once, after the last send
The total is sometimes wrongA shared variable is updated by many goroutinesUse a channel, a sync.Mutex, and test with -race
Program hangs at wg.Wait()Add and Done counts do not matchCall wg.Add(1) for every goroutine and defer wg.Done() inside it

Try it yourself

1. In the pasta program, remove the <-done line. What changes, and why?

Show answer

“pasta: done!” never prints, and “dinner is ready” prints too early, after about 1 second. Without the receive, main no longer waits for the pasta, so it ends after the vegetables and takes the whole program with it.

2. Change the worker pool to use 5 workers. About how long does it take now?

Show answer

About 1 second. Five workers can each take one of the five jobs at the same time.

3. Change make(chan int) in the deadlock example to make(chan int, 1). Does it still deadlock?

Show answer

No. The buffer has one free slot, so ch <- 1 stores the value and continues, and <-ch then reads it back.