很多人把Python当成“脚本语言”来用,写个爬虫、跑个量化策略、做个数据分析,很少去想代码在后台到底经历了什么。但问题往往就藏在这层“不用想”里:为什么同样的逻辑,Python比C++慢那么多?为什么多线程在某些场景下反而更慢?为什么改了一个小语法,整个模块就要重新加载?这些问题的答案,全都指向同一个根源——Python到底是怎么执行代码的。
这篇文章不打算写教科书式的流程堆砌,而是按一条实际代码的执行路径,从源码打到字节码,再进虚拟机,把GIL、内存管理、模块导入这些“幕后黑手”一个个揪出来聊清楚。适合两类人看:一是写了段时间Python、想搞明白底层原理的进阶学习者;二是被Python性能问题折磨过、想知道瓶颈到底卡在哪里的开发者。看完之后,你至少能回答一个问题:一行 total = a + b,在CPython里到底经历了什么。
1. 一条Python指令从源码到字节码的旅程
1.1 先澄清一个误解:Python不是纯粹的“解释型语言”
很多教材把编程语言分成“编译型”和“解释型”两类,然后把C语言归为编译型,把Python归为解释型。这个分法在入门阶段没有问题,但如果你真的去追底层,会发现Python的“解释”和shell脚本的“解释”完全是两回事。
Shell脚本是解释器一行行读取源码、一行行执行,中间基本没有“编译产物”这个概念。而Python在执行任何代码之前,都会先进行一次完整的“编译”——把源码变成一种中间表示,这个中间表示叫字节码(bytecode)。之后再有一个虚拟机逐条读取字节码并执行。
换句话说,Python做的事情其实和Java很像:先编译成字节码,再在虚拟机上跑。只不过这个虚拟机不如JVM那么出名,而且Python不要求你显式执行javac那一步,编译过程被设计成透明的、自动的。所以你才会感觉“好像不用编译”。
1.2 从源码到字节码的四道工序
CPython执行一段Python代码,幕后是这么一条流水线:
- 词法分析:把源码字符串分解成一个个token,比如关键字
def、标识符foo、操作符+都是独立token。 - 语法分析:根据Python文法把token组装成抽象语法树(AST)。这一步如果出错,就是你常见的
SyntaxError——树还没建完,后面根本没法走。 - 编译:遍历AST,生成控制流图,最终输出一个
code object,这个对象里包含字节码、常量表、变量名表等元信息。 - 执行:Python虚拟机读入code object里的字节码,逐个执行。
前三个步骤合起来,就是compile()函数做的事。你甚至可以在代码里手动调用它,把一个字符串编译成code object,再用exec()执行:
python复制source = "def add(a, b):\n return a + b\n"
code_obj = compile(source, "<string>", "exec")
exec(code_obj)
print(add(3, 5)) # 8
实际开发中很少这么干,但理解了这条链路,你才能理解后面所有性能问题——因为每一步都有开销,特别是语法分析和编译阶段,对同一个函数来说是不可忽略的固定成本。
1.3 你的.py文件到底何时被编译成字节码
如果你在项目里运行过Python,一定见过一个叫__pycache__的文件夹,里面躺着一堆.pyc文件。这些文件就是“编译产物”——字节码的固化形态。
关键问题来了:是不是每次运行都会重新编译源码?答案是分情况:
- 如果Python发现
__pycache__里有对应的.pyc文件,且它的时间戳和源码文件匹配,版本也匹配,就直接加载字节码,跳过词法分析、语法分析和编译这三道工序。 - 如果源码被改过,或者
.pyc文件缺失,Python就重新走完整流程,生成新的.pyc。
这就是为什么你第一次运行一个大型脚本会觉得“卡一下”,第二次明显快一点的深层原因——第二次省掉的是编译阶段的时间,而执行阶段本身一点没省。
还有个细节容易被忽略:.pyc文件的二进制格式跟Python版本强相关。你用3.11生成的.pyc,放到3.10的环境里是加载不了的。CPython专门把解释器版本号编码进了.pyc文件的magic number里,不匹配就直接判定无效,重新编译。这也就意味着,如果你在团队里共享.pyc文件或者打包发布时不小心带上了.pyc,换环境后很可能白带。
1.4 字节码到底长什么样
文本形式的源码之所以能变成机器能理解的东西,中间的“桥梁”就是字节码。想看一段代码的字节码长什么样,可以用标准库dis模块:
python复制import dis
def add(a, b):
total = a + b
return total
dis.dis(add)
输出大概是这样的:
code复制 2 0 RESUME 0
3 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 STORE_FAST 2 (total)
4 12 LOAD_FAST 2 (total)
14 RETURN_VALUE
先别急着被这些指令吓到。你只需要抓住一个核心逻辑:Python虚拟机的执行模型是“栈式”的——先把数据压入一个栈,操作符从栈里弹出数据,算完再把结果压回去。
拿上面的total = a + b来看:
LOAD_FAST 0:把局部变量a的值压栈。LOAD_FAST 1:把局部变量b的值压栈。
