Concurrency in Python is no doubt a complex topic and one that is hard to understand. More so, it also doesn’t help that there are multiple ways to produce concurrent programs. For a lot of people, they have to deal with lots of thoughts including asking questions like,
Should I spin up multiple threads?
Use multiple processes?
Use asynchronous programming?
Here is the thing, you should use async IO when you can and use threading when you must.
Today's post will give you detailed information about asynchronous programs both in the older versions of Python as well as the “newer” versions of Python.
Here’s what we will be cover:
- What is asyncio
- Why use asyncio instead of multiple threads in Python?
- Working with an older codebase? The old way of creating asynchronous programs in Python
- Python 3 and the new way of creating asynchronous programs.
- Chaining coroutines (old vs new)
What is asyncio:
- Basically asyncio is a library designed to help you write concurrent code using the async/await syntax. This is often a perfect fit for Input Output-bound and high-level structured network code.
- To put things simply, asyncio is a library that is used to write concurrent code using the async/await syntax.
- However, if your code is Cpu-Bound, then you should use multiprocessing instead of asyncio.
Why use asyncio instead of multiple threads in Python:
- It’s very difficult to write code that is thread-safe. With asynchronous code, you know exactly where the code will shift from one task to the next task, plus race conditions are much harder to come by.
- Threads consume a fair amount of data since each thread needs to have its own stack. With async code, all the code shares the same stack and the stack is kept small due to continuously unwinding the stack between tasks.
- Threads are OS structures and therefore require more memory for the platform to support. There is no such problem with asynchronous tasks.
Python 3.5 asyncio and the new way to create asynchronous programs:
There are three main elements to creating asynchronous programs in Python and they include: coroutines, event loops, and futures.
Coroutines: Coroutines can be defined using the syntax. And before we get into further details, here is an example of a very simple coroutine in python 3.5
async def coro(): await asyncio.sleep(1)
The above coroutine can be run with an event loop as follows.
loop = asyncio.get_event_loop() loop.run_until_complete(coro())
Async: We can create a native coroutine by using async def. A method prefixed with async def, automatically becomes a native coroutine.
async def useless_native_coroutine(): pass
Coroutines using python 3.7+
import asyncio
async def main():
print('hello')
await asyncio.sleep(5)
print('world')
asyncio.run(main())
Output:
hello
world
Looking at this example, asyncio.run will call the main function which will eventually lead to the first ‘hello’ been printed after a 5 seconds delay. This is what we call an asyncio.sleep(5). This will subsequently print the word ‘world.’ This code can be used to cause delay in any task.
From the explanations above, you can easily see how we can deploy coroutines by using 3.7+ version, when we aren’t using 3.7 versions. In python 3.6 or earlier versions, we need to run a coroutine using an event loop.
Event loops: The event loop is a programming construct that wait for events to happen and then dispatches them to an event handler. An event can be a user clicking on a UI button or a process that initiates a file download.
Running an event loop: When using Python 3.7+, which by the way is the preferred way to run the event loop, we advise that you use the asyncio.run() method. This method involves blocking a call till the passed-in coroutine finishes. Here is a sample program to this effect
async def do_something_important(): await asyncio.sleep(10) if __name__ == "__main__": asyncio.run(do_something_important())
Note: If you are working with Python 3.5, then you should know that the asyncio.run() functionality is not available. In that case, you can use event loop by leveraging asyncio.new_event_loop() to run your desired coroutine. Again, you can use run_until_complete() defined on the loop object.
Futures: Future represents a computation that is either in progress or will get scheduled in the future. It is a special low-level awaitable object that represents an eventual result of an asynchronous operation. In general, you wouldn’t need to deal with futures directly. They are usually exposed by libraries or asyncio APIs.
As we proceed further, we’ll show an example that creates a future that is awaited by a coroutine.
import asyncio from asyncio import Future async def bar(future): print("bar will sleep for 3 seconds") await asyncio.sleep(3) print("bar resolving the future") future.done() future.set_result("future is resolved") async def foo(future): print("foo will await the future") await future print("foo finds the future resolved") async def main(): future = Future() results = await asyncio.gather(foo(future), bar(future)) if __name__ == "__main__": asyncio.run(main()) print("main exiting")
Tasks: Tasks are like futures, in fact, Task is a subclass of Future and can be created using the following methods:
asyncio.create_task()
It was introduced in Python 3.7 and has become the preferred way of creating tasks. The method accepts coroutines and wraps them as tasks.loop.create_task()
This only accepts coroutines.asyncio.ensure_future()
This accepts futures, coroutines and any awaitable objects.
import asyncio
async def nested():
return 42
async def main():
task = asyncio.create_task(nested())
await task
asyncio.run(main())
To actually run a coroutine, asyncio provides three main mechanisms:
- The
asyncio.run()
, is a function designed to run top-level entry point “main()” function like asyncio.run(main()) by this simple main() function will call. - Here, checkout an example of awaiting a coroutine:
import asyncio import time async def say_after(delay, what): await asyncio.sleep(delay) print(what) async def main(): print(f"started at {time.strftime('%X')}") await say_after(1, 'hello') await say_after(2, 'world') print(f"finished at {time.strftime('%X')}") asyncio.run(main())
The following code will print “hello” after waiting for 1 second. After waiting two seconds, it will then print “world.” On the flip side, asyncio.run(main()) will call main function immediately after that first wait. To put things simply, after the code prints the first “hello” after a 1 second delay, it proceeds to print “world” after another 2 seconds.
- The
asyncio.create_task()
function to run coroutines concurrently as asyncioTasks
can be written thus:
async def main(): task1 = asyncio.create_task( say_after(1, 'hello')) task2 = asyncio.create_task( say_after(2, 'world')) await task1 await task2
In this example, the main function will run as same, after this, it will await the call job which print hello. Immediately after 2 seconds, it will print world.
Awaitables: We say that an object is an awaitable if that object can be used in an await
expression.
coroutines: Python coroutines are awaitables and therefore can be awaited from other coroutines.
import asyncio async def nested(): return 42 async def main(): nested() print(await nested()) # will print "42". asyncio.run(main())
In this example, if we will call nested function without await, then a coroutine object is created but not awaited. This means it won’t run at all. However, if we call nested with await then ‘42’ will print.
We can run tasks Concurrently using asyncio.gather
.
Example:
import asyncio async def factorial(name, number): f = 1 for i in range(2, number + 1): print(f"Task {name}: Compute factorial({i})...") await asyncio.sleep(1) f *= i print(f"Task {name}: factorial({number}) = {f}") async def main(): await asyncio.gather( factorial("A", 2), factorial("B", 3), factorial("C", 4), ) asyncio.run(main()) Output: Task A: Compute factorial(2) Task B: Compute factorial(2) Task C: Compute factorial(2) Task A: factorial(2) = 2 Task B: Compute factorial(3) Task C: Compute factorial(3) Task B: factorial(3) = 6 Task C: Compute factorial(4) Task C: factorial(4) = 24
Timeouts: _coroutine _asyncio.wait_for
If aw is a coroutine and is automatically scheduled as a Task and lets say a timeout occurs, it cancels the task and raises asyncio.TimeoutError
. To avoid the task cancellation
, wrap it in shield()
. The function will wait until the future is actually canceled, so the total wait time may exceed the timeout.
Example:
async def eternity(): await asyncio.sleep(3600) async def main(): try: await asyncio.wait_for(eternity(), timeout=1.0) except asyncio.TimeoutError: print('timeout!') asyncio.run(main())
In this example, we can see that asyncio.run(main()) will call main function then try to call eternity function especially in a situation where we have defined sleep time to be 1 hour and have our timeout time set at only 1 second. To this end, there is only a one-second wait for the job to be done, otherwise it will return a ‘timeout’ error.
These are just a few basic concepts of asyncio covered in today’s post. Subsequently, we will bring you more information on this topic.