Search code examples
pythonpython-asyncio

Exception in python asyncio.Task not raised until main Task complete


Here is the code, I thought the program will crash at once because of the uncaught exception. However it waited 10s when the main task coro2 completes.

import asyncio

@asyncio.coroutine
def coro1():
    print("coro1 primed")
    yield
    raise Exception("abc")

@asyncio.coroutine
def coro2(loop):
    try:
        print("coro2 primed")
        ts = [asyncio.Task(coro1(),loop=loop) for _ in range(2)]
        res = yield from asyncio.sleep(10)
        print(res)
    except Exception as e:
        print(e)
        raise

loop= asyncio.get_event_loop()
loop.run_until_complete(coro2(loop))

I think this is a serious problems because in more complicated programs, this makes the process stuck forever, instead of crashing with exception information.

Besides, I set a breakpoint in the except block in source code of run_until_complete but it's not triggered. I am interested in which piece of code handled that exception in python asyncio.


Solution

  • First, there is no reason to use generator-based coroutines in Python with the async/await syntax available for many years, and the coroutine decorator now deprecated and scheduled for removal. Also, you don't need to pass the event loop down to each coroutine, you can always use asyncio.get_event_loop() to obtain it when you need it. But these are unrelated to your question.

    The except block in coro2 didn't trigger because the exception raised in coro1 didn't propagate to coro2. This is because you explicitly ran coro1 as a task, which executed it in the background, and didn't await it. You should always ensure that your tasks are awaited and then exceptions won't pass unnoticed; doing this systematically is sometimes referred to as structured concurrency.

    The correct way to write the above would be something like:

    async def coro1():
        print("coro1 primed")
        await asyncio.sleep(0)  # yield to the event loop
        raise Exception("abc")
    
    async def coro2():
        try:
            print("coro2 primed")
            ts = [asyncio.create_task(coro1()) for _ in range(2)]
            await asyncio.sleep(10)
            # ensure we pick up results of the tasks that we've started
            for t in ts:
                await t
          
        except Exception as e:
            print(e)
            raise
    
    asyncio.run(coro2())
    

    Note that this will run sleep() to completion and only then propagate the exceptions raised by the background tasks. If you wanted to propagate immediately, you could use asyncio.gather(), in which case you wouldn't have to bother with explicitly creating tasks in the first place:

    async def coro2():
        try:
            print("coro2 primed")
            res, *ignored = await asyncio.gather(
                asyncio.sleep(10),
                *[(coro1()) for _ in range(2)]
            )
            print(res)
        except Exception as e:
            print(e)
            raise
    

    I am interested in which piece of code handled that exception in python asyncio.

    An exception raised by a coroutine which is not handled is caught by asyncio and stored in the task object. This allows you to await the task or (if you know it's completed) obtain its result using the result() method, either of which will propagate (re-raise) the exception. Since your code never accessed the task's result, the exception instance remained forgotten inside the task object. Python goes so far to notice this and print a "Task exception was never retrieved" warning when the task object is destroyed along with a traceback, but this warning is provided on a best-effort basis, usually comes too late, and should not be relied upon.