Lesson 6 • Intermediate
Concurrency with Goroutines 🐹
By the end of this lesson you'll be able to run work concurrently with goroutines, pass data safely between them with channels, wait on several operations with select, and coordinate everything with a WaitGroup and a Mutex — the foundation of every fast Go program.
What You'll Learn in This Lesson
- Launch concurrent work with
go func()goroutines - Send and receive on channels, and pick buffered vs unbuffered
- Wait on multiple channels (and add timeouts) with
select - Wait for goroutines to finish with
sync.WaitGroup - Protect shared state from data races with
sync.Mutex - Apply Go's rule: "share memory by communicating"
for loops and slices. Everything about concurrency, we'll build here.1️⃣ Goroutines: go func()
A goroutine is a function running concurrently with the rest of your program — like hiring a worker who goes off and does a job while you carry on. You start one by putting the keyword go in front of a function call. They are extremely cheap (around 2KB each), so running thousands is normal. The catch: when main returns, the program exits and any unfinished goroutines are killed — so you need a way to wait for them. That's what a sync.WaitGroup does: Add counts goroutines up, each one calls Done when finished, and Wait blocks until the count is zero.
package main
import (
"fmt"
"sync"
)
// A goroutine is a function that runs CONCURRENTLY with the rest of
// your program. The Go runtime manages them (~2KB of stack each), so
// running thousands is cheap. You start one by writing go <call>.
func sayHello(name string, wg *sync.WaitGroup) {
defer wg.Done() // tell the WaitGroup "I'm finished" when this returns
fmt.Println("Hello,", name)
}
func main() {
// A WaitGroup is a counter the main goroutine waits on.
var wg sync.WaitGroup
names := []string{"Alice", "Bob", "Charlie"}
for _, name := range names {
wg.Add(1) // +1 to the counter for each goroutine
go sayHello(name, &wg) // 'go' launches it concurrently and moves on
}
wg.Wait() // BLOCK here until the counter hits 0 (all Done() called)
fmt.Println("all greetings done")
}Hello, Charlie
Hello, Alice
Hello, Bob
all greetings doneNotice the three greetings can print in any order — the scheduler decides who runs when. Only all greetings done is guaranteed last, because wg.Wait() holds main back until every goroutine has finished.
2️⃣ Channels: send <- and receive
A channel is a typed pipe that goroutines use to pass values to each other safely — the conveyor belt from the analogy. You send with ch <- value and receive with value := <-ch (the arrow always points the way the data moves). Channels come in two flavours. An unbuffered channel (make(chan T)) has no storage, so a send blocks until a receiver is ready — that makes it a synchronisation point as well as a pipe. A buffered channel (make(chan T, n)) holds up to n values, so sends only block once it's full.
package main
import "fmt"
func main() {
// A channel is a typed pipe goroutines use to pass values safely.
// make(chan T) with NO size = UNBUFFERED: a send blocks until a
// receiver is ready (it hands the value over directly).
done := make(chan string)
go func() {
// This runs in a separate goroutine. The send (ch <- value)
// waits until main is ready to receive on the other end.
done <- "worker finished" // SEND a value into the channel
}()
msg := <-done // RECEIVE — blocks until a value arrives
fmt.Println("got:", msg) // got: worker finished
// make(chan T, n) = BUFFERED, capacity n. Sends DON'T block until
// the buffer is full, so you can send a few values up front.
nums := make(chan int, 3) // holds up to 3 ints without a reader
nums <- 10 // doesn't block (buffer has room)
nums <- 20
nums <- 30
close(nums) // no more sends; receivers can drain it
// Ranging over a channel reads values until it is closed.
total := 0
for n := range nums {
total += n
}
fmt.Println("buffered total:", total) // buffered total: 60
}got: worker finished
buffered total: 60Your turn. The program below pings a "server" goroutine and waits for a reply. Fill in the two blanks marked ___ using the hints, then run it.
package main
import "fmt"
func main() {
// 🎯 YOUR TURN — fill in the blanks marked with ___
ch := make(chan string) // an unbuffered string channel
go func() {
// 1) SEND the text "pong" into ch
___ // 👉 ch <- "pong"
}()
// 2) RECEIVE one value from ch into the variable reply
reply := ___ // 👉 <-ch
fmt.Println("server says:", reply)
// ✅ Expected output:
// server says: pong
}server says: pong___ blanks, then run it in the Go Playground and check it matches the expected output.3️⃣ select: wait on many channels
select is like a switch for channels: it waits on several channel operations at once and runs whichever becomes ready first. This is how you add timeouts (race a real result against time.After) and how you merge inputs from multiple goroutines. Add a default case and select becomes non-blocking — if nothing is ready right now, it runs default instead of waiting.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
// Send a value after 100ms from a separate goroutine.
go func() {
time.Sleep(100 * time.Millisecond)
ch <- "work finished"
}()
// select waits on SEVERAL channel operations at once and runs
// whichever is ready first. Here it races a real result against a
// 1-second timeout, so the result (100ms) always wins.
select {
case msg := <-ch:
fmt.Println("got:", msg) // got: work finished
case <-time.After(1 * time.Second):
fmt.Println("timed out")
}
// A 'default' case makes select NON-BLOCKING: if nothing is ready
// right now, run default instead of waiting. ch is empty here, so:
select {
case msg := <-ch:
fmt.Println("got:", msg)
default:
fmt.Println("no value ready right now") // this one runs
}
}got: work finished
no value ready right now4️⃣ sync.WaitGroup and sync.Mutex
When several goroutines touch the same variable, you have a problem: count++ is actually read-add-write, and two goroutines can interleave and lose an update. That's a data race, and the result is wrong and unpredictable. A sync.Mutex (mutual-exclusion lock) fixes it: Lock lets only one goroutine through at a time and Unlock releases it for the next. Pair it with a WaitGroup to wait for all the goroutines to finish.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup // waits for all 100 goroutines
var mu sync.Mutex // a lock that guards the shared counter
count := 0 // shared state — many goroutines touch it
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // only ONE goroutine past this at a time
count++ // safe: the lock makes count++ exclusive
mu.Unlock() // let the next goroutine in
}()
}
wg.Wait() // wait for every increment to finish
fmt.Println("count:", count) // count: 100 (always — no lost updates)
}count: 100Now you wire up the WaitGroup yourself. Fill in the three blanks — Add before launching, Done when finishing, and Wait at the end:
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
// 2) Signal completion when this function returns
defer ___ // 👉 wg.Done()
fmt.Println("worker", id, "done")
}
func main() {
// 🎯 YOUR TURN — fill in the blanks marked with ___
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
// 1) Add ONE to the WaitGroup before launching each worker
___ // 👉 wg.Add(1)
go worker(i, &wg)
}
// 3) Block until all three workers have signalled Done
___ // 👉 wg.Wait()
fmt.Println("all workers finished")
// ✅ Expected output (the three "worker N done" lines may be in
// ANY order; the last line is always last):
// worker 1 done
// worker 2 done
// worker 3 done
// all workers finished
}worker 1 done
worker 2 done
worker 3 done
all workers finished___ blanks, then run it. The three worker N done lines may print in any order; all workers finished is always last.5️⃣ Putting It Together: a Pipeline
Here's the philosophy in action. Instead of many goroutines fighting over one shared slice, each stage owns its data and passes it to the next stage through a channel — generate → square → consume. Notice the return type <-chan int: a receive-only channel, so callers can read results but can't accidentally send into it. No mutex needed, because nothing is shared.
package main
import "fmt"
// "Don't communicate by sharing memory; share memory by communicating."
// Each stage owns its data and passes it on through a channel.
// Stage 1: generate produces numbers, then CLOSES its output channel.
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // closing tells the next stage "no more"
for _, n := range nums {
out <- n
}
}()
return out // <-chan int = receive-only (read it, can't send)
}
// Stage 2: square reads from 'in' and writes squares to a new channel.
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { // ends when 'in' is closed and drained
out <- n * n
}
}()
return out
}
func main() {
// Pipeline: generate -> square -> consume. No mutex, no shared
// variable — the channels carry the data between stages.
for v := range square(generate(1, 2, 3, 4, 5)) {
fmt.Print(v, " ")
}
fmt.Println() // 1 4 9 16 25
}1 4 9 16 25 Common Errors (and the fix)
- "fatal error: all goroutines are asleep - deadlock!" — usually a send on an unbuffered channel with no receiver (or vice-versa). Make sure something is reading the other end, e.g. do the send inside a
go func()whilemainreceives. - Race condition (wrong/unstable results) — many goroutines update a shared variable without a lock, so updates get lost. Guard it with
mu.Lock()/mu.Unlock(), and rungo run -race .to detect races. - Program prints nothing / exits early —
mainreturned before the goroutines ran, killing them. Makemainwait withwg.Wait()or by receiving on a channel. - Hangs forever on
wg.Wait()— you forgot awg.Done()(or did moreAddthanDone), so the counter never reaches zero. Putdefer wg.Done()as the first line of each goroutine. - "panic: send on closed channel" — you sent after
close(). The sender should close a channel, and only once, after all sends are done.
Pro Tips
- 💡 Make
defer wg.Done()the first line of every goroutine — it then fires even if the function returns early or panics. - 💡 The sender closes the channel, never the receiver. Closing signals "no more values"; ranging over the channel then ends cleanly.
- 💡 Prefer channels to a mutex when you're handing off data; reach for a mutex when goroutines must update shared state in place.
- 💡 Run with
go run -race .while developing — the race detector catches unsynchronised access you'd never spot by eye.
📋 Quick Reference
| Task | Go Syntax |
|---|---|
| Start a goroutine | go myFunc() |
| Unbuffered channel | ch := make(chan int) |
| Buffered channel | ch := make(chan int, 5) |
| Send / receive | ch <- v // v := <-ch |
| Close / range | close(ch); for v := range ch {} |
| Select | select { case v := <-ch: ... } |
| WaitGroup | wg.Add(1); defer wg.Done(); wg.Wait() |
| Mutex | mu.Lock(); ...; mu.Unlock() |
Frequently Asked Questions
Q: Why does the output order change every time I run goroutines?
Goroutines run concurrently and the Go scheduler decides when each one gets to run, so the order is not fixed. If you need a guaranteed order, make the goroutines hand values to one collector through a channel, or print from a single goroutine after the others finish.
Q: What's the difference between a buffered and an unbuffered channel?
An unbuffered channel — make(chan T) — has no storage: a send blocks until a receiver is ready, so it also synchronises the two goroutines. A buffered channel — make(chan T, n) — can hold up to n values, so sends only block once the buffer is full. Use unbuffered when you want a handoff/synchronisation point; use buffered to smooth out bursts.
Q: When should I use a channel and when should I use a Mutex?
Prefer a channel when you are passing ownership of data from one goroutine to another — Go's motto is 'share memory by communicating'. Reach for a sync.Mutex when several goroutines must read and update the same shared variable in place (like a counter or a cache) and passing it around would be awkward.
Q: Why does my program finish before the goroutines print anything?
When main returns, the whole program exits and any still-running goroutines are killed instantly. Make main wait — most often with a sync.WaitGroup (wg.Wait()) or by receiving the results on a channel — so it stays alive until the work is done.
Q: What does 'fatal error: all goroutines are asleep - deadlock!' mean?
Every goroutine is blocked waiting for something that will never happen — commonly a send on an unbuffered channel with no receiver, or a receive with no sender. Make sure there is a goroutine on the other end of each channel, and close channels you range over so the loop can end.
Mini-Challenge: Safe Concurrent Sum
No blanks this time — just a brief and an outline. Launch five goroutines that each add their number to a shared total, protected by a mutex, and wait for them all with a WaitGroup. Run it and check your output against the expected line in the comments.
package main
import (
"fmt"
"sync"
)
func main() {
// 🎯 MINI-CHALLENGE: sum work across goroutines, safely
// 1. Make a sync.WaitGroup and a sync.Mutex, and a total := 0.
// 2. Loop i from 1 to 5. For each i:
// - wg.Add(1)
// - launch a goroutine that locks the mutex, does total += i,
// unlocks, and defers wg.Done().
// 3. wg.Wait(), then print: "total:" total
//
// ✅ Expected output (1+2+3+4+5):
// total: 15
// your code here
}total: 1515 — if it varies, your mutex isn't guarding every update.🎉 Lesson Complete!
- ✅ Goroutines run functions concurrently — start one with
go func() - ✅ Channels pass values safely: send
ch <- v, receivev := <-ch - ✅ Unbuffered channels synchronise; buffered channels hold a few values
- ✅
selectwaits on many channels and adds timeouts / non-blocking checks - ✅
WaitGroupwaits for goroutines;Mutexprotects shared state - ✅ Goroutine output order isn't fixed — design for it, don't rely on it
- ✅ Next lesson: Error Handling — Go's explicit
errorvalues and how to wrap them
Sign up for free to track which lessons you've completed and get learning reminders.