Here's a program. It starts a million goroutines, parks every one of them, and then asks the kernel how many threads the process has.
release := make(chan struct{})
var started sync.WaitGroup
started.Add(1_000_000)
for i := 0; i < 1_000_000; i++ {
go func() {
started.Done()
<-release // park here, all million alive at once
}()
}
started.Wait()
fmt.Println("goroutines:", runtime.NumGoroutine())
fmt.Println("OS threads:", threads.Count())
Take a guess before you scroll.
goroutines: 1000001
OS threads: 3
Three.
So what did go actually do a million times, if it wasn't starting threads?
This post answers that with six small programs. Every number below comes from running them. You can run them too.
Why any of this exists: most languages give you one OS thread per unit of work, and threads are expensive - megabytes of stack, real kernel scheduling overhead. So you either pay that cost per task, or restructure everything around callbacks/async-await to avoid it. Go's pitch is that you keep writing plain, blocking-looking code, and the runtime multiplexes thousands of these onto a handful of real threads for you. That claim is exactly what the experiment above just tested.
Go syntax used below, once: go f() starts a goroutine - a concurrently-running call to f. ch <- v sends on a channel, v := <-ch receives; both block until the other side is ready. wg.Add/.Done/.Wait is a counter that blocks until it hits zero. That's the entire vocabulary this post needs. If none of it is new, skip straight to Experiment 1.
The Setup
- Go 1.27.1, released September 2026
- Linux amd64, one vCPU, on purpose, so nobody gets to say "it just used more cores"
- Thread counts come from the
Threads:line of/proc/self/status. That's the kernel's number, not Go's.
package threads
var re = regexp.MustCompile(`Threads:\s+(\d+)`)
func Count() int {
d, _ := os.ReadFile("/proc/self/status")
n, _ := strconv.Atoi(string(re.FindSubmatch(d)[1]))
return n
}
I ran everything on Go 1.22.2 first and then again on 1.27.1. The picture was the same in both. The only visible difference is one extra column in the scheduler trace.
The baseline thread count wobbles between 3 and 5 from run to run. Compare the change, not the absolute value.
All six programs, plus a script that runs them, are in one repo: https://github.com/mohhddhassan/go-goroutine-experiments
Experiment 1: How Many Threads Does a Goroutine Cost?
The program from the top, looped over N and run twice: once with GOMAXPROCS=1, once with GOMAXPROCS=8. It also measures heap plus stack before and after, and how long the spawn loop took.
go run ./01-million
| goroutines alive | OS threads, GOMAXPROCS=1
|
OS threads, GOMAXPROCS=8
|
memory each | spawn cost |
|---|---|---|---|---|
| 1,000 | 3 | 9 | 2.9 KB | 1.6 µs |
| 10,000 | 3 | 9 | 2.7 KB | 2.3 µs |
| 100,000 | 3 | 10 | 2.9 KB | 3.2 µs |
| 1,000,000 | 3 | 10 | 2.9 KB | 3.4 µs |
The thread count ignored the goroutine count completely. It followed GOMAXPROCS.
A goroutine costs about 2.9 KB and a couple of microseconds. The spawn cost creeps up as N grows.
Meet G, M and P
Three letters explain that table.
- G: a goroutine. A small struct plus a stack that starts at 2 KB. The kernel has never heard of it.
- M: an OS thread. The only thing the kernel schedules.
-
P: a scheduling context. There are exactly
GOMAXPROCSof them, and an M needs one to run Go code.
go process()
-> a G is created
-> it goes on a P's run queue
-> an M holding that P runs it
-> the OS schedules the M like any other thread
Goroutines are unbounded. Ps are not. That's the gap the table fell into.
Now to check that story instead of just believing it.
Experiment 2: Where Does a New Goroutine Wait?
GODEBUG=schedtrace=N makes the runtime print its own state every N milliseconds.
The program starts 200 CPU-bound goroutines, all from main:
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
x := 0
for j := 0; j < 200_000_000; j++ {
x += j
}
_ = x
}()
}
wg.Wait()
GODEBUG=schedtrace=1 GOMAXPROCS=4 go run ./02-runqueues
SCHED 1ms: gomaxprocs=4 idleprocs=2 threads=5 runqueue=0 [ 0 0 0 74 ]
SCHED 5ms: gomaxprocs=4 idleprocs=0 threads=5 runqueue=0 [ 17 18 31 32 ]
SCHED 9ms: gomaxprocs=4 idleprocs=0 threads=5 runqueue=0 [ 17 18 31 130 ]
runqueue is the global queue. The bracket is one local run queue per P.
At 1 ms, main has queued 74 goroutines on the P it's running on. The other three Ps are empty.
At 5 ms, those three Ps hold 17, 18 and 31 goroutines. The global queue is still 0.
Nobody put those goroutines there. main only ever queued onto its own P. The other Ps took them from it. That's work stealing.
At 9 ms the numbers add up: 17 + 18 + 31 + 130 = 196, plus the 4 currently running, makes 200.
Now the same program with one P:
GODEBUG=schedtrace=100 GOMAXPROCS=1 go run ./02-runqueues
SCHED 100ms: gomaxprocs=1 runqueue=5 [ 194 ] schedticks=[ 9 ]
SCHED 201ms: gomaxprocs=1 runqueue=10 [ 189 ] schedticks=[ 14 ]
SCHED 302ms: gomaxprocs=1 runqueue=15 [ 184 ] schedticks=[ 19 ]
Every 100 ms, five goroutines move from the local queue to the global one. schedticks, the number of times this P picked something to run, goes up by five.
Five switches per 100 ms.
Something is interrupting these goroutines. Who?
Experiment 3: Who Decides When a Goroutine Stops?
Two goroutines spin on one P. Each one watches the clock, and whenever it notices it was off the CPU for more than a millisecond, it writes down for how long.
runtime.GOMAXPROCS(1)
// in each of the two goroutines:
last := time.Now()
for last.Before(end) {
now := time.Now()
if d := now.Sub(last); d > time.Millisecond {
gaps = append(gaps, d) // we were descheduled for d
}
last = now
}
go run ./03-timeslice
98 descheduled gaps
min=15.3ms median=20.2ms p90=20.2ms max=40.3ms
Each goroutine runs for about 20 ms and then the other one gets the P.
That matches the trace above. Five switches per 100 ms is 20 ms each. Two different measurements, one number.
The runtime's sysmon thread checks every ~10 ms and sets a preemption flag on any G running >10 ms - so a goroutine can run almost a full extra tick before sysmon catches it, which is why the observed number lands closer to 20 ms than 10.
Nothing in those loops asked to be interrupted. So what about a loop with no function calls at all, where the runtime has no natural place to step in?
runtime.GOMAXPROCS(1)
go func() {
for {
}
}()
t := time.Now()
time.Sleep(100 * time.Millisecond)
fmt.Printf("main resumed after %v\n", time.Since(t).Round(time.Millisecond))
go run ./04-tightloop
main resumed after 116ms
GODEBUG=asyncpreemptoff=1 timeout 5 go run ./04-tightloop
(nothing. killed by the timeout after 5 s)
Function calls use compiler-inserted stack checks for cooperative preemption; tight loops (for {}) require Go 1.14+ OS signals (SIGURG).
Experiment 4: What Happens When a Goroutine Blocks?
I expected "blocked" to cost about the same everywhere.
It doesn't.
One program, five ways to get stuck. 200 goroutines each, GOMAXPROCS=1, sampled while they're all stuck and again 1.5 seconds after they finish:
go build -o blocking ./05-blocking
GOMAXPROCS=1 ./blocking rawread 200
| stuck on | threads before | threads during | 1.5 s after |
|---|---|---|---|
| channel receive | 5 | 5 | 5 |
time.Sleep |
4 | 4 | 4 |
TCP socket Read
|
4 | 4 | 4 |
| CPU-bound loop | 4 | 4 | 4 |
raw read(2) on a pipe |
4 | 203 | 203 |
Four rows cost nothing. One cost a thread per goroutine.
The goroutines in the last row do this:
syscall.Pipe(p[:])
go func(fd int) {
b := make([]byte, 1)
syscall.Read(fd, b) // blocks in the kernel
}(p[0])
Scaled up to 1,000 goroutines each:
TCP socket read threads: 5 -> 5
raw read(2) threads: 4 -> 1003 -> 1004
What's going on:
- Channels and timers are runtime bookkeeping. The goroutine parks, and no thread is involved.
- Network reads go through the netpoller. The socket is non-blocking, the goroutine parks, and epoll wakes it up. A thousand idle connections cost zero extra threads.
- A raw syscall blocks the thread inside the kernel. That M is stuck, so the runtime hands its P to another M to keep everything else running.
That handoff is what stops one blocked goroutine from freezing the rest. The price is one OS thread per stuck goroutine.
And the threads don't leave. 203 was still 203 after 1.5 seconds. The runtime keeps them around for reuse.
Experiment 5: What Does GOMAXPROCS Control?
Eight CPU-bound goroutines, identical work, one core:
for p in 1 2 8; do GOMAXPROCS=$p go run ./06-gomaxprocs; done
| GOMAXPROCS | wall time |
|---|---|
| 1 | 1.140 s |
| 2 | 1.139 s |
| 8 | 1.138 s |
Identical. GOMAXPROCS doesn't create CPUs.
It sets the number of Ps, meaning how many threads may run Go code at the same moment. On a machine with real cores that's your parallelism limit. Here there was one core, so raising the limit changed nothing.
What I Couldn't Test
-
Real parallelism. One vCPU. I can show what
GOMAXPROCSsets, not the speedup it unlocks. On a multi-core box, run experiment 5 yourself and tell me what you get. -
Individual steals. I inferred stealing from queue lengths.
go tool tracewould show each steal directly. - Cgo and disk reads. I measured a pipe read. I believe they take the same path, but I didn't test them.
-
Other platforms. Everything here is Linux. The thread counting reads
/proc.
Where This Falls Short
Goroutines are cheap. They aren't free.
- A million goroutines is about 2.9 GB. 2.9 KB each sounds like nothing until you multiply.
- A thread per stuck goroutine adds up. The runtime caps threads at 10,000 by default. 1,000 raw reads already got to 1,003. I didn't push it to the cap.
- "Cheap to start" says nothing about what it does next. Every thread in this post came from what the goroutines did after they started.
The Actual Answer
go process() doesn't start a thread. It starts a chain.
go process()
-> a G is allocated (~2.9 KB, ~2-3 µs)
-> queued on the P that ran the go statement
-> idle Ps steal from it (seen within ~5 ms)
-> an M holding that P runs it (threads follow GOMAXPROCS, not goroutines)
-> interrupted about every 20 ms (tight loops included)
-> parks on a channel, timer or socket (0 extra threads)
-> or blocks in a raw syscall (its P moves to another M: 1 thread each)
A goroutine isn't a lightweight thread. It's a job the runtime schedules onto a handful of threads, and it asks the OS for more only when something blocks in the kernel.
What costs a thread isn't go process().
It's what process() does once it's running.
Next in this series: what actually happens when a Go channel blocks.
Top comments (0)