CPython编译为wasm后如何打印堆栈跟踪?(上)
CPython是标准的Python解释器,它是一个C程序。当编译为WebAssembly时,它将使用Wasm调用堆栈,并且在正确组装时,Wasm模块包含DWARF信息。使用wzprof运行它可以提供CPU和内存分析,但是这只能分析解释器本身!如果你正在编写Python代码,并且对其性能感兴趣,那么测量和显示Python调用堆栈将比CPython更有益。
那么CPython如何打印堆栈跟踪?
深入了解 CPython 内部
作为我职业生涯中编写Python的一部分,我记得traceback模块。它提供了一种“提取、格式化和打印Python程序的堆栈跟踪”的方法。它有一个名为print_stack的函数。它的代码在Lib/traceback.py中:
def print_stack(f=None, limit=None, file=None):
"""Print a stack trace from its invocation point.
The optional 'f' argument can be used to specify an alternate
stack frame at which to start. The optional 'limit' and 'file'
arguments have the same meaning as for print_exception().
"""
if f is None:
f = sys._getframe().f_back
print_list(extract_stack(f, limit=limit), file=file)
Lib/traceback.py
栈遍历
我们需要做的第一件事是遍历堆栈以捕获程序的快照。print_stack 函数首先通过查找似乎是调用者的堆栈帧的引用来开始:
f = sys._getframe().f_back
sys 模块是一个核心 C 模块,因此它的源代码在 Python/sysmodule.c 中:
static PyObject *sys__getframe_impl(PyObject *module, int depth)
{
PyThreadState *tstate = _PyThreadState_GET();
_PyInterpreterFrame *frame = tstate->cframe->current_frame;
if (frame != NULL) {
while (depth > 0) {
frame = frame->previous;
if (frame == NULL) {
break;
}
if (_PyFrame_IsIncomplete(frame)) {
continue;
}
--depth;
}
}
if (frame == NULL) {
_PyErr_SetString(tstate, PyExc_ValueError,
"call stack is not deep enough");
return NULL;
}
PyObject *pyFrame = Py_XNewRef((PyObject *)_PyFrame_GetFrameObject(frame));
if (pyFrame && _PySys_Audit(tstate, "sys._getframe", "(O)", pyFrame) < 0) {
Py_DECREF(pyFrame);
return NULL;
}
return pyFrame;
}
和 Go 一样,第一步是找到当前线程状态,可能是使用 _PyThreadState_GET。这个函数在
Include/internal/pycore_pystate.h 中定义:
static inline PyThreadState* _PyThreadState_GET(void)
{
return _PyRuntimeState_GetThreadState(&_PyRuntime);
}
static inline PyThreadState* _PyRuntimeState_GetThreadState(_PyRuntimeState *runtime)
{
return (PyThreadState*)_Py_atomic_load_relaxed(&runtime->gilstate.tstate_current);
}
_PyRuntime 值需要首先找到。这是在 pycore_runtime.h 中定义的类型为 _PyRuntimeState 的静态全局变量。
通过 Google 搜索该符号名称,我找到了 py-spy。它似乎基本上做了我们想用 wzprof 做的事情,但是是针对本地应用程序的。他们的 README 中说:
获取 Python 解释器的内存地址可能有点棘手,因为存在地址空间布局随机化。如果目标 Python 解释器带有符号,通过引用 interp_head 或 _PyRuntime 变量(具体取决于 Python 版本)很容易找到解释器的内存地址。但是,许多 Python 版本是使用剥离二进制文件进行分发的,或在 Windows 上未使用相应的 PDB 符号文件进行分发。在这些情况下,我们扫描 BSS(未初始化的数据)节以查找看起来可能指向有效 PyInterpreterState 的地址,并检查该地址的布局是否符合我们的预期。
我们可以限制自己只使用包含调试符号的 CPython 构建。在这种情况下,我们将使用一个包含在 timecraft 中的 Wasm+WASI 构建的 CPython。让我们转储 python.wasm 模块的 DWARF 部分,我们可以看到它包含:
$ dwarfdump python.wasm
//...
< 1><0x0000003e> DW_TAG_variable
DW_AT_name _PyRuntime
DW_AT_type 0x0000004f<.debug_info+0x0031828b>
DW_AT_external yes
DW_AT_decl_file 0x00000001 /src/cpython/Python/pylifecycle.c
DW_AT_decl_line 0x00000066
DW_AT_location len 0x0005: 03b0093000: DW_OP_addr 0x003009b0
//...
DWARF 包含一个名为 _PyRuntime 的变量,并给出了它在内存中的地址(DW_AT_location)。我们可以通过查看 decl_file 和 decl_line 属性提供的文件和行确认这是正确的变量。让我们看看是否可以从 CPython 中检索它的内容,以验证找到的内容。为了让自己方便,我在 sys 模块代码(Python/sysmodule.c)中添加了一个小的导出 C 函数来检索一些有用的信息:
PyObject* debugme(PyObject*, PyObject*)
{
printf("DEBUG ME!\n");
_PyRuntimeState *rt = &_PyRuntime;
printf("Address of _PyRuntime: %p\n", rt);
printf("Size of _PyRuntimeState: %d\n", sizeof(_PyRuntimeState));
printf("Address of gilstate: %p\n", &rt->gilstate);
printf("Address of tstate_current: %p\n", &rt->gilstate.tstate_current);
Py_RETURN_NONE;
}
重新构建 CPython 并运行它:
Python 3.11.4+ (heads/dev-dirty:c9eb99e348, Jul 4 2023, 15:40:38) [Clang 15.0.7 ] on wasi
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.debugme()
DEBUG ME!
Address of _PyRuntime: 0x3009e0
Size of _PyRuntimeState: 85152
Address of gilstate: 0x300b44
Address of tstate_current: 0x300b48
>>>
确认这确实是全局 _PyRuntime 值在线性内存中的地址是很好的。我们现在知道:
gilstate 在 _PyRuntime 结构体中偏移了 356 字节,即地址为 0x300b44。
tstate_current 在 gilstate 内偏移了 4 字节,即地址为 0x300b48。
因此,对于这个版本的 Python,指向当前 PyThreadState 的指针位于 addressof(_PyRuntime) + 356 + 4。
考虑到我们已经知道如何解析 DWARF 内容并读取线性内存,这在 wzprof 中实现起来相当简单。
让我们继续跟踪 print_stack,看它在获得指向 PyThreadState 的指针后会做什么:
_PyInterpreterFrame *frame = tstate->cframe->current_frame;
我们唯一可以使用的是暂停程序并读取其内存中任意地址的能力。当 clang 编译 CPython 时,它知道每个结构体的布局,因此为 tstate->cframe 等表达式生成指令很容易。我们需要找出如何在没有这种固有知识的情况下做同样的事情。有多种方法可以实现这一点。我们可以编写一个类似上面的 debugme() 函数来转储我们所需的所有结构体偏移量,或者检查 DWARF 信息以获取结构体成员的偏移量。
在检索了帧指针之后,sys__getframe_impl 在 _PyInterpreterFrame* 上调用 _PyFrame_GetFrameObject:
/* Gets the PyFrameObject for this frame, lazily
* creating it if necessary.
* Returns a borrowed referennce */
static inline PyFrameObject *_PyFrame_GetFrameObject(_PyInterpreterFrame *frame)
{
assert(!_PyFrame_IsIncomplete(frame));
PyFrameObject *res = frame->frame_obj;
if (res != NULL) {
return res;
}
return _PyFrame_MakeAndSetFrameObject(frame);
}
如果 frame_obj 指针不存在,Python 就会创建它。我们不应该修改内存,因此我们不能走这条路。让我们快速看一下它是如何创建的(去除了断言和注释):
PyFrameObject *_PyFrame_MakeAndSetFrameObject(_PyInterpreterFrame *frame)
{
PyObject *error_type, *error_value, *error_traceback;
PyErr_Fetch(&error_type, &error_value, &error_traceback);
PyFrameObject *f = _PyFrame_New_NoTrack(frame->f_code);
if (f == NULL) {
Py_XDECREF(error_type);
Py_XDECREF(error_value);
Py_XDECREF(error_traceback);
return NULL;
}
PyErr_Restore(error_type, error_value, error_traceback);
if (frame->frame_obj) {
f->f_frame = (_PyInterpreterFrame *)f->_f_frame_data;
f->f_frame->owner = FRAME_CLEARED;
f->f_frame->frame_obj = f;
Py_DECREF(f);
return frame->frame_obj;
}
f->f_frame = frame;
frame->frame_obj = f;
return f;
}
除了错误处理之外,此函数实际上只是使用 _PyInterpreterFrame 中的信息填充 PyFrameObject。对此主题的快速搜索没有揭示出 CPython 为什么以这种方式复制信息。如果您对此有线索,请让我知道!
这意味着我们可以仅使用 _PyInterpreterFrame 来实现我们的目的。太好了!我们有一种方法来获取解释器调用堆栈的顶部,它由 _PyInterpreterFrame 结构体组成。让我们查看其定义,以确保可以递归地找到调用者帧(遍历堆栈):
typedef struct _PyInterpreterFrame {
/* "Specials" section */
PyFunctionObject *f_func; /* Strong reference */
PyObject *f_globals; /* Borrowed reference */
PyObject *f_builtins; /* Borrowed reference */
PyObject *f_locals; /* Strong reference, may be NULL */
PyCodeObject *f_code; /* Strong reference */
PyFrameObject *frame_obj; /* Strong reference, may be NULL */ /* Linkage section */
struct _PyInterpreterFrame *previous;
_Py_CODEUNIT *prev_instr; int stacktop; /* Offset of TOS from localsplus */ bool is_entry; // Whether this is the "root" frame for the current _PyCFrame. char owner; /* Locals and stack */
PyObject *localsplus[1];
} _PyInterpreterFrame;
在这里,previous 成员指向另一个 _PyInterpreterFrame。
未完待续……
关于分析编译python位wasm后的动态debug定位能力的挖掘后面还会有一篇文章。今天先写到这里。如果您对我写的python相关的各种文章感兴趣不妨关注我或者我的专栏了解更多python技术细节和python高级玩法。
关于python asyncio因为知识面较多也单独拎出来有一个独立的专栏编写,专栏如下: