继续写一篇关于 Python 的内容吧,虽然可能也没什么人关心,但我常用这个作为面试题目来和候选人聊。 这篇里面用的是 Python v3.5.3 版本。
提到 Python 大家都知道 Python 是解释型语言,很多人也喜欢顺手写个 Python 脚本来处理事情。
但是当我们执行 python xxx.py 之后,到底具体发生了什么?
总体来看差不多就是:
- 进程入口
- 参数解析
Py_Initialize()sys.path和 import machinery 建立- 打开脚本文件
- 源码解码、词法分析、语法分析
- AST 编译成
PyCodeObject - 创建
__main__模块和PyFrameObject - 进入
PyEval_EvalFrameEx() - 最终
Py_Finalize()
这样的一条链路。
如果你之前看过我写的文章,那应该知道我的习惯,我会尽量沿着真实控制流一路往下,跟着程序运行的过程走总是没错的。
先看看整体调用链是什么
如果把 python hello.py 的主路径压成一条链,大概是这样:
Programs/python.c::main
-> Modules/main.c::Py_Main
-> Python/pylifecycle.c::Py_Initialize
-> Python/sysmodule.c::PySys_SetArgv
-> Modules/main.c::run_file
-> Python/pythonrun.c::PyRun_AnyFileExFlags
-> Python/pythonrun.c::PyRun_SimpleFileExFlags
-> Python/pythonrun.c::PyRun_FileExFlags
-> Python/pythonrun.c::PyParser_ASTFromFileObject
-> Parser/parsetok.c::PyParser_ParseFileObject
-> Python/ast.c::PyAST_FromNodeObject
-> Python/compile.c::PyAST_CompileObject
-> Python/ceval.c::PyEval_EvalCode
-> Python/ceval.c::_PyEval_EvalCodeWithName
-> Objects/frameobject.c::PyFrame_New
-> Python/ceval.c::PyEval_EvalFrameEx
-> Python/pylifecycle.c::Py_Finalize
看起来好像也不是很复杂对不对,我还想更具体展开一些细节:
Py_Initialize()到底初始化了什么__main__是什么时候出现的sys.path[0]又是什么时候插进去的python xxx.py会不会直接用__pycache__里的 pyc- 顶层脚本到底是不是通过 importlib loader 执行的
- 一段源码是怎么一步步变成
PyCodeObject的 PyCodeObject又是怎么进入ceval的
举个例子
import math
def add(a, b):
return a + b
print(add(1, 2))
print(math.sqrt(9))
这个例子很简单,但它足够覆盖三类典型行为:
- 顶层模块代码执行
- 模块倒入
- 自定义函数的调用
接下来我会沿着 python hello.py 的真实执行顺序,把这三件事都串起来。
第一阶段,进程入口,先从 main() 开始
在 Unix/macOS 常见路径上,入口在 Programs/python.c::main()。
它做的第一件事并不是立刻执行 Python,而是先把 C 世界里的 char **argv 转成宽字符参数 wchar_t **argv,这里会调用 Py_DecodeLocale()。之后才进入真正的 Python 入口:
Py_Main(argc, argv_copy)
也就是 Modules/main.c::Py_Main()。
这个设计的意义很直接,后面的解释器初始化、路径计算、文件名处理,都会统一使用宽字符参数。
第二阶段,Py_Main() 先处理命令行,但它不是只扫一遍
Modules/main.c::Py_Main() 很有意思的一点是,它对参数做了两遍处理。
第一遍的目标很明确,只尽早确认一些会影响后续行为的选项,例如:
-E,是否忽略环境变量-c, 执行代码串-m,执行模块
而且在很早的时候,它还会调用 _PyRandom_Init(),这样 hash randomization 可以在更靠前的位置生效。
第二遍才是真正设置各种全局 flag,例如:
Py_NoSiteFlagPy_DontWriteBytecodeFlagPy_OptimizeFlagPy_InspectFlagPy_IgnoreEnvironmentFlag
然后再去读环境变量,比如 PYTHONINSPECT、PYTHONWARNINGS、PYTHONUNBUFFERED 等。这里也顺便说明一下,PYTHONPATH 虽然同样会影响最终行为,但它不属于这一段参数/环境处理逻辑本身,而是后面 Py_GetPath() 计算路径时才会参与进来。
如果既不是 -c,也不是 -m,当前参数又不是 -,那么它就会把这个参数视为脚本文件:
filename = argv[_PyOS_optind];
也就是说,对 python hello.py 这种最普通的执行方式而言,真正的脚本路径就是在这里确定下来的。
第三阶段,脚本还没跑,解释器已经初始化了一大堆东西
很多人以为 python hello.py 就是“打开文件然后解释执行”,这其实只描述了最后一小段。
在打开脚本文件之前,CPython 已经完成了一轮很重的 bootstrap。
在 Py_Main() 中,会先调用:
Py_SetProgramName(argv[0]);
Py_Initialize();
真正的初始化逻辑在 Python/pylifecycle.c::_Py_InitializeEx_Private() 里。
这部分顺序非常关键,大体上会做这些事:
- 创建
PyInterpreterState和PyThreadState - 初始化基础对象类型和核心运行时子系统
- 创建 builtins 模块
- 创建 sys 模块
- 计算
sys.path - 初始化 import 机制
- 初始化文件系统编码
- 创建
__main__ - 初始化标准输入输出
- 导入
site
这几个阶段如果拆开看,会发现不少很有意思的细节。
第四阶段,sys.path 是什么时候算出来的
Py_Initialize() 过程中会调用:
PySys_SetPath(Py_GetPath())
而 Py_GetPath() 的核心逻辑在 Modules/getpath.c::calculate_path()。
它会综合考虑:
argv[0]PYTHONHOMEPYTHONPATH- 编译期的
PREFIX/EXEC_PREFIX pyvenv.cfg- 当前可执行文件所在位置
最后算出:
module_search_pathprefixexec_prefixprogpath
然后再挂到 sys.path 等相关位置上。
所以 sys.path 不是 import 的副产品,它在解释器真正执行脚本之前就已经被搭好了主体骨架。
第五阶段,import 主体机制也在脚本运行前就已经装好了
这是整个启动阶段里非常重要的一段。
在 Python/pylifecycle.c::import_init() 中,CPython 会先导入 frozen module _frozen_importlib,然后初始化 _imp,再调用:
_frozen_importlib._install(sys, _imp)
对应逻辑在 Lib/importlib/_bootstrap.py::_install()。
它会把:
BuiltinImporterFrozenImporter
塞进 sys.meta_path。
随后还会继续导入 _frozen_importlib_external,对应 Lib/importlib/_bootstrap_external.py::_install(),这一层会继续把:
FileFinder.path_hook(...)PathFinder
这些和文件系统导入相关的能力装到 sys.path_hooks 和 sys.meta_path 里。
也就是说,当我们的脚本后面第一次执行 import math 时,导入系统并不是“临时现搭的”,而是在 Py_Initialize() 期间就已经准备好了。
第六阶段,encodings、io、site 都是在脚本前导入的
这里还有个很多人可能忽略,但是很重要的东西,那就是 encodings,我之前写过一篇关于 Python 和中文相关的文章,当然很多人也会在 Python 里面遇到需要处理中文的情况,尤其是从 Python2 到 Python3.
encodings
文件系统编码初始化会触发 codec registry 的建立,而这又会导入 encodings。相关逻辑在:
Python/pylifecycle.c::initfsencoding()Python/codecs.c::_PyCodecRegistry_Init()
所以 encodings 并不是“你 import 了才有”,它是解释器自身启动过程的一部分。
io
在 Python/pylifecycle.c::initstdio() 里,CPython 会导入 io,并把合适的 open 包装器挂到 builtins。
site
只要没有使用 -S,Py_Initialize() 期间就会执行 initsite(),也就是导入 site。
这意味着一件很容易被忽略的事:
在脚本还没执行之前,site、sitecustomize、usercustomize 的副作用就已经可能发生了。
第七阶段,__main__ 在脚本打开前就已经存在了
这是我觉得最值得记住的一个点。
在 Python/pylifecycle.c::initmain() 里,CPython 会执行:
PyImport_AddModule("__main__")
也就是说,sys.modules["__main__"] 在脚本文件还没被打开的时候,就已经创建好了。
并且它还会给这个模块准备好:
__builtins__- 初始的
__loader__
所以从运行时视角来看,顶层脚本代码不是在一片真空里执行,而是即将被放进一个已经存在的 __main__ 模块字典中运行。
第八阶段,sys.argv 和 sys.path[0] 是在初始化之后才设置的
Py_Initialize() 结束后,Py_Main() 才会调用:
PySys_SetArgv(argc-_PyOS_optind, argv+_PyOS_optind);
这一步会做两件关键的事:
- 设置
sys.argv - 更新
sys.path[0]
而更新 sys.path[0] 的逻辑在 Python/sysmodule.c::sys_update_path() 里,通常会把脚本所在目录插到 sys.path[0]。
这有个非常容易忽略的副作用:
site 导入发生在 PySys_SetArgv() 之前。
也就是说,启动阶段的一部分导入,并不一定能看到最终脚本目录已经出现在 sys.path[0]。
第九阶段,真正打开脚本前,CPython 还会先试一次“导入式执行”
这一步很多人平时完全不会注意到。
在普通文件打开之前,Py_Main() 先调用:
RunMainFromImporter(filename)
对应 Modules/main.c::RunMainFromImporter()。
它会先检查 argv0 能不能被当作 import source 使用,比如它可能是一个目录,或者一个 zip 文件。
如果是这种情况,CPython 不会走普通脚本文件分支,而是会把它加入 sys.path[0],然后去运行 __main__。
这也是为什么像 python some.zip 这种用法能够成立。
而对于普通的 python hello.py,这一步通常会失败,然后回退到真正的文件执行路径。
第十阶段,脚本文件终于被打开了
回退到普通文件路径后,Py_Main() 会调用 _Py_wfopen(filename, L"r") 打开文件。
然后进入:
run_file(fp, filename, &cf)
也就是 Modules/main.c::run_file(),再继续调用:
PyRun_AnyFileExFlags()
对于普通脚本文件,这一路最终会落到:
PyRun_SimpleFileExFlags()
这里会继续准备 __main__ 相关元数据,例如:
__file____cached____loader__
其中有个很重要但很容易误解的点:
这里虽然会给 __main__ 设置 SourceFileLoader("__main__", filename) 这类信息,但普通 python xxx.py 的执行主路径,并不是通过 importlib loader 去驱动的。
真正执行脚本的仍然是后面的 C 路径。
第十一阶段,先判断它是不是 pyc
在 PyRun_SimpleFileExFlags() 里,CPython 会先调用 maybe_pyc_file()。
这一步会检查:
- 扩展名是不是
.pyc - 或者当前文件流开头的 magic bytes 看起来像不像 pyc
如果命中,就直接进入 run_pyc_file(),读取 marshaled code object 然后执行。
如果没有命中,才会继续把它当源文件处理。
这里顺便说一个特别关键的结论:
对于 python hello.py 这种最普通的脚本执行路径,CPython 不会主动去查找旁边 __pycache__ 里的 pyc 来跳过编译。
也就是说:
python hello.py,通常还是现读源码、现编译、现执行- pyc cache 的主要收益,更多体现在 import 路径上,而不是顶层
__main__直跑路径上
这里我就很想留个疑问,在 Python2 的时候,你觉得是不是这样呢?
第十二阶段,从源码文本到 tokenizer
源文件路径最终会进入:
PyRun_FileExFlags(...)
再调用:
PyParser_ASTFromFileObject(...)
这一步并不是直接“把整个文件读成字符串再 parse”。
它后面会继续调用:
Parser/parsetok.c::PyParser_ParseFileObject()Parser/tokenizer.c::PyTokenizer_FromFile()
真正处理编码和逐行读取的,是 tokenizer。
在 Parser/tokenizer.c 里,它会检查:
- 有没有 BOM
- 前两行有没有
coding:声明 - 如果没有编码声明,源码是否是合法 UTF-8
所以源码从一开始就不是“普通文本”,而是带着 Python 自己的源码解码规则进入词法分析器的。
第十三阶段,先有 parse tree,再有 AST
这一步也常被描述得过于粗糙。
很多文章会直接说“源码会被解析成 AST”,但在 CPython 3.5.3 里,真实过程是两段:
- tokenizer + parser 先生成 parser tree,也可以理解为更接近 concrete syntax 的结构
- 然后再转成 AST
这条链路大致是:
PyParser_ParseFileObject
-> parsetok()
-> PyParser_AddToken()
-> parser tree
-> PyAST_FromNodeObject()
其中 AST 转换入口在 Python/ast.c::PyAST_FromNodeObject()。
所以从源码到 AST,中间其实还隔着一层更接近语法产物本身的结构。
第十四阶段,从 AST 到 PyCodeObject
拿到 AST 后,执行流程会进入 Python/pythonrun.c::run_mod()。
这里是一个非常关键的分界点:
co = PyAST_CompileObject(mod, filename, flags, -1, arena);
v = PyEval_EvalCode((PyObject*)co, globals, locals);
前一句负责“编译”,后一句负责“执行”。
编译阶段做了什么
PyAST_CompileObject() 主要会做几件事:
PyFuture_FromASTObject(),提取 future flagsPySymtable_BuildObject(),构建符号表compiler_mod(),开始真正代码生成assemble()makecode()PyCode_New()
最终得到 PyCodeObject。
这个对象里会固化编译结果中最关键的执行信息:
co_codeco_constsco_namesco_varnamesco_cellvarsco_freevarsco_stacksizeco_flags
也就是说,到了这里,源码才真正从“语言文本”变成了“虚拟机执行契约”。
第十五阶段,顶层脚本代码在哪个模块里执行
前面 PyRun_SimpleFileExFlags() 拿到的是 __main__ 模块对象。
执行时传进去的 globals 和 locals,本质上都是:
sys.modules["__main__"].__dict__
这意味着:
- 顶层
def add(...)会把add绑定到__main__.__dict__ - 顶层
import math也会把math绑定到__main__.__dict__ - 顶层
print(...)查名字时,同样以这个字典为主要作用域
所以脚本执行的本质并不是“在文件里执行”,而是:
把这个文件编译出来的 PyCodeObject,放进 __main__ 这个模块命名空间里执行。
第十六阶段,PyCodeObject 如何变成真正运行的 frame
run_mod() 调用的是:
PyEval_EvalCode((PyObject*)co, globals, locals)
然后会顺着进入:
PyEval_EvalCode
-> PyEval_EvalCodeEx
-> _PyEval_EvalCodeWithName
-> PyFrame_New
-> PyEval_EvalFrameEx
这里最关键的是 Objects/frameobject.c::PyFrame_New()。
它会创建 PyFrameObject,并把这些状态塞进去:
f_codef_globalsf_localsf_builtinsf_lastif_iblock
其中 f_builtins 会优先从 globals["__builtins__"] 来,而前面 initmain() 已经确保了 __main__ 拥有这部分内容。
也就是说,顶层脚本 frame 在创建时,已经天然知道:
- 自己属于哪个 code object
- 自己的全局名字空间是谁
- builtins 从哪来
第十七阶段,f_localsplus 是 frame 里最重要的一块内存
这一步虽然已经属于 VM 内部细节,但我特别想补充几句。
在 Include/frameobject.h 里,PyFrameObject 的尾部定义了:
PyObject *f_localsplus[1];
它不是普通数组,而是变长尾部数组。
在 PyFrame_New() 中,CPython 会按如下布局分配一整块连续内存:
| fast locals | cellvars | freevars | value stack |
也就是说,这一块空间同时承载:
- 局部变量
- 闭包相关 cell/free var
- value stack
这就是为什么后面 LOAD_FAST 这类指令会非常快,本质上就是直接做数组槽位访问。
所以很多人说 CPython 是个 stack VM,这当然没错,但更准确一点的说法应该是:
它是一个围绕 frame 连续内存布局展开实现的 stack VM。
第十八阶段,终于进入 PyEval_EvalFrameEx()
到了 Python/ceval.c::PyEval_EvalFrameEx(),脚本才真正开始以 opcode 形式运行。
对于我们的例子来说,顶层模块代码大致会做这些事:
- 执行
import math - 创建函数对象
add - 调用
print(add(1, 2)) - 调用
print(math.sqrt(9))
这里有两层执行值得分开看。
顶层模块代码执行
顶层代码本身是模块级 code object,它也会进入 PyEval_EvalFrameEx()。
def add(...) 不是立刻执行函数体
函数定义语句执行时,不会立刻跑 return a + b。
它只是:
- 先从常量表里取出函数对应的 code object
- 然后通过
MAKE_FUNCTION - 构造出
PyFunctionObject - 最后绑定到
__main__.__dict__["add"]
真正调用 add(1, 2) 的时候,才会再创建一个新的 frame。
第十九阶段,import math 到底怎么触发 importlib
当顶层模块代码执行到 IMPORT_NAME 时,并不是某个“特殊魔法路径”直接把模块塞进来。
在 Python/ceval.c::TARGET(IMPORT_NAME) 里,解释器会从当前 frame 的 builtins 中取出 __import__,再以普通函数调用的方式去调用它。
也就是说,import 在执行期其实仍然服从 Python 运行时的一般调用模型。
而那个真正干活的 __import__,在 3.5.3 里首先会进入 C 层的 PyImport_ImportModuleLevelObject()。这部分逻辑已经把相当一部分 importlib 语义直接搬到了 C 里,但在真正查找和加载模块时,仍然会接回前面 Py_Initialize() 阶段已经安装好的 frozen importlib 体系上。
所以这条路径是:
顶层字节码 IMPORT_NAME
-> builtins.__import__
-> PyImport_ImportModuleLevelObject()
-> installed importlib bootstrap objects
-> sys.meta_path / PathFinder / FileFinder
这也说明了一个更本质的事实:
import 不是编译器行为,而是运行时行为。
第二十阶段,add(1, 2) 是怎么执行的
当顶层代码执行到 CALL_FUNCTION 时,解释器会进入 Python/ceval.c::call_function()。
这里会根据被调用对象类型做分流:
- C 函数走一条路径
- bound method 走一条路径
- 普通 Python 函数走
fast_function()
对于 add(1, 2) 这种最普通的 Python 函数,通常会进入 fast_function()。
它的思路非常直接:
- 创建一个新的
PyFrameObject - 把参数拷进
f_localsplus - 进入
PyEval_EvalFrameEx()
于是函数体对应的 code object 才真正开始执行。
如果只看核心 opcode,大概就是:
LOAD_FAST a
LOAD_FAST b
BINARY_ADD
RETURN_VALUE
这里最值得记住的一点是:
顶层脚本代码和函数体代码,本质上都走同一套执行器。区别只是它们对应的 code object、globals/locals、frame 场景不同。
第二十一阶段,为什么 python xxx.py 和 import 一个模块不是一回事
这经常会被当作是个误区。
很多人会把“执行脚本”和“导入模块”混为一谈,但在 CPython 3.5.3 里,两者虽然最后都能落到 PyCodeObject + frame + ceval 这条链上,中间路径并不相同。
python xxx.py
主路径是:
Py_Main()PyRun_SimpleFileExFlags()PyRun_FileExFlags()run_mod()
它主要是 C 层直接驱动。
import foo
主路径是 importlib machinery 驱动:
PathFinderFileFinderSourceFileLoaderget_code()- pyc cache 检查
- 需要时重新编译
也正因此,pyc cache 的标准使用场景主要发生在 import 流程里。
换句话说:
__main__ 直跑路径和普通模块导入路径,并不是同一条路,只是最后都接到了 VM 执行器上。
第二十二阶段,.pyc 到底什么时候有用
既然前面提到了这个点,这里顺便展开一下。
这一段我想单独写得更细一点,因为 pyc cache 是关于 Python 启动过程里最容易讲错的一个环境。
很多人脑子里的模型是这样的:
第一次运行
foo.py时生成 pyc,第二次运行foo.py时直接加载 pyc,所以第二次更快。
这个说法只说对了一半,而且更准确地说,它主要适用于 import 路径,不适用于普通的 python foo.py 顶层脚本直跑路径。
我们分几种情况来看。
情况一,只运行单个 hello.py
假设我们只有一个文件:
print("hello")
执行:
python hello.py
此时主路径是:
Py_Main()
-> PyRun_SimpleFileExFlags()
-> maybe_pyc_file()
-> PyRun_FileExFlags()
-> PyParser_ASTFromFileObject()
-> PyAST_CompileObject()
-> PyEval_EvalCode()
这里最关键的是,Python/pythonrun.c::PyRun_SimpleFileExFlags() 并不会去调用:
Lib/importlib/_bootstrap_external.py::cache_from_source()_validate_bytecode_header()_code_to_bytecode()SourceFileLoader.set_data()
也就是说,对普通的 python hello.py 而言:
- 第一次运行,不会去读
__pycache__/hello.cpython-35.pyc - 第一次运行,也不会给这个
hello.py写 pyc - 第二次运行,仍然不会去读
__pycache__/hello.cpython-35.pyc - 第二次运行,还是重新走源码解析、AST、编译、执行
换句话说:
如果你直接执行的是 python hello.py,那么“第二次运行因为 pyc cache 而更快”这件事,对这个 hello.py 本身通常并不成立。
为什么会这样
因为 python hello.py 走的是 __main__ 直跑路径,核心入口是 PyRun_FileExFlags(),不是 importlib 的 SourceLoader.get_code()。
而标准 pyc cache 逻辑,就在 Lib/importlib/_bootstrap_external.py::SourceLoader.get_code() 里。
所以这两条路径虽然最后都会到 PyCodeObject -> PyEval_EvalCode() -> PyEval_EvalFrameEx(),但前半段完全不是同一套机制。
情况二,main.py 导入了 foo.py
这个场景才是 pyc cache 真正发挥作用的典型路径。
假设有两个文件:
main.py
import foo
print("main")
foo.hello()
foo.py
def hello():
print("hello from foo")
现在执行:
python main.py
这里会同时发生两件事:
main.py作为__main__直跑foo.py作为普通模块被 import
而这两件事的 pyc 行为是不同的。
main.py 的行为
仍然和前一种情况一样:
- 不会主动去
__pycache__找main.cpython-35.pyc - 不会自动给
main.py写 pyc
foo.py 的行为
这时 foo.py 会通过 importlib machinery 进入 SourceLoader.get_code(),于是 pyc cache 机制就开始工作了。
第二十二阶段之一,第一次 import foo,没有 pyc 时发生了什么
第一次执行 python main.py 时,如果还没有 __pycache__/foo.cpython-35.pyc,那么 foo 的导入会大致沿着这条路径走:
IMPORT_NAME
-> builtins.__import__
-> PyImport_ImportModuleLevelObject()
-> installed importlib bootstrap objects
-> PathFinder.find_spec()
-> FileFinder.find_spec()
-> SourceFileLoader.get_code()
接下来,Lib/importlib/_bootstrap_external.py::SourceLoader.get_code() 会做这些事:
source_path = self.get_filename(fullname)bytecode_path = cache_from_source(source_path)st = self.path_stats(source_path),拿到mtime和size- 尝试
self.get_data(bytecode_path)去读 pyc - 因为第一次没有 pyc,这里会抛
OSError - 然后退回读源码
self.get_data(source_path) - 调
self.source_to_code(source_bytes, source_path) - 得到
code_object - 如果允许写回,就把 pyc 写到
__pycache__
也就是说,第一次 import 时,“没有 pyc”并不是错误,它只是让 loader 自然回退到源码编译路径。
pyc 文件名是怎么来的
这里使用的是 Lib/importlib/_bootstrap_external.py::cache_from_source()。
在 3.5.3 中:
- cache 目录固定是
__pycache__ - tag 来自
sys.implementation.cache_tag sys.implementation.cache_tag对应cpython-35
所以普通情况下:
foo.py
-> __pycache__/foo.cpython-35.pyc
如果使用优化选项:
python -O,生成foo.cpython-35.opt-1.pycpython -OO,生成foo.cpython-35.opt-2.pyc
pyc 里到底存了什么
在 Lib/importlib/_bootstrap_external.py::_code_to_bytecode() 里,CPython 会把 pyc 写成这样:
[0:4] MAGIC_NUMBER
[4:8] mtime
[8:12] source_size
[12:] marshal.dumps(code_object)
这里的 MAGIC_NUMBER 在 3.5.3 中是:
MAGIC_NUMBER = (3351).to_bytes(2, 'little') + b'\r\n'
这个版本使用的还是非常经典的:
magic + timestamp + size + marshaled code object
这也说明一个经常被忽略的事实:
pyc 缓存的不是 AST,也不是源码文本,而是已经编译好的 code object。
pyc 是怎么写回去的
第一次 import 成功后,SourceLoader.get_code() 会在满足条件时调用:
_code_to_bytecode()self._cache_bytecode()SourceFileLoader.set_data()
SourceFileLoader.set_data() 会:
- 逐级创建缺失的目录
- 调
_write_atomic(path, data, mode)
而 _write_atomic() 的逻辑也很直接:
- 先写入一个临时文件
- 写完后用
os.replace()原子替换目标文件
这说明 pyc 写入是尽量原子化的,同时如果权限不足、目录不可写、或者别的进程竞争写入,import 本身仍然不应该失败。最坏的情况通常只是:
- 模块成功导入
- 但 pyc 没写成
第二十二阶段之二,第二次 import foo,已有 pyc 时发生了什么
现在我们第二次执行:
python main.py
这一次:
main.py仍然作为__main__重新编译执行foo.py则很有机会命中第一次留下的__pycache__/foo.cpython-35.pyc
这时 SourceLoader.get_code() 会再次尝试:
data = self.get_data(bytecode_path)
如果读到了 pyc,接下来不会立刻信任它,而是先进入:
_validate_bytecode_header(data, source_stats=st, ...)
它会校验什么
Lib/importlib/_bootstrap_external.py::_validate_bytecode_header() 会拆出:
magic = data[:4]raw_timestamp = data[4:8]raw_size = data[8:12]
然后做几类检查:
magic对不对- header 有没有截断
mtime是否与当前源码匹配size是否与当前源码匹配
只有都通过,才会把 data[12:] 这部分真正交给 _compile_bytecode()。
命中 cache 后会发生什么
_compile_bytecode() 会:
marshal.loads(data),把 pyc 里的 code object 反序列化出来- 如果是 source-backed import,再调用
_imp._fix_co_filename(code, source_path)
也就是说,第二次 import 成功命中 pyc 后,它直接跳过了源码读取、词法分析、语法分析、AST 构造、编译这些步骤。
这时第二次运行真正省下来的,是被 import 的模块那部分编译成本。
所以如果你执行的是:
python main.py
第二次运行时更可能复用的是:
foo.pybar.py- 其他被 import 的模块
而不是顶层 main.py 本身。
第二十二阶段之三,源码改了之后会怎样
如果 foo.py 改了,那么下一次 import 时,header 校验就可能失败。
在 3.5.3 里,stale pyc 的判定主要依赖:
int(st['mtime'])st['size'] & 0xFFFFFFFF
也就是说,这个版本还不是后来的 hash-based pyc,而是 timestamp/size 驱动的校验逻辑。
一旦 _validate_bytecode_header() 发现:
- magic 不匹配
- mtime 不匹配
- size 不匹配
- header 被截断
那它就会抛 ImportError 或 EOFError。
而 SourceLoader.get_code() 对这些异常的处理方式非常务实:
- 吞掉异常
- 回退到源码读取和重新编译
- 如果允许写回,再把新的 pyc 覆盖写回去
所以对于 source-backed import,更准确的说法应该是:
如果 pyc 缺失、过旧、header 不合法,或者 header 被截断,通常都不是致命问题,因为 loader 会回退到源码重新编译。 但这句话也不能无限泛化,如果 pyc 的 header 已经通过校验,后面的 marshaled payload 自身坏掉了,那就不一定还能优雅 fallback 了;而如果你直接执行的是 python foo.pyc,那更不会回源码。
第二十二阶段之四,-B、-O、python foo.pyc 分别意味着什么
-B
-B 对应 Py_DontWriteBytecodeFlag,暴露到 Python 层就是 sys.dont_write_bytecode。
但有个很容易讲错的点:
-B 只禁止写 pyc,不禁止读 pyc。
从 SourceLoader.get_code() 的逻辑就能看出来,sys.dont_write_bytecode 只出现在“写回 pyc”的 if 分支里,读取和校验已有 pyc 的逻辑并不会因为 -B 而关闭。
-O / -OO
优化级别一方面影响编译行为,另一方面也会影响 pyc 文件名:
-O->.opt-1.pyc-OO->.opt-2.pyc
因此不同优化级别下,缓存文件本身也是分开的。
python foo.pyc
如果直接执行的是:
python foo.pyc
这就不是 import cache 的故事了,而是 Python/pythonrun.c::run_pyc_file() 的故事。
它会:
- 读取 magic
- 跳过 mtime 和 size
- 直接
PyMarshal_ReadLastObjectFromFile() PyEval_EvalCode()
注意这和 SourceLoader.get_code() 很不一样:
- 它不会去比对真实源码的 mtime/size
- 它不会 fallback 到源码
- 它也不会帮你修正 source-backed import 那种
co_filename
所以:
直接跑 .pyc,和通过 import 命中 __pycache__,是两件相似但不相同的事。
小结一下就是:
第一次和第二次执行 python main.py 时,真正可能被 pyc cache 加速的,通常不是 main.py 这个顶层脚本本身,而是它 import 进来的那些普通模块。
再说得更极端一点:
- 只有
hello.py一个文件时,第二次运行通常并不会因为 pyc cache 获得想象中的收益 - 但一旦项目里开始有越来越多的 import,pyc cache 的意义就会迅速放大
这也是为什么 pyc cache 对中大型项目更有感知,而对一段单文件脚本往往没那么显著。
第二十三阶段,程序跑完之后,解释器还要收尾
脚本执行完成后,Py_Main() 最终会调用:
Py_Finalize()
对应 Python/pylifecycle.c::Py_Finalize()。
这里会做很多收尾工作,例如:
- 等待线程退出
- 调用
atexit相关逻辑 - flush 标准流
- 做一轮 GC
- 清理 import 系统
- 清理解释器状态
- 销毁各种对象子系统
如果脚本里抛的是未捕获的 SystemExit,也不是直接粗暴 exit(),而是会先走 Py_Exit(),内部依然会先调用 Py_Finalize()。
所以 CPython 的退出路径也不是简单“进程结束”,它仍然努力维持一个相对有序的解释器生命周期。
总结
现在如果再回到开头的问题:
当执行
python xxx.py时,CPython 到底做了什么?
我会这么说:
先由 main() 进入 Py_Main(),完成参数处理和解释器初始化,建立 sys、builtins、importlib、sys.path、__main__ 等运行时基础设施;然后打开脚本文件,经由 tokenizer、parser、AST、compiler 生成 PyCodeObject;再把它放进 __main__.__dict__ 所对应的 frame 中,交给 PyEval_EvalFrameEx() 执行;最后在程序结束后通过 Py_Finalize() 做清理。
后面大家对这些内容感兴趣,我还可以继续往下写(flag++),比如专门拆:
import的完整细节.pyc文件格式- …
可以通过下面二维码订阅我的文章公众号【MoeLove】

Comments