失物报告单
LOST-011
报告人
夜班设备巡检员 尹师傅
丢了什么
两个一个标签都没有、却没被清理的箱子。已经在 13 号传送带上转了十一天。
结案条件
查明这两个箱子为什么没被清理,清走之后不再出现。
天空岛国际机场 行李中心
从第一天起就有人报告的 13 号传送带上那两个箱子,今天还在转。一个标签都没有。按规定,没有标签的箱子当天就该送去清理场。
都美兰班长把之前的三份记录摊在桌上:第 3 课的 X 光照片、第 6 课的笔记、第 7 课的 partner 记录。“今天就查清这两个家伙的真面目。在那之前,得先把清理规则弄明白。”
CPython 中的每个对象都有一个数字,记着有多少个引用指向自己。这就是引用计数(reference count)。挂一个标签就加 1,摘一个就减 1,变成 0 的那一刻就地清理。
import sys
a = []
print(sys.getrefcount(a))
# 2
b = a
print(sys.getrefcount(a))
# 3
c = [a, a]
print(sys.getrefcount(a))
# 5
del b, c
print(sys.getrefcount(a))
# 2sys.getrefcount(a) 时作为参数传进去的那个引用也算在内。c = [a, a] 在列表里插了两个标签,所以加 2。插在箱子里的标签也是标签。del b, c 摘掉了名字 b;列表 c 被清理时,里面的两个标签也一起没了。别依赖这个数字本身。从 3.14 开始,解释器会做一种跳过部分引用增减的优化。同样的代码放在函数里运行,初始值在 CPython 3.14 中是 1,在 3.13 中是 2。要看的是“一个标签对应 1”这个差值。
变成 0 的那一刻就清理,这可以用 __del__ 来确认。它是对象被清理前一刻调用的双下划线方法(为什么不能信任它,见 11-4)。
class Bag:
def __init__(self, label):
self.label = label
def __del__(self):
print("discard", self.label)
a = Bag("A")
b = a
del a
print("after del a")
del b
print("after del b")
# after del a
# discard A
# after del b
def check_in():
tmp = Bag("T")
print("inside")
check_in()
print("back")
# inside
# discard T
# backdel a 之后还剩标签 b,所以没有清理。摘掉最后一个标签的 del b 一执行,立刻打印出“discard A”。tmp 也随之摘掉。所以它在“back”之前就被清理了。下面的 GC 观测台会在工作进程里创建真正的对象,运行真正的 gc。每个箱子都显示“引用数”(名牌 + 插在其他箱子里的标签)。从任何一个名牌出发、顺着箱子里面走都到不了的箱子,会画成红色虚线。虚线标签会用 w() → … 显示它现在指向什么。
GC 观测台
Python
小钉提示
→ sys.getrefcount 的数字会随版本和位置(在模块里还是函数里)而变。别看绝对值,要看挂上、摘下一个标签时变化 1 的那个“差值”。
把三份记录拼起来,真相就出来了。第 3 课 X 光照片上的 [[...]],是箱子装着自己的样子。第 7 课的记录里,两个箱子用 partner 属性互相指着对方。把两者直接写成代码,就是这样:
标签板
步骤 1 / 0
下一行将执行: 0
正在准备实验台…
刚刚变化的
封口的箱子(不可变)
del a, b 把名牌全摘了。可 A 里面还有指向 B 的标签,B 里面还有指向 A 的标签。两边的引用计数都是 1,永远不会变成 0。这就是循环引用(reference cycle)。
从名字出发到达不了的对象,程序再也没有办法使用,它们就是垃圾。在标签板上消失,也是这个缘故。但光靠引用计数,清不掉这种垃圾。
所以 CPython 还有第二位清洁工:循环垃圾回收器(gc 模块)。
gc.collect() 让它马上做这件事,并返回清理掉的对象数。import gc
gc.disable() # 暂时关掉自动 GC,让示例结果稳定
class Bag:
def __init__(self, label):
self.label = label
self.partner = None
def __del__(self):
print("discard", self.label)
a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
del a, b
print("tags gone")
print(gc.collect() > 0)
gc.enable()
# tags gone
# discard A
# discard B
# Truedel a, b 之后什么都没被清理。“discard”要到 gc.collect() 里面才打印出来。这两行的先后顺序是不确定的。gc.collect() 也没关系。对象创建得多了,解释器会自己运行它。gc.disable() 只是为了这个示例才用的。第 3 课那个装着自己的列表,也是同样的情况。
import gc
a = []
a.append(a)
print(a, a[0] is a)
# [[...]] True
del a
print(gc.collect())
# 例如:23返回的数字会把在此之前积攒的其他垃圾一起算进去,所以随情况和版本而变。在这台电脑上,同一个文件用 3.13.9 运行得到 1,用 3.14.2 运行得到 23。下面的观测台在开始时先清理一次再计数,所以每次结果都一样。
GC 观测台
Python
循环最终也会被清理,那就没问题了吗?有两点不同:
消除循环的方法有两种:用完后亲手摘掉一边的标签(像 break 那样),或者从一开始就把一边做成弱引用。
弱引用(weak reference)是不计入引用计数的虚线标签。它指着箱子,却不抓住箱子。箱子被清理后,虚线标签会自动变空。
import weakref
class Bag:
def __init__(self, label):
self.label = label
a = Bag("A")
w = weakref.ref(a)
print(w() is a)
# True
del a
print(w())
# Nonew() 这样调用一下才能拿到箱子。如果已经被清理,就得到 None,所以用之前总要检查。weakref.ref([1, 2]) 会报 TypeError: cannot create weak reference to 'list' object。int、str、tuple、dict 也一样;自己写的类的实例则可以。GC 观测台
Python
弱引用用得最多的地方是缓存。用普通 dict 做缓存,放进去的对象会被缓存永远抓着,内存只增不减。WeakValueDictionary 用虚线标签拿着值,别处不再使用时,条目会自动消失。
import weakref
class Bag:
def __init__(self, label):
self.label = label
desk = weakref.WeakValueDictionary()
c = Bag("C")
desk["C"] = c
print("C" in desk, len(desk))
# True 1
del c
print("C" in desk, len(desk))
# False 0还有用虚线拿着键的 WeakKeyDictionary,以及用虚线拿着元素的 WeakSet。想给对象另外附加信息、又不想延长它的寿命时,就用它们。
想在箱子被清理时执行某件事,就用 weakref.finalize。
import weakref
class Bag:
def __init__(self, label):
self.label = label
d = Bag("D")
f = weakref.finalize(d, print, "discard D")
print(f.alive)
# True
del d
# discard D
print(f.alive)
# Falsefinalize 最多只调用一次;如果箱子一直活到程序结束,就在退出时调用。它比下一节的 __del__ 可靠。不过要注意:如果传进去的函数和参数抓住了那个箱子(比如传 d.close),箱子就永远不会被清理。
__del__:清理交给 with(6 分钟)__del__ 看起来很适合放清理代码,但有三个原因让它靠不住:
__del__。class Jam:
def __del__(self):
raise RuntimeError("belt jam")
x = Jam()
del x
print("still running")
# (标准错误)例如:Exception ignored while calling deallocator <function Jam.__del__ at 0x...>:
# (标准错误)... RuntimeError: belt jam
# still running文件、套接字、锁这类必须关闭的资源,用 with 来关。离开代码块的那一刻,即使发生了异常,__exit__ 也会被调用(第 8 课)。
class Belt:
def __enter__(self):
print("belt on")
return self
def __exit__(self, *exc):
print("belt off")
return False
with Belt() as belt:
print("moving")
# belt on
# moving
# belt off
try:
with Belt():
raise ValueError("stuck")
except ValueError as e:
print("caught", e)
# belt on
# belt off
# caught stuckopen() 返回的文件也用 with open(path) as f: 来写。清理的时机就定在代码里看得见的地方。
小钉提示
→ __del__ 不承诺“什么时候”。必须关闭的东西用 with;要和对象寿命绑在一起的清理,用 weakref.finalize。
None、True、小整数这类到处都在用的对象,是永生对象(3.12,PEP 683)。它们的引用计数是固定的,无论挂上、摘下多少标签,都不会被清理。
import sys
print(sys.getrefcount(None))
# 3221225472
x = None
print(sys.getrefcount(None))
# 3221225472
print(sys._is_immortal(None), sys._is_immortal([]))
# True Falsesys._is_immortal 是 3.14 新增的。名字以下划线开头,属于 CPython 的实现细节。sys.getsizeof 只称箱子本身的重量,不算里面的东西。
import sys
print(sys.getsizeof([]), sys.getsizeof([1, 2, 3]))
# 56 88
big = [0] * 1000
print(sys.getsizeof(big), sys.getsizeof([big]))
# 8056 64在 64 位 CPython 中,列表的空身子是 56 字节,每个用来插标签的位置再加 8 字节。根据创建方式,有时会预留富余的位置,所以 [1, 2, 3] 是 88 而不是 80。[big] 是只有一个位置的箱子,所以是 64 字节。里面的 big 即使有 8056 字节,也不计算在内。
关于自由线程构建(3.14t)的一句话。在没有 GIL 的构建中,多个线程会同时修改引用计数。因此,创建对象的线程快速地计自己那一份,其他线程则以原子操作单独计共享的那一份(biased reference counting,偏向引用计数)。表面上的规则是一样的。把本课的示例用 3.14.3t(sys._is_gil_enabled() 为 False)运行,引用计数的值、立即清理、由 gc.collect() 清理循环,全都一样。唯一不同的,只有 gc.collect() 返回的数字。
两个箱子是用彼此的 partner 互相抓着的循环引用。虽然没有标签,但引用计数各剩 1,所以没被清理;在循环 GC 运行之前,它们一直留在传送带上。班长一运行 gc.collect(),13 号传送带就空了。
想让它不再出现,就让箱子用虚线标签记住搭档。用第 10 课的 property 包一层,使用方的代码完全不用改。
import weakref
class Bag:
def __init__(self, label):
self.label = label
self._partner = None
@property
def partner(self):
return self._partner() if self._partner is not None else None
@partner.setter
def partner(self, other):
self._partner = weakref.ref(other) # 用虚线标签记住搭档
a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
print(a.partner.label, b.partner.label)
# B A
watch = weakref.ref(a)
del a
print(watch() is None, b.partner)
# True None只用一行 del a,A 就立刻被清理,B 的 partner 变成了 None。从一开始就不存在需要等 GC 的循环。
下班前,尹师傅又拿来一行记录:幽灵箱子 B 上个月曾被存进失物储物柜,可当时谁也没能把它取出来。班长说了句“这个明天最后再看”,把储物柜钥匙放在了桌上。
结案报告 — LOST-011。两个幽灵箱子是用 partner 互相抓着的循环引用。用 gc.collect() 清理,再改为用 weakref.ref 记住搭档,防止再次发生。
a = []、a.append(a) 之后 sys.getrefcount(a) 的值。在模块里运行会得到什么?为什么不是 2?weakref.WeakValueDictionary() 里放一个字符串作为值。会报什么错?用自己写的类的实例包一层又会怎样?__del__ 里关闭的类,再改成用 __enter__、__exit__ 关闭。确认即使代码块里发生异常,文件也会被关闭。班长的工作手册
引用计数 = 箱子上挂的标签数。在变成 0 的那一行立刻清理。
sys.getrefcount 连作为参数传入的引用也算。数值随版本而变,只看差值。
互相装着对方的箱子(循环引用)不会变成 0,由循环 GC 之后清理。gc.collect() 返回清理的数量。
自动 GC 的时机和分代构成随版本而变。不要依赖它们。
弱引用是虚线标签。缓存用 WeakValueDictionary,清理时要做的事用 weakref.finalize。
别信 __del__。必须关闭的资源用 with。
None 这类永生对象不会被清理。getsizeof 只称箱子本身。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。