asyncio is a  module  that provides tools for implementing asynchronous programming. It was introduced in Python 3.3 and has since become a popular choice for creating and managing asynchronous tasks.

The module is available in the standard library, and can therefore be imported and used directly without extra installations.
import the asyncio module

ExampleEdit & Run
import asyncio

print(asyncio)
copy
Output:
<module 'asyncio' from '/app/.heroku/python/lib/python3.11/asyncio/__init__.py'> [Finished in 0.06553183495998383s]

Basic usage

Asynchronous programming makes it possible to execute multiple pieces of code in a non-blocking manner. The basic idea is to execute a task and when a delay is encountered, execution can switch to another task instead of blocking and waiting for the delay to finish.

Asynchronous tasks are created and managed using the async and await keywords in addition to the various functions defined in the asyncio module. Consider the following example:

ExampleEdit & Run

basic asyncio usage

#import the module
import asyncio

#define asynchronous tasks
async def task1():
    #prints even numbers from 0 to 9
    for i in range(10):
       if i%2 ==0:
           print(i)
           await asyncio.sleep(0.0001)#cause a small delay

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

async def main():
    print('Started:')
    await asyncio.gather(task1(), task2())
    print('Finished!')

asyncio.run(main())
copy
Output:
Started: 0 1 2 3 4 5 6 7 8 9 Finished! [Finished in 0.06169923022389412s]

In the above snippet, we:

  1. imported the asyncio module.
  2. Defined two asynchronous tasks(corourines), task1, and task2. task1 prints even numbers from 0 to 9 while task2 prints odd numbers in the same range. Inside each coroutine the asyncio.sleep() call simulates a delay in the execution of the  coroutine, this makes the execution to move to the other coroutine.
  3. We defined the main coroutine, then awaited task1 and task2 inside it.  The asyncio.gather() function combines multiple coroutines into a single awaitable object.
  4. We executed the main() coroutine using the asyncio.run() function.
  5. The result is that task1 and task2 are executed in parallel, as you can tell from the output.

The above example may look somehow complicated but this is the most complete basic example of a practical asynchronous program I can think of.

In the following sections, we will explore the two keywords and the various asyncio functions and what each does.

async/await keywords

Python provides the async and await keywords for creating coroutines. Coroutines are functions that can be executed asynchronously. To be more specific, coroutines are functions in which execution can be suspended and then resumed later on. 

  1. async defines a function as an asynchronous coroutine.
  2. await suspends the running coroutine until another coroutine is fully executed.

The async keyword

Defining coroutine function is much like defining regular functions. We simply put the async keyword before def to tell the interpreter that this is a coroutine function

ExampleEdit & Run

Define a coroutine function

async def greet(name):
     print('Hello ' + name)
copy
Output:
[Finished in 0.010112148709595203s]

Unlike in a regular function, the statement in a coroutine do not get executed immediately when the coroutine is called. Instead, a coroutine object is created.

ExampleEdit & Run

calling a coroutine

async def greet(name):
     print('Hello ' + name)

coro = greet('Jane')#This does not execute the  statements in the coroutine
print(coro)#a coroutine object is created
copy
Output:
<coroutine object greet at 0x7f8842deb5e0> sys:1: RuntimeWarning: coroutine 'greet' was never awaited [Finished in 0.010153516195714474s]

To actually execute the statements in a given coroutine, you have to await it in what is referred to as an event loop. Python provides a simpler way to do this through the asyncio.run() function.

The asyncio.run() function takes a single coroutine as the argument and then automatically performs tasks that a programmer would be required to handle manually, such as scheduling,  creating an event loop,  running the event loop, etc

ExampleEdit & Run
import asyncio

async def greet(name):
     print('Hello ' + name)

coro = greet('Jane')#This does not execute the  statements in the coroutine
asyncio.run(coro)#run the coroutine
copy
Output:
Hello Jane [Finished in 0.05439588101580739s]

The await keyword

Consider what you would do if you wanted to run a coroutine inside of another coroutine. One might be tempted to use the asyncio.run() function inside the coroutine,  but this would automatically lead to a RunTimeError exception.

ExampleEdit & Run
import asyncio

async def greet(name):
     print('Hello ' + name)

async def main():
      asyncio.run(greet('John'))
coro = main()
asyncio.run(coro)#runtime error
copy
Output:
Traceback (most recent call last):   File "<string>", line 9, in <module>   File "/app/.heroku/python/lib/python3.11/asyncio/runners.py", line 190, in run     return runner.run(main)            ^^^^^^^^^^^^^^^^   File "/app/.heroku/python/lib/python3.11/asyncio/runners.py", line 118, in run     return self._loop.run_until_complete(task)            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   File "/app/.heroku/python/lib/python3.11/asyncio/base_events.py", line 654, in run_until_complete     return future.result()            ^^^^^^^^^^^^^^^   File "<string>", line 7, in main   File "/app/.heroku/python/lib/python3.11/asyncio/runners.py", line 186, in run     raise RuntimeError( RuntimeError: asyncio.run() cannot be called from a running event loop sys:1: RuntimeWarning: coroutine 'greet' was never awaited [Finished in 0.056075477972626686s]

To run a coroutine inside of another coroutine, you need to use await keyword. The keyword has the following syntax:

Syntax:
await <coroutine>
copy

await makes the running coroutine to be suspended until the awaited <coroutine> is completely executed.

ExampleEdit & Run
import asyncio

async def greet(name):
     print('Hello ' + name)

async def main():
      await greet('John')

coro = main()
asyncio.run(coro)
copy
Output:
Hello John [Finished in 0.05428297398611903s]

Running coroutines simultaneously

The primary goal of asynchronous programming is to execute multiple tasks in a non-blocking manner. This means that a task does not have to wait until another task is fully executed in order to start.

To run multiple tasks in parallel you would be required to manually interact with the event loop as we will see later. But again, Python offers a simpler way to do this. The process  is as illustrated below:

  1. Create asynchronous coroutines.
  2. Combine and schedule them using asyncio.gather() function
  3. Await the coroutine object returned by asyncio.gather() in another coroutine eg. main().
  4. Run main using asyncio.run() function.

Let us look again at the example that we started with. 

ExampleEdit & Run
#import the module
import asyncio

#define asynchronous tasks
async def task1():
    #prints even numbers from 0 to 9
    for i in range(10):
       if i%2 ==0:
           print(i)
           await asyncio.sleep(0.0001)#cause a small delay

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

async def main():
    print('Started:')
    await asyncio.gather(task1(), task2())
    print('Finished!')

asyncio.run(main())
copy
Output:
Started: 0 1 2 3 4 5 6 7 8 9 Finished! [Finished in 0.06245312886312604s]

If you observe the above output, you will see that the two function are actually being executed at the same time as the output is emitted alternately. Or are they?

One thing that you should keep in mind is that true simultaneity is not achievable with asynchronous programming. In reality, the two tasks are not being executed simultaneously. The small delay caused by asyncio.sleep(0.0001) is enough to make the execution to switch between the two tasks.

ExampleEdit & Run
#import the module
import asyncio

#define asynchronous tasks
async def print_nums():
    for n in range(5):
       print(n)
       await asyncio.sleep(0.0001)#cause a small delay

async def print_letters():
     #prints odd numbers from 0 to 9
     for l in 'abcde':
        print(l)
        await asyncio.sleep(0.0001) # cause a small delay
async def main():
    print('Started:')
    await asyncio.gather(print_nums(), print_letters())
    print('Finished!')

asyncio.run(main())
copy
Output:
Started: 0 a 1 b 2 c 3 d 4 e Finished! [Finished in 0.06083644088357687s]

This is essentially how asynchronous programming works, when a delay is encountered in a running coroutine, the execution is passed to the next coroutine in the schedule. This can be especially useful when dealing with tasks that may lead to blockage such as I/O bound tasks. As it allows the program to continue executing other tasks instead of being blocked until the task is completed.  

The event loop

We have been talking about the event loop without formally introducing it.

In this section we will manually interact with event loops to run manage asynchronous tasks. We will see what the asyncio.run() function is doing in the background.

Creating an event loop

The new_event_loop() function in the asyncio module creates and returns an event loop.

ExampleEdit & Run

create a loop

import asyncio

loop = asyncio.new_event_loop()

print(loop)
print(loop.is_running())
print(loop.is_closed())

loop.close()
print(loop.is_closed())
copy
Output:
<_UnixSelectorEventLoop running=False closed=False debug=False> False False True [Finished in 0.05442363582551479s]

In the above example, we created an event loop then used some methods to check the state of the loop. The is_running() method checks whether a loop is currently running. The is_closed() method checks whether a loop is closed.

You should always remember to call the close() method after you are done using the loop.

run a single coroutine in an event loop

The loop.run_until_complete() can be used to manually execute a one-off coroutine in an event loop.

ExampleEdit & Run
import asyncio

#the coroutine
async def task():
    print('Hello, World!')

loop = asyncio.new_event_loop() #create the loop
loop.run_until_complete(task()) #run the coroutine

loop.close()#close the loop
copy
Output:
Hello, World! [Finished in 0.05517697287723422s]

Run multiple coroutines in an event loop

When multiple coroutines have to be executed simultaneously, we will be required to create an event loop, schedule the tasks in the event loop, the execute them. The following snippet shows a basic example:

ExampleEdit & Run
import asyncio

#the tasks
async def print_nums(num):
     for n in range(num):
          print(n)    
          await asyncio.sleep(0.0001)#simulate

async def print_letters(up_to):
     for letter in range(97,ord(up_to)):
          print(chr(letter))
          await asyncio.sleep(0.0001)#simulate delay

#create an event loop
loop = asyncio.new_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()
copy
Output:
0 a 1 b 2 c 3 d 4 e [Finished in 0.05978733906522393s]