Go's runtime multiplexes many goroutines onto a small number of OS threads by cooperatively scheduling them -- when a goroutine blocks or explicitly yields, the scheduler picks the next one to run. This exercise models that same idea: single-threaded, no real parallelism, but with one real complication real schedulers can't avoid -- not every task is ready every time it's its turn.
Each "task" here is a Python generator -- calling next(task) resumes it until
its next yield, which is exactly a task voluntarily giving the CPU back. A
task can also yield Sleep(n): a request to skip its next n OTHER tasks'
turns before it's eligible to run again (this exercise's stand-in for "waiting
on a timer" or "blocked on I/O" -- the two most common reasons a real
goroutine/thread isn't ready even though it isn't finished).
Implement run_scheduler(tasks): run every task to completion in round-robin
order among whichever tasks are currently ready, returning every yielded
value that ISN'T a Sleep instance, in the exact order it was produced.
The missing link: "ready" is not the same as "not done"
A naive round-robin scheduler only has two states per task: running, or finished. That's enough for the plain interleaving case, but it breaks the instant a task needs to wait for something -- there's nowhere to put a task that isn't finished but also isn't ready for its next turn right now. This exercise forces a third state (sleeping, with a wake time) and, more importantly, forces you to handle the case where EVERY ready-seeming task is actually still asleep: a scheduler that just spins in that case burns CPU doing nothing, and one that gives up and returns early drops real, pending work. The correct move -- skip straight to the next wake time -- is exactly what real timer wheels and event loops do instead of busy-waiting.
Example
def counter(name, n):
for i in range(n):
yield f"{name}{i}"
run_scheduler([counter("a", 3), counter("b", 2)])
# -> ["a0", "b0", "a1", "b1", "a2"] -- b finishes first, a keeps going alone
Constraints
- "Round-robin" means: among tasks that are ready RIGHT NOW, give each one turn per pass, in rotation order, before any of them gets a second turn.
- A
Sleep(n)yielded by a task means: don't give this task another turn untilnturns (of any task) have happened. It is a scheduling instruction, never real output -- it must not appear in the returned list. - If, at some point, no task is currently ready but at least one is asleep, the scheduler must advance directly to the earliest wake time rather than looping or terminating early.
- A task that raises
StopIterationon its next turn simply produces no more output and is removed -- this isn't an error case, it's just how a generator signals "I'm done."
Real-world grounding
- Go's scheduler (goroutines multiplexed onto OS threads, with a network
poller that wakes goroutines blocked on I/O):
src/runtime/proc.go
Go's real scheduler is M:N (many goroutines onto many OS threads, with work
stealing between threads) and goroutines yield implicitly at function calls,
channel operations, and blocking I/O -- not just at explicit yield points,
and its timers are a real min-heap, not a linear scan. But the core shape --
ready tasks round-robin, sleeping tasks wait for a wake time, and the
scheduler skips idle time instead of spinning -- is exactly what you're
building here.