Skip to main content
Go · lesson 14 of 25
Go intermediate 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.

Frequently Asked Questions

How are goroutines different from threads?
A goroutine is a function scheduled by the Go runtime rather than the operating system. It starts with a stack of a few kilobytes that grows on demand, and the runtime multiplexes many goroutines onto a few OS threads. That makes hundreds of thousands of goroutines practical where the same number of threads would not be.
Why does my goroutine's output never appear?
A Go program exits as soon as main returns, without waiting for other goroutines. If main finishes first, their work is simply abandoned. Wait for them explicitly with a sync.WaitGroup, a channel, or errgroup.
What happens when a goroutine panics?
An unrecovered panic in any goroutine crashes the whole program, including main. recover only works inside a deferred function in the goroutine that panicked, so long-running goroutines that must survive bad input need their own defer-recover.
How do I detect data races in Go?
Build or test with the race detector: go run -race, go test -race or go build -race. It instruments memory accesses and reports two goroutines touching the same variable without synchronisation, with stack traces for both. It finds races that actually occur during the run, so exercise the concurrent paths.
What does GOMAXPROCS control?
GOMAXPROCS is the number of OS threads that can execute Go code at the same moment. It defaults to the number of CPUs available; since Go 1.25 that respects a container's CPU limit, so under docker run --cpus=2 it is 2 even when the host has more cores. Goroutines blocked in system calls or I/O do not count against it, so it limits parallel computation, not the number of goroutines.