0x01 Python虚拟机中的执行环境
Python
虚拟机在执行Python
代码时,是模拟操作系统执行可执行文件的过程。
ESP
(Extended stack pointer
)为栈指针,用于指向栈的栈顶(下一个压入栈的活动记录的顶部),而EBP
(extended base pointer
)为帧指针,指向当前活动记录的底部
对于一个函数而言,其所有对局部变量的操作都在自己的栈帧中完成,而函数之间的调用则通过创建新的栈帧完成。
Python
源代码编译完成后,所有的字节码指令和静态信息都在PyCodeObject
对象中,但是只有这些还是不够的,还需要执行环境。
执行环境(PyFrameObject)
当
Python
执行test.py
的第一条表达式时,会创建一个执行环境A
(PyFrameObject *A
),所有的字节码都在这个执行环境A
中执行,Python
可以从这个执行环境中获取变量的值,也可以根据字节码指令修改执行环境中变量的值,以影响后续的字节码指令;这样的过程会一直持续下去,直到发生函数的调用,执行函数调用的字节码时,会在当前执行环境
A
之外创建一个新的执行环境B
(PyFrameObject *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
指向上一个栈帧,用这个域来模拟操作系统中栈帧的关系(操作系统使用ebp
和esp
来维护栈帧关系) -
f_code
中存放的是待执行的PyCodeObject
对象 -
f_builtins
、f_globals
、f_locals
是3
个独立的名字空间(这里可以明确执行环境和名字空间的关系) -
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.f_localsplus
域包含了PyCodeObject
对象中存储的局部变量(co_nlocals
)、co_freevars
、co_cellvars
和运行时栈。 -
f_valuestack
指向栈底,f_stacktop
指向栈顶。
0x02 名字、作用域和名字空间
名字:就是一个符号,用于代表某些事物的一个有助于记忆的字符序列。名字最终的作用并不在于名字本身,而在于名字所代表的事物。
作用域:
Python
是具有静态作用域(也称词法作用域)的,即作用域由源程序的文本决定的,一个Code Block
就是一个作用域,在写Python
代码的时候,作用域就已经确定了名字空间:名字空间是与作用域对应的动态的东西,上面提到的
f_builtins
、f_globals
、f_locals
3
个独立的名字空间
约束与名字空间
赋值语句(具有赋值行为的语句,import xxx
、class 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
对象来实现的。一个进程中可以有多个线程,线程的同步通过全局解释器锁GIL
(Global 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
链表。
欢迎关注微信公众号(coder0x00)或扫描下方二维码关注,我们将持续搜寻程序员必备基础技能包提供给大家。