From 97f9e826a0df581fa238c24a944400e0410c8360 Mon Sep 17 00:00:00 2001 From: Simon Heath Date: Sun, 16 Jul 2023 17:07:16 -0400 Subject: [PATCH] 300 lines of coroutine thonks --- thoughts.md | 290 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) diff --git a/thoughts.md b/thoughts.md index 113832a..a324ba6 100644 --- a/thoughts.md +++ b/thoughts.md @@ -1718,3 +1718,293 @@ Hey I'm trying not to do full dependent types, stop tempting me :-p RazzBerry — Yesterday at 4:12 PM lol ``` + +# Coroutines + +Sooooo I would love to have Lua-like coroutines in a language, because I think it's a very powerful control primimtive you can build lots of cool shit out of. However, my understanding of coro's basically requires a garbage collector, because the potential lifetimes are so insane when you can treat control flow as data, you *have* to be able to work it out at runtime. But Graydon's blog post of "what he wanted Rust to be" mentioned non-escaping coroutines, so let's play with 'em a little + +First, "non-escaping". It means exactly what you think it means. Rust's closures, for example, are non-escaping by default; if they get stuck into a data structure or returned in such a way they can outlive their captured environment then they have to be explicitly boxed and stuffed onto the heap. So a non-escaping coroutine is a coroutine that has to exist on the stack. + +After some contemplation, the simplest way to make sure a coroutine non-escaping is to make it unable to be moved. You can still pass references to it around, but I think by definition the borrow checker will keep us safe though. And if you can't have them recurse then their stack is fixed size. That's a pretty harsh restriction though, so let's play with it and see what its limitations are in practice. + +First, syntax and semantics. A coroutine is declared like a function, with `coro` instead of `fn`. This is probably unnecessary but fine for describing intent right now. A coroutine is an object that must be created, so we'll just say there's a `create()` function that does that. A coroutine can take arguments, so we'll make the resume syntax be `resume coro_object(a, b, c)` which is a little off-beat compared to Lua but for now I wanna make it clear that resuming a coroutine is *not* like calling a function. Then a coroutine can `yield`, which is treated just like a `return` expression and the "return type" of a coro is just like a function's. We have stackful coroutines, because stackless ones are "weaksauce" per technomancy, which is still a moniker I love. So a `yield` yields up to the latest `resume`, not just the immediate caller. The Revisiting Coroutines paper calls these "full coroutines". + +Ok, main use cases for coroutines: + + * Iterators + * Nonlocal error handling/debugging -- exceptions, conditions, etc + * State machines + * Nonblocking I/O + +Let's try these out. + +``` +coro range(from Size, to Size) Option(Size) = + let mut current = from + while current < to do + yield Some(current) + current += 1 + end + None +end +``` + +Ok one immediate thing: it'd be nice to have different args for creating coroutines and resuming them? That's a bit of an odd thonk, but I suppose passing them in with each resume is equivalent. Lua does not provide "creation" args. So I guess that's fine here. + +Notably that's also actually somewhat simpler than I expected because you can put the `current += 1` in the place you actually want it to and it Just Works. + +Using it, without syntactic sugar: + +``` +let mut iter = create(range) +while let Some(i) = resume iter(0, 10) do + println(i) +end +``` + +With some sugar that makes a for loop just do `while let Some(i) = resume ...`: + +``` +let mut iter = create(range) +for i in iter(0, 10) do + println(i) +end +``` + +Creating the iterator explicitly is still kinda weird, but we could have a for loop that sugars that up somehow anyway, so let's not worry about it. It would just make the for loop turn `for i in range(0, 10)` into `let mut thing = create(range); while let Some(i) = resume thing(0, 10)`, which feels a little over-fancy but is probably fine. + +Ok, fancier iterator stuff, let's iterate over a list: + +``` +coro list_iter(l &List(T)) Option(&T) = + let mut idx = 0 + while idx < len(l) do + yield l[idx] + idx += 1 + end + yield None +end + +let some_list = ... +let mut iter = create(list_iter) +for itm in iter(some_list) do + ... +end +``` + + +Should it end with `return` or `yield`? I guess they are the same, it calls up the question of what resuming a finished coroutine does. In Lua it just returns an error. Soooo basically calling `resume` always returns Option or Result or something like that. Rust's coroutine proposal does basically that, it has a trait that has `Yielded` and `Finished` associated types. + +Ok wait no I am misunderstanding args. At least by Lua's semantics, coroutines take no args when they are created. When they are `resume`'d the first time, the function params are set to the values passed to `resume`. Then when they `yield` and are `resume`'d again, the values passed to the `resume` call are the return result of the `yield` invocation. This means that *every call to `resume` may have a different signature*. + +Well I don't see any sane way to do that in a statically-typed language, so for the moment I'm just gonna press on with the semantics I've been using: `resume thing(x)` sets the input arg of `thing` to `x` and `yield` returns no value. Otherwise you have to know exactly what state your coroutine is in to know what args to pass it, which seems a little bonkers to begin with. How does Rust's coroutine proposal handle this I wonder? Let's take a look. ...Ok, it does exactly what I did: coroutines take no parameters on `create` and on `resume` the parameteres they are given are bound to the arg list. There's a fair amount of bikeshedding discussed there though: + +We *could* make it work the other way around I suppose, with `yield` returning a value, and if that's the case then it may work better with a Rust-y postfix syntax. +That's more or less the sort of thing I'm interested in figuring out here anyway, but I'll need more sophisticated examples to do so. + +Let's look back at our `list_iter` example. This has some interesting basic borrowing and data properties. The lifetimes of the list and the option returned are trivially the same and this seems to work out correctly. What is the lifetime of the coroutine? Well it doesn't store `l` in its local state, so its lifetime is in fact completely disconnected from `l`. This is actually not great for us because we have to pass the correct `some_list` into it on each resume, rather than Rust where an iterator's lifetime is tied to the thing it is iterating. You still can't invalidate the iterator by modifying `some_list` inside the for loop, because `itm` borrows `some_list`, but if you just happen to call `resume iter(some_other_list)` then you will get Safe but nonsense results. You could make the `for` loop borrow `iter`, but it's still rather more fragile than I want. + +So let's explore another model: `create` takes args that are bound to the coroutine's params, `resume` takes args that are returned from `yield`, and `yield` takes values that are returned from the `resume`. This is a little weird 'cause it means our coroutine has literally two sets of function params, but let's just roll with it for now. + +``` +-- takes no args to `yield` and returns `Option(&T)` +-- so this is really mostly unchanged. +coro list_iter(l &List(T)) () yields Option(&T) = + let mut idx = 0 + while idx < len(l) do + yield l[idx] + idx += 1 + end + yield None +end + +let some_list = ... +let mut iter = create list_iter(some_list) +-- here `iter` borrows `some_list` +for itm in iter do + ... +end + +-- desugars to: +while let Some(itm) = resume iter do + ... +end +``` + +Here, the `resume` calls of the `for` loop don't pass any data into the coroutine so it's not terribly interesting yet. Let's try to express a state machine... Lemme think about a simple autonomous drone flight controller: + +``` +Happy path: +startup -> waiting on ground -> taking off -> flying -> finding LZ -> landing -> waiting on ground again + +Unhappy paths: +finding LZ -> error-hover (no LZ) +error-hover -> pilot control +pilot control -> waiting on ground (pilot landed, waiting for new mission) +pilot control -> flying (pilot gave new mission, resume flying) +``` + +``` +coro startup() = + init_hardware() + let next = create waiting_on_ground() + yield next +end + +coro waiting_on_ground() = + while not mission_received() then + yield self + end + + let next = create taking_off(get_mission()) + yield next +end + +coro taking_off(mission) = + while not at_target_altitude(mission) do + keep_taking_off() + yield self + end + let next = create flying(mission) + yield next +end +``` + +Ok first off this massively violates our "no moving coroutines" assumption, so there's that. + +Second off this sort of control loop to drive the state machine needs to make a few design decisions: basically, who does the blocking, who does the sleeping, and who does the state transitions... So let's write that and see how those shake out. + +``` +fn control_drone() = + let mut current_state = create startup() + loop + let new_state = resume current_state() + current_state = new_state + show_stats_to_pilot() + delay_to_next_timeslice_if_necessary() + end +end +``` + +Ok that is actually really simple. This is a basic polling architecture, so the coroutines can't block and must return a new coroutine that is the next state to go to. Let's proceed. + +Second, apparently I really want `mission` to be passed in as an arg to the coro when it is created, and have no args to it when it is resumed, because there are coro's that don't give a rat's ass about the current mission. + +Third, apparently `self` is a thing I want my coroutines to be able to say. Though they could just as well yield `Option(NextState)` I suppose. Let's do that, I wanna see how it looks. So our control loop instead looks like this: + +``` +fn control_drone() = + let mut current_state = create startup() + loop + if let Some(new_state) = resume current_state() then + -- oops, again we must be able to move a coro here! + current_state = new_state + end + show_stats_to_pilot() + delay_to_next_timeslice_if_necessary() + end +end +``` + +``` +coro flying(mission) = + loop + if mission_is_done(mission) then + let next = create find_lz(target_area) + yield Some(next) + elif close_enough_to_next_waypoint(mission) then + increment_waypoint(mission) + yield None + else + fly_towards_next_waypoint(mission) + yield None + end + end +end + +coro find_lz(target_area) = + let mut time_spent_searching = 0 + loop + scan_area() + update_map_with_new_data() + time_spent_searching += TIMESLICE_DURATION + + if lz_is_known_unlandable() then + let next = create error_hover("LZ has no landable areas") + yield Some(next) + elif time_spent_searching > FIND_LZ_TIMEOUT then + let next = create error_hover("LZ search timed out") + yield Some(next) + elif good_lz_found() then + let next = create landing(get_good_lz()) + yield Some(next) + else + -- LZ might have a landable point and we can keep searching for it + yield None + end +end + +-- Let's make this a little fancier than it needs to be +-- by worrying about the fact that we actually need to go to the LZ +-- before descending. +coro landing(target_lz) = + while not hovering_over(target_lz) do + fly_towards_landing_waypoint(target_lz) + yield None + end + -- we are now over the LZ + while not on_ground() do + descend_carefully() + yield None + end + -- We are landed! + powerdown_motors() + let next = waiting_on_ground() + yield Some(next) +end + +coro error_hover(message) = + clear_current_mission() + while not pilot_is_in_control() do + if we_have_more_battery() then + try_to_stay_stationary() + set_status_message(message) + yield None + else + -- not much we can do here except attempt to crash gently + descend_carefully() + set_status_message("Goodbye cruel world!") + yield None + end + end + -- Whew, pilot has taken control + let next = pilot_control() + yield Some(next) +end + +coro pilot_control() = + loop + -- Pilot has toggled switch to give them manual control + if pilot_is_in_control() then + manual_flight() + yield None + elif on_ground() then + -- pilot has landed us and given up control + let next = waiting_on_ground() + yield Some(next) + elif mission_received() then + -- pilot has uploaded a new mission and given up control + let next = create flying(get_mission()) + yield Some(next) + else + -- pilot has given up control without telling the drone what to do + let next = create error_hover("I thought you had my back ;_;") + yield Some(next) + end + end +end +``` + +Jeez that was way more of a production than I expected it to be. But it also shows a few things off. These coroutines *need* to create and return new coroutines. However, you could do this by passing in an arena or other memory allocator, and since none of these coroutines borrow jack shit then you don't need the borrow checker to worry about anything. `create()` can make these on the heap, they are freed appropriately when the driver function replaces an old coro with a new one -- however they are not the same size so they would need to be boxed. + +You could also have a "current state" union/enum-ish thing that is created by the driver and holds a union of the memory required for each coroutine, and lets coroutines replace themselves (or have space for two coro's, current and next). Or you can just have an enum of different coroutine options explicitly, at which point it becomes not much different from your normal enum-driven state machine. Hmmmm. -- 2.45.2