Goroutines & the Go Scheduler
What a goroutine really is, how Go's runtime runs a million of them on a few threads (G · M · P, run queues, work stealing, the netpoller, preemption), how to find the ones that leak — and why Go never needed async/await.
The 30-second version
You start one by writing go in front of a function call: go sendEmail(user). The call starts, and your code carries on immediately without waiting for it. That's all the syntax there is.
The interesting part is what happens underneath. Your computer only has a few CPU coresCPU coreOne independent “brain” inside the CPU. Each core runs exactly one thread at any instant, so a 4-core machine runs at most 4 threads truly at the same time., and the operating systemOperating system (OS) / kernelThe core program (Linux, macOS, Windows) that manages hardware, memory, threads, files and network connections for every other program. The kernel is its innermost part. runs code on them through threadsThreadOne worker that runs your code line by line. A program can have several threads, and the operating system decides which one runs on which CPU core.. A Go program may have 100,000 goroutines. Something has to decide which of those 100,000 runs on which of the few threads, and when. That something is the Go scheduler, built into every Go program (it's part of the Go RuntimeRuntimeCode that ships inside every program of a language to support it while it runs. Go's runtime schedules goroutines and manages memory; Node's includes the event loop.).
Cheap to have
Blocking is fine
No async/await
Here's the map of the page:
| Section | You'll learn |
|---|---|
| §02–§03 | What a goroutine is, what it costs, how to start one and wait for it |
| §04–§05 | G · M · P: the scheduler's parts, its queues, and work stealing, in a simulator |
| §06–§07 | What happens when a goroutine waits (system calls, network, timers) or never stops computing (preemption) |
| §08 | GOMAXPROCS, cores and containers (changed in Go 1.25) |
| §09–§11 | Goroutine leaks, why Go doesn't need async/await, and tools to watch the scheduler |
| §12–§14 | Pitfalls, a quiz, and a cheat sheet |
Goroutines vs OS threads: what each one costs
Quick reminder of what a ThreadThreadOne worker that runs your code line by line. A program can have several threads, and the operating system decides which one runs on which CPU core. is: one line of execution, with its own Call stackCall stackThe list of functions that are running right now — each one sits on top of the one that called it. When a function returns, its entry (and its local variables) is thrown away. (the pile of "which function called which, and their local variables"). Threads are created and switched by the operating system'sOperating system (OS) / kernelThe core program (Linux, macOS, Windows) that manages hardware, memory, threads, files and network connections for every other program. The kernel is its innermost part. kernel. Threads vs Processes §03 covers them properly.
A goroutine is also a line of execution with its own call stack. The difference is who manages it and how big it is.
Three differences that matter
Stack size
Creating one
go f() is ordinary Go code: the runtime grabs a small block of memory and puts the goroutine in a queue. No kernel involved.Switching
How a goroutine's stack grows and moves is a story of its own: Stack & Heap §10 shows it step by step. Here, let's measure the cost.
Measuring it
This program starts n goroutines that all just wait, then reports the time it took and how much extra memory the process asked the OS for:
package main
import (
"fmt"
"os"
"runtime"
"strconv"
"sync"
"time"
)
func main() {
n, _ := strconv.Atoi(os.Args[1]) // how many goroutines, from the command line
var before runtime.MemStats
runtime.ReadMemStats(&before) // snapshot of the runtime's memory counters
block := make(chan struct{}) // nobody sends on it until the end, so every goroutine waits
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-block // ← wait here (parked: no CPU, no thread)
}()
}
elapsed := time.Since(start)
var after runtime.MemStats
runtime.ReadMemStats(&after)
fmt.Printf("%d goroutines started in %v (%.0f ns each)\n",
n, elapsed.Round(time.Millisecond), float64(elapsed.Nanoseconds())/float64(n))
fmt.Printf("memory from the OS: +%d MB (≈ %.1f KB per goroutine)\n",
(after.Sys-before.Sys)>>20, float64(after.Sys-before.Sys)/float64(n)/1024)
close(block) // release them all
wg.Wait()
}$ go run cost.go 10000
10000 goroutines started in 20ms (2017 ns each)
memory from the OS: +29 MB (≈ 3.0 KB per goroutine)
$ go run cost.go 100000
100000 goroutines started in 224ms (2235 ns each)
memory from the OS: +263 MB (≈ 2.7 KB per goroutine)
$ go run cost.go 1000000
1000000 goroutines started in 3.199s (3199 ns each)
memory from the OS: +2601 MB (≈ 2.7 KB per goroutine)Read the numbers: about 2.7 KB per goroutine — the 2 KB stack plus the runtime's record for each goroutine. A million waiting goroutines fit in 2.6 GB. Creation took about 2 µs each here, and most of that is the OS handing over fresh memory, which it does in 4 KB chunks called pages. Run a second batch in the same program, reusing that memory, and it drops to 0.6–0.8 µs per goroutine. Timing numbers vary from run to run; the memory per goroutine doesn't.
Now the same question for real OS threads. (Measured with a tiny C program using pthreads, the standard Linux thread API, since Go doesn't let you create raw threads directly.)
Two things to take from this:
- Thread memory is mostly a reservation. 8 MB per thread sounds terrifying, but it's Address spaceAddress spaceThe range of memory addresses a process can use — its private map of memory. Every process gets its own; all threads of a process share one., not RAM. The real cost of an idle thread here was about 24 KB (8 KB of stack actually touched, plus 16 KB the kernel keeps per thread). That's still about 9× a goroutine — and a thread that does real work touches more of its stack.
- The hard limit comes first. Thread creation failed at 32,417 threads on this machine, because every thread needs a kernel ID and the kernel's pid_maxpid_maxA Linux kernel setting: the highest process/thread ID it will hand out. Every thread needs its own ID, so it also caps how many threads can exist at once (the default is 32,768 on older or minimal systems; many modern distributions use 4,194,304). was 32,768. You can raise such limits, but 1,000,000 threads isn't a plan. 1,000,000 goroutines is just 2.6 GB.
go f(), and waiting for goroutines to finish
go f() starts f and returns immediately. Nothing waits for it — not even the end of the program. If you need it to finish, you have to wait for it yourself, usually with a sync.WaitGroup.Starting one
Put go in front of any function call:
package main
import (
"fmt"
"time"
)
func say(msg string) {
fmt.Println(msg)
}
func main() {
go say("hello from a goroutine") // starts say, doesn't wait for it
fmt.Println("hello from main")
time.Sleep(10 * time.Millisecond) // a crude way to wait — see below for the right way
}$ go run hello.go
hello from main
hello from a goroutineNotice the order: main printed first, even though the goroutine was started first. go puts the new goroutine in a queue; main keeps running on its thread, and the new goroutine gets its turn a moment later. You can't rely on the order between goroutines unless you make them coordinate.
The trap: main doesn't wait
Remove the Sleep and run it again:
func main() {
go fmt.Println("hello from a goroutine") // any call can follow go
fmt.Println("hello from main")
} // main returns → the whole program exits, goroutines or not$ go run exit.go
hello from main
$ go run exit.go
hello from main
… (same three more times)The goroutine usually doesn't get to print (once in a while it squeezes its line in first). When main returns, the program exits, and every goroutine still running or waiting simply stops. There's no "wait for background work" step. The Sleep above "worked" only because 10 ms happened to be enough — on a busy machine it might not be.
Waiting properly: sync.WaitGroup
A sync.WaitGroupsync.WaitGroupA counter for “how many goroutines am I waiting on?”: Add(n) before starting them, Done() when each finishes, Wait() blocks until it reaches zero. is a counter. Add(1) before you start a goroutine, Done() when it finishes, and Wait() blocks until the counter is back to zero:
func fetch(id int) {
time.Sleep(time.Duration(100*id) * time.Millisecond) // pretend to call a slow API
fmt.Println("fetched", id)
}
func main() {
start := time.Now()
var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
wg.Add(1) // one more to wait for — BEFORE the go statement
go func() {
defer wg.Done() // "I'm finished" — runs however this function ends
fetch(id)
}()
}
wg.Wait() // blocks until all three called Done
fmt.Println("all done in", time.Since(start).Round(10*time.Millisecond))
}$ go run wait.go
fetched 1
fetched 2
fetched 3
all done in 300msThree fetches of 100, 200 and 300 ms took 300 ms in total, not 600: they waited at the same time. Here's the timeline:
main is blocked in Wait() only as long as the slowest one. The prints came out 1, 2, 3 because the sleeps finish in that order — not because the goroutines started in that order.Go 1.25+: wg.Go
Add, go, defer Done is so common that Go 1.25 added a method that does all three:
var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
wg.Go(func() { fetch(id) }) // = wg.Add(1); go func() { defer wg.Done(); fetch(id) }()
}
wg.Wait()Same output as before. Use it whenever your go.mod says go 1.25 or later; it makes the "forgot to Add" bug impossible.
Getting results back
A goroutine has no return value you can collect: x := go f() doesn't compile. To get data out, you either write into a variable each goroutine owns (like results[i] = …) and read it after Wait(), or send it over a ChannelChannelA typed pipe between goroutines. Send with ch <- v, receive with v := <-ch. By default the sender waits until a receiver takes the value.. Channels are a whole topic: Channels & select. If the goroutines can fail, errgrouperrgroupA helper package (golang.org/x/sync/errgroup): like a WaitGroup that also collects the first error and can cancel the other goroutines. is a WaitGroup that also collects the first error.
G, M and P: the scheduler's three pieces
GOMAXPROCS Ps.Running many goroutines on a few threads is a scheme usually called M:N schedulingM:N schedulingRunning many lightweight tasks on a few OS threads, with the language runtime deciding which task runs on which thread (the name just means "many onto few"). Go's goroutines work this way. (many tasks on a few threads). Go's version of it is described with three letters of its own, the G · M · PG · M · PThe three things Go's scheduler juggles. G = a goroutine (a task). M = an OS thread (a worker; M stands for “machine”). P = a “processor”: a permission slip to run Go code, with its own queue of goroutines waiting their turn. A thread must hold a P to run Go code. model. In the kitchen picture from §01: G is a ticket, M is a cook, P is a stove.
G — the goroutine
A G is a small record the runtime keeps for each goroutine: a pointer to its stack, where in the code it is (the next instruction to run), and its status. At any moment a goroutine is in one of three main states:
| State | Meaning | Kitchen |
|---|---|---|
| running | executing on a thread right now | a cook is working on this ticket |
| runnable | ready to go, waiting in a Run queueRun queueA waiting line of goroutines that are ready to run and just need a turn on a thread. Each P has its own small local run queue (256 slots); there is also one global run queue shared by all Ps. for its turn | ticket on the rail |
| waiting | can't continue until something happens: data on a channel, a network reply, a timer, a lock | ticket on the "waiting for delivery" spike |
A waiting goroutine is parkedParking a goroutineTaking a goroutine that has to wait off the CPU and setting it aside, so the thread can run other goroutines. A parked goroutine costs no CPU until something wakes it.: it isn't in any run queue and uses no CPU. Whatever it's waiting for (a channel, a timer, the network) keeps a note to put it back in a run queue later.
M — the thread
An M ("machine") is a real OS ThreadThreadOne worker that runs your code line by line. A program can have several threads, and the operating system decides which one runs on which CPU core.. The runtime creates them as needed and reuses them; a typical Go server has a dozen or so. Ms are the only things the OS knows about — the OS has no idea goroutines exist.
P — the processor
A P is not hardware; it's a runtime object, best thought of as a slot for running Go code. There are exactly GOMAXPROCSGOMAXPROCSHow many OS threads may run Go code at the same instant. Defaults to the number of CPU cores, or, on Linux since Go 1.25, the container's CPU limit (rounded up, minimum 2) if that is lower. of them (by default, one per CPU core — §08). Each P owns:
- a local run queue of runnable goroutines (up to 256),
- a few private caches (such as a memory cache for small allocations), so the goroutines running on it don't fight over shared data.
The rule that ties them together: an M must hold a P to run Go code. So at most GOMAXPROCS goroutines are running Go code at the same instant. An M without a P is either asleep, or stuck inside a system callSystem call (syscall)A request from your program to the operating system — “read this file”, “open a connection”. While a blocking syscall runs, the calling thread waits inside the OS. (§06).
Watch it run
The simulator below follows these rules. Start with the guided tour: it spawns goroutines, overflows a queue, preempts a long-running goroutine, blocks one in a system call, parks one on the network, and ends with work stealing. Then switch to Free play and break it. (Each P also has a one-goroutine run next slot for the newest goroutine; the tour uses it, and §05 explains why it exists.)
main) on thread M0. P1 has nothing to do, so no thread is attached to it.What to notice:
- Spawning is cheap and local.
go f()just puts a G on the current P: in its run-next slot, pushing the previous occupant into the local queue (§05). No thread is created unless an idle P needs one to run on. - A full local queue spills to the global queue. Half of it moves to the Global run queueGlobal run queueThe one run queue shared by every P. Goroutines land there when a local queue overflows, when they are preempted, or when nobody else can take them. A P checks it when its own queue is empty (and now and then anyway, so it's never ignored)., so the queue never grows without limit and other Ps can help.
- Threads follow work. A P with nothing to do gives up its thread (the thread sleeps); a P that finds work wakes or creates one.
- A blocking system call costs a thread, not a P (the "handoff" step) — §06 explains how the runtime notices.
- A network wait costs neither. The goroutine parks on the NetpollerNetpollerThe part of the Go runtime that uses epoll/kqueue to wait for network data, so goroutines can “block” on the network without blocking an OS thread. and the same thread runs the next one.
The next section zooms in on the queues: in exactly what order a P looks for work, and how stealing works.
Run queues, “run next”, and work stealing
Where a P looks for its next goroutine
Every time the goroutine on a P finishes, parks, or is preempted, the P needs another one. The runtime looks in a fixed order:
- 1Every 61st turn: the global queue first — so goroutines parked there can never be starved by busy local queues
- 2Its run-next slot, then its own local run queue — the cheap, common case: no locks, no other Ps involved
- 3The global run queue — takes a small batch, not just one
- 4The netpoller — any goroutines whose network data has arrived? (non-blocking check)
- 5Steal from another P — up to 4 rounds over the other Ps in random order; takes half of a victim's queue
- 6Give up: park the thread — the P goes on the idle list and the thread sleeps. One sleeping thread may instead wait inside epoll_wait, so it wakes the moment network data arrives or the next timer is due
findRunnable function (simplified; timers and garbage-collection work are also checked along the way). Steps 2–3 are what most turns hit.Why this order? Cheapest first. The local queue belongs to this P alone, so taking from it is nearly free. The global queue is shared, so it needs a LockLock (mutex)A “do not disturb” sign for a piece of data: only the task holding the lock may touch it; others wait their turn. (only one P at a time may touch it). Stealing touches other Ps' queues. And the "every 61st turn" rule at the top is about fairness: without it, two goroutines that keep waking each other up could keep a P's local queue busy forever, and goroutines waiting in the global queue would never get a turn.
The "run next" slot
Each P has one more spot besides its queue: a single run nextRun next (runnext)A one-goroutine slot on each P, checked before its local run queue. A goroutine created with go f() (or just woken up by a channel send) goes there, and whatever was in the slot moves to the back of the queue. So the newest goroutine usually runs next. slot. When a goroutine starts another one with go f(), the new goroutine goes into runnext, pushing whatever was there to the back of the local queue. When the P next picks a goroutine, runnext wins.
The idea: a goroutine you just created (or just woke up by sending it a value) is probably about to be needed, and its data is still in the CPU's cache. You can see the effect with one P:
func main() {
runtime.GOMAXPROCS(1) // one P, so only its queue matters
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Print(i, " ")
}()
}
wg.Wait() // main parks here; the P picks its next goroutine
fmt.Println()
}$ go run runnext.go
4 0 1 2 3
4 0 1 2 3
4 0 1 2 3
4 0 1 2 3
1 0 4 2 3Goroutine 4 was started last, so it sat in runnext; 0–3 were in the queue in order. When main parked in Wait(), the P ran runnext first, then the queue. And then the fifth run came out differently. The runtime makes no promise about order, and real programs have far more going on. Treat this as a peek inside, never as something to rely on.
Work stealing
When a P's own queue and the global queue are both empty, it becomes a thief. Work stealingWork stealingEach worker has its own queue; a worker whose queue is empty takes (steals) work from a busy worker's queue instead of sitting idle. Go's scheduler does this with goroutines. works like this:
- Visit the other Ps in random order (so thieves don't all pile onto P0).
- Find one with goroutines in its queue and take half of them in one go — not one at a time, so the thief won't have to come back right away.
- Run one, keep the rest in its own queue.
- Try up to four rounds. Still nothing? Go to sleep.
A thread that holds a P and is busy hunting for work like this is a Spinning threadSpinning threadA thread that has a P but no goroutine, and is actively hunting for work (checking queues, trying to steal) instead of going to sleep. Go keeps at most a few spinning so new work is picked up fast without wasting CPU.. Spinning burns CPU while finding nothing, so the runtime caps it at roughly half the busy Ps; the other idle threads sleep. In the other direction, whenever new work appears and nobody is spinning, the runtime wakes one thread to go looking. Together that means new goroutines get picked up within microseconds, without a crowd of threads burning CPU on empty queues.
When a goroutine waits: syscalls, the netpoller and timers
"My goroutine is waiting" can mean four different things to the runtime:
| Waiting for… | What the runtime does | Costs a thread? |
|---|---|---|
A channel, select, sync.Mutex, WaitGroup | parks the goroutine; whoever sends / unlocks puts it back in a run queue | No |
The network (conn.Read, http.Get, a database driver) | parks it on the netpoller | No |
A timer (time.Sleep, time.After, a context deadline) | parks it; the P's timer list wakes it on time | No |
A blocking system call (some file I/O, syscall.*) or C code via cgocgoGo's mechanism for calling C code. Go's cgo DNS resolver uses it to call the system's getaddrinfo; the pure-Go resolver doesn't need C at all. | the thread is stuck in the kernel with it; the P is handed off | Yes, while it lasts |
The netpoller: why a network wait doesn't block a thread
When you call conn.Read(buf) and no data has arrived yet, Go doesn't let the thread wait in the kernel. Under the hood every network SocketSocketThe OS's handle for one open network connection. Your program reads and writes bytes through it. is in non-blocking mode, so the read returns at once with "nothing yet". The runtime then:
- registers "wake this goroutine when this socket has data" with the NetpollerNetpollerThe part of the Go runtime that uses epoll/kqueue to wait for network data, so goroutines can “block” on the network without blocking an OS thread., which is built on the OS's epoll / kqueue / IOCPepoll / kqueue / IOCPOS features that let one thread say “here are 10,000 connections — wake me when any of them has data”. epoll is Linux, kqueue is macOS/BSD, IOCP is Windows. (kqueue on macOS),
- parks the goroutine, and
- lets the thread pick the next goroutine from its P.
Whenever a P runs out of work (step 4 of §05's list) — and at least every 10 ms via a background check — the runtime asks epoll "which sockets are ready?" and puts those goroutines back in a run queue. Then conn.Read simply returns with the data. To your code it looks like an ordinary blocking call.
Compare that with the alternatives on six connections:
The netpoller gets close to "one thread per connection" speed (140 ms vs 110 ms in this toy) while using one thread. At 6 connections it doesn't matter. At 10,000 it decides whether your server fits in memory. Here's the real thing: 5,000 goroutines, each waiting on its own TCP connection that never sends anything.
package main
import (
"fmt"
"net"
"os"
"runtime"
"strings"
"time"
)
// threads reads this process's OS thread count from Linux (/proc/self/status).
func threads() string {
status, _ := os.ReadFile("/proc/self/status")
for _, line := range strings.Split(string(status), "\n") {
if v, ok := strings.CutPrefix(line, "Threads:"); ok {
return strings.TrimSpace(v)
}
}
return "?"
}
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0") // a local server on any free port
if err != nil {
panic(err)
}
var keep []net.Conn // hold the server side of each connection open
go func() { // the server: accept connections, never send anything
for {
conn, err := ln.Accept()
if err != nil {
return
}
keep = append(keep, conn)
}
}()
const n = 5000
for i := 0; i < n; i++ {
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
panic(err)
}
go func() {
buf := make([]byte, 1)
conn.Read(buf) // ← waits forever: the server never writes
}()
}
time.Sleep(time.Second)
fmt.Println("goroutines:", runtime.NumGoroutine())
fmt.Println("OS threads:", threads())
}$ go run netpark.go
goroutines: 5002
OS threads: 85,000 goroutines blocked on the network, 8 threads. (5,002 = the 5,000 readers + main + the accept loop. The exact thread count varies a little from run to run. Each connection uses two file descriptors, one per end; if you get "too many open files", raise the limit with ulimit -n 20000 — File Descriptors §07 explains.)
Timers
time.Sleep, time.After, time.Ticker and context.WithTimeout all use runtime timers. Each P keeps its own timers in a heap (a structure that always knows the soonest deadline). When the scheduler looks for work it first fires any timers that are due, making their goroutines runnable. If there's nothing at all to do, a thread waits inside epoll_wait (the system call that asks epoll "wake me when any socket is ready") with a timeout set to the next deadline — so one call wakes it for either "network data arrived" or "a timer is due".
That's why sleeping goroutines are as cheap as network waits. Compare 1,000 goroutines sleeping with time.Sleep against 1,000 asking the kernel to sleep directly:
func main() {
raw := len(os.Args) > 1 && os.Args[1] == "raw"
for i := 0; i < 1000; i++ {
go func() {
if raw {
ts := syscall.Timespec{Sec: 5}
syscall.Nanosleep(&ts, nil) // ← asks the kernel to sleep: the thread is stuck
} else {
time.Sleep(5 * time.Second) // ← a runtime timer: the goroutine parks
}
}()
}
time.Sleep(time.Second)
fmt.Println("1000 sleeping goroutines; OS threads:", threads()) // threads() as in netpark.go
}$ go run sleepers.go
1000 sleeping goroutines; OS threads: 6
$ go run sleepers.go raw
1000 sleeping goroutines; OS threads: 1005Same goroutines, same sleep, 6 threads vs 1,005. The raw system call blocks inside the kernel, and the runtime can't run anything else on a thread that's stuck there.
Blocking system calls: the handoff
So what exactly happens to the P when its thread disappears into a slow System callSystem call (syscall)A request from your program to the operating system — “read this file”, “open a connection”. While a blocking syscall runs, the calling thread waits inside the OS.?
- Entering the call, the goroutine marks itself as "in a system call" and keeps its P. Most system calls (a write to a pipe, reading the clock) finish in a few microseconds, and handing the P over every time would cost more than it saves.
- The watchdog notices. sysmonsysmonThe Go runtime's background watchdog: a thread that runs without a P and wakes up every 20 µs to 10 ms. It takes Ps away from threads stuck in system calls, preempts goroutines that run too long, and polls the network if nobody has lately., a runtime thread that runs without a P, wakes up every 20 µs to 10 ms. If a goroutine has been in the same system call for at least one of its ticks, and there's work waiting in that P's queue (or no other P is free to pick up new work), sysmon takes the P away and gives it to another thread — a sleeping one, or a new one. After ~10 ms it does so regardless. That's the HandoffHandoff (of a P)When a goroutine's thread gets stuck inside a slow system call, the runtime (its sysmon watchdog, after 20 µs to 10 ms) takes the P away from that thread and gives it to another thread, so the goroutines waiting for that P keep running.. (A few calls the runtime knows will block for a long time hand off right away.)
- When the call returns, the thread tries to get a P back: its old one if it's free, otherwise any idle one. If every P is busy, the goroutine goes on the global run queue and the thread goes to sleep.
That's how the raw Nanosleep program ended up with 1,005 threads: 1,000 stuck in the kernel, and a few more holding the Ps. The System Calls topic shows the same effect with pipe reads, and why plain file reads take this path even in normal Go code.
Preemption: stopping a goroutine that won't stop
Everything so far relied on goroutines stopping now and then: finishing, waiting on a channel, the network or a timer. Each of those is a moment where the scheduler gets control back. But what about a goroutine that just computes?
for { } // or: crunching numbers in a loop for 5 seconds, never calling anythingIf nothing ever interrupts it, it keeps its P, and with GOMAXPROCS=1 nothing else in the program ever runs again. Giving a long-running task's turn to someone else by force is called PreemptionPreemptionForcibly pausing a running task so others get a turn. The OS does this to threads when their time slice ends; Go's runtime does the same to goroutines that hog a CPU for more than ~10 ms..
Before Go 1.14: cooperative, at function calls
Go used to be cooperative (Cooperative schedulingCooperative schedulingA task only gives up the CPU at points where it checks in (in old Go: at a function call). A loop that never reaches such a point can hog the CPU forever. The opposite is preemptive scheduling, where the scheduler can interrupt anywhere.). Recall from Stack & Heap §10 that every Go function starts with a tiny check: "is there room on my stack?" The runtime reused that check. To ask a goroutine to stop, sysmonsysmonThe Go runtime's background watchdog: a thread that runs without a P and wakes up every 20 µs to 10 ms. It takes Ps away from threads stuck in system calls, preempts goroutines that run too long, and polls the network if nobody has lately. set a flag that made the check fail; the next time the goroutine called a function, it noticed and yielded its P.
That worked for almost all code, because almost all code calls functions all the time. But a tight loop that never calls anything — or only calls functions the compiler inlinedInliningA compiler optimization that pastes a small function's body into its caller instead of making a real call. It saves the call, and often lets escape analysis see that a value doesn't really leave. — never reaches a check. Real programs hit this: a loop decoding a big buffer, a busy-wait. The result was a stalled program, or a garbage collection that couldn't start because it needed every goroutine to pause briefly.
Go 1.14+: asynchronous, with a signal
Go 1.14 added Asynchronous preemptionAsynchronous preemptionGo 1.14+: the runtime interrupts a goroutine that has run for more than ~10 ms by sending its thread a signal (SIGURG). The signal handler pauses the goroutine wherever it is, even inside a loop with no function calls.. When sysmon sees that a goroutine has been running for more than ~10 ms:
- It sends the goroutine's thread an OS signalSignal (Unix)A tiny message the OS delivers to a running process, e.g. SIGINT (Ctrl+C), SIGTERM (“please shut down” — what kill and Kubernetes send first), SIGKILL (stop immediately; can't be caught) or SIGQUIT (Ctrl+\, Go prints every goroutine's stack). Send one with kill -TERM <pid>.,
SIGURG(chosen because hardly any program uses it for anything else). - The thread's signal handler, inside the Go runtime, checks whether the exact instruction it interrupted is a Safe pointSafe pointA place in running code where the runtime can safely pause a goroutine, because it knows exactly what's on its stack. Function entries were the classic safe points; since Go 1.14 almost every instruction is one., a spot where the runtime knows precisely what's on the stack. Thanks to extra information the compiler now records, almost every instruction is.
- If it is, the handler makes the goroutine "call" a small runtime function at that spot, which saves everything and puts the goroutine on the global run queue. The P is free for someone else.
Try the three cases:
func spin() {
for { } // no function calls inside
}
func main() {
runtime.GOMAXPROCS(1) // one P
go spin()
time.Sleep(10 * time.Millisecond)
fmt.Println("main got the CPU back")
}main got the CPU back
spin has held the P for over 10 ms and sends its thread a SIGURG signal. The signal handler pauses spin right where it is, mid-loop, and main runs. Real run: the program exits after ~13 ms.And here's the same program, for real:
func spin() {
for { // a tight loop: no function calls, no channel operations
}
}
func main() {
runtime.GOMAXPROCS(1) // one P: only one goroutine can run at a time
go spin()
time.Sleep(10 * time.Millisecond) // main parks; spin gets the only P
fmt.Println("main got the CPU back")
}$ time ./spin
main got the CPU back
real 0m0.013s
$ GODEBUG=asyncpreemptoff=1 timeout 5 ./spin # switch async preemption off
$ echo $?
124With asynchronous preemption, main gets its turn back after about 13 ms. With it switched off (the GODEBUGGODEBUGAn environment variable that turns Go runtime debug output and compatibility switches on or off, e.g. GODEBUG=schedtrace=1000 or GODEBUG=asyncpreemptoff=1. Several settings are separated by commas. setting asyncpreemptoff=1), the program prints nothing and timeout has to kill it after 5 seconds: exit code 124 means "timed out".
GOMAXPROCS: cores, containers, and the Go 1.25 change
Reading and setting it
func main() {
fmt.Println("CPUs:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0 = just read it, don't change it
}$ go run procs.go
CPUs: 4
GOMAXPROCS: 4
$ GOMAXPROCS=2 go run procs.go # the environment variable overrides the default
CPUs: 4
GOMAXPROCS: 2
$ taskset -c 0 go run procs.go # only allow CPU 0 (Linux)
CPUs: 1
GOMAXPROCS: 1taskset restricts which CPUs a process may run on, and Go has always respected that. What it didn't respect, until Go 1.25, was a CPU limit.
The container problem
A ContainerContainerA normal Linux process (or a small group of them) that the kernel fences off: it sees only its own files, processes and network, and gets a cap on how much CPU and memory it may use. Docker and Kubernetes run containers. usually doesn't get a set of dedicated CPUs. It gets a CPU limitCPU limit (container)A cap on how much CPU time a container may use, e.g. “1.5 CPUs” = 150 ms of CPU time every 100 ms. Kubernetes sets it from each container's resources.limits.cpu. Use more and the kernel pauses (throttles) the container until the next 100 ms period.: "1.5 CPUs" means "at most 150 ms of CPU time in every 100 ms window, spread over any of the machine's cores". The Linux feature behind it is cgroupscgroupsControl groups: a Linux kernel feature that limits how much CPU, memory and how many processes a group of processes may use. It controls how much a container can use.. In KubernetesKubernetesA system that runs your containers across many machines: it decides where each one runs, restarts them when they crash, rolls out new versions, and gives each group a stable name other services can call. it's each container's resources.limits.cpu.
Say the container has a 1.5-CPU limit and runs on a 64-core machine. Before Go 1.25, a Go program in it still saw all 64 cores and created 64 Ps. Here's what that does, in numbers:
- During a busy moment (say, a garbage collection, or a burst of requests) all 64 Ps run at once: that burns 64 ms of CPU time per real millisecond.
- The 150 ms budget for this 100 ms window is gone after about 2.3 ms.
- The kernel then pauses the whole container for the remaining ~98 ms of the window. That pause is called CPU throttlingCPU throttlingWhat happens when a container hits its CPU limit: the kernel pauses it until the next 100 ms period. Nothing crashes, but requests that needed CPU just then get slower..
So a request that needed 5 ms of CPU could take 100 ms because it landed in a pause. With 2 Ps the program can burn at most 2 ms of CPU time per millisecond. If it stays that busy, the 150 ms budget still runs out, but only after ~75 ms, and the pause lasts ~25 ms instead of ~98. Matching GOMAXPROCS to the limit doesn't make throttling impossible. It makes the pauses rarer (you need a longer stretch of overload to hit them) and much shorter.
We checked this on the 4-core test machine with a 1.5-CPU limit: one goroutine per P spinning for 3 seconds, timing every gap in its progress (a gap = the container was frozen). Two runs of each:
GOMAXPROCS=4: frozen 58–67% of the time, longest pause 62–68 ms
GOMAXPROCS=2: frozen 23–24% of the time, longest pause 26–28 msThat's the ~62 ms and ~25 ms the arithmetic predicts. Before Go 1.25, teams worked around this by setting GOMAXPROCS by hand or adding the go.uber.org/automaxprocs library.
What Go 1.25 changed
From the Go 1.25 release notes, two changes to the default:
- On Linux, the default respects the cgroup CPU limit. If the limit is lower than the number of CPUs,
GOMAXPROCSdefaults to the limit. The limit is rounded up to a whole number, and never goes below 2 (from the runtime source). It does not look at Kubernetes CPU requests, only limits. - On all systems, the runtime re-checks periodically and updates
GOMAXPROCSif the number of CPUs or the limit changes while the program runs.
Both are switched off if you set GOMAXPROCS yourself (the environment variable or a runtime.GOMAXPROCS(n) call), and both are tied to the go line in your Go moduleGo moduleA versioned set of Go packages defined by a go.mod file — roughly what an npm package is in JavaScript. Other projects import your module by its path, e.g. github.com/you/app.'s go.mod: a program whose go.mod says go 1.24 keeps the old behavior even when built with a newer Go.
Here's the same procs.go, built with Go 1.27 (go 1.27 in go.mod), on the 4-CPU test machine, inside a cgroup with different CPU limits. This is how the limit was set (as root, on a machine using cgroup v1; docker run --cpus=1.5 sets the same kind of limit):
mkdir /sys/fs/cgroup/cpu/demo
echo 150000 > /sys/fs/cgroup/cpu/demo/cpu.cfs_quota_us # 150 ms per 100 ms period = 1.5 CPUs
echo $$ > /sys/fs/cgroup/cpu/demo/cgroup.procs # move this shell into the cgroup
./procslimit 1.5 CPUs → GOMAXPROCS: 2 (rounded up)
limit 0.5 CPUs → GOMAXPROCS: 2 (minimum 2)
limit 3 CPUs → GOMAXPROCS: 3
limit 1.5, GOMAXPROCS=4 set by hand → GOMAXPROCS: 4 (you win)
limit 1.5, GODEBUG=containermaxprocs=0 → GOMAXPROCS: 4 (feature off)
no limit → GOMAXPROCS: 4
limit 1.5, go.mod says go 1.24 → GOMAXPROCS: 4 (old behavior)And the periodic update, with a program that prints GOMAXPROCS every 2 seconds while we lower the limit from 3 to 1.5 CPUs:
GOMAXPROCS: 3
GOMAXPROCS: 3
GOMAXPROCS: 3
(limit lowered to 1.5 CPUs)
GOMAXPROCS: 2
GOMAXPROCS: 2Goroutine leaks: finding the ones that never finish
A leak in nine lines
Here's a common pattern: ask three replicas the same question and take whichever answers first.
// firstResult asks three replicas and returns whichever answers first.
func firstResult() (string, error) {
ch := make(chan string) // unbuffered: a send waits until someone receives
for _, name := range []string{"a", "b", "c"} {
go func() {
time.Sleep(10 * time.Millisecond) // pretend to call replica `name`
ch <- "answer from " + name // ← the two losers wait here forever
}()
}
select {
case r := <-ch:
return r, nil // we take ONE value and leave
case <-time.After(time.Second):
return "", errors.New("timeout")
}
}
func main() {
fmt.Println("goroutines at start:", runtime.NumGoroutine())
for i := 0; i < 1000; i++ { // e.g. 1000 requests to a server
firstResult()
}
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutines after 1000 calls:", runtime.NumGoroutine())
}$ go run leak.go
goroutines at start: 1
goroutines after 1000 calls: 2001Each call receives one value and returns. The other two goroutines are still trying to send on ch, and nobody will ever receive from it again. They're parked forever, each holding its stack and everything its variables point to. In a server, that's two more stuck goroutines per request until the process runs out of memory.
The fixes, from simplest:
- Leave room for every answer:
make(chan string, 3). With a buffer of 3, every send succeeds immediately even if nobody receives; the goroutines finish, and the channel is garbage-collected. - Give the goroutines a stop signal: pass a context.Contextcontext.ContextA value passed as the first argument through Go calls that carries a deadline and a “stop now” signal. ctx.Done() is a channel you can receive from once it's time to stop. and
selectonctx.Done()next to the send, then cancel when you return. Channels §08 builds this step by step.
Finding leaks in a running program
1. Watch the count. Export runtime.NumGoroutine() as a metric. A healthy service's count goes up and down with traffic; a leak is a line that only goes up, even at night.
2. Look at where they're stuck: the goroutine profile. The standard pprofpprofGo's built-in profiler and its viewer (go tool pprof). A heap profile shows which lines of code allocated the memory that is still in use. package can list every goroutine, grouped by the line where it's waiting. In a server you'd import net/http/pprof and open /debug/pprof/goroutine?debug=1; in a small program you can print it directly:
pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) // from "runtime/pprof"goroutine profile: total 2001
2000 @ 0x47f06e 0x41491c 0x414517 0x4db129 0x485601
# 0x4db128 main.firstResult.func1+0x68 /tmp/goroutines/leak/main.go:18
…Read it as: 2,000 goroutines are all stopped at line 18 of main.go (the full file, with its package and import lines, where line 18 is the ch <- send), inside the anonymous function in firstResult (func1). The hex numbers are code addresses; ignore them. With debug=2 you get every goroutine separately, with what it's waiting for:
goroutine 33 [chan send]:
main.firstResult.func1()
/tmp/goroutines/leak/main.go:18 +0x69
created by main.firstResult in goroutine 1
/tmp/goroutines/leak/main.go:16 +0xa5[chan send] is the reason it's parked, and "created by" tells you who started it. When the same line shows up thousands of times, you've found your leak.
3. The leak profile (Go 1.27+). Go 1.27 has a goroutineleak profile, read with pprof.Lookup("goroutineleak") or /debug/pprof/goroutineleak. It uses the garbage collector's view of memory: if a goroutine is waiting on a channel (or mutex, or condition variable) that no running goroutine can reach anymore, nobody can ever wake it, so it's reported as leaked. Same program, with pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1) as its last line:
$ go run .
goroutines at start: 1
goroutines after 1000 calls: 2001
goroutineleak profile: total 2000
2000 @ 0x47f06a 0x41421c 0x413e17 0x4e0909 0x484ec1
# 0x4e0908 main.firstResult.func1+0x68 /tmp/goroutines/leak127/main.go:18Unlike the plain goroutine profile, it lists only the stuck ones: the main goroutine isn't counted. It can miss leaks whose channel is still reachable, for example through a global variable or a goroutine that is still running.
Catching leaks in tests
The cheapest place to catch a leak is a test. Without any extra library, compare the goroutine count before and after:
// checkNoLeaks fails the test if goroutines started during it are still alive at the end.
func checkNoLeaks(t *testing.T) {
before := runtime.NumGoroutine()
t.Cleanup(func() { // runs when the test finishes
deadline := time.Now().Add(time.Second)
for runtime.NumGoroutine() > before { // give stragglers a moment to exit
if time.Now().After(deadline) {
t.Errorf("goroutine leak: %d before, %d after", before, runtime.NumGoroutine())
return
}
time.Sleep(10 * time.Millisecond)
}
})
}
func TestFirstResult(t *testing.T) {
checkNoLeaks(t)
firstResult() // the unbuffered version
}--- FAIL: TestFirstResult (1.02s)
fetch_test.go:16: goroutine leak: 2 before, 4 afterThis simple version can be confused by goroutines from other tests running in parallel. The popular go.uber.org/goleak library (a third-party dependency) does it properly: it checks the stacks of the goroutines that remain, not just the count. Add defer goleak.VerifyNone(t) to a test, or call goleak.VerifyTestMain(m) once for a whole package.
Or use testing/synctest (standard library, Go 1.25+). testing/synctesttesting/synctestA Go standard-library package (stable since Go 1.25) that runs a test inside a “bubble” with its own fake clock: time.Sleep and timers inside the bubble jump forward instantly once every goroutine in it is waiting. runs the test in a "bubble" with a fake clock, so the 10 ms sleeps take no real time — and when the test function returns while goroutines in the bubble are still blocked, it fails:
func TestFirstResult(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
firstResult()
})
}--- FAIL: TestFirstResult (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]With make(chan string, 3) the same test passes instantly (0.00s): the 10 ms sleeps run on the fake clock, so no real time passes.
Why Go doesn't need async/await
async, and that mark spreads to every caller. Go doesn't need the mark, because each goroutine has its own stack: any function can wait, and the scheduler from §04–§06 runs something else meanwhile.The same program, both ways
Get one greeting, then three at once. Each greeting waits 100 ms for a pretend network call.
type User struct{ Name string }
// An ordinary function. It waits, and nobody has to know.
func getUser(id int) User {
time.Sleep(100 * time.Millisecond) // pretend network call
return User{Name: fmt.Sprint("user", id)}
}
func greeting(id int) string { // calls a waiting function — still ordinary
return "hi " + getUser(id).Name
}
func main() {
start := time.Now()
fmt.Println(greeting(1)) // one at a time: just call it
var wg sync.WaitGroup // concurrency is chosen HERE, by the caller
out := make([]string, 3)
for i := range 3 { // i = 0, 1, 2
wg.Go(func() { out[i] = greeting(i + 2) })
}
wg.Wait()
fmt.Println(out, time.Since(start).Round(10*time.Millisecond))
}
// Output: hi user1
// [hi user2 hi user3 hi user4] 200msBoth print the same thing and take about 200 ms (100 for the first greeting, 100 for the three together). The difference is in the signatures. In JavaScript, getUser waits, so it must be async; then greeting calls it, so it must be async; and so would every function above it. In Go, getUser and greeting are plain functions, and the only place concurrency shows up is in main, where the caller decided to run three at once.
Function coloring
This spreading is known as Function coloringFunction coloringThe problem that async functions are “contagious”: to await one, a function must itself be async, so its callers must be too, and so on up. Functions end up in two “colors” (async and normal) that don't mix freely., after a 2015 blog post that described async functions as "red" and normal ones as "blue". The rules: you can call blue from red freely, but calling red from blue means dealing with a Promise you can't simply wait on. So once one function deep in your code becomes async, every function on the path up to it has to change color too. Async / Await §09 has an animation of the spreading.
Go has one color. A function that does I/O looks exactly like one that doesn't. That's possible because of what gets set aside when a task waits:
conn.Read changed.async too.async function can only pause itself: its callers get a Promise and must await it too — that's how the “async” color spreads upward.A goroutine is a stackfulStackful vs stackless coroutineA stackful coroutine (a goroutine) has its own whole call stack, so it can pause deep inside any function. A stackless one (a JS async function) saves only its own local variables, so only that one function can pause, and only at an await. CoroutineCoroutineA function that can pause partway through and resume later from the same spot. Async functions and goroutines are both coroutines.: it has a whole call stack of its own, so it can pause at any depth and resume later with every caller's frame intact. A JS async function is stackless: it saves only its own local variables (in a heap object, as a ContinuationContinuation“The rest of the function” after a pause point, saved so it can run later. With await, the code after the await line is the continuation.). It can only pause itself, and only at an await — which is why each caller has to be async too.
JavaScript runs all of this on a single thread with an Event loopEvent loopThe loop inside every JS runtime that repeatedly picks the next piece of waiting work (a callback) and runs it to completion on the single main thread.: the Event Loop topic walks through it, and Async / Await covers the whole JS model.
Neither is free
| Go (goroutines) | JavaScript (async/await) | |
|---|---|---|
| Cost per waiting task | a stack: ≥ 2 KB (§02) | a small heap object: tens to hundreds of bytes |
| Who decides concurrency | the caller, with go | the function's author, with async; the caller adds Promise.all |
| Runs on many cores | yes, GOMAXPROCS at once | no: one thread per event loop (a Web WorkerWeb WorkerA background JavaScript thread in the browser. It can't touch the DOM but can do heavy work without freezing the main thread, talking to the page by messages. or Node worker thread is a separate thread with its own loop, sharing no variables) |
| Data races possible? | yes: goroutines run truly in parallel and share memory | no data races in normal code; but state can still change at every await |
| Where waiting is visible | nowhere in the signature | in every signature on the path |
Go's choice moves the cost from your code to the runtime: the scheduler, the netpoller, growable stacks and preemption all exist so that plain blocking code is cheap. The price you pay is the last row but one — real parallelism means real data racesData raceTwo threads or goroutines access the same memory, at least one of them writes, and nothing orders the two accesses (no lock, channel or atomic between them). The result is unpredictable. if two goroutines touch the same memory without coordination (§12).
Watching the scheduler: schedtrace, metrics, go tool trace, pprof
top — the OS only knows about threads. To see the scheduler itself, ask the Go runtime: a one-line summary per second (schedtrace), counters (runtime/metrics), profiles (pprof), or a full timeline of every event (go tool trace).Which tool for which question:
| Question | Tool |
|---|---|
| Are run queues piling up? Are Ps idle? | GODEBUG=schedtrace=1000 |
| How many goroutines are runnable vs waiting, over time, in production? | runtime/metrics |
| Where are all my goroutines stuck? | goroutine profile (§09) |
| Where does CPU time go? Where do goroutines wait on locks? | pprof CPU, block and mutex profiles |
| Why was this request slow — who ran when, on which P? | execution trace: go tool trace |
schedtrace: one line per tick
The GODEBUGGODEBUGAn environment variable that turns Go runtime debug output and compatibility switches on or off, e.g. GODEBUG=schedtrace=1000 or GODEBUG=asyncpreemptoff=1. Several settings are separated by commas. setting schedtraceschedtraceA GODEBUG setting (GODEBUG=schedtrace=1000) that makes the Go runtime print one line per second describing the scheduler: how many Ps and threads, how many are idle, and how long each run queue is. makes the runtime print a scheduler summary to stderr every N milliseconds. Here it is on a program that starts 200 goroutines, each doing about 50 ms of pure CPU work, on 4 Ps:
func work() int { // pure CPU work: about 50 ms on one core of this machine
x := 0
for i := range 130_000_000 {
x += i % 7
}
return x
}
func main() {
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 200; i++ { // 200 CPU-hungry goroutines, only 4 Ps
wg.Add(1)
go func() { defer wg.Done(); work() }()
}
wg.Wait()
fmt.Fprintln(os.Stderr, "all done in", time.Since(start).Round(10*time.Millisecond))
}$ GODEBUG=schedtrace=500 ./busy
SCHED 0ms: gomaxprocs=4 idleprocs=2 threads=4 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 [ 1 0 0 0 ] schedticks=[ 0 0 0 0 ]
SCHED 504ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=7 [ 76 1 76 2 ] schedticks=[ 29 33 31 40 ]
SCHED 1007ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=24 [ 52 2 51 1 ] schedticks=[ 53 71 56 78 ]
…
all done in 2.42s200 goroutines × ~50 ms = ~10 s of CPU work, shared by 4 Ps: ~2.5 s, and the run took 2.42 s. How to read the 504 ms line:
gomaxprocs=4 idleprocs=0— four Ps, none idle: every core is busy.threads=5— five OS threads in total: four running Go code, plus runtime helpers such as sysmonsysmonThe Go runtime's background watchdog: a thread that runs without a P and wakes up every 20 µs to 10 ms. It takes Ps away from threads stuck in system calls, preempts goroutines that run too long, and polls the network if nobody has lately..spinningthreads=0— nobody is hunting for work; there's plenty.runqueue=7— 7 goroutines in the global queue.[ 76 1 76 2 ]— the local queues of P0–P3. With the global queue, 162 goroutines are waiting for a turn and 4 are running. The other 34 have already finished (4 Ps × 500 ms ÷ 50 ms each ≈ 40).schedticks=[…](Go 1.25+) — how many times each P has picked a goroutine so far.
The numbers are a snapshot, and different on every run. Two things to notice anyway. The queues are lopsided (P0 has 76, P1 has 1) — but P1 isn't idle: it's running a goroutine, and when that one is done or preempted, P1 will take from its queue, the global queue, or steal (§05). And over time the total shrinks as goroutines finish. A service whose local queues stay long, run after run, simply has more CPU work than CPUs. Add scheddetail=1 (GODEBUG=schedtrace=1000,scheddetail=1) to get one line per P, per M and per goroutine as well.
runtime/metrics: scheduler counters you can export
For production you want numbers on a dashboard, not text on stderr. The runtime/metrics package exposes them; Go 1.26 added goroutine counts by state and thread counts (still there in 1.27):
func main() {
for i := 0; i < 100; i++ { // 100 CPU-hungry goroutines on 4 Ps
go func() {
for {
}
}()
}
time.Sleep(100 * time.Millisecond)
samples := []metrics.Sample{
{Name: "/sched/goroutines/running:goroutines"},
{Name: "/sched/goroutines/runnable:goroutines"},
{Name: "/sched/goroutines/waiting:goroutines"},
{Name: "/sched/threads/total:threads"},
{Name: "/sched/goroutines:goroutines"},
}
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
metrics.Read(samples) // fills in each sample's Value
for _, s := range samples {
fmt.Printf("%-40s %d\n", s.Name, s.Value.Uint64())
}
}NumGoroutine: 101
/sched/goroutines/running:goroutines 4
/sched/goroutines/runnable:goroutines 97
/sched/goroutines/waiting:goroutines 5
/sched/threads/total:threads 5
/sched/goroutines:goroutines 1064 running (one per P, and one of them is main itself, reading the metrics), 97 runnable, waiting in queues. (The documentation calls the per-state counts approximate and doesn't promise that they add up exactly; here they happen to.) The metrics count 106 goroutines where runtime.NumGoroutine() says 101: the 5 "waiting" ones are the runtime's own background goroutines (for the garbage collector and such), which NumGoroutine leaves out.
The most useful one for latency is older: /sched/latencies:seconds (since Go 1.17), a histogram of how long goroutines sat runnable before getting a P. If that grows, requests are waiting for CPU, not for the database.
go tool trace: every event on a timeline
An Execution traceExecution traceA recording of every scheduler event in a Go program — goroutine starts, blocks, wake-ups, which P ran what and when — made with runtime/trace and viewed with go tool trace as a timeline in the browser. records every scheduler event: each goroutine created, started, blocked (and on what), unblocked, each P's activity, each GC, each system call. You record one with runtime/trace:
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
if err := trace.Start(f); err != nil { // start recording ("runtime/trace")
panic(err)
}
defer trace.Stop()
// … the code you want to look at …
}go run trace.go
go tool trace trace.out # opens a timeline in your browser
go test -trace=trace.out ./mypkg # or record a test runThe viewer shows one row per P with each goroutine's slices of running time, plus views like "goroutine analysis" (for each goroutine: how long it ran, waited to be scheduled, blocked on the network, on sync, in syscalls). It's the tool for "why was this one request slow?". A web server can serve traces too: net/http/pprof exposes /debug/pprof/trace?seconds=5.
Recording costs some overhead, and a trace of a busy service is big, so you can't leave it on. Go 1.25 added the Flight recorderFlight recorder (trace)Go 1.25+: runtime/trace.FlightRecorder keeps the last few seconds of an execution trace in memory, overwriting the oldest part. When something goes wrong, the program saves just those seconds to a file — like an aircraft's black box.: trace.NewFlightRecorder(cfg) followed by Start() keeps only the last few seconds in memory, and when something goes wrong (a request took over a second, say) your code calls WriteTo(file) to save exactly those seconds.
Pitfalls gallery
Capturing the loop variable (before Go 1.22)
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Print(i, " ") // which i?
}()
}
wg.Wait()# go.mod: go 1.21
3 3 3
# go.mod: go 1.26 (three runs, Go 1.26.0)
2 0 1
2 1 0
2 0 1
# go.mod: go 1.27 (three runs, Go 1.27.0)
2 0 1
2 0 1
2 0 1(The 2 tends to come first for the reason in §05: the newest goroutine sits in the "run next" slot.) Before Go 1.22, the whole loop had one variable i, and every goroutine's ClosureClosureA function that remembers the variables around where it was created, even after that outer function has returned. Both Go and JS have them. referred to that same variable. By the time the goroutines ran, the loop had finished and i was 3. Since Go 1.22, each pass gets its own i (Per-iteration loop variablePer-iteration loop variableGo 1.22+: each pass through a for loop gets a fresh copy of the loop variable, so a goroutine or closure created in the loop sees that pass's value. Before 1.22 all passes shared one variable. Which rule applies depends on the go line in go.mod.), so you get 0, 1 and 2 — in whatever order the scheduler runs them.
Which rule applies depends on the go line in go.mod, not on the compiler you have installed. Old code with a go 1.21 line still behaves the old way. In such code you'll see the classic fix, i := i on the first line of the loop body, or passing i as an argument: go func(i int) { … }(i). In Go 1.22+ modules both are unnecessary. On old modules, go vet warns: loop variable i captured by func literal.
One goroutine per item, with no limit
for _, id := range ids { // ids has 1,000,000 entries
go process(id) // 1,000,000 goroutines, all hitting the database at once
}Goroutines are cheap, so this starts fine. Then a million of them open connections to a database that allows 100, or call an API limited to 50 requests per second. The waiting just moves into a queue you can't see, memory climbs, and timeouts pile up. Limit how many do the work at once, for example with a buffered channel used as a SemaphoreSemaphoreA counter of free slots. acquire() takes a slot (waiting if there are none left); release() gives it back. A semaphore with 10 slots lets at most 10 tasks through at once.:
sem := make(chan struct{}, 10) // 10 slots = at most 10 at once
var wg sync.WaitGroup
for _, id := range ids {
sem <- struct{}{} // take a slot; blocks while all 10 are taken
wg.Go(func() {
defer func() { <-sem }() // give the slot back when done
process(id)
})
}
wg.Wait()(A test run with 1,000 ids measured a peak of exactly 10 running at once.) errgroup's SetLimit(10) does the same with error handling; it lives in golang.org/x/sync, an extra dependency. Worker Pools covers sizing and backpressure.
Sharing memory without synchronization
count := 0
var wg sync.WaitGroup
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
count++ // ← 1000 goroutines, no lock
}()
}
wg.Wait()
fmt.Println("count:", count)count: 990
count: 993
count: 998Goroutines run truly in parallel on several Ps, and count++ is really "read, add one, write". Two goroutines that read the same old value both write back the same new value, and one increment is lost. That's a Data raceData raceTwo threads or goroutines access the same memory, at least one of them writes, and nothing orders the two accesses (no lock, channel or atomic between them). The result is unpredictable.. Run tests and services in staging with go run -race / go test -race; it reports exactly which lines collided:
WARNING: DATA RACE
Read at 0x00c000018118 by goroutine 17:
main.main.func1()
/tmp/goroutines/race/main.go:15 +0x7b
Previous write at 0x00c000018118 by goroutine 8:
main.main.func1()
/tmp/goroutines/race/main.go:15 +0x8dThe fixes are a sync.Mutex, an atomic counter, or giving each goroutine its own data and combining at the end. Races & Deadlocks and Locks cover them.
wg.Add inside the goroutine
for range 3 {
go func() {
wg.Add(1) // ✗ too late: main may already be in Wait()
defer wg.Done()
work()
}()
}
wg.Wait() // can return immediately, with the counter still 0go doesn't start the goroutine immediately (§03): it goes into a queue. main can reach wg.Wait() while the counter is still 0, and Wait returns at once. Call Add before go, or use wg.Go (Go 1.25+), which does it for you. Since Go 1.25, go vet catches it: WaitGroup.Add called from inside new goroutine.
Using time.Sleep to wait for a goroutine
time.Sleep(100 * time.Millisecond) "to give the goroutine time" works on your laptop and fails on a loaded CI machine or a throttled container (§08). The scheduler makes no promise about when a runnable goroutine runs. Wait for the thing you actually need: wg.Wait(), a receive from a channel, or a ctx deadline.
A goroutine with no way to stop
Every goroutine that waits on a channel, a network read or a lock needs an exit: the channel gets closed, a ctx gets cancelled, a deadline passes. Otherwise it's a leak (§09). Count goroutines in your metrics and check for leaks in tests.
Blocking system calls and cgo in hot paths
Goroutines are cheap only while they wait the Go way. Raw syscall functions, slow file I/O and calls into C each pin a whole thread (§06). A thousand goroutines doing that means a thousand threads, and at 10,000 the runtime crashes the program. Use the standard library's net, os and time APIs, and limit concurrency for anything that does go through a blocking call.
Check yourself
Answer before you click. Q1 is covered in §03, Q2 in §06, Q3 in §07, Q4 in §04 and §06, Q5 in §09, Q6 in §08.
func main() {
go fmt.Println("from the goroutine")
fmt.Println("from main")
}A Go program starts 1,000 goroutines and each calls time.Sleep(5 * time.Second). Roughly how many OS threads does the process have while they sleep?
With GOMAXPROCS=1, one goroutine runs for { } forever (no function calls). Another goroutine is waiting to print. On Go 1.27, what happens?
A goroutine on P0 calls a file read that blocks inside the kernel for 200 ms. What happens to the other goroutines in P0's local queue?
func first() string {
ch := make(chan string)
for i := 0; i < 3; i++ {
go func() { ch <- fetch(i) }()
}
return <-ch
}
// a server calls first() 1,000 timesA Go 1.27 service (its go.mod says go 1.27) runs in a container with a CPU limit of 1.5 CPUs on a 64-core machine, and nobody sets GOMAXPROCS. What is runtime.GOMAXPROCS(0)?
Cheat sheet
go f() doesn't wait. Wait with sync.WaitGroup (Add before go) or wg.Go in Go 1.25+.GOMAXPROCS. Each P has a local run queue (256); there's one global queue.go 1.25+ in go.mod and no manual GOMAXPROCS. Throttling pauses get rarer and shorter, not impossible.| I want to… | Use |
|---|---|
| Start concurrent work | go f(x) — and know how it stops |
| Wait for N goroutines | var wg sync.WaitGroup + wg.Go(func() { … }) (1.25+), then wg.Wait() |
| Get results / errors back | a channel, a slot per goroutine, or errgroup |
| Limit concurrency | buffered channel as a semaphore, or errgroup.SetLimit(n) |
| Read / set the number of Ps | runtime.GOMAXPROCS(0) / GOMAXPROCS=n env var |
| Count goroutines | runtime.NumGoroutine(); Go 1.26+: /sched/goroutines/* metrics |
| See where goroutines are stuck | /debug/pprof/goroutine?debug=1 or kill -QUIT <pid> |
| Find goroutines that can never wake up | /debug/pprof/goroutineleak (Go 1.27+) |
| Catch leaks in tests | testing/synctest, go.uber.org/goleak, or a before/after count |
| Watch the scheduler | GODEBUG=schedtrace=1000, go tool trace, trace.FlightRecorder (1.25+) |
| Find data races | go test -race |
| Setting | Effect |
|---|---|
GODEBUG=schedtrace=1000 | print a scheduler summary every second |
GODEBUG=asyncpreemptoff=1 | switch off signal-based preemption (debugging only) |
GODEBUG=containermaxprocs=0 | ignore the cgroup CPU limit for the default GOMAXPROCS (1.25+) |
GODEBUG=updatemaxprocs=0 | stop re-checking GOMAXPROCS while running (1.25+) |
GOEXPERIMENT=goroutineleakprofile | Go 1.26 only: build with the experimental goroutineleak profile (on by default in 1.27+) |