// lesson: the-run-queue
The Run Queue
Where do runnable goroutines actually wait their turn? Each P owns a local
run queue: a fixed 256-slot ring buffer, plus one special runnext slot
for a goroutine that should run immediately (that's where a freshly-unblocked
channel receiver goes). Overflow โ and Gs with no P affinity โ lands on a
mutex-protected global run queue. Every M loops forever in schedule():
// The scheduler, squinted at:
for {
var gp *g
// Every 61st tick, check the global queue *before* the local
// ring, so a busy P can't starve the global queue forever.
if schedtick%61 == 0 {
gp = globrunqget()
}
if gp == nil {
gp = runqget(pp) // local ring โ the common, lock-free case
}
if gp == nil {
gp = findRunnable() // global queue, netpoll, then steal from other Ps
}
execute(gp) // gogo(&gp.sched) โ does not return until gp stops
}
execute hands the CPU to the goroutine, and here is the part this lesson
is really about: the scheduler only gets the CPU back when the goroutine
gives it back. A G re-enters the scheduler at defined points โ it blocks
(gopark), it finishes (goexit), it calls runtime.Gosched(), it hits a
stack check that doubles as a preemption point. Between those points it owns
the thread outright. This is cooperative scheduling, and it was essentially
the whole story before Go 1.14 (a tight for {} loop could famously wedge
the garbage collector). The next lesson covers the preemptive backstop; here
we build the cooperative core, because it is the core.
The handoff pattern
You will now build a scheduler in userspace, and the trick is worth spelling out carefully, because it's the same trick you'll use in the final challenge.
We can't save and restore registers from Go code. But we don't need to: the runtime already knows how to park and resume goroutines โ through channels. So we represent each task as a real goroutine that is almost always parked, and enforce this invariant:
At most one goroutine โ either the scheduler or exactly one task โ is ever doing observable work. Everyone else is parked on a channel receive, or has already done its part and is on its way to park.
Give each task an unbuffered resume channel, and the scheduler one park
channel shared by all tasks:
- Scheduler resumes a task:
t.resume <- struct{}{}โฆ then immediately blocks on<-park. The send wakes the task; the receive parks the scheduler. Control has been handed off โ one runnable goroutine, still. - Task yields:
park <- struct{}{}โฆ then immediately blocks on<-t.resume. Mirror image: scheduler wakes, task parks.
Each unbuffered send is a synchronous rendezvous: the receiver wakes with
the value (remember the direct stack-to-stack copy from last lesson), and
the sender's very next statement is its own blocking receive. Strictly
speaking both sides are runnable for that instant โ but the handing-off
side does nothing observable between waking its partner and parking, and
that is all the invariant needs. A yield is therefore two handoffs:
task โ scheduler (decide who's next) โ next task. That's a real context
switch, built from parts you already understand โ and because at most one
goroutine is ever doing observable work, execution is fully deterministic
even though GOMAXPROCS might be 32. Concurrency without parallelism, on
purpose.
A finished task is just a task whose function returned: signal the scheduler
one last time and never wait for resume again.
โบ A Round-Robin Cooperative Scheduler
40 ptsBuild the heart of this course: a deterministic, single-threaded, cooperative round-robin scheduler.
type Sched struct { /* ... */ }
func NewSched() *Sched
func (s *Sched) Go(f func(yield func())) // register a task (before Run)
func (s *Sched) Run() // run all tasks to completion
Required semantics โ these fully determine the execution order:
Runmaintains a FIFO run queue, initially holding the tasks in the orderGoregistered them.Runpops the front task and hands control to it. The task runs until it callsyield()or its function returns. Onyield()the task goes to the back of the queue; on return it is removed.- Exactly one task executes at any instant, and
Runitself doesn't proceed while one does. Task bodies may therefore touch shared data structures without any locking โ that's the property the tests exploit, and the property that makes this a scheduler rather than a thread pool. Runreturns when the queue is empty.Gois only called beforeRun.
So three tasks A, B, C where A yields twice, B yields once, and C never
yields execute as: A0 B0 C0 A1 B1 A2.
Implement it with the lesson's handoff pattern: spawn one goroutine per task,
parked on its own resume channel; yield is a send on the shared park
channel followed by a receive on resume. Don't let a new task's goroutine
run before its first turn โ park it on resume before calling f. And
make sure the "task finished" signal is distinguishable from "task yielded"
(a flag written before the final park-send is safely visible to the
scheduler: the channel handoff orders it).
Log in to submit a solution and earn points.