Getting started with Python ASYNCIO

By, Cooper Jogan
  • January 11, 2024

Python is an interpreted language. Which means by default it executes code line by line freely at runtime. But sometimes when you execute your code there are certain places where your whole code blocks for a request to finish or it gets stuck due to complex computations. And then all of a sudden your whole code blocks which makes your whole program block and you wish there was a way to run the damn thing seperately on its own and somehow later notified you when it finished. And in the mean time you could execute other code and comeback to it later when you got notified of the finished task.
This is exactly what asyncio does for you. You can run your code concurently along side other code in harmony without blocking each other.

We’ll be using Python 3.10 for this article where the asyncio library has been made a lot easier to use. I cannot guarantee the workability of your code if you use other versions.

Let’s jump in.

First things first. Let’s get familiarized about the terms used for the asyncio library. I will explain everything at a beginner level and will try to keep things as simple to grasp as possible. If you want to dive deeper into this topic, check python’s official documentions where everything is explained in details.

Couroutines: These are just regular functions with the async keyword at the beginning and is awaitable. Coroutines can execute other coroutines inside it with the await keyword. We will create these to control the flow of asynchronous code.

Await: It’s a keyword used to give control back to the event loop and waits until a task is done or has returned with a result.

Event loop: When running a coroutine where an await keyword is encountered the event loop gets the control. It then checks if a particular awaited task is done. If it isn’t, it moves on to check for other tasks that are done or has returned some result. This is when the loop gives the control back to the coroutine to execute rest of the code inside it until another await is encountered.

1_mhopIIMmI5SZgGHQXZTbwQ

Two coroutines running together

import asyncio

TIMES = 5

async def coro1():
    for _ in range(TIMES):
        await asyncio.sleep(1)
        print("coro1()")

async def coro2():
    for _ in range(TIMES):
        await asyncio.sleep(2)
        print ("coro2()")

async def main():
    task1 = asyncio.create_task(coro1())
    task2 = asyncio.create_task(coro2())
    await task1
    await task2

asyncio.run(main())

Output:

coro1()
coro2()
coro1()
coro1()
coro2()
coro1()
coro1()
coro2()
coro2()
coro2()

You can run the code as it is. The coroutine coro1() has run 5 times once every second. And the coroutine coro2() has run 5 times every 2 seconds. The part where we awaited asyncio.sleep() is where the event loop recieved control and the coroutine stopped and waited until the event loop gave the control back after the time was done. And in the mean time gave control to the other coroutine. And the cycle repeated for each iteration.

Please note that there’s no guarantee what coroutine will run first when they both execute simultaneously at the same time. This is when the infamous “race condition” occur if one of the coroutines depend on the other somehow.

Although it seems like task 2 should run after task 1 is done, this is not the case. Both tasks have already started the moment we called asyncio.create_task() . We were merely awaiting for them to finish or else the program would have exited immedietly.

Note: If you know for sure how long your coroutines should run you can even omit the await task1 and await task2 . Instead prevent the program from exiting by adding await asyncio.sleep(YOUR_TIME_OUT_VALUE) in its place.

To manually cancel the tasks before they finish you can call task1.cancel() or task2.cancel().

Turning Blocking code into non-blocking awaitable code

import asyncio

TIMES = 5

async def coro1():
    for _ in range(TIMES):
        await asyncio.sleep(1)
        print("coro1()")

async def coro2():
    for _ in range(TIMES):
        await asyncio.sleep(2)
        print ("coro2()")

async def main():
    task1 = asyncio.create_task(coro1())
    task2 = asyncio.create_task(coro2())    
    task3 = asyncio.create_task(asyncio.to_thread(input, "Type something: "))

    await task3

    result = task3.result()
    print("Your input is: ", result)

asyncio.run(main())

Output:

Type something: coro1()
coro2()
coro1()
coro1()
coro2()
coro1()
coro1()
coro2()
coro2()
coro2()
Hello world
Your input is:  Hello world

There are a few interesting things going on here.

  1. We have written the program in a way that three coroutines are running simultaneously.
  2. We have only awaited task3 but task1 and task2 are also running. If you had provided any input through the console before task1 or task2 was done the program would have exited because there won’t be anything blocking main().
  3. If you had called input() directly instead of using asyncio.to_thread() . The whole program would have blocked because as we know from before the await keyword tells the event loop to switch between tasks.
  4. The asyncio.to_thread() does another thing that is interesting. It creates it’s own thread separate from the main thread and does it’s computations independently there and returns a result to the event loop when it’s done.
Tags: