12月28, 2020

66. 深入 Python 的执行细节

经过之前的准备,我们知道 Python 代码被转换为 CodeObject 对象,虽然 CodeObject 对象的结构挺复杂,但简而言之,CodeObject 保存的就是字节码形式的代码,与 Python 源码在逻辑上是等效的。

CodeObject 存在的主要原因是,计算机只能理解非常简单的操作单元,对于 CPU 这些最小最基本的操作单元就是 CPU 指令,对于 Python VM 而言,这些指令就是 VM 的操作码和它们的参数。

CodeObject 中保存的信息可以通过反汇编技术转换为介于 Python 源码和字节码之间的 “汇编” 形式,几乎离 Python VM 就差一步。

还需要再准备一下

run_mod 函数中,主要只包含两个工作函数:

  • PyAST_CompileObject, 生成 CodeObject
  • run_eval_code_obj, 执行 CodeObject

执行工作就在 run_eval_code_obj 中完成,经过层层深入:

image.png

拆开层层包装,最终我们的关注点落在 _PyEval_EvalCodeWithName 函数上:

// Python/ceval.c:4044

PyObject *
_PyEval_EvalCodeWithName( /* ··· ··· */) {
    // ··· ···

    /* 创建 frame */
    f = _PyFrame_New_NoTrack(tstate, co, globals, locals);

    // ··· ···

    // 执行
    retval = PyEval_EvalFrameEx(f,0);

    // ··· ···
}

可以看到在真正开始处理 Code 之前,先创建了 Frame Object,正所谓 “兵马未动,粮草先行”,这个 Frame 是 Python VM 执行 Code 的先决条件。为什么这么说呢?直接的将,Frame 是 Code 执行的环境。

Frame 到底是什么?

Frame 的结构在之前已经贴过,主要是一些与执行 状态 相关的对象,比如:

  • globals;
  • locals;
  • 各种 args;
  • 执行所需要的栈等;

CPU 的工作过程大概是这样的,从硬盘中加载一些代码到内存中,然后将再从内存中读取一条一条的指令运行,这期间可能会产生一些值,或者称为 “状态”,它们被保存在内存的某个位置。在 CPU 的执行过程中,始终无法离开内存,CPU 虽然很聪明,算力很高,但是却非常健忘,内存就是 CPU 的草稿纸,时不时记录一些信息上去,这就是 CPU 的工作 “现场”。

Python 是一种高级语言,Python VM 中没有直接使用最底层的内存保存数据,而是采用更加抽象的 “对象”,Frame 就是这些对象的容身之处,换言之,Frame 就是 Python VM 的 “内存”,Code、各种变量、执行状态都在 Frame 离。

现场的保存与恢复

“现场” 的意思是指,它记录了某个时间 CPU 的一切信息,代码执行的位置、所有变量等等,包含 VM 运行状态的完全信息。比如在某个时候将这个 Frame 完整的保存下来,再任意时刻再恢复 Frame 的状态,VM 可以像从来没有被打断过一样,继续工作。

保存 Frame 的过程,在汇编中也可以称为 “保护现场”,然后 VM 就可以暂时 中断 当前的工作,去执行其他人物,比如,调用另一个函数。

当其他任务执行完,VM 可以恢复之前保存的 Frame,即 “恢复现场” 继续未完成的工作。这一情况发生在子函数返回之后。

如果函数中调用另一个函数,而另一个函数又调用了下一个函数,很显然每个函数都应该有自己的 “现场”,“保护” -> “恢复” 的流程会被执行多次。

image.png

Python 是单核 VM,无论实体 CPU 是几核的,Python 都只相当于一个虚拟 CPU,所以在某一瞬间只能执行一段特定的代码,也就是只能有一个 Frame 正处于活跃状态,其他的 Frame 就会被挂起。module 是顶层代码块,其中嵌套调用了 func1func2,从 Frame 的保护和恢复顺序是 “先进后出”(FILO)我们就知道,多个 Frame 之间应该是以栈的形式实现,事实上个也正是如此。

调用函数时的 Frame

在我们的例程中定义了函数 func,并且随后就立刻在 module 中调用了它。64 节已经知道,func 的定义实际上是创建了独立的 CodeObject,它保存在 module 的 consts 中。

当 Python VM 进入 _PyEval_EvalCodeWithName 函数,创建了第一个 Frame,做了一些准备后,就进入 PyEval_EvalFrameEx 开始以 Frame 为目标执行 CodeObject。别紧张,CodeObject 就被包装在 Frame 中。

到了 PyEval_EvalFrameEx 函数,就算真的开始处理 Code 了。大框架其实很清晰:


for( /* read next code */){
   switch(code){
       case codeA: /*··· ···*/ break;
       case codeB: /*··· ···*/ break;
   }
}

依次读取 Code、处理。一般的指令处理与字面差异不大,就不再展开。当解析到 func 的定义时,对于 Python 来说函数定义意味着:

  • 从 consts 中加载一个对象,其实函数的 CodeObject;
  • 将它解析构建为函数对象,并在当前 Frame 的 locals 中增加 “func”-> 函数对象的记录。

函数对象直到运行到定义语句才被建立,虽然函数的 CodeObject 在 Code 被执行前就是已知的,这也是为什么试图在函数被定义前引用,会抛出名称未定义异常的原因。

在 module 中调用 func 函数,会由 function_code_fastcall 处理:

static PyObject* _Py_HOT_FUNCTION
function_code_fastcall(PyCodeObject *co, PyObject *const *args, Py_ssize_t nargs,
                       PyObject *globals)
{
    // ··· ···

    f = _PyFrame_New_NoTrack(tstate, co, globals, NULL);

    // ··· ···

    result = PyEval_EvalFrameEx(f,0);

    // ··· ···
}

对比前面 module 开始自行时的 _PyEval_EvalCodeWithName,非常类似的操作:

  • 创建 Frame;
  • 执行 Frame;

_PyFrame_New_NoTrack 创建新的 Frame 时,func 的 Frame 链接到 tstate->frame 上,替换了 module 的 Frame。module 的 Frame 哪里去了呢,在 func 的 Frame->back。其实这是一个单向链表实现的栈。

image.png

这在 Python 实现中非常常见,连标左侧为栈顶。调用函数的时候,就常见新的 Frame 并插入这个栈,并且 VM 的工作始终发生在栈顶 Frame,其他 Frame 就被 “保护” 起来了,直到栈顶 Frame 换出,才能 “恢复” 下一个栈顶 Frame,这就是 Frame 在 Python VM 调用栈中的工作方式。

本文链接:http://www.thinkinpython.com/post/deep_python_vm_66.html

-- EOF --