Asynchronous programming

So far we have been doing synchronous programming. Synchronous program execution is quite simple: a program starts at the first line, then each line is executed until the program reaches the end. Each time a function is called, the program waits for the function to return before continuing to the next line.

In asynchronous programming, the execution of a function is usually non-blocking. In other words, each time you call a function it returns immediately. However, that function does not necessarily gets executed right way. Instead, there is usually a mechanism (called the “scheduler”) which is responsible for the future execution of the function.

The problem with asynchronous programming is that a program may end before any asynchronous function starts. A common solution for this is for asynchronous functions to return “futures” or “promises”. These are objects that represent the state of execution of an async function. Finally, asynchronous programming frameworks typically have mechanisms to block or wait for those async functions to end based on those “future” objects.

Since Python 3.6, the “asyncio” module combined with the async and await keyword allows us to implement what is called co-operative multitasking programs. In this type of programming, a coroutine function voluntarily yields control to another coroutine function when idle or when waiting for some input.

Consider the following asynchronous function that squares a number and sleeps for one second before returning. Asynchronous functions are declared with async def. Ignore the await keyword for now:

  1. import asyncio
  2. async def square(x):
  3. print('Square', x)
  4. await asyncio.sleep(1)
  5. print('End square', x)
  6. return x * x
  7. # Create event loop
  8. loop = asyncio.get_event_loop()
  9. # Run async function and wait for completion
  10. results = loop.run_until_complete(square(1))
  11. print(results)
  12. # Close the loop
  13. loop.close()

The event loop (https://docs.python.org/3/library/asyncio-eventloop.html) is, among other things, the Python mechanism that schedules the execution of asynchronous functions. We use the loop to run the function until completion. This is a synchronizing mechanism that makes sure the next print statement doesn’t execute until we have some results.

The previous example is not a good example of asynchronous programming because we don’t need that much complexity to execute only one function. However, imagine that you would need to execute the square(x) function three times, like this:

  1. square(1)
  2. square(2)
  3. square(3)

Since the square() function has a sleep function inside, the total execution time of this program would be 3 seconds. However, given that the computer is going to be idle for a full second each time the function is executed, why can’t we start the next call while the previous is sleeping? Here’s how we do it:

  1. # Run async function and wait for completion
  2. results = loop.run_until_complete(asyncio.gather(
  3. square(1),
  4. square(2),
  5. square(3)
  6. ))
  7. print(results)

Basically, we use asyncio.gather(*tasks) to inform the loop to wait for all tasks to finish. Since the coroutines will start at almost the same time, the program will run for only 1 second. Asyncio gather() won’t necessarily run the coroutines by order although it will return an ordered list of results.

  1. $ python3 python_async.py
  2. Square 2
  3. Square 1
  4. Square 3
  5. End square 2
  6. End square 1
  7. End square 3
  8. [1, 4, 9]

Sometimes results may be needed as soon as they are available. For that we can use a second coroutine that deals with each result using asyncio.as_completed():

  1. (...)
  2. async def when_done(tasks):
  3. for res in asyncio.as_completed(tasks):
  4. print('Result:', await res)
  5. loop = asyncio.get_event_loop()
  6. loop.run_until_complete(when_done([
  7. square(1),
  8. square(2),
  9. square(3)
  10. ]))

This will print something like:

  1. Square 2
  2. Square 3
  3. Square 1
  4. End square 3
  5. Result: 9
  6. End square 1
  7. Result: 1
  8. End square 2
  9. Result: 4

Finally, async coroutines can call other async coroutine functions with the await keyword:

  1. async def compute_square(x):
  2. await asyncio.sleep(1)
  3. return x * x
  4. async def square(x):
  5. print('Square', x)
  6. res = await compute_square(x)
  7. print('End square', x)
  8. return res

Exercises with asyncio

  1. Implement an asynchronous coroutine function to add two variables and sleep for the duration of the sum. Use the asyncio loop to call the function with two numbers.

  2. Change the previous program to schedule the execution of two calls to the sum function.