CPython 实现原理
  • README
  • 一、简介
    • 1.1 如何使用此书
    • 1.2 额外材料和学习资料
  • 二、获取 CPython 源码
    • 2.1 源代码里有什么?
  • 三、准备你的开发环境
    • 3.1 选IDE还是编辑器?
    • 3.2 安装Visual Studio
    • 3.3 安装Visual Studio Code
    • 3.4 安装JetBrains Clion
    • 3.5 安装Vim
    • 3.6 总结
  • 四、编译 CPython
    • 4.1 在 macOS 上编译 CPython
    • 4.2 在 Linux 上编译 CPython
    • 4.3 安装自定义版本
    • 4.4 make 快速入门
    • 4.5 CPython 的 make 目标
    • 4.6 在 Windows 上编译 CPython
    • 4.7 PGO 优化
    • 4.8 总结
  • 五、Python 语言和语法
    • 5.1 为什么 CPython 是用 C 语言而不是用 Python 语言来实现
    • 5.2 Python 语言规范
    • 5.3 分析器生成器
    • 5.4 重新生成语法
    • 5.5 总结
  • 六、配置和输入
    • 6.1 配置状态
    • 6.2 构建配置
    • 6.3 从输入构建模块
    • 6.4 总结
  • 七、基于语法树的词法分析和解析
    • 7.1 具象语法树生成器
    • 7.2 CPython 解析器-分词器
    • 7.3 抽象语法树
    • 7.4 要记住的术语
    • 7.5 一个示例:添加一个约等于比较运算法
    • 7.6 总结
  • 八、编译器
    • 8.1 相关源文件
    • 8.2 重要的专业术语
    • 8.3 实例化一个编译器
    • 8.4 未来标志和编译器标志
    • 8.5 符号表
    • 8.6 核心编译过程
    • 8.7 汇编
    • 8.8 创建一个 Code Object
    • 8.9 使用 Instaviz 展示 Code Object
    • 8.10 一个示例:实现约等于操作符
    • 8.11 总结
  • 九、求值循环
    • 9.1 构建线程状态
    • 9.2 构建帧对象
    • 9.3 帧的执行
    • 9.4 值栈
    • 9.5 例子:在列表中添加元素
    • 9.6 总结
  • 十、内存管理
    • 10.1 C 中的内存分配
    • 10.2 Python 内存管理系统设计
    • 10.3 CPython 内存分配器
  • 十一、并行和并发
    • 11.1 并行和并发模型
    • 11.2 进程的结构
    • 11.3 多进程并行
    • 11.4 多线程
    • 11.5 异步编程
    • 11.6 生成器
    • 11.7 协程
    • 11.8 异步生成器
    • 11.9 子解释器
    • 11.10 总结
  • 十二、对象和类型
    • 12.1 本章的例子
    • 12.2 内置类型
    • 12.3 对象和可变长度对象类型
    • 12.4 类型类
    • 12.5 布尔和整数类型
    • 12.6 Unicode 字符串类型
    • 12.7 字典类型
    • 12.8 总结
  • 十三、标准库
    • 13.1 Python 模块
    • 13.2 Python 和 C 模块
  • 十四、测试套件
    • 14.1 在 Windows 上运行测试套件
    • 14.2 在 Linux 或 MacOS 上运行测试套件
    • 14.3 测试标志
    • 14.4 运行特定测试
    • 14.5 测试模块
    • 14.6 测试工具
    • 14.7 总结
  • 十五、调试
  • 十六、基准测试、性能分析和追踪
  • 十七、下一步计划
    • 17.1 为 CPython 编写 C 扩展
    • 17.2 改进你的 Python 应用程序
    • 17.3 为 CPython 项目做贡献
    • 17.4 继续学习
  • 十八、附录
    • 18.1 C 预处理器
    • 18.2 基础 C 语法
    • 18.3 总结
  • 致谢
Powered by GitBook
On this page
Edit on GitHub
  1. 九、求值循环

9.5 例子:在列表中添加元素

解析列表添加元素涉及的底层操作

在 Python 中,当你创建了一个列表,你就可以在这个列表对象上使用 append() 方法:

my_list = []
my_list.append(obj)

在这个例子中, obj 是你要添加到列表末尾的对象。

这段代码涉及到了两种字节码操作:

  • 使用 LOAD_FAST 将 obj 从帧中的 locals 列表加载到值栈的顶部;

  • 使用 LIST_APPEND 去添加这个对象。

LOAD_FAST 包括以下 5 步:

  1. 首先要通过 GETLOCAL() 加载指向 obj 的指针,其中要加载的变量是操作码的参数。需要加载的变量指针存储在 fastlocals 列表中,它是 PyFrame 中 f_localsplus 属性的副本。而 oparg 是一个数字,指向 fastlocals 这个数组指针的索引。这意味着 Python 加载的仅仅是一个局部变量(作为指针的副本),而不需要查找变量名;

  2. 如果这个变量已经不存在了,将会引发局部变量未绑定的错误;

  3. 加载的变量 value(在这个例子中指 obj)的引用计数加 1;

  4. 将指向 obj 的指针压入值栈的顶部;

  5. 调用宏 FAST_DISPATCH,如果启用了栈帧跟踪的功能,循环将再次执行去跟踪栈帧的信息。如果没有使能栈帧跟踪的功能,就会直接使用 goto 跳转到 fast_next_opcode。此时 goto 会重新跳转到求值循环的顶部去执行下一条指令。

以下是 LOAD_FAST 中 5 个步骤对应的代码:

...
case TARGET(LOAD_FAST): {
    PyObject *value = GETLOCAL(oparg); // 1.
    if (value == NULL) {
	     format_exc_check_arg(
	     PyExc_UnboundLocalError,
         UNBOUNDLOCAL_ERROR_MSG,
         PyTuple_GetItem(co->co_varnames, oparg));
         goto error; // 2.
    }
    Py_INCREF(value); // 3.
    PUSH(value); // 4.
    FAST_DISPATCH(); // 5.
}
...

现在指向 obj 的指针置于值栈的顶部,下一条指令 LIST_APPEND 就可以开始执行了。

许多字节码操作都会引用基础数据类型,例如 PyUnicode、PyNubmer 等。在这个例子中,LIST_APPEND 要添加一个对象到列表的末尾。为了完成这项工作,需要先从值栈中弹出最后一个对象的指针并将这个指针返回。

这个宏可以用以下方式实现:

PyObject *v = (*--stack_pointer);

现在执行 obj 的指针储存到了变量 v 中。列表的指针由 PEEK(oparg) 加载。

然后以 list 和 v 作为参数,调用 Python 列表的 C API 添加元素。具体的代码在 Objects/listobject.c 文件中,你可以在“对象与类型”这一章学习到更多细节。

随后会调用 PREDICT 宏进行指令预测,它猜测下一条指令将会是 JUMP_ABSOLUTE 。PREDICT 宏中包含了编译器生成的 goto 语句,可以用于跳转到有可能被执行的 case 语句。

这也意味着 CPU 可以跳转到这条指令,而不需要再走一次循环判断的流程:

...
case TARGET(LIST_APPEND): {
    PyObject *v = POP();
    PyObject *list = PEEK(oparg);
    int err;
    err = PyList_Append(list, v);
    Py_DECREF(v);
    if (err != 0)
        goto error;
    PREDICT(JUMP_ABSOLUTE);
    DISPATCH();
}
...

注

有些字节码是成对出现的,这使得在第一个操作执行时预测下一个操作成为可能。例如,COMPARE_OP 后面常常紧接着 POP_JUMP_IF_FALSE 或 POP_JUMP_IF_TRUE。

验证预测的效果需要对寄存器变量内的常量进行高速测试。如果操作码配对测试的效果很好,则说明处理器在分支内部预测的成功率很高,这会让下一个 opcode 的执行开销接近为 0。成功的指令预测可以避免再次执行求值循环的过程和那些不可预测的 switch-case 语句分支。结合处理器内部的分支预测功能,一次成功的 PREDICT 可以让两个 opcode 连续执行,就像它们组成了一个新的 opcode 并把函数体结合到一起。

如果想统计操作码的执行信息,你有两个选择:

  1. 保持预测功能开启,如果有些操作码结合在一起执行,就把它们整体作为统计的结果;

  2. 关闭预测功能,以便统计每一个预测码的执行次数。

可以使用线程代码禁用操作码预测功能,因为线程代码允许 CPU 去记录每一个操作码的分支预测信息。

有一些操作码会引用编译后的函数作为参数,例如 CALL_FUNCTION 和 CALL_METHOD。这种情况下,会将一个新的帧被压入当前线程的帧栈中,同时在求值循环执行该函数,直到该函数结束。每当创建一个新的帧并把它压入栈时,帧中 f_back 字段的值都会在创建新帧之前被设置为当前帧。

当你看到堆栈跟踪结果时,就可以很清晰的看到帧的嵌套关系了。

def function2():
   raise RuntimeError

def function1():
   function2()

if __name__ == '__main__':
   function1()

在命令行中执行上述代码块,你将会得到:

$ ./python example_stack.py

Traceback (most recent call last):
    File "example_stack.py", line 8, in <module>
        function1()
    File "example_stack.py", line 5, in function1
        function2()
    File "example_stack.py", line 2, in function2
        raise RuntimeError
RuntimeError

可以使用 Lib\traceback.py 中的 walk_stack() 函数回溯栈帧的信息:

def walk_stack(f):
    """Walk a stack yielding the frame and line number for each frame.
    This will follow f.f_back from the given frame. If no frame is given, the
    current stack is used. Usually used with StackSummary.extract.
    """
    if f is None:
        f = sys._getframe().f_back.f_back
    while f is not None:
        yield f, f.f_lineno
        f = f.f_back

将当前帧父节点的父节点 (sys._getframe().f_back.f_back) 作为回溯的基准,因为我们不想在栈帧的回溯中看到 walk_stack() 和 print_trace() 的信息。f_back 指针跟随调用栈的顶部。

sys._getframe() 是一个 Python API,它可以获取当前线程的 frame 属性。

以下是帧栈可视化后的结果,以下 3 个帧每个都有它自己独立的 code object,同时线程状态指针指向当前正在执行的帧。

Previous9.4 值栈Next9.6 总结

Last updated 2 years ago

图9.5.1 栈帧可视化