Normally, Python programs are synchronous in nature. This means that tasks in a program are executed sequentially i.e one task has to be completed before another one is started. In more familiar terms,  if you have say two functions, the first one to be called will be executed to completion before the next one starts.

Asynchronous programming aims at overcoming the limitations of sequential mode of execution. It enables  multiple  tasks to be executed concurrently without blocking execution of other tasks. It is more suitable for managing long-running tasks or those that needs to be run in the background such as I/O bound tasks.

Python offers two keywords for creating asynchronous tasks i.e async and await. The asyncio module in the standard library provides the framework for managing such tasks.

Basic asynchronous program

We will start by looking at the simplest program that demonstrates how to use the async and await keywords together with asyncio.

#import asyncio
import asyncio

#define a coroutine
async def main():
    print("Hello, World!")
    await asyncio.sleep(1)#delay execution for 1 second
    print("Goodbye.")

#run main()
asyncio.run(main())

Hello, World!
Goodbye!

In the above example, the function main() has been defined with async keyword appearing before the usual def. Functions defined this way are known as coroutines, these are the type of functions that are eligible of being executed asynchronously. If you are wondering, this is the reason why asynchronous programming is also called coroutine-based programming.

The asyncio.run() function is used to execute functions defined using async and all other similar functions that are called from the function. 

A deeper look on coroutines

In this section we will understand how to create coroutines and how they work.

A coroutine is a function whose execution can be suspended and later on resumed at the same point. If that sounds familiar, it is because coroutines are very much similar to generator functions which you are most likely already familiar with.

The async and await keywords did not exist until  Python 3.5. In older versions of Python, developers used generator functions in a special way to create coroutines. Thanks to the addition of the two keywords, we can now create coroutines in a native and more intuitive way.

The following example shows the simplest example of a coroutine.

async def func():
    return 10

f = func()
print(f)

The definition of the  above coroutine look just like that of a regular function, except that it begins with the async keyword.

The inspect module in the standard library provides the iscoroutinefunction() which can be used to tell whether a function is a  coroutine function or a regular function.

#import the inspect module
import inspect

#a regular function
def func1():
    return 10

#a coroutine function
async def func2():
    return 10

print(inspect.iscoroutinefunction(func1))
print(inspect.iscoroutinefunction(func2))
Running Coroutines

Calling a coroutine directly (as in func()) does not make the statements in its body to be executed, instead, a coroutine object is created. 

Coroutine objects are supposed to be run asynchronously. The most basic way to do so is by using the asyncio.run() function.

#import the asyncio library
import asyncio

#define a coroutine
async def func():
    return 10

#create a coroutine object
coro = func()

#run the coroutine
print(asyncio.run(coro))

Calling the asyncio.run() function makes the running thread to pause and wait until the given coroutine is fully executed.

The asyncio.run() function masks  several operations that you would otherwise have been required to perform manually,  such operations includes creating what is called an event loop , scheduling the coroutine in the loop, and then running the loop. We will look deeper on these operations in a while.

The await keyword

The await keyword is used inside a coroutine function, when another coroutine needs to be run inside the function. It makes the function's execution to pause until the awaited coroutine is completely executed.

Its syntax is as follows:

await <awaitable>

The <awaitable> parameter in its most regular usage refers to a coroutine object. However, any object that defines the __await__() method can be used.

Consider the following example:

import asyncio

#a coroutine to print numbers from 1 to 10
async def task():
      for i in range(10):
          print(i)

async def main():
    await task()
    print("Finished")

asyncio.run(main())

In the above example, we defined two coroutine functions, task() and main(). We used the await keyword when calling task() inside main(). The await keyword makes  main()'s execution to be suspended until task() is completely executed.

Note: Using a coroutine object inside of another coroutine without awaiting it will not work.

Running multiple coroutines simultaneously

The main purpose of asynchronous programming is to execute multiple coroutines simultaneously. 

To run two or more coroutines in parallel, we can first group them into a single awaitable object using the asyncio.gather() function, then call the asyncio.run() function with the object as the argument.

Consider the following example:

import asyncio

#prints even numbers
async def task1():
    for i in range(10):
        if i % 2 == 0:
            print(i)
            await asyncio.sleep(0.1) # cause a small delay
            
#prints odd numbers
async def task2():
    for i in range(10):
        if i % 2 == 1:
            print(i)
            await asyncio.sleep(0.1) # cause a small delay

async def main():
    await asyncio.gather( task1(), task2())
    print("Finished")

asyncio.run(main())

If you observe the above output, you will see that the two functions are actually running simultaneously instead of one after the other. Outputs from the two functions are emitted alternately making it look as if it was just a single function printing all the numbers.

Another example:

import asyncio

#prints numbers
async def task1():
    for num in range(5):
        print(num)
        await asyncio.sleep(0.1) # cause a small delay
            
#prints letters
async def task2():
    for letter in "abcde":
         print(letter)
         await asyncio.sleep(0.1) # cause a small delay

async def main():
    await asyncio.gather( task1(), task2())
    print("Finished")

asyncio.run(main())

The Event Loop

The asyncio.run() function does a lot of work in the background that you would otherwise had to do manually. In this part we will look at how we can manually manage tasks without using the asyncio.run() function.

When we are dealing with multiple coroutines simultaneously, we may need to interact manually with what is called an event loop. Event loops handles the switching between the coroutines and at the same time responds to other coroutine events.

The steps to run tasks in an event loop manually are as follows:

  1. Create an  event loop instance using asyncio.get_event_loop() .
  2. Schedule coroutines to be executed in the event loop using loop.create_task() .
  3. Block the current thread and run the loop until all tasks are done  using loop.run_until_complete().

Consider the following example:

import asyncio

#the tasks
async def print_evens(num):
     for i in range(num):
          if i % 2 == 0:
               print(i) 
     
          await asyncio.sleep(0.5)#delay for half a second

async def print_odds(num):
     for i in range(num):
          if i % 2 == 1:
              print(i)

          await asyncio.sleep(0.5)#delay for half a second

#create an event loop
loop = asyncio.get_event_loop()

#schedule the tasks for execution
task1 = loop.create_task(print_evens(10))
task2 = loop.create_task(print_odds(10))

group = asyncio.gather(task1, task2)

#run_the_loop
loop.run_until_complete(group)

0
1
2
3
4
5
6
7
8
9

The asyncio.run_until_complete() function expects an awaitable object as the argument. The asyncio.gather() method can be used, as in above,   to combine multiple coroutines into a single awaitable object that can then be passed as an argument to the run_until_complete() function.

Instead of manually passing the task objects to the asyncio.gather() function, we can use the asyncio.all_tasks() function to get a set of all the tasks scheduled in a loop, then pass them using the asterisk operator to asyncio.gather(). In simpler terms, we can replace,

this:


#schedule the tasks for execution
task1 = loop.create_task(print_evens(10))
task2 = loop.create_task(print_odds(10))

group = asyncio.gather(task1, task2)

with this:

loop.create_task(print_evens(10))
loop.create_task(print_odds(10))

tasks = asyncio.all_tasks(loop)
group = asyncio.gather(*tasks)

This may look trivial but it can be very helpful if you have a lot of tasks to be grouped together.

Consider the following example:

import asyncio

#the tasks
async def print_nums(num):
     for n in range(num):
          print(n) 
     
          await asyncio.sleep(0.5)#delay for half a second

async def print_letters(up_to):
     for letter in range(97,ord(up_to)):
          print(chr(letter))

          await asyncio.sleep(0.5)#delay for half a second

#create an event loop
loop = asyncio.get_event_loop()

#schedule the tasks for execution
loop.create_task(print_nums(5))
loop.create_task(print_letters('f'))

#get all the tasks scheduled in the loop
tasks = asyncio.all_tasks(loop = loop)
#create an awaitable for the tasks
group = asyncio.gather(*tasks)

#run the loop
loop.run_until_complete(group)

#close the loop
loop.close()

0
a
1
b
2
c
3
d
4
e