42. Goroutines
A goroutine is a function running independently of the one that started it.
You create one by putting go in front of a call. That's the entire syntax,
and it's the feature Go is famous for.
Starting one
package main
import (
"fmt"
"sync"
)
func work(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("worker", id, "did its job")
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go work(i, &wg)
}
wg.Wait()
fmt.Println("all workers finished")
}
go work(i, &wg) starts work and returns immediately — main doesn't
wait. Run this a few times: the three worker lines may appear in a different
order each time, because they genuinely run concurrently.
sync.WaitGroup is what stops main from finishing first; it gets a full
lesson shortly. For now, read it as "count three things, then wait for all
three".
Why main must wait
When main returns, the program exits — every goroutine still running is
killed instantly, without ceremony:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
go func() {
time.Sleep(50 * time.Millisecond)
fmt.Println("this may never print")
}()
fmt.Println("main is done")
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
fmt.Println("this always prints, because main waited")
}()
wg.Wait()
}
The first goroutine is a coin flip; the second is guaranteed. Never use
time.Sleep to coordinate goroutines — it's a guess about timing, and a
guess that's wrong on a loaded machine. Use a WaitGroup or a channel, both
of which are coming up.
Goroutines are cheap
This is the part that changes how you design programs:
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
fmt.Println("goroutines at start:", runtime.NumGoroutine())
var wg sync.WaitGroup
var mu sync.Mutex
total := 0
for i := 1; i <= 1000; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
mu.Lock()
total += n
mu.Unlock()
}(i)
}
wg.Wait()
fmt.Println("sum 1..1000 =", total)
fmt.Println("goroutines at end:", runtime.NumGoroutine())
}
A thousand goroutines is unremarkable. A goroutine starts with about 2 KB of stack that grows on demand, versus an OS thread's fixed 1–8 MB. Go's scheduler multiplexes many goroutines onto a few OS threads, so switching between them doesn't involve the kernel.
Real Go servers run hundreds of thousands of goroutines. "One goroutine per request" is a normal architecture, not a clever one.
Anonymous goroutines and captured variables
The common form is an anonymous function:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]string, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results[n] = fmt.Sprintf("task %d done", n)
}(i)
}
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
}
Two techniques worth copying:
Passing i as an argument. In Go 1.21 and earlier, every iteration
shared one loop variable, so goroutines routinely all saw the final value —
the closure trap from module 3, now with a race attached. Go 1.22 gives each
iteration its own variable and fixed it, but passing the value explicitly is
still the clearest way to say "this goroutine gets this number", and you'll
see it everywhere.
Writing to results[n]. Each goroutine touches a different index, so
there's no conflict and no lock needed — and the output comes out in order
even though the work didn't. Pre-sizing a slice and having each worker own
one slot is a genuinely useful pattern.
defer wg.Done() — always
package main
import (
"fmt"
"sync"
)
func mightFail(n int, wg *sync.WaitGroup) {
defer wg.Done()
if n%2 == 0 {
fmt.Println(n, "-> returning early")
return
}
fmt.Println(n, "-> full work")
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 4; i++ {
wg.Add(1)
go mightFail(i, &wg)
}
wg.Wait()
fmt.Println("done")
}
If Done isn't deferred, an early return — or a panic — means Wait()
never returns and your program hangs forever. defer wg.Done() on the first
line of the goroutine costs nothing and removes the whole class of bug.
Concurrency is not parallelism
Worth getting straight, because the words get used interchangeably everywhere else:
- Concurrency is structure: your program is composed of independently executing pieces. That's what goroutines give you.
- Parallelism is execution: several things literally running at the same instant. That needs multiple cores.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("logical CPUs available:", runtime.NumCPU() > 0)
fmt.Println("max parallel OS threads:", runtime.GOMAXPROCS(0) > 0)
}
A concurrent program runs correctly on one core — just not simultaneously.
Go gives you concurrency; the runtime provides parallelism when the hardware
has it. GOMAXPROCS defaults to the number of cores and you almost never
change it.
Two rules to carry into the next lessons
- A goroutine that never exits is a leak. It holds its stack and everything it references forever. Always make sure there's a path for a goroutine to finish.
- A panic in any goroutine kills the whole program. A
recoverinmaincannot save it — each goroutine needs its own deferred recover if it might panic.
Your turn
Run square concurrently for the numbers 1 to 5, writing each result into
its own slot so the output is ordered:
[1 4 9 16 25]
package main
import (
"fmt"
"sync"
)
func main() {
nums := []int{1, 2, 3, 4, 5}
results := make([]int, len(nums))
var wg sync.WaitGroup
// start one goroutine per number, writing n*n into results[i]
wg.Wait()
fmt.Println(results)
}
package main
import (
"fmt"
"sync"
)
func main() {
nums := []int{1, 2, 3, 4, 5}
results := make([]int, len(nums))
var wg sync.WaitGroup
for i, n := range nums {
wg.Add(1)
go func(idx, val int) {
defer wg.Done()
results[idx] = val * val
}(i, n)
}
wg.Wait()
fmt.Println(results)
}
Next: how goroutines talk to each other.