本文主要总结下Python执行时的一些流程细节和疑惑环节
Python代码的执行流程
Python代码的执行流程,并非简单的"编译"或"解释"二选一,而是一个编译 + 解释的混合过程。
整个过程可以分为两大阶段:编译时 和运行时。下面我为你详细拆解每一个步骤。
第一阶段:编译时(从源码到字节码)
当你执行一个Python程序(例如
python main.py)时,解释器并不会直接读取并执行文本文件里的代码,而是先进行编译。
词法分析与语法分析
词法分析 :解释器将源代码(字符流)拆分成一个个有意义的"单词",即Token (关键字、标识符、运算符等)。例如,
x = 1会被拆分为NAME(x)、OP(=)、NUMBER(1)。语法分析 :根据Python的语法规则,将Token序列组织成一棵抽象语法树(AST) 。这棵树描绘了代码的逻辑结构,但如果代码有语法错误(如缺少冒号),这一步就会直接抛出
SyntaxError并终止。编译为字节码
Python解释器(通常指CPython)会将生成的AST编译为字节码(Bytecode)。字节码是一种低级的、与平台无关的中间表示,它比文本代码更紧凑,且执行效率更高。
.pyc文件 :编译完成后,解释器通常会将字节码保存为.pyc文件,存放在__pycache__目录下。下次再运行同一文件时,如果源代码没有修改(解释器会比对时间戳),就直接加载.pyc文件,跳过编译步骤,从而提升启动速度。
第二阶段:运行时(从字节码到机器码)
编译完成后,真正的执行才刚刚开始。Python的核心是一个虚拟机(PVM,Python Virtual Machine),它负责执行字节码。
构建运行时环境
在逐条执行字节码之前,解释器会先准备好运行时环境,这包括:
创建内置模块(如
builtins)。设置
sys.path模块搜索路径。准备
__main__模块的命名空间。执行核心:解释器循环(Eval Loop)
PVM 会进入一个巨大的
while循环,这个循环被称为"解释器主循环"或"求值循环"。在这个循环中,它会从当前代码对象(Code Object)中逐条读取字节码指令,然后根据指令执行对应的操作。
例如,字节码指令
LOAD_FAST 0(加载变量)会被解释器翻译为"从局部变量字典的索引0处取值",BINARY_OP +会被翻译为"执行加法运算"。运行时动态特性(这是关键)
与静态编译语言(如C++)不同,Python 在字节码执行期间,会动态处理:
变量查找:变量名在运行时通过命名空间(字典)查找,而不是通过内存地址。
类型判断 :Python 是动态类型,
a + b具体是整数加法还是字符串拼接,要等到运行时看到a和b的实际类型才能决定。猴子补丁与动态修改:函数、类甚至模块都可以在运行时被替换,这决定了 Python 无法像 C 语言那样进行深度的提前优化(AOT)。
一个特殊机制:
__pycache__与导入优化
当你直接运行
python main.py时,字节码通常不会 被保存(除非使用-m等参数),仅在内存中执行完毕即销毁。但当你导入 一个模块(如
import utils)时,Python 会主动将utils.py编译并保存为utils.cpython-39.pyc。这是为了后续导入时省去编译时间,提升模块加载速度(请注意,是加载速度,而非运行速度)。
理解执行流程的实用例子
看下面这段代码:
def add(a, b): return a + b result = add(1, 2) print(result)执行流程如下:
编译 :整个文件被编译成字节码,包含函数
add的定义体(但此时函数体内部的a+b还不会执行)。运行时(第3行) :执行到
result = add(1, 2)。
解释器在全局命名空间中找到
add这个名字。创建一个新的栈帧(Frame) ,用于管理
add函数的局部变量(a=1,b=2)。在栈帧内部,执行
return a + b的字节码,此时a和b的类型被动态识别为整数,执行整数加法。计算结果
3返回到上一层栈帧,赋值给result。运行时(第4行) :执行
编译->运行
是先全部编译完再运行,还是一遍编译一边运行?
这是一个非常经典且容易混淆的问题。答案是:先全部编译完,再运行。
但在解释这个"全部"之前,需要先厘清一个关键概念:编译的单位是什么?
Python 的编译单位是整个代码文件(模块),而不是一行一行地编译。这意味着:
编译阶段 :Python 解释器会从头到尾读取整个
.py文件,进行词法、语法分析,生成完整的字节码(.pyc内容)。这个过程完全结束后,编译阶段才完成。运行阶段:编译完成后,Python 虚拟机(PVM)才开始逐条执行内存中的字节码指令。
为什么直觉上感觉像是"一边编译一边运行"?
很多人产生这种错觉,是因为看到了 Python 交互式命令行(REPL)。在交互式环境中,你输入一行代码,它立刻给你结果,这看起来像一边编译一边运行。
但实际上,交互式环境是一个特例 。当你敲下回车时,REPL 会立即对当前输入的这行或这个代码块 执行完整的"编译 -> 运行"流程,然后等待你输入下一行。但对于一个标准的
.py脚本文件,绝对是先全量编译,再全量运行。
用一个实验来"铁证如山"
我们写一个包含致命语法错误 和正常输出 的文件
test.py:
# 第一行:一个明显的语法错误(缺少冒号) print("Hello") if True print("World") # 第三行:一个正常的输出 print("End")如果你执行
python test.py,猜猜结果是什么?会不会先输出 "Hello",然后报错?实际结果是:
File "D:\test.py", line 2 if True ^ SyntaxError: invalid syntax程序直接报错,没有任何输出。
如果是一边编译一边运行,第一行
print("Hello")应该被编译并执行,屏幕会先打印 "Hello"。但现实是,解释器在编译阶段 扫描到第二行的语法错误后,直接抛出
SyntaxError并终止程序。运行阶段根本没有启动,所以第一行代码虽然语法正确,但也完全没有被执行。
Python解释器和虚拟机
python解释器和虚拟机不是一个东西吗?我们在官网下载的python是啥?
这个问题问到了根子上,很多人用了很久Python都没搞清楚这里的关系。
直接回答你:在CPython(官方版本)中,解释器和虚拟机确实是"你中有我,我中有你"的同一个东西,但在概念上,它们是"外壳"和"内核"的关系。
为了让你彻底明白,我们先从概念说起,再落地到你下载的那个东西上。
第一层:概念上的区别(理论层面)
在计算机科学中,这两个词的定义是泾渭分明的:
解释器(Interpreter) :是一个**"翻译官"** 。它的职责是读入源代码,并将其翻译成某种可以执行的形式。它关注的是**"输入是什么"** (源代码)和**"输出是什么"**(执行结果)。
虚拟机(Virtual Machine / PVM) :是一个**"虚拟的CPU"** 。它不直接面对源代码,而是面对一种特定的中间指令(即字节码)。它关注的是**"如何执行这些指令"**(如怎么取数、怎么相加、怎么跳转)。
按照这个理论,一个完整的Python执行环境 = 解释器(编译部分) + 虚拟机(运行部分)。
第二层:落地到CPython(你下载的东西)
你在官网(python.org)下载的安装包,名字通常叫 CPython。
CPython 并不是一个单纯的"解释器"或"虚拟机",而是一个将这二者深度耦合在一起的完整可执行程序(
python.exe)。在这个
python.exe内部,流程是这样的:
前端(解释器角色) :负责读取你的
.py文件,进行词法、语法分析,生成字节码。这部分在CPython源码中通常称为 Compiler(编译器)。后端(虚拟机角色) :负责执行这些字节码。这部分在CPython源码中称为 PVM(Python Virtual Machine) ,它的核心是一个巨大的
_PyEval_EvalFrameDefault函数(即求值循环)。关键点来了: 在CPython的实现里,编译器和虚拟机被编译进了同一个
.exe文件里 。当你双击运行.py文件时,这个进程会先调用编译器模块把代码变成字节码,然后立刻把控制权移交给虚拟机模块去跑。所以,当Python程序员说"Python解释器"时,通常泛指整个
python.exe这个应用程序;当他说"Python虚拟机"时,通常特指里面那个执行字节码的引擎。
第三层:为什么官方不把它们拆开卖?
你可能会问:既然概念上分开,为什么官方不提供一个单独的
compiler.exe和vm.exe?因为Python是动态语言 ,编译器和虚拟机在运行时需要频繁交互。举个最典型的例子:
a = 1 b = "2" print(a + b)
编译器在编译时,只知道这里有加法,但它不知道
a是整数、b是字符串(因为类型是运行时才确定的)。当虚拟机执行到加法指令时,发现类型不匹配(
int+str),它会抛出TypeError。如果编译器和虚拟机是分开的两个独立程序,那么虚拟机在执行报错时,还得跨进程去通知编译器,效率极低。正是因为它们在同一个进程里(同一个
.exe),虚拟机才能随时调用编译时生成的符号表和上下文信息,实现动态特性。
第四层:对其他Python实现的补充
理解了CPython,再看看其他实现,你就更清楚了:
实现名称 解释器/编译器 虚拟机 说明 CPython 内置(C语言编写) PVM(C语言编写) 你下载的就是这个,二者合体。 PyPy 内置(Python编写) JIT虚拟机 它把编译器和虚拟机也合在一起,但虚拟机里加入了即时编译技术,能把热点字节码转成机器码。 Jython 内置 JVM(Java虚拟机) 它的编译器把Python代码编译成Java字节码,然后扔给 外部的 JVM去运行。此时解释器(前端)和虚拟机(JVM)就是两个独立的程序了。 IronPython 内置 CLR(.NET公共语言运行时) 类似Jython,借用外部的.NET虚拟机。
最后,回答你下载的那个"Python"到底是什么
你在官网下载的
python-3.x.x-amd64.exe安装完成后,你的电脑里就有了:
解释器核心 (能把
.py变成.pyc字节码的编译器)。虚拟机核心 (能把
.pyc跑起来的PVM循环)。标准库 (成千上万个预先写好的
.py模块,如os、sys、json)。交互式环境(REPL)(让你敲一行跑一行的那个界面)。
它本质上是一个名为"CPython"的完整运行时环境。我们平时口语中说"装了个Python",其实指的是"装了一个名为CPython的、包含编译器和虚拟机的应用程序套件"。
是否需要编译链接是从上层开发者角度来看的
为啥都是编程语言,有的需要编译链接,有的不需要?
核心根源:语言设计目标不同,目标机器不一样
- C/C++ 目标:直接跑在CPU 硬件上
- Python/Shell 目标:跑在软件解释器 / 虚拟机上
CPU 只能看懂 0 和 1 的机器码,看不懂人类写的源代码。所有编程语言,最终都要变成机器码才能跑,区别只在于:这件事是谁来干、什么时候干、要不要你手动操作。
一、C 语言:编译 + 链接,直接面向 CPU
C 的设计初衷:写操作系统、驱动,直接跟硬件打交道,追求极致速度。
CPU 不认 C 源码,所以要两步:
- 编译 :把
.c源码翻译成 CPU 看得懂的机器码,产出目标文件.o。- 链接:你的代码要调用系统库函数(printf、malloc),链接器把你的机器码 + 系统库的机器码拼到一块,合成一个完整可执行文件(Windows exe、Linux elf)。
👉 结果:得到一个独立二进制程序,脱离编译器直接交给 CPU 运行。
代价:你必须手动执行 gcc 编译、链接;改代码就要重新编译链接。
人类写C源码 →【你调用编译器编译】→目标文件 →【你调用链接器】→独立可执行程序 → CPU直接执行二、Python:不用你编译链接,多了一层虚拟机
Python 不想让你操作复杂编译链接,它加了一层Python 虚拟机(PVM,一个软件程序)。
CPU 看不懂 Python 源码,但CPU 可以运行 Python 解释器这个软件。流程:
- 运行
python test.py,解释器启动;- 内部自动把源码编译成字节码(不是 CPU 机器码!是给 Python 虚拟机看的指令);
- Python 虚拟机软件读取字节码,翻译成真正 CPU 机器码,交给 CPU 执行。
关键点:
编译是程序内部偷偷做的,不用你敲命令;
完全没有链接步骤:库导入是运行时虚拟机动态加载,不是链接阶段把二进制拼进去;
字节码只能给 Python 虚拟机识别,不能直接给 CPU 跑。
人类写Python源码 →【解释器内部自动编译成字节码】→ Python虚拟机 →转成机器码→ CPU执行
pyc 就是缓存的字节码,只是省去下次重复编译源码,依然需要虚拟机。
三、Shell/bash:纯解释,连内部编译都几乎没有
Shell 的定位:调用系统命令,做脚本自动化,简单胶水语言。bash 解释器拿到脚本,读一行,解析一行,执行一行,不会生成任何中间字节码文件。
Shell源码 → bash解释器逐行解析 →调用系统接口 →CPU执行一张通俗对比
语言 最终给谁执行 是否要你手动编译链接 中间产物 C/C++ 直接 CPU ✅要编译 + 链接 exe/elf 二进制 Python Python 虚拟机软件 ❌不用(内部自动编译字节码) pyc 字节码文件 Bash Shell bash 解释器软件 ❌完全不用 无中间文件 链接到底是干嘛的?为什么脚本语言不需要链接?
链接本质:构建独立完整的二进制可执行文件,把所有用到的库的机器码提前合并到程序里。
- C:编译完只是你的代码片段,printf 这些函数在系统库里,链接把它们拼在一起,生成完整程序。
- Python:不需要生成独立二进制。需要库的时候,程序跑起来之后虚拟机动态导入模块,运行时加载,不是编译阶段把二进制拼起来,所以没有链接。
为什么不全部做成脚本语言,不用编译多舒服?
凡事有取舍:
- ✅脚本语言好处:写好直接跑,开发快,跨平台(只要装对应解释器)
- ❌缺点:多一层虚拟机 / 解释器,多一层软件翻译,运行速度慢。
C/C++ 编译链接虽然麻烦,但是产出的程序直接喂给 CPU,少中间层,速度极快,适合操作系统、驱动、游戏引擎。
补充:现代边界模糊
- PyPy (JIT):运行的时候把 Python 字节码动态编译成机器码,提速。
- Cython:把 Python 转 C 再编译。
- Go 语言:编译成独立二进制,但是链接过程内置,一条命令完成,用户感知不到链接。
大白话总结
- 编译 = 翻译源码,链接 = 把零散代码和库拼装成完整程序。
- C 系:翻译 + 拼装交给你手动完成,产出直接给 CPU 跑的文件。
- Python:翻译交给解释器偷偷完成,产出给虚拟机看的字节码,库运行时加载,不用拼装链接。
- Shell:边读边翻译,连预翻译都不做。
- 没有绝对好坏:追求运行速度选编译型;追求开发简单快捷选脚本语言。
CPython
CPython是啥,它本身就是个C运行程序吧?
CPython 就是 Python 语言的官方标准实现,我们平时官网下载安装的那个 Python 就是它。
名字里的 C ,意思是:这个解释器 / 虚拟机是用 C 语言写出来的。
注意区分两件东西:
- Python 语言:一套语法规则、规范(什么是 if、for、函数、类),只是文档标准,本身不是软件。
- CPython:把这套语言规范做成实实在在可运行的程序,是一个软件实现。
CPython 本质上就是一个用 C 语言编写、编译而成的原生可执行程序(在 Windows 上就是
python.exe,在 Linux 上就是python二进制文件)。它和你电脑上其他
.exe程序(比如 Chrome 浏览器、记事本)在底层没有区别,都是直接运行在操作系统和 CPU 之上的机器码。只不过,别的程序处理的是文字、图片或网络数据,而 CPython 这个程序处理的"数据"恰好是 Python 源代码文件。为了让你彻底看清它的真面目,我们可以从三个层面来拆解:
1. 从"外壳"看:它就是普通的 C 程序
当你从官网下载并安装 Python 后,你得到的
python.exe文件,其本体就是 CPython 源代码经过 C 编译器(如 GCC 或 MSVC)编译链接后生成的。你可以把 CPython 的源码想象成这样(极度简化版):
// 这是 CPython 主程序的入口,相当于 C 语言的 main 函数 int main(int argc, char *argv[]) { // 1. 读取你传入的 .py 文件内容 char* source_code = read_file(argv[1]); // 2. 调用词法/语法分析模块,生成字节码(编译器功能) PyCodeObject* code = compile(source_code); // 3. 调用虚拟机主循环,执行字节码(虚拟机功能) PyObject* result = PyEval_EvalCode(code); // 4. 打印结果并退出 return 0; }这个程序被操作系统加载后,CPU 会逐条执行这些 C 语言编译后的机器指令。它并没有"魔法",只是一个专门用来"吃掉 Python 代码,吐出执行结果"的普通进程。
2. 从"内部"看:它内置了 Python 的"运行时工厂"
既然它是个 C 程序,那为什么能跑 Python 代码呢?因为在它的内存空间里,CPython 维护了一套庞大的 C 语言结构体(Struct) 和 指针,用来模拟 Python 的世界。
PyObject(Python 对象) :你在 Python 里写的
a = 1,在 CPython 内存中,就是一个 C 结构体struct PyLongObject,里面包含了数值1和引用计数。PyFrameObject(栈帧):当你调用函数时,CPython 会在堆内存中创建一个 C 结构体来管理局部变量、指令指针等。
PyDictObject(字典) :你用的
dict,底层就是一个 C 语言实现的哈希表。CPython 这个 C 程序,在运行期间不断地
malloc(申请内存)和free(释放内存),动态地构建出 Python 代码中定义的各种对象。 可以说,它是一个用 C 写成的"Python 世界模拟器"。
3. 为什么叫 "C" Python?
因为它几乎所有核心组件(编译器、虚拟机、内置数据类型)都是用 C 语言实现的 ,并且它开放了 C API(应用程序编程接口) ,允许你用 C 语言编写扩展模块(比如
numpy中的矩阵运算底层就是 C 写的),然后让 Python 调用。与之对比:
PyPy :是用 Python 语言本身(确切地说是 RPython 子集)写成的 Python 实现。它的可执行程序底层不是纯 C,而是包含了一个 JIT(即时编译器)。
Jython :是用 Java 语言写成的 Python 实现,它跑在 JVM(Java虚拟机)上。
为了印证这一点,做个"灵魂拷问"
如果你的电脑上没有任何 C 语言运行时(比如
msvcp140.dll)会怎样?在 Windows 上安装 Python 时,安装包往往会顺带安装 Microsoft Visual C++ Redistributable。这是因为 CPython 的源码编译后,动态链接到了 Windows 的 C 标准库。这铁证如山:CPython 就是一个依赖 C 库才能跑起来的原生 C 程序。
Python具体慢在哪
Python执行慢,到底是哪些环节慢?
这个问题问得非常专业。既然你已经理解了CPython是C程序、且流程分为"编译"和"运行",那我们就可以抛开"Python慢"这种笼统的说法,把手术刀切到具体的环节上。
结论先抛出来:Python 慢,90%以上的锅在"运行时(虚拟机执行字节码)"环节,而不是编译环节。 编译虽然慢一点,但在整个执行周期中占比极小。
下面我把整个流程拆开,给你看看到底是哪些具体动作在拖慢速度。
第一环节:编译阶段(几乎不慢,可以忽略)
做什么 :把
.py文本变成.pyc字节码(词法分析、语法分析、生成AST、生成Code Object)。速度 :非常快 。因为Python语法相对简单,没有像C++那样复杂的模板实例化或宏展开。一个10万行的项目,编译耗时通常在 毫秒到几十毫秒 级别。
结论 :启动时稍微有点慢(因为要扫描文件),但运行时完全不受影响。 这也是为什么
.pyc文件只能加速启动,不能加速运行的原因。
第二环节:运行时(慢性子的大本营)
这是Python变慢的核心地带。当虚拟机(PVM)进入主循环,逐条执行字节码时,噩梦开始了。
1. 动态类型检查(最致命的瓶颈)
这是Python慢的头号元凶 。看这行代码:
a + b
在C语言中:编译时已经确定
a和b都是int,CPU一条ADD指令(纳秒级)直接搞定。在Python中:虚拟机看到
BINARY_OP +指令后,必须执行以下C函数调用链:
获取
a的对象指针。查看
a的ob_type(类型字段),确认它是整数还是字符串还是列表?根据类型,去
a的类型对象里查找__add__方法的函数指针。调用该C函数,将
b作为参数传进去。在该C函数内部,还要再次检查
b的类型是否匹配。创建一个新的
PyLongObject对象来存放结果(涉及内存分配)。这一整套流程下来,少说几百纳秒,多则几微秒。而CPU原生的整数加法只需不到1纳秒。Python在这里慢了 100倍以上 。
2. 频繁的内存分配与GC(垃圾回收)压力
在Python中,万物皆对象。这意味着:
你写
x = 1,不是在栈上分配一个4字节的整数,而是在堆上malloc一个28字节的PyLongObject结构体。你写
a = [1,2,3],列表里的每个元素都是指向独立对象的指针。循环一万次
i += 1,意味着创建了一万个新的整数对象(因为整数不可变),旧的被丢弃,触发内存管理器的频繁回收。C语言在做同样的事情时,可能只是在修改一块已有的内存地址。而Python在不停地申请、赋值、释放、引用计数加减。 这种内存体操极大地拖慢了速度。
3. 解释器开销(字节码分发)
虚拟机内部是一个巨大的
switch或goto循环(_PyEval_EvalFrameDefault)。
执行
LOAD_FAST(加载变量)、STORE_FAST(存储变量)这些简单指令时,虽然底层是C语言,但每次读取下一条指令、解析操作数、跳转到对应的处理逻辑,本身就有固定的循环开销。一条Python字节码对应几十条C机器码。而同样的逻辑如果用C写,直接就是几条机器码。这中间有 10倍左右的常数开销。
4. GIL(全局解释器锁)带来的副作用
虽然GIL主要是影响多线程并行,但它也会影响单线程的执行效率。因为GIL的存在,CPython无法利用多核CPU进行指令级并行优化(如超标量流水线),并且每次执行字节码时都需要频繁地获取和释放这把巨锁,增加了额外的上下文切换成本。
来做个直观的对比实验
用同样的逻辑,分别用Python和C实现一个空循环100万次:
Python代码:
i = 0 while i < 1000000: i += 1耗时 :约 0.05秒(Python 3.11+)
C语言代码:
int i = 0; while (i < 1000000) i++;耗时 :约 0.001秒(优化后甚至会被编译器直接优化掉)
慢了50倍。 这50倍,全耗在了刚才说的"动态类型检查"、"整数对象创建/销毁"、"循环字节码分发"这三个环节上。
哪个环节不慢?(出乎意料)
I/O操作(读写文件、网络请求)不慢 :因为Python的
socket和file底层调用的C库(read()、write()),在等待操作系统响应时,Python的虚拟机处于阻塞状态,这部分时间开销和语言无关。内置函数(
sum()、sort()、list.append())不慢 :因为这些函数的循环逻辑是在C语言层面执行的,避开了Python字节码的解释开销。所以写Python代码时,能用内置函数就别用for循环。
一张表总结慢在哪里
执行环节 速度级别 主要原因 是否背锅 编译(.py -> .pyc) 毫秒级 字符串处理、建树 不背锅(占比<1%) 变量/属性查找 慢 字典哈希查找( dict查询)背大锅 算术运算/逻辑操作 极慢 动态类型判断 + 对象内存分配 背最大的锅 循环迭代 慢 每次都要调用迭代器的 __next__背大锅 函数调用 较慢 创建栈帧(Frame)、压栈弹栈 背中锅 C扩展库调用(如Numpy) 快 绕过虚拟机,直接跑C机器码 不背锅
延伸思考:既然这么慢,为什么还能用?
因为现代计算机CPU太快了,对于Web后端、脚本自动化这些I/O密集型 任务,Python的慢(微秒级)根本比不上网络延迟(毫秒级)。只有在CPU密集型 计算(如大循环、图像处理)时,才会感受到明显的卡顿------而这时人们通常会用
numpy(C写的)或Numba(JIT编译)来绕过虚拟机。------------------------"总的来说,就是逐条解析字节码的过程慢"
CPython(纯解释型)------ 软件模拟
执行方式 :源代码 -> 字节码 -> 永远由虚拟机(C程序)逐条读取并分发执行。
CPU视角 :CPU老老实实地执行
python.exe这个C程序的机器码。在这个C程序里,有一个巨大的switch语句(或者goto跳转表)。每执行一条Python字节码,CPU就要跑一遍"从内存读字节码 -> 解析操作码 -> 跳转到对应的C函数 -> 执行C函数 -> 返回循环头"的固定流程。速度 :通常比基准慢 10倍 ~ 100倍。