Search code examples
pythonasynchronousasync-awaitconcurrencyfastapi

Awaiting multiple async functions in sequence


I have been learning and exploring Python asyncio for a while. Before starting this journey I have read loads of articles to understand the subtle differences between multithreading, multiprocessing, and asyncio. But, as far as I know, I missed something on about a fundamental issue. I'll try to explain what I mean by pseudocodes below.

import asyncio
import time


async def io_bound():
    print("Running io_bound...")
    await asyncio.sleep(3)


async def main():
    start = time.perf_counter()

    result_1 = await io_bound()
    result_2 = await io_bound()

    end = time.perf_counter()

    print(f"Finished in {round(end - start, 0)} second(s).")


asyncio.run(main())

For sure, it will take around 6 seconds because we called the io_bound coroutine directly twice and didn't put them to the event loop. This also means that they were not run concurrently. If I would like to run them concurrently I will have to use asyncio.gather(*tasks) feature. I run them concurrently it would only take 3 seconds for sure.

Let's imagine this io_bound coroutine is a coroutine that queries a database to get back some data. This application could be built with FastAPI roughly as follows.

from fastapi import FastAPI

app = FastAPI()


@app.get("/async-example")
async def async_example():
    result_1 = await get_user()
    result_2 = await get_countries()

    if result_1:
        return {"result": result_2}
    
    return {"result": None}

Let's say the get_user and get_countries methods take 3 seconds each and have asynchronous queries implemented correctly. My questions are:

  1. Do I need to use asyncio.gather(*tasks) for these two database queries? If necessary, why? If not, why?
  2. What is the difference between io_bound, which I call twice, and get_user and get_countries, which I call back to back, in the above example?
  3. In the io_bound example, if I did the same thing in FastAPI, wouldn't it take only 6 seconds to give a response back? If so, why not 3 seconds?
  4. In the context of FastAPI, when would be the right time to use asyncio.gather(*tasks) in an endpoint?

Solution

    1. Do I need to use asyncio.gather(*tasks) for these two database queries? If necessary, why? If not, why?

    Do you need to? Nope, what you have done works. The request will take 6 seconds but will not be blocking so if you had another request coming in, FastAPI can process the two requests at the same time. I.e. two requests coming in at the same time will take 6 seconds still, rather than 12 seconds.

    If the two functions get_user() and get_countries() are independant of eachother, then you can get the run the functions concurrently using either asyncio.gather or any of the many other ways of doing it in asyncio, which will mean the request will now take just 3 seconds. For example:

    async def main():
        start = time.perf_counter()
    
        result_1_task = asyncio.create_task(io_bound())
        result_2_task = asyncio.create_task(io_bound())
    
        result_1 = await result_1_task
        result_2 = await result_2_task
    
        end = time.perf_counter()
    
        print(f"Finished in {round(end - start, 0)} second(s).")
    

    or

    async def main_2():
        start = time.perf_counter()
    
        results = await asyncio.gather(io_bound(), io_bound())
    
        end = time.perf_counter()
    
        print(f"Finished in {round(end - start, 0)} second(s).")
    
    1. What is the difference between io_bound, which I call twice, and get_user and get_countries, which I call back to back, in the above example?

    assuming get_user and get_countries just call io_bound, nothing.

    1. In the io_bound example, if I did the same thing in FastAPI, wouldn't it take only 6 seconds to give a response back? If so, why not 3 seconds?

    It will take 6 seconds. FastAPI doesn't do magic to change the way your functions work, it just allows you to create a server that can easily run asynchronous functions.

    1. In the context of FastAPI, when would be the right time to use asyncio.gather(*tasks) in an endpoint?

    When you want run two or more asyncronous functions concurrently. This is the same, regardless of if you are using FastAPI or any other asynchronous code in python.