Go · lesson 14 of 25
Goroutines in Go
Goroutines run for real: main exiting early, sync.WaitGroup, collecting results in order, a data race caught by -race, a goroutine panic, and GOMAXPROCS.
By SkillByExample · Updated
A goroutine is a function running concurrently with the rest of the program, started with the go keyword. Starting one is easy; the work is in knowing when it has finished and making sure goroutines don’t touch the same data unsafely. Concurrent programs can print in different orders on different runs, so every example below synchronises before printing, and the shared-output ones were run repeatedly (including with GOMAXPROCS=1) to confirm the output is stable.
main doesn’t wait
go f() starts f and returns immediately. When main returns, the program ends — whatever other goroutines were doing.
package main
import "fmt"
func work(results *[]int) {
sum := 0
for i := range 10_000_000 {
sum += i
}
*results = append(*results, sum)
}
func main() {
var results []int
go work(&results)
fmt.Println("main done, results:", results)
}
$ go run nowait.go
main done, results: []
The goroutine never got to finish its loop: main printed and returned first, and the program exited. Nothing reported the abandoned work. (Whether the goroutine gets to run at all before main exits is up to the scheduler, which is exactly why you must never rely on it — this program is also a data race on results.)
Waiting with sync.WaitGroup
A WaitGroup counts outstanding goroutines: Add before starting each, Done when each finishes, Wait to block until the count is zero.
package main
import (
"fmt"
"sync"
)
func main() {
urls := []string{"go.dev", "pkg.go.dev", "example.com", "a.io"}
sizes := make([]int, len(urls)) // one slot per goroutine: no sharing
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
go func() {
defer wg.Done()
sizes[i] = len(u) * 100 // stand-in for real work
}()
}
wg.Wait()
for i, u := range urls {
fmt.Println(u, sizes[i])
}
}
$ go run waitgroup.go
go.dev 600
pkg.go.dev 1000
example.com 1100
a.io 400
The goroutines can finish in any order, but each writes only its own index of sizes, and main prints only after Wait returns — so the output is identical on every run. Call Add before the go statement: calling it inside the goroutine races with Wait, which might see a zero count and return early. Since Go 1.22 each iteration has its own i and u, so the closure captures the right values.
Since Go 1.25, wg.Go does the Add, the go statement and the Done in one call, so the misplaced-Add mistake can’t happen:
package main
import (
"fmt"
"sync"
)
func main() {
urls := []string{"go.dev", "pkg.go.dev", "example.com", "a.io"}
sizes := make([]int, len(urls))
var wg sync.WaitGroup
for i, u := range urls {
wg.Go(func() { // Add(1), go, and Done() in one call
sizes[i] = len(u) * 100
})
}
wg.Wait()
for i, u := range urls {
fmt.Println(u, sizes[i])
}
}
$ go run wggo.go
go.dev 600
pkg.go.dev 1000
example.com 1100
a.io 400
Same output, two fewer lines per goroutine. The function you pass takes no arguments and returns nothing. Use wg.Go in new code; you’ll still see Add/Done in most existing code, and in the examples below.
A data race
When goroutines update the same variable without synchronisation, updates are lost — and the result changes from run to run.
package main
import (
"fmt"
"sync"
)
func main() {
count := 0
var wg sync.WaitGroup
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
count++ // read, add, write: not atomic
}()
}
wg.Wait()
fmt.Println("count:", count)
}
$ go run race.go
count: 975
$ go run race.go
count: 997
$ go run -race race.go 2>&1 | head -17
==================
WARNING: DATA RACE
Read at 0x00c00009a078 by goroutine 9:
main.main.func1()
/home/user/gobyex/goroutines/race/race.go:15 +0x84
Previous write at 0x00c00009a078 by goroutine 11:
main.main.func1()
/home/user/gobyex/goroutines/race/race.go:15 +0x96
Goroutine 9 (running) created at:
main.main()
/home/user/gobyex/goroutines/race/race.go:13 +0x78
Goroutine 11 (running) created at:
main.main()
/home/user/gobyex/goroutines/race/race.go:13 +0x78
$ go run -race race.go 2>&1 | tail -3
count: 820
Found 2 data race(s)
exit status 66
Two runs, and the count fell short of 1000 by a different amount each time (on a lightly loaded machine it can also come out right, which is what makes races dangerous). count++ is a read, an add and a write; two goroutines can read the same value and both write back value + 1. The race detector (-race) reports the conflicting accesses — a read and an earlier write of the same address — with the stack of each goroutine involved and where each was created. At the end it counts the races found and exits with status 66, so a CI job fails. Even under the detector, the count was still wrong: -race reports races, it doesn’t prevent them. Run your tests with go test -race routinely.
Fixing the race
Protect the shared variable with a sync.Mutex, or use an atomic type for a simple counter.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var (
mu sync.Mutex
locked int
atomc atomic.Int64
wg sync.WaitGroup
)
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
locked++
mu.Unlock()
atomc.Add(1)
}()
}
wg.Wait()
fmt.Println(locked, atomc.Load())
}
$ go run -race racefix.go
1000 1000
Under -race the detector is silent and both counters reach 1000 on every run. A mutex protects any amount of state; atomic.Int64 is cheaper but only covers single-value operations. The concurrency patterns lesson covers RWMutex, sync.Once and more.
A panic in any goroutine kills the program
recover only works in the goroutine that panicked. A panic in a background goroutine takes main down with it.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
var m map[string]int
m["boom"] = 1
}()
wg.Wait()
fmt.Println("main finished")
}
$ go run gopanic.go 2>&1 | head -3
panic: assignment to entry in nil map
goroutine 6 [running]:
main finished never prints. (The goroutine number in the trace varies from run to run.) A deferred recover in main wouldn’t help either; it has to be inside the goroutine. Long-lived workers that must survive a bad input wrap their body in their own defer func() { if r := recover(); r != nil { ... } }().
Goroutines are cheap
Each goroutine starts with a small stack that grows as needed, so large numbers are practical.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
const n = 100_000
var wg sync.WaitGroup
release := make(chan struct{})
for range n {
wg.Add(1)
go func() {
defer wg.Done()
<-release // park until released
}()
}
runtime.ReadMemStats(&after)
fmt.Println("goroutines:", runtime.NumGoroutine())
fmt.Printf("stack memory: %.1f MB (~%d bytes each)\n",
float64(after.StackInuse-before.StackInuse)/1e6,
(after.StackInuse-before.StackInuse)/n)
close(release)
wg.Wait()
fmt.Println("after release:", runtime.NumGoroutine())
}
$ go run cheap.go
goroutines: 100001
stack memory: 204.8 MB (~2048 bytes each)
after release: 1
The memory figures come from this machine and vary slightly by Go version and platform. 100,001 goroutines (the extra one is main) used about 2 KB of stack each; the same number of OS threads would reserve megabytes apiece. close(release) wakes every parked goroutine at once — a closed channel is always ready to receive from, a trick the channels lesson explains.
GOMAXPROCS
GOMAXPROCS is how many OS threads run Go code at the same time. It defaults to the number of CPUs the program may actually use. Since Go 1.25 that includes a container’s CPU limit.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("CPUs:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0 = query, don't change
}
$ go run maxprocs.go
CPUs: 12
GOMAXPROCS: 12
$ GOMAXPROCS=1 go run maxprocs.go
CPUs: 12
GOMAXPROCS: 1
$ docker run --rm --cpus=2 -v "$PWD":/src -w /src golang:1.27 go run maxprocs.go
CPUs: 12
GOMAXPROCS: 2
$ docker run --rm --cpus=1.5 -v "$PWD":/src -w /src golang:1.27 go run maxprocs.go
CPUs: 12
GOMAXPROCS: 2
$ go mod edit -go=1.24
$ docker run --rm --cpus=2 -v "$PWD":/src -w /src golang:1.27 go run maxprocs.go
CPUs: 12
GOMAXPROCS: 12
These values depend on the machine; this one has 12 CPUs. Inside a container limited to two CPUs, NumCPU still reports all 12, but GOMAXPROCS drops to 2. Since Go 1.25 the runtime reads the cgroup CPU limit, rounds it up (so --cpus=1.5 also gives 2), and re-checks it periodically while the program runs. Before 1.25 it ignored the limit and used 12 here, so a container could run more threads than its CPU quota allowed and get throttled. The new default only applies when go.mod says go 1.25 or later. The last run shows this: the same Go 1.27 toolchain, in a module set back to go 1.24, ignores the --cpus=2 limit and uses all 12. Setting GOMAXPROCS yourself always wins.
With GOMAXPROCS=1, goroutines still run concurrently — the scheduler interleaves them — but never in parallel. That’s why running a concurrent program with GOMAXPROCS=1 is a useful extra check: code that only works when goroutines happen to run in parallel, or only when they don’t, will show it.
Practice
1. Fix nowait.go so that main waits for work and prints the sum.
Solution
package main
import (
"fmt"
"sync"
)
func main() {
var sum int
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := range 10_000_000 {
sum += i
}
}()
wg.Wait()
fmt.Println("sum:", sum)
}
$ go run -race p1.go
sum: 49999995000000
wg.Wait() also establishes happens-before: everything the goroutine wrote before Done is visible to main after Wait, so the race detector stays quiet.
2. Split the sum of 1..1,000,000 across 4 goroutines, each summing a quarter into its own slot of a [4]int, and add the slots at the end.
Solution
package main
import (
"fmt"
"sync"
)
func main() {
const n, workers = 1_000_000, 4
var partial [workers]int
var wg sync.WaitGroup
for w := range workers {
wg.Add(1)
go func() {
defer wg.Done()
for i := w*n/workers + 1; i <= (w+1)*n/workers; i++ {
partial[w] += i
}
}()
}
wg.Wait()
total := 0
for _, p := range partial {
total += p
}
fmt.Println(partial, total)
}
$ go run -race p2.go
[31250125000 93750125000 156250125000 218750125000] 500000500000
Each goroutine owns one element, so there’s nothing to lock. The total matches n(n+1)/2 = 500000500000.
3. In gopanic.go, add a deferred recover inside the goroutine so the program prints the recovered value and then main finished.
Solution
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
var m map[string]int
m["boom"] = 1
}()
wg.Wait()
fmt.Println("main finished")
}
$ go run p3.go
recovered: assignment to entry in nil map
main finished
Deferred calls run last-in first-out, so the recover runs before wg.Done(), and main only continues once the goroutine has fully recovered.
4. Replace the mutex in racefix.go with an atomic.Int64 only, and confirm with -race.
Solution
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count atomic.Int64
var wg sync.WaitGroup
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
count.Add(1)
}()
}
wg.Wait()
fmt.Println(count.Load())
}
$ go run -race p4.go
1000
atomic.Int64 makes the read-modify-write a single indivisible operation.