CodingTour
Python 程序执行的过程

在工作和生活中常常写 Python 程序,今天好不容易有空可以看看 Python VM 执行的过程。

python main.py

本文还是以 CPython 为主,因为它是最早、最受欢迎的解释器实现。

三个阶段

Python 程序的执行可以分为三个阶段:

  • 初始化
  • 编译期
  • 解释执行

CPython 为了运行 Python,要在初始化阶段准备好相应的数据结构,比如内建类型、内建模块、导包系统(import system)等,虽然很容易被忽视,但这是非常重要的阶段。

初始化完成后进入编译阶段。CPython 作为一款解释器是没有生成字节码能力的,而依赖解释器的语言在运行之前需要先将源码翻译成中间码,通常是从源码构建出 AST,再从 AST 生成字节码,顺便执行一些字节码的优化。

我们可以通过一个简短的例子看看这个过程做的事:

# test.py
def add_one(x):
    return x + 1

这是一个很简单的函数,我们用 dis 反汇编它:

Disassembly of <code object add_one at 0x1034dd450, file "test.py", line 4>:
  5           0 LOAD_FAST                0 (x)
              2 LOAD_CONST               1 (1)
              4 BINARY_ADD
              6 RETURN_VALUE

LOAD_FAST 指令表示加载一个本地变量,它的 opcode 为 124,LOAD_CONST 则是加载一个常量,它的 opcode 为 100;BINARY_ADDRETURN_VALUE 的 opcode 分别是 23 和 83,通过 dis 可以看出这两个指令不需要额外的参数。

完整的 opcode 表示可以见 Python 每个版本对应的 opcode.py 文件。

我们还可以从它的字节码确认 opcode:

python3 -m compileall test.py

得到:

xxd ./__pycache__/test.cpython-39.pyc
00000000: 610d 0d0a 0000 0000 f3d5 9f60 4900 0000  a..........`I...
00000010: e300 0000 0000 0000 0000 0000 0000 0000  ................
00000020: 0002 0000 0040 0000 0073 0c00 0000 6400  .....@...s....d.
00000030: 6401 8400 5a00 6402 5300 2903 6301 0000  d...Z.d.S.).c...
00000040: 0000 0000 0000 0000 0001 0000 0002 0000  ................
00000050: 0043 0000 0073 0800 0000 7c00 6401 1700  .C...s....|.d...
00000060: 5300 2902 4ee9 0100 0000 a900 2901 da01  S.).N.......)...
00000070: 7872 0200 0000 7202 0000 00fa 0774 6573  xr....r......tes
00000080: 742e 7079 da07 6164 645f 6f6e 6504 0000  t.py..add_one...
00000090: 0073 0200 0000 0001 7205 0000 004e 2901  .s......r....N).
000000a0: 7205 0000 0072 0200 0000 7202 0000 0072  r....r....r....r
000000b0: 0200 0000 7204 0000 00da 083c 6d6f 6475  ....r......<modu
000000c0: 6c65 3e04 0000 00f3 0000 0000            le>.........
  • 7c00 - LOAD_FAST
  • 6401 - LOAD_CONST
  • 1700 - BINARY_ADD
  • 5300 - RETURN_VALUE

或者直接:

print(list(add_one.__code__.co_code))
# Output: [124, 0, 100, 1, 23, 0, 83, 0]

从源码得到机器码,这就是编译阶段要做的事。

CPython 主要目的就是执行机器码,你可能已经知道了,CPython 是基于栈的 VM,使用栈来存储、检索数据,以上面的例子来说,LOAD_FAST 指令 push 一个变量到栈中,LOAD_CONST 继续 push 一个常量,BINARY_ADD pop 出两个变量并执行加法操作,然后将结果 push 回栈中,最后 RETURN_VALUE 从栈中 pop 出数据返回给它的调用者。

字节码在一个巨大的循环中不断执行,直到结束或发生错误。

更近一点

Code Object

我们已经知道了字节码执行的流程,接下来我们继续看看执行的细节,比如在之前的上文中,CPython 怎么知道 x 是一个本地变量?

实际上 CPython 会将一组可执行的代码封装成 Code Object:

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) */

    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) */
        /* ... more members ... */
};

这个数据结构包含了完整的调用参数和可执行的字节码,不论是运行一个模块还是调用一个方法都有相同的 Code Object 抽象。

Function Object

除了 Code Object,Python 的方法还可以有默认参数和文档注释:

def add_one(x):
    """
    加一
    """
    return x + 1

这些额外信息加上一个 Code Object 共同组成了一个 Functioin Object:

typedef struct {
    PyObject_HEAD
    PyObject *func_code;        /* A code object, the __code__ attribute */
    PyObject *func_globals;     /* A dictionary (other mappings won't do) */
    PyObject *func_defaults;    /* NULL or a tuple */
    PyObject *func_kwdefaults;  /* NULL or a dict */
    PyObject *func_closure;     /* NULL or a tuple of cell objects */
    PyObject *func_doc;         /* The __doc__ attribute, can be anything */
    PyObject *func_name;        /* The __name__ attribute, a string object */
    PyObject *func_dict;        /* The __dict__ attribute, a dict or NULL */
    PyObject *func_weakreflist; /* List of weak references */
    PyObject *func_module;      /* The __module__ attribute, can be anything */
    PyObject *func_annotations; /* Annotations, a dict or NULL */
    PyObject *func_qualname;    /* The qualified name */
    vectorcallfunc vectorcall;
} PyFunctionObject;

这个工作由 MAKE_FUNCTION 完成:

  4           0 LOAD_CONST               0 (<code object add_one at 0x103411450, file "test.py", line 4>)
              2 LOAD_CONST               1 ('add_one')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (add_one)

Frame Object

VM 执行 Code Object 时,它需要在栈中不断跟踪值的变化,它还需要记住 Code Object 返回的位置,以便当前代码执行完后继续执行之前的代码,这些状态将被记录在一个 Frame Object 中:

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 */

    PyObject **f_stacktop;          /* Next free slot in f_valuestack.  ... */
    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 */
};

CPython 会在每次执行另一个 Code Object 时创建一个新的 Frame Object,并且用一个 f_back 指针指向之前的 Frame,这种结构组成了一个 Frame 栈 - 也可以叫调用栈,栈顶是当前正在执行的方法,当方法执行完出栈时,CPython 继续执行之前 Frame 中未执行完的代码。

Thread

线程的状态包含调用栈线程数据、异常状态和调试设置。可以通过 threading 模块开启线程:

from threading import Thread

def f():
    print("f")

t = Thread(target=f)
t.start()
t.join()

当执行 t.start() 时会通过 pthread_create 创建一个新的 OS 线程(在 Windows 上则是通过 _beginthreadex 创建),并在新创建的线程里执行 target

实际上由于 GIL 的存在,CPython 同一时间只能执行一个线程,具体可参见CPython 中的超级大锁

如果一定想在 CPython 中执行多线程,只能考虑使用更高级的数据结构,如解释器级别。

Interpreter

解释器拥有一组线程,它们共享模块、内建类型等初始化阶段创建的数据结构。

Runtime

Runtime 维护进程内的全局状态,比如是否已被初始化,另外还有 GIL。Interpreter 与 Runtime 的边界并不太明显,一般来说同一个进程中的线程都在同一个解释器里执行,但也有例外情况,mod_wsgi 就是一个特例,它为每一个 WSGI 应用创建了一个子解释器,这导致虽然处于同一个进程中,但对子解释器中的全局数据进行修改时,对其他 WSGI 应用中的解释器是不可见的。

总结

从架构上看 CPython 采用了分层设计:

  • Runtime - 表示一个进程全局的状态,包括 GIL、内存管理等组件
  • Interpreter - 包含一组线程,共享上下文数据,如导入的模块
  • Thread - 对应 OS 线程,包含一个调用栈
  • Frame - 调用栈中的元素,一个 Frame 包含一个 Code Object 及执行它的上下文
  • Evaluation loop - Frame 的执行环境

其中每一层都有对应的数据结构。

同时我们也可以看到 Python 程序在执行时可以分为三个阶段:

  • 初始化 CPython
  • 编译源码为 Code Object
  • 执行 Code Object 的字节码

负责字节码执行的这一部分可以称为 VM,CPython 的 VM 有几个重要的概念:Code Object、Frame Object,还有线程、解释器、运行时等不同的状态管理,这些概念构成了 CPython 架构的核心。