Concurrency, 7 of 8
Coroutines and Schedulers
Write sequences that take time as Async Functions that Await Wait, and run them on a Scheduler you tick yourself.
What you’ll build
Two countdowns that run side by side, each waiting between its numbers, written as plain loops.
Then a fade from 100% to 0% on a Scheduler of the program’s own, whose time the program moves
on itself, so it can run slower, faster, or stop.
A coroutine
A coroutine is an Async Function that waits for time rather than for I/O. Await Wait(time)
pauses it for that long without blocking the thread, so a sequence that takes seconds is written
as the steps it is. A time is std.time’s Duration, like 200.Milliseconds or 2.Seconds.
Import std.core
Import std.time
Async Function Countdown(name: String, from: Int, gap: Duration)
For i = from To 1 Step -1
Print("{name}: {i}")
Await Wait(gap)
End For
Print("{name}: go")
End Function
Countdown("slow", 2, 500.Milliseconds)
Countdown("fast", 3, 200.Milliseconds)
Print("top-level code is done")slow: 2 fast: 3 top-level code is done fast: 2 fast: 1 slow: 1 fast: go slow: go
Each call runs its countdown until its first Await, then returns its Task, so both have printed
their first number before the last line of top-level code. When top-level code ends, the main
scheduler runs the coroutines that are still waiting until none is left: fast goes on every
200 milliseconds, and slow every 500, on the main thread, one at a time.
The main scheduler
The main scheduler resumes the coroutines on the main thread. It runs by itself, at these points only:
- at an
Awaitin top-level code, while its Task isn’t done; - at a Task’s
Wait()on the main thread, while the Task isn’t done; - once top-level code ends, until no coroutine waits on it;
- at
Scheduler.Main.Step(), which The main thread uses in a loop of the program’s own.
Nothing else runs it. An Await in an Async Function only pauses that coroutine: the code that
called it, or the scheduler that resumed it, goes on. So a coroutine never runs in the middle of
other main-thread code, and main-thread code needs no locks. A call that blocks, like std.time’s Sleep, doesn’t run it
either:
Import std.core
Import std.time
Async Function Note(text: String)
Await Wait(10.Milliseconds)
Print(text)
End Function
Note("the coroutine goes on")
Sleep(50.Milliseconds)
Print("after Sleep")
Await Wait(1.Milliseconds)
Print("after the Await")after Sleep the coroutine goes on after the Await
The coroutine’s 10 milliseconds are up during the Sleep, but it waits until the top-level
Await runs the main scheduler.
A Scheduler of your own
Scheduler() makes a scheduler whose time moves only when the program says. Run(task) moves a
coroutine onto it, and Tick(seconds) moves its time on by that much and resumes whatever is due.
This fade waits two seconds between steps, in its scheduler’s time:
Import std.core
Import std.time
Async Function Fade(name: String)
For level = 100 To 0 Step -25
Print("{name} at {level}%")
Await Wait(2.Seconds)
End For
End Function
Local playback = Scheduler()
playback.Run(Fade("title"))
For step = 1 To 7
Local seconds = step <= 4 ? 1.0 : 2.0
Print("tick {step}: {seconds} s")
playback.Tick(seconds)
End For
Print(playback.IsIdle())title at 100% tick 1: 1 s tick 2: 1 s title at 75% tick 3: 1 s tick 4: 1 s title at 50% tick 5: 2 s title at 25% tick 6: 2 s title at 0% tick 7: 2 s True
The first four ticks are a second each, so the fade takes a step every other tick. The ticks
after that are two seconds each, and the fade takes a step every tick, twice as fast. A tick
resumes a coroutine once at most, however long it is. No real time passes: a Scheduler’s time is
only what its ticks add, which is what pausing and speeding things up need. Not ticking it pauses
everything on it, and IsIdle() says when nothing waits on it.
Await Wait(time) counts the time of the scheduler the coroutine runs on, with its pauses and
speed, and Await RealWait(time) counts the real clock’s, ignoring both.
What the main scheduler can’t do
The main scheduler’s time is the real clock’s, so ticking it is an error, which says what to call instead:
Import std.core
Scheduler.Main.Tick(1.0)Compiling main.fb main.fb:3:1: error: Scheduler.Main's time is the real clock's, which a Tick can't move; call Scheduler.Main.Step() instead | 3 | Scheduler.Main.Tick(1.0) | ^^^^^^^^^^^^^^^^^^^ error: couldn't build main.fb because of 1 error
And coroutines run only on the main thread, so work on another thread can’t start one:
Import std.core
Import std.time
Async Function Later()
Await Wait(1.Seconds)
End Function
Spawn(Function() => Later())Compiling main.fb main.fb:8:1: error: this work runs on another thread, but it calls 'Later', which is Async, so its code runs on the main thread | 8 | Spawn(Function() => Later()) | ^^^^^ main.fb:8:21: note: here | 8 | Spawn(Function() => Later()) | ----- error: couldn't build main.fb because of 1 error
What you learned
- A coroutine is an Async Function that
AwaitsWait(time)between its steps. - The main scheduler resumes coroutines on the main thread, at top-level Awaits and
Wait()s of Tasks that aren’t done, at Steps, and at the end of top-level code. Scheduler()makes a scheduler whose time moves only withTick, for pausing and scaling time.Run(task)moves a coroutine onto a Scheduler, andRealWaitwaits by the real clock.
The rules are in the language reference.
Report an issue with this page or with FutureBlitz on GitHub.