跳转至

Python的字节码如何执行

原文链接:https://tenthousandmeters.com/blog/python-behind-the-scenes-4-how-python-bytecode-is-executed/

我们启动了这个系列,概述了CPython VM。我们了解到要运行Python程序,CPython首先将其编译为字节码,我们研究了编译器如何在第二部分工作。 上次我们逐步通过Main()函数开始的CPython源代码,直到我们到达评估循环,这是一个Python字节码被执行的地方。我们花时间学习这些事情 的主要原因是为我们今天开始的讨论做准备。本次讨论的目标是了解CPython如何做到我们告诉它的作用,即它如何执行编写编译的代码的字节码。

注意:在此帖子中,我指的是cpython 3.9。一些实施细节肯定会随着CPython演变而变化。我会尝试跟踪重要更改并添加更新说明。

初始点

让我们简要回想一下我们在以前的部分学到的东西。我们通过编写Python代码来告诉CPython该做什么。但是,CPython VM只能理解Python字节码。 这是编译器的工作,将Python代码转换为字节码。编译器存储在代码对象中的字节码,这是一个完全描述代码块,如模块或函数的结构。要执行代码 对象,CPython首先创建一个名为帧对象的执行状态。然后它将帧对象传递给帧评估函数以执行实际计算。默认帧评估函数是Python/Ceval.c中 定义的_pyeval_evalframedefault()。此函数实现CPython VM的核心。即,它实现了执行Python字节码的逻辑。因此,这种功能是我们 今天要学习的。

要了解_pyeval_evalframedefault()的工作原理,请考虑其输入,帧对象是至关重要的。框架对象是由以下C结构定义的Python对象:

// typedef struct _frame PyFrameObject; in other place
struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* previous frame, or NULL */
    PyCodeObject *f_code;       /* code segment */
    PyObject *f_builtins;       /* builtin symbol table (PyDictObject) */
    PyObject *f_globals;        /* global symbol table (PyDictObject) */
    PyObject *f_locals;         /* local symbol table (any mapping) */
    PyObject **f_valuestack;    /* points after the last local */
    /* Next free slot in f_valuestack.  Frame creation sets to f_valuestack.
       Frame evaluation usually NULLs it, but a frame that yields sets it
       to the current stack top. */
    PyObject **f_stacktop;
    PyObject *f_trace;          /* Trace function */
    char f_trace_lines;         /* Emit per-line trace events? */
    char f_trace_opcodes;       /* Emit per-opcode trace events? */

    /* Borrowed reference to a generator, or NULL */
    PyObject *f_gen;

    int f_lasti;                /* Last instruction if called */
    int f_lineno;               /* Current line number */
    int f_iblock;               /* index in f_blockstack */
    char f_executing;           /* whether the frame is still executing */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
};

帧对象的f_code字段指向代码对象。代码对象也是Python对象。这是它的定义:

struct PyCodeObject {
    PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */
    int co_posonlyargcount;     /* #positional only arguments */
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_flags;               /* CO_..., see below */
    int co_firstlineno;         /* first source line number */
    PyObject *co_code;          /* instruction opcodes */
    PyObject *co_consts;        /* list (constants used) */
    PyObject *co_names;         /* list of strings (names used) */
    PyObject *co_varnames;      /* tuple of strings (local variable names) */
    PyObject *co_freevars;      /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
    /* The rest aren't used in either hash or comparisons, except for co_name,
       used in both. This is done to preserve the name and line number
       for tracebacks and debuggers; otherwise, constant de-duplication
       would collapse identical functions/lambdas defined on different lines.
    */
    Py_ssize_t *co_cell2arg;    /* Maps cell vars which are arguments. */
    PyObject *co_filename;      /* unicode (where it was loaded from) */
    PyObject *co_name;          /* unicode (name, for reference) */
    PyObject *co_lnotab;        /* string (encoding addr<->lineno mapping) See
                                   Objects/lnotab_notes.txt for details. */
    void *co_zombieframe;       /* for optimization only (see frameobject.c) */
    PyObject *co_weakreflist;   /* to support weakrefs to code objects */
    /* Scratch space for extra data relating to the code object.
       Type is a void* to keep the format private in codeobject.c to force
       people to go through the proper APIs. */
    void *co_extra;

    /* Per opcodes just-in-time cache
     *
     * To reduce cache size, we use indirect mapping from opcode index to
     * cache object:
     *   cache = co_opcache[co_opcache_map[next_instr - first_instr] - 1]
     */

    // co_opcache_map is indexed by (next_instr - first_instr).
    //  * 0 means there is no cache for this opcode.
    //  * n > 0 means there is cache in co_opcache[n-1].
    unsigned char *co_opcache_map;
    _PyOpcache *co_opcache;
    int co_opcache_flag;  // used to determine when create a cache.
    unsigned char co_opcache_size;  // length of co_opcache.
};

代码对象的最重要字段是co_code。它是指代表字节码的Python字节对象的指针。字节码是一个双字节指令的序列:Opcode的 一个字节和一个字节用于参数。

如果上述结构的一些成员仍然是一个谜。我们将看到它们用于我们前进的东西,我们试图了解CPython VM如何执行字节码。

评估循环概述

执行Python字节码的问题可能对您来说看起来是一个无意识的问题。实际上,所有VM都必须做的就是迭代说明并根据他们行动。 这就是基本上_pyeval_evalframedefault()的所在。它包含一个无限的(;;)循环,我们将其称为评估循环。在该循环中, 所有可能的操作码都有一个巨大的交换机语句。每个操作码都有一个包含用于执行该操作码的代码的相应案例块。字节码由16位无符号整数的数组表示, 每个指令的一个整数。 VM会跟踪使用Next_inst变量执行的下一个指令,这是指向指示指令的指针。在评估循环的每次迭代开始时, VM通过分别采取下一个指令的最重要和最重要的字节并递增vide_instr来计算下一个操作码及其参数。_pyeval_evalframedefault() 函数长时间为3000行,但它的本质可以通过以下简化版本捕获:

PyObject*
_PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
    // ... declarations and initialization of local variables
    // ... macros definitions
    // ... call depth handling
    // ... code for tracing and profiling

    for (;;) {
        // ... check if the bytecode execution must be suspended,
        // e.g. other thread requested the GIL

        // NEXTOPARG() macro
        _Py_CODEUNIT word = *next_instr; // _Py_CODEUNIT is a typedef for uint16_t
        opcode = _Py_OPCODE(word);
        oparg = _Py_OPARG(word);
        next_instr++;

        switch (opcode) {
            case TARGET(NOP) {
                FAST_DISPATCH(); // more on this later
            }

            case TARGET(LOAD_FAST) {
                // ... code for loading local variable
            }

            // ... 117 more cases for every possible opcode
        }

        // ... error handling
    }

    // ... termination
}

为了获得更现实的画面,让我们更详细地讨论一些省略的部分。

暂停循环的原因

当前运行的线程停止执行字节码以做其他事情或者什么都不做。这可能导致的四种原因之一:

  • 有信号需要处理。使用Signal.Signal()将函数注册为信号处理程序时,CPython将此功能存储在处理程序数组中。当线程接收信号时 实际调用的函数是signal_handler()(它将传递给Unix的系统上的SigAction()库函数)。当调用时,Signal_Handler() 设置一个布尔变量,讲述与接收信号相对应的处理程序阵列中的功能。定期上,主要解释器的主线程调用跳闸的处理程序。
  • 有待呼叫呼叫。待处理调用是一种机制,允许计划从主线程执行的函数。该机制由Python/C API通过py_addpendingcall()函数暴露。
  • 发生异步异常。异步异常是一个从另一个线程中设置的异常。这可以使用Python/C API提供的pythreadstate_setaSyncexc()函数来完成。
  • 请求当前正在运行的线程丢弃GIL。当它看到这样的请求时,它会丢弃GIL并等待直到它再次获取GIL。

CPython为每个活动有指标。指示有处理程序调用的变量是runtime->ceval的成员,它是一个_ceval_runtime_state结构体:

struct _ceval_runtime_state {
    /* Request for checking signals. It is shared by all interpreters (see
       bpo-40513). Any thread of any interpreter can receive a signal, but only
       the main thread of the main interpreter can handle signals: see
       _Py_ThreadCanHandleSignals(). */
    _Py_atomic_int signals_pending;
    struct _gil_runtime_state gil;
};
所有指标的运算结果存储在eval_breaker变量中。它告诉当前运行的线程是否有任何理由停止其正常的字节码执行。评估循环的每次迭代 都从检查eval_breaker是否为真开始。如果为真,线程会检查指标以确定它究竟被要求做什么,然后继续执行字节码。

计算的goto

求值循环的代码充满了诸如TARGET()DISPATCH()之类的宏。这些不是使代码更紧凑的方法。它们根据是否使用某种优化(称为 “计算 GOTO”(又名“线程代码”))而扩展为不同的代码。他们这种优化的目标是通过以这种方式编写代码来加速字节码的执行,以便CPU可以 使用其分支预测机制 来预测下一个操作码。

执行任何给定指令后,VM 会执行以下三件事之一:

  • 它从评估函数返回。当VM执行RETURN_VALUEYIELD_VALUEYIELD_FROM指令时会发生这种情况。
  • 它处理错误并继续执行或从带有异常集的评估函数返回。例如,当VM执行BINARY_ADD指令并且要添加的对象没有实现__add____radd__方法时,就会发生错误。
  • 它继续执行。如何让VM执行下一条指令?最简单的解决方案是用continue语句结束每个不返回的case块。然而,真正的解决方案要复杂一些。

要查看简单的continue语句的问题,我们需要了解switch编译成什么。操作码是0255之间的整数。由于范围很密集,编译器可以创建一个跳转表来存储 case块的地址并使用操作码作为该表的索引。现代编译器确实这样做了,因此分派案例被有效地实现为单个间接跳转。这是实现切换的有效方式。但是,将switch 放在循环内并添加continue语句会造成两个低效率:

  • case块末尾的continue语句添加了另一个跳转。因此,要执行操作码,VM 必须跳转两次:跳转到循环的开始,然后跳转到下一个case块。
  • 由于所有操作码都是通过一次跳转调度的,因此CPU预测下一个操作码的机会很小。它可以做的最好的事情是选择最后一个操作码,或者可能是最频繁的一个。

优化的想法是在每个非返回case块的末尾放置一个单独的调度跳转。首先,它节省了跳跃。其次,CPU可以将下一个操作码预测为当前操作码之后最可能的操作码。

可以启用或禁用优化。这取决于编译器是否支持称为“标签作为值”的 GCC C 扩展。启用优化的效果是某些宏,如TARGET()DISPATCH()FAST_DISPATCH(), 以不同的方式扩展。这些宏在整个评估循环的代码中被广泛使用。每个case表达式都有一个TARGET(op)形式,其中op是表示操作码的整数文字的宏。 每个不返回的case块都以DISPATCH()FAST_DISPATCH()宏结束。我们先来看看禁用优化时这些宏扩展成什么:

for (;;) {
    // ... check if the bytecode execution must be suspended

fast_next_opcode:
    // NEXTOPARG() macro
    _Py_CODEUNIT word = *next_instr;
    opcode = _Py_OPCODE(word);
    oparg = _Py_OPARG(word);
    next_instr++;

    switch (opcode) {
        // TARGET(NOP) expands to NOP
        case NOP: {
            goto fast_next_opcode; // FAST_DISPATCH() macro
        }

        // ...

        case BINARY_MULTIPLY: {
            // ... code for binary multiplication
            continue; // DISPATCH() macro
        }

        // ...
    }

    // ... error handling
}

FAST_DISPATCH()宏用于某些操作码,当在执行该操作码后不希望暂停计算循环时。否则,实现非常简单。

如果编译器支持“标签作为值”扩展,我们可以在标签上使用一元&&运算符来获取其地址。它有一个void *类型的值,所以我们可以将它存储在一个指针中:

void *ptr = &&my_label;

然后我们可以通过取消引用指针来转到标签:

goto *ptr;

此扩展允许在C中将跳转表实现为标签指针数组。这就是CPython所做的:

static void *opcode_targets[256] = {
    &&_unknown_opcode,
    &&TARGET_POP_TOP,
    &&TARGET_ROT_TWO,
    &&TARGET_ROT_THREE,
    &&TARGET_DUP_TOP,
    &&TARGET_DUP_TOP_TWO,
    &&TARGET_ROT_FOUR,
    &&_unknown_opcode,
    &&_unknown_opcode,
    &&TARGET_NOP,
    &&TARGET_UNARY_POSITIVE,
    &&TARGET_UNARY_NEGATIVE,
    &&TARGET_UNARY_NOT,
    // ... quite a few more
};

以下是评估循环的优化版本:

for (;;) {
    // ... check if the bytecode execution must be suspended

fast_next_opcode:
    // NEXTOPARG() macro
    _Py_CODEUNIT word = *next_instr;
    opcode = _Py_OPCODE(word);
    oparg = _Py_OPARG(word);
    next_instr++;

    switch (opcode) {
        // TARGET(NOP) expands to NOP: TARGET_NOP:
        // TARGET_NOP is a label
        case NOP: TARGET_NOP: {
            // FAST_DISPATCH() macro
            // when tracing is disabled
            f->f_lasti = INSTR_OFFSET();
            NEXTOPARG();
            goto *opcode_targets[opcode];
        }

        // ...

        case BINARY_MULTIPLY: TARGET_BINARY_MULTIPLY: {
            // ... code for binary multiplication
            // DISPATCH() macro
            if (!_Py_atomic_load_relaxed(eval_breaker)) {
              FAST_DISPATCH();
            }
            continue;
        }

        // ...
    }

    // ... error handling
}

GCC和Clang编译器支持该扩展。因此,当您运行python时,您可能启用了优化。当然,问题是它如何影响性能。在这里,我将依赖源代码中的注释:

在撰写本文时,“线程代码”版本比普通“开关”版本快 15-20%,具体取决于编译器和 CPU 架构。

本节应该让我们了解CPython VM如何从一条指令转到下一条指令,以及它在这两者之间可以做什么。下一个合乎逻辑的步骤是更深入地研究VM 如何执行单个指令。 CPython 3.9有119种不同的操作码 。当然,我们不会在这篇文章中研究每个操作码的实现。相反,我们将专注于VM用于执行它们的一般原则。

值堆栈

CPython VM最重要且幸运的是非常简单的事实是它是基于堆栈的。这意味着为了计算事物,VM从堆栈中弹出(或查看)值,对它们执行计算并将结果推回。下面是一些例子:

  • UNARY_NEGATIVE操作码从堆栈中弹出值,对它取反并推送结果。
  • GET_ITER操作码从堆栈中弹出值,对其调用iter()并推送结果。
  • BINARY_ADD操作码从堆栈中弹出值,从顶部查看另一个值,将第一个值添加到第二个值并用结果替换顶部值。

值堆栈驻留在帧对象中。它是作为f_localsplus数组的一部分实现的。数组被分成几个部分来存储不同的东西,但只有最后一部分用于值堆栈。这部分的开始是堆栈的底部。 帧对象的f_valuestack字段指向它。为了定位栈顶,CPython保留了stack_pointer局部变量,它指向栈顶之后的下一个槽。f_localsplus数组的元素是指向 Python对象的指针,而指向Python对象的指针是CPython VM实际使用的。

错误处理和块堆栈

并非VM执行的所有计算都成功。假设我们尝试将一个数字添加到像1 + '41'这样的字符串中。编译器生成BINARY_ADD操作码来添加两个对象。当VM执行此操作码时, 它会调用PyNumber_Add()来计算结果:

case TARGET(BINARY_ADD): {
    PyObject *right = POP();
    PyObject *left = TOP();
    PyObject *sum;
    // ... special case of string addition
    sum = PyNumber_Add(left, right);
    Py_DECREF(left);
    Py_DECREF(right);
    SET_TOP(sum);
    if (sum == NULL)
        goto error;
    DISPATCH();
}

现在对我们来说重要的不是PyNumber_Add()是如何实现的,而是调用它会导致错误。错误意味着两件事:

  • PyNumber_Add()返回NULL
  • PyNumber_Add()将当前异常设置为TypeError异常。这涉及设置tstate->curexc_typetstate->curexc_valuetstate->curexc_tracebackNULL是错误的指示符。VM看到它并在评估循环结束时转到错误标签。接下来会发生什么取决于我们是否设置了任何异常处理程序。如果还没有,VM到达break语句并且 评估函数返回NULL,并在线程状态上设置异常。 CPython打印异常的详细信息并退出。我们得到了预期的结果:
$ python -c "1 + '42'"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

但是假设我们将相同的代码放在try-finally语句的try子句中。在这种情况下,finally子句中的代码也会被执行:

$ python -q
>>> try:
...     1 + '41'
... finally:
...     print('Hey!')
... 
Hey!
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

发生错误后,VM 如何继续执行?让我们看看编译器为try-finally语句生成的字节码:

$ python -m dis try-finally.py

  1           0 SETUP_FINALLY           20 (to 22)

  2           2 LOAD_CONST               0 (1)
              4 LOAD_CONST               1 ('41')
              6 BINARY_ADD
              8 POP_TOP
             10 POP_BLOCK

  4          12 LOAD_NAME                0 (print)
             14 LOAD_CONST               2 ('Hey!')
             16 CALL_FUNCTION            1
             18 POP_TOP
             20 JUMP_FORWARD            10 (to 32)
        >>   22 LOAD_NAME                0 (print)
             24 LOAD_CONST               2 ('Hey!')
             26 CALL_FUNCTION            1
             28 POP_TOP
             30 RERAISE
        >>   32 LOAD_CONST               3 (None)
             34 RETURN_VALUE

注意SETUP_FINALLYPOP_BLOCK操作码。第一个设置异常处理程序,第二个删除它。如果在VM执行它们之间的指令时发生错误,则继续执行偏移量22处的指令, 这是finally子句的开始。否则,finally子句在try子句之后执行。在这两种情况下,finally子句的字节码几乎相同。唯一的区别是处理程序重新引发在 try子句中设置的异常。

异常处理程序被实现为一个简单的C结构,称为块:

typedef struct {
    int b_type;                 /* what kind of block this is */
    int b_handler;              /* where to jump to find handler */
    int b_level;                /* value stack level to pop to */
} PyTryBlock;

VM将块保存在块堆栈中。设置异常处理程序意味着将新块推送到块堆栈上。这就是像SETUP_FINALLY这样的操作码所做的。错误标签指向尝试使用块堆栈上的块处理错误的一段代码。 VM展开块堆栈,直到它找到类型为SETUP_FINALLY的最顶层块。它将值堆栈的级别恢复到块的b_level字段指定的级别,并继续执行偏移b_handler处的指令的 字节码。这基本上就是CPython 实现try-excepttry-finallywith等语句的方式。

关于异常处理还有一件事要说。想想当 VM 处理异常时发生错误时会发生什么:

$ python -q
>>> try:
...     1 + '41'
... except:
...     1/0
... 
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ZeroDivisionError: division by zero

正如预期的那样,CPython打印了原始异常。为了实现这种行为,当CPython使用SETUP_FINALLY块处理异常时,它会设置另一个EXCEPT_HANDLER类型的块。 如果这种类型的块在块堆栈上时发生错误,VM会从值堆栈中获取原始异常并将其设置为当前异常。 CPython曾经有不同类型的块,但现在只有SETUP_FINALLYEXCEPT_HANDLER

块堆栈作为框架对象中的f_blockstack数组实现。数组的大小静态定义为20。因此,如果嵌套超过20个try子句,则会得到 SyntaxError: too many statically nested blocks

总结

今天我们了解到CPython VM 在无限循环中一条一条地执行字节码指令。该循环包含一个覆盖所有可能操作码的switch语句。每个操作码都在相应的case块中执行。 评估函数在一个线程中运行,有时该线程会挂起循环以执行其他操作。例如,一个线程可能需要释放GIL,以便其他线程可以使用它并继续执行其字节码。为了加快字节码的执行速度, CPython采用了一种优化方法,可以利用CPU的分支预测机制。有评论说它使CPython速度提高了 15-20%。

我们还研究了两个对字节码执行至关重要的数据结构:

  • VM 用于计算事物的值堆栈;和
  • VM 用于处理异常的块堆栈。

这篇文章最重要的结论是:如果你想研究Python某些方面的实现,评估循环是一个完美的起点。想知道当你写x + y时会发生什么吗?查看BINARY_ADD操作码的代码。 想知道with语句是如何实现的吗?请参阅SETUP_WITH。对函数调用的确切语义感兴趣?CALL_FUNCTION操作码就是你要找的。下次研究变量在CPython中是 如何实现的,我们将应用这种方法。

2020年11月10日更新:我在HN的评论中指出,UNARY_NEGATIVEGET_ITER操作码查看堆栈顶部的值并将其替换为结果,而不是弹出值并推送结果。 确实如此。在语义上,这两种方法是等效的。唯一的区别是pop/push 方法首先递减然后递增stack_pointer。CPython避免了这些冗余操作。

评论