编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • ScriptHub 脚本工具箱
  • Python

    • Python 从入门到实战
    • 入门与基础类型
    • 序列与集合类型
    • 流程控制与函数
    • 面向对象与工程
    • 爬虫全流程实战
    • 数据分析三件套
    • 办公自动化实战
    • 开发环境与规范
    • 调试与性能优化
    • 部署与并发实战
    • 函数高级特性剖析:装饰器 / 生成器 / 上下文管理器
    • 并发底层原理揭秘
    • 面向对象与类型系统:元类 / 描述符 / 鸭子类型
    • 解释器源码初探
      • 14.0 源码侦探之旅
      • 14.1 CPython 源码地图
      • 14.2 PyObject 对象模型
        • 14.2.1 sizeof 的线索
        • 14.2.2 PyObject 结构体
        • 14.2.3 C 模拟继承
        • 14.2.4 _slots_ 原理
      • 14.3 列表底层实现
        • 14.3.1 数据结构解析
        • 14.3.2 扩容策略推导
        • 14.3.3 共享引用陷阱
      • 14.4 字典底层实现
        • 14.4.1 新旧结构对比
        • 14.4.2 新结构布局
        • 14.4.3 哈希探查
      • 14.5 GIL 原理与影响
        • 14.5.1 互斥锁定义
        • 14.5.2 为什么需要 GIL
        • 14.5.3 释放时机
        • 14.5.4 替代方案
      • 14.6 内存管理机制
        • 14.6.1 引用计数
        • 14.6.2 循环引用 GC
        • 14.6.3 内存池
      • 14.7 字节码与执行循环
        • 14.7.1 源码到字节码
        • 14.7.2 dis 模块侦探
        • 14.7.3 执行循环
        • 14.7.4 编译实验
      • 14.8 核心要点总结
  • Shell-Bash

  • 工具脚本

  • ScriptHub
  • Python
杨充
2025-03-17
目录

解释器源码初探

# 第 14 章 解释器源码初探

# 目录介绍

  • 14.1 CPython 源码地图
  • 14.2 PyObject 对象模型
    • 14.2.1 sizeof 的线索
    • 14.2.2 PyObject 结构体
    • 14.2.3 C 模拟继承技巧
    • 14.2.4 slots 原理
  • 14.3 列表底层实现
    • 14.3.1 数据结构解析
    • 14.3.2 扩容策略推导
    • 14.3.3 共享引用陷阱
  • 14.4 字典底层实现
    • 14.4.1 新旧结构对比
    • 14.4.2 哈希冲突解决
  • 14.5 GIL 原理与影响
    • 14.5.1 互斥锁定义
    • 14.5.2 释放与绕过方案
  • 14.6 内存管理机制
    • 14.6.1 引用计数原理
    • 14.6.2 循环引用与 GC
    • 14.6.3 小对象内存池
  • 14.7 字节码与执行循环
    • 14.7.1 源码到字节码
    • 14.7.2 dis 模块侦探
    • 14.7.3 ceval.c 心跳循环
    • 14.7.4 编译自己的 Python
  • 14.8 核心要点总结

一行 Python 代码在 CPython 内部经历了什么?本文是一次源码侦探之旅——从日常代码出发,逐层追问"为什么",走进 C 源码求证。


# 14.0 源码侦探之旅

假设你正在写一行最普通的 Python:

a = [1, 2, 3]
1

你按下回车,一瞬间结果就出来了。但如果你停下来想一秒:这行代码在底层到底发生了什么?

  1. Python 怎么知道 [1,2,3] 是一个"列表"而不是别的?
  2. 这个列表在内存里长什么样?放在哪里?占多少空间?
  3. 如果我执行 a.append(4),空间不够了怎么办?
  4. 如果我开两个线程同时 append,会出事吗?
  5. 整条语句是怎么被执行的一行代码,最终变成了 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 字节码
1
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() 函数在这里)
1
2
3
4
5
6
7
8

如果你只有 10 分钟,建议阅读这 4 个文件的顺序:

  1. Include/object.h — 一切对象的"祖宗" PyObject 结构体(~100 行)
  2. Objects/listobject.c — 列表的完整实现(~3000 行,是源码中最易读的内置类型)
  3. Python/ceval.c — 字节码执行循环(~3000 行,一条 for 循环在这里变成几十个 case)
  4. 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 字节
1
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;
1
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;
1
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)
1
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 字节
1
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;
1
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);
1
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 ...
1
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
1
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;
1
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 → 快 + 插入序
1
2
3
4
5
6
7

三个实际意义:

  1. 内存减少 20-25%:稀疏的 indices 表用小整数(uint8_t),而较大的 entries 紧凑排列
  2. 迭代更快:遍历 entries 就是遍历"实际存储的数据",不碰空槽
  3. 插入顺序 = 迭代顺序:entries 本身就按插入顺序 append——Python 3.7 正式承诺了这一行为

# 14.4.3 哈希探查

当 hash(key) & mask 产生的位置已经被占时,需要找下一个空位。CPython 用了一个精妙的线性探查公式,不是简单的+1,而是:

index = (index * 5 + 1) & mask
1

乘 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'
1
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")
1
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);          // 释放
}
1
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 → ...
1
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
1
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!
1
2
3

这就是经典的"循环引用"问题。如果没有额外的清理机制,这两个列表就会永久占用内存。CPython 的解决方案是分代垃圾回收:

// Modules/gcmodule.c
// 分代收集 + 引用计数补充

// 三代设计:
//  gen[0] — 新创建的对象 → 频繁扫描(触发阈值:700 个新对象)
//  gen[1] — 在 gen[0] 中幸存 → 中等频率(gen[0] 每扫 10 次触发一次)
//  gen[2] — 长生命周期 → 很少扫描(gen[1] 每扫 10 次触发一次)
1
2
3
4
5
6
7

分代收集的核心前提:"大多数对象朝生夕死"。str 拼接的临时结果、for 循环的迭代器——这些活不过几毫秒的对象大量产生又快速消亡。如果用传统的"标记-清除"全量扫描,是对长生命周期对象的巨大浪费。

你可以在这个算法的影响下做更有意识的选择:

import gc
gc.set_threshold(700, 10, 10)   # 查看当前的 GC 阈值
gc.collect()                     # 手动触发 GC
gc.disable()                     # 极少数场景——临时禁用 GC 提升性能
1
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
1
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                    ...
1
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           ← 返回栈顶值
1
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
1
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 分支 ...
        }
    }
}
1
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 性能——你亲手验证了扩容策略的影响
1
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 中。

上次更新: 2026/06/17, 12:47:39
面向对象与类型系统:元类 / 描述符 / 鸭子类型
Shell & Bash 从0到1实战专栏

← 面向对象与类型系统:元类 / 描述符 / 鸭子类型 Shell & Bash 从0到1实战专栏→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式