分享一件有趣的事情,我帮 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 = 1234

# args 是一个元组
def foo(x, y, z, *args):
    pass

# 多返回值本质上也是返回了一个元组
def bar():
    return 12

所以元组会被大量创建,并且通常都是隐式的。那么问题来了,元组的缓存池长什么样子呢?

首先元组缓存池是一个 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 的数据结构以及虚拟机有着非常深刻的认识,一定不会让你失望的。

相关推荐

  • 如何防止被恶意刷接口?
  • 赛尔笔记 | 具身大模型研究综述
  • 鄂维南院士领衔新作:大模型不止有RAG、参数存储,还有第3种记忆
  • 明显提升Transformer在高频信号的预测效果的策略。
  • [开源]一款快速且灵活的后台框架,可轻松实现复杂页面,内置代码生成器
  • 推荐!神器 Jupyter 的可视化 Debug~
  • 手把手AI实战(六)老照片动起来
  • CODESYS为何在自动化行业如此牛叉???
  • 187K Star 快20万人关注!!!2000多款开源自托管平替软件
  • ACM MM 2024 以人为中心多媒体分析研讨会,诚邀各界专家学者参与
  • 博士申请 | 香港理工大学李青教授课题组招收人工智能全奖博士/博后/RA
  • 厦门大学首发多模态阅读理解新任务: 图文深度融合数据集VEGA
  • “闭门造车”之多模态思路浅谈:自回归学习与生成
  • Spring Boot集成drools快速入门Demo
  • 腾讯全员调薪,还算厚道
  • 实例分享:如何稳妥重构消费金融系统
  • 由浅入深的混合精度训练教程
  • 硕士生一作!985,发Science!
  • 美联储鲍威尔 | 货币政策半年度参议院听证会 全文+视频
  • 在抖音卖书半年,我如何从月亏十万到月GMV两千万?