Concurrent programming using Python’s Async IO

For a long time, achieving concurrency in Python was a difficult task. Python developers often had to use threads to run tasks concurrently. However, with the advent of Async IO, Python has greatly simplified concurrency. 

To understand Async IO better, let’s take an example. There is a function that takes a delay and a string as arguments. The function prints the string five times every time after the specified delay. If we pass 2 and “First” as the arguments, then the function should print “First” five times after waiting for 2 seconds every time.   

Running the functions synchronously

If we call this function again passing 2 and “Second” as the arguments, we will first have “First” printed five times after which “Second” will be printed five times. Altogether, the program will take 20 seconds. Our goal, now, is to run these two concurrently so that “First” and “Second” will be printed almost simultaneously. The program should complete printing in 10 seconds.  

Let’s try to implement this function in Python.  

def delayedPrint(delay, text): 
    i = 0 

If we call delayedPrint passing 2 and “First” as the arguments and follow it up by calling it again with 2 and “Second”, the output will be as follows. 

Async IO - Synchronous

This shows that the program is calling these functions sequentially. Now, let’s try to run them concurrently. To make them run concurrently, we have to turn delayedPrint into an asynchronous function.  

Using Async IO to run these functions asynchronously

From Python 3.7 onwards, we have async and await keywords to run functions asynchronously. To declare delayedPrint to be an asynchronous function, let’s use the async keyword. You have to import. The asyncio library before being able to use the async keyword.  

async def delayedPrint(delay, text): 

We call functions such as this as coroutines. 

Python runs functions asynchronously using an event loop. When an async function is called, the event loop runs it. When the function reaches a time-consuming task, the event loop pauses the function and moves on to the next function. Once the time-consuming task is completed, the function resumes running.  

Awaitables in Async IO

The time-consuming tasks are called awaitables. Awaitables can be coroutines, Tasks, or Futures. The await keyword is used with awaitables to tell the event loop to not block at that task and to move on to the next function.  

In our example, the time-consuming task is the sleep function. Instead of waiting there for a couple of seconds, the event loop should move to the next function and come back when the two seconds elapse. In order to make Python do this, we need to use the await keyword with the sleep function. 

However, unfortunately, the native sleep function is not an awaitable. So, we will have to use the sleep function provided by the “asyncio” library.  

def delayedPrint(delay, text): 
    i = 0 
        await asyncio.sleep(delay) 

Now, our async function (coroutine) is ready. But we can’t call it directly like calling normal functions. Either it needs to be called with the awaitable keyword within another coroutine or we should use to run it.  

Using we will only be able to call the coroutine once. Since we need to call the coroutine twice, we will have to call them using the await keyword within another coroutine. So, let’s create another coroutine called main and call the delayedPrint coroutine inside it. 

async def main(): 
    await delayedPrint(2, "First") 
    await delayedPrint(2, "Second") 

Now, we can use to run the main coroutine.

If you run this program, you will see that this no different to the one we ran before. First, “First” will be printed five times followed by “Second”.  

This is because both the coroutines are being called inside one coroutine. When the event loop reaches the first await command, it doesn’t have another coroutine to execute. So, it pauses there until the first coroutine is completed and then moves on to the next coroutine. This happens synchronously. 

Using Tasks in Async IO to achieve concurrency

To make the delayedPrint coroutines run concurrently, we need to wrap the coroutines inside Tasks. Tasks are used to schedule coroutines concurrently. We can wrap a coroutine within a Task using the asyncio.create_task() method. 

async def main(): 
    task1=asyncio.create_task(delayedPrint(2, "First")) 
    task2=asyncio.create_task(delayedPrint(2, "Second")) 

Now, when we run the program, we will get no output in the console! This happens because the main coroutine exits as soon as the two lines are executed. The Tasks start running as soon as they are called. After the second Task is called the main coroutine exits ending the program.  

To prevent this, we need to wait till task1 and task2 are completed. We can use the await for this.  

async def main(): 
    task1=asyncio.create_task(delayedPrint(0, "First")) 
    task2=asyncio.create_task(delayedPrint(0, "Second")) 

    await task1
    await task2

Now, the main coroutine won’t exit until task1 and task2 are completed. At the same time, these two Tasks will be running concurrently. So, the output to the console will look like this. 

Async IO using Tasks

Unlike the previous time, here both “First” and “Second” get printed one after the other and the program as a whole takes only around 10 seconds. This is because both the coroutines ran concurrently. 

Using asyncio.gather

We can write the same program in a lot simpler way using asyncio.gather.  

async def main(): 
    await asyncio.gather(delayedPrint(2, "First"), delayedPrint(2, "Second")) 

The asyncio.gather runs awaitables concurrently. If the awaitable is a coroutine as it is the case here, this method automatically schedules them as Tasks.  

You can find codes used above in the GitHub repository available here.  

Leave a Reply