You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I feel like trio-asyncio's core functionality is been pretty solid for a while, but we're still flailing a bit trying to find the most usable way to expose it to users. I guess right now it has 3 different APIs, we're discussing a 4th, and it's not clear whether any of them is actually what we want? And I'm frustrated that I don't feel like I understand what the pitfalls are or what features users actually need. And my main coping strategy for that kind of frustration is to open an issue and write a bunch of text to organize my (our?) thoughts, so here we go. Hopefully laying out all the information in one place will give us a clearer picture of what the trade-offs actually are, and help us find the best way forward. (4 overlapping APIs and constant churn cannot be the solution!)
What I think I know
Strategies we've tried:
run_asyncio, run_future, run_trio: explicit call-in-other-mode transitions, inspired by trio.run
Advantages: very explicit, captures the core functionality (these are technically enough to do anything), and the idiom is familiar to trio users
Limitation: while you can do anything with just these, it often requires frustratingly verbose code. Some cases that aren't handled well: context managers, iterables, handlers (in particular, defining a function that asyncio can call but runs in trio mode)
Averted limitation: Recall that await whatever() is shorthand for coro = whatever(); await coro. It turns out that there is some asyncio code (in aiohttp? which code?), that calls asyncio.current_task() from inside the coro = whatever() part, not within the await coro part. This breaks a naive implementation of run_asyncio, that only runs the await coro part inside an asyncio.Task. However, now that we know about this, it's easy to fix by making run_asyncio perform both halves of the await whatever() dance inside the new task. (Code like this is also broken in pure asyncio if you do asyncio.create_task(whatever()), but never mind that...)
aio_as_trio, trio_as_aio: translators for common protocols (specifically: CM, async CM, iterable, async iterable, and async callable – but not sync callable).
Advantages: can handle the three problem cases listed above.
Limitation: the syntax in aio_as_trio(fn)(...) is going to confuse the heck out of newcomers ("Readability counts")
Limitation: often CM/iterables are constructed by making a synchronous call, and we still don't have any good way to write that. And... this shouldn't matter, because generally speaking it's fine to use synchronous trio APIs from aio mode, and vice-versa... with the ONE exception of asyncio.current_task, and it turns out that people (aiohttp) call this annoyingly often. And... you can't really spawn a task just to call asyncio.current_task, that's not going to give useful results; the task it returns will be gone before you can do anything with it.
Limitation: some context managers, like the one in async_timeout, assume that their __aenter__, __aexit__, and body all execute in the same task. That's not true for a naive implementation of aio_as_trio that just uses run_asyncio to call the __aenter__ and __aexit__. Is a non-naive implementation even possible? I don't see how...
This is also very problematic when going in the other direction: any trio context manager that contains a nursery or cancel scope is currently totally broken if used with trio_as_aio
allow_asyncio: use some Clever Coroutine Tricks to create a hybrid asyncio/trio mode where you can call either kind of function.
Advantages: super ergonomic!
Limitation: We don't know where the boundaries are between trio code and asyncio code, so we can't translate asyncio.CancelledError <-> trio.Cancelled at the boundary. It's not clear how bad this is in practice... mainly it means that trio.Cancelled might pass through asyncio code. This will probably be treated like a generic unhandled exception, which in many cases will be what you want. Grepping aiohttp, there are a number of places that look for asyncio.CancelledError, but they mostly seem to be ad hoc attempts to implement something like Trio's cancel scope delimination, so maybe we already handle that fine?
Limitation: We know how to go from trio-mode to hybrid-mode, but currently can't go from asyncio-mode to hybrid-mode. I guess composing run_trio with allow_asyncio would do it though.
Limitation: current_task doesn't work at all. This breaks the popular async_timeout library (used by e.g. aiohttp, homeassistant, and others). This seems like a showstopper problem. If we can't fix this, then I don't think we can in good conscience offer "hybrid mode" as a feature. Even if we document that it only works in "simple cases", then the first thing people will try is writing a simple little program... that uses aiohttp, and it won't work.
Basically run_asyncio/run_trio, but with better ergonomics because you don't need to define and call another function.
Some tentative conclusions?
The allow_asyncio hybrid-mode approach is never going to be 100% reliable (because of the cancellation issue), and currently is pretty badly broken (because of the current_task issue making it incompatible with super-popular libraries like aiohttp). Conclusion: it will never be the only option (we want to give people the option of using something less magical and more reliable when they have to), and right now we probably shouldn't be shipping it at all. So let's put it aside for the moment and focus on the other options.
From an API design standpoint, I think it makes sense to provide the basic run_asyncio, run_future, run_trio primitives. They aren't necessarily the thing we expect people to use all the time, but they provide a set of simple, reliable core primitives that you can always fall back to if you have some confusing situation where our more ergonomic options don't work. Alternatively, if we get async with aio_mode/async with trio_mode working, those could also serve as a set of basic primitives.
The aio_as_trio/trio_as_aio are... maybe not actually a good idea after all? That's not the conclusion I was expecting to reach; I thought I was going to end up arguing for them as convenience shorthand on top of the run_* primitives. But I'm really concerned about the issues caused by running __aenter__ and __aexit__ in different tasks – that really will break all kinds of stuff. The async with aio_mode/async with trio_mode approach avoids this problem, e.g.:
asyncwithtrio_mode:
asyncwithtrio.open_nursery() asnursery:
nursery.start_soon(...)
asyncwithaio_mode:
...
# Here we leave aio_mode and return back to *the original trio task*# So when we exit the nursery here, that happens in the same task where it was created
So....... maybe I've argued myself into thinking that async with aio_mode / async with trio_mode really are something we have to dig into more, and might even be The Solution To All Our Problems? There are still a bunch of details to work out first to figure out how these can work, but maybe we should do that.
They even make a reasonable substitute for @aio2trio/@trio2aio, e.g. instead of
@aio2trioasyncdefthis_is_called_by_homeassistant():
# And I write Trio code inside it
You write
asyncdefthis_is_called_by_homeassistant():
asyncwithtrio_mode:
# And I write Trio code inside it
One extra level of indentation, but the same number of lines, and no need for anything really annoying like writing a trampoline function.
What am I missing?
Probably a bunch of stuff, but hopefully laying out my thinking will make it obvious to someone what terrible mistakes I'm making? Please help me be smarter :-)
I feel like trio-asyncio's core functionality is been pretty solid for a while, but we're still flailing a bit trying to find the most usable way to expose it to users. I guess right now it has 3 different APIs, we're discussing a 4th, and it's not clear whether any of them is actually what we want? And I'm frustrated that I don't feel like I understand what the pitfalls are or what features users actually need. And my main coping strategy for that kind of frustration is to open an issue and write a bunch of text to organize my (our?) thoughts, so here we go. Hopefully laying out all the information in one place will give us a clearer picture of what the trade-offs actually are, and help us find the best way forward. (4 overlapping APIs and constant churn cannot be the solution!)
What I think I know
Strategies we've tried:
run_asyncio,run_future,run_trio: explicit call-in-other-mode transitions, inspired bytrio.runawait whatever()is shorthand forcoro = whatever(); await coro. It turns out that there is some asyncio code (in aiohttp? which code?), that callsasyncio.current_task()from inside thecoro = whatever()part, not within theawait coropart. This breaks a naive implementation ofrun_asyncio, that only runs theawait coropart inside anasyncio.Task. However, now that we know about this, it's easy to fix by makingrun_asyncioperform both halves of theawait whatever()dance inside the new task. (Code like this is also broken in pure asyncio if you doasyncio.create_task(whatever()), but never mind that...)aio_as_trio,trio_as_aio: translators for common protocols (specifically: CM, async CM, iterable, async iterable, and async callable – but not sync callable).aio_as_trio(fn)(...)is going to confuse the heck out of newcomers ("Readability counts")asyncio.current_task, and it turns out that people (aiohttp) call this annoyingly often. And... you can't really spawn a task just to callasyncio.current_task, that's not going to give useful results; the task it returns will be gone before you can do anything with it.async_timeout, assume that their__aenter__,__aexit__, and body all execute in the same task. That's not true for a naive implementation ofaio_as_triothat just usesrun_asyncioto call the__aenter__and__aexit__. Is a non-naive implementation even possible? I don't see how...trio_as_aioallow_asyncio: use some Clever Coroutine Tricks to create a hybrid asyncio/trio mode where you can call either kind of function.asyncio.CancelledError<->trio.Cancelledat the boundary. It's not clear how bad this is in practice... mainly it means thattrio.Cancelledmight pass through asyncio code. This will probably be treated like a generic unhandled exception, which in many cases will be what you want. Grepping aiohttp, there are a number of places that look forasyncio.CancelledError, but they mostly seem to be ad hoc attempts to implement something like Trio's cancel scope delimination, so maybe we already handle that fine?run_triowithallow_asynciowould do it though.current_taskdoesn't work at all. This breaks the popularasync_timeoutlibrary (used by e.g. aiohttp, homeassistant, and others). This seems like a showstopper problem. If we can't fix this, then I don't think we can in good conscience offer "hybrid mode" as a feature. Even if we document that it only works in "simple cases", then the first thing people will try is writing a simple little program... that uses aiohttp, and it won't work.async with aio_mode,async with trio_mode, or maybe evenasync with hybrid_mode: Hypothetical approach that would let you switch modes in the middle of a function (details: Should we have a way to let some other coroutine runner take temporary control of a task? trio#649).run_asyncio/run_trio, but with better ergonomics because you don't need to define and call another function.Some tentative conclusions?
The
allow_asynciohybrid-mode approach is never going to be 100% reliable (because of the cancellation issue), and currently is pretty badly broken (because of thecurrent_taskissue making it incompatible with super-popular libraries likeaiohttp). Conclusion: it will never be the only option (we want to give people the option of using something less magical and more reliable when they have to), and right now we probably shouldn't be shipping it at all. So let's put it aside for the moment and focus on the other options.From an API design standpoint, I think it makes sense to provide the basic
run_asyncio,run_future,run_trioprimitives. They aren't necessarily the thing we expect people to use all the time, but they provide a set of simple, reliable core primitives that you can always fall back to if you have some confusing situation where our more ergonomic options don't work. Alternatively, if we getasync with aio_mode/async with trio_modeworking, those could also serve as a set of basic primitives.The
aio_as_trio/trio_as_aioare... maybe not actually a good idea after all? That's not the conclusion I was expecting to reach; I thought I was going to end up arguing for them as convenience shorthand on top of therun_*primitives. But I'm really concerned about the issues caused by running__aenter__and__aexit__in different tasks – that really will break all kinds of stuff. Theasync with aio_mode/async with trio_modeapproach avoids this problem, e.g.:So....... maybe I've argued myself into thinking that
async with aio_mode/async with trio_modereally are something we have to dig into more, and might even be The Solution To All Our Problems? There are still a bunch of details to work out first to figure out how these can work, but maybe we should do that.They even make a reasonable substitute for
@aio2trio/@trio2aio, e.g. instead ofYou write
One extra level of indentation, but the same number of lines, and no need for anything really annoying like writing a trampoline function.
What am I missing?
Probably a bunch of stuff, but hopefully laying out my thinking will make it obvious to someone what terrible mistakes I'm making? Please help me be smarter :-)