Asyncio Basics in Python

Skyler Lewis
Development at Canopy Tax
6 min readMar 9, 2018
Concurrently a unicorn and a person.

Python 3.5 brought with it asyncio, and is part of the standard library. There are other async libraries out there, but I am going to focus on the one built in to Python.

Creating an event loop

import asyncio

async def some_func():
print("Done sleeping")
await asyncio.sleep(1)
print("Done sleeping")

asyncio.run(some_func())

This is the basic example to get you up and running. This is the core of asyncio. Starting an event loop and running some function on top of it. Some frameworks abstract this from you and handle it on the bootstrap of the application layer (fastapi, for example).

The async function

async def some_func():
print("Done sleeping")
await asyncio.sleep(1)
print("Done sleeping")

An async function is like defining a regular function, except we add the async keyword before def. This not only let's you know the function is asynchronous, but it also let's the interpreter know that it’s a special function called a coroutine.

In our first example we started an event loop and ran our function inside asyncio.run() That will run your function until all synchronous and non-synchronous calls are complete. I like to think of this step as an instantiation of our asynchronous paradigm.

The real magic — await

Let’s add a new async function and rename some_func to run since that is all we are going to have it do -- run our async logic.

import asyncio

async def speak():
print("Hey!")

async def run():
await speak()

asyncio.run(run())

await tells our event loop to pause here and wait for the coroutine to finish work before continuing on.

If you do not await the function, then the function will run in series, and the event loop will not yield time to another coroutine. If your function is a coroutine (starts with async def) then not calling await on your function will give you an runtime warning:

coroutine 'speak' was never awaited

Starting a function and getting back to it later.

The first thing you might need an async pattern for is starting a job in the background, do other tasks in the front and then coming back to your original task.

import asyncio

async def my_async_func():
print("B")
await asyncio.sleep(1)
print("C")

async def run():
async_func = my_async_func()
print("A")
await async_func

asyncio.run(run())

If you run this you will see the results:

A
B
C

That’s what we wanted right? Close. We wanted to start our async job my_async_func()and when it was done, return the results. However, the first thing we printed to our terminal was A not C. We did not actually start our job, we just defined it. We started it’s execution later when we called await my_aysnc_func. To start the job running while we move on to other tasks, we need to use asyncio.create_task().

import asyncio

async def my_async_func():
print("B")
await asyncio.sleep(1)
print("C")

async def run():
async_func = asyncio.create_task(my_async_func())
await asyncio.sleep(0)
print("A")
await async_func

asyncio.run(run())

Note: I added a 0 second sleep immediately after we did create_task(). In order for our async_func task to run, we must yield the event loop, allowing the event loop to schedule the task to begin work.

  1. We make our coroutine a task
  2. we call asyncio.sleep(0) to pause execution on run() and hop over to my_async_func
  3. inside my_async_func we print “B”.
  4. The function will continue to do any other work until we hit the next await statement where it yields back to run().
  5. In run it then prints “A”.
  6. It then hits await async_func where it yields back to my_async_func to print “C”

This should result in the following output:

B
A
C

Order mucking

import asyncio

async def my_async_func(letter):
print(letter)

async def run():
# We assign our coroutines to variables
a = my_async_func("a")
b = my_async_func("b")
c = my_async_func("c")
d = my_async_func("d")

# Then we can execute then in any order we wish
await c
await b
await d
await a

asyncio.run(run())

This will return:

c
b
d
a

In the above example, I am showing that you can define your work in any order. Then await them out of order. Or in order. Or out of order and then back in order. Or any combination. Great. They all run sequentially though in the order they are awaited.

Concurrent runnings

import asyncio

async def my_async_func(i):
print("Starting:", i)
await asyncio.sleep(1)
print("Ending:", i)


async def run():
tasks = []
for i in range(3):
tasks.append(asyncio.create_task(my_async_func(i)))
await asyncio.wait(tasks)

asyncio.run(run())

Returns:

Starting: 0
Starting: 1
Starting: 2
Ending: 0
Ending: 1
Ending: 2

Here we are creating a list and then adding all of our items to said list. Then we use wait to execute our tasks concurrently. Don't forget to await the asyncio.wait(). The asyncio.wait function requires your coroutines to be wrapped in a task.

What if we want to do something with the results of the functions?

Handling async function results

Our previous example handles the concurrency beautifully. But what if you want to do something with the results of those functions, a la “scatter-gather”? Use gather()!

import asyncio

async def my_async_func(i):
print("Starting:", i)
await asyncio.sleep(1)
print("Ending:", i)
return (i + 1) * 2 # the first i is a 0, which is why we add 1


async def run():
tasks = []
for i in range(3):
tasks.append(asyncio.create_task(my_async_func(i)))
r = await asyncio.gather(*tasks)
print("Do something with the results:", sum(r)) # sum is just an example of doing something with the results.

asyncio.run(run())

This returns:

Starting: 0
Starting: 1
Starting: 2
Ending: 0
Ending: 1
Ending: 2
Do something with the results: 12

Update (03–31–18): Blocking the loop

One of the most frustrating things you will run into is blocking the event loop. This happens when using a non async function or library. For example if you use requests (non-async as of this update) it will block the event loop, halting all execution until the http call completes and produces a result. Other things that might block the event loop: time.sleep() If you need a non-blocking sleep use asyncio.sleep() Http requests, use a library like aiohttp for async requesting. AMQP, aioamqp is a great alternative to Pika. Last example, Databases. Postgres users I recommend asyncpg and asyncpgsa.

Example of blocking the event loop.

import asyncio
import time
async def bark():
await asyncio.sleep(3)
time.sleep(3) # This here is the culprit
async def speak():
await bark()
return True

async def run():
r = [speak() for x in range(10)]
await asyncio.wait(r)
loop = asyncio.get_event_loop()
loop.run_until_complete(run())

If we were to profile the above example in Pycharm, you can see that the first 3 seconds all jobs run at the same. Then we hit a blocking call, and each task is forced to wait until the previous jobs sleep finishes.

Update (12–04–18): Unblock the Blockers

I ran into a situation recently where I was using a library that was synchronous. This threw a wrench into how I had designed my worker service. Now, instead of being able to run many jobs simultaneously, all jobs stopped to wait for my synchronous code to run.

loop.run_in_executor() allows you to take a non async function and put it into an executor, without getting technical makes a sync function async. Take the following code for example:

import asyncio
import time
from concurrent.futures import ThreadPoolExecutor

# This is the blocking function you want to run
def blocking_function():
print("Start blocking")
time.sleep(5) # This is the blocking operation
print("End blocking")

# An async wrapper to run the blocking function in a separate thread
async def run_blocking_function():
loop = asyncio.get_running_loop()
# Run in a default ThreadPoolExecutor
await loop.run_in_executor(None, blocking_function)

# Main function to run async tasks
async def main():
# Running the blocking function without blocking the event loop
await run_blocking_function()

# Run the main function
asyncio.run(main())

In this example, loop.run_in_executor(None, blocking_function) runs the blocking function in a separate thread. By passing None as the first argument, it uses the default ThreadPoolExecutor. This approach allows the rest of your asyncio program to continue running asynchronously, even while waiting for the blocking operation to complete.

Skyler Lewis makes no claims to expertise or exactness of the information presented and may not reflect the opinions of the owners or advertisers. Parental guidance isn’t recommended. Any rebroadcast, retransmission, or account of this article, without the express written consent of Major League Baseball, is not prohibited. Use as directed. May cause sleepiness, headaches, backaches, or thoughts of bacon wrapped shrimp.

--

--