Structured Concurrency Kickoff

I actually like the reified lifetimes that nurseries give you. I just posted some of the reasons why in the ZIO thread. And of course you also need them for objects that encapsulate a nursery. E.g. in Trio you write:

async with open_websocket("https://...") as ws:
    message = await ws.receive()
    await ws.send("reply")

Here the async with open_websocket internally opens a nursery, whose scope extends over the open_websocket’s block. The ws object internally holds a reference to this nursery, so it’s helpful that it is an object :-). But code inside the async with block doesn’t have any way to access this nursery directly. (For example, it can’t spawn new tasks into it.) So reified lifetime objects are really useful for encapsulation and abstraction.

Also note that you can construct a reified nursery from an implicit nursery, though it’s kind of awkward:

# In a made-up language with `go` statements that are implicitly scoped to the
# surrounding function
def pseudo_nursery_manager(nursery):
    while True:
        thunk = nursery.receive()
        go thunk

Now whenever I want a nursery object that I can pass around, I write:

def uses_pseudo_nursery():
    nursery = open_channel()
    go pseudo_nursery_manager(nursery)
    # These both get to spawn tasks into my psuedo-nursery by
    # sending on the channel
    foo(nursery)
    bar(nursery)

Doing things this way is clunky and awkward, but you haven’t actually stopped people from shooting themselves in the foot if they really try :-).

Anyway… disallowing “dynamic” / “heap-allocated” nurseries actually works pretty well for us. And you have to admit: heap-allocated nurseries are a wishy-washy compromise that lets unstructured control-flow leak into your language. Have the courage of your convictions :wink:

I think the key thing you need to make Trio’s approach practical is to have a language that lets users define their own “block types”. So like in Python, anyone can invent a new kind of with block. And that’s what allows open_nursery to be encapsulated inside some user-defined function like open_websocket – they just have to make their function a with block.

In many modern languages, the way you would do this is instead to use some kind of closure/block syntax. Like in JS you’d probably make the primitive

withNursery(nursery => {
    # ... code that uses nursery ...
})

And then for the websocket, you’d do:

withWebsocket(url, ws => {
    # ... code that uses ws ...
})

There are similar idiomatic features in Ruby, Swift, Rust, etc. I think this is the key feature that C is missing, that’s making your life difficult.

This is actually what Trio does when it gets a Ctrl+C at an awkward time where it can’t deliver it immediately – it sets a flag, and then uses its cancellation system to inject the KeyboardInterrupt into the main task at the next available opportunity. But, this isn’t for the reason you suggest :-). Trio does it this way because it needs to deliver it to some task, and the main task is guaranteed to always be there, so it’s a convenient choice. But it doesn’t help with the issue you’re thinking of: in Trio the main task is not really special with respect to exception handling, and the KeyboardInterrupt could still get lost, if one of the main task’s children crashes while the KeyboardInterrupt is propagating.

Oh sorry, when I said “common vocabulary”, I meant, a generic way for all Trio apps to talk about it – so if my app has an embedded HTTP server, an embedded websocket server, and something else, and they’re all written by different third parties, then it’s very helpful if there’s a standard uniform way to say “All right all of you, do a graceful shutdown”.

I’d like to hear more about this! I was thinking it seemed like a pretty small and natural extension, that fits naturally with the “structured thing”; since we already have a way to deliver a cancellation at a branch of the task tree, extend that mechanism to deliver soft-cancellations as well.

We’ve experimented with “user space” implementations of this. But a channel is pretty awkward here. Take our accept loop:

while True:
    conn = listener.accept()
    nursery.start_soon(handler, conn)

The accept call might block indefinitely. But when the graceful shutdown is requested, we want the accept call to exit immediately (while any handlers are allowed to keep running of course). So if we use a channel for this, then it means we need some kind of accept-a-socket-or-else-receive-from-a-channel operation, which is really difficult. (For regular OS sockets it’s possible, if you go all concurrent ML, but that’s a whole pile of complexity that you don’t need just for this use case, and it doesn’t necessarily work for cases where listener is more complicated than a bare OS socket.)

Instead, we’d write:

while True:
    with cancel_if_graceful_shutdown_requested as cancel_scope:
        conn = listener.accept()
        nursery.start_soon(handler, conn)
    if cancel_scope.was_cancelled:
         # Graceful shutdown requested
        break

Looking forward to it :slight_smile:

1 Like