pythonpython-3.xpython-asyncio

How do I handle SIGTERM inside python async methods?


Based on this code, I'm trying to catch SIGINT and SIGTERM. It works perfectly for SIGINT: I see it enter the signal handler, then my tasks do their cleanup before the whole program exits. On SIGTERM, though, the program simply exits immediately.

My code is a bit of a hybrid of the two examples from the link above, as much of the original doesn't work under python 3.12:

import asyncio
import functools
import signal

async def signal_handler(sig, loop):
    """
    Exit cleanly on SIGTERM ("docker stop"), SIGINT (^C when interactive)
    """
    print('caught {0}'.format(sig.name))
    tasks = [task for task in asyncio.all_tasks() if task is not
             asyncio.current_task()]
    list(map(lambda task: task.cancel(), tasks))
    results = await asyncio.gather(*tasks, return_exceptions=True)
    print('finished awaiting cancelled tasks, results: {0}'.format(results))
    loop.stop()

if __name__ == "__main__":
    loop = asyncio.new_event_loop()
    asyncio.ensure_future(task1(), loop=loop)
    asyncio.ensure_future(task2(), loop=loop)
    loop.add_signal_handler(signal.SIGTERM,
                                functools.partial(asyncio.ensure_future,
                                                signal_handler(signal.SIGTERM, loop)))
    loop.add_signal_handler(signal.SIGINT,
                                functools.partial(asyncio.ensure_future,
                                                signal_handler(signal.SIGINT, loop)))
    try:
        loop.run_forever()
    finally:
        loop.close()

task1 can terminate immediately, but task2 has cleanup code that is clearly being executed after SIGINT, but not after SIGTERM


Solution

  • That gist is very old, and asyncio/python has evolved since.

    Your code sort of works, but the way it's designed, the signal handling will create two coroutines, one of which will not be awaited when the other signal is received. This is because the couroutines are eagerly created, but they're only launched (ensure_future) when the corresponding signal is received. Thus, SIGTERM will be properly handled, but python will complain with RuntimeWarning: corouting 'signal_handler' was never awaited.

    A more modern take on your version might look something like:

    import asyncio
    import signal
    
    
    async def task1():
        try:
            while True:
                print("Task 1 running...")
                await asyncio.sleep(1)
        except asyncio.CancelledError:
            print("Task 1 cancelled")
            # Task naturally stops once it raises CancelledError.
    
    
    async def task2():
        try:
            while True:
                print("Task 2 running...")
                await asyncio.sleep(1)
        except asyncio.CancelledError:
            print("Task 2 cancelled")
    
    
    # Need to pass the list of tasks to cancel so that we don't kill the main task.
    # Alternatively, one could pass in the main task to explicitly exclude it.
    async def shutdown(sig, tasks):
        print(f"Caught signal: {sig.name}")
        for task in tasks:
            task.cancel()
    
        await asyncio.gather(*tasks, return_exceptions=True)
        print("Shutdown complete.")
    
    
    async def main():
        tasks = [asyncio.create_task(task1()), asyncio.create_task(task2())]
        loop = asyncio.get_running_loop()
        for s in (signal.SIGINT, signal.SIGTERM):
            loop.add_signal_handler(s, lambda s=s: asyncio.create_task(shutdown(s, tasks)))
        await asyncio.gather(*tasks)
    
    
    if __name__ == "__main__":
        asyncio.run(main())
    
    $ timeout 2s python3 sigterm.py
    Task 1 running...
    Task 2 running...
    Task 1 running...
    Task 2 running...
    Caught signal: SIGTERM
    Task 1 cancelled
    Task 2 cancelled
    Shutdown complete.
    

    In this particular case, though, I'd probably use a stop event or similar to signal the tasks to exit:

    import signal
    import asyncio
    
    stop_event = asyncio.Event()
    
    
    def signal_handler():
        print("SIGTERM received! Exiting...")
        stop_event.set()
    
    
    async def looping_task(task_num):
        while not stop_event.is_set():
            print(f"Task {task_num} is running...")
            await asyncio.sleep((task_num + 1) / 3)
    
    
    async def main():
        loop = asyncio.get_event_loop()
        loop.add_signal_handler(signal.SIGTERM, signal_handler)
        await asyncio.gather(*(looping_task(i) for i in range(5)))
    
    
    if __name__ == "__main__":
        asyncio.run(main())