解释器源码初探
# 第 14 章 解释器源码初探
# 目录介绍
- 14.1 CPython 源码地图
- 14.2 PyObject 对象模型
- 14.3 列表底层实现
- 14.4 字典底层实现
- 14.5 GIL 原理与影响
- 14.6 内存管理机制
- 14.7 字节码与执行循环
- 14.8 核心要点总结
一行 Python 代码在 CPython 内部经历了什么?本文是一次源码侦探之旅——从日常代码出发,逐层追问"为什么",走进 C 源码求证。
# 14.0 源码侦探之旅
假设你正在写一行最普通的 Python:
a = [1, 2, 3]
你按下回车,一瞬间结果就出来了。但如果你停下来想一秒:这行代码在底层到底发生了什么?
- Python 怎么知道
[1,2,3]是一个"列表"而不是别的? - 这个列表在内存里长什么样?放在哪里?占多少空间?
- 如果我执行
a.append(4),空间不够了怎么办? - 如果我开两个线程同时 append,会出事吗?
- 整条语句是怎么被执行的一行代码,最终变成了 CPU 能理解的机器指令?
这些问题不是抽象的学术探讨——你每天都在写的 for 循环为什么快、dict 为什么能 O(1) 查找、多线程为什么"有时候行有时候不行"——答案都在源码里。
本文的结构就是沿着这些追问一层层深入:
a = [1,2,3]
├─ ① 对象模型:`list` 在 C 层是什么数据结构? → §2 PyObject
├─ ② 内存如何分配:扩容策略是什么? → §3 listobject.c
├─ ③ dict 也是对象——和 list 的实现有何不同? → §4 dictobject.c
├─ ④ 多线程为什么有 GIL? → §5 GIL
├─ ⑤ 内存怎么回收?循环引用怎么办? → §6 内存管理
└─ ⑥ 最后,代码是怎么执行起来的? → §7 字节码
2
3
4
5
6
7
每一节都从"日常写代码时会碰到的问题"出发,带你追踪到 C 源码求证。
# 14.1 CPython 源码地图
在开始追踪之前,我们得知道源码在哪里。你从 python.org 下载的 python3 命令,背后是 CPython——一个用 C 语言写的解释器,源码约 100 万行 C 代码。但 100 万行不是让你全读的——只有几个目录是核心:
cpython/
├── Include/ ← 头文件:所有 C API 声明。这是"地图"——从这里找结构体定义
├── Objects/ ← 内置类型实现:listobject.c / dictobject.c / unicodeobject.c...
├── Python/ ← 解释器核心:ceval.c(执行循环——所有代码最终在这里运行)
├── Parser/ ← 词法分析 + PEG 语法解析(把 .py 文本变成 AST)
├── Modules/ ← C 扩展模块:socket / math / json 等
├── Lib/ ← Python 标准库(用 Python 写的部分——你可以直接用编辑器看)
└── Programs/ ← python 可执行文件入口(main() 函数在这里)
2
3
4
5
6
7
8
如果你只有 10 分钟,建议阅读这 4 个文件的顺序:
Include/object.h— 一切对象的"祖宗"PyObject结构体(~100 行)Objects/listobject.c— 列表的完整实现(~3000 行,是源码中最易读的内置类型)Python/ceval.c— 字节码执行循环(~3000 行,一条 for 循环在这里变成几十个 case)Include/cpython/object.h—PyVarObject定义和引用计数宏
有了地图,我们可以开始第一段追踪了。
# 14.2 PyObject 对象模型
# 14.2.1 sizeof 的线索
让我们先从 Python 侧观察一些数据——它们会给我们线索:
import sys
print(sys.getsizeof(1)) # 28 字节
print(sys.getsizeof([1,2,3])) # 88 字节
print(sys.getsizeof("hello")) # 54 字节
2
3
4
5
一个整数占 28 字节?列表只有 3 个元素却占 88 字节?Python 的对象比你想象的大得多。这些"额外"字节是什么?答案是每个 Python 对象都带有一个固定头部。
# 14.2.2 PyObject 结构体
打开 Include/object.h,你会看到这个结构体:
// Include/object.h —— 所有 Python 对象的基类
typedef struct _object {
Py_ssize_t ob_refcnt; // 引用计数——决定何时释放内存
PyTypeObject *ob_type; // 指向类型对象的指针
} PyObject;
2
3
4
5
这就是 Python 中"一切皆对象"的物理证明。 无论是一个整数 1、一个字符串 "hello",还是一个列表 [1,2,3],它们在 C 层的结构体开头都是这两个字段。
但这还不够——列表是变长的(能 append),所以它还需要一个字段记录当前有多少个元素。这就是 PyVarObject:
// 变长对象多了一个 ob_size——len() 就是读这个字段
typedef struct {
PyObject ob_base; // 继承基类头部(ob_refcnt + ob_type)
Py_ssize_t ob_size; // 元素个数
} PyVarObject;
2
3
4
5
# 14.2.3 C 模拟继承
C 语言没有继承——你可能会问:怎么让一段代码能同时处理整数和列表?
CPython 的答案很巧妙:结构体首字段相同 = 可以安全强转。 任何 Python 对象的指针,因为都是以 ob_refcnt 和 ob_type 开头,都可以安全地转为 PyObject *:
// 无论传入什么对象(int/list/dict...),这两个宏都能工作——
// 因为它们的首字段都是 ob_refcnt!
#define Py_INCREF(op) (((PyObject *)(op))->ob_refcnt++) // 引用+1
#define Py_DECREF(op) if (--((PyObject *)(op))->ob_refcnt == 0) _Py_Dealloc(op)
2
3
4
这在《解释器源码初探》的角度意味着什么? 意味着 Python 的"多态"不是语言特性,而是一个 C 语言的内存布局技巧。理解了这一点,你就理解了为什么 id() 返回的是内存地址(就是 PyObject * 指针值),为什么 is 比 == 快(is 只比较指针,== 走 __eq__ 方法)。
# 14.2.4 slots 原理
你可能听说过 __slots__ 能省内存——但为什么?在底层视角下,答案很直观:
class A: pass
a = A()
print(type(a.__dict__)) # <class 'dict'> ← 每个实例都有一个完整的 dict!
class B:
__slots__ = ('x',)
b = B()
# b 没有 __dict__!属性直接存储在 PyObject 结构体尾部固定偏移量上
# 省掉了一个 dict 的内存开销——每个实例省了约 56 字节
2
3
4
5
6
7
8
9
追踪结论:__slots__ 的工作方式不是"限制你只能赋哪些属性"——而是在 C 层面移除了 __dict__ 指针,用结构体字段直接存储属性值。这是语言层面的特性直接对应到底层实现的典型案例。
# 14.3 列表底层实现
# 14.3.1 数据结构解析
打开 Include/cpython/listobject.h,列表的 C 定义出奇地简洁:
typedef struct {
PyObject_VAR_HEAD // ob_refcnt + ob_type + ob_size (来自 PyVarObject)
PyObject **ob_item; // 指向指针数组的指针——这才是真正的"列表数据"
Py_ssize_t allocated; // 数组容量——总是 >= ob_size
} PyListObject;
2
3
4
5
列表的本质就是一个 C 指针数组 + 一个记录"实际用了多少"的计数器。 ob_item 是一个 PyObject **——指向指针的指针,即指向一个 PyObject* 数组。ob_item[i] 就是那个 Python 对象的 C 指针。
这个结构解释了很多你习以为常的行为:
list[0]为什么是 O(1)?因为它在底层是ob_item[0]——直接数组下标访问。for x in list为什么很快?因为底层是用for (i=0; i<ob_size; i++)遍历。list.insert(0, x)为什么是 O(n)?因为要先把ob_item[0]到ob_item[n-1]全部往后挪一位。list.pop()为什么是 O(1)?因为只改ob_size--,不回收内存。- 为什么列表可以存任意类型?因为
ob_item存的是PyObject *——指针,不是值本身。
# 14.3.2 扩容策略推导
这是源码中最精彩的设计之一。当你执行 a.append(5) 而 ob_size == allocated 时,需要先扩容。但这个公式不是随便写的:
// Objects/listobject.c
// 新容量 = 新长度 + (新长度 >> 3) + (新长度 < 9 ? 3 : 6)
// 即:元素少时多扩一点,元素多时按约 12.5% 增长
new_allocated = new_size + (new_size >> 3) + (new_size < 9 ? 3 : 6);
2
3
4
为什么是 ~12.5% 而不是固定倍数? 这背后是一段实验优化的历史。Python 2.x 用的是 (newsize >> 3) + (newsize < 9 ? 3 : 6) 过渡公式,后来发现对大多数用例,12.5% 的增长率在内存浪费和扩容次数之间达到了最佳平衡。太大的增长率浪费内存,太小的增长率导致频繁扩容。
你可以动手验证这个公式:
import sys
lst = []
for i in range(100):
lst.append(i)
if i <= 10 or i % 20 == 0:
print(f"len={len(lst):3d} capacity={(sys.getsizeof(lst)-56)//8:3d}")
# 观察输出——扩容时机和公式预测完全吻合:
# len=1 capacity=4 → len=5 capacity=8 → len=9 capacity=16 → len=17 capacity=25 ...
2
3
4
5
6
7
8
均摊分析:每次扩容 ~12.5%,扩容次数随 N 增长呈 O(log N)。总扩容开销均摊到每次 append 上是 O(1)。这就是你哪怕 append 一百万次也不觉得卡的原因。
# 14.3.3 共享引用陷阱
列表的底层是指针数组——这意味着"共享引用"是默认行为:
a = [[0]*3]*4 # 你以为创建了 4 个不同的 [0,0,0]?
a[0][0] = 5 # 结果四行都变了!
# 原因:*4 是把同一个 [0,0,0] 的引用复制了 4 次放进 ob_item
# 四个 ob_item[i] 指向的是同一个 PyObject
2
3
4
追踪结论:当你理解了 ob_item 存的是指针而非值,这就不再是"陷阱",而是自然而然的行为。
# 14.4 字典底层实现
# 14.4.1 新旧结构对比
在 Python 3.5 及以前,dict 的迭代顺序是"任意"的——相同代码在不同运行中可能输出不同顺序。这给调试和测试带来很多麻烦。Python 3.6 的开发者做了一个大胆的决定:重新设计 dict 的内部结构。
旧版 dict 是一个标准的开放寻址哈希表——键值对直接散列在数组中。问题是迭代时要跳过大量空槽(内存碎片化),且顺序由哈希函数决定而非插入顺序。
# 14.4.2 新结构布局
新版 dict 的核心思想是把"找 key 在哪个位置"和"存储实际数据"分开:
// Python 3.6+ dict 的物理布局
typedef struct {
uint8_t indices[...]; // 稀疏哈希表 → 存的是 entries 的下标
struct {
PyObject *key, *value; // 实际键值对
uint64_t hash; // 预存的哈希值——比较时不用重算
} entries[]; // 紧凑数组,按插入顺序排列
} PyDictKeysObject;
2
3
4
5
6
7
8
这个设计妙在哪里?
传统 dict(3.5 及以前):
[slot0: k1,v1] [slot1: EMPTY] [slot2: k2,v2] [slot3: EMPTY] ...
迭代 = 扫描整个数组,跳过 EMPTY → 慢 + 顺序不可预测
新版 dict(3.6+):
indices: [2, 0, -1, 1, ...] ← 只有这个表稀疏(但存的是 uint8_t,很小)
entries: [k1,v1, k2,v2, k3,v3] ← 紧凑,迭代 = 顺序遍历 entries → 快 + 插入序
2
3
4
5
6
7
三个实际意义:
- 内存减少 20-25%:稀疏的 indices 表用小整数(
uint8_t),而较大的 entries 紧凑排列 - 迭代更快:遍历 entries 就是遍历"实际存储的数据",不碰空槽
- 插入顺序 = 迭代顺序:entries 本身就按插入顺序 append——Python 3.7 正式承诺了这一行为
# 14.4.3 哈希探查
当 hash(key) & mask 产生的位置已经被占时,需要找下一个空位。CPython 用了一个精妙的线性探查公式,不是简单的+1,而是:
index = (index * 5 + 1) & mask
乘 5 加 1 再取模——相比简单的 index = (index + 1) & mask,这个公式能更好地利用哈希表中的所有位置,减少"簇"(连续占用块)的大小。在数学上,当 mask 为 2^n-1 且 n≥3 时,乘 5 加 1 的步长会遍历所有表项——保证不会卡在某个循环中。
你可以验证哈希冲突的行为:
print(hash(-1)) # -2 ← Python 保留 -1 为错误标志
print(hash(1.0)) # 1
print(hash(1)) # 1 ← Python 保证数值相同的 int 和 float 哈希相同!
d = {1: 'int', 1.0: 'float'}
print(d) # {1: 'float'} ← 被覆盖了
print(d[1]) # 'float'
2
3
4
5
6
# 14.5 GIL 原理与影响
# 14.5.1 互斥锁定义
每个 Python 程序员都有过这样的经历:
import threading, time
def cpu_heavy():
s = 0
for i in range(10**8):
s += i
return s
start = time.time()
threads = [threading.Thread(target=cpu_heavy) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"4 线程耗时: {time.time()-start:.1f}s")
2
3
4
5
6
7
8
9
10
11
12
13
结果发现:4 线程和单线程基本一样快——甚至更慢。
这是为什么?答案不在 Python 语言规范里——它在 Python/ceval.c 的 C 源码中。
# 14.5.2 为什么需要 GIL
// Python/ceval.c —— GIL 的物理定义
static PyThread_type_lock interpreter_lock = NULL; // 一个 pthread_mutex
void PyEval_AcquireLock(void) {
PyThread_acquire_lock(interpreter_lock, 1); // 阻塞等待
}
void PyEval_ReleaseLock(void) {
PyThread_release_lock(interpreter_lock); // 释放
}
2
3
4
5
6
7
8
9
10
GIL 就是一把全局的互斥锁——同一个时刻,只有一个线程能持有它,也就只有一个线程能执行 Python 字节码。
GIL 不是 Python 语言规范的一部分(PyPy 就没有 GIL),而是 CPython 为了解决"如何保证引用计数在多线程下安全"而做的设计选择。引用计数 ob_refcnt 是一个普通整数——如果不加锁,两个线程同时改引用计数就会产生竞态条件。CPython 的回答是:与其在每个对象上加细粒度锁,不如加一个全局大锁——简单、好维护,但代价是多线程 CPU 密集型任务无效。
# 14.5.3 释放时机
GIL 不是一把死锁——它有"呼吸"节奏:
线程 1: 获取 GIL → 执行 5ms 字节码 → 释放 GIL → 等待 ...
线程 2: 等待 → → 获取 GIL → 执行 5ms → ...
2
CPython 每执行一段字节码(默认时间片 5ms)或者遇到 I/O 操作时,会主动释放 GIL。这就是为什么网络爬虫用多线程有效——在等待网络响应的 IO 等待期间,GIL 被释放,其他线程可以工作。
而纯 Python 计算——全程需要持有 GIL,所以多线程无效。
# 14.5.4 替代方案
| 场景 | 方案 | 原理 |
|---|---|---|
| CPU 密集型 | multiprocessing | 多进程,每个进程独立的 GIL |
| I/O 密集型 | asyncio 或 多线程 | I/O 等待时释放 GIL |
| C 扩展 | Py_BEGIN_ALLOW_THREADS | C 扩展在计算前主动释放 GIL |
| 换解释器 | PyPy(无 GIL) | 另一个 Python 实现 |
| 等 3.13+ | CPython free-threaded | 实验性 --disable-gil 编译选项 |
# 14.6 内存管理机制
# 14.6.1 引用计数
当你执行 del x 时,Python 不是立即抹掉内存——它只做一件事:引用计数 -1。只有当引用计数归零时,才真正释放:
import sys
a = []
print(sys.getrefcount(a)) # 2(a 变量 + 传给 getrefcount 的临时引用)
b = a
print(sys.getrefcount(a)) # 3
del b
print(sys.getrefcount(a)) # 2
2
3
4
5
6
7
引用计数机制有点像"图书馆借阅":每本书有一个借阅计数器,有人借就+1,归还就-1。计数器归零时就把书下架。优点是简单、即时——没有任何延迟。缺点是无法处理一种特殊情况。
# 14.6.2 循环引用 GC
a = []; b = []
a.append(b); b.append(a) # a 引用 b,b 引用 a——循环了
del a; del b # 两个变量的引用都断了,但两个对象互相引用——永远到不了 0!
2
3
这就是经典的"循环引用"问题。如果没有额外的清理机制,这两个列表就会永久占用内存。CPython 的解决方案是分代垃圾回收:
// Modules/gcmodule.c
// 分代收集 + 引用计数补充
// 三代设计:
// gen[0] — 新创建的对象 → 频繁扫描(触发阈值:700 个新对象)
// gen[1] — 在 gen[0] 中幸存 → 中等频率(gen[0] 每扫 10 次触发一次)
// gen[2] — 长生命周期 → 很少扫描(gen[1] 每扫 10 次触发一次)
2
3
4
5
6
7
分代收集的核心前提:"大多数对象朝生夕死"。str 拼接的临时结果、for 循环的迭代器——这些活不过几毫秒的对象大量产生又快速消亡。如果用传统的"标记-清除"全量扫描,是对长生命周期对象的巨大浪费。
你可以在这个算法的影响下做更有意识的选择:
import gc
gc.set_threshold(700, 10, 10) # 查看当前的 GC 阈值
gc.collect() # 手动触发 GC
gc.disable() # 极少数场景——临时禁用 GC 提升性能
2
3
4
# 14.6.3 内存池
除了引用计数和 GC,CPython 还有第三层:对小对象(≤512 字节)的内存池。它预分配一大块内存,然后用空闲链表管理小对象分配——避免频繁的系统调用 malloc/free,减少内存碎片。
Python 侧你能看到痕迹——每个基础对象都比想象中大:
print(sys.getsizeof(1)) # 28 字节 ← ob_refcnt(8) + ob_type(8) + 值(4) + 对齐(8)
print(sys.getsizeof("")) # 49 字节 ← PyUnicodeObject 头部巨大
print(sys.getsizeof([])) # 56 字节 ← ob_item 指针数组初始容量 0
2
3
这些"额外"字节就是对象头部——是 Python"便利性"的物理代价。
# 14.7 字节码与执行循环
# 14.7.1 源码到字节码
我们已经追到了对象的物理结构、内存管理、多线程限制。但还缺最后一块拼图:从你写的 .py 文本到 CPU 执行,中间发生了什么?
CPython 的执行链路是三层架构:
Python 源码 字节码(中间表示) C 执行循环
│ │ │
a = 1 + 2 ──编译──> LOAD_CONST 1 ──ceval.c──> case LOAD_CONST:
LOAD_CONST 2 ...
BINARY_ADD case BINARY_ADD:
STORE_NAME ...
2
3
4
5
6
与 C/Java 不同,Python 不直接生成机器码——它先编译为字节码(一种跨平台的中间表示),然后由一个 C 语言的 while 循环逐条解释执行。
# 14.7.2 dis 模块侦探
Python 提供了 dis 模块直接查看任何函数的字节码:
import dis
def add(a, b):
return a + b
dis.dis(add)
# 输出:
# LOAD_FAST 0 (a) ← 把局部变量 a 压入"值栈"
# LOAD_FAST 1 (b) ← 把 b 也压入栈
# BINARY_ADD ← 弹出栈顶两个值,调用 PyNumber_Add,结果压回栈
# RETURN_VALUE ← 返回栈顶值
2
3
4
5
6
7
8
9
10
11
CPython 是一个基于栈的虚拟机。所有操作都在"值栈"上进行——LOAD 压栈,操作从栈顶弹出,结果压回。这个模型简单到极致,也因此容易理解和调试。
如果你好奇一条 while 循环展开了多少条指令:
def loop(n):
total = 0; i = 0
while i < n:
total += i; i += 1
return total
dis.dis(loop) # 展开为 10 条字节码指令——每条对应 ceval.c 中的一个 case
2
3
4
5
6
7
# 14.7.3 执行循环
所有字节码指令最终在 Python/ceval.c 中执行。这个文件约 3000 行,核心是一个巨大的 for (;;) + switch (opcode):
// Python/ceval.c —— 整个 CPython 的心跳循环
PyObject* _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag) {
for (;;) { // 无限循环——直到遇到 RETURN_VALUE
opcode = NEXTOPARG(); // 取下一字节码指令
switch (opcode) {
case TARGET(BINARY_ADD): {
PyObject *right = POP(); // 弹出右操作数
PyObject *left = TOP(); // 读左操作数(不移除)
PyObject *sum = PyNumber_Add(left, right);
SET_TOP(sum); Py_DECREF(left); Py_DECREF(right);
DISPATCH(); // 跳转到下一条指令
}
// ... 还有 100+ 条指令的 case 分支 ...
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这段代码揭示了 Python "慢"的根本原因:每条指令都要经过一次 C 层次的函数调用/类型检查/对象创建。编译时优化(如 JIT)在 CPython 中不存在——这就是为什么 PyPy 和 Numba 能显著加速 Python 代码。
# 14.7.4 编译实验
你可以自己编译 CPython 来观看指令执行过程:
#!/bin/bash
# 1. 克隆 CPython(约 30 秒)
git clone --depth 1 https://github.com/python/cpython.git
cd cpython
# 2. 用 debug 模式编译——会打印更多内部信息
./configure --with-pydebug && make -j$(nproc)
# 3. 编译完成后,你的 ./python 就是一个完整的 Python 解释器
./python -c "print('Hello from my own CPython!')"
# 4. debug 模式下的引用计数追踪
./python -X showrefcount -c "x = [1,2,3]"
# 输出:[total refs: 12345] ← 每一步的引用计数变化
# 5. 真正的探索——修改源码看效果
# 打开 Objects/listobject.c,找到 list_resize() 函数
# 把扩容公式里 12.5% 改成 50%,重新编译
# 用 timeit 对比 append 性能——你亲手验证了扩容策略的影响
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 14.8 核心要点总结
你跟着这条侦探链从头走到了尾:从 a = [1,2,3] 的物理结构,到列表的扩容策略,到 dict 的革命性重构,到 GIL 的本质,再到最终的执行循环。这些不是孤立的"底层知识碎片"——它们在一条线上:
| 你的代码 | CPython 的响应 | 为什么 |
|---|---|---|
lst = [1,2,3] | 分配 PyListObject,ob_item 指向 3 个 PyObject* | list 是指针数组 |
lst.append(4) | 容量不够 → 扩容 ~12.5% → ob_item 重新分配 | 均摊 O(1) 的数学保证 |
d['x'] = 1 | indices 找槽 → entries 追加 → uint8_t 存位置 | 3.6+ 插入序 = 迭代序 |
threading.Thread | 获取 GIL → 执行 → IO 时释放 → 再次竞争 | CPython 的互斥锁实现 |
del lst[0] | Py_DECREF[0] → 引用计数-1 → 为 0 则释放 | 即时回收 + GC 清理循环 |
for x in lst | ceval.c 逐条执行 LOAD_FAST/LOAD_CONST/BINARY_ADD | C switch 循环 ≈ 无 JIT |
最后:读完这篇,你不需要记住 ceval.c 的每一行。但你会对 Python 中那些"理所当然的行为"有更深的把握——知道为什么 dict 的迭代顺序现在可靠了、知道为什么多线程加锁有时候没用、知道 list 的 append 为什么这么快。对解释器的理解,最终体现在你写的每一行 Python 中。