skip to content

What stops goroutines in Go's global run queue from starving behind the per-P local queues?

level: seniorimportance: nice to knowfreq 26%

answer

  1. local work always wins by default
  2. so the runtime forces an occasional look
  3. a prime number of scheduling ticks
  4. the fast slot borrows the time slice
  5. the monitor thread is the last backstop

basics

~20 s

On every 61st scheduling tick a P takes one goroutine from the global run queue before looking at its own, and a P that empties its local queue drains a global batch. Without the periodic check, local work would win forever.

solid answer

~50 s

Two guards. The scheduler always prefers a P's `runnext` slot and local run queue because they need no lock, so the runtime forces the issue: on every 61st scheduling tick, `schedule` takes one goroutine from the global run queue before consulting the local structures, and a P that empties its local queue drains a batch from the global queue proportional to its share. The second guard covers the LIFO fast path: two goroutines handing off to each other would each land in `runnext` and could loop forever, so a goroutine run from `runnext` inherits the remaining time slice and does not advance the P's schedule tick. That has a sharp consequence — such a chain never reaches its 61st tick either, so what actually breaks it is the runtime's monitor thread preempting a P that has held one time slice for about ten milliseconds. The 61 is simply a prime large enough to be cheap and odd enough not to resonate with program periods.

go deeper

for a junior

Know that a goroutine sitting on the shared global queue still gets to run: the scheduler does not let one P's own backlog crowd it out forever.

for a middle

Be able to name the mechanism rather than gesture at fairness — a periodic forced look at the global run queue, plus a batch drain whenever a local queue empties.

for a senior

Show that you can match each guard to the starvation it prevents: the tick counter for a busy local queue, and time-slice inheritance plus the monitor thread for a runnext hand-off chain.

for a principal

The judgment to own is that fairness backstops belong in the runtime; application code that yields to 'be fair' hides the real stall and usually costs throughput.

## Two ways the global run queue could starve, and two guards Go keeps runnable goroutines in three places: a one-goroutine `runnext` fast slot per P, a 256-entry local run queue per P, and one global run queue shared by every P. The scheduler *prefers* the local structures, because touching them needs no lock — and a strict preference is exactly how starvation happens. Goroutines land on the global queue when a local queue overflows (half of it, plus the new goroutine, move over in one batch), when there is no P available to take a newly readied goroutine, and when a P is retired and its queue is flushed. If a P with a permanently non-empty local queue never looked at the global one, those goroutines would wait forever. ### Guard one: the 61st tick Before consulting its own structures, the scheduler tests the P's schedule tick counter: on every 61st scheduling decision, if the global run queue is non-empty, it takes **one** goroutine from there and runs it. That is the whole guard — cheap, unconditional, and enough to bound how long a global-queue goroutine waits behind a busy local queue. Why 61? Because it needs to be a small prime: large enough that the extra global-lock acquisition is negligible (one in sixty-one scheduling decisions), and odd and prime so that the check does not resonate with any natural period in the program — a power of two would tend to line up with loop counts and batch sizes. A second, less-discussed part of the same guard: a P that empties its local queue drains a *batch* from the global queue rather than a single goroutine — about the global queue's length divided by the number of Ps, plus one, capped at half the local queue's capacity. That keeps the global lock cold while still moving a fair share. ### Guard two: the fast slot borrows the time slice The `runnext` slot is a separate starvation risk of its own shape. It holds the goroutine that this P most recently made runnable, and it is run ahead of the entire local queue. Two goroutines that hand off to each other — a channel ping-pong, a lock hand-off, a request/response pair — would each land in `runnext` as they wake the other, so the pair could run back and forth indefinitely with the local and global queues never getting a look. The runtime handles this by *not advancing the P's schedule tick* when it runs the `runnext` goroutine: that goroutine inherits the remaining time slice of the goroutine that readied it, so the whole hand-off chain counts as one slice rather than refreshing the clock on every hop. Notice the consequence, which is the sharp end of this question: because the tick does not advance, a pure `runnext` chain **never reaches its 61st tick either**. The 61-tick guard cannot break it. What breaks it is the runtime's monitor thread, which watches for a P that has been running the same time slice for about ten milliseconds and preempts it. Once that happens the P goes back through the normal scheduling path, the tick advances, and the global queue is consulted again. ## How to talk about it The clean way to present this is as a layered defence rather than a single rule: 1. A P that runs out of local work drains a share of the global queue immediately. 2. A P that never runs out is forced to take one global goroutine every 61st scheduling tick. 3. A P captured by a `runnext` hand-off chain — which does not tick — is preempted by the monitor thread after the time slice expires, dropping it back into case 2. 4. Meanwhile, idle Ps steal from the *oldest* end of a busy P's local queue, so the goroutines that have waited longest move first. ## Why it matters in practice You will not tune any of this — none of it is exposed. Its value is in reasoning about latency tails. It tells you that a runnable goroutine's worst-case wait is bounded by scheduling ticks and the ten-millisecond preemption interval rather than by application behaviour, and it tells you that "my goroutine never got scheduled" is almost never the correct diagnosis for a stall. The usual real causes are a goroutine that is blocked rather than runnable, a lock it cannot get, or a thread stuck in a long syscall or C call — not the run queues failing to be fair.

  • Why doesn't the 61-tick check rescue a pair of goroutines ping-ponging through runnext?
    Because the tick is exactly what does not advance. Running the `runnext` goroutine is treated as a continuation of the current time slice, so the P's schedule counter stays put and the modulo test never comes round. The chain is broken instead by the runtime's monitor thread, which preempts a P that has been on the same slice for roughly ten milliseconds.
  • How do goroutines end up on the global run queue in the first place?
    Overflow from a full local queue — half of it plus the new goroutine move over as a batch — goroutines readied when no P is available to take them, and the queues of Ps retired when the runtime changes how many Ps it keeps. Nothing routes work there on purpose; it is overflow plus a fairness backstop.
  • What does taking a batch rather than one goroutine from the global run queue buy?
    One acquisition of the scheduler's global lock instead of one per goroutine. A P that empties its local queue takes roughly its share — the global queue's length divided by the number of Ps, plus one, capped at half its local queue's capacity — so the shared lock stays cold even when a burst has overflowed several local queues.

saying these in an interview costs you the question

  • Says the global queue is read only when a P goes idle
  • Claims every scheduling decision polls the global queue
  • Thinks a runnext goroutine gets a fresh time slice
  • Says goroutines are scheduled strictly first in, first out
  • Believes preemption alone keeps the scheduler fair