On 08/03/2025 20:22, Edmond Dantes wrote:
For coroutines to work, a Scheduler must be started. There can be only
one Scheduler per OS thread. That means creating a new async task does
not create a new Scheduler.
Apparently, async {} in the examples above is the entry point for the
Scheduler.
I've been pondering this, and I think talking about "starting" or
"initialising" the Scheduler is slightly misleading, because it implies
that the Scheduler is something that "happens over there".
It sounds like we'd be writing this:
// No scheduler running, this is probably an error
Async\runOnScheduler( something(...) );
Async\startScheduler();
// Great, now it's running...
Async\runonScheduler( something(...) );
// If we can start it, we can stop it I guess?
Async\stopScheduler();
But that's not we're talking about. As the RFC says:
> Once the Scheduler is activated, it will take control of the
Null-Fiber context, and execution within it will pause until all Fibers,
all microtasks, and all event loop events have been processed.
The actual flow in the RFC is like this:
// This is queued somewhere special, ready for a scheduler to pick it up
later
Async\enqueueForScheduler( something(...) );
// Only now does anything actually run
Async\runSchedulerUntilQueueEmpty();
// At this point, the scheduler isn't running any more
// If we add to the queue now, it won't run unless we run another scheduler
Async\enqueueForScheduler( something(...) );
Pondering this, I think one of the things we've been missing is what
Unix[-like] systems call "process 0". I'm not an expert, so may get
details wrong, but my understanding is that if you had a single-tasking
OS, and used it to bootstrap a Unix[-like] system, it would look
something like this:
1. You would replace the currently running single process with the new
kernel / scheduler process
2. That scheduler would always start with exactly one process in the
queue, traditionally called "init"
3. The scheduler would hand control to process 0 (because it's the only
thing in the queue), and that process would be responsible for starting
all the other processes in the system: TTYs and login prompts, network
daemons, etc
I think the same thing applies to scheduling coroutines: we want the
Scheduler to take over the "null fiber", but in order to be useful, it
needs something in its queue. So I propose we have a similar "coroutine
zero" [name for illustration only]:
// No scheduler running, this is an error
Async\runOnScheduler( something(...) );
Async\runScheduler(
coroutine_zero: something(...);
);
// At this point, the scheduler isn't running any more
It's then the responsibility of "coroutine 0", here the function
"something", to schedule what's actually wanted, like a network
listener, or a worker pool reading from a queue, etc.
At that point, the relationship to a block syntax perhaps becomes clearer:
async {
spawn start_network_listener();
}
is roughly (ignoring the difference between a code block and a closure)
sugar for:
Async\runScheduler(
coroutine_zero: function() {
spawn start_network_listener();
}
);
That leaves the question of whether it would ever make sense to nest
those blocks (indirectly, e.g. something() itself contains an async{}
block, or calls something else which does).
I guess in our analogy, nested blocks could be like running Containers
within the currently running OS: they don't actually start a new
Scheduler, but they mark a namespace of related coroutines, that can be
treated specially in some way.
Alternatively, it could simply be an error, like trying to run the
kernel as a userland program.
--
Rowan Tommins
[IMSoP]