Free Handbook · Every example compiled & verified

Goroutines & Channels

Run work concurrently with goroutines, pass data safely over channels, and stop it cleanly with select, mutexes and context.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 08 · what you'll be able to do

  • Start goroutines and wait for all of them with sync.WaitGroup before main returns
  • Choose between unbuffered and buffered channels, and close and range over a channel correctly
  • Use select for timeouts, and build a worker pool that fans work out and results back in
  • Protect shared state with sync.Mutex and sync.Once, and prove it is safe with go run -race
  • Cancel goroutines with context.WithCancel and context.WithTimeout instead of leaking them
01

Goroutines and sync.WaitGroup

A goroutine is a function running concurrently with the rest of your program. You start one by putting go in front of a function call: go work(). It costs a few kilobytes of stack, so a program can run hundreds of thousands of them — the Go runtime multiplexes them onto a small number of OS threads for you.

The catch every beginner hits: when main returns, the program exits, and every goroutine still running is killed mid-sentence. You need a way to wait. sync.WaitGroup is a counter: Add(1) before starting each goroutine, Done() when it finishes, and Wait() blocks until the counter is back to zero.

gomain.go
package main

import (
	"fmt"
	"sync"
)

func square(n int) int { return n * n }

func main() {
	nums := []int{2, 3, 4, 5}
	results := make([]int, len(nums))

	var wg sync.WaitGroup
	for i, n := range nums {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results[i] = square(n) // each goroutine owns one slot
		}()
	}
	wg.Wait()

	fmt.Println(results)
}
Outputcompiled & run with real Go
[4 9 16 25]

Each goroutine writes to its own index, so there is no shared write and the printed order is always the input order — whichever goroutine happens to finish first.

Your turn

Replace the wg.Add(1) / go func / defer wg.Done() trio with wg.Go(func() { results[i] = square(n) }) — the shorthand added in Go 1.25.

Loop variables are per-iteration since Go 1.22
Before Go 1.22, i and n were one shared variable for the whole loop, and every goroutine above could see the last value. Old code works around it with go func(i, n int) { … }(i, n). With go 1.22 or later in go.mod, each iteration gets fresh variables and the closure is safe as written.
Error you will hit

panic: sync: negative WaitGroup counter

go
package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Done()
	wg.Wait()
}
panic: sync: negative WaitGroup counter

goroutine 1 [running]:
sync.(*WaitGroup).Add(0x38342f788000, 0xffffffffffffffff)
	/usr/local/go/src/sync/waitgroup.go:118 +0x264
sync.(*WaitGroup).Done(...)
	/usr/local/go/src/sync/waitgroup.go:156
main.main()
	./main.go:7 +0x3c
exit status 2
Why the compiler said that

Done() is just Add(-1). Calling it more times than you called Add drives the counter below zero, which can only mean the bookkeeping is wrong, so the runtime panics instead of guessing.

The fix

Call wg.Add(1) exactly once per goroutine, before the go statement (never inside the goroutine — Wait might run before it), and defer wg.Done() as its first line. Or use wg.Go(f), which does both for you.

go
package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
	}()
	wg.Wait()
}
02

Unbuffered vs buffered channels

A channel is a typed pipe between goroutines: ch <- v sends, v := <-ch receives. Go's motto is "do not communicate by sharing memory; share memory by communicating" — instead of two goroutines touching the same variable, one hands the value to the other.

An unbuffered channel (make(chan int)) has no storage. A send blocks until a receiver takes the value, so the two goroutines meet at that line — a hand-off and a synchronisation point in one. A buffered channel (make(chan int, 3)) holds up to 3 values; sends only block when the buffer is full, and receives only block when it is empty.

gomain.go
package main

import "fmt"

func main() {
	// Unbuffered: the send waits for main to receive.
	done := make(chan string)
	go func() {
		done <- "worker finished"
	}()
	fmt.Println(<-done)

	// Buffered: three sends succeed with nobody receiving yet.
	queue := make(chan int, 3)
	queue <- 10
	queue <- 20
	queue <- 30
	fmt.Println("len:", len(queue), "cap:", cap(queue))
	fmt.Println(<-queue, <-queue, <-queue)
}
Outputcompiled & run with real Go
worker finished
len: 3 cap: 3
10 20 30
Your turn

Add a fourth queue <- 40 before the receives and run it. The buffer is full and nothing else can receive, so you will meet the deadlock error below.

Unbuffered make(chan T)

  • Send blocks until a receiver is ready
  • Guarantees the receiver has the value when the send returns
  • Default choice — use it for hand-offs and signals

Buffered make(chan T, n)

  • Send blocks only when n values are waiting
  • Decouples producer and consumer speed for short bursts
  • Use it with a reason: a known number of results, a semaphore, a bounded queue
Error you will hit

fatal error: all goroutines are asleep - deadlock!

go
package main

import "fmt"

func main() {
	ch := make(chan int)
	ch <- 1
	fmt.Println(<-ch)
}
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	./main.go:7 +0x38
exit status 2
Why the compiler said that

The channel is unbuffered, so ch <- 1 waits for a receiver. The only receive is on the next line of the same goroutine, which can never be reached. The runtime notices that every goroutine is blocked and stops the program; [chan send] tells you what goroutine 1 was stuck on.

The fix

Send from a different goroutine than the one that receives, or give the channel a buffer if a goroutine genuinely needs to send to itself.

go
package main

import "fmt"

func main() {
	ch := make(chan int)
	go func() { ch <- 1 }()
	fmt.Println(<-ch)
}
03

Closing a channel and ranging over it

close(ch) says "no more values are coming". Receivers still drain whatever is buffered; after that, a receive returns the zero value immediately, and the two-value form v, ok := <-ch reports ok == false. for v := range ch keeps receiving until the channel is closed and empty, then exits the loop.

The rule of ownership: only the sender closes, and only when it is sure it will send nothing else. Receivers never close a channel, because sending on a closed channel panics.

gomain.go
package main

import "fmt"

func countdown(from int, out chan<- int) {
	for i := from; i > 0; i-- {
		out <- i
	}
	close(out) // the sender closes
}

func main() {
	ch := make(chan int)
	go countdown(3, ch)

	for n := range ch {
		fmt.Println("got", n)
	}

	v, ok := <-ch
	fmt.Println("after close:", v, ok)
}
Outputcompiled & run with real Go
got 3
got 2
got 1
after close: 0 false

chan<- int is a send-only channel type: countdown cannot accidentally receive from it. <-chan int is the receive-only version.

Your turn

Delete the close(out) line and run it again. Compare what happens with the deadlock error below.

VisualizeHow range and close cooperateStep 1 / 6
func countdown(from int, out chan<- int) {
for i := from; i > 0; i-- {
out <- i
}
close(out)
}
// in main:
for n := range ch {
fmt.Println("got", n)
}
Line 9

main reaches range ch and blocks: the channel is unbuffered and empty.

Variables now

nothing yet

All 6 steps as a table
StepLineWhat happenedVariables now
19main reaches range ch and blocks: the channel is unbuffered and empty.
23countdown sends 3. Because main is waiting, the hand-off happens immediately.i = 3
310main prints the value, then loops back to receive again.n = 3
43The same hand-off repeats for 2 and 1.i = 1
55The loop in countdown ends and it closes the channel.
69range sees closed and empty, so the loop ends instead of blocking forever.
Error you will hit

Deadlock after the last value: range over a channel nobody closes

go
package main

import "fmt"

func main() {
	ch := make(chan int)
	go func() {
		for i := 1; i <= 3; i++ {
			ch <- i
		}
	}()
	for v := range ch {
		fmt.Println(v)
	}
}
1
2
3
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
	./main.go:12 +0xc0
exit status 2
Why the compiler said that

The program prints all three values, then crashes. The sender goroutine returned without closing ch, so range keeps waiting for a fourth value that will never arrive. [chan receive] on line 12 points straight at the range.

The fix

Close the channel in the sending goroutine once it has sent everything — defer close(ch) at the top of the goroutine is the tidy way.

go
package main

import "fmt"

func main() {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for i := 1; i <= 3; i++ {
			ch <- i
		}
	}()
	for v := range ch {
		fmt.Println(v)
	}
}
04

select: waiting on several channels at once

select is a switch for channel operations. It blocks until one of its cases can proceed, then runs that case. If several are ready at the same moment, it picks one at random — so never rely on case order. A default case makes the whole select non-blocking: it runs when nothing else is ready.

The everyday use is a timeout: race the real result against time.After(d), a channel that delivers a value once d has passed.

gomain.go
package main

import (
	"fmt"
	"time"
)

func fetch(delay time.Duration) <-chan string {
	out := make(chan string, 1)
	go func() {
		time.Sleep(delay)
		out <- "data"
	}()
	return out
}

func get(delay time.Duration) {
	select {
	case v := <-fetch(delay):
		fmt.Println("got", v)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("timed out")
	}
}

func main() {
	get(10 * time.Millisecond)  // fast enough
	get(500 * time.Millisecond) // too slow

	ch := make(chan int)
	select {
	case v := <-ch:
		fmt.Println(v)
	default:
		fmt.Println("nothing ready, not waiting")
	}
}
Outputcompiled & run with real Go
got data
timed out
nothing ready, not waiting

fetch uses a buffer of 1 so its goroutine can still finish its send and exit after the caller has given up — an unbuffered channel here would leak a goroutine blocked forever.

Your turn

Change the timeout to time.Second and confirm that both calls now print got data.

In real services
Hand-written time.After timeouts are fine in small tools. In servers you will almost always use a context.Context deadline instead (last lesson), because it travels through every function call, HTTP request and database query automatically.
05

Worker pools, fan-out and fan-in

Fan-out means several goroutines read from the same channel, splitting the work between them. Fan-in means their results flow back into one channel. Together they form a worker pool: a fixed number of workers, which caps how much runs at once — important when each job opens a file, a socket or a database connection.

Results come back in whatever order the workers finish. If the order matters, carry the job ID with the result and sort at the end — which is exactly what makes the output below deterministic.

gomain.go
package main

import (
	"fmt"
	"sort"
	"sync"
)

type result struct {
	id, value int
}

func worker(jobs <-chan int, results chan<- result, wg *sync.WaitGroup) {
	defer wg.Done()
	for n := range jobs { // fan-out: every worker reads the same channel
		results <- result{id: n, value: n * n}
	}
}

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

	var wg sync.WaitGroup
	for w := 1; w <= 3; w++ {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}

	go func() { // feed the jobs, then close
		for i := 1; i <= 6; i++ {
			jobs <- i
		}
		close(jobs)
	}()

	go func() { // fan-in: close results once every worker is done
		wg.Wait()
		close(results)
	}()

	var all []result
	for r := range results {
		all = append(all, r)
	}
	sort.Slice(all, func(i, j int) bool { return all[i].id < all[j].id })
	for _, r := range all {
		fmt.Printf("job %d -> %d\n", r.id, r.value)
	}
}
Outputcompiled & run with real Go
job 1 -> 1
job 2 -> 4
job 3 -> 9
job 4 -> 16
job 5 -> 25
job 6 -> 36
Your turn

Add a worker int field to result and pass the worker number in. Print it too — you will see the split change from run to run, which is exactly why the output is sorted by job ID.

  1. 1
    Close jobs when the feeding is done

    That is what ends each worker's for range jobs loop.

  2. 2
    Close results only after every worker returns

    A separate goroutine does wg.Wait(); close(results). Closing earlier would make a slow worker panic with send on closed channel.

  3. 3
    Main ranges over results

    It runs concurrently with the workers, so the unbuffered results channel never fills up and nothing deadlocks.

A buffered channel as a semaphore
To cap concurrency without a separate pool, use sem := make(chan struct{}, 3): each goroutine does sem <- struct{}{} before its work and <-sem after. At most 3 can hold a slot at once. golang.org/x/sync/errgroup with SetLimit packages the same idea with error handling.
06

sync.Mutex, sync.Once and the race detector

Channels are not the only tool. When several goroutines need to update one shared value — a counter, a cache map — a mutex is simpler. mu.Lock() lets exactly one goroutine in; the rest wait at their own Lock() until mu.Unlock(). Always pair them with defer so a panic or early return cannot leave the lock held.

gomain.go
package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu   sync.Mutex
	hits map[string]int
}

func (c *Counter) Inc(page string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.hits[page]++
}

func main() {
	c := Counter{hits: map[string]int{}}
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if i%2 == 0 {
				c.Inc("/home")
			} else {
				c.Inc("/about")
			}
		}()
	}
	wg.Wait()
	fmt.Println("/home:", c.hits["/home"], "/about:", c.hits["/about"])
}
Outputcompiled & run with real Go
/home: 500 /about: 500

The mutex lives next to the data it guards, inside the struct. Methods take a pointer receiver — copying a Counter would copy its lock, which go vet reports.

sync.Once runs a function exactly once, no matter how many goroutines call it at the same time — the standard way to lazily load a config file or open a shared connection.

gomain.go
package main

import (
	"fmt"
	"sync"
)

var (
	once   sync.Once
	config map[string]string
	loads  int
)

func getConfig() map[string]string {
	once.Do(func() {
		loads++
		config = map[string]string{"env": "prod"}
	})
	return config
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_ = getConfig()
		}()
	}
	wg.Wait()
	fmt.Println("env:", getConfig()["env"], "loaded", loads, "time")
}
Outputcompiled & run with real Go
env: prod loaded 1 time
Your turn

Go 1.21 added sync.OnceValue. Rewrite getConfig as var getConfig = sync.OnceValue(func() map[string]string { … }).

A data race is two goroutines touching the same memory at the same time with at least one of them writing. The result is undefined — lost updates, torn values, a crash under load that never shows up on your laptop. The race detector finds them for you: add -race to go run, go test or go build.

Error you will hit

WARNING: DATA RACE (go run -race main.go)

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	counter := 0
	var wg sync.WaitGroup
	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++
		}()
	}
	wg.Wait()
	fmt.Println(counter)
}
$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c00011e038 by goroutine 7:
  main.main.func1()
      ./main.go:15 +0x68

Previous write at 0x00c00011e038 by goroutine 8:
  main.main.func1()
      ./main.go:15 +0x78

Goroutine 7 (running) created at:
  main.main()
      ./main.go:13 +0x6c

Goroutine 8 (finished) created at:
  main.main()
      ./main.go:13 +0x6c
==================
2
Found 1 data race(s)
exit status 66
Why the compiler said that

counter++ is three steps: read, add one, write. Two goroutines can both read 0 and both write 1. Without -race this program usually prints 2 and looks fine, which is what makes races dangerous. The report names both accesses (line 15) and where each goroutine was started (line 13).

The fix

Guard the shared variable with a sync.Mutex, use sync/atomic (atomic.Int64) for a plain counter, or restructure so only one goroutine owns the value. Run your tests with go test -race ./... in CI.

go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Add(1)
		}()
	}
	wg.Wait()
	fmt.Println(counter.Load())
}
07

Cancellation and timeouts with context

A goroutine that nobody can stop is a goroutine leak: it holds memory and maybe a connection forever. context.Context is Go's standard stop signal. You create one with context.WithCancel or context.WithTimeout, pass it as the first parameter of every function that might block, and the function watches ctx.Done() — a channel that is closed when the work should stop. ctx.Err() then says why.

gomain.go
package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// slowQuery pretends to be a database call that takes 200ms.
func slowQuery(ctx context.Context) (string, error) {
	select {
	case <-time.After(200 * time.Millisecond):
		return "rows", nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	_, err := slowQuery(ctx)
	fmt.Println("err:", err)
	fmt.Println("is deadline:", errors.Is(err, context.DeadlineExceeded))

	ctx2, cancel2 := context.WithCancel(context.Background())
	cancel2() // e.g. the user closed the browser tab
	_, err = slowQuery(ctx2)
	fmt.Println("err:", err)
}
Outputcompiled & run with real Go
err: context deadline exceeded
is deadline: true
err: context canceled
Your turn

Raise the timeout to time.Second. The first call should now return rows with a nil error.

gomain.go
package main

import (
	"context"
	"fmt"
	"sync"
)

func producer(ctx context.Context, out chan<- int) {
	defer close(out)
	for i := 1; ; i++ {
		select {
		case out <- i:
		case <-ctx.Done():
			return // stop cleanly instead of leaking
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	nums := make(chan int)

	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		producer(ctx, nums)
	}()

	for n := range nums {
		fmt.Println(n)
		if n == 3 {
			cancel()
			break
		}
	}
	wg.Wait() // proves the producer really returned
	fmt.Println("producer stopped")
}
Outputcompiled & run with real Go
1
2
3
producer stopped

An infinite producer is only safe because every send sits in a select next to ctx.Done(). Without it, the producer would block forever on its next send after main stops reading.

  • ctx is the first parameter, named ctx, and never stored in a struct.
  • Always defer cancel() — even for a timeout context — or its timer is held until it fires. go vet flags a discarded cancel function.
  • In an HTTP handler, use r.Context(): it is cancelled automatically when the client disconnects.
  • context.WithValue is for request-scoped data such as a trace ID, not for passing optional parameters.
Quick check

A worker pool's results channel is closed by main right after it finishes sending all jobs. What is the most likely failure?

Quick check

Two cases of a select are ready at the same time. Which one runs?

goroutine
A function running concurrently, started with the go keyword and scheduled by the Go runtime.
sync.WaitGroup
A counter that lets one goroutine wait until a group of others have called Done.
unbuffered channel
A channel with no storage: each send waits for a matching receive.
buffered channel
A channel with capacity n: sends only block when n values are already waiting.
select
Waits on several channel operations and runs whichever is ready; random choice if several are.
worker pool
A fixed number of goroutines reading jobs from one channel (fan-out) and writing results to another (fan-in).
data race
Two goroutines accessing the same memory concurrently with at least one write and no synchronisation.
context.Context
A value carrying a cancellation signal and deadline through a call chain; watch ctx.Done().
goroutine leak
A goroutine blocked forever on a channel or lock that nothing will ever release.

Frequently asked questions

What is the difference between a goroutine and a thread in Go?
A goroutine is managed by the Go runtime, not the operating system. It starts with a stack of a few kilobytes that grows as needed, and the runtime schedules many goroutines onto a small pool of OS threads. That makes it practical to run hundreds of thousands of goroutines, where the same number of OS threads would exhaust memory.
Should I use channels or a mutex in Go?
Use channels when you are passing ownership of data or coordinating stages of work (pipelines, worker pools, signals). Use a mutex when several goroutines simply need to read and update one shared value such as a counter or a cache. Both are idiomatic; pick whichever makes the code shorter and easier to reason about.
How do I find a data race in Go?
Run your program or tests with the race detector: go run -race main.go or go test -race ./... . It instruments every memory access and prints a WARNING: DATA RACE report naming both conflicting lines and where each goroutine was started. It only finds races that actually happen during that run, so exercise concurrent code paths in tests.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.