Skip to content

Goroutines

A goroutine is a function executing concurrently with other goroutines in the same address space. You launch one with a single keyword:

go f(args)

That is the entire syntax. go schedules f to run concurrently, then the calling goroutine proceeds immediately without waiting for f to return.

An OS thread typically starts with a 1–8 MB fixed stack. A goroutine starts with roughly 2–8 KB of stack that grows and shrinks automatically as needed (up to 1 GB by default). The Go runtime multiplexes goroutines onto OS threads using an M:N scheduler (M goroutines over N threads). Context switches between goroutines happen in user-space and are orders of magnitude cheaper than kernel thread switches.

In production, it is common to have tens of thousands of goroutines running simultaneously.

The scheduler operates on three concepts:

SymbolMeaning
GGoroutine — a unit of concurrent work
MMachine — an OS thread
PProcessor — a scheduling context (GOMAXPROCS of these exist)

Each P holds a run queue of Gs. An M must hold a P to run Gs. When a goroutine blocks on network I/O, the netpoller parks it and the M keeps its P. When it blocks in a system call (file I/O, cgo), the M hands its P to another thread so other goroutines keep running.

When a P’s own run queue runs dry, it does not sit idle — it steals half the Gs from another P’s run queue. This keeps every core busy even when work is unevenly distributed across goroutines, without any coordination from your code.

Work stealing only helps if a G ever gives the scheduler a chance to move it. Before Go 1.14, a goroutine only yielded at specific points — a function call, a channel operation, an allocation — so a tight loop with none of those (for { x++ }) could hog its P forever, starving every other goroutine assigned to it. Go 1.14 added asynchronous preemption: the runtime periodically sends a signal (SIGURG on Unix) to threads running a goroutine that has held its P too long (about 10ms), and the signal handler forces that goroutine to yield at a safe point, even mid-loop. This is why a CPU-bound loop with no function calls is no longer able to lock out the rest of your program the way it could on Go 1.13 and earlier.

These two are easy to conflate but answer different questions:

runtime.NumCPU()runtime.GOMAXPROCS(0)
AnswersHow many logical CPUs does the OS report for this process?How many Ps exist — how many goroutines can run Go code at the same instant?
Can change at runtime?No — read once at process startYes — GOMAXPROCS(n) with n >= 1 changes it immediately
Default relationship—Historically equal to NumCPU(); since Go 1.25 also capped by the cgroup CPU quota in a container, and re-checked periodically
Passing 0not applicablereturns the current value without changing it — the idiomatic way to just read it

runtime.GOMAXPROCS never sets the value below 2 on its own (unless the machine itself has fewer than 2 CPUs available), even under a tight cgroup quota — a detail worth knowing if a container’s CPU limit is a fraction like 0.5.

For a live view of the scheduler’s internal state — not just the P count — run any program with GODEBUG=schedtrace=1000 to print a scheduler snapshot every 1000ms to stderr:

SCHED 0ms: gomaxprocs=11 idleprocs=8 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 [ 1 0 0 0 0 0 0 0 0 0 0 ] schedticks=[ 0 0 0 0 0 0 0 0 0 0 2 ]

(gomaxprocs here is 11 because this trace was captured on an 11-core machine — the exact number always reflects the machine it runs on.) runqueue is the global run queue length; the bracketed list is each P’s local run queue length. Reach for this when goroutines seem stuck and you need to see whether the scheduler is starved of Ps, not just whether your code has a logic bug.

sync.WaitGroup is the standard way to wait for a fixed number of goroutines to finish.

  • wg.Add(n) — increment the counter by n (always before the go statement)
  • wg.Done() — decrement the counter by 1 (call with defer inside the goroutine)
  • wg.Wait() — block until the counter reaches zero

Since Go 1.25 WaitGroup has a Go method that bundles Add(1), the go statement, and the deferred Done() into one call:

var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Go(func() { work(i) })
}
wg.Wait()

wg.Go removes the two classic mistakes — forgetting Add before the go statement, or forgetting Done — and go vet (the waitgroup analyzer, Go 1.25+) flags a misplaced Add. The Add/Done form below still works and is what you will read in older code; the in-browser runner predates wg.Go, so the playground keeps the classic form.

package main
import (
"fmt"
"sync"
)
func worker(id int, results []string, wg *sync.WaitGroup) {
defer wg.Done()
sum := 0
for j := 1; j <= 5; j++ {
sum += j
}
results[id-1] = fmt.Sprintf("worker %d: sum 1..5 = %d", id, sum)
}
func main() {
var wg sync.WaitGroup
results := make([]string, 3)
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, results, &wg)
}
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
fmt.Println("all workers done")
}

A goroutine leak is a goroutine that is still alive but can never make progress and can never exit — it sits parked forever, holding its stack and anything it closed over. Unlike a memory leak from an unreferenced allocation, the garbage collector cannot free it, because the runtime itself still holds a live reference to it. Three causes account for almost every leak in real code:

  1. Blocked on a channel with no counterpart. A goroutine receives from (or sends to) a channel that nothing will ever send to (or receive from) — the most common cause by far.
  2. Blocked on a lock that is never released. A goroutine calls mu.Lock() and the goroutine that should call Unlock() panicked, returned early, or was itself leaked first.
  3. A loop with no exit condition. A for loop meant to run “until cancelled” that never checks ctx.Done() or a stop channel on any iteration.

The runnable program below is cause #1: five goroutines each receive from an unbuffered channel that nothing ever sends to or closes.

func leaky() {
ch := make(chan int) // unbuffered, nobody ever sends
go func() {
<-ch // blocks forever: nothing ever sends on ch or closes it
}()
}
func main() {
before := runtime.NumGoroutine()
for i := 0; i < 5; i++ {
leaky()
}
time.Sleep(50 * time.Millisecond) // let the leaked goroutines actually start
after := runtime.NumGoroutine()
fmt.Println("leaked goroutines:", after-before)
}
leaked goroutines: 5

runtime.NumGoroutine() is the cheapest leak-detection tool there is — a table-driven test that snapshots the count before and after calling the code under test, then asserts it returns to the baseline, catches most leaks in CI before they ever reach production. In production, runtime/pprof’s goroutine profile (or, since Go 1.27, the dedicated goroutineleak profile mentioned below) shows exactly where every live goroutine is blocked.

A panic in one goroutine kills the whole process

Section titled “A panic in one goroutine kills the whole process”

recover() only catches a panic in the same goroutine where it is deferred. A panic in a goroutine you launched with go f() cannot be recovered by a defer/recover() in main or any other goroutine — if that goroutine does not recover its own panic, the entire process terminates immediately, taking down every other goroutine including main, no matter how much unfinished work they had:

func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r) // never reached
}
}()
go func() {
panic("boom")
}()
time.Sleep(100 * time.Millisecond)
fmt.Println("main finished normally") // never reached
}
panic: boom
goroutine 7 [running]:
main.main.func2()
/tmp/goroutine-panic/main.go:15 +0x2c
created by main.main in goroutine 1
/tmp/goroutine-panic/main.go:14 +0x40
exit status 2

The defer/recover() in main never runs — the runtime does not even attempt it, because recover only ever applies to the goroutine currently unwinding. Any goroutine that can panic must recover from its own panic if the program is meant to survive it; a common pattern is a small defer func() { recover() }() at the top of every long-lived worker goroutine, logging the recovered value instead of just discarding it.

go handle(req) once per incoming request is the simplest possible concurrency model, and it works fine — until request volume spikes and the number of in-flight goroutines grows without any ceiling. Each one holds a stack, whatever it captured, and often a database connection or file handle; enough of them exhausts memory or a connection pool long before CPU becomes the bottleneck. The fix is always to put a bound on how many run at once — a fixed-size worker pool, a buffered channel used as a semaphore (see the Channels lesson), or errgroup.SetLimit (covered in the Sync Primitives lesson) — rather than trusting that the input will stay small.

Three runtime changes from Go 1.25–1.27 matter for everyday goroutine code:

  • Container-aware GOMAXPROCS (Go 1.25). On Linux the default now respects cgroup CPU limits, so a pod limited to 2 CPUs on a 64-core node gets GOMAXPROCS=2 instead of 64, and the value is re-checked periodically if the limit changes. The uber-go/automaxprocs workaround is no longer needed; runtime.SetDefaultGOMAXPROCS() restores the default after a manual override.
  • Green Tea garbage collector (default since Go 1.26). A redesigned marking phase with better memory locality cuts GC overhead by 10–40% in real programs. No code changes; the opt-out is GOEXPERIMENT=nogreenteagc.
  • Goroutine leak profile (GA in Go 1.27). runtime/pprof gains a goroutineleak profile — also served at /debug/pprof/goroutineleak by net/http/pprof — that uses the GC’s reachability analysis to list goroutines blocked on a channel or lock that nothing can ever unblock. Tracebacks from go 1.27+ modules also print runtime/pprof goroutine labels, which makes a leaked goroutine easier to attribute.
// in a real server, with the pprof handler mounted on :6060
import _ "net/http/pprof"
// then, with the pprof handler mounted on :6060
// curl "localhost:6060/debug/pprof/goroutineleak?debug=1"
flowchart TD
  subgraph Gs["Goroutines (G) -- thousands, cheap"]
    G1["G"]
    G2["G"]
    G3["G"]
    G4["G"]
  end
  subgraph Ps["Processors (P) -- GOMAXPROCS of these"]
    P1["P1 run queue"]
    P2["P2 run queue"]
  end
  subgraph Ms["OS threads (M)"]
    M1["M1"]
    M2["M2"]
  end
  G1 --> P1
  G2 --> P1
  G3 --> P2
  G4 --> P2
  P1 --> M1 --> CPU0["CPU core 0"]
  P2 --> M2 --> CPU1["CPU core 1"]
  P1 -.P1 idle: steal a G from P2.-> P2
  subgraph IO["When a G blocks on I/O"]
    Gio["G"] -->|"blocks"| WaitQ["waiting queue"]
    Mrel["M"] -.->|"releases P"| Pfree["P"]
    Pfree -.->|"picked up by another M"| Mnew["M"]
    Mnew --> Grun["another G runs"]
  end
The GMP model: goroutines queue on a P, a P must be held by an M to run on a CPU core, and an idle P steals work from a busy one
What you getBenefitCost
goroutine (2-8 KB stack)hundreds of thousands can existper-goroutine scheduler overhead
dynamic stack growthno upfront memory coststack copy cost on growth
M:N schedulerI/O doesn’t block an OS threadscheduler complexity, preemption cost
work stealinghigh CPU utilizationcache misses when a goroutine moves P
  • A goroutine is an OS thread — a goroutine starts with a 2–8 KB stack; an OS thread starts with 1–8 MB
  • Creating a goroutine is entirely free — it starts with a ~2 KB stack allocation plus per-goroutine scheduler bookkeeping
  • More GOMAXPROCS always means faster — I/O-bound workloads benefit less than CPU-bound ones
  • Goroutine leaks are not a real problem — a goroutine with no way to exit is a memory leak

When a generated snippet launches a goroutine, check for these before trusting it:

  • Is wg.Add(1) called before the go statement, in the calling goroutine — never as the first line inside the new goroutine?
  • Does every goroutine have a real way to stop: a loop condition that eventually becomes false, a ctx.Done() case, or a channel it is guaranteed to receive from or send to exactly once?
  • If this goroutine can panic, does anything assume recover() elsewhere (including main) will catch it? It will not — a panic in a goroutine that does not recover its own panic kills the whole process.
  • Is the number of goroutines spawned bounded by a worker pool, semaphore, or errgroup.SetLimit — or does it grow with an unbounded input like one per request or one per line of a file?
  • Is a sync.WaitGroup (or any struct containing one) ever passed or returned by value instead of by pointer?

🐛 Spot the bug

func main() {
var wg sync.WaitGroup
results := make([]int, 5)
for i := 0; i < 5; i++ {
go func(id int) {
wg.Add(1) // BUG: Add happens inside the goroutine
defer wg.Done()
results[id] = id * id
}(i)
}
wg.Wait()
fmt.Println(results)
}
Show the answer

wg.Add(1) has to run in the calling goroutine before wg.Wait() can possibly observe it — here it runs inside each new goroutine instead, so the scheduler is free to reach wg.Wait() while the counter is still 0 because none of the five goroutines have called Add yet. Wait() then returns immediately, main reads results while some of the five goroutines are still writing to it, and go run -race catches the resulting race on results:

WARNING: DATA RACE
Read at 0x00c000140000 by main goroutine:
fmt.Println()
/opt/homebrew/Cellar/go/1.26.5/libexec/src/fmt/print.go:315 +0x1ec
main.main()
/tmp/wgaddbug/main.go:19 +0x88
Previous write at 0x00c000140000 by goroutine 7:
main.main.func1()
/tmp/wgaddbug/main.go:15 +0x94
...
Found 1 data race(s)
exit status 66

The output slice can print with missing or zero entries, or the race detector can report more than one race, depending entirely on scheduling — that unpredictability is itself the tell. The fix is exactly the rule from the Goroutines lesson: call wg.Add(1) once per iteration in the loop, before the go statement.

What is the approximate initial stack size of a new goroutine?
Where must wg.Add(1) be called relative to the go statement?
What happens if main returns while goroutines are still running?
What does GOMAXPROCS control?
What happens to the 5 goroutines started inside leaky?
func leaky() {
    ch := make(chan int)
    go func() {
        <-ch
    }()
}

func main() {
    for i := 0; i < 5; i++ {
        leaky()
    }
    time.Sleep(50 * time.Millisecond)
    fmt.Println(runtime.NumGoroutine())
}
Does the deferred recover() in main catch the panic?
func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    go func() { panic("boom") }()
    time.Sleep(100 * time.Millisecond)
}