import time
def waiter():
cook('Pasta', 8)
cook('Caesar salad', 3)
cook('Chops', 16)
def cook(order, time_to_prepare):
print(f'New order {order}')
time.sleep(time_to_prepare)
print(order, '-ready')
if __name__ == '__main__':
waiter()
Let’s save the file sync.py
. Here the cook is simulated as a function. He takes the order and the time to cook it. Then the cooking process itself is simulated with the time.sleep
function. And when it is finished, a message is displayed saying that the order is ready. There is another function of the waiter. According to the internal logic, the waiter takes orders from customers and synchronously passes them to the chef. Make sure you have Python 3.7+ installed by using python3 --version
on Mac or python --version
on Windows. If the version is less than 3.7, upgrade. Run the example to make sure that all orders are slowly but surely submitted.
New Order: Pasta
Pasta is done
New order: Caesar salad
Caesar Salad - Ready
New order: Chops
Chops - done
Table of Contents
The first asynchronous program
Now convert the program so that it uses the asyncio library. This will be the first step in figuring out how to write asynchronous code. Copy the sync.py
file into a new coros.py
file with the following code:
import asyncio
import time
async def waiter()-> None:
cook('Pasta', 8)
cook('Caesar salad', 3)
cook('Chops', 16)
async def cook(order: str, time_to_prepare: int)-> None:
print(f'New Order {order}')
time.sleep(time_to_prepare)
print(order, '-ready')
asyncio.run(waiter())
The first thing to do is to import the standard Python library called asyncio
. This is needed to get asynchronous features. At the end of the program, replace if __name__ == '__main__'
with the new run
method from the asyncio
module. What exactly does run
do? Essentially, run takes a low-level asyncio pseudo-server called the work loop. This loop is a coordinator that makes sure that tasks are paused and resumed from the code. In the cook and waiter example, the “cook(‘Pasta’)” call is a task that will run, but will also be suspended for 8 seconds. So once the request is received, it is marked and the program moves on to the next one. Once the pasta order is complete, the loop will continue on to the next line where the Caesar salad is prepared. The run
command needs a function to execute, so we pass the waiter
, which is the main function in this code. Run is also responsible for cleanup, so when all the code has run, it will disconnect from the loop. These changes, however, are not enough to make the code asynchronous. We need to tell asyncio which functions and tasks will run asynchronously. So let’s change the waiter
function to look like this.
async def waiter()-> None:
await cook('Pasta', 8)
await cook('Caesar salad', 3)
await cook('Chops', 16)
The function waiter
is declared asynchronous by adding an async
prefix at the beginning. After that it is possible to tell asyncio which of the tasks will be asynchronous inside. To do this, the keyword await
is added to them. Such code can read as follows: “call the cook
function and wait(await
) for its result before moving on to the next line”. But it is not a thread-locked process. On the contrary, it tells the loop the following: “if there are other requests, you can move on to their execution while we wait, and we’ll let you know when the current request finishes.” Just remember that if there are tasks with await
, the function itself must be declared with async
. What about the cook
function? It should also be asynchronous, so let’s rewrite it like this
async def cook(order, time_to_prepare):
print(f'New order {order}')
wait time.sleep(time_to_prepare)
print(order, '-ready')
But there is one problem here. If we use the standard time.sleep
function, it will block the whole execution process, making asynchronous program useless. In this case we need to use the sleep
function from the asyncio module.
async def cook(order, time_to_prepare):
print(f'New order {order}')
await asyncio.sleep(time_to_prepare)
print(order, '-ready')
This ensures that as long as the cook
function is in sleep until the timer ends, the program can start executing other requests. If you run the program now, the result will be:
New Order: Pasta
Pasta - done
New order: Caesar salad
Caesar Salad - Ready
New order: Chops
Chops - done
But there is no difference with the asynchronous version. Perhaps it seemed that this version would execute faster. In fact, this is one of the major misconceptions about asynchronous code. Some people mistakenly think it is faster. But this program is already better, although you can’t say that explicitly based on your experience with it. If you run this program as part of a site, it will be possible to serve hundreds of thousands of visitors simultaneously on a single server without any problems. When you use synchronous code instead, you can count on a couple of dozen users at most. If there are more of them, the server processor will not be able to handle the load.
Coroutines and tasks
Coroutines
The functions waiter
and cook
are transformed precisely at the moment when their definition is preceded by the async
keyword. At this point they can be considered as coroutines. If you try to execute one of these functions, you will get a message about it, but the program will not be executed. Let’s try to run a Python terminal and import the cook
function from a coros
file. First, you need to comment out the asyncio.run
command so that the code won’t run. After that the file can be saved.
# asyncio.run(waiter())
Then open the terminal and do the following:
>>> from coros import cook
>>> cook('Pasta', 8)
The coprograms can only be executed within a work cycle or their waiting (awaiting) within other coprograms. But there is a third way to execute a coprogram. We will show it in the next section.
Tasks
With tasks, we can start several coprograms at the same time. Copy the file coros.py
into the file tasks.py
and add the following:
import asyncio
async def waiter():
task1 = asyncio.create_task(cook('Pasta', 8))
task2 = asyncio.create_task(cook('Caesar salad', 3))
task3 = asyncio.create_task(cook('Chops', 16))
await task1
await task2
await task3
async def cook(order, time_to_prepare):
print(f'New order {order}')
await asyncio.sleep(time_to_prepare)
print(order, '-ready')
asyncio.run(waiter())
Here, we create tasks with three different orders. Tasks give you two advantages that you can’t get by adding await
:
- These are used to schedule sequential execution of coprograms;
- Tasks can be canceled when they are pending completion.
For example, in the code above, when waiting for three tasks, the three cook
coprograms run simultaneously, so the result is quite different. Run the code.
New Order: Pasta
New Order: Caesar Salad
New Order: Chops
Caesar salad - done
Pasta - done
Chops - done
This is already more like what many people have been waiting for: the waiter takes the orders in the order in which the cook returns them.
Common mistakes with asyncio
There are a few things to keep in mind when moving to asynchronous code:
Calling a blocking function from a coprogram
One of the most common problems is using a synchronous function inside an asynchronous function. One example of this is using the synchronous time.sleep
function inside an asynchronous cook
function. Using the usual sleep
method from the standard library would have blocked all the code. Try. Add import time
at the top of the coros.py
file and the synchronous sleep
function:
async def cook(order, time_to_prepare):
print(f'New Order {order}')
wait time.sleep(time_to_prepare)
print(order, '-ready')
The following error will be returned if you try to execute such code:
...
File "coros.py", line 7, in waiter
await cook('Pasta', 8)
File "coros.py", line 13, in cook
await time.sleep(time_to_prepare)
TypeError: object NoneType can't be used in 'await' expression
At first glance, this error seems strange. The point is that time.sleep()
is not an object to wait for (await). So it returns None
to the function that called it. And even the exception appears not immediately, but only after 8 seconds. On the other hand, asyncio.sleep
is also a co-program. This means that it returns a corresponding object (whose execution can be waited for). Such an object can be marked in the loop, after which the program will move on to the next requests until the sleep
function is finished. But this is an extremely good example. The danger of synchronous or blocking functions is that sometimes they quietly block code in such a way that you can’t even see the errors. This is because they do not return to the function that called them. It is therefore important to remember that only other co-programs have to be called inside the co-program.
Not waiting for the completion of the co-program
First we remove time.sleep
and replace it with asyncio.sleep
. Then we change the second call to the cook
function by taking away the await
keyword:
import asyncio
import time
async def waiter()-> None:
await cook('Pasta', 8)
cook('Caesar salad', 3)
await cook('Chops', 16)
async cook(order, time_to_prepare):
print(f'New order {order}')
await asyncio.sleep(time_to_prepare)
print(order, '-ready')
asyncio.run(waiter())
Trying to run this code will return the following error:
coros.py:5: RuntimeWarning: coroutine 'cook' was never awaited
It appears when a function has been called without await
. Sometimes it’s not so obvious and you’ll have to dig around to find out exactly where it happened.
Unreceived results
Another trap is the termination of the co-program, when the internal co-program is still running. What happens to it at this point? You can get an error from the Python garbage collector. For example, let’s take the following code:
import asyncio
async def executed():
asyncio.sleep(15)
print("function executed")
async def main():
asyncio.create_task(executed())
asyncio.run(main())
After running it the following error will be returned:
RuntimeWarning: coroutine 'sleep' was never awaited
asyncio.sleep(15)
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
Function executed
In this situation, the main
co-program is executed, but since the result of the internal co-program is not expected, the execution ends, and asyncio.sleep
did not have a chance to start. Now you know what to look out for when you get a not consumed
error. These problems are much easier to deal with if you have some practice of working with asynchronous code, so do not be afraid of it. Still its advantages far outweigh the disadvantages and potential pitfalls.