- Golden Rule: Never block the event loop
- Key Takeaway: There can only be one thing that the event loop is doing at any given time.
- Execution: There will be some additional overhead in running async code & switching between tasks. This will lead to increased time comapred to sync code
- Yet another key takeaway: Just adding
async
&await
doesn't guarantee concurrency. To actually run things concurrently, you need to create tasks. Seev4
&v5
below & their differences to understand it.
import asyncio
async def main():
print("Hello")
await foo()
print("World")
async def foo():
print("foo")
await asyncio.sleep(1)
asyncio.run(main())
Even though we have written asynchronous code, this is not actually running asynchronusly. The event loop is created using asyncio.run
which runns the main
function.
The execution order will be as follows -
asyncio.run(main())
is called, which runs the main coroutine.- Inside main, "Hello" is printed, and then it awaits
foo()
. - The
foo
coroutine starts running, prints "foo", and then encountersawait asyncio.sleep(1)
. - At this point,
foo
pauses and control is returned to the event loop, which allows other tasks to run. - Since there are no other tasks in this example, the event loop waits for the sleep period (1 second) to complete.
- After 1 second,
foo
resumes execution, prints "bar", and then completes. - Control returns to the main coroutine, which then prints "World" and completes.
Output
Hello
foo
-- wait for 1 second --
bar
World
Execution Time
In all it takes > 1 second to complete the execution.
This is no different or better than running the code synchronously as follows -
import time
def main():
print("Hello")
foo()
print("World")
def foo():
print("foo")
time.sleep(1)
print("bar")
main()
To actually run the code asynchronously, we need to create a task using asyncio.create_task
.
import asyncio
async def main():
print("Hello")
task = asyncio.create_task(foo())
print("World")
async def foo():
print("foo")
await asyncio.sleep(1)
print("bar")
asyncio.run(main())
PS - Here, I am not awaiting the task. In such a case, the main
function will continue executing without waiting for the foo
function to complete.
🤯 But when you run the program, you get the following output
Output
Hello
foo
World
Execution Time
~200ms
bar
is not printed & the program completed in ~200ms.
This happened because the main
function completed before the foo
function could complete. The foo
function was running in the background as a task. When the main
function completed, the program exited without waiting for the foo
function to complete.
To fix this, we need to await
the task created using asyncio.create_task
.
import asyncio
async def main():
print("Hello")
task = asyncio.create_task(foo())
await task
print("World")
async def foo():
print("foo")
await asyncio.sleep(1)
print("bar")
asyncio.run(main())
Output
Hello
foo
-- wait for 1 second --
bar
World
Execution Time ~ 1.2 seconds
The
main
function will wait for thefoo
function to complete before printing "World". Thefoo
function will complete in 1 second and then themain
function will print "World".
😞 But note that it still takes 1.2s to run, so from these examples the benefit of async is not clear at all.
🧠 Where async really shines is when you have multiple tasks running concurrently. In the next example, we will run multiple tasks concurrently.
import asyncio
async def main():
print("Hello")
task1 = asyncio.create_task(foo(x="foo", y="bar"))
task2 = asyncio.create_task(foo(x="baz", y="qux"))
await task1
await task2
print("World")
async def foo(x: str, y: str):
print(x)
await asyncio.sleep(1)
print(y)
asyncio.run(main())
Here, we are running two tasks concurrently. The main
function will wait for both tasks to complete before printing "World".
Since, both tasks are running concurrently, the total time taken to complete the program will be the time taken by the longest running task.
Output
Hello
foo
baz
-- wait for 1 second --
bar
qux
World
Execution Time ~ 1.2 seconds
Just to show that if you don't wrap the coroutines as tasks, they will run sequentially. So, here creating the coroutine using asyncio.create_task
is important for concurrency.*
import asyncio
async def main():
print("Hello")
coroutine1 = foo(x="foo", y="bar")
coroutine1 = foo(x="baz", y="qux")
await coroutine1
await coroutine1
print("World")
async def foo(x: str, y: str):
print(x)
await asyncio.sleep(1)
print(y)
asyncio.run(main())
Output
Hello
foo
-- wait for 1 second --
bar
baz
-- wait for 1 second --
qux
World
Execution Time ~ 2.2 seconds
Here, the 2 coroutines run sequentially, so the total time taken is the sum of the time taken by each coroutine.
😄 Just using
async
andawait
keywords doesn't make your code run concurrently. You need to create tasks usingasyncio.create_task
to run them concurrently.
To better understand the concurrency & how the context switch happens based on the await
statement as well as the amount of time a blocking function runs for (sleep time in our case), let's look at the following example -
import asyncio
async def main():
print("Hello")
task = asyncio.create_task(foo())
# we have created the task, but we are not awaiting it
await asyncio.sleep(1)
print("World")
async def foo():
print("foo")
# the function will run for 10 seconds
await asyncio.sleep(10)
print("bar")
asyncio.run(main())
Things to note here -
- I am not awaiting the task created using
asyncio.create_task
- The
main
function will sleep for 1 second after creating the task
Output
Hello
foo
-- wait for 1 second --
World
Execution Time ~ 1.2 seconds
🤯 bar
was never printed and the script completed in ~1.2 seconds - why did this happen?
asyncio.run(main())
: starts themain()
coroutine. This call initializes and starts running the event loop.print("Hello")
: prints "Hello" to the console.asyncio.create_task(foo())
: This schedules thefoo()
coroutine to be run by the event loop. However, it doesn't start executingfoo()
immediately; it merely schedules it to be run as soon as the event loop gets control and decides to start it.await asyncio.sleep(1)
: This tells the event loop to pause the execution of themain()
coroutine for 1 second. The await keyword here is critical because it tells the event loop that themain()
coroutine is going to wait for 1 second and that the event loop should take this opportunity to run other tasks/coroutines that are ready to run. This is our first explicit context switch.print("foo")
: Since, the only other coroutine is thetask
created previously, the event loop runs thefoo
coroutines & hence "foo" is printed to the console.await asyncio.sleep(10)
:foo()
coroutine is now going to wait for 10 seconds. Theawait
keyword tells the event loop that thefoo()
coroutine is going to wait for 10 seconds and that the event loop should take this opportunity to run other tasks/coroutines that are ready to run. This is our second explicit context switch.- Back to
main()
: Since foo() is now sleeping and the only other coroutine is main(), which itself was sleeping for 1 second, the event loop returns tomain()
once the 1-second sleep completes. print("World")
: After the 1-second sleep, "World" is printed to the console. This happens before foo() completes its 10-second sleep.- End of
main()
: At this point,main()
has finished executing all its code. However, the event loop is still running becausefoo()
is still in its sleep. - Event Loop Closes: Since
main()
was the coroutine called byasyncio.run()
, the event loop automatically closes whenmain()
completes, even if other tasks likefoo()
have not yet completed
Note - Here, the context switch happened from main
to foo
& the foo
function ran till it hit the await asyncio.sleep(10)
statement. This happened without we explicitly awaiting the task we created. The event loop was smart enough to switch the context to the foo
(the only other coroutine) as soon as it hit the await asyncio.sleep(1)
statement in the main
function.
🤔 It would be very interesting to see what would happen if there were 2 tasks & we awaited one of the tasks & didn't await the other.
Now what would happen if we await the task created using asyncio.create_task
?
import asyncio
async def main():
print("Hello")
task = asyncio.create_task(foo())
await task
# sleep for 1 second
await asyncio.sleep(1)
print("World")
async def foo():
print("foo")
await asyncio.sleep(10)
print("bar")
asyncio.run(main())
Output
Hello
foo
-- wait for 10 seconds --
bar
-- wait for 1 second --
World
Execution Time ~ 11.2 seconds
This is on expected lines. The execution order will be as follows -
main()
coroutine startsprint("Hello")
is executedasyncio.create_task(foo())
is executed. This schedules thefoo()
coroutine to be run by the event loop but doesn't start executing it immediately. The event loop will start executingfoo()
as soon as it gets control.await task
pauses the execution of themain()
coroutine until thefoo()
coroutine completes. This is our 1️⃣ explicit context switch.print("foo")
is executed. Sincefoo()
is the only other coroutine, the event loop runsfoo()
and prints "foo" to the console.await asyncio.sleep(10)
pauses the execution of thefoo()
coroutine for 10 seconds. This is our 2️⃣ explicit context switch.print("bar")
is executed after the 10-second sleep. Now, thefoo()
coroutine has completed. The event loop returns to themain()
coroutine.await asyncio.sleep(1)
pauses the execution of themain()
coroutine for 1 second. This is our 3️⃣ explicit context switch.print("World")
is executed after the 1-second sleep.- The
main()
coroutine has completed, and the event loop closes.
For the final variation, we will just switch the amount of time the foo
function sleeps for and the amount of time the main
function sleeps for.
import asyncio
async def main():
print("Hello")
task = asyncio.create_task(foo())
await task
await asyncio.sleep(10)
print("World")
async def foo():
print("foo")
await asyncio.sleep(1)
print("bar")
asyncio.run(main())
Output
Hello
foo
-- wait for 1 second --
bar
-- wait for 10 seconds --
World
Execution Time ~ 11.2 seconds
The execution order will be as follows -
asyncio.run(main())
: starts themain()
coroutine. This call initializes and starts running the event loop.print("Hello")
: prints "Hello" to the console.asyncio.create_task(foo())
: This schedules thefoo()
coroutine to be run by the event loop. However, it doesn't start executingfoo()
immediately; it merely schedules it to be run as soon as the event loop gets control and decides to start it.await task
: pauses the execution of themain()
coroutine until thefoo()
coroutine completes. This is our 1️⃣ explicit context switch.print("foo")
: is executed. Sincefoo()
is the only other coroutine, the event loop runsfoo()
and prints "foo" to the console.await asyncio.sleep(1)
: pauses the execution of thefoo()
coroutine for 1 second. This is our 2️⃣ explicit context switch.print("bar")
: is executed after the 1-second sleep.- Back to
main()
: Thefoo()
coroutine has completed. The event loop returns to themain()
coroutine. This is an implicit context switch. await asyncio.sleep(10)
: pauses the execution of themain()
coroutine for 10 seconds. This is our 3️⃣ explicit context switch.print("World")
: is executed after the 10-second sleep.
The most complex example -
import asyncio
async def main():
print("Hello")
task1 = asyncio.create_task(foo(x="foo", y="bar", sleep_time=1))
task2 = asyncio.create_task(foo(x="baz", y="qux", sleep_time=2))
await task1
await asyncio.sleep(0.5)
print("World")
async def foo(x: str, y: str, sleep_time: int):
print(x)
await asyncio.sleep(sleep_time)
print(y)
asyncio.run(main())
🤯 Output
Hello
foo
baz
-- wait for 1 second --
bar
World
Execution Time ~ 1.27 seconds
The execution order will be as follows -
asyncio.run(main())
: Starts the main() coroutine and initializes the event loop.print("Hello")
: Prints "Hello".- Task Creation:
task1 = asyncio.create_task(foo(x="foo", y="bar", sleep_time=1))
: Schedulesfoo()
to be run withx="foo", y="bar", sleep_time=1
.task2 = asyncio.create_task(foo(x="baz", y="qux", sleep_time=2))
: Schedules another instance offoo()
to be run concurrently withx="baz", y="qux", sleep_time=2
.
- Immediate Execution of Both Tasks:
task1
starts and prints "foo".- Almost simultaneously,
task2
starts and prints "baz".
- Context Switch Due to await in Tasks: Both tasks enter their respective sleep calls (
await asyncio.sleep(...)
).task1
will sleep for 1 second, andtask2
will sleep for 2 seconds. await task1
inmain
:- The
main()
coroutine now explicitly waits fortask1
to complete. This isn't a context switch to another task but rather waiting for a particular task (task1) to finish. - The event loop waits until task1's sleep of 1 second is complete. During this time, task2 is still sleeping.
- The
task1
completes:- After 1 second,
task1
resumes and prints "bar". task1
completes andmain()
resumes immediately aftertask1
.
- After 1 second,
main()
Continues Aftertask1
: Aftertask1
finishes,main()
executesawait asyncio.sleep(0.5)
. This introduces another context switch as main() now waits for 0.5 seconds. But this time, it's not waiting for a specific task to complete.print("World")
: After the 0.5-second sleep,main()
prints "World".
🔥 Note, that when the event loop faced await asyncio.sleep(0.5)
in main
function, it did context switch & ran the task2
function. But since the task2
function was still sleeping, the event loop switched back to the main
function after 0.5 seconds & printed "World".
🔥 🔥 Had the main
function be sleeping for 5
seconds, instead of 0.5
seconds, task2
would have ran to completion without explicitly needing to await for it.
So, basically, the context switch happens whenever the event loop faces an await
statement. The outpu then looks like -
Output
Hello
foo
baz
-- wait for 1 second --
bar
-- wait for another 1 second --
-- task2 already waited 1 second before (remeber task1 & task2 started concurrently) --
qux
-- wait for 5 seconds --
World
The execution time will be ~ 6.2 seconds.
NOTE: If the await
statement is in the main
function, the event loop will switch to the next task in the queue. If the await
statement is in a task, the event loop will switch to the next task in the queue.
You can create a list of tasks & run them concurrently using asyncio.TaskGroup
as well.
import asyncio
async def fetch_data(id, sleep_time):
print(f"Fetching data with id: {id}")
await asyncio.sleep(sleep_time)
print(f"Data with id: {id} fetched successfully")
return {"id": id, "data": id}
async def main():
tasks = []
async with asyncio.TaskGroup() as tg:
for i in range(1, 4):
task = tg.create_task(fetch_data(i, i))
tasks.append(task)
results = await asyncio.gather(*tasks)
for result in results:
print(result)
asyncio.run(main())
Output
Fetching data with id: 1
Fetching data with id: 2
Fetching data with id: 3
-- wait for 1 second --
Data with id: 1 fetched successfully
-- wait for another 1 second --
Data with id: 2 fetched successfully
-- wait for another 1 second --
Data with id: 3 fetched successfully
{'id': 1, 'data': 1}
{'id': 2, 'data': 2}
{'id': 3, 'data': 3}
Execution Time ~ 3.2 seconds
Here, all the 3 tasks ran concurrently & the total time taken was the time taken by the longest running task.
async.TaskGroup
is a context manager that creates a group of tasks. You can create tasks using tg.create_task
and then await the completion of all tasks using await tg
.