Python asynchrony

by Alex
Python asynchrony
A good real-world example of comparing applications with synchronization and asynchrony is a waiter and a cook in a busy restaurant. The waiter takes orders and dispenses them, and the cook prepares the food.
Let’s write the cook and waiter functions using traditional synchronous Python code. This is what it will look like:
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

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:

  1. These are used to schedule sequential execution of coprograms;
  2. 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.

Related Posts

LEAVE A COMMENT