事情的起因是我最近在写一个专栏,内容是剖析 3.12 版本的 Python 解释器源码,当我写到元组相关的部分时,我发现了一个问题,下面来和大家聊一聊。
阅读过我之前文章的朋友应该知道,Python 的对象其实就是 C 的 malloc 函数为结构体实例在堆区申请的一块内存。如果每次创建对象都要重新申请内存,销毁对象都要释放内存,那么 Python 的效率会非常低。
为此,Python 引入了缓存池,当销毁一个对象时,它的内存并没有被释放,而是被缓存起来了。我们以列表为例:
del lst1 之后,它指向的列表会被销毁,但内存却没有释放,而是被放到了缓存池中。创建列表时,也会先看缓存池中是否有可用列表,如果有的话,则直接复用。所以代码中,lst1 和 lst2 指向的列表的地址相同,因为它们是同一块内存。
Python 的大部分对象都有自己的缓存池,当然也包括元组,并且元组的缓存池的容量要远高于其它对象。比如列表缓存池的容量默认是 80,而元组缓存池的容量是 40000,也就是说解释器最多可以缓存 40000 个元组。
元组缓存池的容量之所以这么大,是因为元组的使用频率非常高,尽管你在代码中可能很少创建元组,但解释器会大量使用它。
# 右侧的 1, 2, 3, 4 等价于 (1, 2, 3, 4)
a, b, c, d = 1, 2, 3, 4
# args 是一个元组
def foo(x, y, z, *args):
pass
# 多返回值本质上也是返回了一个元组
def bar():
return 1, 2
所以元组会被大量创建,并且通常都是隐式的。那么问题来了,元组的缓存池长什么样子呢?
首先元组缓存池是一个 C 数组,名称为 free_list,长度为 20,里面的每个元素都分别指向了链表的头结点。也就是说有 20 条链表,每条链表最多可以缓存 2000 个元组,而这 20 条链表的头结点便可以通过 free_list 获取。
那么问题来了,为什么要整出 20 条链表?很简单,因为要区分元组的长度。
free_list[0] 缓存的是长度为 1 的元组;
free_list[1] 缓存的是长度为 2 的元组;
free_list[2] 缓存的是长度为 3 的元组;
······
free_list[19] 缓存的是长度为 20 的元组;
所以只有长度为 1 ~ 20 的元组才会被缓存,每种长度的元组最多缓存 2000 个。至于空元组,它是单例的永恒对象,在解释器启动之后就已经初始化好了。
引用计数为 2 ** 32 - 1,所以它是一个永恒对象,会在进程的整个生命周期内保持存活。
到此,相信你已经明白了元组的缓存池是怎么一回事,那么它的 bug 出现在什么地方呢?我们来修改解释器源码,复现这一过程。
我们尝试给 <class 'tuple'> 增加一个类方法 get_free_list_count,它接收一个参数 length,会返回指定长度的元组已经缓存了多少个。
蓝色方框里面的代码是我们额外添加的,它负责给 tuple 增加一个类方法,然后我们将 Python 源码重新编译。编译完成之后,测试一下:
首先我们调用 get_free_list_count(3),返回了 5,说明解释器启动之后,长度为 3 的元组已经缓存了 5 个。
然后创建 a = (1, 2, 3),显然会从缓存池获取,创建之后再次打印缓存的元组个数,发现变成了 4;
创建 b = (4, 5, 6),依旧会从缓存池获取,创建之后发现缓存个数变成了 3;
创建 c = (7, 8, 9),依旧会从缓存池获取,创建之后发现缓存个数变成了 2;
然后 del a, b, c,它们指向的元组会被销毁,但内存不会释放,而是被缓存起来了。所以我们看到缓存个数又变成了 5。
整个过程没有问题,对于长度为 1 ~ 19 的元组是正常的,但当元组长度为 20 时,就有问题了。
长度为 20 的元组不常见,因此解释器在启动过程中并没有创建,所以缓存个数为 0。然后我们手动创建三个长度为 20 的元组,再销毁掉,发现缓存个数变成了 3,这是肯定的。
但当我们再次创建 d = tuple(range(20)) 的时候,发现并没有从缓存获取,而是重新创建了。然后 del d,元组又放到缓存里了。
所以 bug 就出现在这里,对于长度为 1 ~ 20 的元组,在销毁时不会释放内存,而是会缓存起来。那么创建长度为 1 ~ 20 的元组,也应该优先从缓存中获取,但目前只有长度为 1 ~ 19 的元组会这么做,如果长度为 20,则不会从缓存获取,尽管它在销毁时也会被缓存起来。
我们看一下出现问题的源码:
size 表示元组的长度,PyTuple_MAXSAVESIZE 是一个宏,值为 20,因此条件应该是小于等于,而不是小于。目前只有 3.12 和 3.13 会受影响,其它版本则不用关心。
所以我做的工作只是加上了一个等于号😂,不过蛮有意思的,也鼓励大家一起给 CPython 添砖加瓦。
最后给我自己打个广告,如果你对 Python 实现原理感兴趣的话,可以订阅我的专栏。看完之后,你将会对 Python 的数据结构以及虚拟机有着非常深刻的认识,一定不会让你失望的。