分享一件有趣的事情,我帮 CPython 修复了一个 bug

简介: 分享一件有趣的事情,我帮 CPython 修复了一个 bug

事情的起因是我最近在写一个专栏,内容是剖析 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 个。至于空元组,它是单例的永恒对象,在解释器启动之后就已经初始化好了。

9f1083e7b9e09a1017532c37696ea7ed.png

引用计数为 2 ** 32 - 1,所以它是一个永恒对象,会在进程的整个生命周期内保持存活。

到此,相信你已经明白了元组的缓存池是怎么一回事,那么它的 bug 出现在什么地方呢?我们来修改解释器源码,复现这一过程。

我们尝试给 <class 'tuple'> 增加一个类方法 get_free_list_count,它接收一个参数 length,会返回指定长度的元组已经缓存了多少个。

1971a2e08d81fb5a2225b2ce59c6c25e.png

蓝色方框里面的代码是我们额外添加的,它负责给 tuple 增加一个类方法,然后我们将 Python 源码重新编译。编译完成之后,测试一下:

f08f7d754377bfc6b55075885662ff33.png

首先我们调用 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 时,就有问题了。

408c746149693eb70724064567774ad8.png

长度为 20 的元组不常见,因此解释器在启动过程中并没有创建,所以缓存个数为 0。然后我们手动创建三个长度为 20 的元组,再销毁掉,发现缓存个数变成了 3,这是肯定的。

但当我们再次创建 d = tuple(range(20)) 的时候,发现并没有从缓存获取,而是重新创建了。然后 del d,元组又放到缓存里了。

所以 bug 就出现在这里,对于长度为 1 ~ 20 的元组,在销毁时不会释放内存,而是会缓存起来。那么创建长度为 1 ~ 20 的元组,也应该优先从缓存中获取,但目前只有长度为 1 ~ 19 的元组会这么做,如果长度为 20,则不会从缓存获取,尽管它在销毁时也会被缓存起来。

我们看一下出现问题的源码:

07820be26dd316eee075f7ef78f4df98.png

size 表示元组的长度,PyTuple_MAXSAVESIZE 是一个宏,值为 20,因此条件应该是小于等于,而不是小于。目前只有 3.12 和 3.13 会受影响,其它版本则不用关心。

所以我做的工作只是加上了一个等于号😂,不过蛮有意思的,也鼓励大家一起给 CPython 添砖加瓦。

相关文章
|
2月前
|
SQL 运维 Java
记一个折磨了我一天半的 Bug
一杯茶,一根烟,一个 Bug 一天根本改不完。
35 1
|
3月前
|
JavaScript 前端开发
iciba的雷人“bug”
iciba的雷人“bug”
|
7月前
|
安全 Java 测试技术
咦,出BUG了
咦,出BUG了
50 0
|
存储 Web App开发 JavaScript
一个有趣的BUG
一个有趣的BUG
104 0
一个有趣的BUG
|
JavaScript 前端开发 IDE
因为使用peerDependencies而引发的bug
因为使用peerDependencies而引发的bug
因为使用peerDependencies而引发的bug
|
Web App开发 关系型数据库 项目管理
|
Android开发
坦白说bug
安卓收藏他发的图片就可以在收藏里看到了哦 苹果直接搜索聊天记录就行了哦(人 •͈ᴗ•͈)۶比心心
656 0
|
Web App开发 前端开发 JavaScript

热门文章

最新文章