Getting started with async in Python
Christopher Graf
Driving DevSecOps Success Through Strategic Partnerships | Connecting Tools, Technologies & Teams
Starting with asynchronous programming can be overwhelming. It definitely was to me.
Our usual programs run in a synchronous fashion, each statement waiting for the previous to finish. When it comes to asynchronous code, our brain must adapt, e.g., values of variables might not be available yet or they might never be returned.
In this article I want to show examples that clarify the differences between synchronous and asynchronous code and the consequences of blocking vs non-blocking calls to help you with your journey to asynchronous algorithms.
Hello (async) World!
The library we will use for our examples is called Python's asyncio.
Let us start with the Hello World of asyncio:
import asyncio
async def main():
print('Hello ...')
await asyncio.sleep(1)
print('... World!')
# Python 3.7+
asyncio.run(main())
The previous code will:
Now you might think, what the heck does this give me? Why do I have to use?asyncio?with?async/await when I can accomplish the same thing with a simple?time.sleep()?
import time
def main():
print('Hello ...')
time.sleep(1)
print('... World!')
main()
Where is the difference? The answer is non-blocking vs. blocking code. Let us imagine that we want other tasks to run in the meantime while our program waits for either?sleep()?to finish. After all, otherwise we could be wasting precious CPU cycles.
Adding tasks to blocking code
import time
def task(n):
print(f'Starting Task {n}.')
time.sleep(1)
print(f'Done with Task {n}.')
def main():
print(f"started at {time.strftime('%X')}")
# Start some additional tasks
task(1)
task(2)
task(3)
print('Hello ...')
time.sleep(2)
print('... World!')
print(f"finished at {time.strftime('%X')}")
main()
Here is the output for the?blocking?version:
started at 21:11:25
Starting Task 1.
Done with Task 1.
Starting Task 2.
Done with Task 2.
Starting Task 3.
Done with Task 3.
Hello ...
... World!
finished at 21:11:30
In the blocking version, the program took?5 seconds?to complete. This is what we expected to happen.
Adding tasks to non-blocking code
import asyncio import time
async def task(n):
print(f'Starting Task {n}.')
await asyncio.sleep(1)
print(f'Done with Task {n}.')
async def main():
print(f"started at {time.strftime('%X')}")
# Start some additional tasks
t1 = asyncio.create_task(task(1))
t2 = asyncio.create_task(task(2))
t3 = asyncio.create_task(task(3))
print('Hello ...')
await asyncio.sleep(2)
print('... World!')
await asyncio.gather(t1, t2, t3)
print(f"finished at {time.strftime('%X')}")
Here is the printout for the?non-blocking?version:
started at 21:01:31
Hello ...
Starting Task 1.
Starting Task 2.
Starting Task 3.
Done with Task 1.
Done with Task 2.
Done with Task 3.
... World!
finished at 21:01:33
Our code only took?2 seconds?to finish! ????
What happened under the hood?
Blocking version
The code started with?task(1), which involved sleeping for 1 second, then continued with?task(2), waiting for that task to sleep for another second, then repeated the same for?task(3). Then the program finally came to our?Hello World?code snippet, which involved sleeping for 2 seconds.
领英推荐
Non-blocking version
Let's first start with a quick definition of?asyncio's?event loop.
The event loop is the core of every asyncio application. Event loops run asynchronous tasks and callbacks, perform network IO operations, and run subprocesses.
Where does this come into play? When we execute?asyncio.run(main()), we start the event loop.
With the?asyncio.create_task()?calls, we tell the event loop:?"Hey mate, here are some tasks I want to schedule. Please keep an eye on them, but no need to wait for them to finish before you move on. Go do some other stuff in the meantime."
# Start some additional tasks
t1 = asyncio.create_task(task(1))
t2 = asyncio.create_task(task(2))
t3 = asyncio.create_task(task(3))
And while the 3 tasks are running concurrently, we can turn our attention to our?Hello World?snippet, which can execute without having to wait for the tasks to finish.
print('Hello ...')
await asyncio.sleep(2)
print('... World!')
We finally make sure to wait for our 3 tasks to finish, otherwise we would leave the function and exit the program before the tasks are done.
await asyncio.gather(t1, t2, t3)
You can try this out for yourself by changing the argument of the call to?asyncio.sleep()?inside the function?task()?to 3 instead of 1 and running the program.
async def task(n):
print(f'Starting Task {n}.')
await asyncio.sleep(3) # changed argument to 3
print(f'Done with Task {n}.')
Output:
started at 22:44:06
Hello ...
Starting Task 1.
Starting Task 2.
Starting Task 3.
... World!
finished at 22:44:08
We are not able to finish the tasks because we didn't wait for them to finish ??
Don't add blocking code to your non-blocking code
We looked at separate examples for blocking and non-blocking code. What about mixing blocking and non-blocking code? Blocking code will block non-blocking code (say that 10 times fast)!
Let us take a look at one more example to showcase why you need to be aware of the consequences when mixing blocking and non-blocking code.
import asyncio
import time
async def task(n):
print(f'Starting Task {n}.')
await asyncio.sleep(1)
print(f'Done with Task {n}.')
async def main():
print(f"started at {time.strftime('%X')}")
# Start some additional tasks which can be completed asynchronously
t1 = asyncio.create_task(task(1))
t2 = asyncio.create_task(task(2))
t3 = asyncio.create_task(task(3))
print('Hello ...')
time.sleep(2) # changed from asyncio.sleep(2)
print('... World!')
await asyncio.gather(t1, t2, t3)
print(f"finished at {time.strftime('%X')}")
asyncio.run(main())
What do you think will be the run-time when executing this code snippet?
started at 22:53:50
Hello ...
... World!
Starting Task 1.
Starting Task 2.
Starting Task 3.
Done with Task 1.
Done with Task 2.
Done with Task 3.
finished at 22:53:53
The answer is?3 seconds. This version took 1 second longer than our?pure?asynchronous version.
What happened?
As before, we scheduled the 3 tasks to run immediately without waiting for each other to complete. This takes about 1 second in total. Next, we execute the blocking function?time.sleep(2). This is where even our asynchronous tasks are put on-hold, i.e., blocked.
Conclusion
By now you should have a better understanding of the differences between blocking and non-blocking code and be motivated to explore?asyncio?on your own. If you have any questions or comments, please let me know.
Christopher is a consultant at?DEMICON?and has been working in the Automotive Industry within Infrastructure & Operations, Connected Cars, and Telematics for over ten years. As a result, Christopher has experienced firsthand the problems heterogeneous tools, teams, and processes cause. Therefore, Christopher sees fostering an Agile mindset and DevOps practices as part of his mission. One of his significant involvements is building data analysis, aggregation, and synchronization between separate units and tools at a premium Automotive OEM.
B2B Marketing | Social Media | Content Marketing | AWS Women's User Group Berlin UG Lead | Elevating Powerful Brand Identities Through Creative Content Strategy ?
3 年Great article, Christopher Graf! ??