python 函数执行结果保存_Python线程、协程探究(终篇)—python虚拟机是如何保存协程的执行环境的...

本文深入探讨了Python虚拟机的实现,特别是如何保存协程的执行环境。通过介绍PyCodeObject和PyFrameObject,阐述了Python如何在协程切换时保留运行状态。读者将了解到,PyFrameObject在协程执行中的关键作用,以及它是如何在内存中被管理和保存以支持协程的上下文切换。此外,文中还推荐了一本关于Python虚拟机的书籍,以供进一步学习。
摘要由CSDN通过智能技术生成

b04595fa7648cc37dacc83ffab146962.png

前言

本篇是协程技术介绍的最后一篇。我们曾多次提及协程的两大特征:

  • 协程可以保留运行时的状态数据
  • 协程可以出让自己的执行权,当重新获得执行权时从上一次暂停的位置继续执行

在专栏之前的两篇协程文章中,第一篇介绍协程本质就是用户级的线程,其调度切换以及上下文保存都由用户自己控制。在第二篇介绍了python中协程的调度切换,从本篇中我们了解到借助事件驱动编程,asyncio方便的实现了协程切换并避免了callback hell。到目前为止,我们已经懂了协程是什么、协程如何主动出让执行权以及协程是如何被调度执行。那么我们遗留的最后一个问题就是协程保留运行时的状态数据到底是如何实现的?想要弄清楚这个问题,我们就必须得深入到python语言本身的实现,更确切的说,深入到python虚拟机的细节中。所以本篇会有大量的内容来介绍python虚拟机的实现机制。同时需要指出的是,python有Jython和Cpython等实现方式,在这里我们选择社区中最常用的Cpython虚拟机实现来介绍,如下的python虚拟机都指的是Cpython的虚拟机。

python的实现非常的复杂,如果完全介绍python的实现细节,这篇博客估计得写10万字。为了尽可能清楚把协程的整个机理介绍清楚,我们只介绍实现中的几个核心概念,讲清楚这几个核心概念后,我们以一个基于C++实现的简易版的python虚拟机来展示虚拟机运行协程和运行普通函数的区别,进而弄明白协程的运行时状态数据到底是如何被保存的。本篇的内容安排如下:

  1. Python语言总览,首先介绍整个python源码被执行的过程
  2. python虚拟机实现:这里我们主要介绍几个核心概念,有PyCodeObject, PyFrameObject
  3. 以一个基于C++的简易python虚拟机为例介绍协程是如何被保存执行环境的

需要指出的是,我们第3部分的简易版python虚拟机来自于海纳写的这本书。海纳本身长期从事虚拟机开发的工作,同时比较擅长剖析复杂的概念,整体来讲这本书我个人认为写的不错的,是一本了解python虚拟机的好的入门教材,在这里也推荐给大家。

《自己动手写Python虚拟机 海纳 书》【摘要 书评 试读】- 京东图书​item.jd.com

Python语言总览

所有的高级语言的组成核心大体上都分为编译器和执行器,对于C/C++来说,我们通过静态编译直接生成了对应机器架构的机器指令,该机器指令的执行器就是物理机器本身。与平台的直接绑定带来的问题就是跨平台的困难,同样一段C代码程序,在windows下编译得到的exe文件是windows系统下的机器指令,这个exe文件放到装有linux系统的机器上就不能够被识别和执行,因为linux系统有自己的一套指令集。为了更好的实现跨平台特性,python、Java这类解释型语言就诞生了,人们定义了新的相比机器指令集更高层的指令集并基于C/C++实现了相应的指令执行器来模拟物理机器对指令的执行。Java的指令集就是Java字节码,其执行器由C和汇编实现,python的指令集就是python字节码,其执行器也由C实现。回忆我们所写java代码文件或者python代码文件,其本质上只是一种普通的文本文件,写程序的过程就是我们是通过一系列的文本来定义我们想要计算机进行执行的操作序列或者说指令序列。执行器是不能直接执行这个文本文件中的命令的,所以我们还需要将这个文件翻译成可以被执行器认识和执行的指令序列,这个过程就是编译。所以我们还需要实现一个编译器来进行源代码文件的翻译。

指令集、编译器、执行器基本就构成了python语言的核心。而编译器、执行器两个组合起来就构成了python的解释器,解释器的具体组成和工作流程如下图所示:

8602ea8341cecb4209c9ca0082df1bd4.png
python解释器整体框架

当我们运行python Hello.py 这行命令时,python解释器中的编译器首先对Hello.py进行编译生成python字节码,字节码的设计类似于CPU指令,有自己定义的数值计算、位操作、比较操作和跳转操作等,接着解释器中的执行器对字节码进行逐行的执行,进而得到了我们想要的结果。这个执行器实际上就是我们常说的虚拟机,它模拟CPU执行指令的过程来执行字节码,是一个软CPU。我们之前提过Python的实现有Jython和CPython,那他们的区别又在哪里呢?其区别就在于Jython的编译器把Hello.py编译生成了Java字节码,这样Jython编译后的字节码可以被Java虚拟机直接执行,进而实现了python和Java的无缝兼容,而CPython则是编译生成了python原生的一套字节码,并且实现了一个能够解释执行该字节码的执行器。

举个栗子

到目前为止大家可能觉得比较抽象,我们举个例子来帮助理解python程序被编译生成的字节码是什么以及到底执行器是个什么东西。例子中我们首先定义一个函数hello,然后借助dis模块来查看该函数编译得到的字节码。输出结果中的LOAD_GLOBALLOAD_CONST等就是编译生成python自己定义的操作码,操作码右边的这一列就是该操作码的参数,我们可以发现有的操作码有参数,有的操作码没有参数。编译的过程,主要就是将python代码文件编译生成这样的(操作码、操作码参数)的序列,这就是我们所说的字节码。

>>> def hello():
...     print("nihao")
...
...
>>> import dis
>>> dis.dis(hello)
  2           0 LOAD_GLOBAL              0 (print)
              2 LOAD_CONST               1 ('nihao')
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE
>>>

得到了字节码序列,也就是我们程序的指令序列,执行器就可以一行行的进行指令执行了。我们对每一种操作码都定义了一系列对应的操作函数,所谓的执行器执行字节码,就是根据不同的字节码来执行每个操作码对应的函数。那么我们可以写一个简单的执行器如下,整个执行器就是一个巨大的循环和switch/case结构,每次我们都读取字节码序列中的一个操作码op_code和该操作码的参数op_arg,然后我们根据op_code的类型进行相应的操作。比如遇到POP_TOP操作码,我们就执行一个POP()函数操作等等。

 void Interpreter() {
    while (_frame->has_more_codes()) {
        //获得操作码
        op_code = _frame->get_op_code();
        //获得操作码参数
        op_arg = _frame->get_op_arg();
        
        //根据操作码的类型来执行相应的操作
        switch (op_code) {
            //如果是POP_TOP操作码,执行相应操作
            case ByteCode::POP_TOP:
                POP();
                break;

            case ByteCode::ROT_TWO:
                v = POP();
                w = POP();
                PUSH(v);
                PUSH(w);
                break;

            case ByteCode::ROT_THREE:
                v = POP();
                w = POP();
                u = POP();
                PUSH(v);
                PUSH(u);
                PUSH(w);
                break;
            case ...

           }
    }
}

现在是不是发现Python的整体架构还是比较好懂的?只要定义好字节码,然后实现好对应的编译器和执行器,我们就可以自己发明一种解释型语言!我们甚至可以像Jython一样,借助其他成熟解释型语言的字节码定义和其执行器,这样只需要实现一个编译器把源代码文件编译生成对应语言的字节码就行。这样看来,自己发明一个编程语言也不是什么不可能的事情呢。

43e58299787f5b4b204348b6f6004178.png

python虚拟机的实现

接下来我们要进入到硬核的部分了,你可以先深吸一口气,然后再往下阅读。我们接着介绍python虚拟机的具体实现。当然,我不可能,也没有必要在这篇博客中把所有的python虚拟机的内容都介绍了。我们本篇文章的核心还是要弄明白python中的协程在切换的时候,其执行环境是如何被保存下来的,所以这里只介绍虚拟机实现中和协程相关的两个最关键的概念,弄明白了这两个概念大家就基本懂了协程的执行环境是如何被保存的了。

PyCodeObject

前文提到,当我们执行Hello.py的时候,编译器首先会将Hello.py进行编译得到字节码,python编译器将编译代码得到的结果保存在一个叫PyCodeObject的结构体中。我们首先看PyCodeObject的定义:

/* Bytecode object */
/*代码有删减*/
typedef struct {
    PyObject_HEAD
    int co_argcount;            /* #*/
    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) */
    PyObject *co_lnotab;        /* string (encoding addr<->lineno mapping) See
                                   Objects/lnotab_notes.txt for details. */

} PyCodeObject;

一个PyCodeObject中包含了关于一个python源码分析得到的结果,包括代码块中的变量名称,所有的常量,当然,还包括代码编译得到的字节码指令序列。总而言之,在python的虚拟机中,每一个代码块都被一个PyCodeObject结构体所代表。该结构体中部分域的含义如下

88b2099dcb68becaa190fb9d84682d75.png
PyCodeObject中部分field的含义

上文我们一直再提代码块(Code Block),那什么是一个代码块呢?在python里面,每一个新的命名空间就是一个代码块,一个module是一个代码块,一个function也是一个代码块。比如如下这个代码文件就有两个代码块。首先整个hello.py的内容是一个代码块,在这个代码块中还嵌套这一个hello()函数对应的代码块,即代码块是可以嵌套的

# hello.py
global_var = 3
def hello():
    print("nihao")

print("main part")

举个栗子

上面的文字可能还是比较抽象,我们举个例子来帮助理解。在Python中,有个与C中的PyCodeObject对应的对象——code对象,这个对象是C中的PyCodeObject的简单包装。我们通过编译上面的hello.py得到其code对象,然后访问其各个field来查看其中的内容。我们查看整个hello.py对应的code对象的co_consts属性时,我们发现其中还有一个code对象,这个code对象就是hello()这个函数对应的code对象,该对象中的符号名称有print函数对应的符号“print”,同时我们还可以访问hello()函数的code对象中的co_code属性来查看该函数块编译得到的字节码,即b'tx00dx01x83x01x01x00dx00Sx00'

>>> source = open("hello.py").read()
#编译hello.py代码,并得到其PyCodeObject对象
>>> co = compile(source=source, filename="hello.py", mode="exec")
#查看代码中的常量
>>> co.co_consts
(3, <code object hello at 0x000001B955BD5420, file "hello.py", line 3>, 'hello', 'main part', None)
#查看代码中的符号名称
>>> co.co_names
('global_var', 'hello', 'print')
#hello.py得到的PyCodeObject中的常量中包含hello这个函数对应的代码块
>>> co.co_consts[1].co_consts
(None, 'nihao')
>>> co.co_consts[1].co_names
('print',)
>>> co.co_consts[1].co_code
b'tx00dx01x83x01x01x00dx00Sx00'

总而言之,无论你是否看懂了上面的文字描述,到了这里,你都需要建立的一个认知就是在python虚拟机中,每一个代码块都对应一个PyCodeObject对象。一个源代码文件对应一个PyCodeObject,源代码中的每个函数也是一个PyCodeObject。事实上,我们在执行完hello.py后文件夹底下的hello.pyc文件就是虚拟机把PyCodeObject序列化到了文件中的结果。这样下次再执行hello.py的时候,如果检测到该代码没有进行过改动,就可以直接load进来hello.pyc文件中的PyCodeObject,而不需要再重新编译。有了PyCodeObject,python的虚拟机/执行器就可以从该对象中依次读入每一条字节码指令并在当前的上下文环境中执行这条字节码指令。如此反复运行,直到所有我们期望的操作都被完成。PyCodeObject中实际包含了代码的静态信息,那么运行时的上下文环境又怎么保存的呢?这就是我们接下来要介绍的PyFrameObject。

PyFrameObject

前面我们提过说Python虚拟机实际上就是在模拟操作系统运行可执行文件的过程,那么首先我们就来以下面的代码为例了解下在一台普通的x86机器上可执行文件是以一种什么样的方式运行的,这对我们去理解python虚拟机的运行原理有很大的帮助。

void f(int x, int b){
    printf("a = %d, b = %d",a,b);
}
void g(){
    f(1,2);
}
int main(){
    g();
}

上述的三个函数形成了一个调用链,main->g->f, 执行上述的程序流程进入到函数f时,该程序的栈帧如下图所示。图中的【调用者的栈帧】就是函数g的栈帧,【当前栈帧】就是函数f的栈帧。下图所示的系统中运行时栈是从地址空间的高地址向低地址延伸的,当g中调用f时,操作系统就在g的栈帧之后创建f的栈帧。为了保证函数f执行完之后程序能够回到g中继续向下执行,系统就会保存g的栈帧的栈指针esp和帧指针ebp。当f执行完成后,系统就会把esp和ebp的值恢复为创建f的栈帧之前的值,这样程序的流程又回到了函数g中,而程序的工作空间就又回到了函数g的栈帧中。我们需要注意的是,当f执行完之后,esp和ebp被赋值指向g的栈帧,意味着调用f时创建的栈帧被销毁了,当我们在g中再次调用f时,就会重新为f创建栈帧。这时f的执行是无状态记忆的,即每次执行f都是从头执行,一直执行到结束或者出错。而我们说协程能保存其运行状态,就是指协程从中间退出时,其能够实现栈帧环境的保存,当下次再执行该协程时,可以不用完全创建新的栈帧从头执行指令,而是可以加载之前的栈帧,然后从之前的栈帧中当时的环境继续向下执行。

4472c8785a43e34cdedcfc12627e01bf.png
函数调用栈帧切换

python虚拟机既然模拟操作系统对可执行文件的执行,那么就需要模拟上述的栈帧的创建、切换和回跳。那么python中是如何进行栈帧环境的模拟的呢?答案就是PyFrameObject。即当函数切换的时候创建新的PyFrameObject,然后执行该栈帧中的指令,执行结束之后,跳回到调用者的PyFrameObject中继续原先的执行。PyFrameObject的定义如下:

typedef struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* 执行环境链上的前一个frame*/
    PyCodeObject *f_code;       /* PyCodeObject对象,即该栈帧中的代码*/
    PyObject *f_builtins;       /* 内建对象符号表 */
    PyObject *f_globals;        /* 全局符号表 */
    PyObject *f_locals;         /* 局部符号表 */
    PyObject **f_valuestack;    /* points after the last local */

    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 */
   
} PyFrameObject;

我们注意到PyFrameObject中有一个f_back指针指向另一个PyFrameObject,事实上随着函数的调用不断发生,会产生很多的PyFrameObject对象,这些对象形成一个如下图所示的执行环境链表,这正是对x86机器上栈帧间关系的模拟。在x86上,栈帧之间通过esp和ebp指针建立关系,使新的栈帧在结束之后能顺利回到旧的栈帧中,而python是利用f_back来完成这个操作的。除此之外还需要强调的是,我们调用一个函数的时候就会为该函数生成一个PyFrameObject,而该PyFrameObject中的f_code就是该函数对应的PyCodeObject。(前文提过,一个函数块就对应一个PyCodeObject)

1263f68133a8c423538dcf0a94b001a1.png
python执行的某个时刻的运行时环境

阅读到这里,希望大家能建立起来的认知就是当进行函数调用的时候,我们首先会为该函数对应的代码块的PyCodeObject创建一个执行环境PyFrameObject,这个PyFrameObject保留了该函数执行的所有运行环境,当该函数执行完毕时其对应生成的PyFrameObject就会被销毁,内存被释放。当下次再次执行的时候,又会再次创建一个新的PyFrameObject。即只要一个代码块被再次执行,我们就为该代码块创建一个新的PyFrameObject来保存所有运行时环境信息,运行时环境包括当前执行的指令行数,该行指令之前的变量的值等等。

协程运行时环境是如何保存的

有了上面的PyFrameObject的概念,协程与函数的区别就非常好理解了。无论协程还是函数,其被执行的时候python虚拟机都会为之创建PyFrameObject来保存运行时环境,二者的区别在于当函数执行结束或者意外退出的时候,其对应的栈帧也就是对应的PyFrameObject被销毁释放了,但是协程对应的PyFrameObject被保留了,这样当下一次执行该协程函数的时候我们就可以接着之前的执行环境继续向下执行,而不需要从头执行。而由于PyFrameObject是由链表管理起来的,保留协程的PyFrameObject只需要不释放相应的内存并保存指向该协程的PyFrameObject的指针即可。

一个基于C++的简易Python执行器实现

希望读到这里,大家心里对什么是协程的运行状态保存已经有大致的了解了。接下来我们就通过研究一个简易版的Python虚拟机的实现来看具体的实现过程是怎么样的。这里推荐一个我之前看到的一个Python解释器的实现,代码写的简单易懂,读完之后能对整个Python执行器由很好的理解。限于篇幅就不展开讲了,只在这里贴下代码,大家有兴趣可以去研究一下:

hinus/pythonvm​gitee.com
5e883ca14e40942f427a1f32a2099c48.png

结语

本篇主要介绍了Python虚拟机实现的一些概念和技术来分析协程的运行时状态是如何被保留的。在最后我们也发现,保留协程的运行时环境就只需要保留协程被执行时的栈帧环境即PyFrameObject即可,这样当下一次执行该协程的时候,我们就不需要为协程重新创建PyFrameObject,而是直接load之前的PyFrameObject继续执行即可。如果大家对python虚拟机感兴趣,建议大家可以阅读下上面的源码,会非常的有帮助。最后,欢迎大家关注我的知乎技术专栏~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值