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
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 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.
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
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
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
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
main coroutine won’t exit until
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.
We can write the same program in a lot simpler way using
async def main(): await asyncio.gather(delayedPrint(2, "First"), delayedPrint(2, "Second"))
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.