[Async-sig] New blog post: Notes on structured concurrency, or: Go statement considered harmful

Nathaniel Smith njs at pobox.com
Fri Apr 27 01:08:33 EDT 2018


On Wed, Apr 25, 2018 at 3:17 AM, Antoine Pitrou <solipsis at pitrou.net> wrote:
> On Wed, 25 Apr 2018 02:24:15 -0700
> Nathaniel Smith <njs at pobox.com> wrote:
>> Hi all,
>>
>> I just posted another essay on concurrent API design:
>>
>> https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
>>
>> This is the one that finally gets at the core reasons why Trio exists;
>> I've been trying to figure out how to write it for at least a year
>> now. I hope you like it.
>
> My experience is indeed that something like the nursery construct would
> make concurrent programming much more robust in complex cases.
> This is a great explanation why.

Thanks!

> API note: I would expect to be able to use it this way:
>
> class MyEndpoint:
>
>     def __init__(self):
>         self._nursery = open_nursery()
>
>     # Lots of behaviour methods that can put new tasks in the nursery
>
>     def close(self):
>         self._nursery.close()

You might expect to be able to use it that way, but you can't! The
'async with' part of 'async with open_nursery()' is mandatory. This is
what I mean about it forcing you to rethink things, and why I think
there is room for genuine controversy :-). (Just like there was about
goto -- it's weird to think that it could have turned out differently
in hindsight, but people really did have valid concerns...)

I think the pattern we're settling on for this particular case is:

class MyEndpoint:
    def __init__(self, nursery, ...):
        self._nursery = nursery
    # methods here that use nursery

@asynccontextmanager
async def open_my_endpoint(...):
    async with trio.open_nursery() as nursery:
        yield MyEndpoint(nursery, ...)

Then most end-users do 'async with open_my_endpoint() as endpoint:'
and then use the 'endpoint' object inside the block; or if you have
some special reason why you need to have multiple endpoints in the
same nursery (e.g. you have an unbounded number of endpoints and don't
want to have to somehow write an unbounded number of 'async with'
blocks in your source code), then you can call MyEndpoint() directly
and pass an explicit nursery. A little bit of extra fuss, but not too
much.

So that's how you handle it. Why do we make you jump through these hoops?

The problem is, we want to enforce that each nursery object's lifetime
is bound to the lifetime of a calling frame. The point of the 'async
with' in 'async with open_nursery()' is to perform this binding. To
reduce errors, open_nursery() doesn't even return a nursery object –
only open_nursery().__aenter__() does that. Otherwise, if a task in
the nursery has an unhandled error, we have nowhere to report it
(among other issues).

Of course this is Python, so you can always do gross hacks like
calling __aenter__ yourself, but then you're responsible for making
sure the context manager semantics are respected. In most systems
you'd expect this kind of thing to syntactically enforced as part of
the language; it's actually pretty amazing that Trio is able to makes
things work as well as it can as a "mere library". It's really a
testament to how much thought has been put into Python -- other
languages don't really have any equivalent to with or Python's
generator-based async/await.

> Also perhaps more finegrained shutdown routines such as:
>
> * Nursery.join(cancel_after=None):
>
>   wait for all tasks to join, cancel the remaining ones
>   after the given timeout

Hmm, I've never needed that particular pattern, but it's actually
pretty easy to express. I didn't go into it in this writeup, but:
because nurseries need to be able to cancel their contents in order to
unwind the stack during exception propagation, they need to enclose
their contents in a cancel scope. And since they have this cancel
scope anyway, we expose it on the nursery object. And cancel scopes
allow you to adjust their deadline. So if you write:

async with trio.open_nursery() as nursery:
   ... blah blah ...
   # Last line before exiting the block and triggering the implicit join():
   nursery.cancel_scope.deadline = trio.current_time() + TIMEOUT

then it'll give you the semantics you're asking about. There could be
more sugar for this if it turns out to be useful. Maybe a .timeout
attribute on cancel scopes that's a magic property always equal to
(self.deadline - trio.current_time()), so you could do
'nursery.cancel_scope.timeout = TIMEOUT'?

-n

-- 
Nathaniel J. Smith -- https://vorpus.org


More information about the Async-sig mailing list