skip to content

How do you guarantee a generator's `finally` cleanup runs when an image-thumbnail worker abandons it half-consumed?

level: seniorimportance: should knowfreq 34%

answer

  1. Cleanup is not guaranteed by luck
  2. An abandoned frame is still suspended
  3. Closing injects a special control exception
  4. GeneratorExit unwinds and runs the finally
  5. contextlib.closing makes the timing deterministic

basics

~20 s

Close it explicitly rather than trusting the garbage collector: gen.close() raises GeneratorExit at the paused yield, which runs the generator's finally block. Wrap the consumer in contextlib.closing(gen) or a try/finally that calls close() so early exits cannot skip it.

solid answer

~40 s

A generator suspended inside a `try`/`finally` has not run its cleanup yet — the frame is parked at the `yield`. `gen.close()` raises `GeneratorExit` at that point, which unwinds the frame and runs the `finally`. If you simply drop the reference, CPython's reference counting usually calls `close()` for you almost immediately, and that is why the bug hides: any lingering reference — a cycle, a traceback holding the frame alive, a list of pending workers, a debugger — defers cleanup to a later collection, so open file handles pile up until something intermittently times out. Make it deterministic: `with contextlib.closing(thumbnail_chunks(blob)) as source:` around the consumer, or an explicit `try`/`finally: gen.close()`. Note that a generator which catches `GeneratorExit` and yields again gets `RuntimeError: generator ignored GeneratorExit`.

code

python · 18 lines
python
import contextlib
import io

def thumbnail_chunks(blob):
    stream = io.BytesIO(blob)
    try:
        while chunk := stream.read(4):
            yield chunk
    finally:
        stream.close()
        print("handle released")

with contextlib.closing(thumbnail_chunks(b"aaaabbbbcccc")) as source:
    for chunk in source:
        print(chunk)
        break            # early exit still triggers the finally

print("after the with block")

go deeper

for a junior

Recall that a generator paused at a yield has not run its finally yet, and that gen.close() is the call that ends it and triggers that cleanup.

for a middle

Explain that close() raises GeneratorExit at the suspension point, that the generator must not yield again in response, and that contextlib.closing or try/finally binds the close to a scope.

for a senior

Diagnose the production shape: intermittent descriptor exhaustion from generators abandoned early, cleanup deferred by cycles or by a traceback pinning the frame, and errors in a collector-run finally swallowed as ignored exceptions.

for a principal

Set the convention: decide whether stages may own resources at all, whether such stages must be exposed as context managers, and how the team proves resource hygiene in CI rather than discovering it in a long-running job.

## Why a suspended generator has unfinished business A generator that acquires a resource before its loop and releases it in a `finally` is idiomatic: ```python def thumbnail_chunks(path): handle = open(path, "rb") try: while chunk := handle.read(65536): yield chunk finally: handle.close() ``` While the generator is suspended at the `yield`, the `finally` has *not* run. It cannot: the frame is alive and parked inside the `try`. Cleanup happens only when the frame finally unwinds — because the body finished, because an exception propagated out of it, or because someone closed the generator. Consumers that iterate to exhaustion are fine. The problem is the consumer that stops early: a `break` after the first thumbnail passes a size check, a `next()` used for a peek, an exception in the loop body. The generator is left suspended, holding an open file, waiting for a resume that never comes. ## What `close()` does `gen.close()` raises `GeneratorExit` at the suspension point. It is not an `Exception` subclass — it inherits directly from `BaseException`, so a well-written `except Exception:` inside the generator does not swallow it. The `finally` runs, the frame unwinds, and the generator ends in the closed state. Calling `close()` on an already-closed or already-exhausted generator is a harmless no-op, and closing a generator that was never advanced simply marks it closed without running any of the body — there was nothing to clean up. One rule the generator itself must respect: it may catch `GeneratorExit` to do work on the way out, but it must not `yield` again. If it does, CPython raises `RuntimeError: generator ignored GeneratorExit` at the `close()` call. Refusing to die is not a supported option. ## Why "the garbage collector handles it" is only half true In CPython, when the last reference to a suspended generator goes away, its finalizer calls `close()`, and reference counting normally makes that immediate — which is exactly why the failure is intermittent rather than constant. Cleanup slips when: * the generator is part of a **reference cycle**, so it waits for a cyclic collection pass; * a **traceback holds its frame alive** — an exception raised inside the loop keeps the frame chain, and the whole generator with it, referenced from the exception object for as long as you keep that object; * something **holds the object on purpose**: a list of in-flight workers, a cache, a profiler or debugger, a closure; * you are not on reference-counted CPython at all, or the object is reachable only from another thread's stack. And when the finalizer does eventually run, it runs at an arbitrary point in some other thread's execution, with exceptions inside the `finally` swallowed and printed as `Exception ignored in:`. That is the worst kind of cleanup: late, unordered, and quiet. The observable symptom in an image-thumbnail worker is precisely the annoying kind. Under a 27-minute suite the worker opens far more source images than it closes, file descriptors accumulate, and somewhere past the middle of the run an unrelated open or connect starts failing — an intermittent timeout that reproduces on the whole suite and never on the single test. ## Making it deterministic Do not rely on lifetime. Bind cleanup to a scope: ```python with contextlib.closing(thumbnail_chunks(path)) as source: for chunk in source: ... break ``` `contextlib.closing` calls `close()` on exit regardless of how the block ends, which converts "eventually" into "now". An explicit `try`/`finally: gen.close()` is the same thing written by hand. Two design-level alternatives are worth having ready. First, **do not let the generator own the resource**: open the file in the caller's `with` block and pass the already-open stream in, so the resource's lifetime is a lexical scope rather than a generator's lifetime. Second, when a stage genuinely must own a resource across suspensions, give the consumer a context manager rather than a bare generator, so the API makes the close mandatory instead of optional. ## How to prove it in production Count descriptors over the run rather than guessing: sample the process's open handles periodically, or fail the suite deliberately by tightening the descriptor limit so the leak reproduces early instead of at minute 27. `warnings` in development mode will also surface unclosed file objects finalized by the collector, which points straight at the generator that was never closed.

  • Why is `GeneratorExit` derived from `BaseException` rather than `Exception`?
    So that ordinary error handling does not swallow it. A generator body full of `except Exception:` blocks still shuts down correctly when closed, because `GeneratorExit` slips past them — the same reasoning that puts `KeyboardInterrupt` and `SystemExit` outside `Exception`.
  • What happens if you call `close()` on a generator that has already finished?
    Nothing — it is a no-op, and so is closing the same generator twice. Closing one that was never advanced also does nothing beyond marking it closed, because the body never ran and there is no `finally` waiting to execute.
  • How would you confirm that leaked file handles are the cause rather than a coincidence?
    Sample the process's open-descriptor count across the run and look for monotonic growth, and lower the descriptor limit locally so the failure reproduces in minutes rather than at the end of a long suite. Development-mode warnings about unclosed files name the offending call site directly.
  • Is a context-manager-based API better than handing out a bare generator here?
    Usually yes when the generator owns a resource. A bare generator makes correct cleanup opt-in; returning a context manager makes it structural, and the alternative — opening the resource in the caller and passing it in — removes the ownership question altogether.

saying these in an interview costs you the question

  • Assumes the collector always cleans up promptly
  • Thinks finally already ran while the generator is suspended
  • Catches GeneratorExit and yields again
  • Uses except Exception expecting to catch GeneratorExit
  • Believes a break in the consumer closes the generator explicitly
  • Confuses close() with simply deleting the variable

context