How the Go Scheduler Decides Which Goroutine Runs Next
How the Go scheduler works: goroutines, threads and processors, run queues, work stealing, blocking, preemption since Go 1.14 and how to observe it.

The scheduler is the part of the Go runtime you never call and always depend on. It decides which of your goroutines runs on which thread at every moment, and it is the reason go f() is cheap enough to use freely. Understanding its outline pays off in three situations: when you reason about why a program is slow under load, when you set GOMAXPROCS in a container, and when you read a goroutine dump or an execution trace and need to know what the states mean.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article gives the model in the order you need it, then shows two experiments that make it visible. It follows goroutines explained and leads to GOMAXPROCS in containers. The details are from the Go runtime's own documentation and source, and everything ran on Go 1.25.6.
Three letters: G, M, P
The runtime's source calls its entities G, M and P, and the whole design hangs on them.
- G is a goroutine: its stack, its instruction pointer, its status. Creating one allocates a small stack and a G structure.
- M is an operating system thread, which the runtime calls a machine. Threads are what the kernel actually schedules onto CPUs. A thread runs Go code only while it is attached to a P.
- P is a logical processor, a token that represents the right to run Go code, together with the resources needed to do it: a local run queue of runnable goroutines, a cache for memory allocation, and some state for the garbage collector.
There are exactly GOMAXPROCS Ps, so at most that many threads execute Go code at the same instant. A program with many goroutines and four Ps has four goroutines running and the rest waiting, runnable or blocked. The number of Ms can be larger than the number of Ps: threads blocked in system calls do not hold a P, so the runtime creates more threads as needed.
The reason for the extra layer, the P, is to avoid a global lock. If every thread took work from one shared queue, they would contend on its lock. By giving each P its own queue, most scheduling decisions touch only local data.
Where goroutines wait
A goroutine is always in one of a few states, and these appear in goroutine dumps:
| State | Meaning |
|---|---|
| runnable | Ready to run, waiting in a queue for a P |
| running | Executing on an M that holds a P |
| waiting | Blocked on a channel, mutex, WaitGroup, select, timer or similar |
| syscall | In a system call, its M detached from the P |
| dead | Finished |
New goroutines go onto the creating P's local run queue, in a special "run next" slot so that a goroutine that just started to communicate with its creator runs soon. Each P also periodically checks a global queue so that goroutines placed there do not starve.
Work stealing
When a P runs out of goroutines, it does not sit idle. It looks in the global queue, polls the network, and then picks another P at random and steals half of its local queue. Work moves to idle processors without any central dispatcher, and a burst of goroutines created on one P spreads across all of them within microseconds. The effect is that you do not have to place goroutines on cores; you start them, and the runtime balances.
Blocking, without wasting threads
The scheduler's most useful trick is what happens when a goroutine waits. For things the runtime controls, channels, mutexes, select, timers, WaitGroup and network I/O, the goroutine is parked: marked as waiting, taken off the thread, and the thread immediately runs another goroutine from the P's queue. Network reads are the key case, because Go's net package uses the operating system's event mechanism (epoll, kqueue) underneath. A goroutine blocked in conn.Read does not occupy a thread; it is parked until the poller reports data. That is how one process serves a hundred thousand connections with a handful of threads.
For calls the runtime cannot park, blocking system calls and cgo, the thread itself blocks. The runtime notices that a thread has been in a system call for a short time and hands its P to another thread so the queued goroutines keep running (cgo and blocked threads and blocking system calls cover the details). When the call returns, the goroutine needs a P again and joins a queue.
Preemption: a busy loop cannot hog the machine
A goroutine that never blocks, such as a tight computation loop, would hold its P indefinitely if the scheduler relied on goroutines to yield. For years it did rely on yielding at function calls, and a loop with no calls could starve everything else. Go 1.14 added asynchronous preemption: a background thread, sysmon, notices a goroutine that has run for more than about 10 ms and sends its thread a signal, and the signal handler stops the goroutine at a safe point so another can run. You can see it work by trying to starve the program:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1) // a single P: only one goroutine runs at a time
go func() {
for { // never yields, never blocks, makes no function calls
}
}()
time.Sleep(50 * time.Millisecond) // lets the busy goroutine start and spin
fmt.Println("main got the processor back")
}main got the processor backWith one P and a goroutine that spins forever, the program still finishes: main wakes after its sleep, is made runnable, and the runtime preempts the spinner to run it. On a Go older than 1.14 this program would hang. The time-slice is about 10 ms, so latency-sensitive goroutines can wait that long behind a CPU-bound one when there are more runnable goroutines than Ps.
Watching it: schedtrace
The runtime can print its own state. GODEBUG=schedtrace=100 writes one line every 100 ms with the number of Ps and Ms and the length of each run queue. Here is a program with busy goroutines and a trace of it:
package main
import (
"sync"
"time"
)
func spin(d time.Duration) {
end := time.Now().Add(d)
for time.Now().Before(end) {
}
}
func main() {
var wg sync.WaitGroup
for range 16 {
wg.Go(func() { spin(300 * time.Millisecond) })
}
wg.Wait()
}go build -o spinner .
GODEBUG=schedtrace=100 ./spinner 2>&1 | head -n 4SCHED 0ms: gomaxprocs=10 idleprocs=7 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=1 [ 0 0 0 0 0 0 0 0 0 0 ] schedticks=[ 0 1 0 0 0 0 0 0 0 0 ]
SCHED 106ms: gomaxprocs=10 idleprocs=0 threads=11 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=6 [ 0 0 0 0 0 0 0 0 0 0 ] schedticks=[ 4 6 6 5 4 4 4 4 4 4 ]
SCHED 213ms: gomaxprocs=10 idleprocs=0 threads=11 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=6 [ 0 0 0 0 0 0 0 0 0 0 ] schedticks=[ 8 10 10 9 8 8 8 8 8 8 ]
SCHED 325ms: gomaxprocs=10 idleprocs=6 threads=11 spinningthreads=1 needspinning=0 idlethreads=6 runqueue=1 [ 0 0 0 0 0 0 0 0 0 0 ] schedticks=[ 12 14 14 12 11 13 13 12 12 11 ]Each SCHED line reports gomaxprocs (the number of Ps), idleprocs (how many are idle), threads, runqueue (the global queue length) and then the length of each P's local queue in brackets. The run above, on a machine with ten Ps, started with idleprocs=7 and one goroutine in the global queue. By 106 ms every P was busy (idleprocs=0), with six more goroutines waiting in the global runqueue=6, and by 325 ms the work was draining and six Ps were idle again. Sixteen busy goroutines on ten Ps means six wait their turn, which is exactly what the middle lines show. Add scheddetail=1 for per-goroutine detail. The numbers depend on the machine, so use the output to learn the format and to look for patterns such as many idle Ps with a long global queue.
For a time-based view, go test -trace or runtime/trace records an execution trace that go tool trace renders, showing each goroutine on each P over time (the execution tracer). It is the right tool when you ask why goroutines are not running when you expect them to.
What you control
Very little, on purpose. The knobs are few.
GOMAXPROCSsets the number of Ps. Since Go 1.25 the default takes the cgroup CPU bandwidth limit into account on Linux and the runtime updates it periodically when the number of available CPUs changes; before that, a container with a 2-CPU limit on a 64-core host got 64 Ps and was throttled by the kernel (GOMAXPROCS in containers).runtime.Gosched()yields the processor voluntarily, which tests use and production code rarely needs.runtime.LockOSThread()pins a goroutine to its thread, needed for libraries that keep thread-local state such as some graphics and GUI toolkits (LockOSThread).- Goroutine count. The most effective tuning is writing programs that start a sensible number of goroutines for the work.
What you cannot do is set priorities, pin goroutines to cores, or choose the order they run in. Code that depends on the order goroutines run in is wrong even when it works, because the order is not specified and changes between releases. The scheduler's fairness is good enough for ordinary programs, and when it is not, the fix is almost always in the program's structure, not in the scheduler.
Misconceptions worth dropping
"Goroutines are green threads on one core." They run in parallel across all Ps. "More goroutines make a program faster." They make blocking work overlap; CPU-bound work gains nothing past the number of Ps. "A goroutine runs until it blocks." Not since 1.14. "GOMAXPROCS limits threads." It limits threads executing Go code at once, and the process can have many more threads in system calls. "The scheduler guarantees FIFO." It does not; do not rely on order.
For the authoritative description, the comments at the top of the runtime's proc.go describe the design in the maintainers' words, and the Go release notes for Go 1.14 introduce asynchronous preemption. For the practical side, the GODEBUG variables are documented in the runtime package.


