保证退出其上下文管理器的协程

Coroutine that is guaranteed to exit its context managers

我想在协程中使用上下文管理器。这个协程应该处理未知数量的步骤。但是,由于步骤数未知,上下文管理器何时退出尚不清楚。我希望它在协程超出范围/被垃圾收集时退出;然而,在下面的例子中这似乎并没有发生:

import contextlib


@contextlib.contextmanager
def cm():
    print("STARTED")
    yield
    print("ENDED")


def coro(a: str):
    with cm():
        print(a)
        while True:
            val1, val2 = yield
            print(val1, val2)


c = coro("HI")


c.send(None)
print("---")
c.send((1, 2))
print("---!")

这个程序的输出:

STARTED
HI
---
1 2
---!

我怎样才能制作一个支持任意数量步骤的协程,并保证优雅地退出?我不想让这成为来电者的责任。

你可以通过发送一些会导致它跳出循环的东西来告诉协程关闭,return如下图所示。这样做会导致在完成此操作的地方引发 StopIteration 异常,因此我添加了另一个上下文管理器以允许它被抑制。请注意,我还添加了一个 coroutine 装饰器,使它们在首次调用时自动启动,但这部分是严格可选的。

import contextlib
from typing import Callable


QUIT = 'quit'

def coroutine(func: Callable):
    """ Decorator to make coroutines automatically start when called. """
    def start(*args, **kwargs):
        cr = func(*args, **kwargs)
        next(cr)
        return cr
    return start

@contextlib.contextmanager
def ignored(*exceptions):
    try:
        yield
    except exceptions:
        pass


@contextlib.contextmanager
def cm():
    print("STARTED")
    yield
    print("ENDED")

@coroutine
def coro(a: str):
    with cm():
        print(a)
        while True:
            value = (yield)
            if value == QUIT:
                break
            val1, val2 = value
            print(val1, val2)

print("---")
with ignored(StopIteration):
    c = coro("HI")
    #c.send(None)  # No longer needed.

    c.send((1, 2))
    c.send((3, 5))
    c.send(QUIT)  # Tell coroutine to clean itself up and exit.
print("---!")

输出:

STARTED
HI
---
1 2
3 5
ENDED
---!

TLDR:所以问题是当在 with 块内引发(而不是处理)异常时。调用上下文管理器的 __exit__ 方法时出现该异常。对于 contextmanager 修饰的生成器,这会导致生成器的异常为 thrown。 cm 不处理此异常,因此清理代码不是 运行。当 coro 被垃圾回收时,它的 close 方法被调用,该方法 throwGeneratorExitcoro(然后被扔到 cm)。下面是对以上步骤的详细说明。

close 方法 throw 是一个 GeneratorExitcoro,这意味着 GeneratorExityield 点被引发。 coro 不处理 GeneratorExit 因此它通过错误退出上下文。这会导致使用错误和错误信息调用上下文的 __exit__ 方法。 contextmanager 装饰生成器中的 __exit__ method 有什么作用?如果调用它时出现异常,它会将异常抛给底层生成器。

在这一点上,a GeneratorExit 是从上下文管理器主体中的 yield 语句引发的。该未处理的异常导致清理代码不是 运行。该未处理的异常由上下文管理器引发,并传递回 contextmanager 装饰器的 __exit__。作为抛出的相同错误,__exit__ returns False 表示发送到 __exit__ 的原始错误未得到处理。

最后,GeneratorExitcoro 内的 with 块之外继续传播,在那里它继续未处理。 但是,不处理GeneratorExits对于生成器来说是正常的,所以原来的close方法抑制了GeneratorExit.

看到这部分yield documentation

If the generator is not resumed before it is finalized (by reaching a zero reference count or by being garbage collected), the generator-iterator’s close() method will be called, allowing any pending finally clauses to execute.

查看 close documentation 我们看到:

Raises a GeneratorExit at the point where the generator function was paused. If the generator function then exits gracefully, is already closed, or raises GeneratorExit (by not catching the exception), close returns to its caller.

这部分with statement documentation

  1. The suite is executed.

  2. The context manager’s exit() method is invoked. If an exception caused the suite to be exited, its type, value, and traceback are passed as arguments to exit(). Otherwise, three None arguments are supplied.

以及 contextmanager 装饰器的 __exit__ method 代码。

因此,对于所有这些上下文 (rim-shot),我们可以获得所需行为的最简单方法是在上下文管理器的定义中使用 try-except-finally。这是 contextlib docs 推荐的方法。他们所有的例子都遵循这种形式。

Thus, you can use a try…except…finally statement to trap the error (if any), or ensure that some cleanup takes place.

import contextlib


@contextlib.contextmanager
def cm():
    try:
        print("STARTED")
        yield
    except Exception:
        raise
    finally:
        print("ENDED")


def coro(a: str):
    with cm():
        print(a)
        while True:
            val1, val2 = yield
            print(val1, val2)


c = coro("HI")


c.send(None)
print("---")
c.send((1, 2))
print("---!")

现在的输出是:

STARTED
HI
---
1 2
---!
ENDED

随心所欲。

我们也可以用传统方式定义我们的上下文管理器:作为一个 class 和一个 __enter____exit__ 方法并且仍然得到正确的行为:

class CM:
    def __enter__(self):
        print('STARTED')

    def __exit__(self, exc_type, exc_value, traceback):
        print('ENDED')
        return False

情况稍微简单一些,因为我们不用去看源码就可以清楚地看到__exit__方法是什么。 GeneratorExit 被发送(作为参数)到 __exit__,其中 __exit__ 愉快地 运行 发送其清理代码,然后 return 发送 False。这不是 strictly 必需的,否则 None(另一个 Falsey 值)将被 returned,但它表明发送到 [=14] 的任何异常=] 没有处理。 (如果没有异常,__exit__ 的 return 值无关紧要)。