· 7 min read · by

How Go runs a million goroutines on eight cores

The M:N scheduler in plain words: G, M and P, run queues, work stealing, and what happens when a goroutine blocks.

  • Go
  • Concurrency
  • Runtime
Share
Aerial view of a busy multi-level highway interchange full of cars
Photo by Denys Nevozhai on Unsplash

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

Global run queueG9G10P0local run queueG1G2G3G4running now:G0M0OS threadCPU core 1P1local run queueG6running now:G5M1OS threadCPU core 2idle P steals half a queuechecked now and then
G = goroutine, M = OS thread, P = processor (a run-queue plus the right to run Go code). GOMAXPROCS sets how many Ps exist.
  • 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:

  1. Its own local run queue (fast, no locking with other Ps).
  2. Every so often, the global run queue, so goroutines parked there don't starve.
  3. The network poller, for goroutines whose network I/O is now ready.
  4. 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.

BeforeP0queue: G2, G3M0running G1G1 calls a blocking syscallAfterM0G1 stuck in syscallP0queue: G3M2now running G2P handed off
A goroutine blocks in a syscall: its thread waits, but the P (and its queue) moves to another thread so work continues.

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 GOMAXPROCS alone 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=1000 and 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.

Found this useful? Pass it on.

Share