GMP Scheduler and Netpoller: I/O Waiting Costs Zero CPU
One OS thread waits on thousands of sockets; each waiter is a ~2KB goroutine. How the GMP scheduler parks and wakes I/O-bound goroutines.
One OS thread waits on thousands of sockets; each waiter is just a ~2KB goroutine.
The GMP Model
- G = goroutine (~2KB), M = OS thread (~1MB), P = logical processor (count =
GOMAXPROCS). A G must bind to a P bound to an M to execute. At mostGOMAXPROCSgoroutines run Go code in parallel at any instant. - Park: goroutine reads a socket and gets EAGAIN → runtime calls
gopark, sets G to_Gwaiting, registers the fd with epoll, and frees the P/M. A parked G holds 0 CPU. - Wake: socket becomes ready → epoll returns the fd → runtime maps fd back to G →
goreadymarks G as_Grunnable→ P/M resumes execution right afterRead().
Role in This OOM
Each /capture goroutine in the screenshot service spent ~99% of its time parked on the browser’s websocket. With GOMAXPROCS=1, 19 goroutines were simultaneously in-flight (each with an open browser tab), but only 1 was “running”. The 19 tabs and their memory all existed at once (GOMAXPROCS Caps Parallelism, Not Concurrency).
I/O-parked doesn’t mean non-existent. The goroutine holds zero CPU, but the browser tab it opened holds real memory.
References:
- https://internals-for-interns.com/posts/go-netpoller/
- https://nghiant3223.github.io/2025/04/15/go-scheduler.html
- https://goperf.dev/02-networking/networking-internals/
- https://man7.org/linux/man-pages/man7/epoll.7.html
Related: see GOMAXPROCS Caps Parallelism, Not Concurrency for why cpu:1 wasn’t enough, Load Shedding with TryAcquire for the semaphore fix, or go back to the series overview.