Python内核阅读(九): 虚拟机框架

Python 2017-08-07

起步

字节码指令看起来就像汇编代码, 这些指令需要python虚拟机接手整个工作, python虚拟机会从编译得到的PyCodeObject对象中依次读入每一条字节码指令, 并在当前上下文环境中执行这个指令.

虚拟机的执行环境

为了更接近操作系统运行可执行文件的过程, python虚拟机需要模拟cpu的运行模式--栈帧. 为了能够获得程序运行时的动态信息, 需要一个概念-- 执行环境, 比方说如下代码:

i = 1
def f():
    i = 999
    print(i)

f()
print(i)

虽然有两处 print(i) 但显然两处的i是不同的变量. 在执行环境A中调用函数 f() 的字节码指令时, 会先创建一个新的执行环境B, 由B作为函数f的执行环境. 所以在python真正执行的时候, 它的虚拟机实际上面对的不是一个 PyCodeObject 对象, 而是另一个 PyFrameObject 对象. 它就是我们说的执行环境, 也是python在系统上对栈帧的模拟.

PyFrameObject

[frameobject.h]
typedef struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      // 执行环境链的上一个frame
    PyCodeObject *f_code;       // PyCodeObject对象
    PyObject *f_builtins;       // builtin名字空间
    PyObject *f_globals;        // global 名字空间
    PyObject *f_locals;         // local 名字空间
    PyObject **f_valuestack;    // 运行栈的栈底
    PyObject **f_stacktop;      // 运行栈的栈顶
    PyObject *f_trace;          // 异常时调用的句柄
    ...

    int f_lasti;                // 上一条字节码指令在f_code中的偏移位置
    int f_lineno;               // 当前字节码对应的源代码行
    int f_iblock;               // 当前指令在栈f_blockstack中的索引
    ...
    PyObject *f_localsplus[1];  // locals+stack, 动态内存, 维护(局部变量+运行时栈)所需要的空间 */
} PyFrameObject;

栈帧通过 f_back 将对象形成一条执行环境链表.这就类似x86系统上栈帧间通过esp和ebp指针建立了关系, 使新的栈帧在结束之后可以顺利回到旧的栈帧中. f_code 存放是一个待执行的PyCodeObject对象, 而接下来的 f_builtins, f_globalsf_locals 是3个独立的名字空间, 关系到维护着变量名和变量值之间的关系, 他们本身是PyDictObject对象.

PyFrameObject 对象也是一个变长对象, 每次创建PyFrameObject对象的大小可能是不一样的. 每个栈帧对象都维护了一个PyCodeObject对象, 而PyCodeObject对象对应了 Code Block . 编译一段代码块时, 需要计算这段代码执行过程中所需要的栈空间的大小, 这个占空间大小存在 f_stacksize 中, 不同的代码段需要的占空间大小明显是不同的.

PyFrameObject 的动态内存空间

PyFrameObject* PyFrame_New(PyThreadState *tstate, PyCodeObject *code,
            PyObject *globals, PyObject *locals)
{
    PyFrameObject *f = _PyFrame_New_NoTrack(tstate, code, globals, locals);
    if (f)
        _PyObject_GC_TRACK(f);
    return f;
}

_PyFrame_New_NoTrack 的内容:

PyFrameObject* _Py_HOT_FUNCTION _PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code,
                     PyObject *globals, PyObject *locals)
{
    PyFrameObject *back = tstate->frame;
    PyFrameObject *f;
    PyObject *builtins;
    Py_ssize_t i;

    if (code->co_zombieframe != NULL) {
        ...
    }
    else {
        Py_ssize_t extras, ncells, nfrees;
        ncells = PyTuple_GET_SIZE(code->co_cellvars);
        nfrees = PyTuple_GET_SIZE(code->co_freevars);
        //  四部分构成了PyFrameObject维护的动态内存区
        extras = code->co_stacksize + code->co_nlocals + ncells + nfrees;
        f = PyObject_GC_NewVar(PyFrameObject, &PyFrame_Type, extras);
        f->f_code = code;
        extras = code->co_nlocals + ncells + nfrees; // 运行栈的栈顶
        f->f_valuestack = f->f_localsplus + extras;  // 运行栈的栈底
        for (i=0; i<extras; i++)
            f->f_localsplus[i] = NULL;
        f->f_locals = NULL;
        f->f_trace = NULL;
        f->f_exc_type = f->f_exc_value = f->f_exc_traceback = NULL;
    }
    f->f_stacktop = f->f_valuestack;    // 初始时, 栈顶等于栈底
    f->f_builtins = builtins;
    f->f_globals = globals;
    ...
    return f;
}

可见, 在创建PyFrameObject对象是, 额外申请的那部分中有一部分是给PyCodeObject的, 一部分给co_cellvars, 一部分给co_freevars. 栈顶保存在f_stacktop, 栈底保存在f_valuestack(高内存地址).

访问PyFrameObject 对象

通过 sys.get_frame 可以查看:

import sys
version = 1

def g():
    frame = sys._getframe()
    print('current function is : ', frame.f_code.co_name)
    caller = frame.f_back
    print('g() function is called by ', caller.f_code.co_name)
    print('local namespace: ', caller.f_locals)
    print('global namaspace: ', caller.f_globals.keys())

def f():
    a = 1
    b = 2
    g()

def show():
    f()

show()

名字,作用域,名字空间

在PyFrameObject中,有3个独立的名字空间:local, global, builtin. 一般来说, python程序会由多个.py文件组成, 每一个.py文件被视为一个module. 这些module中, 有一个主module. 主module通过 python main.py 命令进行加载, 其他module则通过 import 机制加载.

在一个module被加载后, 它在内存中以module对象的形式存在, 在该对象中,维护着一个名字空间(dict对象). import a a.f 其中 a.f 是一个属性引用. 属性引用就是使用另一个名字空间的名字. 为了找到某个给定名字所引用的对象, 应该用该名字当前的作用域里查找, 没找到再到外围作用域去查找. python用了LEGB规则: 名字引用沿着local, enclosing(闭包), global, builtin顺序查找名字.

虚拟机运行框架

python启动后, 首先会进行python运行时环境的初始化. 运行时环境与执行环境不一样. 运行时环境是一个全局的概念, 而执行环境是个栈帧, 栈帧与Code Block对应. 运行时环境初始化过程非常复杂, 待初始化完成后, 整个过程就想多米诺骨牌一样, 一环扣一环地展开.

假设运行时初始化已经完成, 推动第一个骨牌的是一个叫 PyEval_EvalFrameEx 的函数.

[ceval.c]
PyObject * PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
{
    PyThreadState *tstate = PyThreadState_GET();
    return tstate->interp->eval_frame(f, throwflag);
}

eval_frame 默认是 PyEval_EvalFrameEx:

[pystate.c]
PyInterpreterState * PyInterpreterState_New(void)
{
    ...
    interp->eval_frame = _PyEval_EvalFrameDefault;
    ...
}

_PyEval_EvalFrameDefault 的内容比较多:

PyObject* _Py_HOT_FUNCTION _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{
    ...
    co = f->f_code;
    names = co->co_names;
    consts = co->co_consts;
    fastlocals = f->f_localsplus;
    freevars = f->f_localsplus + co->co_nlocals;

    first_instr = (_Py_CODEUNIT *) PyBytes_AS_STRING(co->co_code);
    next_instr = first_instr;

    f->f_stacktop = NULL;       /* remains NULL unless yield suspends frame */
    f->f_executing = 1;
    ... 
}

PyCodeObject对象的co_code域保存这字节码指令和字节码指令的参数. python虚拟机执行字节码指令序列的过程就是从头到尾便利整个co_code.在整个字节码指令序列中, first_instr 永远指向字节码指令序列的开始位置, next_instr 永远指向下一条待执行的字节码指令的位置, f_lasti 指向上一条已经执行过的字节码指令的位置. 运行的架构就是一个for循环加上一个巨大的switch/case结构.

why = WHY_NOT;
for (;;) {
    _Py_CODEUNIT word = *next_instr;
    opcode = _Py_OPCODE(word);
    oparg = _Py_OPARG(word);
    next_instr++;

    switch (opcode) {
        ...
    }
}

有些字节码是带参数的, 有的没有, 主要通过 HAS_ARG 宏实现. 因为参数的存在, next_instr 的位移可能是不同的, next_instr总是指向下一条要执行的字节码, 这很像x86平台上的PC寄存器.

获得一条字节码指令和需要的参数后, 利用switch进行判断, 每一条字节码指令都有会一个case语句, 里面就是python对字节码指令的实现.

需要提到一个叫 why 的变量, 它指示了在退出这个巨大的for循环时Python执行引擎的状态. 因为Python执行引擎不一样每次执行都是正确无误的, 很有可能执行到某条指令时产生错误, 这就是我们熟悉的那个"异常"exception.

变量why的取值范围在 ceval.c 中被定义, 其实就是Python结束字节码执行时的状态:

[ceval.c]
enum why_code {
    WHY_NOT =       0x0001, /* No error */
    WHY_EXCEPTION = 0x0002, /* Exception occurred */
    WHY_RETURN =    0x0008, /* 'return' statement */
    WHY_BREAK =     0x0010, /* 'break' statement */
    WHY_CONTINUE =  0x0020, /* 'continue' statement */
    WHY_YIELD =     0x0040, /* 'yield' operator */
    WHY_SILENCED =  0x0080  /* Exception silenced by 'with' */
};

这样python执行引擎大体框架就是这样了, 执行流程进入for循环, 取出第一条字节码之后, 判断指令后执行, 然后一条接一条的从字节流中获取.

运行时环境初步

python虚拟机在执行时需要不断地使用执行环境. 前面的, PyFrameObject对应可执行文件运行时的栈帧, 但是一个可执行文件要在操作系统中运行, 只有栈帧是不够的. 可执行文件还有一个很重要的东西: 进程和线程.

python在初始化时会创建一个主线程, 所以其运行时环境中存在一个主线程.

对于通常的单线程可执行文件, 在执行的时操作系统会创建一个进程, 在进程中, 又会有一个主线程, 而对于多线程的可执行文件, 在执行时操作系统会创建一个进程和多个线程. 多个线程能共享进程地址空间中的全局变量, 这就自然会引发线程同步的问题. CPU对任务的切换实际上是线程之间的切换, 在切换任务时, CPU需要执行线程环境的保存工作, 而在切换到新的线程后, 需要恢复该线程的线程环境.

python实现了对多线程的支持, 而且python中的线程就是操作系统上的一个原生线程. python虚拟机是对CPU的抽象, 在切换线程之前同样需要保存关于当前线程的信息. 在python, 线程状态信息的抽象是通过 PyThreadState 对象来实现的, 一个线程将拥有一个PyThreadState对象. PyThreadState不是模拟线程, 而是对线程状态的抽象. python的线程仍然使用操作系统的原生线程.对于进程的抽象, 由 PyInterPreterState 对象来实现.

通常情况下, python只有一个interpreter, 其中维护了一个或多个PyThreadState对象, 这些对象对应的线程轮流使用一个字节码执行引擎. 为了实现线程同步, python通过一个全局解释器锁GIL.

PyThreadState对象的定义:

typedef struct _is {
    struct _is *next;
    struct _ts *tstate_head;    // 模拟线程环境中的线程集合

    int64_t id;

    PyObject *modules;
    PyObject *modules_by_index;
    PyObject *sysdict;
    PyObject *builtins;
    PyObject *importlib;
    ...
} PyInterpreterState;

typedef struct _ts {
    struct _ts *prev;
    struct _ts *next;
    PyInterpreterState *interp;

    struct _frame *frame;   // 模拟线程中的函数调用堆栈
    int recursion_depth;
    ...
} PyThreadState;

也就是说, 在每个PyThreadState对象中, 会维护一个栈帧的列表, 以线程中函数调用机制对应. 每个线程都会有一个函数调用堆栈. 在建立新的PyFrameObject对象时, 则从当前线程状态对象中取出旧的frame, 建立PyFrameObject链表.

进程, 线程, 栈帧布局大致如下:

进程->next 进程 -> next 进程
 \
  线程->next 线程 -> next 线程
   |                       |
  栈帧(frame)             ...
   |
  f_back
   |
  f_back
   |
  ...

本文由 hongweipeng 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

赏个馒头吧