Concurrency with Goroutines
Reviewed & published by Brayan K
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.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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"
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")
}Notice 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
}Your 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
}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
}
}4️⃣ 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)
}Now 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
}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
}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() while main receives.
- 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 run go run -race . to detect races.
- Program prints nothing / exits early — main returned before the goroutines ran, killing them. Make main wait with wg.Wait() or by receiving on a channel.
- Hangs forever on wg.Wait() — you forgot a wg.Done() (or did more Add than Done), so the counter never reaches zero. Put defer 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() |
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.
🎉 Lesson Complete!
- ✅ Goroutines run functions concurrently — start one with go func()
- ✅ Channels pass values safely: send ch <- v, receive v := <-ch
- ✅ Unbuffered channels synchronise; buffered channels hold a few values
- ✅ select waits on many channels and adds timeouts / non-blocking checks
- ✅ WaitGroup waits for goroutines; Mutex protects shared state
- ✅ Goroutine output order isn't fixed — design for it, don't rely on it
Practice quiz
How do you start a function running concurrently as a goroutine?
- async myFunc()
- concurrent myFunc()
- go myFunc()
- spawn myFunc()
Answer: go myFunc(). Putting the keyword go in front of a call launches it as a goroutine and returns immediately.
What does wg.Wait() do on a sync.WaitGroup?
- Blocks until the counter reaches zero
- Adds one to the counter
- Sleeps one second
- Resets the group
Answer: Blocks until the counter reaches zero. Wait blocks until every Add has been matched by a Done, i.e. the counter is back to zero.
Why can the order of goroutine output vary between runs?
- The compiler shuffles it
- Channels reorder values
- It is a bug in Go
- The scheduler decides when each goroutine runs
Answer: The scheduler decides when each goroutine runs. Goroutines run concurrently and the Go scheduler chooses when each one runs, so output order is not fixed.
What problem does a sync.Mutex solve?
- Starting goroutines faster
- A data race when goroutines update shared state
- Closing channels
- Buffering values
Answer: A data race when goroutines update shared state. A mutex serialises access so only one goroutine touches the shared variable at a time, preventing lost updates.
Where is the idiomatic place to put defer wg.Done() in a goroutine?
- As the first line of the goroutine
- As the last line
- Inside wg.Wait()
- After wg.Add
Answer: As the first line of the goroutine. Making defer wg.Done() the first line ensures it fires even if the function returns early or panics.
What is the difference between make(chan int) and make(chan int, 3)?
- No difference
- The first is faster
- The first is unbuffered; the second buffers up to 3
- The second cannot be closed
Answer: The first is unbuffered; the second buffers up to 3. make(chan int) is unbuffered (synchronises send/receive); make(chan int, 3) buffers up to three values.
What does a default case make a select statement do?
- Loop forever
- Become non-blocking — run default if nothing is ready
- Block until any case fires
- Close the channels
Answer: Become non-blocking — run default if nothing is ready. With a default case, select runs default immediately when no other case is ready, making it non-blocking.
What commonly causes 'fatal error: all goroutines are asleep - deadlock!'?
- Too many goroutines
- Using a Mutex
- Closing a channel twice
- A send on an unbuffered channel with no receiver
Answer: A send on an unbuffered channel with no receiver. A send (or receive) on an unbuffered channel with nothing on the other end blocks every goroutine — a deadlock.
In a pipeline, what does a return type of <-chan int mean?
- A buffered channel
- A receive-only channel
- A send-only channel
- A closed channel
Answer: A receive-only channel. <-chan int is a receive-only channel, so callers can read results but cannot accidentally send into it.
Why might a program exit before its goroutines print anything?
- Goroutines are too slow
- Channels block main
- When main returns the program exits and kills running goroutines
- The scheduler stops early
Answer: When main returns the program exits and kills running goroutines. When main returns the whole program exits, killing unfinished goroutines. Make main wait with a WaitGroup or channel.
Continue this course
- Previous: Channels
- Next: defer, panic & recover — Cleanup with defer, and Go's panic/recover error model
Frequently asked questions
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.
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.
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.
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.
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.