当执行 python xxx.py 时,CPython 到底做了什么?

继续写一篇关于 Python 的内容吧,虽然可能也没什么人关心,但我常用这个作为面试题目来和候选人聊。 这篇里面用的是 Python v3.5.3 版本。

提到 Python 大家都知道 Python 是解释型语言,很多人也喜欢顺手写个 Python 脚本来处理事情。

但是当我们执行 python xxx.py 之后,到底具体发生了什么?

总体来看差不多就是:

这样的一条链路。

如果你之前看过我写的文章,那应该知道我的习惯,我会尽量沿着真实控制流一路往下,跟着程序运行的过程走总是没错的。

先看看整体调用链是什么

如果把 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

看起来好像也不是很复杂对不对,我还想更具体展开一些细节:

举个例子

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() 很有意思的一点是,它对参数做了两遍处理。

第一遍的目标很明确,只尽早确认一些会影响后续行为的选项,例如:

而且在很早的时候,它还会调用 _PyRandom_Init(),这样 hash randomization 可以在更靠前的位置生效。

第二遍才是真正设置各种全局 flag,例如:

然后再去读环境变量,比如 PYTHONINSPECTPYTHONWARNINGSPYTHONUNBUFFERED 等。这里也顺便说明一下,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() 里。

这部分顺序非常关键,大体上会做这些事:

  1. 创建 PyInterpreterStatePyThreadState
  2. 初始化基础对象类型和核心运行时子系统
  3. 创建 builtins 模块
  4. 创建 sys 模块
  5. 计算 sys.path
  6. 初始化 import 机制
  7. 初始化文件系统编码
  8. 创建 __main__
  9. 初始化标准输入输出
  10. 导入 site

这几个阶段如果拆开看,会发现不少很有意思的细节。

第四阶段,sys.path 是什么时候算出来的

Py_Initialize() 过程中会调用:

PySys_SetPath(Py_GetPath())

Py_GetPath() 的核心逻辑在 Modules/getpath.c::calculate_path()

它会综合考虑:

最后算出:

然后再挂到 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()

它会把:

塞进 sys.meta_path

随后还会继续导入 _frozen_importlib_external,对应 Lib/importlib/_bootstrap_external.py::_install(),这一层会继续把:

这些和文件系统导入相关的能力装到 sys.path_hookssys.meta_path 里。

也就是说,当我们的脚本后面第一次执行 import math 时,导入系统并不是“临时现搭的”,而是在 Py_Initialize() 期间就已经准备好了。

第六阶段,encodingsiosite 都是在脚本前导入的

这里还有个很多人可能忽略,但是很重要的东西,那就是 encodings,我之前写过一篇关于 Python 和中文相关的文章,当然很多人也会在 Python 里面遇到需要处理中文的情况,尤其是从 Python2 到 Python3.

encodings

文件系统编码初始化会触发 codec registry 的建立,而这又会导入 encodings。相关逻辑在:

所以 encodings 并不是“你 import 了才有”,它是解释器自身启动过程的一部分。

io

Python/pylifecycle.c::initstdio() 里,CPython 会导入 io,并把合适的 open 包装器挂到 builtins。

site

只要没有使用 -SPy_Initialize() 期间就会执行 initsite(),也就是导入 site

这意味着一件很容易被忽略的事:

在脚本还没执行之前,sitesitecustomizeusercustomize 的副作用就已经可能发生了。

第七阶段,__main__ 在脚本打开前就已经存在了

这是我觉得最值得记住的一个点。

Python/pylifecycle.c::initmain() 里,CPython 会执行:

PyImport_AddModule("__main__")

也就是说,sys.modules["__main__"] 在脚本文件还没被打开的时候,就已经创建好了。

并且它还会给这个模块准备好:

所以从运行时视角来看,顶层脚本代码不是在一片真空里执行,而是即将被放进一个已经存在的 __main__ 模块字典中运行。

第八阶段,sys.argvsys.path[0] 是在初始化之后才设置的

Py_Initialize() 结束后,Py_Main() 才会调用:

PySys_SetArgv(argc-_PyOS_optind, argv+_PyOS_optind);

这一步会做两件关键的事:

  1. 设置 sys.argv
  2. 更新 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__ 相关元数据,例如:

其中有个很重要但很容易误解的点:

这里虽然会给 __main__ 设置 SourceFileLoader("__main__", filename) 这类信息,但普通 python xxx.py 的执行主路径,并不是通过 importlib loader 去驱动的。

真正执行脚本的仍然是后面的 C 路径。

第十一阶段,先判断它是不是 pyc

PyRun_SimpleFileExFlags() 里,CPython 会先调用 maybe_pyc_file()

这一步会检查:

如果命中,就直接进入 run_pyc_file(),读取 marshaled code object 然后执行。

如果没有命中,才会继续把它当源文件处理。

这里顺便说一个特别关键的结论:

对于 python hello.py 这种最普通的脚本执行路径,CPython 不会主动去查找旁边 __pycache__ 里的 pyc 来跳过编译。

也就是说:

这里我就很想留个疑问,在 Python2 的时候,你觉得是不是这样呢?

第十二阶段,从源码文本到 tokenizer

源文件路径最终会进入:

PyRun_FileExFlags(...)

再调用:

PyParser_ASTFromFileObject(...)

这一步并不是直接“把整个文件读成字符串再 parse”。

它后面会继续调用:

真正处理编码和逐行读取的,是 tokenizer。

Parser/tokenizer.c 里,它会检查:

所以源码从一开始就不是“普通文本”,而是带着 Python 自己的源码解码规则进入词法分析器的。

第十三阶段,先有 parse tree,再有 AST

这一步也常被描述得过于粗糙。

很多文章会直接说“源码会被解析成 AST”,但在 CPython 3.5.3 里,真实过程是两段:

  1. tokenizer + parser 先生成 parser tree,也可以理解为更接近 concrete syntax 的结构
  2. 然后再转成 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() 主要会做几件事:

  1. PyFuture_FromASTObject(),提取 future flags
  2. PySymtable_BuildObject(),构建符号表
  3. compiler_mod(),开始真正代码生成
  4. assemble()
  5. makecode()
  6. PyCode_New()

最终得到 PyCodeObject

这个对象里会固化编译结果中最关键的执行信息:

也就是说,到了这里,源码才真正从“语言文本”变成了“虚拟机执行契约”。

第十五阶段,顶层脚本代码在哪个模块里执行

前面 PyRun_SimpleFileExFlags() 拿到的是 __main__ 模块对象。

执行时传进去的 globalslocals,本质上都是:

sys.modules["__main__"].__dict__

这意味着:

所以脚本执行的本质并不是“在文件里执行”,而是:

把这个文件编译出来的 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_builtins 会优先从 globals["__builtins__"] 来,而前面 initmain() 已经确保了 __main__ 拥有这部分内容。

也就是说,顶层脚本 frame 在创建时,已经天然知道:

第十七阶段,f_localsplus 是 frame 里最重要的一块内存

这一步虽然已经属于 VM 内部细节,但我特别想补充几句。

Include/frameobject.h 里,PyFrameObject 的尾部定义了:

PyObject *f_localsplus[1];

它不是普通数组,而是变长尾部数组。

PyFrame_New() 中,CPython 会按如下布局分配一整块连续内存:

| fast locals | cellvars | freevars | value stack |

也就是说,这一块空间同时承载:

这就是为什么后面 LOAD_FAST 这类指令会非常快,本质上就是直接做数组槽位访问。

所以很多人说 CPython 是个 stack VM,这当然没错,但更准确一点的说法应该是:

它是一个围绕 frame 连续内存布局展开实现的 stack VM。

第十八阶段,终于进入 PyEval_EvalFrameEx()

到了 Python/ceval.c::PyEval_EvalFrameEx(),脚本才真正开始以 opcode 形式运行。

对于我们的例子来说,顶层模块代码大致会做这些事:

  1. 执行 import math
  2. 创建函数对象 add
  3. 调用 print(add(1, 2))
  4. 调用 print(math.sqrt(9))

这里有两层执行值得分开看。

顶层模块代码执行

顶层代码本身是模块级 code object,它也会进入 PyEval_EvalFrameEx()

def add(...) 不是立刻执行函数体

函数定义语句执行时,不会立刻跑 return a + b

它只是:

真正调用 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()

这里会根据被调用对象类型做分流:

对于 add(1, 2) 这种最普通的 Python 函数,通常会进入 fast_function()

它的思路非常直接:

  1. 创建一个新的 PyFrameObject
  2. 把参数拷进 f_localsplus
  3. 进入 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

主路径是:

它主要是 C 层直接驱动。

import foo

主路径是 importlib machinery 驱动:

也正因此,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() 并不会去调用:

也就是说,对普通的 python hello.py 而言:

换句话说:

如果你直接执行的是 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

这里会同时发生两件事:

  1. main.py 作为 __main__ 直跑
  2. foo.py 作为普通模块被 import

而这两件事的 pyc 行为是不同的。

main.py 的行为

仍然和前一种情况一样:

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() 会做这些事:

  1. source_path = self.get_filename(fullname)
  2. bytecode_path = cache_from_source(source_path)
  3. st = self.path_stats(source_path),拿到 mtimesize
  4. 尝试 self.get_data(bytecode_path) 去读 pyc
  5. 因为第一次没有 pyc,这里会抛 OSError
  6. 然后退回读源码 self.get_data(source_path)
  7. self.source_to_code(source_bytes, source_path)
  8. 得到 code_object
  9. 如果允许写回,就把 pyc 写到 __pycache__

也就是说,第一次 import 时,“没有 pyc”并不是错误,它只是让 loader 自然回退到源码编译路径。

pyc 文件名是怎么来的

这里使用的是 Lib/importlib/_bootstrap_external.py::cache_from_source()

3.5.3 中:

所以普通情况下:

foo.py
-> __pycache__/foo.cpython-35.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_NUMBER3.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() 会在满足条件时调用:

SourceFileLoader.set_data() 会:

  1. 逐级创建缺失的目录
  2. _write_atomic(path, data, mode)

_write_atomic() 的逻辑也很直接:

  1. 先写入一个临时文件
  2. 写完后用 os.replace() 原子替换目标文件

这说明 pyc 写入是尽量原子化的,同时如果权限不足、目录不可写、或者别的进程竞争写入,import 本身仍然不应该失败。最坏的情况通常只是:

第二十二阶段之二,第二次 import foo,已有 pyc 时发生了什么

现在我们第二次执行:

python main.py

这一次:

这时 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() 会拆出:

然后做几类检查:

  1. magic 对不对
  2. header 有没有截断
  3. mtime 是否与当前源码匹配
  4. size 是否与当前源码匹配

只有都通过,才会把 data[12:] 这部分真正交给 _compile_bytecode()

命中 cache 后会发生什么

_compile_bytecode() 会:

  1. marshal.loads(data),把 pyc 里的 code object 反序列化出来
  2. 如果是 source-backed import,再调用 _imp._fix_co_filename(code, source_path)

也就是说,第二次 import 成功命中 pyc 后,它直接跳过了源码读取、词法分析、语法分析、AST 构造、编译这些步骤。

这时第二次运行真正省下来的,是被 import 的模块那部分编译成本。

所以如果你执行的是:

python main.py

第二次运行时更可能复用的是:

而不是顶层 main.py 本身。

第二十二阶段之三,源码改了之后会怎样

如果 foo.py 改了,那么下一次 import 时,header 校验就可能失败。

3.5.3 里,stale pyc 的判定主要依赖:

也就是说,这个版本还不是后来的 hash-based pyc,而是 timestamp/size 驱动的校验逻辑。

一旦 _validate_bytecode_header() 发现:

那它就会抛 ImportErrorEOFError

SourceLoader.get_code() 对这些异常的处理方式非常务实:

所以对于 source-backed import,更准确的说法应该是:

如果 pyc 缺失、过旧、header 不合法,或者 header 被截断,通常都不是致命问题,因为 loader 会回退到源码重新编译。 但这句话也不能无限泛化,如果 pyc 的 header 已经通过校验,后面的 marshaled payload 自身坏掉了,那就不一定还能优雅 fallback 了;而如果你直接执行的是 python foo.pyc,那更不会回源码。

第二十二阶段之四,-B-Opython 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 文件名:

因此不同优化级别下,缓存文件本身也是分开的。

python foo.pyc

如果直接执行的是:

python foo.pyc

这就不是 import cache 的故事了,而是 Python/pythonrun.c::run_pyc_file() 的故事。

它会:

  1. 读取 magic
  2. 跳过 mtime 和 size
  3. 直接 PyMarshal_ReadLastObjectFromFile()
  4. PyEval_EvalCode()

注意这和 SourceLoader.get_code() 很不一样:

所以:

直接跑 .pyc,和通过 import 命中 __pycache__,是两件相似但不相同的事。

小结一下就是:

第一次和第二次执行 python main.py 时,真正可能被 pyc cache 加速的,通常不是 main.py 这个顶层脚本本身,而是它 import 进来的那些普通模块。

再说得更极端一点:

这也是为什么 pyc cache 对中大型项目更有感知,而对一段单文件脚本往往没那么显著。

第二十三阶段,程序跑完之后,解释器还要收尾

脚本执行完成后,Py_Main() 最终会调用:

Py_Finalize()

对应 Python/pylifecycle.c::Py_Finalize()

这里会做很多收尾工作,例如:

如果脚本里抛的是未捕获的 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++),比如专门拆:


可以通过下面二维码订阅我的文章公众号【MoeLove】

TheMoeLove

Related Posts

Comments