【Python】虚拟机执行框架

0x01 Python虚拟机中的执行环境

Python虚拟机在执行Python代码时,是模拟操作系统执行可执行文件的过程。

ESPExtended stack pointer)为栈指针,用于指向栈的栈顶(下一个压入栈的活动记录的顶部),而EBPextended base pointer)为帧指针,指向当前活动记录的底部

对于一个函数而言,其所有对局部变量的操作都在自己的栈帧中完成,而函数之间的调用则通过创建新的栈帧完成。

操作系统运行时栈的某一时刻

Python源代码编译完成后,所有的字节码指令和静态信息都在PyCodeObject对象中,但是只有这些还是不够的,还需要执行环境

执行环境(PyFrameObject)

Python执行test.py的第一条表达式时,会创建一个执行环境APyFrameObject *A),所有的字节码都在这个执行环境A中执行,Python可以从这个执行环境中获取变量的值,也可以根据字节码指令修改执行环境中变量的值,以影响后续的字节码指令;

这样的过程会一直持续下去,直到发生函数的调用,执行函数调用的字节码时,会在当前执行环境A之外创建一个新的执行环境BPyFrameObject *B)。

这块的执行环境对应运行时栈中的栈帧。

typedef struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;  /* 执行环境链上的前一个frame previous frame, or NULL */
    PyCodeObject *f_code;   /* PyCodeObject对象 code segment */
    PyObject *f_builtins;   /* builtin名字空间 builtin symbol table (PyDictObject) */
    PyObject *f_globals;    /* global名字空间 global symbol table (PyDictObject) */
    PyObject *f_locals;     /* local名字空间 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 */

    /* If an exception is raised in this frame, the next three are used to
     * record the exception info (if any) originally in the thread state.  See
     * comments before set_exc_info() -- it's not obvious.
     * Invariant:  if _type is NULL, then so are _value and _traceback.
     * Desired invariant:  all three are NULL, or all three are non-NULL.  That
     * one isn't currently true, but "should be".
     */
    PyObject *f_exc_type, *f_exc_value, *f_exc_traceback;

    PyThreadState *f_tstate;
    int f_lasti;        /* 上一条字节码指令在f_code中的偏移位置 Last instruction if called */
    /* As of 2.3 f_lineno is only valid when tracing is active (i.e. when
       f_trace is set) -- at other times use PyCode_Addr2Line instead. */
    int f_lineno;       /* 当前字节码对应的源代码行 Current line number */
    int f_iblock;       /* index in f_blockstack */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    // 动态内存,维护(局部变量+cell对象集合+free对象集合+运行时栈)所需的空间
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
} PyFrameObject;
  • f_back指向上一个栈帧,用这个域来模拟操作系统中栈帧的关系(操作系统使用ebpesp来维护栈帧关系)
  • f_code中存放的是待执行的PyCodeObject对象
  • f_builtinsf_globalsf_locals3个独立的名字空间(这里可以明确执行环境和名字空间的关系
  • PyObject_VAR_HEAD表示PyFrameObject是一个变长的对象,类似于PyStringObject一样,每个新对象的大小可能不一样。那变长的内存是用来干啥的呢?
    • 每一个PyFrameObject对象都维护了一个PyCodeObject对象
    • 在将.py代码编译成PyCodeObject对象的时候,会计算这段Code Block执行过程中所需的栈空间大小,存储在PyCodeObject.co_stacksize域,不同的Code Block所需的栈空间不同,这个就是变长的内存的用处
  • Python在执行计算的时候也需要一些内存空间(存储临时变量等内容),我们将其称为“运行时栈”

PyFrameObject中的动态内存空间

// frameobject.c 有删减
PyFrameObject *
PyFrame_New(PyThreadState *tstate, PyCodeObject *code, PyObject *globals,
            PyObject *locals)
{
    // 从PyThreadState对象中获取得到当前线程的执行环境
    PyFrameObject *back = tstate->frame;
    PyFrameObject *f;
    PyObject *builtins;
    Py_ssize_t i;
    Py_ssize_t extras, ncells, nfrees;
    ncells = PyTuple_GET_SIZE(code->co_cellvars);
    nfrees = PyTuple_GET_SIZE(code->co_freevars);
    // 4部分构成了PyFrameObject维护的动态内存区
    extras = code->co_stacksize + code->co_nlocals + ncells + nfrees;
    // 创建新的执行环境
    f = PyObject_GC_NewVar(PyFrameObject, &PyFrame_Type, extras);
    // 计算初始化时“运行时栈”的栈顶
    extras = code->co_nlocals + ncells + nfrees;
    // f_valuestack 维护“运行时栈”的栈底,f_stacktop 维护“运行时栈”的栈顶
    f->f_valuestack = f->f_localsplus + extras;
    f->f_stacktop = f->f_valuestack;
    // 链接当前执行环境
    f->f_back = back;
    f->f_tstate = tstate;
    return f;
}
新创建的PyFrameObject对象
  • PyFrameObject.f_localsplus域包含了PyCodeObject对象中存储的局部变量(co_nlocals)、co_freevarsco_cellvars和运行时栈。
  • f_valuestack指向栈底,f_stacktop指向栈顶。

0x02 名字、作用域和名字空间

名字:就是一个符号,用于代表某些事物的一个有助于记忆的字符序列。名字最终的作用并不在于名字本身,而在于名字所代表的事物。

作用域:Python是具有静态作用域(也称词法作用域)的,即作用域由源程序的文本决定的,一个Code Block就是一个作用域,在写Python代码的时候,作用域就已经确定了

名字空间:名字空间是与作用域对应的动态的东西,上面提到的f_builtinsf_globalsf_locals 3个独立的名字空间

约束与名字空间

赋值语句(具有赋值行为的语句,import xxxclass A(object):都算)是一类特殊的语句,因为它会影响名字空间。

赋值语句被执行了以后,会得到(name,obj)这样的关联关系,称之为约束。赋值语句就是建立约束的地方,约束的容身之处就是名字空间。

Python中名字空间用PyDictObject对象表示,约束刚好与键值对对应起来。

一个对象的名字空间中所有的名字都称为对象的属性。这样看,赋值语句也具有“设置对象属性的行为”,那“访问对象属性”的动作称为属性引用,属性引用就是使用另一个名字空间中的名字(eg:import A&print A.a

作用域与名字空间

一个module对应一个名字空间,module内部可能有多个名字空间,每一个名字空间与一个作用域对应。

一个约束起作用的那一段程序正文区域称为这个约束的作用域。一个作用域则是指一段程序正文区域,一旦出了这个正文区域,这个约束就不起作用了。

位于一个作用域中的代码可以直接访问作用域中出现的名字,称为“直接访问”(直接print a不需要print A.a)。就是指不用加上属性引用方式的访问修饰符“.”。访问名字这样的行为被称为“名字引用”。

Python的名字空间的行为被它所支持的嵌套作用域影响,产生了最内嵌套作用域规则LEGB):由一个赋值语句引进的名字在这个赋值语句所在的作用域里是可见(起作用)的,而且在其内部嵌套的每一个作用域里也是可见的,除非嵌套的作用域内,被引进了同样名字所遮蔽

LGB

Python中,一个module对应的源文件定义了一个作用域,称为global作用域(对应global名字空间);

一个函数定义了一个local作用域(对应local名字空间);

Python自身还定义了一个最顶层作用域,builtin作用域(对应builtin名字空间);

Python 2.2之前这3个作用域就已经存在,被称为LGB规则:名字引用动作沿着local作用域、global作用域、builtin作用域的顺序查找名字的对应约束。

LEGB

Python 2.2开始,Python引入嵌套函数,这时候又加了一层enclosing作用域,称为LEGB

嵌套函数会将在直接外围作用域中使用到的约束,与嵌套函数捆绑在一起,捆绑起来的整体被称为“闭包”。

global表达式

当一个作用域中出现global语句时,就意味着我们强制命令Python对某个名字的引用只参考global名字空间,而不用去管LEGB规则。

属性引用与名字引用

属性引用实质上也是一种名字引用,其本质就是到名字空间中去查找一个名字所引用的对象。但是属性引用可以视为一种特殊的名字引用,它不受LEGB规则制约。

属性引用没有嵌套作用域,在名字空间中查找名字时,有就有,没有就没有,没有更多的规则限制。

名字引用遵循的LEGB规则不会越过module的边界

0x03 Python虚拟机的运行框架(伪CPU)

Python启动后,首先会进行Python运行时环境的初始化。这里的运行时环境和上面提到的执行环境是不同的概念。运行时环境是一个全局的概念,而执行环境实际上就是一个栈帧(PYFrameObject),是一个和某一个Code Block对应的概念。

初始化完成以后,就开始运行程序,入口就是PyEval_EvalFramEx(),这个函数就是Python虚拟机的具体实现

PyEval_EvalFramEx()函数代码有2000多行,就不列出所有代码了。

  • 首先会初始化一些变量;然后初始化了堆栈的栈顶指针,使其指向f->f_stacktop
  • PyCodeObject对象的co_code域中保存着字节码指令和字节码指令的参数,其实它就是C中的普通字符数组,使用3个变量来遍历整个字节码字符数组:first_instr永远指向字节码指令序列的开始位置,next_instr永远指向下一条待执行的字节码指令位置,f_lasti指向上一条已经执行过的字节码指令位置。
  • 执行字节码指令的动作是如何实现的呢?
    • Python虚拟机执行字节码指令的整体框架:其实就是一个for循环加上一个巨大的switch/case结构。
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
{
  ......
  // 获取当前活动线程的线程状态对象(PyThreadState)
  PyThreadState *tstate = PyThreadState_GET();
  // 设置线程状态对象中的frame
  tstate->frame = f;
  co = f->f_code;
  names = co->co_names;
  consts = co->co_consts;

  why = WHY_NOT;
  ......
  for (;;) {
    fast_next_opcode:
        f->f_lasti = INSTR_OFFSET();
        // 获取字节码指令
        opcode = NEXTOP();
        oparg = 0; 
        // 如果指令有参数,获取参数
        if (HAS_ARG(opcode))
            oparg = NEXTARG();
    dispatch_opcode:
      ......
  }
}

在这个执行框架中,对字节码的一步一步的遍历是通过下面几个宏来实现的:

// ceval.c
/* Code access macros */
#define INSTR_OFFSET()  ((int)(next_instr - first_instr))
#define NEXTOP()        (*next_instr++)
#define NEXTARG()       (next_instr += 2, (next_instr[-1]<<8) + next_instr[-2])
#define PEEKARG()       ((next_instr[2]<<8) + next_instr[1])
#define JUMPTO(x)       (next_instr = first_instr + (x))
#define JUMPBY(x)       (next_instr += (x))
  • Python的字节码有的是带参数的,有的是没有参数的(通过HAS_ARG宏来判断),所以next_instr的位移可能是不同的,但是无论如何,next_instr总是指向Python的下一条要执行的字节码。
  • 然后Python在获得了一条字节码指令和其所需的指令参数后,会对字节码利用switch进行判断,根据不同的指令来执行不同的case语句,case语句就是Python对字节码指令的具体实现。
  • 在成功执行完一条字节码指令后,Python的执行流程会跳转到fast_next_opcode或者for循环处,不管如何,接下来的动作都是获取下一条字节码指令和指令参数,然后执行指令对应的case语句。
  • 如此一条一条的遍历co_code中包含的所有字节码指令,最终完成了对Python程序的执行。

why变量:它指示了在退出这个巨大的for循环时Python执行引擎的状态。
在执行字节码过程中可能会报错或出现异常(exception),在退出的时候我们需要知道原因,why就扮演者这个角色。

/* Status code for main loop (reason for stack unwind) */
enum why_code {
        WHY_NOT =       0x0001, /* No error */
        WHY_EXCEPTION = 0x0002, /* Exception occurred */
        WHY_RERAISE =   0x0004, /* Exception re-raised by 'finally' */
        WHY_RETURN =    0x0008, /* 'return' statement */
        WHY_BREAK =     0x0010, /* 'break' statement */
        WHY_CONTINUE =  0x0020, /* 'continue' statement */
        WHY_YIELD =     0x0040  /* 'yield' operator */
};

0x04 Python运行时环境初探

进程(process)不是与机器指令序列相对应的活动对象,这个与可执行文件中机器指令序列对应的活动对象是线程(Thread),而进程是线程的活动对象

讲人话就是说,在CPU上执行任务的不是进程而是线程。

前面已经讲了执行框架执行环境,现在就来了解一下运行时环境

Python在初始化时会创建一个主线程,所以其运行时环境中存在一个主线程。Python中的一个线程就是操作系统上的一个原生线程

前面讲了Python虚拟机的运行框架(for&switch/case),这个运行框架就是对CPU的抽象(就把这个执行框架认为是软CPU就行)。Python中所有线程都使用这个软CPU来完成计算工作。

真实机器上的任务切换机制对应到Python中,就是使不同的线程轮流使用虚拟机的机制。

CPU切换任务时需要保存线程上下文环境,对于Python来说,切换线程之前也需要保存当前线程信息。Python中使用PyThreadState对象来保存线程状态信息。一个线程拥有一个PyThreadState对象。

Python对于进程的抽象是由PyInterpreterState对象来实现的。一个进程中可以有多个线程,线程的同步通过全局解释器锁GILGlobal Interpreter Lock)来实现。

typedef struct _is {

    struct _is *next;
    struct _ts *tstate_head;    // 模拟进程环境中的线程集合
    PyObject *modules;
    PyObject *sysdict;
    PyObject *builtins;
    PyObject *modules_reloading;

    PyObject *codec_search_path;
    PyObject *codec_search_cache;
    PyObject *codec_error_registry;
} PyInterpreterState;

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

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

    Py_tracefunc c_profilefunc;
    Py_tracefunc c_tracefunc;
    PyObject *c_profileobj;
    PyObject *c_traceobj;

    PyObject *curexc_type;
    PyObject *curexc_value;
    PyObject *curexc_traceback;

    PyObject *exc_type;
    PyObject *exc_value;
    PyObject *exc_traceback;

    PyObject *dict;  /* Stores per-thread state */

    int tick_counter;

    int gilstate_counter;

    PyObject *async_exc; /* Asynchronous exception to raise */
    long thread_id; /* Thread id where this tstate was created */

    int trash_delete_nesting;
    PyObject *trash_delete_later;
} PyThreadState;
  • 在每个PyThreadState对象中,会维护一个栈帧列表,以与PyThreadState对象的线程中的函数调用机制对应。
  • PyEval_EvalFrameEx()函数中,会将当前线程状态对象(PyThreadState)中的frame设置为当前的执行难环境(frame)。
// ceval.c
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
{
  ......
  // 获取当前活动线程的线程状态对象(PyThreadState)
  PyThreadState *tstate = PyThreadState_GET();
  // 设置线程状态对象中的frame
  tstate->frame = f;
  co = f->f_code;
  names = co->co_names;
  consts = co->co_consts;

  why = WHY_NOT;
  ......
  for (;;) {
    fast_next_opcode:
        f->f_lasti = INSTR_OFFSET();
        // 获取字节码指令
        opcode = NEXTOP();
        oparg = 0; 
        // 如果指令有参数,获取参数
        if (HAS_ARG(opcode))
            oparg = NEXTARG();
    dispatch_opcode:
      ......
  }
}
  • 而在建立新的PyFrameObject对象时,则从当前活动线程的线程状态对象PyThreadState中取出旧的frame,建立PyFrameObject链表。
Python运行时环境

欢迎关注微信公众号(coder0x00)或扫描下方二维码关注,我们将持续搜寻程序员必备基础技能包提供给大家。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容

  • 原文地址:C语言函数调用栈(一)C语言函数调用栈(二) 0 引言 程序的执行过程可看作连续的函数调用。当一个函数执...
    小猪啊呜阅读 4,585评论 1 19
  • 一、温故而知新 1. 内存不够怎么办 内存简单分配策略的问题地址空间不隔离内存使用效率低程序运行的地址不确定 关于...
    SeanCST阅读 7,774评论 0 27
  • 在一个寂静无人的夜晚 闲的无聊,便进入QQ语聊大厅和陌生人聊天。这里不需要问你的年龄、性别、工作、是否单身,...
    小七姑娘678阅读 364评论 0 1
  • 我向远方呼唤 你没有听见 我不怪你 因为你离我实在太远 我向大山呼唤 你没听见 我不怪你 因为你在山的那一边 我向...
    港仔1961阅读 475评论 0 1
  • 三十年后,外聘到广东五邑大学,终于看到了类似的产品,邻居章老师两岁的小孙子穿了一双响声鞋,每走一步就发出悦耳声响,...
    颂奇2018阅读 213评论 0 1