There are two questions here: one is about awaiting a coroutine "at top-level", or more concretely in a development environment. The other is about running a coroutine without an event loop.
Regarding the first question, this is certainly possible in Python, just like it is possible in Chrome Canary Dev Tools - by the tool handling it via its own integration with the event loop. And indeed, IPython 7.0 and later support asyncio natively and you can use await coro()
at top-level as expected.
Regarding the second question, it is easy to drive a single coroutine without an event loop, but it is not very useful. Let's examine why.
When a coroutine function is called, it returns a coroutine object. This object is started and resumed by calling its send()
method. When the coroutine decides to suspend (because it await
s something that blocks), send()
will return. When the coroutine decides to return (because it has reached the end or because it encountered an explicit return
), it will raise a StopIteration
exception with the value
attribute set to the return value. With that in mind, a minimal driver for a single coroutine could look like this:
def drive(c):
while True:
try:
c.send(None)
except StopIteration as e:
return e.value
This will work great for simple coroutines:
>>> async def pi():
... return 3.14
...
>>> drive(pi())
3.14
Or even for a bit more complex ones:
>>> async def plus(a, b):
... return a + b
...
>>> async def pi():
... val = await plus(3, 0.14)
... return val
...
>>> drive(pi())
3.14
But something is still missing - none of the above coroutines ever suspend their execution. When a coroutine suspends, it allows other coroutines to run, which enables the event loop to (appear to) execute many coroutines at once. For example, asyncio has a sleep()
coroutine that, when awaited, suspends the execution for the specified period:
async def wait(s):
await asyncio.sleep(1)
return s
>>> asyncio.run(wait("hello world"))
'hello world' # printed after a 1-second pause
However, drive
fails to execute this coroutine to completion:
>>> drive(wait("hello world"))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 4, in drive
File "<stdin>", line 2, in wait
File "/usr/lib/python3.7/asyncio/tasks.py", line 564, in sleep
return await future
RuntimeError: await wasn't used with future
What happened is that sleep()
communicates with the event loop by yielding a special "future" object. A coroutine awaiting on a future can only be resumed after the future has been set. The "real" event loop would do so by running other coroutines until the future is done.
To fix this, we can write our own sleep
implementation that works with our mini event loop. To do this, we need to use an iterator to implement the awaitable:
class my_sleep:
def __init__(self, d):
self.d = d
def __await__(self):
yield 'sleep', self.d
We yield a tuple that will not be seen by the coroutine caller, but will tell drive
(our event loop) what to do. drive
and wait
now look like this:
def drive(c):
while True:
try:
susp_val = c.send(None)
if susp_val is not None and susp_val[0] == 'sleep':
time.sleep(susp_val[1])
except StopIteration as e:
return e.value
async def wait(s):
await my_sleep(1)
return s
With this version, wait
works as expected:
>>> drive(wait("hello world"))
'hello world'
This is still not very useful because the only way to drive our coroutine is to call drive()
, which again supports a single coroutine. So we might as well have written a synchronous function that simply calls time.sleep()
and calls it a day. For our coroutines to support the use case of asynchronous programming, drive()
would need to:
- support running and suspension of multiple coroutines
- implement spawning of new coroutines in the drive loop
- allow coroutines to register wakeups on IO-related events, such as a file descriptor becoming readable or writable - all the while supporting multiple such events without loss of performance
This is what the asyncio event loop brings to the table, along with many other features. Building an event loop from scratch is superbly demonstrated in this talk by David Beazley, where he implements a functional event loop in front of a live audience.