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

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

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

my_list = []
my_list.append(obj)

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

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

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

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

LOAD_FAST 包括以下 5 步:

  1. 首先要通过 GETLOCAL() 加载指向 obj 的指针,其中要加载的变量是操作码的参数。需要加载的变量指针存储在 fastlocals 列表中,它是 PyFramef_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 就可以开始执行了。

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

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

PyObject *v = (*--stack_pointer);

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

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

随后会调用 PREDICT 宏进行指令预测,它猜测下一条指令将会是 JUMP_ABSOLUTEPREDICT 宏中包含了编译器生成的 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_FALSEPOP_JUMP_IF_TRUE

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

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

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

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

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

有一些操作码会引用编译后的函数作为参数,例如 CALL_FUNCTIONCALL_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,同时线程状态指针指向当前正在执行的帧。

Last updated