Search code examples
pythonpython-asyncio

Relationship between Python asyncio loop and executor


I generally understand the concept of async vs threads/processes, I am just a bit confused when I am reading about the event loop of asyncio.

When you use asyncio.run() I presume it creates an event loop? Does this event loop use an executor? The link above says the event loop will use the default executor, which after a brief google search it appears to be ThreadPoolExecutor.

I am a bit confused if I am writing coroutines why would that use ThreadPoolExecutor since I don't want to execute my async code in threads.


Solution

  • When you use asyncio.run() I presume it creates an event loop?

    Yes, from https://docs.python.org/3/library/asyncio-runner.html#asyncio.run:

    This function always creates a new event loop and closes it at the end.


    Does this event loop use an executor?

    The event loop provides single-threaded concurrency for IO-bound work. For CPU-bound work, which would otherwise block the event loop, asyncio lets you execute code outside the event loop in a separate thread or process, by using an executor.

    This is done by calling run_in_executor. It wraps the executor API and returns an awaitable which lets you use it transparently in async/await code, whereas if you used an executor directly, you would have to deal with futures and callbacks.1

    The event loop has a default executor which will be used by run_in_executor if no other executor is provided as argument. A custom default executor can be provided by calling set_default_executor.

    If you don't call run_in_executor yourself, the code you pass to asyncio.run will not be executed in an executor, but asyncio might still use the default executor internally for some of its own code, I guess.

    asyncio.run will automatically shut down the default executor at the end by calling shutdown_default_executor internally (since Python 3.9).2


    1 To illustrate the differences, suppose you want to do some CPU-intensive calculation in a separate thread and then report on the result.

    Using ThreadPoolExecutor and Future.add_done_callback:

    from concurrent.futures import ThreadPoolExecutor
    from time import sleep
    
    def calculate():
        print("Calculating...")
        sleep(1)
        return 42
    
    def report(future):
        print(f"The result is {future.result()}")
    
    with ThreadPoolExecutor() as executor:
        future = executor.submit(calculate)
        future.add_done_callback(report)
    

    Using ThreadPoolExecutor and concurrent.futures.as_completed:

    from concurrent.futures import ThreadPoolExecutor, as_completed
    from time import sleep
    
    def calculate():
        print("Calculating...")
        sleep(1)
        return 42
    
    with ThreadPoolExecutor() as executor:
        futures = [executor.submit(calculate)]
        for future in as_completed(futures):
            print(f"The result is {future.result()}")
    

    Using asyncio:

    import asyncio
    from time import sleep
    
    def calculate():
        print("Calculating...")
        sleep(1)
        return 42
    
    async def main():
        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(None, calculate)
        print(f"The result is {result}")
    
    asyncio.run(main())
    

    2 I'm not sure if the remark about the change in Python 3.9 in https://docs.python.org/3/library/asyncio-runner.html#asyncio.run means that in earlier versions it did not shut down the executor at all, or if it did it by other means than calling shutdown_default_executor:

    This function runs the passed coroutine, taking care of managing [...], and closing the threadpool.
    [...]
    Changed in version 3.9: Updated to use loop.shutdown_default_executor().