Go scheduler simulator

The Go runtime runs any number of goroutines on a handful of OS threads. Each of GOMAXPROCS processors keeps its own queue of goroutines, idle processors steal from busy ones, and a background monitor preempts long runners and rescues processors stuck behind system calls. Pick a workload and watch it happen.

Time
0.00 ms
Goroutines done
0/1
OS threads
1
Workload
GOMAXPROCS
Preemption
Work stealing
main starts 48 workers that each compute for 1 ms, then waits for all of them.
P0idle
running–
runnextG1
queue 0
P1idle
running–
runnext–
queue 0
P2idle
running–
runnext–
queue 0
P3idle
running–
runnext–
queue 0
global queue 0empty
in syscallsnone
Timeline: which goroutine each P ran
P0 P1 P2 P3 01

main worker time in ms

Counters
Running now
0 of 4 Ps
Steals
0 (0 goroutines)
Batches from the global queue
0
Preemptions
0
P handoffs from syscalls
0
Longest wait to run
0.00 ms
Scheduler events
  1. –Press Run. Steals, global-queue batches, preemptions and syscall handoffs are listed here as they happen.

How to use the simulator

Each card is a P (processor) with the goroutine it is running, its runnext slot and its local run queue. Below are the global run queue and any goroutines blocked in system calls, each holding its own thread. The timeline shows which goroutine ran on each P and when.

  • Fan-out: main starts 48 goroutines on P0. Each new one goes into runnext and pushes the previous one into the local queue; the idle Ps wake up and steal half of it at a time. Switch work stealing off to see P0 do everything alone.
  • Blocking syscalls: watch the thread count climb past GOMAXPROCS as sysmon hands Ps away from threads stuck in the kernel.
  • Tight loop on one P: with Go 1.14+ the loop is preempted after 10 ms and the waiting goroutines run; set preemption to before 1.14 and they wait for the whole loop.
  • Ping-pong: the pair hand control to each other through runnext and run back to back on one P, until they have used a 10 ms slice between them and are preempted together.

What is the Go scheduler?

The Go scheduler is the part of the Go runtime that decides which goroutine runs on which operating-system thread. A Go program may have hundreds of thousands of goroutines, but the operating system only sees a few threads; the runtime multiplexes one onto the other in user space, which makes starting a goroutine and switching between goroutines far cheaper than doing the same with threads.

Because blocking operations such as channel receives, locks and network reads park only the goroutine, the thread underneath moves on to other work. The scheduler's job is to keep every processor busy without much locking, to treat goroutines fairly, and to cope with the cases it cannot see into, such as system calls that block in the kernel.

G, M and P

  • G, a goroutine: a function to run, its stack (which starts small and grows as needed) and its saved registers.
  • M, a machine: an OS thread. Ms are created as needed, up to a default limit of 10,000 (debug.SetMaxThreads).
  • P, a processor: the right to run Go code, with a run queue and per-processor caches. There are exactly GOMAXPROCS Ps.

An M must hold a P to run a G. The P is what makes the design scale: before it was added, all threads took goroutines from one global queue under one lock. With a P per CPU, the common path, taking the next goroutine from your own queue, needs no lock at all.

Where a goroutine waits

A runnable goroutine sits in one of three places:

  • runnext, a single slot per P. go f() puts the new goroutine there, and so does waking a goroutine blocked on a channel. Whatever was in runnext moves to the back of the local queue. Running the woken goroutine next on the same P keeps shared data in cache, and a goroutine taken from runnext inherits the current time slice, so a pair that keeps waking each other cannot starve everyone else.
  • The local run queue, a ring of 256 goroutines owned by the P. When it is full, half of it and the new goroutine move to the global queue.
  • The global run queue, shared and protected by a lock. Preempted goroutines go here, as do goroutines whose system call returned when no P was free.

When a P needs work it looks, in order: at the global queue first on every 61st scheduling round, so that goroutines there are not starved by two that keep respawning each other; then runnext; then its local queue; then the global queue, taking a batch of up to half a local queue (the queue's length divided by GOMAXPROCS, plus one); then the network poller; and finally other Ps, to steal.

Work stealing

A P with nothing to run visits the other Ps in a random order and takes half of the first non-empty local queue it finds, rounding up. It makes four passes; on the last one it may also take a victim's runnext goroutine. Stealing half moves enough work to keep the thief busy for a while without leaving the victim empty, and the random order spreads thieves across victims. The fan-out workload, 48 one-millisecond goroutines started by one goroutine:

GOMAXPROCSWith stealingSteals (goroutines moved)Without stealing
148.1 ms0 (0)48.1 ms
224.1 ms1 (24)48.1 ms
412.1 ms9 (60)48.1 ms
86.11 ms25 (93)48.1 ms

Without stealing (not a real Go option, shown for comparison) the goroutines stay on the P that created them, and extra Ps sit idle. Work stealing as a scheduling method goes back to Blumofe and Leiserson's analysis in the 1990s and is also used by Cilk, Java's ForkJoinPool, Rust's Tokio and Rayon, and .NET's thread pool.

System calls and threads

Network I/O in Go does not block threads: sockets are non-blocking and a parked goroutine is woken by the runtime's network poller (epoll, kqueue or IOCP). File reads and other system calls do block the thread, and the runtime cannot know for how long. The goroutine keeps its thread for the duration, and the thread keeps a loose hold on its P. The sysmon thread checks regularly; if a P has been stuck in a system call for more than one of its ticks (at least 20 µs) and there is work it could do, sysmon takes the P and gives it to another thread, waking an idle one or creating a new one. When the call returns, the goroutine tries to get its old P back, then any idle P, and failing that it goes to the global queue and its thread goes to sleep.

GOMAXPROCSFinished inOS threadsP handoffs
112.2 ms832
210.2 ms832
49.55 ms932

Eight goroutines, each doing four rounds of 0.3 ms of work and a 2 ms blocking read. The thread count exceeds GOMAXPROCS because threads blocked in the kernel do not count against it. A program that blocks many goroutines in system calls at once, such as one reading thousands of files in parallel, can create thousands of threads; the runtime crashes the program at the 10,000-thread limit rather than let it exhaust the machine.

Preemption

sysmon also watches each P's scheduling counter. If the same goroutine, or the same chain of runnext goroutines, has been running for 10 ms, sysmon asks it to stop, and it goes to the back of the global queue. Until Go 1.14 the request was a flag checked in function prologues, so a loop that made no function calls could not be stopped, which could hold up the garbage collector and every goroutine waiting on that P. Go 1.14 added asynchronous preemption: the runtime sends the thread a signal (SIGURG on Unix) and stops the goroutine at almost any instruction. The tight-loop workload, a 40 ms loop without calls and three short goroutines on one P:

PreemptionPreemptionsShort goroutines done byLongest wait
Go 1.14 and later311.6 ms11.0 ms
Before Go 1.14041.5 ms41.0 ms

In the ping-pong workload on 2 Ps, the two goroutines exchanging messages ran back to back through runnext and were preempted 3 times between them, because they share one time slice. The three busy goroutines were preempted too, so the longest any of the pair waited to run was 10.0 ms.

How the simulation works

The model steps in 10 µs ticks and follows the rules in runtime/proc.go: runqput and its spill to the global queue, the order of checks in findRunnable including the every-61st global check and the batch size, runqsteal taking half with four passes in random order, runnext inheriting the time slice, sysmon's 10 ms preemption and its syscall retake and handoff. It leaves out garbage collection, timers and the network poller, lets every idle P look for work on every tick (the runtime limits how many threads spin), and runs sysmon every 20 µs (the real one backs off to 10 ms when the program is idle). Context switches and steals cost no time.

It is checked headless: in every workload with 1 to 8 Ps, with and without stealing and preemption, every goroutine runs to completion, the CPU time used equals the work requested, and no goroutine is ever in two places at once.

History

Go was released in 2009 with a scheduler that used a single global run queue protected by a lock. In 2012 Dmitry Vyukov wrote the "Scalable Go Scheduler Design Doc", which introduced the P, per-P run queues and work stealing; his "runtime: improved scheduler" change landed in March 2013 and shipped in Go 1.1. Preemption at function calls followed in Go 1.2, asynchronous preemption by signal in Go 1.14 (February 2020), and Go 1.25 (August 2025) made the default GOMAXPROCS follow a container's CPU limit on Linux.

Go scheduler questions and answers

What are G, M and P in the Go scheduler?

G is a goroutine: a function with its own small stack and saved registers. M is an OS thread. P is a processor, a scheduling context that holds a run queue of goroutines; an M must hold a P to run Go code. There are GOMAXPROCS Ps, so at most that many goroutines run Go code at once, while Ms can outnumber Ps when some are blocked in system calls.

How does work stealing work in Go?

A P that runs out of goroutines first checks the global queue, then picks other Ps in random order and takes half of one’s local run queue, trying up to four times and, on the last try, the victim’s runnext slot too. On this page’s fan-out workload with 4 Ps, stealing finishes in 12.1 ms; with stealing switched off, everything runs on the P that spawned the goroutines and takes 48.1 ms.

Is Go’s scheduler preemptive?

Yes. A background thread called sysmon preempts any goroutine that has held its P for 10 ms. Before Go 1.14 that only took effect at a function call, so a loop without calls could hold a P indefinitely; since Go 1.14 the runtime interrupts the thread with a signal. In this page’s tight-loop workload on one P, the other goroutines finish by 11.6 ms with asynchronous preemption and 41.5 ms without it.

What happens when a goroutine makes a blocking system call?

The goroutine’s thread blocks in the kernel and keeps the goroutine. If the call lasts beyond a sysmon tick (at least 20 µs) and there is other work, sysmon takes the P away and gives it to another thread, starting a new one if none is idle. That is why a Go program can have many more threads than GOMAXPROCS: the syscall workload here, with 2 Ps, used 8 threads.

What is runnext in the Go scheduler?

A one-goroutine slot on each P that is checked before the local run queue. A newly started goroutine, or one woken by a channel operation, goes into runnext, so it runs next on the same P and finds the data it shares with its waker still in cache. It inherits the remaining time slice, so two goroutines handing work back and forth cannot starve the rest.

What is the default GOMAXPROCS?

The number of logical CPUs. Since Go 1.25, on Linux, the default is lowered to the container’s CPU limit (the cgroup CPU bandwidth limit) when that is smaller, and the runtime updates it if the limit changes, unless GOMAXPROCS is set explicitly.

Further reading

Published Updated