Python 源码剖析笔记 3-Python 执行原理初探
之前写了几篇源码剖析笔记, 然而慢慢觉得没有从一个宏观的角度理解 python 执行原理的话, 从底向上分析未免太容易让人疑惑, 不如先从宏观上对 python 执行原理有了一个基本了解, 再慢慢探究细节, 这样也许会好很多. 这也是最近这么久没有更新了笔记了, 一直在看源码剖析书籍和源码, 希望能够从一个宏观层面理清 python 执行原理. 人说读书从薄读厚, 再从厚读薄方是理解了真意, 希望能够达到这个境地吧, 加了个油.
1 Python 运行环境初始化
在看怎么执行之前, 先要简单的说明一下 python 的运行时环境初始化. python 中有一个解释器状态对象 PyInterpreterState 用于模拟进程(后面简称进程对象), 另外有一个线程状态对象 PyThreadState 模拟线程(后面简称线程对象).python 中的 PyInterpreterState 结构通过一个链表链接起来, 用于模拟操作系统多进程. 进程对象中有一个指针指向线程集合, 线程对象则有一个指针指向其对应的进程对象, 这样线程和进程就关联了起来. 当然, 还少不了一个当前运行线程对象_PyThreadState_Current 用来维护当前运行的线程.
1.1 进程线程初始化
python 中调用 PyInitialize()函数来完成运行环境初始化. 在初始化函数中, 会创建进程对象 interp 以及线程对象并在进程对象和线程对象建立关联, 并设置当前运行线程对象为刚创建的线程对象. 接下来是类型系统初始化, 包括 int,str,bool,list 等类型初始化, 这里留到后面再慢慢分析. 然后, 就是另外一个大头, 那就是系统模块初始化. 进程对象 interp 中有一个 modules 变量用于维护所有的模块对象, modules 变量为字典对象, 其中维护 (name, module) 对应关系, 在 python 中对应着 sys.modules.
1.2 模块初始化
系统模块初始化过程会初始化
__builtin__, sys, __main__, site
等模块. 在 python 中, 模块对象是以 PyModuleObject 结构体存在的, 除了通用的对象头部, 其中就只有一个字典字段 md_dict. 模块对象中的 md_dict 字段存储的内容是我们很熟悉的, 比如__name__, __doc__等属性, 以及模块中的方法等.
在__builtin__模块初始化中, md_dict 中存储的内容就包括内置函数以及系统类型对象, 如 len,dir,getattr 等函数以及 int,str,list 等类型对象. 正因为如此, 我们才能在代码中直接用 len 函数, 因为根据 LEGB 规则, 我们能够在__builtin__模块中找到 len 这个符号. 几乎同样的过程创建 sys 模块以及__main__模块. 创建完成后, 进程对象 interp->builtins 会被设置为__builtin__模块的 md_dict 字段, 即模块对象中的那个字典字段. 而 interp->sysdict 则是被设置为 sys 模块的 md_dict 字段.
sys 模块初始化后, 其中包括前面提到过的 modules 以及 path,version,stdin,stdout,maxint 等属性, exit,getrefcount,_getframe 等函数. 注意这里是设置了基本的 sys.path(即 python 安装目录的 lib 路径等), 第三方模块的路径是在 site 模块初始化的时候添加的.
需要说明的是,__main__模块是个特殊的模块, 在我们写第一个 python 程序时, 其中的
__name__ == "__main__"
中的__main__指的就是这个模块名字. 当我们用 python xxx.py 运行 python 程序时, 该源文件就可以当作是名为__main__的模块了, 而如果是通过其他模块导入, 则其名字就是源文件本身的名字, 至于为什么, 这个在后面运行一个 python 程序的例子中会详细说明. 其中还有一点要说明的是, 在创建__main__模块的时候, 会在模块的字典中插入
("__builtins__", __builtin__ module)
对应关系. 在后面可以看到这个模块特别重要, 因为在运行时栈帧对象 PyFrameObject 的 f_buitins 字段就会被设置为__builtin__模块, 而栈帧对象的 locals 和 globals 字段初始会被设置为__main__模块的字典.
另外, site 模块初始化主要用来初始化 python 第三方模块搜索路径, 我们经常用的 sys.path 就是这个模块设置的了. 它不仅将 site-packages 路径加到 sys.path 中, 还会把 site-packages 目录下面的. pth 文件中的所有路径加入到 sys.path 中.
下面是一些验证代码, 可以看到 sys.modules 中果然有了
__builtin__, sys, __main__
等模块. 此外, 系统的类型对象都已经位于__builtin__模块字典中.
- In [13]: import sys
- In [14]: sys.modules['__builtin__'].__dict__['int']
- Out[14]: int
- In [15]: sys.modules['__builtin__'].__dict__['len']
- Out[15]: <function len>
- In [16]: sys.modules['__builtin__'].__dict__['__name__']
- Out[16]: '__builtin__'
- In [17]: sys.modules['__builtin__'].__dict__['__doc__']
- Out[17]: "Built-in functions, exceptions, and other objects.\n\nNoteworthy: None is the `nil' object; Ellipsis represents `...'in slices."
- In [18]: sys.modules['sys']
- Out[18]: <module 'sys' (built-in)>
- In [19]: sys.modules['__main__']
- Out[19]: <module '__main__' (built-in)>
好了, 基本工作已经准备妥当, 接下来可以运行 python 程序了. 有两种方式, 一种是在命令行下面的交互, 另外一种是以 python xxx.py 的方式运行. 在说明这两种方式前, 需要先介绍下 python 程序运行相关的几个结构.
1.3 Python 运行相关数据结构
python 运行相关数据结构主要由 PyCodeObject,PyFrameObject 以及 PyFunctionObject. 其中 PyCodeObject 是 python 字节码的存储结构, 编译后的 pyc 文件就是以 PyCodeObject 结构序列化后存储的, 运行时加载并反序列化为 PyCodeObject 对象. PyFrameObject 是对栈帧的模拟, 当进入到一个新的函数时, 都会有 PyFrameObject 对象用于模拟栈帧操作. PyFunctionObject 则是函数对象, 一个函数对应一个 PyCodeObject, 在执行 def test(): 语句的时候会创建 PyFunctionObject 对象. 可以这样认为, PyCodeObject 是一种静态的结构, python 源文件确定, 那么编译后的 PyCodeObject 对象也是不变的; 而 PyFrameObject 和 PyFunctionObject 是动态结构, 其中的内容会在运行时动态变化.
PyCodeObject 对象
python 程序文件在执行前需要编译成 PyCodeObject 对象, 每一个 CodeBlock 都会是一个 PyCodeObject 对象, 在 Python 中, 类, 函数, 模块都是一个 Code Block, 也就是说编译后都有一个单独的 PyCodeObject 对象, 因此, 一个 python 文件编译后可能会有多个 PyCodeObject 对象, 比如下面的示例程序编译后就会存在 2 个 PyCodeObject 对象, 一个对应 test.py 整个文件, 一个对应函数 test. 关于 PyCodeObject 对象的解析, 可以参见我之前的文章 Python pyc 格式解析 https://www.jianshu.com/p/03d81eb9ac9b , 这里就不赘述了.
- # 示例代码 test.py
- def test():
- print "hello world"
- if __name__ == "__main__":
- test()
PyFrameObject 对象
python 程序的字节码指令以及一些静态信息比如常量等都存储在 PyCodeObject 中, 运行时显然不可能只是操作 PyCodeObject 对象, 因为有很多内容是运行时动态改变的, 比如下面这个代码 test2.py, 虽然 1 和 2 处的字节码指令相同, 但是它们执行结果显然是不同的, 这些信息显然不能在 PyCodeObject 中存储, 这些信息其实需要通过 PyFrameObject 也就是栈帧对象来获取. PyFrameObject 对象中有 locals,globals,builtins 三个字段对应 local,global,builtin 三个名字空间, 即我们常说的 LGB 规则, 当然加上闭包, 就是 LEGB 规则. 一个模块对应的文件定义一个 global 作用域, 一个函数定义一个 local 作用域, python 自身定义了一个顶级作用域 builtin 作用域, 这三个作用域分别对应 PyFrameObject 对象的三个字段, 这样就可以找到对应的名字引用. 比如 test2.py 中的 1 处的 i 引用的是函数 test 的局部变量 i, 对应内容是字符串 "hello world", 而 2 处的 i 引用的是模块的 local 作用域的名字 i, 对应内容是整数 123(注意模块的 local 作用域和 global 作用域是一样的). 需要注意的是, 函数中局部变量的访问并不需要去访问 locals 名字空间, 因为函数的局部变量总是不变的, 在编译时就能确定局部变量使用的内存位置.
- # 示例代码 test2.py
- i = 123
- def test():
- i = 'hello world'
- print i #1
- test()
- print i #2
PyFunctionObject 对象
PyFunctionObject 是函数对象, 在创建函数的指令 MAKE_FUNCTION 中构建. PyFunctionObject 中有个 func_code 字段指向该函数对应的 PyCodeObject 对象, 另外还有 func_globals 指向 global 名字空间, 注意到这里并没有使用 local 名字空间. 调用函数时, 会创建新的栈帧对象 PyFrameObject 来执行函数, 函数调用关系通过栈帧对象 PyFrameObject 中的 f_back 字段进行关联. 最终执行函数调用时, PyFunctionObject 对象的影响已经消失, 真正起作用的是 PyFunctionObject 的 PyCodeObject 对象和 global 名字空间, 因为在创建函数栈帧时会将这两个参数传给 PyFrameObject 对象.
1.4 Python 程序运行过程浅析
说完几个基本对象, 现在回到之前的话题, 开始准备执行 python 程序. 两种方式交互式和直接 python xxx.py 虽然有所不同, 但最终归于一处, 就是启动虚拟机执行 python 字节码. 这里以 python xxx.py 方式为例, 在运行 python 程序之前, 需要对源文件编译成字节码, 创建 PyCodeObject 对象. 这个是通过 PyAST_Compile 函数实现的, 至于具体编译流程, 这就要参看编译原理那本龙书了, 这里暂时当做黑盒好了, 因为单就编译这部分而言, 一时半会也说不清楚(好吧, 其实是我也没有学好编译原理). 编译后得到 PyCodeObject 对象, 然后调用
PyEval_EvalCode(co, globals, locals)
函数创建 PyFrameObject 对象并执行字节码了. 注意到参数里面的 co 是 PyCodeObject 对象, 而由于运行 PyEval_EvalCode 时创建的栈帧对象是 Python 创建的第一个 PyFrameObject 对象, 所以 f_back 为 NULL, 而且它的 globals 和 locals 就是__main__模块的字典对象. 如果我们不是直接运行, 而是导入一个模块的话, 则还会将 python 源码编译后得到的 PyCodeObject 对象保存到 pyc 文件中, 下次加载模块时如果这个模块没有改动过就可以直接从 pyc 文件中读取内容而不需要再次编译了.
执行字节码的过程就是模拟 CPU 执行指令的过程一样, 先指向 PyFrameObject 的 f_code 字段对应的 PyCodeObject 对象的 co_code 字段, 这就是字节码存储的位置, 然后取出第一条指令, 接着第二条指令... 依次执行完所有的指令. python 中指令长度为 1 个字节或者 3 个字节, 其中无参数的指令长度是 1 个字节, 有参数的指令长度是 3 个字节(指令 1 字节 + 参数 2 字节).
python 虚拟机的进程, 线程, 栈帧对象等关系如下图所示:
py.png
2 Python 程序运行实例说明
程序猿学习一门新的语言往往都是从 hello world 开始的, 一来就跟世界打个招呼, 因为接下来就要去面对程序语言未知的世界了. 我学习 python 也是从这里开始的, 只是以前并不去深究它的执行原理, 这回是逃不过去了. 看看下面的栗子.
- # 示例代码 test3.py
- i = 1
- s = 'hello world'
- def test():
- k = 5
- print k
- print s
- if __name__ == "__main__":
- test()
这个例子代码不多, 不过也涉及到 python 运行原理的方方面面(除了类机制那一块外, 类机制那一块还没有理清楚, 先不理会). 那么按照之前部分说的, 执行 python test3.py 的时候, 会先初始化 python 进程和线程, 然后初始化系统模块以及类型系统等, 然后运行 python 程序 test3.py. 每次运行 python 程序都是开启一个 python 虚拟机, 由于是直接运行, 需要先编译为字节码格式, 得到 PyCodeObject 对象, 然后从字节码对象的第一条指令开始执行. 因为是直接运行, 所以 PyCodeObject 也就没有序列化到 pyc 文件保存了. 下面可以看下 test3.py 的 PyCodeObject, 使用 python 的 dis 模块可以看到字节码指令.
- In [1]: source = open('test3.py').read()
- In [2]: co = compile(source, 'test3.py', 'exec')
- In [3]: co.co_consts
- Out[3]:
- (1,
- 'hello world',
- <code object test at 0x1108eaaf8, file "run.py", line 4>,
- '__main__',
- None)
- In [4]: co.co_names
- Out[4]: ('i', 's', 'test', '__name__')
- In [5]: dis.dis(co) ## 模块本身的字节码, 下面说的整数, 字符串等都是指 python 中的对象, 对应 PyIntObject,PyStringObject 等.
- 1 0 LOAD_CONST 0 (1) # 加载常量表中的第 0 个常量也就是整数 1 到栈中.
- 3 STORE_NAME 0 (i) # 获取变量名 i, 出栈刚刚加载的整数 1, 然后存储变量名和整数 1 到 f->f_locals 中, 这个字段对应着查找名字时的 local 名字空间.
- 2 6 LOAD_CONST 1 ('hello world')
- 9 STORE_NAME 1 (s) #同理, 获取变量名 s, 出栈刚刚加载的字符串 hello world, 并存储变量名和字符串 hello world 的对应关系到 local 名字空间.
- 4 12 LOAD_CONST 2 (<code object test at 0xb744bd10, file "test3.py", line 4>)
- 15 MAKE_FUNCTION 0 #出栈刚刚入栈的函数 test 的 PyCodeObject 对象, 以 code object 和 PyFrameObject 的 f_globals 为参数创建函数对象 PyFunctionObject 并入栈
- 18 STORE_NAME 2 (test) #获取变量 test, 并出栈刚入栈的 PyFunctionObject 对象, 并存储到 local 名字空间.
- 9 21 LOAD_NAME 3 (__name__) ##LOAD_NAME 会先依次搜索 local,global,builtin 名字空间, 当然我们这里是在 local 名字空间能找到__name__.
- 24 LOAD_CONST 3 ('__main__')
- 27 COMPARE_OP 2 (==) ## 比较指令
- 30 JUMP_IF_FALSE 11 (to 44) ## 如果不相等则直接跳转到 44 对应的指令处, 也就是下面的 POP_TOP. 因为在 COMPARE_OP 指令中, 会设置栈顶为比较的结果, 所以需要出栈这个比较结果. 当然我们这里是相等, 所以接着往下执行 33 处的指令, 也是 POP_TOP.
- 33 POP_TOP
- 10 34 LOAD_NAME 2 (test) ## 加载函数对象
- 37 CALL_FUNCTION 0 ## 调用函数
- 40 POP_TOP ## 出栈函数返回值
- 41 JUMP_FORWARD 1 (to 45) ## 前进 1 步, 注意是下一条指令地址 + 1, 也就是 44+1=45
- >> 44 POP_TOP
- >> 45 LOAD_CONST 4 (None)
- 48 RETURN_VALUE #返回 None
- In [6]: dis.dis(co.co_consts[2]) ## 查看函数 test 的字节码
- 5 0 LOAD_CONST 1 (5)
- 3 STORE_FAST 0 (k) #STORE_FAST 与 STORE_NAME 不同, 它是存储到 PyFrameObject 的 f_localsplus 中, 不是 local 名字空间.
- 6 6 LOAD_FAST 0 (k) #相对应的, LOAD_FAST 是从 f_localsplus 取值
- 9 PRINT_ITEM
- 10 PRINT_NEWLINE #打印输出
- 7 11 LOAD_GLOBAL 0 (s) #因为函数没有使用 local 名字空间, 所以, 这里不是 LOAD_NAME, 而是 LOAD_GLOBAL, 不要被名字迷惑, 它实际上会依次搜索 global,builtin 名字空间.
- 14 PRINT_ITEM
- 15 PRINT_NEWLINE
- 16 LOAD_CONST 0 (None)
- 19 RETURN_VALUE
按照我们前面的分析, test3.py 这个文件编译后其实对应 2 个 PyCodeObject, 一个是本身 test3.py 这个模块整体的 PyCodeObject, 另外一个则是函数 test 对应的 PyCodeObject. 根据 PyCodeObject 的结构, 我们可以知道 test3.py 字节码中常量 co_consts 有 5 个, 分别是整数 1, 字符串'hello world', 函数 test 对应的 PyCodeObject 对象, 字符串__main__, 以及模块返回值 None 对象. 恩, 从这里可以发现, 其实模块也是有返回值的. 我们同样可以用 dis 模块查看函数 test 的字节码.
关于字节码指令, 代码中做了解析. 需要注意到函数中局部变量如 k 的取值用的是 LOAD_FAST, 即直接从 PyFrameObject 的 f_localsplus 字段取, 而不是 LOAD_NAME 那样依次从 local,global 以及 builtin 查找, 这是函数的特性决定的. 函数的运行时栈也是位于 f_localsplus 对应的那片内存中, 只是前面一部分用于存储函数参数和局部变量, 而后面那部分才是运行时栈使用, 这样逻辑上运行时栈和函数参数以及局部变量是分离的, 虽然物理上它们是连在一起的. 需要注意的是, python 中使用了预测指令机制, 比如 COMPARE_OP 经常跟 JUMP_IF_FALSE 或 JUMP_IF_TRUE 成对出现, 所以如果 COMPARE_OP 的下一条指令正好是 JUNP_IF_FALSE, 则可以直接跳转到对应代码处执行, 提高一定效率.
此外, 还要知道在运行 test3.py 的时候, 模块的 test3.py 栈帧对象中的 f_locals 和 f_globals 的值是一样的, 都是__main__模块的字典. 在 test3.py 的代码后面加上如下代码可以验证这个猜想.
- ... #test3.py 的代码
- if __name__ == "__main__":
- test()
- print locals() == sys.modules['__main__'].__dict__ # True
- print globals() == sys.modules['__main__'].__dict__ # True
- print globals() == locals() # True
正式因为如此, 所以 python 中函数定义顺序是无关的, 不需要跟 C 语言那样在调用函数前先声明函数. 比如下面 test4.py 是完全正常的代码, 函数定义顺序不影响函数调用, 因为在执行 def 语句的时候, 会执行 MAKE_FUNCTION 指令将函数对象加入到 local 名字空间, 而 local 和 global 此时对应的是同一个字典, 所以也相当于加入了 global 名字空间, 从而在运行函数 g 的时候是可以找到函数 f 的. 另外也可以注意到, 函数声明和实现其实是分离的, 声明的字节码指令在模块的 PyCodeObject 中执行, 而实现的字节码指令则是在函数自己的 PyCodeObject 中.
- #test4.py
- def g():
- print 'function g'
- f()
- def f():
- print 'function f'
- g()
- ~
来源: https://juejin.im/entry/5b76341ff265da27e43d1c05