循环引用
今天我们来聊一下循环引用,reference cycle。在聊循环引用之前,我们首先要聊一下循环引用里面这个引用是什么意思。
引用计数的工作原理
我们知道在 Python 中,你是可以随时随地地这样建立一个新的 object 的。那这个新的 object 显然是需要内存的。可是和 C、C++ 这样的语言不一样,Python 是没有显式的内存管理的,或者说 Python 的内存管理并不是由用户直接控制的。那我们就需要一个机制,自动把这些已经分配的内存给释放掉。这个机制我们一般叫垃圾回收,garbage collection。而 Python 的垃圾回收是以引用计数为核心的。
我们先来看下面这个例子。我定义了一个 class A,在 class A 里面有 __del__ 这个 method。这个 method 是在这个对象即将被释放的时候调用的。在函数 f 里我们建立了一个 A 的 object,把它赋值给了 o。然后我们调用 f 函数,打印一个 end。
class A:
def __del__(self):
print("object deleted")
def f():
o = A()
f()
print("end")
我们看运行的结果,这个 object o 先被释放了,然后才打印的 end。
那 Python 怎么知道要释放这个 object 呢?这就是引用计数的功劳。在 Python 建立完这个 object 之后,它就会数着有多少其他的变量或者对象正在使用这个新建的对象。比如这里我们把它赋值给 o 了,这个对象的引用计数就是 1,有一个变量 o 正在用着它。那这个 o 是一个 f 函数的局部变量,所以当 f 函数返回的时候,这个 o 就没有了。这个时候我们新建的这个对象上面的引用就到了 0,Python 就知道没有任何东西需要这个对象了,于是就把它给释放了。
我们看,假如把这个代码改一下,我们把这个 o 作为函数的结果返回,然后赋值给 r,这个时候打印的两行就反过来了。
class A:
def __del__(self):
print("object deleted")
def f():
o = A()
return o
r = f()
print("end")
是先打印的 end,然后打印的这个 object deleted。这是因为全局变量 r 也引用了这个对象,所以这个对象还不能被释放。只有到整个这个 module 运行结束的时候,这个全局变量 r 也用不着了,这个对象才会被释放。
那我们可以提前声明这个 r 变量已经用不着了,也就是写一个 del r。这样在第十行的时候,这个对象上就没有引用了,它就会被释放。
class A:
def __del__(self):
print("object deleted")
def f():
o = A()
return o
r = f()
del r
print("end")
我们可以看到结果,这个对象先被释放的,然后再打印的 end。
对于 Python 中绝大部分对象,Python 在运行这些代码的时候,都会数着这个对象身上有多少个引用,在引用到零的时候来释放这个对象。那这个引用计数其实是一个很容易理解的规则,同时它也非常高效,因为你多一个引用只需要做一个加法,然后减法减到零的时候释放。它是一个非常优质的机制。
循环引用的问题
然而这个机制有一个问题,就是所谓的循环引用。我们看,我们加了一个 __init__ 函数,这个 __init__ 函数里面有一句话,叫 self.myself = self。那这句话一写,是不是就是我这个对象本身被我这个对象的属性所引用了?这就形成了一个循环,我自己引用自己。
class A:
def __init__(self):
self.myself = self
def __del__(self):
print("object deleted")
def f():
o = A()
return o
r = f()
del r
print("end")
那可以想见,这个对象的引用数就永远不可能到零了,因为只要这个对象存在,这个对象的属性就存在,这个对象的属性就在引用这个对象,无论如何它也到不了零。所以我们看,哪怕还是刚才的那段代码,我们运行一下,object deleted 也是最后才完成的。也就是尽管我们已经删掉了所有的外部引用,但是由于内部引用的存在,这个对象还一直保持在内存里。
垃圾回收如何处理循环引用
当然你说,那这个世界就这么完蛋了吗?也不是啊。这个循环引用在我们实际的编程中呢,其实并不罕见。如果有一个循环引用就泄漏内存的话,那程序没法写了。在垃圾回收这个机制里面,有一些特定的算法来检查这些循环引用,从而判断一个对象是不是只剩下这些内部引用了。如果是的话,它也可以正常释放。那我们平时所说的垃圾回收,或者说是那些真正占用时间的垃圾回收,其实就是在检查这些循环引用。
我们看,这里我们可以手动触发垃圾回收,gc.collect()。
import gc
class A:
def __init__(self):
self.myself = self
def __del__(self):
print("object deleted")
def f():
o = A()
return o
r = f()
del r
gc.collect()
print("end")
这时候我们就会发现,在垃圾回收的时候,这个垃圾就被回收了,这个对象所占用的内存就被释放了。
为什么这很重要
那既然有这种机制,我们为什么还要在意这件事呢?你就等着垃圾回收给它回收了不就完了吗?这里面有两层原因。
第一,垃圾回收你不可能一直做,对吧?它总是有一个间隔的,你隔一阵儿做一次,隔一阵儿做一次。也就是说你的内存的释放会有一个滞后性,这些内存你明明现在用不着了,但是你还霸占了它们一段时间。
第二,我们不难想象,检查循环引用的这个算法不是一个非常简单的算法,它是一个比较耗时、比较复杂的算法。因为大多数时候,我们真正的程序里面的循环引用并没有我们写的这么明显。它可能是 A 引用了 B,B 引用了 C,C 引用了 D,D 引用了 E,然后一串下去,回头又引用了 A,对吧?那无谓的循环引用会增加我们垃圾回收的负担。所以在有些时候,我们可以手动地避免这样的循环引用。
比如说,我举个非常简单的例子,我们这个对象什么时候用完了,我可以调这个 finish 函数。这个 finish 函数里面,就把这个 myself 清空。
class A:
def __init__(self):
self.myself = self
def finish(self):
self.myself = None
def __del__(self):
print("object deleted")
def f():
o = A()
return o
r = f()
r.finish()
del r
print("end")
这样我们就可以看到,这个对象就被正常地通过引用计数回收了。
那这个例子很直白啊,并不是说大家写的每一行代码都要去想,哎呀,我这个东西是不是循环引用了,怎么怎么样。但是脑子里面有这根弦是有帮助的。
frame 引用的陷阱
我们举个更有趣的例子。我们看这个 A 在 __init__ 里面,它拿到了一个这个 frame 的引用,而 B 就是刚才的 A。在 f 函数里面呢,我们建立了一个 A 的 object 返回了。我们也建立了一个 B 的 object,但是这个 B 的 object o2 非常无辜,我们只是建立了它,赋值给 o2 了而已。它应该是一个很局部的东西。
import sys
class A:
def __init__(self):
self.frame = sys._getframe(1)
def __del__(self):
print("A deleted")
class B:
def __del__(self):
print("B deleted")
def f():
o = A()
o2 = B()
return o
r = f()
del r
print("end")
然而我们看这段代码的运行结果,A 跟 B 都是在所有程序运行完之后才被释放的。
这是因为在 Python 里面,frame 这个东西包含了太多的信息。它包含了这个 frame 下的所有局部变量、全局变量,它还包含了指向上一个 frame 的指针。也就是说只要你残留一个 frame 的引用,你基本上就保留了从这个 frame 开始,往上的每一个 frame 的所有变量的引用。那所有的局部变量,所有这些局部变量引用的其他变量,都暂时无法释放。那像这样的循环引用,可能就是我们需要避免的。
循环引用与内存泄漏
那我们虽然这篇文章聊的是循环引用,但是我们大家想一下,其实循环引用和内存泄漏是非常接近的。循环引用我们还可以通过垃圾回收的机制,把这些东西收回。那如果这个 frame object 被我遗忘在了某一个对象的 attribute 里,而那个对象又活了很久,它就意味着所有这个引用链上的东西都不能被释放。可实际上有可能那个对象已经不需要这个 frame object。所以虽然我们聊的是循环引用,但是同样的思想,我们也可以把它放到一个对象里面要不要去手动解引用这件事,也就是这个数据我需不需要一直记着。
bdb 中的真实案例
这里给大家一个真实的例子。在 Python 3.13 里面,我给 bdb 加了这么一个东西。我在 bdb 里面记录了一下触发 debugger 的这个 frame,这样我在进行一些操作的时候,就知道哪一些是 debugger 的 frame,哪一些是我运行的用户代码的 frame。
def trace_dispatch(self, frame, event, arg):
self.enterframe = frame
if self.quitting:
return # None
if event == 'line':
return self.dispatch_line(frame)
...
然而这就导致这个 frame 被永久地记录到了 bdb 里。而由于我的另外一个修改,上一次使用的 pdb 对象是会被一直保存在程序里的。也就是说这两件事加起来,实际上是有一个比较严重的内存泄漏的。很多的 frame object 和这些 frame 里面的局部变量都没有办法被释放,因为有一个东西一直在无谓地引用着它们。
那实际上这个 enterframe,我只有这一次运行的时候,bdb 内部会需要这个信息。所以我们就把它改成了一个 context manager,每次进来的时候赋值,结束的时候清空,这样就防止了这种引用泄漏。
@contextmanager
def set_enterframe(self, frame):
self.enterframe = frame
yield
self.enterframe = None
def trace_dispatch(self, frame, event, arg):
"""Dispatch a trace function for debugged frames based on the event.
This function is installed as the trace function for debugged
frames. Its return value is the new trace function, which is
usually itself. The default implementation decides how to
dispatch a frame, depending on the type of event (passed in as a
string) that is about to be executed.
The event can be one of the following:
line: A new line of code is going to be executed.
call: A function is about to be called or another code block
is entered.
return: A function or other code block is about to return.
exception: An exception has occurred.
c_call: A C function is about to be called.
c_return: A C function has returned.
c_exception: A C function has raised an exception.
For the Python events, specialized functions (see the dispatch_*()
methods) are called. For the C events, no action is taken.
The arg parameter depends on the previous event.
"""
with self.set_enterframe(frame):
if self.quitting:
return # None
if event == 'line':
return self.dispatch_line(frame)
if event == 'call':
return self.dispatch_call(frame, arg)
if event == 'return':
return self.dispatch_return(frame, arg)
if event == 'exception':
return self.dispatch_exception(frame, arg)
if event == 'c_call':
return self.trace_dispatch
if event == 'c_exception':
return self.trace_dispatch
if event == 'c_return':
return self.trace_dispatch
if event == 'opcode':
return self.dispatch_opcode(frame, arg)
print('bdb.Bdb.dispatch: unknown debugging event:', repr(event))
return self.trace_dispatch
好,那回到我们今天内容总结一下。循环引用这个东西不是洪水猛兽,你只要正常写程序,写得稍微复杂一点,它一定会出现。我们既然语言已经选择了 Python,就没有必要试图去完全手动管理内存。在大部分情况下,它其实不会影响你程序的运行。但是我们最好了解一下它背后的机制,以及在什么情况下,它可能会产生比较大的问题。然后在这些情况下,脑子里想着这么个事,如果能很简单地避免这个问题,就尝试避免一下。