Debug 日志系列第二篇,CPython 的 GH-121528,也是很有趣的调试和讨论过程,写出来希望帮助大家

太长不看的版:Python 3.13 Beta 版本中,因为 PEP 683 的实现+周边的改动,导致低版本下编译的一些扩展无法在 Python 3.13 中运行

开篇

7月9日的时候,PyO3 社区提出了一个 Bug , 编号为 GH-1215281。这个 Bug 可以做这样的表示

假设我们有一个 C 扩展文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <Python.h>

static PyObject *
foo_bar(PyObject *self, PyObject *args)
{
Py_INCREF(PyExc_TypeError);
PyErr_SetString(PyExc_TypeError, "foo");
return NULL;
}

static PyMethodDef foomethods[] = {
{"bar", foo_bar, METH_VARARGS, ""},
{NULL, NULL, 0, NULL},
};

static PyModuleDef foomodule = {
PyModuleDef_HEAD_INIT,
.m_name = "foo",
.m_doc = "foo test module",
.m_size = -1,
.m_methods = foomethods,
};

PyMODINIT_FUNC
PyInit_foo(void)
{
return PyModule_Create(&foomodule);
}

然后假设我们有这样的 setup.py 文件

1
2
3
4
5
6
7
from setuptools import setup, Extension

setup(name='foo',
version='0',
ext_modules=[
Extension('foo', ['foo.c'], py_limited_api='cp38'),
])

OK, 基于 Limited API (aka Stable ABI) 编译,社区发现,如果在 <= 3.11 的版本中编译的扩展,在 Python 3.13 以及最新主分支中加载扩展,那么会出现问题

我们来看下堆栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Process 10157 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = hit program assert
frame #4: 0x000000010034043c python.exe`_PyType_AllocNoTrack.cold.2 [inlined] _PyObject_Init(op=<unavailable>, typeobj=<unavailable>) at pycore_object.h:269:5 [opt]
266 {
267 assert(op != NULL);
268 Py_SET_TYPE(op, typeobj);
-> 269 assert(_PyType_HasFeature(typeobj, Py_TPFLAGS_HEAPTYPE) || _Py_IsImmortal(typeobj));
270 Py_INCREF(typeobj);
271 _Py_NewReference(op);
272 }
Target 0: (python.exe) stopped.
warning: python.exe was compiled with optimization - stepping may behave oddly; variables may not be available.
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = hit program assert
frame #0: 0x0000000190ec75e0 libsystem_kernel.dylib`__pthread_kill + 8
frame #1: 0x0000000190efff70 libsystem_pthread.dylib`pthread_kill + 288
frame #2: 0x0000000190e0c908 libsystem_c.dylib`abort + 128
frame #3: 0x0000000190e0bc1c libsystem_c.dylib`__assert_rtn + 284
* frame #4: 0x000000010034043c python.exe`_PyType_AllocNoTrack.cold.2 [inlined] _PyObject_Init(op=<unavailable>, typeobj=<unavailable>) at pycore_object.h:269:5 [opt]
frame #5: 0x000000010034041c python.exe`_PyType_AllocNoTrack.cold.2 at typeobject.c:2224:9 [opt]
frame #6: 0x00000001001299a8 python.exe`_PyType_AllocNoTrack [inlined] _PyObject_Init(op=0x0000000100b0eba0, typeobj=0x000000010054db80) at pycore_object.h:269:5 [opt]
frame #7: 0x00000001001299a4 python.exe`_PyType_AllocNoTrack(type=0x000000010054db80, nitems=0) at typeobject.c:2224:9 [opt]
frame #8: 0x00000001001297bc python.exe`PyType_GenericAlloc(type=0x000000010054db80, nitems=<unavailable>) at typeobject.c:2238:21 [opt]
frame #9: 0x00000001000a7638 python.exe`BaseException_vectorcall(type_obj=0x000000010054db80, args=0x000000016fdfd500, nargsf=9223372036854775809, kwnames=<unavailable>) at exceptions.c:92:37 [opt]
frame #10: 0x0000000100093220 python.exe`_PyObject_VectorcallTstate(tstate=0x00000001005e6370, callable=0x000000010054db80, args=0x000000016fdfd500, nargsf=9223372036854775809, kwnames=0x0000000000000000) at pycore_call.h:167:11 [opt]
frame #11: 0x00000001000942bc python.exe`PyObject_CallOneArg(func=<unavailable>, arg=<unavailable>) at call.c:395:12 [opt]
frame #12: 0x0000000100214d2c python.exe`_PyErr_CreateException(exception_type=0x000000010054db80, value=<unavailable>) at errors.c:44:15 [opt]
frame #13: 0x0000000100215160 python.exe`_PyErr_SetObject(tstate=0x00000001005e6370, exception=0x000000010054db80, value=0x0000000100c41530) at errors.c:184:33 [opt]
frame #14: 0x0000000100214ed0 python.exe`PyErr_SetString [inlined] _PyErr_SetString(tstate=0x00000001005e6370, exception=<unavailable>, string=<unavailable>) at errors.c:291:9 [opt]
frame #15: 0x0000000100214eb0 python.exe`PyErr_SetString(exception=0x000000010054db80, string=<unavailable>) at errors.c:300:5 [opt]
frame #16: 0x000000010099bf30 foo.abi3.so`foo_bar(self=<unavailable>, args=<unavailable>) at foo.c:7:2 [opt]

OK ,看到问题的部分的代码是这样

1
2
3
4
5
6
7
8
9
static inline void
_PyObject_Init(PyObject *op, PyTypeObject *typeobj)
{
assert(op != NULL);
Py_SET_TYPE(op, typeobj);
assert(_PyType_HasFeature(typeobj, Py_TPFLAGS_HEAPTYPE) || _Py_IsImmortal(typeobj));
Py_INCREF(typeobj);
_Py_NewReference(op);
}

我们能看到是在处理 PyExc_TypeError 对象的时候, 进入到了 _PyObject_Init 函数,这里有一个逻辑是判定对象是否是在堆上或者是 Immortal 对象

我们 Bisect 确认了下,这个变更是在 GH-1161152 中引入的,原本的逻辑是这样的

1
2
3
4
5
6
7
8
9
10
11
static inline void
_PyObject_Init(PyObject *op, PyTypeObject *typeobj)
{
assert(op != NULL);
Py_SET_TYPE(op, typeobj);
if (_PyType_HasFeature(typeobj, Py_TPFLAGS_HEAPTYPE)) {
Py_INCREF(typeobj);
}
Py_INCREF(typeobj);
_Py_NewReference(op);
}

这里我们需要先去看下 PyExc_TypeError 的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#define PyObject_HEAD_INIT(type)    \
{ \
{ _Py_IMMORTAL_REFCNT }, \
(type) \
},

#define PyVarObject_HEAD_INIT(type, size) \
{ \
PyObject_HEAD_INIT(type) \
(size) \
},


static PyTypeObject _PyExc_ ## EXCNAME = { \
PyVarObject_HEAD_INIT(NULL, 0) \
# EXCNAME, \
sizeof(Py ## EXCSTORE ## Object), 0, \
(destructor)EXCSTORE ## _dealloc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, \
(reprfunc)EXCSTR, 0, 0, 0, \
Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, \
PyDoc_STR(EXCDOC), (traverseproc)EXCSTORE ## _traverse, \
(inquiry)EXCSTORE ## _clear, 0, 0, 0, 0, EXCMETHODS, \
EXCMEMBERS, EXCGETSET, &_ ## EXCBASE, \
0, 0, 0, offsetof(Py ## EXCSTORE ## Object, dict), \
(initproc)EXCSTORE ## _init, 0, EXCNEW,\
}; \
PyObject *PyExc_ ## EXCNAME = (PyObject *)&_PyExc_ ## EXCNAME

SimpleExtendsException(PyExc_Exception, TypeError,
"Inappropriate argument type.");

这里我们能看到(注意 _Py_IMMORTAL_REFCNTPy_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC),PyExc_TypeError 是一个非堆上 Immortal 对象,在 GH-1161152 之前,我们走到 false 的分支,而在之后,理论上讲 _PyType_HasFeature(typeobj, Py_TPFLAGS_HEAPTYPE) || _Py_IsImmortal(typeobj) 应该是一个为 true 的表达式,不应该会 assert failed 才对。那么为什么呢

我们在这里断点一下看一下表达式的值,结果我们惊讶的发现,_Py_IsImmortal(typeobj) 也为 false ,为啥捏?

我们先来看一下 _Py_IsImmortal(typeobj) 的实现

1
2
3
4
5
static inline Py_ALWAYS_INLINE int _Py_IsImmortal(PyObject *op)
{

return (op->ob_refcnt == _Py_IMMORTAL_REFCNT);
}

这里我们能看到,_Py_IsImmortal 的实现是判断对象的引用计数是否等于 _Py_IMMORTAL_REFCNT ,奇怪,我们之前看到的 PyExc_TypeError 的定义里其 Reference Count 是 _Py_IMMORTAL_REFCNT, 难道 reference count 发生了什么变化?这个时候我们需要注意到,在 PyErr_SetString 之前我们调用了 Py_INCREF,我们来验证下

我们在 foo_bar 函数中加入断点,我们发现,在执行 Py_INCREF 后,我们我们的引用技术 +1 ,从而导致了 _Py_IsImmortal 的判断为 false

那么这里新的问题又来了,为什么我们在 >= 3.12 的版本上编译的插件,在后续执行正常呢?这种奇怪的问题我们就先来看下汇编

在 3.11 下编译的产物

1
2
3
4
5
6
7
8
9
10
11
12
0000000000001120 <foo_bar>:
1120: 48 83 ec 08 sub $0x8,%rsp
1124: 48 8b 05 9d 2e 00 00 mov 0x2e9d(%rip),%rax # 3fc8 <PyExc_TypeError@Base>
112b: 48 8d 35 ce 0e 00 00 lea 0xece(%rip),%rsi # 2000 <_fini+0xe9c>
1132: 48 8b 38 mov (%rax),%rdi
1135: 48 83 07 01 addq $0x1,(%rdi)
1139: e8 f2 fe ff ff call 1030 <PyErr_SetString@plt>
113e: 31 c0 xor %eax,%eax
1140: 48 83 c4 08 add $0x8,%rsp
1144: c3 ret
1145: 66 66 2e 0f 1f 84 00 data16 cs nopw 0x0(%rax,%rax,1)
114c: 00 00 00 00

在 3.13 下编译的产物

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0000000000001120 <foo_bar>:
1120: 48 83 ec 08 sub $0x8,%rsp
1124: 48 8b 05 9d 2e 00 00 mov 0x2e9d(%rip),%rax # 3fc8 <PyExc_TypeError@Base>
112b: 48 8b 38 mov (%rax),%rdi
112e: 8b 07 mov (%rdi),%eax
1130: 83 c0 01 add $0x1,%eax
1133: 74 02 je 1137 <foo_bar+0x17>
1135: 89 07 mov %eax,(%rdi)
1137: 48 8d 35 c2 0e 00 00 lea 0xec2(%rip),%rsi # 2000 <_fini+0xe9c>
113e: e8 ed fe ff ff call 1030 <PyErr_SetString@plt>
1143: 31 c0 xor %eax,%eax
1145: 48 83 c4 08 add $0x8,%rsp
1149: c3 ret
114a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)

我们能发现我们在 call 1030 <PyErr_SetString@plt> 这条指令前的汇编完全不一样,我们这里能归纳出两点

  1. PyErr_SetString 调用的地址是在运行时动态解析的
  2. Py_INCREF 则处理成不同逻辑的汇编了

这种情况只有两种可能

  1. Py_INCREF 是一组宏定义
  2. Py_INCREF 是被 inline 处理了

我们来看下 Py_INCREF 的定义

1
static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op);

果然是第二种情况,那么这种情况就意味着 Py_INCREF 的实现在 3.13 和 3.11 中是不一样的,我们来看下代码

3.13

1
2
3
4
5
6
7
static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op)
{
if (_Py_IsImmortal(op)) {
return;
}
op->ob_refcnt++;
}

3.11

1
2
3
4
5
static inline void Py_INCREF(PyObject *op)
{

op->ob_refcnt++;
}

果然,在 3.13 中我们对于 immortal 对象的引用计数不再增加,而 3.11 不会做检查直接增加,这会使 immortal 对象的引用计数不再是 _Py_IMMORTAL_REFCNT,从而导致了我们的问题

这个问题那么其实说白了可以这样总结,在 PEP 683 Immortal 对象的实现中,我们将 immortal 的状态和引用技术 mix up 了,导致我们部分 ABI 在低版本 inline 后在高版本中有错误的逻辑。同时我们在 GH-1161152 中收窄了对于对象检测的严谨性,从而导致出现了兼容的问题

这个问题其实修复起来也很容易,目前我和另外一个 Python 核心开发者各自采用了一种处理方式

  1. 我是选择将 assert 的部分 revert 到之前的 if condition 检查,这样可以保证对象的兼容性,改动也比较小。缺陷就是算是 case by case 的解决
  2. 另外一位核心开发者解决的方式是将 immortal 的检查范围放大(大小于某个区间即可认为是 immortal 对象),这样的好处是可以扩展,而缺陷就是可能让 immortal 对象的实现复杂度进一步提升

不过说白了归根到底还是 PEP 683 实现的时候状态混合了,估计后续还有不少问题

总结

这个 case 其实也是个查起来不难,修复不难的问题。但是后面牵扯的东西太多了,很多有趣的讨论可以点进 issue 去看看

Reference

  1. https://github.com/python/cpython/issues/121528
  1. https://github.com/python/cpython/pull/116115