Supporting asyncio.get_event_loop().run_until_complete() in repls

Upfront apologies if this email is late to the party and we’ve overlooked previous closure / guidance on this subject. If there is a resolution here, It’d be very kind if you could provide an authoritative link. I will make sure to share it broadly as I am talking with a few other folks wondering about the same thing.

If possible, perhaps adding a link or documentation to the asyncio docs would be good as well as this is a fairly broadly encountered issue and the hacks being recommended are leading to a lot of problems.

Please See here -

The issue is that folks write code, and then may (or may not) try to execute that code in the context of a repl. That repl may (or may not) have an asyncio event loop already running. Sometimes one repl may not be sufficient, and sometimes folks may decide to move onto another repl.

There are many repls in the ecosystem. They all satisfy different needs and requirements. And in fact, we are writing a new one as well which takes a more visual approach than previous ones. It will not be based on iPython or jupyter.

Repls are critical to the success of python, especially in the realm of data science and notebooks, which has lead to a great deal of industry investment. Usually these types of users are not engaged or concerned about the subtleties of Python itself, they want it to be very clear and just simply work. It usually is and does, which makes Python great for this use case.

But in writing our own repl, we are faced with an issue everyone else is who makes a repl like solution - what is the asyncio contract for executing asyncio code within the context of a repl?

This comes when pasted code samples for various reasons benefit from asyncio. And indeed, for our network use cases we really like coroutines as python threads have issues.

Note that configuring the environment or cherry picking the right asyncio code to execute in the right context is not a user friendly solution. It leads to user surprise and endless support. The user can’t just copy/paste/execute like they usually do in the other countless samples they’re leveraging.

So the extra steps required is not how repls are generally used, and goes against their value proposition of just working. For our product, we’d like there to be a way to call code which will work on both async and sync contexts while minimizing behavioral differences without surprising the user with some special case treatment that is orthogonal to what they are trying to achieve.

Ideally, we would have an agreed upon asyncio API we can run in repls that works in either context.

I’ve seen in a few places folks have solved this problem by fudging their execution environments a bit and suggesting using asyncio.get_event_loop().run_until_complete, however the documentation for asyncio specifically discourages the use of this API. Some environments do sub optimal (IMHO) like allowing await outside the context of an async API. I imagine this will inevitably lead to confusion, though I agree it’s tricky to disallow.

The get_event_loop() solutions out there currently are not very solid and only work in inconsistent circumstances, which is not surprising as there is no contract here. For example, calling asyncio.run() can break the api above.

However, the solution is actually reasonable and we will use the get_event_loop() API if that is the consensus. It supports our users fairly well. We will investigate ways to discourage use of await outside of functions (perhaps just a warning).

So, second question - If using get_event_loop().run_until_complete() is the right way is it possible to add something to the documentation to this effect to provide some reassurance to both repl dev and users that efforts will be made to avoid breaking this?

It can also be used to inform other folks about the ‘right way’. Some maintainers are recommending monkey patching, which leads to poor outcomes.

Even better would be to add implementation support for this, but I don’t have high expectations there. Guidance on how best to support it and what will be forwards compatible would be nice, tho.

It doesn’t have to be forever, but it would be good to have at least some documented if temporary agreement among the language/stdlib designers and the repl community.

If not the right way, what is the way to support non intrusive code?

Fwiw, if there is no consensus opinion on a non intrusive solution, likely we will default and follow suit of other repls and defacto support get_event_loop().run_until_complete(). Not ideal, in my honest opinion, but practical.

I’d also like to personally add to the meta discussion and be upfront about my own technical assumptions / biases. These are not necessarily shared with people I collaborate with and so are mine alone.

There are some folks who are suggesting that asyncio should be discouraged, and rather use gevent like libraries as dynamic coroutines are more appropriate and compelling than those that are lexical and lead to colored function / segregated architectures.

Monkey patches like nest_asyncio are a way of protesting and advocating for this point of view. Sadly, this patch and the act of rebellion it encouraged has wrought a degree of damage across the ecosystem as you can see with the high number of github / stackoverflow issues that resulted. I am pretty sure it doesn’t actually work, either.

Personally, for my own work, I have actually found great code clarity and success with gevent, but in order to do so, I had to understand its particular brand of monkey patching and its potential issues - of which they are legion if you are not careful.

That said, I am sympathetic that dynamic coroutine can lead to more tricky to debug code when this approach is broadly adopted and potentially abused by any developer, regardless of their level of expertise. I sympathize with the asyncio developers in their attempt to put up roadblocks and speedbumps regarding coroutine usage

However, speedbumps aside, asyncio code is not necessarily easier to follow. You can make a mess of it just as easily as anything else, and I’ve seen plenty of messy uses of it. And, I’d argue, that the speedbumps have lead to some rather unfortunate messes (witness nest_asyncio)

So yes, it’s important to be very conservative and thoughtful about spawning new coroutines with dynamic or any coroutine frameworks. And when I am careful and document well, I’ve found that the lack of segregation constraints found in asyncio leads to much cleaner / greater code clarity and fewer disruptive code management issues like that presented in this thread.

Of course, gevent doesn’t enjoy stdlib support, which puts a rather painful cloud over it.

It’s unfortunate that we can’t have nice things.

The specific reason as to why get_event_loop() is not recommended is because of the behavior when there’s not an event loop – it only works when you’re calling it from the main thread and one has not been set within the current context at any point. If you can live with that and guarantee that it will be called from the main thread 100% of the time, then it’s okay to use it. That’s why the docs state:

Because this function has rather complex behavior (especially when custom event loop policies are in use), using the get_running_loop() function is preferred to get_event_loop() in coroutines and callbacks.

Rather than just:

It is preferred to use get_running_loop() instead of get_event_loop().

That being said, I find that the best practice is to stick with get_running_loop() as much as possible, and it works well as a replacement for get_event_loop() as long as you can guarantee that an event loop is currently running. If you can’t, then you could potentially do something like the following (instead of a bare get_event_loop()):

# Within a coroutine, simply use `asyncio.get_running_loop()`, since the coroutine wouldn't be able 
# to execute in the first place without a running event loop present.
try:
    loop = asyncio.get_running_loop()
except RuntimeError:
   # depending on context, you might debug or warning log that a running event loop wasn't found
   loop = asyncio.get_event_loop()

The above tries to use get_running_loop() first to fetch the currently running event loop, but if there’s not one, it uses get_event_loop() instead. This will still be problematic outside of the main thread or if there was an event loop set and removed earlier, but IMO it’s better than just using get_event_loop() first if you need it.

I don’t think we want to explicitly encourage usage of get_event_loop().run_until_complete(), but maybe some of the confusion surrounding get_event_loop() could be alleviated with a brief paragraph that states when it’s okay to use it, and maybe a section explaining the earlier code snippet that could be used in place of get_event_loop() for similar functionality. Would this be useful?

As for usage of loop.run_until_complete(), it’s fine to use it as long as you are okay with that function call being made blocking. Sometimes, this is even preferred, but the problem is generally when you use it without consideration within a coroutine or callback and then later can’t figure out why your program is blocking. It also works decently well for when you simply want to call an async function, and are simply concerned about getting the result rather than worrying about concurrency.

But you should not use it indiscriminately for every async function call, otherwise it will defeat the purpose of using async entirely (this is a common mistake that I see when authors attempt to write an all-purpose method of calling synchronous and async code). Simply put, there’s not a catch-all single API to call code from both an async and sync context without defeating the purpose of async by making every call blocking.

That being said, you could potentially use something with asyncio.create_task() (if it’s a coroutine function), store the task internally, and then await them all at the end of your program with asyncio.gather(*tasks) – this can be decent approach as long as you remember to keep track of the tasks and await them at the end; it also ensures that the tasks will be completed concurrently (assuming there’s not blocking code within the tasks themselves). If the user needs the result immediately, you can maybe have a block flag (defaults to false, preferably) that directly returns task.result() instead of waiting until it has a chance to execute or the end of the program. Or, simply return the task object back to the user and let them handle it (be sure to include a link to the asyncio.Task docs with this approach).

Note that you can also use asyncio.all_tasks() to fetch all not-yet completed tasks, but especially when intermixed with other library code that uses asyncio, it would be a better practice to separately track an internal collection of tasks so that some aren’t accidentally pulled into the await asyncio.gather(*tasks) at the end.

In general, I also think the approach where you can use top-level await outside of coroutines with a REPL is a decent approach, and is much better of a practice than using get_event_loop().run_until_complete(). It may require some explanation and brief initial confusion, but it gives users much more flexibility, whereas solely using get_event_loop().run_until_complete() for every coroutine effectively forces every call to be blocking. AFAIK, that’s why we use the top-level await approach for python -m asyncio (3.8+).

I agree await outside of functions is the best way to go, but first we’d like to see the language updated to support this. For example, something like if get_running_loop() -> await -> else run.

It’s very vital to appreciate that our (and probably many other REPLs) users will run their code outside of the REPL in a pure python environment (part of the workflow) and having to constantly explain to them why it only works in the REPL is not ideal especially as the coroutines are orthogonal to their reasons for using the REPL.

And of course, we can explain it. But there are other things to explain and it almost seems like every subsystem wants us to explain something. We see the value add here in reducing unnecessary friction, not just passing it on to our users.

TBH, It’s actually surprising to me that this wasn’t settled awhile ago. A large reason I use REPLs personally, the reason I thought most people used them, was to first run code line by line in the REPL to verify correctness and then paste it into the code. Having issue where it works in the REPL but not in the code (or vice versa) seems like a contradiction to the purpose of these tools.

Btw, do you have any guidance on how to support the code sample above in a REPL in a forward compatible manner? It looks like a good solution but doesn’t work out of box. Eg, running it in jupyter I get a “RuntimeError: This event loop is already running” error when I call run_until_complete on the loop.

The create_task() approach is very interesting for sure and I’m going to have to think about that some more… However, it doesn’t solve the cross environment initial entry point problem, right?

I withdrew my post, which while correct, danced around the issue.

The utility functions recommend above can solve most concerns about nesting asyncio.

This is ironic, in a way, that this can be solved easily but not the initial transfer of control which is causing so much confusion with REPLs (I strongly encourage you to google “jupyter runtimeerror”)

The code sample above is nice, but not quite complete as it only talks about getting the loop. It doesn’t address how control will be transferred to asyncio.

The initial errors will go away now, but now the behavior will be different, which in some ways is more nuanced and much harder to explain.

Perhaps I missed the reason for this, but why can’t I call

asyncio.get_running_loop().run_until_complete(asyncio.sleep(1))? Of course, you can use await, but that doen’t help for reasons I’ve mentioned.

It’s like the colored function constraint is going every which way in unexpected fashion. Sync can’t call async, but async can’t always call async either

Isn’t this basically the get_event_loop() semantic, which has already been proved unreliable and not the way to go?

So, the reason is because you can’t use run_until_complete() within a running event loop, doing so results in errors (even without the internal _check_running() that raises the RuntimeError, you would get a separate error from attempting to enter a task within a running task). Essentially, this means that you have to choose between using top-level await or the run_until_complete() approach.

(In my prior response, I was explaining get_running_loop() vs get_event_loop() and run_until_complete() vs top-level await separately rather than grouping them, I’m realizing now that it might not been the best way to structure it.)

I think the next best solution in a general REPL environment would be using something like the create_task() approach I mentioned earlier.

Unless there’s something I’m missing regarding the environment entry points, usage of loop.create_task() should work in any of them. asyncio.create_task() could encounter some issues in your situation because it internally requires a running event loop (it uses get_running_loop()).

But if you use my prior code example to fetch that initial event loop reference and then use loop.create_task(), I don’t see any issues with using it in both REPLs that support top-level await and those that don’t at the same time. The only potential issue I can see is that it only accepts coroutines (and not generalized awaitables), but I’m assuming the users are primarily working with coroutines and tasks, not futures or other custom awaitables.

To some degree, I think create_task() directly will work more intuitive in a REPL environment in the way that users expect for “run this coroutine concurrently” than await, especially if you have the result displayed for them when finished. Here’s a rough example:

py -m asyncio
...
>>> import asyncio
>>> def run_coro(coro):
...     if not asyncio.iscoroutine(coro):
...             raise ValueError("coro must be a coroutine")
...
...     try:
...             loop = asyncio.get_running_loop()
...     except RuntimeError:
...             loop = asyncio.get_event_loop()
...
...     task = loop.create_task(coro)
...     # Storing the task in a container may be useful for non-REPL (for `asyncio.gather(*tasks)` 
...     # to ensure they finish at the end) or you can leave it to the user to handle 
...     if loop.is_running():
...             task.add_done_callback(print_after)
...             # Not strictly necessary to return task to user, but useful if they want something
...             # besides simply displaying the result when it's done
...             return task
...     else:
...             # Since the event loop isn't running, blocking isn't a concern
...             return loop.run_until_complete(task)
...
>>> def print_after(task):
...     # Probably a better way to display this, but that's a separate UI concern
...     print(f"{task.get_coro().__qualname__} result: {task.result()}")
...
>>> run_coro(asyncio.sleep(10, result=42))
...     # omitted task returns for brevity
>>> run_coro(asyncio.sleep(5, result=40))
... 
>>> sleep result: 40
sleep result: 42

Note: If you do want to support any awaitable without top-level await in a similar way to create_task(), one additional option is ensure_future(). It does come at the cost of having a bit more complex behavior though (see docs for details), so it will probably only be worthwhile if your users have a real need to interact with awaitables besides tasks and coroutines.

Hopefully that example proves to be a bit more useful. :slight_smile:

1 Like