I have an event loop that runs some co-routines as part of a command line tool. The user may interrupt the tool with the usual Ctrl + C, at which point I want to clean up properly after the interrupted event loop.
Here's what I tried.
import asyncio
@asyncio.coroutine
def shleepy_time(seconds):
print("Shleeping for {s} seconds...".format(s=seconds))
yield from asyncio.sleep(seconds)
if __name__ == '__main__':
loop = asyncio.get_event_loop()
# Side note: Apparently, async() will be deprecated in 3.4.4.
# See: https://docs.python.org/3.4/library/asyncio-task.html#asyncio.async
tasks = [
asyncio.async(shleepy_time(seconds=5)),
asyncio.async(shleepy_time(seconds=10))
]
try:
loop.run_until_complete(asyncio.gather(*tasks))
except KeyboardInterrupt as e:
print("Caught keyboard interrupt. Canceling tasks...")
# This doesn't seem to be the correct solution.
for t in tasks:
t.cancel()
finally:
loop.close()
Running this and hitting Ctrl + C yields:
$ python3 asyncio-keyboardinterrupt-example.py
Shleeping for 5 seconds...
Shleeping for 10 seconds...
^CCaught keyboard interrupt. Canceling tasks...
Task was destroyed but it is pending!
task: <Task pending coro=<shleepy_time() running at asyncio-keyboardinterrupt-example.py:7> wait_for=<Future cancelled> cb=[gather.<locals>._done_callback(1)() at /usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/lib/python3.4/asyncio/tasks.py:587]>
Task was destroyed but it is pending!
task: <Task pending coro=<shleepy_time() running at asyncio-keyboardinterrupt-example.py:7> wait_for=<Future cancelled> cb=[gather.<locals>._done_callback(0)() at /usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/lib/python3.4/asyncio/tasks.py:587]>
Clearly, I didn't clean up correctly. I thought perhaps calling cancel()
on the tasks would be the way to do it.
What's the correct way to clean up after an interrupted event loop?
If using Python 3.7+, using asyncio.run
to execute your main function will take care of this, as pointed out in this answer.
See below for an explanation of why just calling t.cancel()
with the code in the question does not work, and a way to handle it that was relevant on older versions of Python, prior to the introduction of asyncio.run()
.
When you CTRL+C, the event loop gets stopped, so your calls to t.cancel()
don't actually take effect. For the tasks to be cancelled, you need to start the loop back up again.
Here's how you can handle it:
import asyncio
@asyncio.coroutine
def shleepy_time(seconds):
print("Shleeping for {s} seconds...".format(s=seconds))
yield from asyncio.sleep(seconds)
if __name__ == '__main__':
loop = asyncio.get_event_loop()
# Side note: Apparently, async() will be deprecated in 3.4.4.
# See: https://docs.python.org/3.4/library/asyncio-task.html#asyncio.async
tasks = asyncio.gather(
asyncio.async(shleepy_time(seconds=5)),
asyncio.async(shleepy_time(seconds=10))
)
try:
loop.run_until_complete(tasks)
except KeyboardInterrupt as e:
print("Caught keyboard interrupt. Canceling tasks...")
tasks.cancel()
loop.run_forever()
tasks.exception()
finally:
loop.close()
Once we catch KeyboardInterrupt
, we call tasks.cancel()
and then start the loop
up again. run_forever
will actually exit as soon as tasks
gets cancelled (note that cancelling the Future
returned by asyncio.gather
also cancels all the Futures
inside of it), because the interrupted loop.run_until_complete
call added a done_callback
to tasks
that stops the loop. So, when we cancel tasks
, that callback fires, and the loop stops. At that point we call tasks.exception
, just to avoid getting a warning about not fetching the exception from the _GatheringFuture
.