深入理解 Python 求值执行

你是否遇到过这种情况,包含 Python 语言的应用崩溃了,但面对调用栈日志无处下手?

现代机器学习项目往往需要采用多语言方案,以同时确保提供高性能和用户友好体验,比如:

  • NVIDIA Triton Inference Server 提供 Python Backend,允许用户无需编写 C++ 代码即可通过 Python 脚本部署模型,极大地降低了业务人员的门槛。
  • PyTorch Custom C++ Extensions:为了加速特定的算子,编写 C++/CUDA 代码并通过 pybind11 绑定到 Python。

在这种混合架构下,一旦程序崩溃,GDB 打印出的堆栈通常充满了 PyEval_* 等晦涩的符号。

12345678910111213141516171819#0  0x00007ffff7e0b12c in trigger_crash () at libcrash.c:8
#1  0x00007ffff7de8ac6 in ?? () from /usr/lib/libffi.so.8
#2  0x00007ffff7de576b in ?? () from /usr/lib/libffi.so.8
#3  0x00007ffff7de806e in ffi_call () from /usr/lib/libffi.so.8
#4  0x00007ffff7e2f1bd in ?? () from /usr/lib/python3.13/lib-dynload/_ctypes.cpython-313-x86_64-linux-gnu.so
#5  0x00007ffff7e2cec3 in ?? () from /usr/lib/python3.13/lib-dynload/_ctypes.cpython-313-x86_64-linux-gnu.so
#6  0x00007ffff79620cb in _PyObject_MakeTpCall () from /usr/lib/libpython3.13.so.1.0
#7  0x00007ffff797697a in _PyEval_EvalFrameDefault () from /usr/lib/libpython3.13.so.1.0
#8  0x00007ffff7a4e2c9 in PyEval_EvalCode () from /usr/lib/libpython3.13.so.1.0
#9  0x00007ffff7a8c88c in ?? () from /usr/lib/libpython3.13.so.1.0
#10 0x00007ffff7a8985d in ?? () from /usr/lib/libpython3.13.so.1.0
#11 0x00007ffff7a86f58 in ?? () from /usr/lib/libpython3.13.so.1.0
#12 0x00007ffff7a86212 in ?? () from /usr/lib/libpython3.13.so.1.0
#13 0x00007ffff7a85b83 in ?? () from /usr/lib/libpython3.13.so.1.0
#14 0x00007ffff7a83e51 in Py_RunMain () from /usr/lib/libpython3.13.so.1.0
#15 0x00007ffff7a3bbeb in Py_BytesMain () from /usr/lib/libpython3.13.so.1.0
#16 0x00007ffff7427635 in ?? () from /usr/lib/libc.so.6
#17 0x00007ffff74276e9 in __libc_start_main () from /usr/lib/libc.so.6
#18 0x0000555555555045 in _start ()

你看到的是 CPythonCPython 是 Python 编程语言的官方、标准、也是最广泛使用的解释器实现。当我们平时说“我安装了 Python”,99% 的情况下,我们指的就是 CPython。 虚拟机的执行路径,而不是编写的 Python 业务代码。针对这种情况,本文将深入 CPython,剖析 Python 代码是如何被求值的,为调试这类跨语言工具提供基础知识。

看完本文,你能回答以下问题:

  • 为什么 GDB 默认无法直接看到 Python 调用栈?
  • 如何在只有 Core Dump 的情况下恢复 Python 业务逻辑的堆栈?
  • 如何判断一个崩溃的 Python 进程是否正持有 GIL?
  • py-spy 与 PyStack 这类工具在实现“零侵入”观测时,核心差异在哪里?

字节码与虚拟机

在深入之前,首先需要理解 Python 是如何被“翻译/执行”的。

Python 并非纯粹的解释型语言。实际上,CPython 拥有一个严格的编译阶段。当 Python 程序执行时,源代码首先被编译成一种名为字节码(Bytecode)的中间表示(Intermediate Representation, IR)。这种字节码是特定于Python 虚拟机(Python Virtual Machine, PVM)的低级指令集。虚拟机加载字节码对象,逐条执行指令,操纵堆栈和内存对象。

PyCodeObject 与其序列化

Python 的编译单元是代码对象(Code Object),在 C 语言层面,它对应 PyCodeObject 结构体;在 Python 层面,它是 types.CodeType 的实例。代码对象是不可变(immutable)的,它包含了虚拟机执行一段代码所需的所有静态信息。下面是 PyCodeObject 定义的代码片断本文的代码片段旨在展示核心原理,并非完整的类结构或逻辑链路。

123456789101112131415161718192021struct PyCodeObject _PyCode_DEF(1);

#define _PyCode_DEF(SIZE) {                                                    \
    /* The hottest fields (in the eval loop) are grouped here at the top. */   \
    PyObject *co_consts;           /* list (constants used) */                 \
    PyObject *co_names;            /* list of strings (names used) */          \
    PyObject *co_exceptiontable;   /* Byte string encoding exception handling  \
                                      table */                                 \
    PyObject *co_filename;        /* unicode (where it was loaded from) */     \
    PyObject *co_name;            /* unicode (name, for reference) */          \
    PyObject *co_qualname;        /* unicode (qualname, for reference) */      \
    _PyCoCached *_co_cached;      /* cached co_* attributes */                 \
    char co_code_adaptive[(SIZE)];                                             \
}

typedef struct {
    PyObject *_co_code;      /* 字节码指令流 */
    PyObject *_co_varnames;  /* 局部变量 */
    PyObject *_co_cellvars;
    PyObject *_co_freevars;
} _PyCoCached;

编译好的代码对象会被序列化(Marshal)到 .pyc 文件中。与 pickle 不同,marshal 格式不保证跨版本兼容,它专门用于 Python 内部的对象持久化。下面这个完整的实验代码展现了 .pyc 的格式:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748import marshal
import py_compile
import struct
import time
import dis

source_code = """
global_var = 114514
def foo(x):
    local_var = 42
    return x + local_var + global_var
"""

filename = "test_script.py"
with open(filename, "w") as f:
    f.write(source_code)

# cfile 是编译后的输出路径,例如 __pycache__/test_script.cpython-310.pyc
# 也可以使用 co_module = compile(src, "<string>", "exec") 编译为不包含头结构的 Code Object
cfile = py_compile.compile(filename)

# 解析 .pyc 文件结构 (Python 3.7+):
# [4 bytes Magic Number] [4 bytes Bit Field] [4 bytes Timestamp] [4 bytes Size] [Marshalled Code Object...]
with open(cfile, "rb") as f:
    magic = f.read(4)
    bit_field = f.read(4)
    timestamp = f.read(4)
    file_size = f.read(4)

    print(f"Magic Bytes: {magic.hex()}")
    mod_time = time.ctime(struct.unpack('<I', timestamp)[0])
    print(f"Timestamp: {mod_time}")

    # 反序列化代码对象 (Unmarshalling)
    # 这里的 load 对应 C 语言层面的 PyMarshal_ReadObjectFromString
    code_obj = marshal.load(f)

    print(f"co_name (函数/模块名): {code_obj.co_name}")
    print(f"co_consts (常量池): {code_obj.co_consts}")

    func_code = None
    for const in code_obj.co_consts:
        if hasattr(const, 'co_name'):
            print(f"\n[Dive into '{const.co_name}']:")
            func_code = const
            print(f"co_varnames (局部变量): {func_code.co_varnames}")
            print(f"co_code (字节码指令流): {func_code.co_code.hex()}")
            dis.dis(func_code)

代码运行后输出如下:

123456789101112131415161718Magic Bytes: f30d0d0a
Timestamp: Thu Jan  1 22:20:54 2026
co_name (函数/模块名): <module>
co_consts (常量池): (114514, <code object foo at 0x7f7ff69e70f0, file "test_script.py", line 3>, None)

[Dive into 'foo']:
co_varnames (局部变量): ('x', 'local_var')
co_code (字节码指令流): 950053016e0158012d0000005b0000000000000000002d0000002400
  3           RESUME                   0

  4           LOAD_CONST               1 (42)
              STORE_FAST               1 (local_var)

  5           LOAD_FAST_LOAD_FAST      1 (x, local_var)
              BINARY_OP                0 (+)
              LOAD_GLOBAL              0 (global_var)
              BINARY_OP                0 (+)
              RETURN_VALUE

通过 types.CodeType,我们可以手动创建一个新的代码对象,替换原函数的 __code__ 属性,从而在不改变源码的情况下修改函数行为。下面这个例子把一个加法函数的行为改变为乘法。下面这个例子把一个加法函数的行为改变为乘法。

12345678910111213141516171819202122232425import opcode

def target_func(a, b):
    return a + b

print(f"Original: {target_func(5, 5)}")

co = target_func.__code__
code = bytearray(co.co_code)

# Patching Logic (Python 3.11+)
# BINARY_OP (opcode 122) uses an arg to define the operation (0=Add, 5=Mul)
BINARY_OP = opcode.opmap['BINARY_OP']
NB_ADD = 0  # Slot for addition (+)
NB_MUL = 5  # Slot for multiplication (*)

for i in range(0, len(code), 2):
    op, arg = code[i], code[i + 1]
    if op == BINARY_OP and arg == NB_ADD:
        print(f"Patching BINARY_OP(+) at offset {i} to (*)")
        code[i + 1] = NB_MUL

target_func.__code__ = co.replace(co_code=bytes(code))

print(f"Patched:  {target_func(5, 5)}")

虚拟机架构:基于堆栈的计算模型

CPython 虚拟机是一个堆栈机器(Stack Machine)。与基于寄存器的虚拟机(如 Lua VM 或 DalvikDalvik 是 Google 为 Android 平台设计的 Java 虚拟机(JVM)变体。)不同,PVM 的指令通常不包含显式的操作数地址,而是隐式地从堆栈顶部获取操作数。

我曾开发过一个名为 lightning-ac 的栈式虚拟机玩具项目,其核心代码仅有 400 行。该项目的核心设计思路是为了解决在 LeetCode 等算法平台提交代码时的隐私问题。项目设计了一套堆栈机器的指令集,在提交时,只需提供一个约 100 行的微型虚拟机解释器以及编译好的字节码指令流,即可在保护核心逻辑不被窥视的同时完成题目交付。项目已完整通过了汉诺塔和快速排序的测试。当然这只是玩具,执行时间是原生 C 程序的 4 到 10 倍;而且我没有实现从 C 程序到指令集的编译器,需要手写 IR 指令,非常需要耐心。

继续使用上一个小章节的例子,从 dis.dis 的输出可以看到计算过程:

行号字节码指令操作数栈的变化语义说明
3RESUME0[]no-op,用于调试、安全检查
4LOAD_CONST1 (42)[42]从常量池 co_consts 加载索引为 1 的对象压入栈顶。
STORE_FAST1 (local_var)[]弹出栈顶值,存入局部变量表索引 1 的位置(即 local_var)。
5LOAD_FAST_LOAD_FAST1 (x, local_var)[x, 42]特化指令:为了加速,一次性将两个局部变量压入栈中。
BINARY_OP0 (+)[x + 42]弹出栈顶两个元素执行加法,将结果压回。
LOAD_GLOBAL0 (global_var)[x + 42, 114514]在全局命名空间查找 global_var 并压入栈顶。
BINARY_OP0 (+)[(x + 42) + 114514]再次执行加法,结果压回栈顶。
RETURN_VALUE[]弹出栈顶元素作为函数的返回值。

官方文档 可以查询到字节码指令的详细规范,比如特化指令 LOAD_FAST_LOAD_FAST(var_names) 的规范是:Pushes references to co_varnames[var_nums >> 4] and co_varnames[var_nums & 15] onto the stack.

下面看看源码是如何实现的,Python/bytecodes.c 是新版本中字节码定义的源头。CPython 现在使用一种特殊的 DSL(领域特定语言)来描述字节码,然后自动生成到 generated_cases.c.h (用于 Tier 1,后文会介绍是什么)和 executor_cases.c.h (用于 Tier 2)中。下面是 LOAD_FAST_LOAD_FAST 的实现,这里的 (inputs -- outputs) 是一种描述栈效应(Stack Effect)的 DSL,表达需要从栈中弹出 inputs(可以为空),并压入 outputs

123456inst(LOAD_FAST_LOAD_FAST, ( -- value1, value2)) {
    uint32_t oparg1 = oparg >> 4;
    uint32_t oparg2 = oparg & 15;
    value1 = PyStackRef_DUP(GETLOCAL(oparg1));
    value2 = PyStackRef_DUP(GETLOCAL(oparg2));
}

CPython 目前采用分层执行架构,通过两套不同的系统来平衡通用性与高性能。Tier 1 是标准的字节码解释器,其入口位于 _PyEval_EvalFrameDefault(该函数是求值的核心入口函数,位于Python/ceval.c)。当某些代码路径被识别为“热点”时,系统会将其转换为微操作(uops) 序列,并进入 Tier 2 层(Python 3.12 引入此特性)。Tier 2 可以进一步选择通过解释器运行,或者由 JIT(即时编译器)编译为机器码。

1234567891011121314151617181920212223242526272829PyObject* _Py_HOT_FUNCTION DONT_SLP_VECTORIZE
_PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)
{
    goto start_frame;  // start_frame is a label defined in "generated_cases.c.h"
#   include "generated_cases.c.h"
}

#ifdef _Py_TIER2
#ifdef _Py_JIT
_PyJitEntryFuncPtr _Py_jit_entry = _Py_LazyJitShim;
#else
_PyJitEntryFuncPtr _Py_jit_entry = _PyTier2Interpreter;
#endif
#endif

_Py_CODEUNIT *
_PyTier2Interpreter(
    _PyExecutorObject *current_executor, _PyInterpreterFrame *frame,
    _PyStackRef *stack_pointer, PyThreadState *tstate
) {
    for (;;) {
        uopcode = next_uop->opcode;
        switch (uopcode) {
#include "executor_cases.c.h"
            default:
            Py_UNREACHABLE();
         }
    }
}

求值框架

Python 的执行模型建立在一个基于栈的虚拟机之上,而这一模型的核心是“求值框架”(Evaluation Frame),即通常所说的“栈帧”。它是 CPython 在运行时维护函数调用上下文、局部变量、操作数栈以及指令指针的关键数据结构。

栈帧

长久以来,Python 的函数调用开销被诟病为性能瓶颈之一,其根源很大程度上在于帧对象的分配机制。近年来,为了提升 Python 的运行速度,核心开发团队推动了名为“香农计划”(Shannon Plan)的一系列优化措施。这导致了帧结构的剧烈演变。在 Python 3.10 及以前,帧是一个完整的 Python 对象(PyFrameObject),分配在堆上,生命周期管理依赖引用计数,开销巨大。而在 Python 3.11 中,引入了 _PyInterpreterFrame,这是一种轻量级的、非 Python 对象的 C 结构体,主要分配在专用的 C 数据栈上,极大地减少了内存分配和垃圾回收的压力。

现在的架构可以被视为“双层”模型:

  • _PyInterpreterFrame (内部帧):这是虚拟机实际使用的帧。它是一个轻量级的 C 结构体,通常分配在解释器的“数据栈”(Data Stack)上。它不是 PyObject,没有引用计数头部,创建和销毁几乎没有开销(仅是指针移动)。它包含了执行所需的所有状态,如指令指针、局部变量数组等 。

  • PyFrameObject (外部帧):这仍然是一个 Python 对象,但它变成了一个“懒加载”的代理包装器。只有当用户代码(如通过 sys._getframe(), inspect.stack())或调试器显式请求访问帧对象时,解释器才会从堆上分配一个 PyFrameObject,并将其指向对应的 _PyInterpreterFrame。如果代码不进行内省内省(Introspection)是指程序在运行时检查对象、类或函数的类型、属性和能力的一种能力。另一个相关的概念是反射 (Reflection),反射包含了内省,并在此基础上允许程序在运行时修改自身的行为或结构。,这个对象可能永远不会被创建 。

1234567891011typedef struct _PyInterpreterFrame {
    _PyStackRef f_executable; /* Deferred or strong reference (code object or None) */
    struct _PyInterpreterFrame *previous;
    _PyStackRef f_funcobj; /* Deferred or strong reference. Only valid if not on C stack */
    PyObject *f_globals; /* Borrowed reference. Only valid if not on C stack */
    PyObject *f_builtins; /* Borrowed reference. Only valid if not on C stack */
    PyObject *f_locals; /* Strong reference, may be NULL. Only valid if not on C stack */
    PyFrameObject *frame_obj; /* Strong reference, may be NULL. Only valid if not on C stack */
    _Py_CODEUNIT *instr_ptr; /* Instruction currently executing (or about to begin) */
    _PyStackRef *stackpointer;
} _PyInterpreterFrame;

Python 3.11 引入了基于数据栈的执行模型。当一个 Python 函数调用另一个 Python 函数时,解释器不再递归调用 C 函数 _PyEval_EvalFrame,而是简单地在预分配的“数据栈”上初始化一个新的 _PyInterpreterFrame,更新状态指针,然后通过 goto 跳转到解释器循环的开始处。这种技术实际上实现了类似于尾调用优化(Tail call optimization)的效果,使得大部分 Python 函数调用不再消耗 C 栈空间 。这种设计对调试带来不小影响,在 C 栈中我们看到的一个 _PyEval_EvalFrameDefault 调用 可能实际上对应着多个 Python 函数的调用。

内存布局与栈架构

我们需要区分两个“栈”的概念:

  • 调用栈(Call Stack):由一系列帧组成的链表,表示函数的嵌套调用关系。在 3.11+ 中,这体现为 _PyInterpreterFrame 结构体通过 previous 指针连接而成的链。
  • 值栈(Value Stack):每个帧内部用于计算的临时存储区。例如,计算 a + b 时,ab 会被压入值栈,BINARY_OP 指令弹出它们并将结果压回栈顶 。

Python 并不使用字典来存储局部变量(尽管 locals() 返回一个字典)。这种机制被称为 Fast Locals。 为了最大限度地提高 CPU 缓存命中率,CPython 将局部变量、闭包变量(Cells/Free Variables)和值栈分配在同一块连续的内存区域中,这块区域被称为 f_localsplus。其内存分布如下:

索引范围内容对应对应指令
0co_nlocals - 1局部变量 (Local Variables)LOAD_FAST / STORE_FAST
co_nlocalsco_nlocals + co_ncellvars + co_nfreevars - 1闭包单元 (Cell/Free Vars)LOAD_DEREF / STORE_DEREF
动态变化(不占固定索引)值栈 (Value Stack)PUSH / POP 操作 (如 BINARY_ADD)

在 Python 3.12 及以前,locals() 返回的字典是一个快照(Snapshot)。如果需要修改生效(例如调试器想要修改变量),必须调用 C API PyFrame_LocalsToFast,将字典的内容强制同步回数组 。Python 3.13 引入了 PEP 667 (Consistent views of namespaces),彻底解决了这个问题。现在,frame.f_locals 不再返回一个普通的字典,而是返回一个 PyFrameLocalsProxy 对象。对这个代理对象的任何读取或写入操作,都会直接透传到底层的 f_localsplus 数组。

核心执行循环

所有 Python 代码的执行最终都会进入 _PyEval_EvalFrameDefault 函数(或其变体)。这个函数的核心是一个无限 for 循环,循环体内包含一个巨大的 switch 语句(或计算跳转表),根据当前字节码指令(Opcode)进行分发 。

CPython 提供了一个允许替换默认求值函数的 API:_PyInterpreterState_SetEvalFrameFunc。这允许第三方工具(如 JIT 编译器 Pyjion,或者调试器)拦截帧的执行。当创建一个新帧时,解释器会调用 tstate->interp->eval_frame(tstate, frame, throwflag)。默认情况下,这个指针指向 _PyEval_EvalFrameDefault。但是,如果安装了 JIT,它可以指向一个编译成机器码的函数,完全绕过 ceval.c 的字节码解释循环。在 Python 3.11 中,为了优化性能,如果 eval_frameNULL,则直接调用默认解释器,避免了函数指针调用的开销。

123456789static inline PyObject*
_PyEval_EvalFrame(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)
{
    EVAL_CALL_STAT_INC(EVAL_CALL_TOTAL);
    if (tstate->interp->eval_frame == NULL) {
        return _PyEval_EvalFrameDefault(tstate, frame, throwflag);
    }
    return tstate->interp->eval_frame(tstate, frame, throwflag);
}

我们可以用 sys.settrace 在每行代码或每个函数调用时介入。当 sys.settrace 被激活时,线程状态 tstate->cframe->use_tracing 标志被设置。解释器循环会放弃使用优化的特化指令(Specialized Instructions)CPython 引入了 PEP 659 (Specializing Adaptive Interpreter)。以一个简单的字节码指令 BINARY_OP 为例: 在执行之前,虚拟机并不知道操作数是什么,它必须在运行时进行繁琐的检查。如果虚拟机发现这行代码连续多次都在处理两个整数相加,它就会把这条指令原地替换为专门处理整数的特化版本:BINARY_OP_ADD_INT,转而进入 legacy tracing 路径。这会导致性能显著下降(可能慢 10 倍以上),因为解释器必须在每条指令或每行代码边界暂停,构造帧对象,并调用 Python 回调函数 。

生成器与协程的帧状态管理

生成器(Generator)和协程(Coroutine)都从底层实现看,协程的挂起同样依赖 yieldawait something 本质上是调用 something.__await__(),最终通过 yield 实现非阻塞挂起。通过 yield 关键字实现了函数的暂停和恢复。普通函数的帧在其返回后就可以销毁(在 3.11+ 中是从 C 栈中弹出)。但生成器不同,当它 yield 时,函数并未结束,其局部变量状态必须保留,以便下次恢复。因此,生成器的帧必须分配在堆上,或者其状态必须能够从栈上“逃逸”到堆上。 在 Python 3.11 的实现中,当执行 RETURN_GENERATOR 指令时,解释器会创建一个生成器对象,并将当前的 _PyInterpreterFrame 状态复制或关联到这个生成器对象中。这使得帧的生命周期与生成器对象的生命周期绑定。

GIL 锁与求值计算

全局解释器锁(Global Interpreter Lock,简称 GIL)是 CPython 解释器中最为核心且最具争议的同步原语,GIL 并非 Python 语言规范的一部分,而是 CPython 实现层面的产物。Jython, Codon 等其他 Python 实现就不包含 GIL 锁。尽管其初衷是保护非线程安全的 CPython 内存管理机制,保证只有一个线程能够持有对 Python 对象及其内存管理器的写权限,但在多核处理器普及的今天,GIL 已成为限制 Python 并行计算能力的主要瓶颈。

GIL 锁的实现演进:

  • 在很长一段时间里(Python 3.12 之前),GIL 的状态存储在进程级的全局变量 _PyRuntimeState 中(符号表中通常显示为 _PyRuntime)。这意味着整个进程共享这一把锁,无论你开启了多少个线程。
  • 随着 Python 3.13 的发布以及 PEP 684(Per-Interpreter GIL)的推进,情况发生了本质的变化。为了支持真正的多解释器并行注意,多解释器并行并不等同于 Python 中的多线程。一个解释器可以包含多个线程。如果你在一个解释器内部启动了若干个线程,这些线程依然要争夺解释器的那一把 GIL。它们之间互斥,不能并行。,绝大多数运行时状态——包括 GIL 本身——正在从全局的 _PyRuntime 迁移到每个解释器独有的 PyInterpreterState 结构中。
  • Python 3.14 提出 No-GIL,使用了有偏引用计数(Biased Reference Counting)和 Mimalloc(由微软开发的高性能分配器)。Python 3.14 并不会默认开启 No-GIL,它依然是一个构建选项(--disable-gil)。但这一版本标志着 CPython 从“由于 GIL 存在而只能并发”向“真正的并行运行时”的实质性跨越。No-GIL 并非没有代价,即便使用了有偏引用计数,为了维护线程安全,解释器在执行字节码时仍需进行额外的检查(例如延迟引用计数的处理)。目前的基准测试显示,No-GIL 版本在单线程任务上比标准版本慢 10% 左右;同时,无法直接混用标准 Python 和 No-GIL Python 的扩展模块,No-GIL 版本拥有独立的 ABI 标签(abi3t,其中 t 代表 threaded),这意味着像 NumPy、PyTorch 这样的 C 扩展库必须专门为 No-GIL 版本进行编译和发布。

GIL 对求值的影响主要体现在它打断了指令流的连续性。解释器不可能在每条字节码指令之后都检查是否有线程想要抢占 GIL,因为原子操作和锁检查的开销相对于简单的指令(如 LOAD_FAST)来说太大了。因此,CPython 采用了一种名为 eval_breaker 的机制。 在 Python 3.10 之前,系统维护一个 tick 计数器,每执行 N 条指令检查一次,当这个标志位被设置时,解释器会暂停当前的字节码执行,去处理信号或释放 GIL 让其他线程运行。在 Python 3.13 中,为了支持未来的 No-GIL 构建,eval_breaker 被移动到了 PyThreadState 结构中 。

通过 GDB,我们可以直接观察到这个机制在内存中的真实状态。

123456789// 以 Python 3.14 为例
(gdb) print _PyRuntime.interpreters.main.ceval.gil.locked
$1 = 1  // 锁状态:1 表示当前 GIL 被占用

(gdb) print _PyRuntime.interpreters.main.ceval.gil.last_holder
$2 = (PyThreadState *) 0x555555bd1538 <_PyRuntime+331640> // 指向当前持有锁的线程状态对象

(gdb) print _PyRuntime.interpreters.main.ceval.gil.last_holder->native_thread_id
$3 = 3130637 // 对应的操作系统原生线程 ID

在 GDB 中查看 Python 语义

实战中,我们通常需要对业务团队提供的 Core Dump(核心转储)文件Core Dump 是进程在终止瞬间的内存、寄存器和执行状态的二进制快照。对于 C/C++ 等编译型语言,使用 GNU Debugger (GDB) 分析 Core Dump 是标准流程。进行分析,这一章节讨论如何使用 GDB 完成这个任务。

Core Dump 文件的生成与内核机制

Core Dump 的生成涉及 Linux 内核的信号处理、内存管理以及文件系统交互。当进程接收到某些特定信号(如 SIGSEGVSIGQUITSIGABRT)且未定义自定义处理函数时,内核会触发默认动作:终止进程并生成 Core Dump。这一过程由内核函数 do_coredumpvfs_coredump 协调。当然,也可以使用 gcore 命令对正在运行的进程生成 Core Dump 文件,这通常在调试死锁的问题中使用。

Linux 系统出于磁盘空间和性能考虑,通常将 Core 文件大小限制默认为零,我们需要使用下面的指令显式解除此限制。对于由 systemd 管理的守护进程,需要在 Unit 文件中设置 LimitCORE=infinity

1$ ulimit -c unlimited

内核参数 kernel.core_pattern 决定了 Core Dump 的命名规则和存储位置。查看当前配置:

123# 查看 core 文件生成规则
$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h %d %F

此处的管道符 | 具有特殊意义。它表明内核不会直接将 Core 数据写入磁盘,而是将其通过管道流式传输给用户态程序 /usr/lib/systemd/systemd-coredump。 在现代 Linux 发行版中,systemd-coredump 会将 Core 数据压缩存储在 /var/lib/systemd/coredump 中。coredumpctl 是管理这些数据的标准工具。为了使用 GDB 分析,必须将压缩的内部格式导出为标准的 ELF Core 文件:

12coredumpctl list
coredumpctl dump <PID> --output core.mycrash

启动 GDB 时,需要同时指定崩溃时的可执行文件和Core 文件。这两个文件必须严格匹配,否则符号表无法正确映射。

123(gdb) core core.mycrash
# 或者在启动时加载
$ gdb /usr/bin/python3 core.mycrash

直接使用原生的 GDB 只能看到无法直接映射回 Python 源码路径和行号。下一小节讨论如何获取调试信息。

调试信息的获取与 Build-ID 机制

当 GDB 加载二进制文件时,它会检查 .note.gnu.build-id 段。这是一个由编译器(GCC/LLVM)在链接阶段计算出的 SHA-1 哈希值(160位),用于唯一标识该二进制构建版本。通过 readelfeu-unstrip 可以查看二进制文件的 Build-ID:

1234567$ readelf -n /usr/bin/python | grep -A3 build.id
Displaying notes found in: .note.gnu.build-id
  Owner                Data size        Description
  GNU                  0x00000014       NT_GNU_BUILD_ID (unique build ID bitstring)
    Build ID: 8c0dc848f5b978c56ebeb07255bb332b4b37ae4e
$ eu-unstrip -n --core=core.mycrash | head -n 1
0x55a08b363000+0x5000 8c0dc848f5b978c56ebeb07255bb332b4b37ae4e@0x55a08b3633b0 . - /usr/bin/python3.13

Linux 发行版通常将二进制文件剥离符号后发布(以减小体积),并将调试符号打包在独立的 -debuginfo-dbg 包中。

12345678910# Fedora
$ yum install gdb python-debuginfo

# Ubuntu
$ apt-get install gdb python3.12-dbg

# Centos
$ yum install yum-utils
$ debuginfo-install glibc
$ yum install gdb python-debuginfo

在微服务和容器化环境中,手动安装每个依赖库的 debuginfo 包极其繁琐且不仅可能。debuginfod 协议解决了这个问题。它是一个基于 HTTP 的文件服务器,能够根据 Build-ID 索引并提供调试资源。用 debuginfod-find 可以查询某个 Build-ID 在服务器上是否存在。

1$ debuginfod-find debuginfo d5e3b0d8921923f35438adefa9f864745abc5e90

若不需要查询(例如网络隔离环境或查询速度过慢,在生产环境中这很可能出现),可显式关闭:

12$ unset DEBUGINFOD_URLS
(gdb) set debuginfod enabled off

对于深入的解释器开发或调试极其隐晦的 Bug,发行版提供的符号可能不够用(通常编译优化级别为 -O2-O3,导致变量被“优化掉”)。此外,像 Arch Linux 等滚动更新发行版,其上游并不总是提供配套的调试文件。此时需要手动编译 CPython:

1234# 调试版本会启用 Py_DEBUG 宏,会改变 cpython 的行为
$ ./configure --with-pydebug CFLAGS="-O0 -g"
# 带调试信息的生产版本
$ ./configure --enable-optimizations CFLAGS="-g -O3"

加载 python-gdb.py 扩展

拥有了 Core 文件和调试符号后,我们终于可以开始真正的分析。此时,原生的 GDB 指令(如 bt, info registers)虽然可用,但它们显示的是 C 语言层面的执行流。我们需要借助 CPython 提供的 GDB 扩展脚本来“翻译”这些信息。

python-gdb.py(在某些系统中名为 libpython.py)是 CPython 源码树的一部分(这意味着不保证跨版本兼容)。它利用 GDB 的 Python API,读取 C 结构体(如 PyFrameObject)并将其格式化为 Python 开发者熟悉的形式。

一旦扩展加载成功,一系列 py- 开头的指令便可使用。比如,可以使用 thread apply all py-bt 打印每个线程在 Python 层面的执行位置。

为了更深入理解 python-gdb.py 做了什么,我们可以从头写一个简单版本的 pt-bt

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374import gdb


def get_code_info(py_frame):
    try:
        f_executable = py_frame["f_executable"]

        if f_executable.type.name != "_PyStackRef":
            return None, None, None

        USED_TAGS = 0b11
        bits = int(f_executable["bits"]) & ~USED_TAGS
        if bits == 0:
            return None, None, None

        PyObject_ptr = gdb.lookup_type("PyObject").pointer()
        py_obj = gdb.Value(bits).cast(PyObject_ptr)

        PyCodeObject_ptr = gdb.lookup_type("PyCodeObject").pointer()
        code_obj = py_obj.cast(PyCodeObject_ptr)

        co_name_ptr = code_obj["co_name"]
        co_filename_ptr = code_obj["co_filename"]

        name = co_name_ptr.format_string().strip("'")
        filename = co_filename_ptr.format_string().strip("'")

        try:
            lineno = int(code_obj["co_firstlineno"])
        except:
            lineno = 0

        return name, filename, lineno
    except:
        return None, None, None


class MyPyBacktrace(gdb.Command):
    def __init__(self):
        super(MyPyBacktrace, self).__init__(
            "my-py-bt", gdb.COMMAND_STACK, gdb.COMPLETE_NONE
        )

    def invoke(self, argument, from_tty):
        frame = gdb.selected_frame()
        index = 0
        print(f"{'INDEX':<5} {'FUNCTION':<25} {'FILE:LINE'}")

        while frame:
            if frame.name() == "_PyEval_EvalFrameDefault":
                try:
                    py_frame = frame.read_var("frame")

                    FRAME_OWNED_BY_INTERPRETER = 3
                    while py_frame:
                        owner = int(py_frame["owner"])
                        if owner == FRAME_OWNED_BY_INTERPRETER:
                            break

                        name, filename, lineno = get_code_info(py_frame)
                        if name:
                            print(f"{index:<5} {name:<25} {filename}:{lineno}")
                            index += 1
                        try:
                            py_frame = py_frame["previous"]
                        except:
                            break
                except:
                    pass
            frame = frame.older()


MyPyBacktrace()
print("Custom command 'my-py-bt' loaded.")

关键点如下:

  • 寻找 C 与 Python 的边界:脚本首先遍历 GDB 获取的 C 语言调用栈 frame = frame.older(),寻找符号 _PyEval_EvalFrameDefault。一旦定位到这个 C 栈帧,我们就能读取其局部变量 _PyInterpreterFrame *frame
  • 解码 _PyStackRef 与指针标记(Pointer Tagging):f_executable 不是一个裸指针,而是一个 _PyStackRef 联合体(Union)。这是Python 3.13 引入的延迟引用计数 (Deferred Refcounting)优化,避免频繁对引用计数进行更新。虚拟机需要知道栈上的某个值到底是“借来的(Borrowed)”还是“拥有的(Owned)”。CPython 没有使用额外的内存来存这个布尔值,而是使用了指针标记(Pointer Tagging)技术,使用地址的低 2 位来实现标记。解码后我们得到PyCodeObject
  • 逆向回溯帧链表:一旦抓住了当前的 Python 帧,脚本便利用 previous 指针向后回溯调用链,直到遇到 FRAME_OWNED_BY_INTERPRETER其他的标记包括FRAME_OWNED_BY_THREAD, FRAME_OWNED_BY_GENERATOR, FRAME_OWNED_BY_FRAME_OBJECT (当使用内省时使用)。。当遇到这种类型的帧时,意味着我们已经到达了当前解释器执行流的底部(例如模块层级或由 C 扩展调用的边界),此时应当停止回溯。

观测工具技术原理分析

观测工具通常被划分为进程内(In-Process)观测与进程外(Out-of-Process)观测。传统的调试器如 pdb 或性能分析器如 cProfile 均属于前者。它们通过 sys.settrace() 等机制挂钩(Hook)到 Python 解释器的执行循环中,但无可避免地引入了显著的“观察者效应”(Observer Effect)——即观测行为本身改变了目标系统的运行时特征。对“零侵入”观测的需求催生了以 PyStack 和 py-spy 为代表的进程外观测技术。

跨进程内存访问机制主要有两种:

  • ptrace:Unix 哲学中用于进程控制和调试的基石。缺点是传统的 ptrace(PTRACE_PEEKDATA,...) 操作一次只能读取一个字(Word,通常为 4 或 8 字节)的数据 ,同时采用的是 “停止-世界”(Stop-the-World)的方法。
  • process_vm_readv(引入于 Linux 3.2):允许调用者构建一个包含多个远程地址段的 iovec 向量,内核会在一次系统调用中完成所有这些分散内存段的读取,并将它们填充到观测者进程的连续缓冲区中。该方法不保证原子性。

目标 Python 进程仅仅是操作系统眼中的一堆内存页,它不会主动告诉外部观测者“这里是解释器状态”。因此,观测工具必须通过以下两种途径之一来定位关键数据结构(如 PyInterpreterState_PyRuntime):

  • 依赖二进制文件(ELF)中的符号表(Symbol Table)或调试信息(DWARF)。这是 PyStack 处理 Core Dump 时的主要手段 。
  • 在符号表被剥离(Stripped)的情况下,扫描内存段(如 BSS 段),通过特征匹配来猜测解释器结构的位置。这是 py-spy 的主要手段。

PyStack

PyStack 通过解析二进制文件中的 DWARF 信息实现了混合显示 C 栈和 Python 栈。DWARF 标准定义了 .debug_info(类型信息)和 .debug_frame(栈帧回溯信息)在现代编译器优化(如 -fomit-frame-pointer)下,栈帧不再通过简单的帧指针rbp相连。需要利用 DWARF 中定义的 CFI(Call Frame Information)状态机程序。。在分析运行中的进程时,PyStack 面临数据竞争(Data Race)的风险。PyStack 采取了较为保守的策略:短暂地使用 ptrace 挂起目标线程,确保在通过 process_vm_readv 抓取关键结构时,内存状态是静止的。

PyStack 的一大杀手锏是能够打印栈帧中的局部变量和参数 。PyStack 内部实现了一套 Python 对象解码器。它读取远程内存中的 PyObject 数据结构,通过 ob_type 字段判断对象类型。

py-spy

py-spy 旨在以极低的开销进行持续采样,生成火焰图(Flame Graph)或实时拓扑视图(Top View)。

针对无符号表的情况,py-spy 引入了一套 .bss 启发式扫描算法,找出潜在的 PyInterpreterState 指针。

除了记录“正在运行什么函数”,py-spy 还能洞察“线程是否持有 GIL”。这是通过直接读取 _PyRuntime 结构中的 ceval.gil 字段实现的 。

在 x86_64 架构下,混合栈展开面临 PyFrameObject 指针的丢失的问题PyEval_EvalFrameDefault 的参数中包含了当前的 Python 帧对象。这个参数通常传递给 rdi 寄存器。 然而,rdi 是一个“调用者保存”寄存器。在 PyEval_EvalFrameDefault 函数内部执行期间,编译器可能会复用 rdi 寄存器来存储其他临时变量。这意味着当 py-spy 展开到这一层 C 栈帧时,它无法通过简单的寄存器读取来获取当前的 Python 栈帧地址 。py-spy 实现了一系列猜测和扫描帧对象的方法。

总结

这篇文章深入探讨了 CPython 虚拟机的内部求值机制,并系统性地介绍了如何处理和分析包含 Python 逻辑的跨语言(Python/C++)应用崩溃问题。

相信下次生产环境再遇到 Python 相关问题时,你可以从从容容游刃有余。

拓展阅读