How it works
The explanation, with examples you can run.
The G-M-P model
- There are exactly
GOMAXPROCSPs. Each P has a local run queue of goroutines ready to run, and there is also a global queue. - An M picks up a P and runs goroutines from its queue. When a goroutine blocks, the M moves on to the next one.
- A P whose queue is empty steals half of another P's queue. Work spreads without one central lock.
go f()puts the new G on the current P's queue, so a burst of goroutines starts close to its parent.
GOMAXPROCS used to default to the number of logical CPUs. Since Go 1.25, on Linux it also respects the container's cgroup CPU limit and is updated if that limit changes, which stops Go services in Kubernetes from creating far more running threads than their CPU quota allows. Setting GOMAXPROCS yourself turns this off.
When a goroutine blocks
- Channels, locks, sleeps. The goroutine is parked on that object and costs nothing until it is woken.
- Network I/O. Sockets are non-blocking underneath. A goroutine that waits for data is parked on the netpoller (epoll, kqueue or IOCP), and the runtime makes it runnable when the socket is ready. Ten thousand idle connections are ten thousand parked goroutines, not ten thousand blocked threads.
- Blocking system calls and cgo. File reads on many systems, and C code, really do block the thread. The runtime detaches the P from that thread and gives it to another M, starting a new thread if needed. That is why a Go program can have more threads than
GOMAXPROCS.
Preemption
Goroutines used to switch only at function calls and blocking operations, so a tight loop with no calls could hold a P forever and even delay garbage collection. Since Go 1.14 goroutines are asynchronously preemptible: on most platforms the runtime sends the thread a signal and switches goroutines at a safe point.
package main import ( "fmt" "runtime" "time") func main() { runtime.GOMAXPROCS(1) // one P: only one goroutine runs Go code at a time go func() { for { // a tight loop with no function calls } }() time.Sleep(10 * time.Millisecond) // the spinning goroutine gets the P fmt.Println("main still gets to run") // because the runtime preempts the loop}On Go 1.26 this prints its line and exits. Scheduling is fair enough, but not in any guaranteed order: never use sleeps or the order goroutines were started as synchronisation.
Cheap, but not free
Each goroutine holds its stack and everything it references until it returns. A goroutine blocked forever on a channel nobody will send to is a leak: memory and often a socket. Every go statement should have an answer to "how does this end?", usually a closed channel or a cancelled context. Go 1.27 made a goroutineleak profile generally available (in runtime/pprof and at /debug/pprof/goroutineleak); it uses the garbage collector to find goroutines blocked on something no running goroutine can reach.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.