Python coroutines allow for asynchronous programming in a language that earlier in its history, has only supported synchronous execution. I’ve previously compared taking a synchronous approach in Python to a parallel approach in Go using channels. If you’re familiar with async/await in JavaScript, Python’s syntax will look familiar. Python’s event loop allows coroutines to yield control back to the loop, awaiting their turn to resume execution, which can lead to more efficient use of resources. Using coroutines in Python is different from JavaScript because they can easily or even accidentally be intermingled with synchronously executing functions. Doing this can produce some unexpected results, such as blocking the event loop and preventing other tasks from running concurrently.

Here is an example demonstrating the issue:

import asyncio
import time

async def blocking_function():
    print("This function blocks.")
    # This will block the event loop
    time.sleep(5)
    print("Function complete.")

async def main():
    print("Started.")
    await asyncio.gather(
        blocking_function(),
        blocking_function(),
    )
    print("All complete.")

if __name__ == "__main__":
    asyncio.run(main())

The above code outputs

Started.
This function blocks.
Function complete.
This function blocks.
Function complete.
All complete.

Even though blocking_function is an async function and thus can be awaited as a coroutine, it blocks the event loop when called. When we run the code above, we see the functions run in series based on the printed output.

Most importantly, these calls block the entire event loop started in main. This behavior may be obvious when presented in such a transparent example, but can easily become a problem that is more difficult to diagnose with layers of async/await calls. Here is the equivalent code using coroutines throughout.

import asyncio


async def async_function():
    print("This function is async.")
    # asyncio.sleep is a non-blocking sleep that allows other coroutines to run while this one is sleeping
    await asyncio.sleep(5)
    print("Async function complete.")

async def main():
    # These will now run in parallel
    await asyncio.gather(
        async_function(),
        async_function(),
    )
    print("All complete.")

if __name__ == "__main__":
    asyncio.run(main())

This code outputs

This function is async.
This function is async.
Async function complete.
Async function complete.
All complete.

We see the printed output is different – the functions begin and end together and the running duration of the program is around half compared to the first example. The sleep calls are “non-blocking” and the event loop remains available to process additional coroutines rather than getting monopolized by a single synchronous call, behind which all additional work gets blocked.

The above example is contrived, if you’re using coroutines in Python, it quickly shows up. Consider this call to OpenAI using their client.

async def get_completion(prompt: str):
    print("start completion")
    client = OpenAI()
    response = client.chat.completions.create(
        model="gpt-4-1106-preview",
        messages=[
            {
                "role": "user",
                "content": prompt,
            },
        ],
    )
    content = response.choices[0].message.content
    print(content)
    return content

This call will block the event loop because the client.chat.completions.create is synchronous, even if we try and call the functions in a non-blocking manner.

import asyncio


async def main():
    task1 = asyncio.create_task(get_completion("Pick a random color"))
    task2 = asyncio.create_task(get_completion("Pick a random number"))
    await asyncio.gather(task1, task2)


if __name__ == "__main__":
    asyncio.run(main())

The output looks like this

start completion
Cerulean.
start completion
Sure! Here's a random number: 67

If we switch to using an AsyncOpenAI from the openai package, we can now make actual non-blocking calls.

import asyncio


async def nonblocking_get_completion(prompt: str):
    print("start completion")
    client = AsyncOpenAI()
    response = await client.chat.completions.create(
        model="gpt-4-1106-preview",
        messages=[
            {
                "role": "user",
                "content": prompt,
            },
        ],
    )
    content = response.choices[0].message.content
    print(content)
    return content

async def main():
    task1 = asyncio.create_task(nonblocking_get_completion("Pick a random color"))
    task2 = asyncio.create_task(nonblocking_get_completion("Pick a random number"))
    await asyncio.gather(task1, task2)

if __name__ == "__main__":
    asyncio.run(main())

This outputs something like

start completion
start completion
Periwinkle
Sure! Here is a random number: 42

Based on the output, we see nonblocking_get_completion was called twice before either completes, which indicates they’re running as non-blocking coroutines.