在Python中,依靠__del__()进行清理工作是否不可靠?

10

我在阅读关于Python中清理对象的不同方法的文章,并且偶然发现了这些问题(1, 2),它们基本上都表示使用__del__()进行清理是不可靠的,应该避免使用以下代码:

def __init__(self):
    rc.open()

def __del__(self):
    rc.close()

问题在于,我正在使用完全相同的代码,但我无法重现上面提到的任何问题。就我所知,我不能选择with语句的替代方案,因为我为封闭源软件(testIDEA,有人用过吗?)提供Python模块。这个软件将创建特定类的实例并且处置它们,这些实例必须准备好在之间提供服务。我看到的唯一替代__del__()的方法是根据需要手动调用open()close(),我认为这将很容易出现错误。

我知道当我关闭解释器时,没有保证我的对象会被正确地销毁(这并不困扰我,甚至Python的作者也认为这样做没问题)。除此之外,我使用__del__()进行清理是否存在风险?


1
不,我只是指,因为你说“我知道当我关闭解释器时,没有任何保证我的对象会被正确地销毁......”这似乎暗示__exit__将在那种情况下执行。我很想说不会,但我意识到我实际上不知道答案。抱歉-这只是一个旁观者的评论。 - Rick
@RickTeachey 说实话,我当时并没有认真考虑就这么暗示了。你把我勾起了兴趣,现在我也很感兴趣。 - Dmitry Grigoryev
1
@DmitryGrigoryev 答案似乎是“否”(https://dl.dropboxusercontent.com/u/8182793/Stackoverflow/test__exit__.py)。 - Rick
@user2357112,我对解释器终止时发生的任何事情都没问题,即使是重新启动。这是一个测试项目,意味着由两个人编写代码来测试由十个人编写的功能,没有人期望我们编写完美的代码。我关心的是我的代码不会在过程中挂起或锁定资源(如COM对象)。 - Dmitry Grigoryev
@user2357112 这让我想起另一个项目中我们已经有了一个数字信号发生器/分析仪,但它的API似乎不是线程安全的,并且在使用DLL_THREAD_DETACH调用时会在DllMain()中死锁。由于我们时间有限且无法调试该闭源DLL,我们最终决定可以通过任务管理器结束GUI来解决问题。 - Dmitry Grigoryev
显示剩余4条评论
3个回答

9
您观察到了垃圾回收语言中终结器的典型问题。Java有它,C#也有它,它们都提供了基于范围的清理方法(如Python中的with关键字)来处理它。主要问题在于,垃圾收集器负责清除和销毁对象。在C++中,当对象超出作用域时会被销毁,因此您可以使用RAII并具有明确定义的语义。在Python中,对象超出作用域后就会一直存在,只要GC需要它。根据Python实现的不同,这可能是不同的。基于引用计数的CPython相对温和(因此很少出现问题),而PyPy、IronPython和Jython可能会使一个对象保持活动状态很长时间。
例如:
def bad_code(filename):
    return open(filename, 'r').read()

for i in xrange(10000):
    bad_code('some_file.txt')

bad_code泄漏了一个文件句柄。在CPython中,这并不重要。引用计数会降为零,并立即被删除。在PyPy或IronPython中,您可能会遇到IOErrors或类似问题,因为您耗尽了所有可用的文件描述符(在Unix上达到ulimit或在Windows上达到509个句柄)。

使用上下文管理器和with的基于范围的清理是首选,如果需要保证清理,则可以准确知道何时执行对象最终操作。但有时很难实施这种作用域清理。这就是您可能会使用__del__atexit或类似构造来尽力清理的情况。虽然它并不可靠,但总比没有强。

您可以让用户承担明确的清理责任或者强制执行明确的作用域,也可以通过__del__的赌博方式,偶尔出现一些奇怪情况(特别是解释器关闭时)。


谢谢。实际上,在我的代码中,我依靠的不仅仅是最佳努力清理。您能澄清一下CPython在您的示例中保证了什么吗?最多只有一个打开的句柄吗?最多10个?还是其他什么? - Dmitry Grigoryev
2
CPython使用引用计数系统来处理清理工作。因此,在上述示例中,当句柄超出作用域时,它的引用计数会减少并立即被清理,所以在函数运行期间只有一个句柄是打开的。 - schlenk
这对我来说已经足够好了。我可以满足需要特定的Python解释器来运行我的代码的要求。 - Dmitry Grigoryev

2
使用__del__运行代码存在一些问题。
首先,只有在积极跟踪引用时才能工作,即使这样,除非您在代码中手动启动垃圾收集,否则不能保证它会立即运行。自动垃圾收集已经让我在准确跟踪引用方面受到了破坏。即使您在代码中非常勤奋,也要依赖使用您的代码的其他用户在引用计数方面同样勤奋。
其次,有很多情况下__del__永远不会运行。当对象被初始化和创建时是否出现异常?解释器退出了吗?是否存在循环引用?是的,很多事情可能会出错,而且很少有方法可以清洁和一致地处理它们。
第三,即使它运行了,它也不会引发异常,因此您无法像处理其他代码那样处理它们的异常。还几乎不可能保证来自各种对象的__del__方法以任何特定顺序运行。因此,析构函数的最常见用例 - 清理和删除大量对象 - 有点无意义,并且不太可能按计划进行。
如果您真的想要运行代码,则有更好的机制 - 上下文管理器、信号/插槽、事件等。

你能详细说明一下__init__中的异常吗?我应该捕获并重新抛出吗?我知道'with'是正确的方法,但我必须向一个闭源调用者提供一个对象,所以我的选择似乎有限。 - Dmitry Grigoryev
如果你在__init__中创建或执行某些操作时出现错误,它将无法完成,并且对该类的引用将永远不会被创建,因此__del__将永远不会被调用以拆除或清理在错误发生之前所创建的任何内容。你必须提供对象的规格是什么?你能指向闭源库的文档吗? - Brendan Abel
1
@Brendan Abel 在 __init__ 中的错误不会阻止 __del__ 被调用。 - wombatonfire
__init__ 在已创建的对象上调用,因此引用已经存在,可能发生的情况是在 __del__ 上尝试访问未设置属性时出现 AttributeError - hldev

2
如果您使用的是CPython,则__del__会在对象的引用计数归零时可靠且可预测地触发。在https://docs.python.org/3/c-api/intro.html文档中说明:

当对象的引用计数变为零时,对象将被释放。如果它包含对其他对象的引用,则它们的引用计数将递减。如果此递减使其引用计数变为零,那么这些其他对象也可能被依次释放,以此类推。

您可以轻松测试并自行查看此立即清除过程的发生:
>>> class Foo:
...     def __del__(self):
...         print('Bye bye!')
... 
>>> x = Foo()
>>> x = None
Bye bye!
>>> for i in range(5):
...     print(Foo())
... 
<__main__.Foo object at 0x7f037e6a0550>
Bye bye!
<__main__.Foo object at 0x7f037e6a0550>
Bye bye!
<__main__.Foo object at 0x7f037e6a0550>
Bye bye!
<__main__.Foo object at 0x7f037e6a0550>
Bye bye!
<__main__.Foo object at 0x7f037e6a0550>
Bye bye!
>>>

虽然如果你想在REPL中测试涉及__del__的内容,需要注意最后一个评估表达式的结果会被存储为_,这算作一个引用。

换句话说,如果你的代码严格只在CPython中运行,依赖__del__是安全的。


参考循环怎么办? - Bananach
如果你的对象存在引用循环,那么它们的引用计数就不会降为零,你需要等待垃圾回收器。 - Mark Amery

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接