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 while(i<5): sleep(delay) print(text) i+=1
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.
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): pass
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 while(i<5): await asyncio.sleep(delay) print(text) i+=1
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 asyncio.run()
to run it.
Using asyncio.run()
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 asyncio.run()
to run the main
coroutine.
asyncio.run(main())
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.
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 availablehere.
Leave a Reply