// lesson: going-mn

Going M:N

One P and its run queue gave us determinism. The real runtime wants the opposite: GOMAXPROCS Ps chewing through goroutines in parallel, with no single point of contention. Two design decisions in Vyukov's scheduler make that scale:

Distributed queues + work stealing. A single shared run queue would mean every M fighting over one lock (this was, roughly, the pre-Go-1.1 scheduler, and it was a bottleneck). Instead each P has its own lock-free ring, and an M that runs dry goes hunting in findRunnable: global queue, netpoll (a non-blocking check of the OS's readiness notifier โ€” epoll on Linux, kqueue on BSD/macOS โ€” for goroutines that were parked waiting on a network connection and have since become ready), then up to four rounds of picking random victim Ps and stealing half their run queue. Stealing half amortizes: one steal buys a while of quiet.

Emptying your run queue shouldn't mean parking the thread immediately โ€” new work might land a microsecond later, and unparking a thread is far more expensive than finding work that's already there. So an M that runs dry first becomes spinning: it keeps looking (other Ps' queues, the global queue, the netpoller) for a bounded time before actually parking. This one convention fixes two opposite failure modes at once. Without it, either every idle M would try to grab the same newly-readied goroutine the instant it appears โ€” a thundering-herd stampede of threads waking for one G โ€” or the runtime would only wake a thread lazily and risk a lost wakeup: work appears while nothing is looking for it, and nobody notices until something else happens to poll. The rule that resolves both: waking a new (previously parked) thread to spin is conservative โ€” it only happens when no spinning thread already exists โ€” while an M that's already running is allowed to become spinning on its own as it hunts for work (capped at roughly half the busy Ps, so idle CPUs stay bounded). Net effect: new work almost always finds a thread already searching for it, so most readies don't need to wake anyone at all.

A watchdog thread: sysmon. Cooperative scheduling has failure modes, so the runtime runs one M without a P โ€” it never runs Go code โ€” in a loop (sleeping 20ยตsโ€“10ms) doing what parked Ms cannot:

  • Preempting hogs. Any G running >10ms gets flagged: stackguard0 is poisoned so the next function-prologue stack check traps into the scheduler; since Go 1.14 sysmon also sends the thread a signal (SIGURG), whose handler parks the G at the nearest safe point โ€” asynchronous preemption, no cooperation needed. The tight-loop wedge is gone.
  • Rescuing Ps from syscalls. An M stuck in a blocking syscall drags its P with it; sysmon notices and hands the P to another M (handoffp), so 10,000 goroutines doing file I/O don't strand the scheduler.

The shape to internalize: N workers, one source of work, work conservation โ€” no worker idles while tasks wait, and the number of executors is fixed regardless of the number of tasks. That decoupling ("how many things can happen at once" vs "how many things there are to do") is the entire point of M:N. Your challenge is its minimal form: the classic bounded worker pool. You'll build the contended single-queue version the runtime rejected โ€” for a fixed worker count it's perfectly good engineering (a buffered channel is a fine queue at this scale); just know that per-worker deques + stealing is what it grows into when workers multiply and the queue becomes the hot spot.

One correctness point worth sweating: worker lifetime. The runtime never leaks Ms; your pool must not leak goroutines. "Queue is closed and drained" is the shutdown signal, and RunOn must not return until every worker has finished its last task and exited.

โ€บ An M:N Worker Pool

30 pts
func RunOn(workers int, tasks []func())

Execute all tasks using exactly workers concurrently-running worker goroutines pulling from one shared FIFO queue. Required semantics:

  • Every task runs exactly once. RunOn returns only after all tasks have completed and all worker goroutines have exited (no leaks).
  • Exactly workers workers: under load the number of tasks executing concurrently reaches workers and never exceeds it. The tests measure this behaviorally with a high-water mark and a rendezvous barrier that only workers concurrent tasks can pass.
  • The queue is FIFO: with workers == 1, tasks execute in slice order.
  • Assume workers >= 1; tasks may be empty. Tasks may block; don't let one slow task stall the other workers.

The clean implementation is a buffered channel pre-loaded with all tasks and then closed, plus workers goroutines ranging over it. You may use sync.WaitGroup here โ€” you built one in lesson 1; you've earned it.

Log in to submit a solution and earn points.