Goroutines are cheap¶
Level: 101 · anyone who has sized a thread pool and wonders why Go code seldom does
One line: A goroutine starts with a stack of a few kilobytes, not an operating-system thread's, so a program can start 100,000 goroutines that block, count every one of them exactly with runtime.NumGoroutine, and watch the count fall back to 1 once they are released.
The Go FAQ ↗ gives the reason. Goroutines are multiplexed onto a smaller set of operating-system threads, and each begins with a stack of a few kilobytes that the runtime grows and shrinks as needed, so hundreds of thousands of them in one address space is practical — where the same number of threads would run the system out of resources long before.
100,000 goroutines, counted¶
Verified output of goroutines_are_cheap_go.go — regenerated by tools/run_examples.py, never hand-typed.
Reading the output¶
Each number is read at a moment when no goroutine is starting and none is ending, which is what makes the three the same on every run.
- 1 when
mainstarts.runtime.NumGoroutine↗ counts the goroutines that currently exist, and at the top ofmainthe only one ismain's. - 100,001 while the workers block.
started.Wait()returns only after every worker has calledstarted.Done(), so all 100,000 exist by then, and each is blocked receiving fromrelease, on which nothing has been sent. The workers, plusmain. - 1 after release. Closing
releaselets every blocked receive proceed, because a receive from a closed channel ↗ can always proceed at once — the rule Closing a channel ends a range is built on. Every worker returns, andfinished.Wait()returns. The program still does not read the count there: it loops, callingruntime.Gosched↗ to let other goroutines run, until the count itself is 1.
The loop is there because Wait promises less than it seems to. WaitGroup.Go ↗ removes a task from the group when its function returns, and the goroutine still has to finish exiting after that. Read the moment Wait returns, NumGoroutine usually says 1, and not always:
go version go1.25.5 darwin/amd64
runs NumGoroutine the moment Wait returned
190 1
10 2
The tally comes from bash demo/at_wait.sh, run from this folder. Your counts will differ, and a key that recorded either number would fail some of the time.
goroutines_are_cheap_go.go
goroutines_are_cheap_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// Goroutines are cheap: start 100,000 that block on a channel, count them,
// release them, and count again. runtime.NumGoroutine is read only when what
// it counts has settled, so the three numbers are the same on every run.
//
// go run goroutines_are_cheap_go.go
package main
import (
"fmt"
"runtime"
"sync"
)
const workers = 100_000
func main() {
fmt.Printf("goroutines when main starts: %6d\n", runtime.NumGoroutine())
release := make(chan struct{})
var started, finished sync.WaitGroup
started.Add(workers)
for range workers {
finished.Go(func() {
started.Done() // this goroutine exists and is about to block
<-release
})
}
started.Wait() // every worker has got as far as Done
fmt.Printf("goroutines while workers block: %6d\n", runtime.NumGoroutine())
close(release) // one close wakes every receiver
finished.Wait()
// Wait returns once every function has returned, not once every goroutine
// has gone, so wait for the count itself.
for runtime.NumGoroutine() > 1 {
runtime.Gosched()
}
fmt.Printf("goroutines after release: %6d\n", runtime.NumGoroutine())
}
What they cost¶
Time and memory cannot go in an answer key either. bash demo/cost.sh, from this folder, starts 10,000, 100,000 and 1,000,000 blocked goroutines five times each, and reports how much runtime.MemStats ↗ grew while they were blocked:
go version go1.25.5 darwin/amd64
goroutines started in ms stack KiB each from OS KiB each NumGoroutine at Wait
10000 18.9 2.02 2.64 1
10000 17.4 2.02 3.03 1
10000 16.3 2.02 3.10 1
10000 19.2 2.02 2.64 1
10000 18.3 2.02 3.05 1
100000 228.8 2.00 2.63 1
100000 195.3 2.00 2.62 1
100000 189.7 2.00 2.62 1
100000 171.4 2.00 2.67 1
100000 169.3 2.00 2.63 1
1000000 2088.6 2.00 2.60 1
1000000 1813.3 2.00 2.60 1
1000000 1869.0 2.00 2.61 1
1000000 1955.0 2.00 2.61 1
1000000 1910.9 2.00 2.61 1
- Stack KiB each is the growth of
StackInuse, the bytes in stack spans, divided by the number of goroutines: 2 KiB for each blocked goroutine at every size, so a million of them held about 1.9 GiB of stack. - From OS KiB each is the growth of
Sysdivided the same way: a little over 2.6 KiB at the two larger sizes.Sysis the virtual address space the runtime has reserved for the heap, the stacks and its own bookkeeping, and theMemStatsdocumentation notes that not all of it need be backed by physical memory at any moment. - Started in grows in step with the count — about a fifth of a second for 100,000, about two seconds for a million — and includes the time each worker took to reach
Done. - NumGoroutine at Wait is the reading tallied above; these fifteen runs happened to see only 1.
The stack figure is not a constant of the language: since Go 1.19 ↗ the runtime sizes a new goroutine's first stack from the historic average stack usage of goroutines.
Cheap is not free¶
- A goroutine that never ends is never freed. A hundred thousand of them blocked forever is a hundred thousand stacks held until the program exits: A leaked goroutine never ends.
- Cheap to start does not make the work cheap. 100,000 goroutines that each open a connection open 100,000 connections. Limit the work, not the goroutines: A buffered channel as a semaphore, A worker pool.
- The limit that does exist is on threads.
debug.SetMaxThreads↗ starts at 10,000 operating-system threads, and its documentation says the limit counts threads, not goroutines: the runtime creates a new thread only when a goroutine is ready to run and every existing thread is blocked in a system call or a cgo call, or locked to another goroutine byruntime.LockOSThread. The example above ran 100,000 goroutines without reaching it.
What to do¶
- Start a goroutine for each independent piece of work — a request, a file, an item — rather than pooling goroutines to save what they cost; bound the resource the work uses instead.
- Make sure every goroutine you start can end, and that something waits for it to.
- Read
runtime.NumGoroutineonly when nothing is starting or ending — after every goroutine you are counting has signalled, or in a loop that waits for the number you expect. In a test,synctest.Waitmakes such a moment: it blocks until every other goroutine in the bubble is durably blocked.
In other languages¶
- The Concurrency library's 01 Threads chapter plans How many threads can you start? — operating-system threads, goroutines and Java's virtual threads, counted on the same machine.
- The Rust library's Spawning a thread ↗: every
thread::spawnis an operating-system thread, and thestd::threaddocumentation ↗ currently gives a spawned thread a default stack of 2 MiB on Rust's Tier-1 platforms, against the 2 KiB of stack each goroutine held above.
Sources¶
- The Go FAQ: Why goroutines instead of threads? ↗; Effective Go: Goroutines ↗.
runtime.NumGoroutine↗,runtime.Gosched↗,runtime.MemStats↗,runtime/debug.SetMaxThreads↗,sync.WaitGroup.Go↗.- The Go Programming Language Specification: Receive operator ↗.
- Go 1.19 release notes ↗: initial goroutine stacks sized from historic average stack usage.
- Katherine Cox-Buday, Concurrency in Go (O'Reilly, 2017), chapter 3, "Go's Concurrency Building Blocks" — measures a goroutine's memory with
runtime.MemStats, much asdemo/cost.godoes. - James Cutajar, Learn Concurrent Programming with Go (Manning, 2024), chapter 2, "Dealing with threads" — what is special about goroutines, set against threads and processes.