时间:2024-01-06 10:24:01 | 来源:网站运营
时间:2024-01-06 10:24:01 来源:网站运营
为何Python不像JVM那样推出个“虚拟机版”?:Python 是有自带的虚拟机的哈,只是90%学习 Python 的小伙伴没有意识到罢了!而且,你学习 Python 完全没太大必要研究 Python 虚拟机,知道有这个东西就行了,不像学习 Java 一定要把 Java 虚拟机研究一遍。python code.py
就可以运行编写的代码,而无需使用类似于 javac
或者 gcc
进行编译。那么,Python 解释器是真的一行一行读取 Python 源代码而后执行吗? 实际上,Python 在执行程序时和 Java、C# 一样,都是先将源码进行编译生成字节码,然后由虚拟机进行执行,只不过 Python 解释器把这两步合二为一了而已。Python 虚拟机我们常说 Python 一是门解释型语言,只需要敲下 python code.py
就可以运行编写的代码,而无需使用类似于 javac
或者 gcc
进行编译。那么,Python 解释器是真的一行一行读取 Python 源代码而后执行吗? 实际上,Python 在执行程序时和 Java、C# 一样,都是先将源码进行编译生成字节码,然后由虚拟机进行执行,只不过 Python 解释器把这两步合二为一了而已。javac
对源代码进行编译,但是并不直接编译成机器语言,而是和 Python 一样,编译成字节码,而后由 JVM 进行执行。从这一点上来看,Python 和 Java 非常类似,只不过 Python 的编译过程由解释器完成,用户也可以手动的对 Python 源代码进行编译,生成 .pyc
文件,节省那么一丢丢的时间。python -m compileall <dir>
通过运行上述命令可对 <dir>
目录下所有的 Python 文件进行编译,编译结果将会存放于该目录下的 __pycache__
的 .pyc
文件中。compile
以及 exec
两个方法,前者将源代码编译成为 Code Object 对象,Code Object 对象中即保存着源代码所对应的字节。而 exec
方法则是运行 Python 语句或者是由 compile
方法所返回的 Code Object。exec
方法可直接运行 Python 语句,其参数并一定需要是 Code Object。>>> snippet = "for i in range(3): print(f'Output: {i}')">>> result = compile(snippet, "", "exec")>>> result<code object <module> at 0x7f8e7e6471e0, file "", line 1>>>> exec(result)Output: 0Output: 1Output: 2
在上述代码中定义了一个非常简单的 Python 代码片段,其作用就是在标准输出中打印 0,1,2 这三个数而已。通过 compile
方法对该片段进行编译,得到 Code Object 对象,并将该对象交由 exec
函数执行。下面来具体看下返回的 Code Object 中到底包含了什么。cpython/Include/code.h
中定义了 PyCodeObject
结构体,即 Code Object 对象:/* Bytecode object */typedef struct { PyObject_HEAD /* Python定长对象头 */ PyObject *co_code; /* 指令操作码,即字节码 */ PyObject *co_consts; /* 常量列表 */ PyObject *co_names; /* 名称列表(不一定是变量,也可能是函数名称、类名称等) */ PyObject *co_filename; /* 源码文件名称 */ ... /* 省略若干字段 */} PyCodeObject;
字段 co_code
即为 Python 编译后字节码,其它字段在此处可暂时忽略。字节码的格式为人类不可阅读格式,其形式通常是这样的:>>> result.co_codeb'x/x1ee/x00d/x00/x83/x01D/x00]/x12Z/x01e/x02d/x01e/x01/x9b/x00/x9d/x02/x83/x01/x01/x00q/nW/x00d/x02S/x00'
这个时候我们需要一个”反汇编器”来将字节码转换成人类可阅读的格式,”反汇编器”打引号的原因是在 Python 中并不能称为真正的反汇编器。>>> import dis>>> dis.dis(result.co_code) 0 SETUP_LOOP 30 (to 32) 2 LOAD_NAME 0 (0) 4 LOAD_CONST 0 (0) 6 CALL_FUNCTION 1 8 GET_ITER >> 10 FOR_ITER 18 (to 30) 12 STORE_NAME 1 (1) 14 LOAD_NAME 2 (2) 16 LOAD_CONST 1 (1) 18 LOAD_NAME 1 (1) 20 FORMAT_VALUE 0 22 BUILD_STRING 2 24 CALL_FUNCTION 1 26 POP_TOP 28 JUMP_ABSOLUTE 10 >> 30 POP_BLOCK >> 32 LOAD_CONST 2 (2) 34 RETURN_VALUE
dis
方法将返回字节码的助记符(mnemonics),和汇编语言非常类似,从这些助记符的名称上我们就可以大概猜出解释器将要执行的动作,例如 LOAD_NAME
加载名称,LOAD_CONST
加载常量。所以,我们完全可以将这些助记符看作是汇编指令,而指令的操作数则在助记符后面描述。例如 LOAD_NAME
操作,其操作数的下标为 0,而在源代码中使用过的名称保存在 co_names
字段中,所以 LOAD_NAME 0
即表示加载 result.co_names[0]
:>>> result.co_names[0]'range'
又比如 LOAD_CONST
操作,其操作数的下标也为 0,只不过这次操作数不再保存在 co_names
,而是 co_consts
中,所以 LOAD_CONST 0
则表示加载 result.co_consts[0]
:>>> result.co_consts[0]3
由于 Code Object 对象保存了常量、变量、名称等一系列的上下文内容,所以可以直接对该对象进行反汇编操作:>>> dis.dis(result) 1 0 SETUP_LOOP 30 (to 32) 2 LOAD_NAME 0 (range) 4 LOAD_CONST 0 (3) ...
现在,我们可以对 Python 字节码做一下小结了。Python 在编译某段源码时,并不会直接返回字节码,而是返回一个 Code Object 对象,字节码则保存在该对象的 co_code
字段中。由于字节码是一个二进制字节序列,无法直接进行阅读,所以需要通过”反汇编器”(dis
模块)将字节码转换成人类可读的助记符。助记符的形式和汇编语言非常类似,均由操作指令+操作数所组成。dict
实现。命名空间可以分为 3 类: 内置命名空间,全局命名空间与局部命名空间,在作用域存在嵌套的特殊情况下,可能还会有闭包命名空间。len
, dis
),内置异常(Exception
)等。>>> import builtins>>> builtins.__dict__
dict
对象,其中保存了模块中的变量名、类名、函数名等等。在字节码中,全局变量的导入使用 LOAD_GLOBAL
。number = 10def foo():number += 10print(number)if **name** == "**main**":foo()UnboundLocalError: local variable 'number' referenced before assignment
在运行上述代码时将会抛出 UnboundLocalError
异常,这简直莫名其妙,在其它语言中,上述代码都能够正常运行,以 C 语言为例:#include <stdio.h>int number = 10;int main() { number += 10; printf("%d/n", number); // 正常运行并打印结果: 20}
但是在 Python 中却抛出了异常,这又是为什么? 官方在 Python FAQ 给出了相关解释,原文如下:In Python, variables that are only referenced inside a function are implicitly global. If a variable is assigned a value anywhere within the function’s body, it’s assumed to be a local unless explicitly declared as global.简单来说,当我们在函数中引用一个变量时,Python 将隐式地默认该变量为全局变量。但是,一旦变量在没有
global
关键字修饰的情况下进行了赋值操作,Python 会将其作为局部变量处理。number += 10
进行了赋值动作,此时 number
为局部变量,该函数中又没有声明该局部变量,故而抛出异常。Python 这么做的目的就是为了防止开发者者在某些函数中修改了全局变量而又不自知,通过显式地添加 global
关键字来确保开发者知道自己在做什么。这正如 Python 之禅所述的,Explicit is better than implicit。import disdef foo(): number = 10 print(number)if __name__ == "__main__": print(dis.dis(foo))0 LOAD_CONST 1 (10)2 STORE_FAST 0 (number)4 LOAD_GLOBAL 0 (print)6 LOAD_FAST 0 (number)
STORE_FAST
将当前变量压入到函数运行栈中,而 LOAD_FAST
则从函数运行栈取出该变量。LOAD_FAST
之前必须存在 STORE_FAST
,否则在运行时将会抛出异常。对于最初的例子而言,在未添加 global
关键字的情况下,语句 number += 10
将会直接执行 LOAD_FAST
指令,而此时当前变量并未压入至当前函数运行栈。import logging as loggerdef foo(func): def wrapper(*args, **kwargs): logger.info(f"Execute func: {func.__name__}") func(*args, **kwargs) return wrapper
在 foo
闭包函数中,参数 func
即属于闭包命名空间,内层函数 wrapper
在寻找变量时,若局部命名空间内无此变量,将会于闭包命名空间中进行查找。def foo(): number = 10 def bar(): number += 10 return bar
正如同在局部命名空间中提到的一样,当一个变量在函数中被赋值时,Python 默认将其作为全局变量,既不是局部变量,也不是这里提到的闭包空间变量。所以,当我们在实际运行 bar
方法时,同样会得到 UnboundLocalError
异常。在这里如果想要使用 foo
函数中的 number
变量的话,需要使用 nonlocal
关键字进行修饰,让 Python 去 bar
函数的最近外层,也就是 foo
寻找该变量的定义。class Reader(object): BUFFER_SIZE = 4096 class ReaderInternal(object): def __init__(self): self._buffer_size = BUFFER_SIZE * 2 # ...if __name__ == "__main__": Reader.ReaderInternal()
在执行 Reader.ReaderInternal()
语句时,将会抛出 NameError
的异常,表示 BUFFER_SIZE
未定义。X
时,将会按照 Local -> Enclosing -> Global -> Builtin 的顺序进行查找,俗称 LEGB 规则。PyFrameObject
表示,于源码 cpython/Include/frameobject.h
中定义。typedef struct _frame { PyObject_VAR_HEAD /* Python固定长度对象头 */ struct _frame *f_back; /* 指向上一个栈帧的指针 */ PyCodeObject *f_code; /* Code Object代码对象,其中包含了字节码 */ PyObject *f_builtins; /* 内建命名空间字典(PyDictObject) */ PyObject *f_globals; /* 全局命名空间字典(PyDictObject) */ PyObject *f_locals; /* 局部命名空间表(通常是数组) */ int f_lasti; /* 上一条指令编号 */ ...} PyFrameObject;
可以看到,在一个栈帧中包含了 Code Object 代码对象,三个命名空间表,上一个栈帧指针等信息。可以说,PyFrameObject
对象包含了 Python 虚拟机执行所需的全部上下文。在 Python 源码层面,可以通过 sys
模块中的 _getframe
方法来获取当前函数运行时的栈帧,方法将返回 FrameType
类型,其实就是 PyFrameObject
简化后的 Python 结构。import sysdef first(): middle()def middle(): finish()def finish(): print_frame()def print_frame(): current_frame = sys._getframe() while current_frame: print(f"func name: {current_frame.f_code.co_name}") print("*" * 20) current_frame = current_frame.f_backif __name__ == "__main__": first()func name: print_frame****************************************func name: finish****************************************func name: middle****************************************func name: first****************************************func name: <module>****************************************
在 Python 开始执行该程序时,首先创建一个用于执行模块代码对象的栈帧对象,也就是 module
。随着一个一个的函数调用,不同的栈帧对象将会被创建,并且压入至运行栈中,而连接这些栈帧对象的纽带就是 f_back
指针。当栈顶的函数执行完毕开始返回时,将沿着 f_back
指针方向一直到当前调用链的起始位置。cpython/Python/ceval.c
中,入口函数有两个,一个是 PyEval_EvalCode
,另一个则是 PyEval_EvalCodeEx
,最终的实际调用函数为 _PyEval_EvalCodeWithName
,所以我们只需要关注该函数即可。_PyEval_EvalCodeWithName
函数的主要作用为进行函数调用的例常检查,例如校验函数参数的个数、类型,校验关键字参数等。除此之外,该函数将会初始化栈帧对象并将其交给 PyEval_EvalFrame
函数进行处理,最终由 _PyEval_EvalFrameDefault
函数真正的运行指令。_PyEval_EvalFrameDefault
函数定义超过了 3K 行,绝大部分的逻辑其实都是 switch-case
: 根据指令类型执行相应的逻辑。for (;;) { switch (opcode) { case TARGET(LOAD_CONST): { /* 加载常量 */ ... } case TARGET(ROT_TWO): { /* 交换两个变量 */ ... } case TARGET(FORMAT_VALUE):{ /* 格式化字符串 */ ... }
可以看到 TARGET()
调用中的参数其实就是 dis
方法返回的助记符,当我们在分析助记符的具体实现逻辑时,可以在该文件中找到对应的 C 实现方法。ConcurrentHashMap
,或者是 Golang 中的 sync.Map
,这是因为 Python 中的容器(list, dict)本身就是并发安全的,但是在这些容器的源码中并没有发现定义 mutex
,也就是说,Python 容器的并发安全并不是通过互斥锁实现的。关键词:虚拟