-
-
Notifications
You must be signed in to change notification settings - Fork 38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
API thoughts #2
Comments
OK. Food for thought (and coding). Well, the original motivation was to have full bidirectional callability, for two reasons - (a) I wanted to be able to run the original Python asyncio tests on the trio-asyncio loop, to make sure the whole thing is correct and all that, (b) there might one day be a nice universe of trio-based libraries which you'd want to call from poor old ancient asyncio, instead of the other way 'round. ;-) I don't need to monkeypatch asyncio. Instead, I simply install my own loop policy, which is necessary anyway because auto-creation of the asyncio loop now becomes a big no-no. As to Cancelling: trio→asyncio: IMHO the decision whether to propagate an asyncio CancelledError as-is or by cancelling the enclosing scope should be left to the caller – either they explicitly pass in a scope, or not. The scope to use now becomes the scope of the asyncio loop, if none is passed in explicitly, so that's easy to fix. ;-) asyncio→trio: Instead of |
Re: requiring that we start from Trio: oh ick, I forgot about the testing use case. (I don't care so much about the trio libraries on asyncio use case; it's irrelevant right now, and we can always revisit later.) I don't necessarily object to being able to run this as a standalone asyncio loop, but given that it adds a lot of complexity and we only really care about it for testing, I wonder if there's some ugly hack we can use to get this working just well enough for that. (And avoid making it a public API.) Here's a gross idea that might work: We want to have everything happen inside a single call to
It took me a bit to remember why I had decided we needed to monkey-patch here... I think there are two reasons. First, The second reason is more real, but only matters in a pretty obscure case. In theory, someone might want to run trio in on thread and, I dunno, uvloop or something in another thread. So ideally, we'd only be taking over the event loop policy in the trio thread, not globally across the process. But there is no way to do that using the usual tools: the event loop policy gets to run arbitrary code, but is process-global; the running event loop is thread-local, but has to be a single value across the whole thread. We could potentially wait until someone complains about this to worry about it. It's also possible we might be able to convince Yury to make the running event loop a context-local (see PEP 567) in 3.7, and then we could use that b/c it would have different values in different trio tasks. (And if people who want to use uvloop and trio and asyncio_trio all together then just tell them they need to upgrade to 3.7.)
Hmm, I think I vote we just have
I think there must be something we're thinking about differently here, because this concept just doesn't make any sense to me at all. I don't understand why cancelling the enclosing scope would ever make sense. However, after sleeping on it, the cancellation issues seem less scary. For trio calling asyncio, if the trio side gets cancelled, it should cancel the asyncio code, wait for it to finish, and if it raises from trio.hazmat import reschedule, Result, Abort
async def run_future(self, future):
task = trio.hazmat.current_task()
raise_cancel = None
def done_cb(_):
reschedule(task, Result.capture(future.result))
future.add_done_callback(done_cb)
def abort_cb(raise_cancel_arg):
# Save the cancel-raising function
nonlocal raise_cancel
raise_cancel = raise_cancel_arg
# Attempt to cancel our future
future.cancel()
# Keep waiting
return Abort.FAILED
try:
return await trio.hazmat.wait_task_rescheduled(abort_cb)
except asyncio.CancelledError as exc:
if raise_cancel is not None:
try:
raise_cancel()
finally:
# Try to preserve the exception chain for more detailed tracebacks
sys.exc_info()[1].__cause__ = exc
else:
raise For asyncio calling trio, your plan sounds right to me. If we want to get fancy then I guess we could even make sure that the That leaves the more subtle cases: The task we're cancelling in The task we're cancelling in Unexpected The trio-asyncio main loop/watchers/etc. get cancelled: This happens if someone does: with open_cancel_scope():
async with open_loop():
... i.e., the whole thing gets cancelled. In this case, I think (maybe?) what we want to do is to shield all the internal worker tasks from cancellation, and only propagate the cancellation to the body of the |
Here's some untested code to illustrate my terrible idea about how to let us run, say, the aiohttp test suite against import queue
import trio
from trio_asyncio import open_loop, run_future
import threading
from functools import partial
class HackyTestingLoop(TrioEventLoop):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__blocking_job_queue = queue.Queue()
self.__blocking_result_queue = queue.Queue()
self.__thread = threading.Thread(
target=trio.run,
args=(self.__trio_thread_main,))
async def __trio_thread_main(self):
async with open_loop():
while True:
# This *blocks*
async_fn = self.__blocking_job_queue.get()
if async_fn is None:
self.close()
return
result = await trio.hazmat.Result.acapture(async_fn)
self.__blocking_result_queue.put(result)
def __run_in_thread(self, async_fn, *args):
if not self.__thread.is_alive():
self.__thread.start()
self.__blocking_job_queue.put(partial(async_fn, *args))
return self.__blocking_result_queue.get().unwrap()
def run_until_complete(self, fut):
return self.__run_in_thread(run_future, fut)
def run_forever(self):
# TODO: need to reset the stopped Event, because asyncio loops support
# multiple rounds of run_forever/stop/run_forever/stop. Maybe instead
# of wait_stopped, we want a function that resets the event AND then
# blocks waiting for it, similar to how BaseEventloop.run_forever
# always resets the internal _stopped flag?
self.__run_in_thread(self.wait_stopped)
def close(self):
self.__blocking_job_queue.put(None)
self.__thread.join()
def __enter__(self):
return self
def __exit__(self, _, _, _):
self.close()
# TODO: an event loop policy that returns HackyTestingLoop objects |
Thanks. I'll investigate using something like this to support the run_forever/run_until_complete test cases. The problem is that we need to use the same TrioEventLoop in both threads, so your code doesn't work as-is. The rest is now implemented. |
This is super cool!
I want to change a bunch of the public API decisions :-).
I'll dump a list of ideas here, so we can discuss.
Starting the trio_asyncio loop
Right now, you have to use the trio_asyncio loop as the top-level entry point to your async code. I'm guessing this was forced on you by the old "parenting is a full time job" rule, but that's gone now :-). And I really think we want to switch this around.
What I'm imagining is, you start in trio, and create a loop with an
async with
statement, e.g.Given that the main motivation for this package is using asyncio libraries from trio, this feels way more natural. It becomes possible to use an asyncio library from inside a trio library, without the trio library's ultimate end user having to know about it! It lets us drop tricky stuff like having to take over the asyncio loop during import. It lets you stop worrying about clock consistency across multiple calls to
trio.run
. And perhaps most interestingly, it lets us have multiple trio_asyncio loops active simultaneously and independently in different parts of the trio task tree, without interfering with each other. In particular, you can kill off part of the tree and know that any random detritus that was left running in that tree's aio loop won't "leak out" and keep running. It lets us keep the callback spaghetti in quarantine.Detailed semantics
We should make sure that the loop is treated as the "current" event loop for asyncio. In practice, I think this means storing it in a trio.TaskLocal so we can find it again later, and then monkeypatching
asyncio._get_running_loop
andasyncio.events._get_running_loop
so that they do something like:Our loop object doesn't have methods
run_forever
,run_until_complete
,close
, etc.: the loop starts running when it's created, and it stops running and is closed (cancelling anything remaining) when we leave the block. The equivalent ofrun_until_complete
iscall_asyncio
(orrun_asyncio
, see below).In order to support existing cases that use
run_forever
, we should provide a methodawait loop.wait_stopped()
, which just pauses until someone callsloop.stop()
. So instead of:you would write:
(Considered alternatives:
trio_asyncio.run
is too close torun_asyncio
; havingrun_asyncio
start a loop on first invocation seems too magical -- loop is super super stateful so need to mark where it comes into existence. And it's nice to have creating the loop and entering asyncio as two separate actions, e.g. for those who want to create a loop at the top of their program and then do trio stuff underneath.)loop.call_{trio,asyncio}
Two things:
run_trio
,run_asyncio
, for consistency with all of trio's other functions that run an async function and wait for it to finish.trio_asyncio.run_trio
,trio_asyncio.run_asyncio
, so you don't have to pass the loop around.I'm not sure if
wait_for
is the best name... in asyncio there's a function called that, but what it actually does is impose a timeout on a future (very poorly chosen name). We can bikeshed that at some point I guess.Synchronous functions
I think we can make it so that these "just work", in both directions, without special translation. The tricky issues for translating are (1) the different
await
protocols, (2) cancellation exceptions – right? And both of these only apply to async functions, right?All the trio synchronous functions just need to be run inside
trio.run
, which everything here certainly is. And if we makeget_event_loop()
work, then I think that makes synchronous asyncio functions Just Work too.Keyword arguments
I know it's convenient, but I think it's a mistake to support kwargs directly in all the
call_*
functions. It's inconsistent with both trio and asyncio's conventions, so anyone who uses this library is already going to be familiar with thepartial
trick. And there's a good reason for these conventions -- which you already ran into with the_scope
kwarg. There are very few things in asyncio that seemed worth preserving in trio, but this was one of them :-)Cancellation translation
Hoooboy this is tough. I'm not sure what the right thing to do here is, but the bit where it reaches "up" into trio to find a cancel scope to cancel is definitely wrong.
For an exception propagating from asyncio→trio, I don't know that there's anything better to do than just let the
asyncio.CancelledError
come out, and if you're using trio_asyncio then hopefully you're prepared for that, just like you're prepared for whatever other exceptions this asyncio library might raise as part of its interface.For propagating from trio→asyncio, I think my comments in python-trio/trio#171 were pretty naive. (Maybe I didn't understand trio as well back then.) You can't even reasonably do
except Cancelled:
, because that will mess up onMultiError
s, and it's quite common to have aMultiError
holdingCancelled
exceptions. Maybe we do want to just let these exceptions ride.This part will need more discussion for sure.
The text was updated successfully, but these errors were encountered: