// 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 pts

Build 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:

  • Run maintains a FIFO run queue, initially holding the tasks in the order Go registered them.
  • Run pops the front task and hands control to it. The task runs until it calls yield() or its function returns. On yield() the task goes to the back of the queue; on return it is removed.
  • Exactly one task executes at any instant, and Run itself 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.
  • Run returns when the queue is empty. Go is only called before Run.

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.