Skip to content

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.

goroutines when main starts:         1
goroutines while workers block: 100001
goroutines after release:            1

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 main starts. runtime.NumGoroutine counts the goroutines that currently exist, and at the top of main the only one is main's.
  • 100,001 while the workers block. started.Wait() returns only after every worker has called started.Done(), so all 100,000 exist by then, and each is blocked receiving from release, on which nothing has been sent. The workers, plus main.
  • 1 after release. Closing release lets 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, and finished.Wait() returns. The program still does not read the count there: it loops, calling runtime.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:

Real runs — go1.25.5, x86-64 Mac (Intel Core i5-10500, 12 logical CPUs), macOS 26.6.2, 200 runs of 10,000 goroutines, 2026-09-14
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:

Real runs — go1.25.5, x86-64 Mac (Intel Core i5-10500, 12 logical CPUs, 48 GiB), macOS 26.6.2, 5 runs per size, 2026-09-14
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 Sys divided the same way: a little over 2.6 KiB at the two larger sizes. Sys is the virtual address space the runtime has reserved for the heap, the stacks and its own bookkeeping, and the MemStats documentation 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 by runtime.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.NumGoroutine only 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.Wait makes 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::spawn is an operating-system thread, and the std::thread documentation ↗ 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