The magic trick
Write this on a laptop with 8 cores:
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 100_000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(time.Second)
}()
}
fmt.Println("goroutines:", runtime.NumGoroutine(), "| GOMAXPROCS:", runtime.GOMAXPROCS(0))
wg.Wait()
}
It starts a hundred thousand concurrent tasks and finishes in about a second, without melting the laptop. Try that with 100,000 OS threads and your machine will file a complaint.
The trick is that a goroutine is not a thread. It's a tiny task (it starts with a stack of just a few kilobytes that grows when needed), and the Go runtime decides which thread runs it and when.
Why not one thread per task?
OS threads are heavy. Each has a big stack, and switching between them involves the kernel, which is like asking the government for permission every time you switch tabs.
The opposite extreme, everything on one thread (hello, JavaScript), is cheap but can only use one CPU core.
Go takes the middle path: many goroutines multiplexed onto a few threads. Cheap like green threads, but using all your cores. That's M:N.
Meet G, M and P
- G (goroutine): the work. Your function, its stack, and where it left off.
- M (machine): a real OS thread. Only Ms actually execute code on a CPU.
- P (processor): a license to run Go code, plus a local run queue of Gs waiting their turn. An M must hold a P to run Go code.
The number of Ps is GOMAXPROCS, which defaults to the number of CPUs available (recent Go versions also respect container CPU limits). So on 8 cores you get 8 Ps, which means at most 8 goroutines are running Go code at the same instant. The other 99,992 are waiting in queues, sleeping, or blocked on something. That's fine. Waiting is free.
How a goroutine gets picked
When an M needs something to run, its P looks for work roughly in this order:
- Its own local run queue (fast, no locking with other Ps).
- Every so often, the global run queue, so goroutines parked there don't starve.
- The network poller, for goroutines whose network I/O is now ready.
- Steal from another P: grab about half of a busy P's queue.
Step 4 is called work stealing, and it's the reason Go keeps all cores busy without a central boss. An idle P doesn't wait for instructions. It goes and takes work from a coworker. (Please don't apply this strategy at your office.)
What happens when a goroutine blocks?
This is where the P design really pays off.
Blocking on channels, mutexes or time.Sleep: the goroutine is simply parked. The M and P move on to the next G immediately. No thread wasted.
Network I/O: Go uses the OS's event notification (epoll, kqueue, IOCP) under the hood. Your code looks blocking, conn.Read(buf), but the goroutine is parked and woken when data arrives. You get simple code and efficient I/O.
A blocking system call (like some file operations): the OS thread really is stuck. So the runtime does a handoff. It detaches the P from the stuck M and gives it to another M (an idle one or a new one) so the other goroutines keep running.
When the syscall returns, the old M tries to get a P back to continue its goroutine. If none is free, the goroutine goes into a run queue and the thread goes idle.
Fairness: nobody hogs the CPU
What if a goroutine runs a tight loop forever? Since Go 1.14 the scheduler can preempt it asynchronously: a background monitor notices it has run for too long (about 10 ms) and interrupts it so others get a turn. Before that, a loop without function calls could hog a P. Now even selfish goroutines take turns.
Practical takeaways
- Goroutines are cheap, not free. 100,000 sleeping goroutines is fine. 100,000 goroutines each holding a database connection is not. Bound concurrency with worker pools or a semaphore.
- Leave
GOMAXPROCSalone unless you've measured a reason. The default is usually right. - Don't fear blocking I/O in goroutines. That's the idiomatic way. The runtime handles it.
- Want to see it? Run your program with
GODEBUG=schedtrace=1000and the runtime prints scheduler stats every second. Very nerdy, very satisfying.
At work I write Go services that handle live sensor data from industrial kilns, and this scheduler is the quiet reason it all just works: thousands of streams, a handful of cores, and very little drama.
