- 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&awaitdoesn't guarantee concurrency. To actually run things concurrently, you need to create tasks. Seev4&v5below & 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
foocoroutine starts running, prints "foo", and then encountersawait asyncio.sleep(1). - At this point,
foopauses 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,
fooresumes 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
WorldExecution 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
WorldExecution Time
~200ms
baris 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
WorldExecution Time ~ 1.2 seconds
The
mainfunction will wait for thefoofunction to complete before printing "World". Thefoofunction will complete in 1 second and then themainfunction 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
WorldExecution 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
WorldExecution 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
asyncandawaitkeywords doesn't make your code run concurrently. You need to create tasks usingasyncio.create_taskto 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
mainfunction will sleep for 1 second after creating the task
Output
Hello
foo
-- wait for 1 second --
WorldExecution 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 thetaskcreated previously, the event loop runs thefoocoroutines & hence "foo" is printed to the console.await asyncio.sleep(10):foo()coroutine is now going to wait for 10 seconds. Theawaitkeyword 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 --
WorldExecution 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 taskpauses 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 --
WorldExecution 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
WorldExecution 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:
task1starts and prints "foo".- Almost simultaneously,
task2starts and prints "baz".
- Context Switch Due to await in Tasks: Both tasks enter their respective sleep calls (
await asyncio.sleep(...)).task1will sleep for 1 second, andtask2will sleep for 2 seconds. await task1inmain:- The
main()coroutine now explicitly waits fortask1to 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
task1completes:- After 1 second,
task1resumes and prints "bar". task1completes andmain()resumes immediately aftertask1.
- After 1 second,
main()Continues Aftertask1: Aftertask1finishes,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 --
WorldThe 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.