Python源码漫游指南(一)

Python源码漫游指南(一)

做者:秘塔科技算法研究员 Qian Wan前端

前几天IEEE Spectrum发布了第五届顶级语言交互排行榜,Python语言继续稳坐第一把交椅,而且相比去年的排行状况,拉开了与第二名的距离(去年第二名的排名得分为99.7)。从下图能看出Python的优点仍是很明显的,并且在Web、企业级和嵌入式这三种应用类别的流行度都很高。python

clipboard.png

冰冻三尺非一日之寒。Python语言自1990年由Guido van Rossum第一次发布至今已经快三十年的历史,它支持多种操做系统,并以CPython为参考实现。Python语言在不少领域都有杀手级的应用框架,如深度学习方面有PyTorch和Tensorflow,天然语言处理有NLTK,Web框架有Django、Flask,科学计算有Numpy、Scipy,计算机视觉有OpenCV,科学绘图有Matplotlib,爬虫有Scrapy,凡此种种,不一而足。面对这么多不一样种类的Python应用框架,下面一些问题是值得咱们思考的:git

  1. 怎样使用Python语言能将程序的性能发挥到极致?
  2. 什么类型的单一语言框架不适合用Python来实现?
  3. 多语言框架中与Python语言的交互如何作到高效?
  4. 从架构的角度看,Python内部的架构设计如何?
  5. 从使用Python语言的角度,它适合于什么样的软件架构设计?
  6. 在多语言(Python与CUDA)、异构节点(CPU与GPU)、多业务类型(IO密集型与CPU密集型)以及跨区域(跨国多机房)的复杂系统中,Python语言的定位又如何?其余语言呢?

三言两语可能很难比较全面的回答上面一些问题,并且只研究Python语言获得的答案也可能会有失偏颇。可是Python语言的源代码可以为回答这些问题提供一些线索,并且经过阅读源码能让咱们在使用Python语言时看到一些之前咱们看不到的细节,就如同《黑客帝国》电影里的Neo同样能看到母体世界的源代码,也能像Neo那样在机器的世界里飞天遁地。github

Python环境的部署

咱们使用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

安装后的目录结构以下sass

  • Python源码:~/.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语言直接相关的目录及其功能以下bash

  • 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语言较为核心的部分。

使用下面的图示能更好的展现这些目录以前的相互关系,虚线箭头表示提供接口定义,实线箭头表示提供服务,自顶向下的结构也体现了语言设计在架构上的层次关系。数据结构

clipboard.png

Include目录

从上面这些模块的大体功能上分析,咱们能够判断出IncludeObjectsPython中的代码比较重要。咱们先看一下这三个目录包含的代码量架构

$ 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.habstract.h是两个比较重要的头文件,实际上它们定义了Python底层的抽象对象以及统一的抽象接口
unicodeobject.h虽然体积大,可是有不少跟它相似的头文件,如boolobject.hlongobject.hfloatobject.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.cType对象实现。

统计一下代码量

$ 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.cdictobject.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.cpythonrun.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

这样浓缩下来IncludeObjectsPython三个文件夹中比较重要的代码一共大约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源文件的运行大体分为两个步骤:

  1. Py_Initialize:初始化过程,主要涉及到解释器状态线程状态全局解释器锁以及内置类型的初始化。
  2. run_file:运行源文件,能够分为三个小步骤

    1. PyParser_ASTFromFileObject:对源文件的文本进行语法分析,获得抽象语法树
    2. PyAST_CompileObject:将抽象语法树编译成PyCodeObject对象。
    3. PyEval_EvalCode:在Python虚拟机中运行PyCodeObject对象。
  3. Py_FinalizeEx:源文件执行结束后的清理工做。

用流程图的形式表示上述调用树的主干部分应该更加清晰明了。

clipboard.png

须要指出的是,解释器循环真正执行的是PyEval_EvalFrameEx函数,它的参数是PyFrameObject对象,该对象为PyCodeObject对象提供了执行的上下文环境,因此PyFrameObjectPyCodeObject都是很是核心的对象。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_tuplesimple_list这两个函数反编译后的最大区别么?tuple是做为常量被加载进来的,而list的生成还须要调用BUILD_LIST。缘由在于tuple在Python的运行时会进行缓存,也就是每次使用无需请求操做系统内核以得到内存空间。对比一下使用tuplelist的耗时状况

>>> %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来讲有如下几点

  1. Python对象的内存部署方式是以在知足必定效率的前提下足够通用为目标的,所以在面临特定问题时它不必定是最优的。
  2. Python是动态类型语言,并非编译型语言,致使代码在运行时是可变的,从Python将抽象语法树PyCodeObject对象暴露出来这一点就能看出。
  3. 全局解释器锁也会妨碍使用多进程来实现性能的提高。
  4. Python虚拟机做为对CPU硬件的抽象也是无法甩锅的。

因此为了提升Python程序的效率,咱们须要深刻了解Python对象的实现原理、PyCodeObject的特性以及全局解释器和Python虚拟机的限制。之于文章开头的其余问题,咱们将随着Python源码的深刻研究慢慢展开。

如今咱们对Python代码的运行有了一个宏观的理解,并且大量的细节都有待深刻研究。经过对调用树主干部分的梳理,能看出其余比较重要的支持性模块还包括Python抽象对象PyObject抽象语法树及其编译,PyCodeObject对象,PyFrameObject对象,解释器状态线程状态全局解释器锁。在之后的文章中,咱们会分别对这些模块进行探讨。

相关文章
相关标签/搜索