pengkunstone 2019-06-28
作者:秘塔科技算法研究员 Qian Wan
前几天IEEE Spectrum发布了第五届顶级语言交互排行榜,Python语言继续稳坐第一把交椅,并且相比去年的排行情况,拉开了与第二名的距离(去年第二名的排名得分为99.7)。从下图能看出Python的优势还是很明显的,而且在Web、企业级和嵌入式这三种应用类别的流行度都很高。
冰冻三尺非一日之寒。Python语言自1990年由Guido van Rossum第一次发布至今已经快三十年的历史,它支持多种操作系统,并以CPython为参考实现。Python语言在很多领域都有杀手级的应用框架,如深度学习方面有PyTorch和Tensorflow,自然语言处理有NLTK,Web框架有Django、Flask,科学计算有Numpy、Scipy,计算机视觉有OpenCV,科学绘图有Matplotlib,爬虫有Scrapy,凡此种种,不一而足。面对这么多不同种类的Python应用框架,下面一些问题是值得我们思考的:
三言两语可能很难比较全面的回答上面一些问题,而且只研究Python语言得到的答案也可能会有失偏颇。但是Python语言的源代码能够为回答这些问题提供一些线索,而且通过阅读源码能让我们在使用Python语言时看到一些以前我们看不到的细节,就如同《黑客帝国》电影里的Neo一样能看到母体世界的源代码,也能像Neo那样在机器的世界里飞天遁地。
我们使用pyenv花几分钟时间来构建Python运行环境,它不仅可以与操作系统原生的Python环境隔离,还能支持多种版本的Python环境,另外也支持在同一Python版本下的多个虚拟环境,可以用来隔离不同应用的Python依赖包。部署代码如下
$ git clone https://github.com/pyenv/pyenv.git ~/.pyenv $ echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc $ echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc $ git clone https://github.com/pyenv/pyenv-virtualenv.git ${HOME}/.pyenv/plugins/pyenv-virtualenv $ echo 'eval "$(pyenv init -)"' >> ~/.bashrc $ echo 'eval "$(pyenv virtualenv-init -)"' >> ~/.bashrc $ CONFIGURE_OPTS=--enable-shared $HOME/.pyenv/bin/pyenv install 3.6.6 -k -v $ $HOME/.pyenv/bin/pyenv virtualenv 3.6.6 py3.6
部署好了之后每次运行下面命令就能替换掉系统原生的Python环境
$ pyenv activate py3.6
安装后的目录结构如下
~/.pyenv/sources/3.6.6/Python-3.6.6
~/.pyenv/versions/3.6.6/include/python3.6m/
~/.pyenv/versions/3.6.6/lib/libpython3.6m.dylib
要深入剖析Python的源代码,就要对源码中几个大的模块的作用有一个初步的认识。我们进入到源码目录~/.pyenv/sources/3.6.6/Python-3.6.6
,其中几个跟Python语言直接相关的目录及其功能如下
Include
:C头文件,与部署好的头文件目录~/.pyenv/versions/3.6.6/include/python3.6m/
中的文件一致(严格来说,部署好的头文件目录中会多一个自动生成的pyconfig.h
文件),这些头文件定义了Python语言的底层抽象结构。Lib
:Python语言库,这部分不参与Python的编译,而是用Python语言写好的模块库。Modules
:用C语言实现的Python内置库。Objects
:Python内置对象的C语言实现以及抽象接口的实现。Parser
:Python编译器的前端,词法分析器和语法分析器。后者就是基于龙书的LL(1)实现的。Programs
:可执行文件~/.pyenv/versions/3.6.6/bin/python
的源码所在的目录。Python
:Python虚拟机所在的目录,也是整个Python语言较为核心的部分。使用下面的图示能更好的展示这些目录之前的相互关系,虚线箭头表示提供接口定义,实线箭头表示提供服务,自顶向下的结构也体现了语言设计在架构上的层次关系。
Include
目录从上面这些模块的大致功能上分析,我们可以判断出Include
、Objects
和Python
中的代码比较重要。我们先看一下这三个目录包含的代码量
$ cat Include/* Objects/* Python/* | wc -l cat: Objects/clinic: Is a directory cat: Objects/stringlib: Is a directory cat: Python/clinic: Is a directory 215478
21万行代码的阅读量有点略大,我们还是先挨个看看这些目录中文件的命名、大小以及一些注释,看能不能得到一些线索。
$ wc -l Include/*.h | sort -k1 ... 324 pystate.h 370 objimpl.h 499 dynamic_annotations.h 503 pyerrors.h 637 Python-ast.h 767 pyport.h 1077 object.h 1377 abstract.h 2342 unicodeobject.h 15980 total
从文件名和文件大小可以初步判断object.h
和abstract.h
是两个比较重要的头文件,实际上它们定义了Python底层的抽象对象以及统一的抽象接口。unicodeobject.h
虽然体积大,但是有很多跟它类似的头文件,如boolobject.h
、longobject.h
、floatobject.h
等等,这些头文件应该是内置类型的头文件,我们可以暂时不去理会这些文件,对语言的总体理解不会造成困难。
为了不漏掉一些重要的头文件,我们快速阅读一下其他头文件中可能包含的一些引导性的注释,发现这些头文件也比较重要:
Python.h
:元头文件,通常在写Python的C扩展时会包含它。ceval.h
:作为Python/ceval.c
的头文件,而Python/ceval.c
负责运行编译后的代码。code.h
:包含字节码相关的底层抽象。compile.h
:抽象语法树的编译接口。objimpl.h
:跟内存相关的抽象对象高层接口,如内存分配,初始化,垃圾回收等等。pystate.h
:线程状态与解释器状态以及它们的接口。pythonrun.h
:Python代码的语法分析与执行接口。通过以上筛选,我们看看还剩下多少代码:
$ cat object.h abstract.h objimpl.h Python.h ceval.h code.h compile.h pystate.h pythonrun.h | wc -l 3950
核心头文件压缩到不到4千行。
Objects
目录用类似的思路,我们能从Objects
目录中筛选出一些比较重要的文件
abstract.c
:抽象对象的接口实现。codeobject.c
:字节码对象的实现。object.c
:通用对象操作的实现。obmalloc.c
:内存分配相关实现。typeobject.c
:Type
对象实现。统计一下代码量
$ wc -l abstract.c codeobject.c object.c obmalloc.c typeobject.c 3246 abstract.c 921 codeobject.c 2048 object.c 2376 obmalloc.c 7612 typeobject.c 16203 total
一下子新增了1.6万行,毕竟是实打实的C语言实现。
另外还有一些具象化的对象实现文件,虽然它们跟longobject.c
和dictobject.c
之类的对象实现类似,都是具体的对象,但是它们跟Python语言特性比较相关,在这里也把它们列出来,做为备份。
classobject.c
:类对象实现。codeobject.c
:代码对象实现。frameobject.c
:Frame对象实现。funcobject.c
:函数对象实现。methodobject.c
:方法对象实现。moduleobject.c
:模块对象实现。顺便统计下行数
$ wc -l classobject.c codeobject.c frameobject.c funcobject.c methodobject.c moduleobject.c 648 classobject.c 921 codeobject.c 1038 frameobject.c 1031 funcobject.c 553 methodobject.c 802 moduleobject.c 4993 total
Objects
目录中合计约2.1万行。通过探索这些源代码,我们看出Python的一个设计原则就是:一切皆对象。
严格来说,只有Python语言暴露给外部使用的部分才抽象成了对象,而一些仅在内部使用的数据结构则没有对象封装,如后面会提到的解释器状态和线程状态等。
Python
目录依然经过一轮筛选,能得到下面这些比较重要的文件
ast.c
:将具体语法树转换成抽象语法树,主要函数是PyAST_FromNode()
ceval.c
:执行编译后的字节码。ceval_gil.h
:全局解释器锁(Global Interpreter Lock,GIL)的接口。compile.c
:将抽象语法树编译成Python字节码。pylifecycle.c
:Python解释器的顶层代码,包括解释器的初始化以及退出。pystate.c
:线程状态与解释器状态,以及它们的接口实现。pythonrun.c
:Python解释器的顶层代码,包括解释器的初始化以及退出。能够注意到,pylifecycle.c
和pythonrun.c
的功能是类似的,实际上查阅Python开发历史记录能发现前者是因为开发需要从后者分离出来的。统计一下代码的数量:
$ wc -l ast.c ceval.c ceval_gil.h compile.c pystate.c pythonrun.c 5277 ast.c 5600 ceval.c 270 ceval_gil.h 5329 compile.c 958 pystate.c 1596 pythonrun.c 19030 total
这样浓缩下来Include
、Objects
和Python
三个文件夹中比较重要的代码一共大约4.4万行,先不说我们这样筛选出来的一波有没有漏掉重要信息,其他很多支持性的代码都还没有包含进去。至少目前有了一个大的轮廓,接下来在深入代码的时候可以慢慢扩展开。
前面讨论了Python源码的主要目录结构,以及其中主要的源文件。这里我们换一个思路,看看一个Python源文件是如何在Python解释器里面运行的。调用Python的可执行文件~/.pyenv/versions/3.6.6/bin/python
和调用我们编写的其他C语言程序在方式上并没有太大区别,不同之处在于Python可执行文件读取的Python源文件,并执行其中的代码。Python之于C就如同C之于汇编,只是Python编译的字节码在Python虚拟机上运行,汇编代码直接在物理机上运行(严格来说还需要转换成机器代码)。
以下面这条Python源文件运行为例来考察Python可执行文件的执行过程(大家可以玩玩这个生命游戏,运气好能看到滑翔机)。
$ python ~/.pyenv/sources/3.6.6/Python-3.6.6/Tools/demo/life.py
既然Python的可执行文件是C语言编译成的,那么一定有C语言的入口函数main
,它就位于Python源码的./Programs/python.c
文件中。
int main(int argc, char **argv) { // ... res = Py_Main(argc, argv_copy); // ... }
顺藤摸瓜,我们可以梳理出调用树的主干部分。下面的树形结构中,冒号左边为函数名,右边表示函数定义所在的C源文件,树形结构表示函数定义中包含的其他函数嵌套调用。
main: Programs/python.c └─ Py_Main: Modules/main.c ├─ Py_Initialize: Python/pylifecycle.c │ ├─ PyInterpreterState_New: Python/pystate.c │ ├─ PyThreadState_New: Python/pystate.c │ ├─ _PyGILState_Init: Python/pystate.c │ └─ _Py_ReadyTypes: Objects/object.c ├─ run_file: Modules/main.c │ └─ PyRun_FileExFlags: Python/pythonrun.c │ ├─ PyParser_ASTFromFileObject: Python/pythonrun.c │ │ ├─ PyParser_ParseFileObject: Parser/parsetok.c │ │ └─ PyAST_FromNodeObject: Python/ast.c │ └─ run_mod: Python/pythonrun.c │ ├─ PyAST_CompileObject: Python/compile.c │ └─ PyEval_EvalCode: Python/ceval.c │ ├─ PyFrame_New: Objects/frameobject.c │ └─ PyEval_EvalFrameEx: Python/ceval.c └─ Py_FinalizeEx: Python/pylifecycle.c
不得不说,Python源码的可读性非常好,这些函数的命名方式都是自解释的。Python源文件的运行大致分为两个步骤:
Py_Initialize
:初始化过程,主要涉及到解释器状态、线程状态、全局解释器锁以及内置类型的初始化。run_file
:运行源文件,可以分为三个小步骤
PyParser_ASTFromFileObject
:对源文件的文本进行语法分析,得到抽象语法树。PyAST_CompileObject
:将抽象语法树编译成PyCodeObject
对象。PyEval_EvalCode
:在Python虚拟机中运行PyCodeObject
对象。Py_FinalizeEx
:源文件执行结束后的清理工作。用流程图的形式表示上述调用树的主干部分应该更加清晰明了。
需要指出的是,解释器循环真正执行的是PyEval_EvalFrameEx
函数,它的参数是PyFrameObject
对象,该对象为PyCodeObject
对象提供了执行的上下文环境,所以PyFrameObject
和PyCodeObject
都是非常核心的对象。Python提供了一些工具让我们可以查看编译后的代码对象,即对编译好的函数进行反汇编。下面的例子虽然简单,但已经能给人清晰的直观认识
>>> from dis import dis >>> class C(object): ... def __init__(self, x): ... self.x = x ... def add(self, y): ... return self.x + y ... >>> dis(C) Disassembly of __init__: 3 0 LOAD_FAST 1 (x) 2 LOAD_FAST 0 (self) 4 STORE_ATTR 0 (x) 6 LOAD_CONST 0 (None) 8 RETURN_VALUE Disassembly of add: 5 0 LOAD_FAST 0 (self) 2 LOAD_ATTR 0 (x) 4 LOAD_FAST 1 (y) 6 BINARY_ADD 8 RETURN_VALUE
反编译的结果是一系列的操作码。头文件Include/opcode.h
包含了Python虚拟机的所有操作码。能看出上面simple_tuple
和simple_list
这两个函数反编译后的最大区别么?tuple
是作为常量被加载进来的,而list
的生成还需要调用BUILD_LIST
。原因在于tuple
在Python的运行时会进行缓存,也就是每次使用无需请求操作系统内核以获得内存空间。对比一下使用tuple
和list
的耗时情况
>>> %timeit x = (1, 2, 3) 10.9 ns ± 0.0617 ns per loop (mean ± std. dev. of 7 runs, 100000000 loops each) >>> %timeit x = [1, 2, 3] 46.5 ns ± 0.186 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
从统计结果能看出,tuple
的在效率上的优势非常明显。如果某一段调用特别频繁的代码中有些list
可以替换成tuple
,千万不要犹豫。
我们可以试着为文章开头第一个问题提供一些思路。我们知道,对计算机做任何形式上的抽象都有可能伤害到计算的效率,对于Python来说有以下几点
PyCodeObject
对象暴露出来这一点就能看出。所以为了提高Python程序的效率,我们需要深入了解Python对象的实现原理、PyCodeObject
的特性以及全局解释器和Python虚拟机的限制。之于文章开头的其他问题,我们将随着Python源码的深入研究慢慢展开。
现在我们对Python代码的运行有了一个宏观的理解,而且大量的细节都有待深入研究。通过对调用树主干部分的梳理,能看出其他比较重要的支持性模块还包括Python抽象对象PyObject
,抽象语法树及其编译,PyCodeObject
对象,PyFrameObject
对象,解释器状态,线程状态,全局解释器锁。在以后的文章中,我们会分别对这些模块进行探讨。