Python xxx.py 执行全流程:从源码到机制(CPython 3.11+)
总览:执行的七个阶段
当你在终端敲下 python xxx.py 并按下回车,系统会经历这样的链路:
Shell执行 → OS加载ELF/PE → CPython启动初始化 → 编译(源码→AST→字节码)
→ 字节码执行(ceval循环+自适应优化) → 内存管理(引用计数+GC) → 解释器关闭
下面逐一拆解,每个阶段我都会给出 CPython 3.11+ 的实际源码路径和关键数据结构。
阶段一:进程启动与解释器初始化
1.1 从 Shell 到 main()
python 实际上是一个 ELF(Linux)/PE(Windows)可执行文件,由 Modules/main.c 中的 Py_BytesMain 或 Py_Main 驱动。真正的入口在 Programs/python.c:
// Programs/python.c
int main(int argc, char **argv) {
return Py_BytesMain(argc, argv);
}
这一步会触发操作系统的标准加载流程:内核解析 ELF header,映射 .text/.data/.bss 段,动态链接器(ld.so)解析并加载 libpython3.11.so(如果是动态链接构建)及其依赖的 libc、libm 等。
1.2 三阶段初始化模型
CPython 3.8 之后采用了重构过的初始化 API(PEP 432/587),分为三个阶段,对应 Include/cpython/pylifecycle.h 和 Python/pylifecycle.c:
Pre-Initialization(预初始化):调用 _PyRuntime_Initialize(),设置最基础的运行时状态 _PyRuntimeState(全局唯一,定义在 Include/internal/pycore_runtime.h),此时还不能使用任何 Python 对象,因为内存分配器尚未配置。
Core Initialization:调用 pyinit_core() → pycore_init_runtime(),这一步创建解释器状态 PyInterpreterState 和主线程状态 PyThreadState,初始化内存分配域(pymalloc),创建内建类型系统(PyBaseObject_Type 等核心类型的 PyTypeObject 在此被静态初始化并完成 PyType_Ready()),构建 sys 和 builtins 模块的骨架。
Main Initialization:调用 pyinit_main() → init_interp_main(),完成 sys.path 计算(涉及 site 模块、虚拟环境检测、PYTHONPATH 处理)、标准流(stdin/stdout/stderr)的编码设置、site.py 的执行(除非 -S 参数)、signal handler 安装等。
1.3 关键数据结构:运行时状态层级
_PyRuntimeState (进程级单例,Python/pylifecycle.c中的 _PyRuntime)
└── PyInterpreterState (解释器级,支持多解释器/子解释器,PEP 554/684)
├── PyObject *modules // sys.modules 字典
├── PyTypeObject *types[] // 静态类型表
├── PyGC_Head generations[3] // 三代GC链表
└── PyThreadState *tstate_head
└── PyThreadState (线程级)
├── PyFrameObject *frame // 当前栈帧(3.11后为_PyInterpreterFrame)
├── int recursion_depth
└── _PyErr_StackItem exc_info
这个层级结构是理解 GIL、子解释器隔离、协程栈帧管理的基础。3.11 的一个重要变化是 PyFrameObject 被拆分为轻量级的 _PyInterpreterFrame(栈上分配,避免堆分配开销),只有当 Python 代码显式访问 frame 对象(比如 inspect 模块或异常回溯)时才会"实体化"(materialize)成完整的 PyFrameObject。这是 3.11 性能提升的关键优化之一。
阶段二:编译流程——从文本到字节码
这是整个链路中最复杂的部分,CPython 把它分为五个子阶段,源码主要在 Parser/ 和 Python/compile.c、Python/symtable.c。
2.1 Tokenizer(词法分析)
Parser/tokenizer.c 中的 _PyTokenizer_Get() 把源码字符流切分成 token 流。CPython 3.8+ 使用了新的 PEG(Parsing Expression Grammar)解析器替代了旧的 LL(1) 解析器,tokenizer 本身处理缩进(生成 INDENT/DEDENT 伪 token)、字符串前缀(f-string 在 3.12 前是特殊处理,3.12 起 f-string 有了独立 tokenizer 支持 PEP 701)、行连接符等。
2.2 Parser(语法分析)—— PEG Parser
3.9 起 CPython 完全切换到 PEG 解析器(PEP 617),文法定义在 Grammar/python.gram,通过 Parser/pegen/ 下的生成器在编译 CPython 时生成 Parser/parser.c。PEG 相比传统 LL(1)/LALR 的优势是支持无限回溯(unlimited lookahead)和左递归处理,这让 Python 语法可以更自然地表达(比如海象运算符 :=、模式匹配 match-case 的复杂文法)。
解析器直接构建 AST(抽象语法树),节点类型定义在 Python/Python-ast.c(由 Parser/asdl_c.py 从 Parser/Python.asdl 自动生成)。例如一个简单赋值语句 x = 1 + 2 会生成:
Module(
body=[Assign(
targets=[Name(id='x', ctx=Store())],
value=BinOp(left=Constant(value=1), op=Add(), right=Constant(value=2))
)]
)
你可以用 ast 模块自己验证:ast.dump(ast.parse("x = 1 + 2"))。
2.3 Symbol Table(符号表构建)
Python/symtable.c 遍历 AST,为每个作用域(module/function/class/comprehension)构建 PySTEntryObject,确定每个变量是 local、global、nonlocal、还是 free variable(闭包捕获)。这一步直接决定了后续字节码里变量访问用哪条指令:LOAD_FAST(局部变量,数组索引访问,最快)、LOAD_GLOBAL、LOAD_DEREF(闭包变量,通过 cell object)。这也是为什么 Python 函数内部访问局部变量远快于访问全局变量——本质是数组下标 vs 字典查找的差异。
2.4 CFG 构建与字节码生成
Python/compile.c 中的编译器(struct compiler)把 AST 转换为控制流图(CFG,每个 basicblock 是一组顺序执行的指令),然后做几轮优化:
死代码消除、跳转目标合并、3.11 引入的 compiler_optimize_cfg 会做基本块合并等。最后 assemble() 把 CFG 线性化为最终的字节码序列,打包进 PyCodeObject。
3.11 的 PyCodeObject 相比之前版本新增了 co_exceptiontable(异常处理表,取代了旧版本里每个 try 块对应的 SETUP_FINALLY 指令——见下文"零成本异常处理")和 co_qualname、co_linetable(取代 co_lnotab,支持列级别的精确定位,这是 3.11 更精确报错位置的基础)。
// Include/cpython/code.h(简化)
struct PyCodeObject {
PyObject_VAR_HEAD
PyObject *co_consts; // 常量池
PyObject *co_names; // 全局/属性名称
PyObject *co_exceptiontable;// 异常表(3.11新增)
int co_flags;
int co_argcount;
int co_nlocalsplus; // 局部变量总数(含cell/free变量)
_Py_CODEUNIT *co_code_adaptive; // 实际字节码(可被quickening改写)
...
};
2.5 字节码 .pyc 缓存
如果是 import 而非直接运行的脚本,编译结果会被序列化(marshal 模块)缓存到 __pycache__/xxx.cpython-311.pyc,下次导入时通过 mtime 和源文件哈希判断是否需要重新编译(Lib/importlib/_bootstrap_external.py 中的 SourceFileLoader)。注意:作为 __main__ 直接执行的脚本本身不会生成 .pyc,但它 import 的模块会。
阶段三:字节码执行引擎——3.11 的核心变革
这是你最关心的部分。3.11 引入了 PEP 659 描述的 Specializing Adaptive Interpreter,这是自 CPython 诞生以来对执行引擎最大的一次改造。
3.1 主循环结构:_PyEval_EvalFrameDefault
字节码执行的核心在 Python/ceval.c 的 _PyEval_EvalFrameDefault(),本质是一个巨大的 for(;;) { switch(opcode) {...} },但 3.11 后实际生成方式变了——通过 Python/generated_cases.c.h(由 Tools/cases_generator/ 从 Python/bytecodes.c 的 DSL 描述自动生成)。这种"伪代码生成真实C代码"的方式是 CPython 团队为了后续支持 JIT(3.13 的 Tier 2 JIT 用同一份 DSL 生成)做的架构铺垫。
每条指令的骨架:
TARGET(BINARY_OP) {
PyObject *rhs = stack_pointer[-1];
PyObject *lhs = stack_pointer[-2];
PyObject *res;
// 内联缓存(inline cache)读取
next_instr += 1;
INSTRUCTION_STATS(BINARY_OP);
PREDICTED(BINARY_OP);
_Py_CODEUNIT *this_instr = next_instr - 2;
...
DISPATCH();
}
3.2 自适应解释器的核心机制:Quickening + Inline Caching
这是 3.11 的灵魂所在,分两层:
Quickening(指令淬火):当一个函数被调用超过一定次数(阈值在 _Py_QUICKENING_WARMUP_DELAY,默认很小,几次调用就会触发),通用版字节码会被"特化"替换。例如通用的 LOAD_GLOBAL 在热路径上会被改写为 LOAD_GLOBAL_MODULE 或 LOAD_GLOBAL_BUILTIN,跳过字典查找,直接走缓存的版本号校验。
内联缓存(Inline Cache, IC):每条可特化指令后面会跟随若干个"影子"_Py_CODEUNIT(不是真正的指令,是缓存数据槛位),用于存放类型指针、字典版本号、属性偏移量等。例如 LOAD_ATTR 指令家族:
// Python/bytecodes.c 中的简化伪码(DSL源码)
inst(LOAD_ATTR, (unused/9, owner -- res2 if (oparg & 1), res)) {
#if ENABLE_SPECIALIZATION
if (ADAPTIVE_COUNTER_IS_ZERO(cache->counter)) {
// 走特化逻辑:分析owner的类型,写入对应特化指令
_Py_Specialize_LoadAttr(owner, next_instr, name);
DISPATCH_SAME_OPARG();
}
#endif
// 通用回退路径:常规getattr
}
具体到属性访问场景,一个 obj.attr 会根据 obj 的类型被特化为:
LOAD_ATTR_INSTANCE_VALUE:实例字典里直接偏移读取(最常见的情况,普通对象属性)LOAD_ATTR_SLOT:__slots__定义的属性,直接内存偏移LOAD_ATTR_MODULE:模块级属性LOAD_ATTR_WITH_HINT:字典访问但带版本校验的"hint"加速
如果连续几次执行该指令时类型/状态没变,缓存命中,直接走快路径(几乎是 C 级别的指针解引用);如果类型变了(比如多态调用),会触发 去优化(de-optimization),退回通用版本,这就是"自适应"的含义——解释器在运行时根据实际数据形态动态调整自己的执行策略,本质上是一种轻量级 JIT 的雏形,但仍然是纯解释执行(不生成机器码,3.13 的 Tier 2 才开始有 micro-op 和实验性 JIT)。
3.3 求值栈与帧布局
3.11 把栈帧从堆分配改为栈上连续分配(在 C 调用栈上,作为 _PyInterpreterFrame),多个 Python 帧打包在一个连续的内存块(_PyStackChunk)里,函数调用不再需要单独 malloc 一个 frame 对象,这是函数调用性能提升的主因(官方数据:调用开销降低约 1.3-1.7 倍)。
// Include/internal/pycore_frame.h
typedef struct _PyInterpreterFrame {
PyFunctionObject *f_func;
PyObject *f_globals;
PyObject *f_builtins;
PyObject *f_locals;
PyCodeObject *f_code;
PyFrameObject *frame_obj; // 仅在需要时才实体化
_Py_CODEUNIT *prev_instr;
int stacktop;
bool is_entry;
PyObject *localsplus[1]; // 局部变量+求值栈,柔性数组
} _PyInterpreterFrame;
localsplus 之后紧跟的就是求值栈空间,局部变量和操作数栈在物理内存上是连续的一块,这极大改善了 cache locality。
3.4 零成本异常处理
旧版本(3.10 及之前)用 SETUP_FINALLY 字节码显式地把异常处理块压入一个 block stack,每进入一个 try 块都有运行时开销,即使没有异常发生。3.11 完全移除了这套机制,改用 co_exceptiontable:一张静态的 (起始指令偏移, 结束偏移, 处理器偏移, 栈深度) 范围表。没有异常发生时,try 块的进入和退出零开销(连一条额外指令都不需要);只有异常真正抛出时,才会在 _PyEval_EvalFrameDefault 里查这张表来定位处理器,用二分查找而不是链表遍历,定位效率也更高。
3.5 函数调用机制:CALL 指令族
3.11 重写了整个调用机制,目标是减少"胶水帧"。例如调用一个 Python 写的简单函数时,旧版本要走 C 函数 call_function() → _PyEval_EvalCode() 多层 C 栈,3.11 的 CALL 指令可以在某些情况下直接"内联"被调用帧(inlined call),不需要递归进入新的 C 栈帧,是用一个 goto 跳到新帧的起始指令实现的,省去了 C 函数调用本身的开销。这也是为什么 3.11 makes recursive/调用密集型代码显著加速。
阶段四:对象模型与内存管理
4.1 PyObject 的通用头部
所有 Python 对象的内存布局都以这个结构开头:
// Include/object.h
typedef struct _object {
_PyObject_HEAD_EXTRA // 调试模式下的双向链表指针
Py_ssize_t ob_refcnt; // 引用计数
PyTypeObject *ob_type;// 指向类型对象(决定行为的"虚函数表"角色)
} PyObject;
PyTypeObject 本身极其庞大,包含 tp_alloc、tp_new、tp_dealloc、tp_as_number(数值协议槛位,比如 __add__ 对应的 C 级钩子)等几十个函数指针,这本质上是 C 语言手工实现的"虚函数表+协议"系统,是整个 Python 对象多态性的根基。
4.2 内存分配器:pymalloc 三层架构
CPython 不直接对每个小对象调用 malloc,而是用 Objects/obmalloc.c 实现的 pymalloc,专门优化 512 字节以下的小对象分配:
Arena (256KB,通过mmap/malloc从OS获取)
└── Pool (4KB,对齐到系统页大小)
└── Block (固定大小的小块,8字节对齐,分为多个size class)
每个 Pool 只服务一种 size class(8, 16, 24, … 512字节),内部用空闲链表(freelist)管理,分配/释放是 O(1) 的指针操作。这套机制是 Python 创建大量小对象(比如频繁创建的 int、小元组、小字典)时性能可观的关键原因。3.11/3.12 进一步对常见的小整数(-5 到 256)、空元组、None/True/False 等做了单例化或预分配,完全跳过分配器。
此外 3.12 起引入了 per-object 自由列表/对象池优化(比如 frame、生成器对象的复用),3.11 本身则在浮点数和整数对象上有专门的 freelist。
4.3 引用计数与垃圾回收
每个对象的 ob_refcnt 在赋值、传参、容器存储时通过 Py_INCREF/Py_DECREF 宏维护,计数归零时立即调用对应类型的 tp_dealloc 释放。这是确定性的回收(不像 Java 那种不确定时机的 GC),但无法处理循环引用(比如两个对象互相引用对方)。
为此 CPython 额外实现了分代垃圾回收器(Modules/gcmodule.c),分三代(0/1/2代),新对象进入0代,每次0代满了触发一次0代回收并把存活对象提升到1代,以此类推,代际假设是"存活越久的对象越不可能很快变成垃圾"。GC 只追踪"容器类型"对象(list/dict/自定义类实例等,通过 tp_traverse 协议遍历对象内部引用),原子标量不需要被追踪。检测循环引用的算法本质是基于"标记-清除",通过临时复制引用计数并模拟"如果删除所有来自外部根的引用,还剩多少在引用"的方式来识别孤立环。
值得一提的是 3.11/3.12 引入了 不可变对象的引用计数延迟优化和 PEP 683(Immortal Objects,3.12 起正式生效)的雏形工作——让 None、True、False、小整数等"永生对象"的引用计数被设为一个特殊的最大值,永远不会真正递减到0,从而省去了对这些超高频访问对象做原子操作的开销,这对未来的 No-GIL(PEP 703,3.13 实验性支持)也是必要的前置工作。
4.4 GIL(全局解释器锁)
3.11 的 GIL 实现在 Python/ceval_gil.c,本质是一个带有"切换间隔"(sys.setswitchinterval(),默认5ms)的协作式锁,字节码执行循环会周期性检查 eval_breaker 标志位(这是一个原子整数,多个条件会触发它:信号到达、其他线程请求GIL、异步异常等),决定是否让出 GIL。3.11 优化了 GIL 的争抢公平性算法,减少了之前版本中"饥饿线程"的问题。
阶段五:模块导入机制
import 语句触发 Lib/importlib/_bootstrap.py 中的 _find_and_load(),核心流程:
Finder 阶段:遍历 sys.meta_path 中的查找器(默认包含 BuiltinImporter、FrozenImporter、PathFinder),PathFinder 遍历 sys.path 中每个目录,对每个路径用对应的 FileFinder 配合一系列 Loader(SourceFileLoader、SourcelessFileLoader、ExtensionFileLoader)尝试匹配文件。
Loader 阶段:找到模块文件后,SourceFileLoader.exec_module() 读取源码或 .pyc 缓存,走前面讲的编译流程得到 PyCodeObject,然后用 PyEval_EvalCode() 在新建的模块命名空间(一个全新的 dict 作为 __dict__)里执行这段字节码,模块级的所有顶层语句在此时真正运行。
缓存:执行完毕的模块对象被塞进 sys.modules 字典,下次 import 同名模块直接复用,不会重新执行。这也解释了为什么模块级代码只在首次 import 时跑一次。
C 扩展模块(.so/.pyd)走 ExtensionFileLoader,通过 dlopen/LoadLibrary 动态加载,调用其 PyInit_<modulename> 入口函数,这个函数返回一个通过 PyModuleDef 描述的模块对象——这是 CPython 和 C/C++ 扩展之间的标准 ABI 契约。
阶段六:解释器关闭
脚本执行完毕(或抛出未捕获异常)后,Py_RunMain() 调用 Py_FinalizeEx(),这一步会:等待非守护线程、执行所有通过 atexit 注册的清理函数、按依赖反序清理模块(实际上 CPython 并不保证完美的析构顺序,这也是为什么有时候解释器关闭阶段会看到奇怪的 AttributeError: NoneType)、释放所有解释器状态、最后做一次完整的垃圾回收并销毁主线程状态。
接下来我会把以上内容整理成一份结构化的技术文档,包含完整的源码路径索引和分阶段流程图,方便你保存和后续检索。考虑到你的工作风格(之前的输出多是 Markdown 技术文档,比如 IPTV 案例分析、NIC 架构文章),这次我会用 Markdown 而不是 Word 来生成——内容包含大段代码块和层级结构,Markdown 渲染效果更好,也方便你后续用编辑器/Git 做版本管理或贴进笔记系统。现在开始生成文档。文档涵盖了七大阶段的完整链路,并附带源码路径索引表,方便你按需跳转到 CPython 仓库对应文件深挖。
如果接下来想针对某一环节继续深入(比如你之前研究 Windows tcpip.sys 内核时用过 capstone 反汇编静态分析,类似手法也可以用在 CPython 字节码特化机制上——用 dis 模块对比 quickening 前后的指令差异,或者直接 gdb attach 到 _PyEval_EvalFrameDefault 观察 inline cache 的实际写入过程),可以告诉我具体方向,我可以再做一轮源码级或实验级的深挖。

9076

被折叠的 条评论
为什么被折叠?



