I'd like to know what guarantees python gives around when a event loop will switch tasks.
As I understand it async
/ await
are significantly different from threads in that the event loop does not switch task based on time slicing, meaning that unless the task yields (await
), it will carry on indefinitely. This can actually be useful because it is easier to manage critical sections under asyncio than with threading.
What I'm less clear about is something like the following:
async def caller():
while True:
await callee()
async def callee():
pass
In this example caller
is repeatedly doing await
. So technically it is yielding. But I'm not clear on whether this will allow other tasks on the event loop to execute because it only yields to callee
and that is never yielding.
That is if I awaited callee
inside a "critical section" even though I know it won't block, am I at risk of something else unexpected happening?
You are right to be wary. caller
yields from callee
, and yields to the event loop. Then the event loop decides which task to resume. Other tasks may (hopefully) be squeezed in between the calls to callee
. callee
needs to await an actual blocking Awaitable
such as asyncio.Future
or asyncio.sleep()
, not a coroutine, otherwise the control will not be returned to the event loop until caller
returns.
For example, the following code will finish the caller2
task before it starts working on the caller1
task. Because callee2
is essentially a sync function without awaiting a blocking I/O operations, therefore, no suspension point is created and caller2
will resume immediately after each call to callee2
.
import asyncio
import time
async def caller1():
for i in range(5):
await callee1()
async def callee1():
await asyncio.sleep(1)
print(f"called at {time.strftime('%X')}")
async def caller2():
for i in range(5):
await callee2()
async def callee2():
time.sleep(1)
print(f"sync called at {time.strftime('%X')}")
async def main():
task1 = asyncio.create_task(caller1())
task2 = asyncio.create_task(caller2())
await task1
await task2
asyncio.run(main())
Result:
sync called at 19:23:39
sync called at 19:23:40
sync called at 19:23:41
sync called at 19:23:42
sync called at 19:23:43
called at 19:23:43
called at 19:23:44
called at 19:23:45
called at 19:23:46
called at 19:23:47
But if callee2
awaits as the following, the task switching will happen even if it awaits asyncio.sleep(0)
, and the tasks will run concurrently.
async def callee2():
await asyncio.sleep(1)
print('sync called')
Result:
called at 19:22:52
sync called at 19:22:52
called at 19:22:53
sync called at 19:22:53
called at 19:22:54
sync called at 19:22:54
called at 19:22:55
sync called at 19:22:55
called at 19:22:56
sync called at 19:22:56
This behavior is not necessarily intuitive, but it makes sense considering that asyncio
was made to handle I/O operations and networking concurrently, not the usual synchronous python codes.
Another thing to note is: This still works if the callee
awaits a coroutine that, in turn, awaits a asyncio.Future
, asyncio.sleep()
, or another coroutine that await one of those things down the chain. The flow control will be returned to the event loop when the blocking Awaitable
is awaited. So the following also works.
async def callee2():
await inner_callee()
print(f"sync called at {time.strftime('%X')}")
async def inner_callee():
await asyncio.sleep(1)