【问题标题】:python generators garbage collectionpython生成器垃圾收集
【发布时间】:2018-04-29 21:37:31
【问题描述】:

我认为我的问题与this 有关,但并不完全相同。考虑这段代码:

def countdown(n):
    try:
        while n > 0:
            yield n
            n -= 1
    finally:
        print('In the finally block')

def main():
    for n in countdown(10):
        if n == 5:
            break
        print('Counting... ', n)
    print('Finished counting')

main()

这段代码的输出是:

Counting...  10      
Counting...  9       
Counting...  8       
Counting...  7       
Counting...  6       
In the finally block 
Finished counting  

是否保证在“完成计数”之前打印“在 finally 块中”行?还是因为 cPython 的实现细节,当引用计数达到 0 时,对象将被垃圾回收。

我也很好奇countdown 生成器的finally 块是如何执行的?例如如果我将main 的代码更改为

def main():
    c = countdown(10)
    for n in c:
        if n == 5:
            break
        print('Counting... ', n)
    print('Finished counting')

然后我确实看到Finished counting 打印在In the finally block 之前。垃圾收集器如何直接到finally块?我想我一直认为try/except/finally 的表面价值,但在发电机的背景下思考让我三思而后行。

【问题讨论】:

  • 垃圾收集器不保证在特定时间收集引用。

标签: python generator python-internals


【解决方案1】:

正如您所料,您依赖于 CPython 引用计数的特定于实现的行为。1

事实上,如果你在 PyPy 中运行这段代码,输出通常是:

Counting...  10
Counting...  9
Counting...  8
Counting...  7
Counting...  6
Finished counting
In the finally block

如果你在交互式 PyPy 会话中运行它,最后一行可能会在很多行之后出现,甚至只有在你最终退出时出现。


如果你看看生成器是如何实现的,它们的方法大致是这样的:

def __del__(self):
    self.close()
def close(self):
    try:
        self.raise(GeneratorExit)
    except GeneratorExit:
        pass

当引用计数变为零时,CPython 会立即删除对象(它还有一个垃圾收集器来分解循环引用,但这与这里无关)。一旦生成器超出范围,它就会被删除,所以它会被关闭,所以它会在生成器框架中引发GeneratorExit 并恢复它。当然,GeneratorExit 没有处理程序,所以finally 子句被执行,控制权向上传递到堆栈,在那里异常被吞没。

在使用混合垃圾收集器的 PyPy 中,直到下次 GC 决定扫描时,生成器才会被删除。在内存压力较低的交互式会话中,这可能会延迟到退出时间。但一旦发生,同样的事情也会发生。

您可以通过显式处理GeneratorExit 看到这一点:

def countdown(n):
    try:
        while n > 0:
            yield n
            n -= 1
    except GeneratorExit:
        print('Exit!')
        raise
    finally:
        print('In the finally block')

(如果您关闭raise,您将获得相同的结果,只是原因略有不同。)


您可以显式地close 生成器——而且,与上面的内容不同,这是生成器类型的公共接口的一部分:

def main():
    c = countdown(10)
    for n in c:
        if n == 5:
            break
        print('Counting... ', n)
    c.close()
    print('Finished counting')

或者,当然,您可以使用with 声明:

def main():
    with contextlib.closing(countdown(10)) as c:
        for n in c:
            if n == 5:
                break
            print('Counting... ', n)
    print('Finished counting')

1。正如Tim Peters' answer 指出的那样,您在第二个测试中依赖于 CPython 编译器的特定于实现的行为。

【讨论】:

  • @Jean-FrançoisFabre 回答一个有趣且写得很好的问题总是更容易......
  • 不,回答简单问题更容易。回答个问题更好。坚持下去。
  • 可能值得一提:这不是garbage collector,它只是普通的旧引用计数。即使您事先调用 gc.disable(),您仍然会看到该对象在 CPython 中被删除。
  • @wim 答案是“在 CPython 中,它使用引用计数(加上一个循环收集器,但这里不相关)……”这需要进一步阐述吗?
  • 我刚刚直接编辑删除了我认为麻烦的部分(“依赖于垃圾收集器的实现特定行为”)。如果您不喜欢它,请随时回滚。我的 cmets here will self-destruct 24 小时内。
【解决方案2】:

我赞同@abarnert 的回答,但是因为我已经输入了这个......

是的,第一个示例中的行为是 CPython 引用计数的产物。当您跳出循环时,返回的匿名生成器迭代器对象countdown(10) 会丢失其最后一个引用,因此会立即进行垃圾收集。这反过来会触发生成器的finally: 套件。

在您的第二个示例中,生成器迭代器一直绑定到 c,直到您的 main() 退出,所以只要 CPython 知道您可以随时恢复 c。在main() 退出之前,它不是“垃圾”。更高级的编译器可能注意到c 在循环结束后永远不会被引用,并决定在此之前有效地del c,但CPython 不会尝试预测未来。所有本地名称都保持绑定,直到您自己显式取消绑定它们,或者它们是本地结束的范围。

【讨论】:

  • 对。 @abarnert 还回答了我关于控制流如何最终到达生成器的第二个问题,因此我将他的回答标记为已接受。
  • 关于编译器可以del 死对象这一事实很好。事实上,即使是带有插件优化器的 CPython,如 Victor Stinner 的提议(或围绕 byteplay 构建的一些 hacky)也可以做到这一点。
  • @abarnert:编译器必须知道对象的早期销毁不会导致可观察到的差异。并且用户不关心内省(f_locals 等)。
  • @DavisHerring:嗯,“没有可观察到的差异”位是语言设计决定。我很确定 Java 和 C# 愿意在这种情况下完成对象,即使这种早期完成对程序的语义有重大影响。这归结为语言中“垃圾”的概念是什么。
  • @DavisHerring 很多编译器都这样做——尽管对于像 Python 这样动态的语言来说显然更难,但考虑到 Python 2.x 确实(试图)检测到 exec 和其他使用自省来处理正确地使用快速本地人,这并没有太大的不同。但我真的将优化器设想为可选的装饰器或导入钩子等,这意味着优化器知道你不关心内省,因为这是其文档的一部分……
猜你喜欢
  • 2016-04-03
  • 2014-01-31
  • 2021-03-25
  • 2015-07-10
  • 1970-01-01
  • 2018-12-30
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多