Goroutines
Reviewed & published by Brayan K
Concurrency is Go's superpower, and goroutines are how you reach it. By the end of this lesson you'll start work running in the background with one keyword, wait for it correctly with a WaitGroup, avoid the classic loop-variable bug, and protect shared data with a Mutex.
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
- Start a concurrent function with the go keyword
- Understand why main exiting kills its goroutines
- Wait for goroutines properly with sync.WaitGroup
- Use Add, Done (via defer), and Wait correctly
- Avoid the loop-variable capture bug
- Protect shared state with sync.Mutex
- Know when you have a data race and how to fix it
📊 The Concurrency Toolkit
| Tool | What it does |
|---|---|
| go f() | Run f concurrently as a goroutine |
| sync.WaitGroup | Wait for a set of goroutines to finish |
| sync.Mutex | Let one goroutine touch shared data at a time |
| defer wg.Done() | Mark a goroutine complete, even if it returns early |
Goroutines are not OS threads. They start at about 2 KB of stack and are multiplexed onto a small pool of threads by the Go runtime, so launching thousands is normal and cheap.
1️⃣ Starting a Goroutine
Put go in front of any function call and it runs concurrently — your code keeps going without waiting for it. The catch every beginner hits: main is itself a goroutine, and when main returns the whole program exits, abandoning any goroutines still running. Below we pause with time.Sleep just to see them — that's a teaching crutch we'll replace next.
package main
import (
"fmt"
"time"
)
// A goroutine is a function running CONCURRENTLY — managed by Go, not the OS.
// You start one by writing "go" before a function call.
func greet(name string) {
fmt.Println("Hello,", name)
}
func main() {
go greet("Alice") // runs concurrently — main does NOT wait here
go greet("Bob")
// main is itself a goroutine. If it returns, the program ends
// and any unfinished goroutines are killed. So we pause briefly.
time.Sleep(100 * time.Millisecond)
fmt.Println("main done")
}2️⃣ Waiting Properly with WaitGroup
Sleeping is fragile — too short and you miss output, too long and you waste time. The correct tool is sync.WaitGroup: a counter you bump with Add(1) before each goroutine, decrement with Done() when it finishes, and block on with Wait(). Always call Done through defer so it runs even if the goroutine returns early.
package main
import (
"fmt"
"sync"
)
// time.Sleep is a hack. The real tool for "wait for goroutines to finish"
// is a sync.WaitGroup: a counter you Add to, mark Done, and Wait on.
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // register one more goroutine to wait for
go func(id int) {
defer wg.Done() // signal "I'm finished" when this returns
fmt.Printf("worker %d done\n", id)
}(i) // pass i as an argument — capture it safely
}
wg.Wait() // block until the counter reaches zero
fmt.Println("all workers finished")
}Your turn. Add the two missing WaitGroup calls so the program waits for all three jobs before printing the final line.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
jobs := []string{"build", "test", "deploy"}
for _, job := range jobs {
___ // 👉 wg.Add(1) (before starting the goroutine)
go func(name string) {
defer wg.Done()
fmt.Println("running:", name)
}(job)
}
___ // 👉 wg.Wait() (block until all finish)
fmt.Println("pipeline complete")
// ✅ Output (3 "running:" lines in any order) then:
// pipeline complete
}3️⃣ The Loop-Variable Trap
A famous gotcha: starting goroutines inside a loop that all reference the loop variable. In older Go versions every goroutine could end up seeing the same final value because they shared one variable. The bulletproof fix — correct in every Go version — is to pass the loop variable as an argument, giving each goroutine its own copy.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
// ⚠️ Always PASS the loop variable as an argument. If you close over it
// directly (older Go), every goroutine could see the SAME final value.
// Passing it makes each goroutine get its own copy — always correct.
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) { // n is a private copy for this goroutine
defer wg.Done()
fmt.Println("got", n)
}(i)
}
wg.Wait()
}4️⃣ Protecting Shared State with Mutex
When many goroutines read and write the same variable at once, you have a data race — the result is unpredictable and undefined. A sync.Mutex fixes it: Lock() lets exactly one goroutine into the critical section, and Unlock() lets the next in. Run code with go run -race to have Go detect races for you.
package main
import (
"fmt"
"sync"
)
// When many goroutines touch the SAME variable, you need a lock or
// you get a data race. A sync.Mutex lets one goroutine in at a time.
func main() {
var (
mu sync.Mutex
counter int
wg sync.WaitGroup
)
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // only one goroutine past this line at a time
counter++ // safe: protected by the lock
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("counter =", counter) // always exactly 1000
}🧩 Reorder Challenge
These lines run three goroutines and wait for them with a WaitGroup. Put them in the correct order:
A. defer wg.Done()
B. wg.Wait()
C. var wg sync.WaitGroup
D. wg.Add(1)
E. go func() {
F. }()
G. for i := 0; i < 3; i++ {
H. }C, G, D, E, A, F, H, B. Declare the WaitGroup, then loop: inside the loop Add(1) first, then start the goroutine whose body defers Done. Close the goroutine literal and the loop, then Wait() after the loop. Add must run before the goroutine starts so the counter is never raced.
🧠 Quick Recall
Predict the output before revealing the answer.
1. What is likely to print?
func main() {
go fmt.Println("hi")
}Usually nothing. main returns immediately and the program exits before the goroutine gets a chance to run. There's no Wait or Sleep to hold the program open.
2. What does counter print?
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); counter++ }()
}
wg.Wait()
fmt.Println(counter)Some number ≤ 1000, unpredictable — this is a data race because counter++ is unprotected. Wrap it in mu.Lock()/mu.Unlock() (or use atomic) to get a reliable 1000.
3. Does this deadlock or finish?
var wg sync.WaitGroup
wg.Add(1)
wg.Wait()Deadlocks. The counter is 1 and nothing ever calls Done, so Wait blocks forever. Go detects this and panics with "all goroutines are asleep - deadlock!".
Pro Tips
- 💡 Always defer wg.Done() at the very top of the goroutine, so it runs no matter how the function exits.
- 💡 Call wg.Add before starting the goroutine, never inside it — otherwise Wait can race ahead and return too early.
- 💡 Develop with go run -race ./.... The race detector finds bugs that show up only occasionally in production.
- 💡 Don't share memory to communicate — communicate to share memory. Often a channel (next lesson) is cleaner than a mutex.
Common Errors (and the fix)
- "fatal error: all goroutines are asleep - deadlock!" — something is waiting on a counter or channel that will never be satisfied. Check every Add has a matching Done.
- Output silently missing — main exited before the goroutines ran. Use a WaitGroup, not luck.
- "WaitGroup is reused before previous Wait has returned" — you called Add after Wait started. Keep all Add calls before Wait.
- Inconsistent numbers across runs — a data race on shared state. Protect it with a Mutex or use the sync/atomic package.
- "go func(...) used as value" — you forgot the trailing () that actually calls the function literal. A goroutine must be a call.
Frequently Asked Questions
Q: How is a goroutine different from a thread?
A goroutine is much lighter — it starts at roughly 2 KB and the Go runtime schedules many goroutines onto a small set of OS threads. You can run hundreds of thousands of goroutines; you could not run hundreds of thousands of OS threads.
Q: Why isn't my output in order?
Concurrency makes no promise about ordering. Goroutines are scheduled independently, so two consecutive runs can print in different orders. If you need ordering, coordinate with channels or a WaitGroup.
Q: Do I always need a Mutex?
Only when goroutines share mutable state. If each goroutine works on its own data, or you pass results through channels, you may need no lock at all. Locks are for the shared-variable case.
Q: When should I prefer channels over a WaitGroup?
Use a WaitGroup when you only need to wait for completion. Use channels when goroutines need to send data back or coordinate work. You'll learn channels in the next lesson.
Mini-Challenge: Parallel Greeters
No blanks — just a brief. Launch one goroutine per name, wait for them all with a WaitGroup, and remember to pass the name in as an argument. This is the canonical "fan out, then wait" pattern.
package main
import (
"fmt"
"sync"
)
func main() {
// 🎯 MINI-CHALLENGE: parallel greeters
// 1. Make a sync.WaitGroup.
// 2. Range over names := []string{"Sam", "Lee", "Mia"}.
// 3. For each name, Add(1) and start a goroutine that prints
// "Hi, <name>!" and calls Done (use defer).
// 4. Remember to PASS the name as an argument.
// 5. Wait() for all of them, then print "everyone greeted".
//
// ✅ Three "Hi, ...!" lines in any order, then: everyone greeted
// your code here
}🎉 Lesson Complete!
- ✅ go f() runs a function concurrently as a goroutine
- ✅ When main returns, the program exits and abandons goroutines
- ✅ sync.WaitGroup with Add/Done/Wait is the correct way to wait
- ✅ Pass the loop variable as an argument to give each goroutine its own copy
- ✅ Protect shared state with a sync.Mutex; find races with -race
Practice quiz
How do you start a goroutine?
- async f()
- spawn f()
- go f()
- thread f()
Answer: go f(). Put go before a function call to run it concurrently as a goroutine.
What happens to running goroutines when main returns?
- the program exits and abandons them
- they keep running
- main waits for them
- they restart
Answer: the program exits and abandons them. main is itself a goroutine; when it returns the whole program exits, killing the rest.
What is the correct tool to wait for a set of goroutines?
- time.Sleep
- a for loop
- sync.Mutex
- sync.WaitGroup
Answer: sync.WaitGroup. A sync.WaitGroup with Add/Done/Wait waits reliably, unlike the fragile time.Sleep.
On a WaitGroup, which call blocks until the counter reaches zero?
- Add
- Wait
- Done
- Lock
Answer: Wait. Wait() blocks until every Add has a matching Done and the counter hits zero.
Why call wg.Done() with defer?
- it runs even if the goroutine returns early
- it runs faster
- it adds to the counter
- it locks the group
Answer: it runs even if the goroutine returns early. defer wg.Done() guarantees the decrement on every exit path of the goroutine.
wg.Add(1) should be called...
- inside the goroutine
- after Wait
- before starting the goroutine
- only once total
Answer: before starting the goroutine. Add before launching the goroutine so Wait can't race ahead and return too early.
What is the bulletproof fix for the loop-variable capture bug?
- use a global
- pass the loop variable as an argument to the goroutine
- add a Sleep
- use a Mutex
Answer: pass the loop variable as an argument to the goroutine. Passing the loop variable gives each goroutine its own copy in every Go version.
What happens when many goroutines write the same variable without a lock?
- nothing, it's safe
- a compile error
- automatic locking
- a data race with unpredictable results
Answer: a data race with unpredictable results. Unsynchronized shared writes are a data race; the result is undefined.
What does sync.Mutex provide?
- a wait counter
- one goroutine in the critical section at a time
- a channel
- goroutine scheduling
Answer: one goroutine in the critical section at a time. Lock() admits exactly one goroutine; Unlock() lets the next in, protecting shared state.
Which flag makes Go detect data races at runtime?
- -vet
- -async
- -race
- -lock
Answer: -race. Run with go run -race (or go test -race) to have Go report data races.
Continue this course
- Previous: Error Handling
- Next: Channels — Communicate between goroutines — buffered, unbuffered, and close