Ask what happens when a goroutine blocks and you usually get an answer about goroutines being cheap. That answer is true and it is not the interesting part. The interesting part is what happens to the thread the goroutine was running on, because that is the resource you can actually run out of.
The runtime’s answer depends entirely on how the goroutine blocked, and the gap between the two cases is much larger than most people expect.
The three vocabulary words
The scheduler is usually drawn as G, M and P, and the letters matter here:
- G — a goroutine. Cheap, a few kilobytes, thousands are fine.
- M — a machine, meaning an OS thread. Expensive: a kernel stack, a scheduling entity, real memory.
- P — a processor, meaning permission to run Go code. There are
GOMAXPROCSof them and no more.
An M needs a P to execute Go code. That is the whole design: goroutines are unlimited, threads are not, and the P is the token that rations the middle.
When a goroutine blocks, the question the runtime has to answer is whether the P can be taken away from it and given to someone else.
The measurement
Two hundred goroutines, each blocked in a different way, GOMAXPROCS=2, and a
count of the OS threads the process created. Thread count comes from the
runtime’s own threadcreate profile, so there is nothing to interpret:
func osThreads() int { return pprof.Lookup("threadcreate").Count() }
Case one parks each goroutine in read(2) on the read end of a raw pipe nobody
writes to. The descriptors come from syscall.Pipe, not os.Pipe, which
matters: raw file descriptors never reach the netpoller, so the syscall really
does block.
func blockInSyscall() {
for i := 0; i < blockers; i++ {
var fds [2]int
if err := syscall.Pipe(fds[:]); err != nil {
panic(err)
}
go func(fd int) {
buf := make([]byte, 1)
syscall.Read(fd, buf)
}(fds[0])
}
}
Case two parks each goroutine in a read on an idle TCP connection — same “waiting for bytes that never come”, different plumbing.
$ GOMAXPROCS=2 ./threads syscall
GOMAXPROCS=2
threads before: 4
goroutines blocked: 200
threads after: 203
$ GOMAXPROCS=2 ./threads netpoll
GOMAXPROCS=2
threads before: 4
goroutines blocked: 200
threads after: 4
Two hundred and three threads against four. Same number of blocked goroutines, same amount of nothing happening, a fifty-fold difference in what it costs.
What the scheduler saw
GODEBUG=schedtrace=1000 prints the scheduler’s own view once a second, and it
explains the number:
# 200 goroutines in read(2)
SCHED 1007ms: gomaxprocs=2 idleprocs=2 threads=202 spinningthreads=0 idlethreads=0 runqueue=0 [ 0 0 ]
# 200 goroutines in a TCP read
SCHED 1011ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [ 0 0 ]
Look at idleprocs=2 in the first line. Both Ps are idle while 202 threads
exist. That looks like a contradiction and it is the whole mechanism.
When a goroutine enters a syscall, the runtime marks its P as _Psyscall and
lets the thread go. If the syscall returns quickly the thread reacquires its own
P and nothing happened. If it does not, the monitor thread — sysmon, which
runs outside the scheduler precisely so it can watch it — notices a P that has
been sitting in _Psyscall too long and retakes it: the P is detached from
the blocked thread and handed to another M, which is started if none is
available.
So the blocked thread keeps its goroutine and its kernel wait, and the P — the right to run Go code — moves on without it. The Ps are idle because there is no Go code to run. The threads exist because 200 of them are sitting in the kernel.
Why the network case is free
net.Conn never issues a blocking read. Every descriptor created through net
or os is registered with the netpoller, the runtime’s epoll/kqueue
integration. A read on an empty connection does not enter the kernel and wait;
it parks the goroutine on the netpoller and returns the thread to the scheduler
immediately.
The goroutine is now the runtime’s problem rather than the kernel’s. When bytes arrive, the netpoller — polled by the scheduler when it goes looking for work, and by sysmon when nothing else does — moves the goroutine back onto a run queue. No thread was ever committed to the wait.
Four threads for two hundred idle connections is the reason a Go server holds tens of thousands of them without a thread-per-connection catastrophe.
Slow is what matters, not “syscall”
The handoff is not free — retaking a P and starting an M costs more than the syscall usually does. So sysmon does not do it eagerly. It only retakes a P whose syscall has been running longer than 20µs.
Two hundred goroutines in a tight loop of write(2) to /dev/null spend nearly
all their time inside syscalls, but each call returns in well under that
threshold:
$ GOMAXPROCS=2 ./threads fast
GOMAXPROCS=2
threads before: 4
goroutines blocked: 200
threads after: 5
One extra thread. The word “syscall” is not what costs you; duration is. A program making millions of fast syscalls behaves nothing like a program making two hundred slow ones.
What this means when it is your service
- A goroutine blocked on a channel, a mutex, a timer, or any
net/osI/O costs no thread. Blocking on those is not a resource decision. - A goroutine blocked in a cgo call, a
syscall.Syscallon a raw descriptor, or a read from a slow local disk costs a thread for the duration. Two hundred concurrent ones cost two hundred threads. - That is why an unbounded worker pool doing cgo or file I/O degrades in a way
an unbounded pool doing network I/O does not — and why the fix is a semaphore
around the blocking call rather than a lower
GOMAXPROCS. - The runtime’s own ceiling is
debug.SetMaxThreads, default 10,000, and it does not throttle. It crashes the process.
Worth noting that the goroutine itself is the cheap part of all this, but not
free: the closure a go statement captures has to outlive the frame that
started it, which makes it a heap allocation every time. That is the subject of
escape analysis is not a rule of thumb,
where the compiler’s verdict on that closure is shown next to what it actually
costs.
What this does not measure
Thread count is not thread cost. These threads are parked in the kernel and consume no CPU; what they consume is memory, scheduler bookkeeping and file descriptors, none of which this experiment quantifies.
The numbers are also one machine’s — go1.27.0 darwin/arm64, Apple M4 Pro, 12
cores, macOS 26.6.2. The 20µs retake threshold and the netpoller integration are
runtime constants rather than platform ones, so the shape should hold anywhere;
the exact thread counts will not.
And the raw pipe is a laboratory instrument, not a workload. It was chosen
because it is the shortest way to get a syscall the netpoller cannot see. If you
want to reproduce this against something real, a database/sql driver using cgo
is the closest production equivalent.
Frequently asked
- Does a blocked goroutine always cost an OS thread?
- No. Only a goroutine blocked in a syscall the runtime cannot park it on. Channel operations, mutex waits, timers and any I/O on a descriptor registered with the netpoller park the goroutine and leave the thread free for other work.
- Should I lower GOMAXPROCS to limit OS threads?
- No — those are different knobs. GOMAXPROCS caps how many goroutines run Go code simultaneously; it places no limit on threads blocked in syscalls. In the measurement below GOMAXPROCS was 2 while 203 threads existed. The runtime-wide thread ceiling is SetMaxThreads, which crashes the process when reached rather than throttling.
- Why does reading a regular file behave like the syscall case?
- Regular files are not pollable on most systems, so the runtime issues a genuinely blocking read rather than registering the descriptor with the netpoller. A goroutine reading a slow disk holds its thread the same way the raw pipe below does.