06 — Goroutines
Go's concurrency building blocks: starting work with go, waiting for it
with sync.WaitGroup, spotting data races, and protecting shared data with
sync.Mutex. Every example below is live — edit it and press
Run.
1. A goroutine
A goroutine is a function running concurrently with the rest of your
program. You start one by writing go in front of a call. It returns
immediately; the new goroutine runs alongside the caller.
If you've used POSIX threads, a goroutine fills a similar role but is far lighter — it's
scheduled by the Go runtime onto a small pool of OS threads, so having thousands is
normal. The catch: go f() does not wait. If main
returns while goroutines are still running, the program exits and they just stop.
So we need a way to wait. The next section covers it properly; for now this example collects each goroutine's result into its own slot and prints them in order after they finish:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]string, 3)
for i := range results {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = fmt.Sprintf("worker %d done", i)
}()
}
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
}
Output:
worker 0 done
worker 1 done
worker 2 done
Why the order is stable here. Each goroutine writes to its own slot
results[i], and we only print after Wait(). The goroutines
still run in an unpredictable order — but since we read the results in index
order, the output is the same every time. If you printed from inside the goroutines
instead, the line order would vary from run to run.
2. Waiting with sync.WaitGroup
There's no handle to join like pthread_join. Instead a
sync.WaitGroup counts outstanding goroutines:
Add(n)raises the counter byn— call it before launching.Done()lowers it by one — put it in adeferso it always runs.Wait()blocks until the counter is back to zero.
After Wait() returns, every goroutine has finished, so their results are
safe to use. Here we sum a slice concurrently — one goroutine per element, each adding
into a shared total (protected by a mutex, which section 4 explains):
package main
import (
"fmt"
"sync"
)
func SumConcurrent(nums []int) int {
var mu sync.Mutex
var wg sync.WaitGroup
total := 0
for _, n := range nums {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
total += n
mu.Unlock()
}()
}
wg.Wait()
return total
}
func main() {
fmt.Println(SumConcurrent([]int{1, 2, 3, 4, 5}))
}
Output:
15
Newer shortcut: Go 1.25+ adds wg.Go(fn), which bundles
Add(1), launching the goroutine, and Done() into one call.
Try rewriting the loop above with it. The example in section 3 uses it.
3. Distinct slots need no lock
A lock is only needed when goroutines touch the same location. If each writes to
a distinct slot, there's no conflict and no lock required. This ParallelMap
pre-sizes the output and lets goroutine i write only out[i],
using the wg.Go shortcut:
package main
import (
"fmt"
"sync"
)
func ParallelMap(in []int, f func(int) int) []int {
out := make([]int, len(in))
var wg sync.WaitGroup
for i, v := range in {
wg.Go(func() {
out[i] = f(v)
})
}
wg.Wait()
return out
}
func main() {
squares := ParallelMap([]int{1, 2, 3, 4}, func(x int) int { return x * x })
fmt.Println(squares)
}
Output:
[1 4 9 16]
Order is preserved for free: index i always lands at out[i],
regardless of which goroutine finishes first.
4. Data races, and the Mutex that fixes them
Goroutines share memory. If two of them write the same variable at once, the result is
undefined — a data race. The classic shape is many goroutines doing
total += n on a shared total with no protection: that's really
"read, add, write," and interleaving two of them loses updates.
Go ships a detector for this. Running tests or programs with the -race flag
reports races at runtime, with file and line numbers:
# a program with an unguarded shared write
go run -race .
==================
WARNING: DATA RACE
Write at 0x... by goroutine 7:
main.main.func1()
...
==================
(That one isn't runnable here on purpose — a race has no single correct output, which is
exactly the problem.) The fix is a sync.Mutex: a lock that
lets only one goroutine into a section at a time. Lock() acquires it,
Unlock() releases it, and pairing the unlock with defer makes
it foolproof.
A clean pattern bundles the mutex with the data it protects and exposes safe methods — here a concurrency-safe counter hammered by 100 goroutines doing 100 increments each:
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
func main() {
var c Counter
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
for range 100 {
c.Inc()
}
})
}
wg.Wait()
fmt.Println(c.Value())
}
Output:
10000
Because every Inc runs under the lock, all 10,000 increments are counted —
remove the Lock/Unlock and run with -race to watch
the detector complain and the total drop below 10000. Use pointer receivers on these
methods: a Mutex must not be copied.
Recap
| Need | Tool |
|---|---|
| Start concurrent work | go f() (doesn't wait) |
| Wait for a group | sync.WaitGroup: Add/Done/Wait, or wg.Go |
| Find races | -race flag |
| Protect shared data | sync.Mutex with Lock/defer Unlock |
| No shared data | Write distinct slots — no lock needed |
Next: 07 — Channels, where goroutines pass data to each other instead of sharing it under a lock.