这是《Python源码剖析 — 深度探索动态语言核心技术》的阅读记录.
从这一篇开始, 我们就要和Python的虚拟机打交道了, 在开始本篇的正式内容之前, 我们先来讨论下Python程序的执行是怎样进行的吧.
众所周知, Python是一个解释型语言. 而我上个学期学习了《编译原理》这门课, 再加上书中的说明, 现在我想说,Python和Java, C#一样, 都是需要编译的语言.
大家在写Python的时候都会知道, 在我们执行过一个python程序之后, 会出现一个.pyc
文件. 这有没有想到什么? 没错, 有没有联想到Java的.class
文件? 事实上, 我们Python的执行过程仍然是类似Java的, 先通过解释器(interpreter)进行编译, 生成.pyc
文件, 这个文件中就是源程序的字节码集合, 接着, 通过虚拟机(virtual machine)进行一条条字节码的执行, 从而完成执行过程.
只不过, 这里的虚拟机和我们的JVM仍然是有区别的, 那就是Python的虚拟机要更上层一点, 也就是离机器底层更远一点.
接下来我们就从Python的Code对象开始看起.
PYC的起源
在我们使用Python的编译器进行代码编译的时候, 会产生许多信息, 包含字符串, 常量, 一些特殊操作等等, 在编译过程中, 这些静态信息就会被编译器收集起来, 储存到一个地方. 这个地方是哪里呢? 现在我们要想的稍微动态一点了, 因为现在我们开始研究虚拟机了嘛, 这个虚拟机可是在你的程序运行的时候一直也在运行的, 所以说这些信息其实也是存储在一个动态的运行时对象身上的. 这个对象就是我们待会要说的PyCodeObject
. 另外, 有的时候, 在我们结束Python程序的运行时, 会多出来一个叫做.pyc
的文件, 这个文件就是我们的PyCodeObject
对象的序列化.
所以其实说白了, 这个.pyc
文件就是PyCodeObject
对象在磁盘上的表现形式, 他们所代表的东西都是一样的. 这样做的一个好处就是, 在下一次执行Python程序的时候, 虚拟机可以通过读取这个文件从而直接在内存中build出Code对象.
Code对象初探
接下来我们就来看下这个PyCodeObject
是个什么样子的:
1 | typedef struct { |
现在我们也不是很了解这些都是起到什么作用的, 所以就先不细看了. 不过一个Code对象到底代表什么呢? 是一段代码? 还是一个py文件?
事实上, Python编译器在对Python源文件进行编译的的时候, 是将一个代码块就抽象成一个Code对象的. 那么什么是一个代码块(Code Block), 这个概念在其他的语言里面也有哦, 虽然有的时候意思发生了一点改变, 在这里代码块的意思是一个命名空间就是一个代码块了, 简单的说可以认为一个作用域的出现就是一个代码块的出现了, 例如:
1 | class A(): |
这一段代码运行的时候就会产生3个代码对象, 一个对应整个Py源文件的, 一个是class A
所带来的作用域, 一个是def foo()
所带来的.
命名空间将会是我们之后还会观察的东西, 在Python中, 一个符号所代表的意义是取决于命名空间的, 一个又一个的命名空间所生成的命名空间链, 虚拟机在执行的过程中, 会消耗一定量的时间和资源在这个链中检索, 从而寻找符号所代表的意义或者对象是什么.
其实在Python中, 我们可以通过调用他设计的与C一级PyCodeObject对象的code对象来访问, 直接来试一下吧:
1 | open("FloatRange.py").read() source = |
pyc文件初探
现在我们就要来研究下pyc文件了, 在此之前我们必须获取一个pyc才行, 那么说干就干随便写一段Python代码:
1 | print "Hello" |
执行.
接下来找一找它生成的__pycache__
中的 .pyc
. 哎? 怎么没有?
事实上, 我们在日常的编码中也可以发现这个事情, 有点时候就会生成__pycache__
有的时候就是没有. 至于原因, 或者说Python什么时候会生成pyc文件, 我简单的查了下知乎, 看了下文档. 大概就是:
**.pyc
的产生, 不是为了提升执行速度而存在的. 他只是为了能够更快的load
. 也就是说, 如果这个.py
不是为了load而存在的, 他就不需要存在于硬盘上(毕竟存到硬盘上还是要时间的啊). **
什么样的.py
会被load? 自然就是我们通过import
进来的模块啥的, 那么我们再做一个实验:
1 | #! /usr/bin/env python3 |
接着在另一个:
1 | #! /usr/bin/env python3 |
此时执行一下B.py就可以出发这个机制了, 你会看到熟悉的__pycache__
和A.cpython-36.pyc
. 因为A被load了.
其实在Python运行过程中, 如果遇到import XXX
这样的语句, 就会先去设定好的路径里面找XXX.pyc
或者XXX.dll
. 如果说找到了, 就会先对比时间戳, 如果有必要就会重新生成. 如果没有, 就会先去编译XXX.py
再将编译生成的PyCodeObject中间结果创建成.pyc
文件. 最后再加载生成的.pyc
就像前面说的, 将从这个.pyc
中PyCodeObject在内存中复制出来.
但是我们也可以手动的生成pyc或者pyo文件, 通过使用Python提供的编译工具:
1 | ➜ /tmp python |
这样就会生成.pyc
了.
接下来就到了解开.pyc
神秘面纱的时候了, 再次之前 我们必须要看看PyCodeObject有哪些域, 这样才可以理解pyc的格式.
还是把之前的Code对象的声明列出来, 这一次我们就写上每一个域的意义:
1 | typedef struct { |
有意思的是哪个字节码和源码行号的映射. Python不是简单的直接记录, 而是采取增量式的记录方式, 例如: 0, 1, 6, 1, 44, 5
这个的意思就是:
字节码偏移0位, 对应源代码第1行, 字节码偏移0+6位, 对应源代码第1+1行, 字节码偏移0+6+44位, 对应源代码1+1+5行.
OK, 现在就让我们来看下pyc到底是怎么被创建的吧.
创建
首先我们来到/Python/import.c
这个文件 (还记得吗, import触发pyc写入), 找到这个函数:
1 | static void |
首先 Python唯一性的打开一个文件, 总之就是各种尝试排他性的打开文件.
接着就是关键部分了, 这个函数写入了三个关键信息到了pyc文件中, 分别是:
- 幻数
- 时间信息
- PyCodeObject
所谓幻数, 其实就是一个约定好的整数值, 不同版本的Python会约定不同的幻数, 这其实就是为了保证兼容性的.
例如说, 当python尝试加载一个pyc文件中的CodeObject的时候, 会先检查文件中的幻数是否一致. 如果不一致就会拒绝加载. 那么为啥同样都是Python , 会出现不兼容的情况呢? 其实想来也很简单了. Python的版本迭代中, 字节码指令时会发生变化的, 可能旧版本的某些指令到了新版本, 发生了变化或者直接被舍弃了.
那么Python的幻数都是什么样子的呢, 我们可以在import.c
文件中看到
1 |
并且在上面的注释中, 我们可以看到当前版本Python所有的幻数的值.
接着这里, 我们会发现一个有意思的细节, 关于时间的写入, 先是写了一个0占了一个位置, 接着写完对象文件之后才去写入的真实时间戳.
关于Python写入这些数据到文件, 所有涉及到的函数和方法实现都存在在marshal.c
这个文件中. 总结一下的话, 其实就是下面的这些:
1 |
|
另外, 对于Object写入, 源代码实在是太长了, 这里就不列出来了, 不过实现的逻辑倒是十分简单的, 就是对应不同的PythonObject类型, 传入不同参数的调用底层w_byte
.
例如说, 当写入一个列表对象的时候, 就是将这个列表的内容写入到pyc中, 当加载的时候, 再根据这些数据进行列表对象的重新创建.
所以截取其中写入CodeObject的代码, 就是这个样子的:
1 | else if (PyCode_Check(v)) { |
嗯 其实就是把他的属性都写了一遍.
当你在阅读源代码的时候, 你会发现这个文件中还有很多写入函数和方法, 而且他们都有一个共同点, 那就是他们会在写入真正的数据之前写入一个似乎是表示类型的宏进去.
这是在干什么? 我们知道, w_byte
写入的是字节流, 这样做的一个最大的影响就是 所有的数据都是一个样的, Python无法从这些字节流中分析出这些内容是什么对象的. 这就是类型标识的作用, 通过写入这些约定的类型标识, 我们就可以重建对象.
关于这些类型, 也都定义在文件中了:
1 |
|
关于数值写入, 是最简单的了, 只需要写入一下类型, 接着把数值丢进去就行了.
但是对于字符串,可能就会有一点麻烦了.
首先我们来关注一下和pyc交互的时候, 有个关键的结构体, 叫做WFILE
. 简单的看, 就是一个对文件的简单的封装, 其中有一个域:
1 | PyObject *strings; /* dict on marshal, list on unmarshal */ |
根据后面的注释, 我们可以看出来, 在写入到pyc的时候, 这是一个字典对象, 当读出的时候, 这是一个列表对象.
可以在下面的代码看到, 在实际写入之前, 这个域就被创建了:
1 | void |
那么接下来我们具体的看下在w_object
函数中, 是怎么写入字符串的:
1 | else if (PyString_Check(v)) { |
还记得我们之前在说String的时候, 提到的Intern机制吗, 这里又要用到了. 写入过程主要的分成了三个判断:
- 非Intern字符串写入
- Intern字符串的首次写入
- Intern字符串的非首次写入
我们先从最简单的来看, 那就是写入普通的字符串, 也就是非Intern的字符串.
就做了两件事情, 写入长度和写入字符串本身. 很简单.
问题主要是关于Interned的字符串的, 我们发现这个地方分成首次和非首次写入两种情况. 为什么要这么分类呢? 先不直接解答, 我们看一下非首次写入的Intern字符串, 处理起来很简单, 先是写入了类型信息, 接着是一个long值, 也就是从字典中年获得的值.
另外一种判断情况, 我们发现这里的很多操作都和上面提到的那个strings
域有关系, 我们说这个东西在写入的时候是个字典, 那么这个字典到底写了什么内容? 来看一下代码:
1 | int ok; |
这里我们把错误处理也都略去了, 关键的代码其实就是这么一行:
1 | PyDict_SetItem(p->strings, v, o) >= 0; |
v是什么? 字符串本体, o是什么? 长度 其实也可以理解成是序号的概念.
这里主要的疑问就是为啥Python要这么设计, 这个序号有什么意义吗.
我们假设现在的字典里已经有了一个字符串, 为了解释方便就叫做string吧, 他的值是0, 接着又来了一个新的字符串, 叫做stringstring, 给它编号为1, 这个时候string又来了, 尽管我们之前曾经存了一个, 但是这个时候我又把它作为序号2加了进来, 先不说这里出现了键的冲突, 如果说这个string在之后又出现了多次, 那么这个WFILE
的string就会充满很多对于的信息, 我们并不需要存储这么多的无用信息. 也就是说 这就是区分的原因, 当已经有过字典中可以查询到的时候, 不如直接就把这个字符串的某个标识符给拿过来, 最简单的标识符 就是顺序标记的序号了. 这就是这么个部分的大体思想.
就这么结束了? 并不是 , 还记得之前提到的这个神奇的域吗:
1 | PyObject *strings; /* dict on marshal, list on unmarshal */ |
写入的时候是个字典, 读取的时候是个列表.
仔细想想, 这个序号 也只有在我们需要加载pyc构建对象的时候才会使用, 而这, 正好列表对象是可以支持索引的而字典是不可以的, 有没有一种巧妙的感觉?
加载
当我们加载pyc的时候, 我们会进行下面的操作:
1 | PyObject * |
可以看到, 在这里我们把strings域变成了一个新的列表对象, 注意这个地方的对象已经不是WFILE了, 而是RFILE, 但是其实他们的结构几乎是一样的:
1 | typedef WFILE RFILE; /* Same struct with different invariants */ |
多么直接!
这样就可以通过列表索引来直接获得位置上的字符串值了.
我们又考虑到了一个问题, 比如之前做测试的那一段简简单单的代码:
1 | class A(): |
这里只会产生3个代码对象. 如果我们把A类的定义写在另外一个文件中, 并且在当前文件中进行引用. 这样就会产生一个pyc文件, 但是问题来了, 3个代码对象怎么load, 写入到pyc文件中的, 只有A一个CodeObject呀?
这里Python是这样设计的, 它将A代码块产生的codeobject写入到pyc时, 会将其他的两个代码对象作为值丢到co_consts
这个域里面. 这样当进行加载的时候, 就会递归的进行load. 问题就这么解决了.
源文件中是如何进行嵌套的, 那么在pyc文件这种二进制文件中, 其实也是存在这样的嵌套结构的.
初探字节码和pyc文件解析
现在就让我们来看看Python定义的字节码是什么样子的吧. 在opcode.h
中定义了Python指定的字节码.
1 | /* Instruction opcodes for compiled code */ |
序号并不是一直连续的, 一共最后排到了143号, 当然一共就定义了104条字节码指令.
这些字节码, 有些需要参数传递, 有不需要, 这是怎么实现的呢.来看90号:
1 |
|
Python直接定义了一个宏, 用来判断是否是需要参数的, 只要是90号之后的都是需要参数的.
接着我们来尝试解析一个Pyc文件. 事实上, Python提供了解析的工具, 叫做dis
. 我们可以直接调用试试:
1 | import dis |
注意, 我这里使用的python2.7版本, 不同版本编译出来的字节码可能不会一样.
最左列是行数, 由于我之前有注释所以是从第8行开始的, 其实这个是代码的第一行. 对照着代码和这个字节码指令, 你会发现看起来也没有这么困难!