This is not specific to __aexit__ but to all async code: When an event loop shuts down it must decide between cancelling remaining tasks or preserving them. In the interest of cleanup, most frameworks prefer cancellation instead of relying on the programmer to clean up preserved tasks later on.
This kind of shutdown cleanup is a separate mechanism from the graceful unrolling of functions, contexts and similar on the call stack during normal execution. A context manager that must also clean up during cancellation must be specifically prepared for it. Still, in many cases it is fine not to be prepared for this since many resources fail safe by themselves.
In contemporary event loop frameworks there are usually three levels of cleanup:
- Unrolling: The
__aexit__ is called when the scope ends and might receive an exception that triggered the unrolling as an argument. Cleanup is expected to be delayed as long as necessary. This is comparable to __exit__ running synchronous code.
- Cancellation: The
__aexit__ may receive a CancelledError1 as an argument or as an exception on any await/async for/async with. Cleanup may delay this, but is expected to proceed as fast as possible. This is comparable to KeyboardInterrupt cancelling synchronous code.
- Closing: The
__aexit__ may receive a GeneratorExit as an argument or as an exception on any await/async for/async with. Cleanup must proceed as fast as possible. This is comparable to GeneratorExit closing a synchronous generator.
To handle cancellation/closing, any async code – be it in __aexit__ or elsewhere – must expect to handle CancelledError or GeneratorExit. While the former may be delayed or suppressed, the latter should be dealt with immediately and synchronously2.
async def __aexit__(self, exc_type, exc_value, exc_tb):
print("Exit from the Context Manager...")
try:
await asyncio.sleep(1)
except GeneratorExit:
print("Exit stage left NOW")
except asyncio.CancelledError:
print("Got cancelled, just cleaning up a few things...")
await asyncio.sleep(0.5)
else:
print("Nothing to see here, taking my time on the way out")
await asyncio.sleep(1)
Note: It is generally not possible to exhaustively handle these cases. Different forms of cleanup may interrupt one another, such as unrolling being cancelled and then closed. Handling cleanup is only possible on a best effort basis; robust cleanup is achieved by fail safety, for example via transactions, instead of explicit cleanup.
Cleanup of asynchronous generators in specific is a tricky case since they can be cleaned up by all cases at once: Unrolling as the generator finishes, cancellation as the owning task is destroyed or closing as the generator is garbage collected. The order at which the cleanup signals arrive is implementation dependent.
The proper way to address this is not to rely on implicit cleanup in the first place. Instead, every coroutine should make sure that all its resources are closed before exiting.
async def main():
async_iter = get_numbers()
async for i in async_iter:
print(i)
if i == 1:
break
await async_iter.aclose() # wait for generator to close before exiting
In recent versions, this pattern is codified via the aclosing context manager.
1The name and/or identity of this exception may vary.
2While it is possible to await asynchronous things during GeneratorExit, they may not yield to the event loop. A synchronous interface is advantageous to enforce this.