Skip to content
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

Should we have a way to let some other coroutine runner take temporary control of a task? #649

Closed
njsmith opened this issue Sep 6, 2018 · 6 comments

Comments

@njsmith
Copy link
Member

njsmith commented Sep 6, 2018

So there's a cute hack, which as far as I know @agronholm was the first to discover, where you can use an async with block to temporarily switch between coroutine runners. For example:

async def somefunc():
    await do_something_in_main_thread()
    async with in_worker_thread():
        do_something_blocking()
    await do_something_in_main_thread()

Another possible application would be to allow trio-asyncio to switch back and forth between trio mode and asyncio mode within a single function.

This is kind of brain melting, but the implementation is surprisingly simple: a task's current execution is represented by a coroutine object. So, you suspend the coroutine, and from Trio's point of view it becomes "blocked"; then you take that coroutine object and give it to another coroutine runner to iterate for a while. Later, it gets handed back and Trio "resumes" it.

You can't actually implement something like this right now though, using Trio's public API. You can suspend a task, and get the coroutine object... but the coroutine is suspended inside wait_task_rescheduled, so to resume you need to send in the kind of value that wait_task_rescheduled is expecting, and that's not part of the public API. Similarly, to resume a task, trio.hazmat.reschedule will send in some kind of value, but you're not supposed to know how to interpret it – only wait_task_rescheduled knows that, so to resume the task you have to somehow get back into wait_task_rescheduled.

But it wouldn't be terribly hard to add a new suspend/resume API just for this. In trio.hazmat, obviously! This is extremely hazmat-y. But that's why trio.hazmat exists, so we can expose hazmat-y things. Something like:

@types.coroutine
def detach_task(abort_fn):
    yield some_magic_value(abort_fn)

@types.coroutine
def reattach_task(yield_value):
    got = yield yield_value
    do_whatever_wait_task_rescheduled_would_do_with(got)

So the idea is that to detach the task from trio, you do await detach_task(abort_fn), which is exactly like wait_task_rescheduled except that it has a documented API for what you send into it to resume the task (maybe None, or maybe it's return yield ..., so whoever resumes the coroutine can choose the return value). And then you iterate the coroutine however you want, in whatever context you want – as a synchronous thread, as an asyncio Task, whatever. And then, when you're finished, you do await reattach_task(yield_value), and it sends that yield_value to the coroutine runner – so for asyncio maybe it's a special Future, whatever you like, nothing to do with trio really, just make sure that your other coroutine runner stops trying to mess with this coroutine objects – and then it goes into a state where you can call trio.hazmat.reschedule and continue.

I think this + #642 would make it possible to fully implement curio's "async threads" API as a trio library.

So... is it a good idea?

I've known about this trick for quite some time, and hesitated because async with in_worker_thread seemed so magical that it was in dubious taste. But... OTOH trio's lowest-level APIs do try hard to make anything possible, regardless of whether I think it's in good taste or not :-). And using this in trio-asyncio seems like it might be compelling? And the API described above is would not be too terrible to implement or maintain. (That's the other thing... I haven't been excited about this because I couldn't figure out how to even do it while respecting trio's abstraction boundaries. But then today I figured out the API above.)

@smurfix
Copy link
Contributor

smurfix commented Sep 6, 2018

That's kind of brain-melting and we'd need to have a hard look at how to do hook this into trio_asyncio, but, yeah, IMHO this would be cool to have.

We also need to figure out how to do the same thing in reverse.

@njsmith
Copy link
Member Author

njsmith commented Sep 6, 2018

Doing the handoff part in reverse is fairly straightforward, I think, because asyncio's yield protocol is effectively public (IIRC: yield a Future, send None when the future is done). Probably some details to work out around making trio task bookkeeping and asyncio task bookkeeping line up – this applies in both directions I guess. Like:

async def start_in_trio_mode():
    original_trio_task = trio.hazmat.current_task()
    async with aio_mode:
        original_aio_task = asyncio.current_task()
        async with trio_mode:
            # Can nesting return to the original task, instead of creating a new one?
            assert original_trio_task == trio.hazmat.current_task()
            async with aio_mode:
                original_aio_task = asyncio.current_task()
    # Back in trio mode now...
    # If we switch into aio mode several times, do we keep re-entering the same task?
    async with aio_mode:
        assert original_aio_task is asyncio.current_task()

I'm not sure how much this matters, but as we've seen with aiohttp, asyncio code does sometimes make strong assumptions about asyncio.current_task().

@smurfix
Copy link
Contributor

smurfix commented Sep 6, 2018

Hmm. I was thinking more of async with trio_mode which would need to somehow set up a Trio task to run the Trio part of the coroutine in.

NB: your assertions will fail: you cannot re-purpose the original "outer" task, because there may be more than one concurrent "inner" task. So you need a new one. For the same reason you'll probably need a new context manager for each level, so async with trio_mode() etc.

@njsmith
Copy link
Member Author

njsmith commented Sep 6, 2018

somehow set up a Trio task to run the Trio part of the coroutine in.

Yeah, it'd have to spawn it under the loop's nursery. I guess teardown might be an issue – like maybe we also need a way to say "don't just detach this task temporarily, actually forget about it entirely, pretend it just exited even though the coroutine wasn't exhausted". We need to prototype some stuff here to learn what the actual tricky bits are :-).

NB: your assertions will fail: you cannot re-purpose the original "outer" task, because there may be more than one concurrent "inner" task. So you need a new one.

I don't understand. I'm imagining that this is all within the text of a single function, and by definition a single Python function is a purely sequential thing, no concurrency allowed.

async with trio_mode()

I'm just being lazy with my pseudocode :-). No point in worrying about details like parentheses when we don't even know if the basic idea is possible or useful :-).

@smurfix
Copy link
Contributor

smurfix commented Sep 6, 2018

I don't understand. I'm imagining that this is all within the text of a single function, and by definition a single Python function is a purely sequential thing, no concurrency allowed.

Yeah, in your example it's all within a single function, but conceptually it's the same as

async def foo():
    async with aio_mode():
        await bar()
async def bar():
    async with trio_mode():
        await baz()
async def baz():
    await trio.sleep(0)
trio_asyncio.run(foo)

and nothing prevents one from inserting a couple of nurseries and start_soons / ensure_futures in there. I'd rather avoid the thorniness that creeps in when we try to special-case the question whether there is (or is not) an outer trio and/or asyncio task we could temporarily re-purpose to run the async with context. Best to always allocate a new temporary task, methinks.

@njsmith
Copy link
Member Author

njsmith commented Sep 7, 2018

I was imagining that we'd keep some kind of bidirectional mapping between asyncio and trio Tasks, so that a transition from a given aio Task always give you the same trio Task, and vice-versa. That's well-defined even in your complex cases. I'm not sure it's actually important though. And there's a thorny problem of figuring out when a Task is no longer needed.

njsmith added a commit to njsmith/trio that referenced this issue Oct 13, 2018
I'm not sure if this is a good idea, but I guess we'll give it a try
and see how it goes.

Closes python-triogh-649.
wgwz pushed a commit to wgwz/trio that referenced this issue Oct 21, 2018
I'm not sure if this is a good idea, but I guess we'll give it a try
and see how it goes.

Closes python-triogh-649.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants