Skip to content

Implement a strategy for handling (async) generator cleanup #265

Closed
@njsmith

Description

There's a problem with generators and async generators: they don't necessarily iterate to the end. Sometimes they get suspended in the middle, and then garbage collected. When this happens, their __del__ method throws in a GeneratorExit exception, so that finally blocks and __exit__ methods get a chance to run. BUT, __del__ is very weird: it can be run in the middle of any arbitrary python bytecode instruction. It's like a signal handler but even more so.

So for example, this code:

def generator_fn():
    try:
        yield
    finally:
        print(trio.current_task())

async def caller():
    for _ in generator_fn():
        break

Here the generator's finally block gets run at some totally arbitrary time, so it could in principle print any task, or crash because no task is currently active, or because it doesn't even run in the trio thread. And similar problems arise for pretty much any access to trio APIs. (Note that even without trio, the same thing happens for threading.current_thread(), accessing thread-local storage, etc. But it trio the use of this kind of implicit thread-bound state is much more common, since the run loop itself is accessed via thread-local storage.)

PEP 533 would be the real fix, but that seems to be stalled for now.

For regular generators, we just need to document this; I can't see what else we can do. There are some related issues in #264 that also need documentation; probably they should go in the same section of the manual.

For async generators, this problem is both better and worse. The thing about async generators is that the gc can't actually clean them up directly: it would like to do the same thing as it does for regular generators and have __del__ throw in GeneratorExit, but this doesn't really work, because for an async generator you should use athrow and let the coroutine runner iterate it, but __del__ doesn't have access to a coroutine runner. So PEP 525 defines some hooks that trio can set, to get (a) notification that an async generator has been started, and (b) notification when an async generator is ready for garbage collection.

The intention was that these hooks are used to let the event loop take care of doing gen.athrow(GeneratorExit) to emulate how regular generators work. But as we saw above, regular generators are already broken so... we don't want to emulate them. Being able to re-enter trio's run loop doesn't help, because we need a task context. And this is even more true for async generators, since the whole point of an async generator is that it can do async-y things, and if you want to await something you need to make sure you have a timeout set, which means you really really really need to make sure that this happens in the original task context where the user was expecting it to happen. Letting an await sneak outside of its with fail_after is definitely not OK.

So we aren't going to use these hooks in the way they were originally intended. But, until PEP 533 becomes a thing, maybe we can at least use these hooks for harm reduction.

Some options:

  • Don't define the hooks at all. In this case I'm not 100% sure what Python does, but I think it does something like: throw in GeneratorExit and iterate once, and if it yields again (e.g. because there's an async with or a finally: await ...) then it gives up and prints an error message. This is basically like synchronous generators, but maybe a little less friendly. It's not awful, but maybe we can do better.

  • Use the firstiter hook to forbid the use of async generators, unless we have some explicit evidence that someone is taking responsibility for cleaning them up. For example, we could make it so that our @acontextmanager marks its async generators as being cleaned up (though 3.7's contextlib.asynccontextmanager is going to be a problem...), and provide an aclosing context manager that does the same. This approach was pioneered by Curio, which goes a step further and introspects the generator's bytecode, and if it doesn't see any dangerous operations (yield or yield from inside an except, finally, with, or async with), then it allows those async generators as well. I really don't want to have this kind of bytecode introspection in trio. OTOH without it then async comprehensions like (2 * i async for i in aiter) become essentially unusable.

  • Use the finalizer hook to catch when an async generator is cleaned up, and run it in a context that (a) is carefully set up so that any attempt to access trio state generates a clear error message, and (b) any attempt to yield causes a clear error / crashes the program (as opposed to just being printed to stderr, like regular __del__ does). (If doing this we'd probably also want a firstiter hook like asyncio's, so we can force all async generators to be finalized when the run loop exits. This is just a minor refinement, though.)

The good thing about the finalizer approach is that it has no false negatives: all async generators that can be run safely are allowed to run. The downside is that it has false positives: if an async generator is written poorly, then it's possible that it will be allowed to run and everything will look fine in tests, but then in production it blows up (e.g. because in tests it's always iterated to the end, and in production someone breaks out of a loop so the generator gets collected while only half-finished). The firstiter approach is the opposite: it only allows generators to run if it can be sure that they're safe in all circumstances.

(If PEP 533 is too much for 3.7, then maybe a smaller ask would be set_gen_hooks() that work the same way but for synchronous generators?)

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions