前言
这是一篇为了更好地说明为什么我们在Python程序开发过程中,为什么要使用Cython作为Python的超集的原因,因为Python是一种很慢的语言,你得理解Python为什么会慢!?因为CPython从诞生到现在它有一个巨大的"肿瘤",GIL的存在多半原因是为了维持每个PyObject对象内部正确的引用计数,而抑制了多线程的执行效率。到目前为止CPython事实上仍然是以单线程去执行的。为了摆脱CPython内部GIL的束缚,诞生了许多第三方的优化实现Cython、Jython、IronPython、PyPy和Number。
为什么笔者推崇Cython?因为Cython并不像其他Python实现那样强迫你完全替换CPython,而是作为是CPython的扩展后端,与CPython在运行过程中良好地交互。
Cython同时是一种新兴的语言,是原生C/C++和Python语法混写的语言风格。也就是说Python语义和少量C数据类型语义,能够写出一个性能高效的C扩展。
Cython对C/C++代码有非常好交互接口,基本上用类似Python的语法能完成对C/C++代码的封装重用。
Cython支持真正的并发。
但在迈进Cython台阶前,除了客观要求你熟悉C/C++之外,你得还熟悉CPython内核。有些人说使用Cython无需理解CPython内核,这点我是不认同的。因为Cython写的代码,导出的C代码,大量使用了CPython的内部C函数接口。不理解CPython内核,你会一脸懵B的。因此笔者将之前的学习CPython内核的以随笔的形式记录下来。
那么我们开始吧!
Python程序的生命周期
一个Python源代码被Python虚拟机第一次执行会经历如下过程(该过程已经被简化)
那么什么是AST?
简单来说,就是Python源代码用树结构的表示代码的一种表示形式,其实抽象树,我自己本人觉得很枯燥乏味的,本文会减略带过,我们只需了解Python内置的ast模块干这事:
Python源代码中的任何文字符号在解释阶段解释为AST中层次鲜明的节点,
def foo(a,b): return a+b
如下例子所示,我们使用ast.parse函数对下面函数解释为AST中的节点,AST中的节点我们也叫标记(token):
上图我们可以使用ast.dump函数列出所有语法树中的节点(标记)
下面我们继续上面的示例,我们尝试编译上面的AST中的所有节点,使用Python内置的complie函数,我们得到一个类型为code的对象
代码对象
从上面的例子,我们从AST编译得到代码对象,代码对象是不可变的数据结构并且包含了运行代码所需的指令和信息,它们是Python解释器生成字节码的内部表示形式。
进一步深究的话,我们查看code对象中的API,这些都是解释器运行此代码所需的的信息
我们从中只需了解代码对象中我们认为关键的属性或方法co_name 通常输出'',因为所有Python源码都包含在模块中
co_varnames 它是一个元组,包含所有本地变量名称
co_stacksize,它是编译器最大的栈尺寸,由编译器静态计算得到
co_const,它是一个元组,包含代码中的文字符号
co_argcount,记录位置参数的数量
co_code: 表示字节码指令的字节序列,这也是我们本文重点讲解的
CPython运行机制概述
为了更好地理解Cython如何以及为什么提高Python代码的性能之前,我们需要理解CPython的内部运行原理
在描述Python的执行过程之前,需要清楚地了解什么是编译器和解释器以及它们之间的区别。 简单来说:编译器是使用高级语言编写的程序,并将其转换为本机CPU可以直接运行的机器代码的程序。 C / C ++是“编译”语言的典型示例,我们的CPython编译器正是由C语言实现的
解释器按语句读取高级源程序语句,并在解释器中执行它们,同时保留某种上下文。 但是通常,所谓的“解释语言”并不是从其源程序中真正得到解释的,而是在将源代码转换成某种形式的中间表示形式之后才进行解释。 准确地说,Python属于这一类。
字节码
Python遵循两步过程:第一步是“编译”步骤,将Python源代码转换为称为Python字节码的中间形式。 CPython使用缓存机制来避免这种情况。 第一次加载模块时,它会记录字节码-是的,它发生在模块级别。 如果您不加修改地再次加载它,它将读取缓存的字节码并仅进行解释过程。 这些缓存的字节码保存在.pyc文件中:
字节码指令集格式:
字节码与数据一起使用称为marshal的非常特殊的序列化格式序列化为这些.pyc文件,该序列化严格是python解释器实现的内部格式,不应被应用程序使用。
重要概念在CPython 3.6中,所有字节码指令的长度恰好为两个字节
单个CPython指令包含内置的指令码+参数,其中某些指令的参数可能为空,为了内存对齐但仍占用第二个字节。
字节码文件就好像一个线性表一样的容器装载这些不同的指令,如下所示
<指令1><参数1><指令2><参数2>.....<指令N><参数N>
每个字节一个。集合中总共有118条唯一的指令,其中一些指令不关心它的参数,因为它们没有参数。VM将忽略此类指令的第二个字节。每个(人类可读的)指令都有一个整数表示(当然是256以下,因为它是一个字节)。通过比较上述不可读的字节码版本和可读的字节码版本,可以很容易地找出其中一些字节码的映射。
CPython的实现版本中,可以通过内置的反编译模块dis查看Python源代码被编译后的字节码内容,我们用一个示例来解析字节码的构成。在进入下面示例之前,建议你查看dis模块的具体用法
https://docs.python.org/zh-cn/3/library/dis.html#python-bytecode-instructions
示例导入
我们定一个简单的Python函数,calc(x)并将其保存在一个名为calc.py的文件中。我们在Python交互环境下尝试查看它的字节码内容
def calc(x):
y=x**2 return x+y
如下图所示
我们通过dis()反编译函数,并传入函数名称打印出所有我们一看就懂的字节码内容,而且我们也通过函数的内置属性code也可以查看字节码的字节数组的形式,为了更加容易识别,我们通过一个list表达式将所有字节码的每个字节装换为整数
这条函数的字节码主体8个指令组成,我特此也用红色的关键字标注了,一目了然,那么现在问题来了
问题1:以下是这些每个指令中的指令码都代码什么含义呢?LOAD_FAST,
LOAD_CONST
BINARY_POWER
STORE_FAST
BINARY_ADD
RETURN_VALUE
如果您目光锐利,那么您可能已经注意到,生成的字节码与python源程序之间存在差异。 在此示例中,我在语句y = x ** 2中使用了数值(2)。您在编译后的字节码中发现到和字面量2相关的数据吗?这个就是重点,源程序的代码和数据位于不同的对象中。 这里需要说明的是Python函数的内置属性__code __.co_code保留原始字节码指令,但不保留和字面量相关的内容。 如果我们要通过code属性获取函数体内部的字面常量,使用如下代码
calc.__code__.co_consts
问题2:Python解释器如何去读取这些字节码呢?
CPython从Python源代码编译的Python字节码,无法直接被CPU直接执行,而是要通过Python的虚拟机执行字节码中的指令,Python虚拟机(VM)是一个单独的程序,在CPython编译器生成字节码后才会出现。 它实际上是对物理CPU的模拟-它具有软件定义的栈,指令指针(EIP)和其他功能。 虽然,其他虚拟机可能还有很多其他组件,例如寄存器等,但是CPython VM完全基于栈数据结构,这就是为什么它通常被称为“基于栈”的虚拟机的原因。
在运行之前,Python代码会自动编译为Python字节码。 字节码是要由Python虚拟机(VM)执行或解释的基本指令。 由于VM提取了所有特定于平台的详细信息,因此Python字节码可以在一个平台上生成并在其他任何地方运行。 由VM决定将每个高级字节码转换为一个或多个可由操作系统以及最终由CPU执行的低级操作。 这种虚拟化设计是通用且非常灵活的,它带来了很多好处-首先,它们不必操心挑剔的编译器! 主要缺点是VM比运行本机编译的代码慢。
而底层的C/C++,没有所谓的虚拟机或解释器,也没有高级字节码。 编译器将C代码直接编译为机器代码。 该机器代码被合并到可执行文件或编译的库中。 它针对特定的平台和体系结构量身定制,可以直接由CPU运行,并且级别低
有一种弥合执行字节码的VM和执行机器码的CPU之间的区别的方法:Python解释器可以直接且对最终用户透明地运行编译的C代码。必须将C代码编译到称为扩展模块的特定类型的动态库中。这些模块是成熟的Python模块,但是其中的代码已由标准C编译器预编译为机器代码。在扩展模块中运行代码时,Python VM不再解释高级字节码,而是直接运行机器代码。 当此扩展模块内部的任何操作正在运行时,这消除了解释器的性能开销
Cython如何适应? 如前所述,我们可以使用cython和标准C编译器将Cython源代码转换为已编译的平台特定的扩展模块。 每当Python在扩展模块中运行任何内容时,它都会运行编译后的代码,因此没有解释器开销会减慢运行速度。
解释执行与编译后执行有何不同? 取决于所讨论的Python代码,它的变化范围可能很大,但通常我们可以预期将Python代码转换为等效的C/C++扩展模块可以使速度提高100%到300%,Cython免费为Python提供了这种加速方案,我们很高兴接受它。 但是真正的性能改进来自用静态类型替换Python的动态类型。
动态类型和静态类型
诸如Python,Ruby,Tcl和JavaScript之类的高级语言与诸如C,C ++和Java之类的低级语言之间的另一个重要区别是,前者是动态类型的,而后者是静态类型的。 静态类型语言要求在编译时固定变量的类型。 通常,我们可以通过明确声明变量的类型来完成此操作,或者在可能的情况下,编译器可以自动推断变量的类型。 无论哪种情况,在使用它的上下文中,变量都具有该类型,并且只有该类型。
静态类型带来什么好处? 除了编译时类型检查外,编译器还使用静态类型生成适合该特定类型的快速机器代码。
动态类型化语言对变量的类型没有任何限制:例如,同一变量可以以整数开头,然后以字符串,列表或自定义Python对象的实例结尾。 动态类型化的语言通常更易于编写,因为用户不必显式声明变量的类型,并且可以权衡在运行时捕获与类型相关的错误。
Python解释器的动态调度
运行Python程序时,解释器会花费大部分时间来确定要执行的低级操作,然后提取数据以提供给该低级操作。 鉴于Python的设计和灵活性,Python解释器始终必须以一种完全通用的方式确定低级操作,因为变量可以随时具有任何类型。 这称为动态调度,由于许多原因,完全常规的动态调度很慢.
Python解析器动态调度慢的原因,大概几点不完全执行路线总结:对象(变量)的类型检测-->确定数据类型后,对象(变量)的堆内存分配-->调用其他内置函数-->写入对象的堆内存-->对象的内存回收
Python的所有对象的内存是堆分配的而不是栈,且堆的内存分配要比栈的分配要慢很多
例如,动态调度的例子,可以考虑Python解释器运行时a + b时会发生什么:解释器检查a所引用的Python对象的类型,该类型至少需要在C级别进行一
次指针查找。
解释器向类型系统询问add方法的实现,该方法可能需要一个或多个其他指针查找和内部函数调用。
如果找到了有问题的方法,则解释器将具有可以调用的实际函数,该函数可以用Python或C语言实现。
解释器调用加法函数并将a和b作为参数传递。
add函数从a和b中提取必要的内部数据,这可能需要更多的指针查找以及从Python类型到C类型的转换。 如果成功,则只有这样才能执行将a和b加在一起的实际操作。
然后必须将结果放置在(可能是新的)Python对象中并返回。 只有这样,操作才能完成
C的情况非常不同。 因为C是经过编译和静态类型化的,所以C编译器可以在编译时确定执行哪些低级操作以及将哪些低级数据作为参数传递。 在运行时,已编译的C程序几乎跳过了Python解释器必须执行的所有步骤。 对于a和b之类的东西,其中a和b都是基本数字类型,编译器会生成少量机器代码指令,以将数据加载到寄存器中,相加并存储结果,对于基本数据类型所有运算几乎是在寄存器内完成。
编译后的C程序几乎所有时间都花费在调用快速C函数和执行基本操作上。 由于静态类型的语言对其变量施加的限制,编译器会生成针对其数据量身定制的更快,更专业的指令。 鉴于这种效率,对于某些操作,像C这样的语言可以比Python快数百甚至数千倍吗!Cython之所以能够令P如此出色的性能提升的主要原因是它将**C/C++的类型静态化带入了动态语言。 静态类型将运行时动态调度转换为静态类型优化的机器代码。
Cython(和Cython的前身Pyrex)之前,我们只能通过在C中重新实现Python代码来从静态类型中受益。Cython使得将Python代码保持原样并利用C的静态类型系统变得容易。我们将学习的第一个也是最重要的Cython专用关键字是cdef,这是我们获得C语言性能的途径。