Almost 4 years ago I wrote this post about some differences between the async machinery in JavaScript and Python. There are many more things I could add regarding the workings of their event loops and more, but I'll talk today about one case I've been looking into lately: awaiting for an already resolved Promise/Future or a function marked as async but that does not suspend.
In JavaScript awaiting for an already resolved Promise or a function marked as async but that does not suspend will give control to the event loop (so the function that performs the await will get suspended), while in Python the function doing that await will not suspended, it will run the next instruction without transferring control to the event loop. Let's see an example in JavaScript:
async function fn(){
console.log("fn started");
result = await Promise.resolve("Bonjour");
console.log("fn, after await, result: " + result);
}
fn();
console.log("right before quitting");
// output:
// main1 started
// right before quitting
// main1, after await, result: Bonjour
In the above code, when we await an already resolved Promise, the then() callback that the compiler added to that Promise to call again into the state-machine corresponding to that async function, is added to the microtask queue (rather than being executed immediately). So the fn function gets suspended and the execution flow continues in the global scope from which fn had been called, writing the "right before quitting" message. Then the event loop takes control, check that it has a task in its microtask queue and executes it, resuming the fn function and writing the "fn, after..." message.
If we await for an async function that does not perform a suspension (getPost(0)) the result is the same (well, if the async function does not suspend it indeed returns also a resolved promise). Let's see:
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function getPost(id){
let result
console.log("getPost started");
if (id === 0){
result = "Post 0";
}
else {
await sleep(1000);
result = "Post " + id;
}
console.log("getPost finished");
return result;
}
async function main2(){
console.log("main2 started");
let result = await getPost(0);
console.log("main2, after await, result: " + result);
}
main2();
console.log("right before quitting");
// output:
// main2 started
// getPost started
// getPost finished
// right before quitting
// main2, after await, result: Post 0
If we run know an equivalent example in Python we can see that the behaviour is different, the function continues its execution without suspending
async def do_something():
print("inside do_something")
async def main1():
print("main1 started")
asyncio.create_task(do_something())
ft = asyncio.Future()
ft.set_result("Bonjour") # immediately resolved future
result = await ft
print("main1, after await, result: " + result)
asyncio.run(main1())
sys.exit()
# output:
# main1 started
# main1, after await, result: Bonjour
# inside do_something
In main1 create_task creates a task and adds it to a queue in the event loop so that it schedules it when it has a chance (next time it gets the control). main1 execution continues creating a future and resolving it and when we await it, as it's already resolved/completed, no suspension happens, the execution continues writing the "main1, after await". Then the main1 function finishes and the event loop takes control and runs remaining tasks that it had in its queue, our "do_something" task in this case.
If we run now an example where we await for a coroutine (get_post) that does not suspend the result is the same:
async def get_post(post_id):
print("get_post started")
if post_id == 0:
result = "Post 0"
else:
await asyncio.sleep(0.5) # Simulate async operation
result = f"Post {post_id}"
print("get_post finished")
return result
async def do_something():
print("inside do_something")
async def main2():
print("main2 started")
asyncio.create_task(do_something())
# the do_something task is scheduled to run when the eventloop has a chance,
result = await get_post(0)
print("main2, after await, result: " + result)
asyncio.run(main2())
# output:
# main2 started
# get_post started
# get_post finished
# main2, after await, result: Post 0
# inside do_something
We invoke the get_post(0) coroutine, that does not do any asynchronous operation that suspends it, but directly returns a value. The await receives a normal value rather than an unresolved Future, so it justs continues with the print("main2, after") rather than suspending, and hence no control transfer to the event loop until when main2 is finished.
A bit related to all this, some days ago I was writing some code where I have multiple async operations running and I wait for any of them to complete with asyncio.wait(), like this:
while pending_actions:
done_actions, pending_actions = await asyncio.wait(
pending_actions,
return_when=asyncio.FIRST_COMPLETED
)
for done_task in done_actions:
# result = await done_task
result = done_task.result()
# do something with result
)
In done_actions we have Tasks/Futures that are already complete. That means that to get its result we can do both task.result() or await task. Given that awaiting for a resolved/completed Task/Future does not cause any suspension, both options are valid and similar, but with some differences. As I read somewhere:
Internally, coroutines are a special kind of generators, every await is suspended by a yield somewhere down the chain of await calls (please refer to PEP 3156 for a detailed explanation).
This means that even if that await done_task will not transfer control to the event loop causing a suspension, because there's not an unresolved Task/Future to wait for, the chain of coroutine calls moves back up to the Task that ultimately controls this coroutine chain and from there (given that the Future is already resolved and hence there's no need to get suspended waiting for its resolution) the Task will move forward again in the coroutine chain. So this means some overhead because of this going back and forth. Additionally, invoking result() makes evident that we are accessing to a completed item, while using await makes it feel more as if the item is not complete (and hence we await it).