CPython VM的工作原理

2020-09-08 05:06:31

这篇文章开始了一个系列,试图回答这个问题。我们将深入了解CPython的内部结构,这是Python最流行的实现。通过这样做,我们将在更深的层面上理解语言本身。这是本系列的主要目标。如果您熟悉Python并熟练地阅读C语言,但没有太多使用CPython源代码的经验,那么您很有可能会发现这篇文章很有趣。

让我们从陈述一些众所周知的事实开始吧。CPython是一个用C语言编写的Python解释器。它是Python实现之一,还有PyPy、Jython、IronPython和许多其他的实现。CPython的独特之处在于它是原创的,维护最多的,也是最受欢迎的。

CPython实现了Python,但是Python是什么呢?答案可能很简单--Python是一种编程语言。当同样的问题被恰当地提出时,答案就会变得更加微妙:什么定义了Python?与C等语言不同,Python没有正式的规范。最接近它的是Python Language Reference,它以以下单词开头:

虽然我试图尽可能精确,但除了语法和词法分析之外,我选择使用英语而不是正式规范。这应该会让普通读者更容易理解文档,但会留下模棱两可的空间。因此,如果您来自火星并试图仅从本文档重新实现Python,您可能不得不猜测,实际上您最终可能会实现一种完全不同的语言。另一方面,如果您正在使用Python,并且想知道关于该语言的特定领域的确切规则是什么,您应该可以在这里找到它们。

因此,Python不仅仅是由它的语言参考定义的。说Python是由它的参考实现CPython定义的也是错误的,因为有些实现细节不是语言的一部分。依赖引用计数的垃圾收集器就是一个例子。由于没有单一的真理来源,我们可以说Python部分是由Python语言参考定义的,部分是由它的主要实现CPython定义的。

这样的推理可能看起来很迂腐,但我认为澄清我们将要研究的主题的关键作用是至关重要的。尽管如此,你可能仍然会想,我们为什么要研究它。除了纯粹的好奇心,我认为还有以下几个原因:

对这门语言有一个全面的了解会让你对这门语言有更深的理解。如果您了解Python的实现细节,就更容易掌握它的某些特性。

实施细节在实践中很重要。当人们想要了解语言的适用性及其局限性、评估性能或检测低效时,对象如何存储、垃圾收集器如何工作以及如何协调多个线程都是非常重要的主题。

CPython提供了Python/C API,允许用C扩展Python并将Python嵌入到C中。要有效地使用这个API,程序员需要很好地理解CPython是如何工作的。

CPython被设计成易于维护。新手当然可以期望能够阅读源代码并理解它的功能。不过,这可能需要一些时间。通过撰写本系列,我希望能帮助您缩短它。

我选择了自上而下的方法。在本部分中,我们将探讨CPython虚拟机(VM)的核心概念。接下来,我们将了解CPython如何将程序编译成VM可以执行的内容。之后,我们将熟悉源代码,并逐步执行一个程序,在此过程中学习解释器的主要部分。最终,我们将能够一个接一个地挑选出语言的不同方面,并看看它们是如何实现的。这决不是一个严格的计划,而是我的大致想法。

注意:在这篇文章中,我指的是CPython3.9。随着CPython的发展,一些实现细节肯定会发生变化。我将尝试跟踪重要更改并添加更新笔记。

在初始化阶段,CPython初始化运行Python所需的数据结构。它还准备内置类型、配置和加载内置模块、设置导入系统以及执行许多其他操作。这是一个非常重要的阶段,由于其服务性质,常常被CPython的探索者忽略。

接下来是编译阶段。CPython是解释器,而不是编译器,因为它不会生成机器代码。然而,解释器通常在执行源代码之前将其转换为某种中间表示形式。CPython也是如此。此转换阶段执行与典型编译器相同的操作:解析源代码并构建AST(抽象语法树),从AST生成字节码,甚至执行一些字节码优化。

在研究下一阶段之前,我们需要了解字节码是什么。字节码是一系列指令。每条指令由两个字节组成:一个用于操作码,一个用于参数。请考虑一个示例:

CPython将函数g的主体转换为以下字节序列:[124,0,100,1,23,0,83,0]。如果我们运行一个标准的反汇编模块来拆卸它,下面是我们将得到的结果:

LOAD_FAST操作码对应于字节124,并且具有自变量0。LOAD_CONST操作码对应于字节100,具有参数1。BINARY_ADD和RETURN_VALUE指令始终分别编码为(23,0)和(83,0),因为它们不需要参数。

CPython的核心是执行字节码的虚拟机。通过查看前面的示例,您可能会猜到它是如何工作的。CPython的虚拟机是基于堆栈的。这意味着它使用堆栈执行指令来存储和检索数据。LOAD_FAST指令将局部变量压入堆栈。LOAD_CONST推送一个常量。BINARY_ADD从堆栈中弹出两个对象,将它们相加并将结果推回。最后,RETURN_VALUE弹出堆栈上的任何内容,并将结果返回给其调用者。

字节码执行发生在一个巨大的求值循环中,该循环在有指令要执行时运行。它会停止以产生一个值,或者如果发生错误。

操作码LOAD_FAST和LOAD_CONST的参数是什么意思?它们是索引吗?他们的索引是什么?

将两个数字相加的指令与连接两个字符串的指令相同吗?如果是,那么虚拟机如何区分这些操作?

为了回答这些和其他有趣的问题,我们需要了解CPythonVM的核心概念。

我们看到了一个简单函数的字节码是什么样子。但是典型的Python程序要复杂得多。VM如何执行包含函数定义的模块并进行函数调用?

它的字节码是什么样子的?要回答这个问题,让我们来分析一下这个程序是做什么的。它定义一个函数f,使用1作为参数调用函数f,并打印调用结果。无论函数f做什么,它都不是模块字节码的一部分。我们可以通过运行反汇编程序来确信自己。

1 0 Load_Const 0(<;代码对象f位于0x10bffd1e0,文件";example.py";,行1>;)2 Load_Const 1(';f';)4 Make_Function 0 6 Store_Name 0(F)4 8 Load_Name 1(Print)10 Load_Name 0(F)12 Load_Const 2(1)14 Call_Function 1 16 Call_Function 1 18 POP_TOP 20 Load_Const 3(None)22 Return_Value。

在第一行,我们定义函数f,方法是从名为code object的东西创建一个函数,并将名称f绑定到它。我们没有看到返回递增参数的函数f的字节码。

作为单个单元(如模块或函数体)执行的代码片段称为代码块。CPython将有关代码块执行什么操作的信息存储在一个称为代码对象的结构中。它包含字节码和块内使用的变量名列表等内容。运行模块或调用函数意味着开始计算相应的代码对象。

然而,函数不仅仅是代码对象。它必须包括附加信息,如名称、文档字符串、默认参数和在封闭范围中定义的变量的值。此信息与代码对象一起存储在函数对象中。Make_function指令用于创建它。CPython源代码中函数对象结构的定义前面有以下注释:

函数对象是通过执行';def';语句创建的。它们在__code__属性中引用代码对象,这是一个纯粹的语法对象,也就是说,只不过是一些源代码行的编译版本。每个源代码&34;片段";有一个代码对象,但是每个代码对象可以被零个或多个函数对象引用,这仅取决于到目前为止源代码中的';def';语句被执行了多少次。

几个函数对象怎么可能引用单个代码对象呢?下面是一个例子:

Make_add_x函数的字节码包含make_function指令。函数add_4和add_5是使用与参数相同的代码对象调用此指令的结果。但是有一个参数是不同的,那就是x的值。每个函数都通过单元格变量的机制获得自己的值,该机制允许我们创建像add_4和add_5这样的闭包。

我建议您在进入下一个概念之前先看一下代码和函数对象的定义。

Struct PyCodeObject{PyObject_head int co_argcount;/*#参数,除*args*/int co_posonlyargcount;/*#仅位置参数*/int co_kwan lyargcount;/*#仅关键字参数*/int co_nlocals;/*#局部变量*/int co_stacksize;/*#求值堆栈所需的条目*/int co_flag;/*CO_...,请参见下面的*/int co_firstlineno。/*指令操作码*/PyObject*co_consts;/*list(使用的常量)*/PyObject*co_name;/*字符串(使用的名称)列表*/PyObject*co_varames;/*字符串数组(本地变量名)*/PyObject*co_freevars;/*字符串数组(自由变量名)*/PyObject*co_cellvars;/*字符串数组(单元格变量名)*/py_ssize_t。/*映射作为参数的单元格变量。*/PyObject*co_filename;/*unicode(从中加载)*/PyObject*co_name;/*unicode(名称,供参考)*//*...。更多成员...*/};

Tyfinf struct{PyObject_head PyObject*func_code;/*代码对象,__code__属性*/PyObject*func_globals;/*字典(其他映射不需要)*/PyObject*func_defaults;/*null或元组*/PyObject*func_kwdefaults;/*null或dict*/PyObject*func_close;/*null或元组*/PyObject*func_kwdefaults;/*null或dict*/PyObject*func_close;/*null或元组。/*__doc__属性,可以是任意*/PyObject*func_name;/*__name__属性,字符串对象*/PyObject*func_dict;/*__dict__属性,dict或null*/PyObject*func_deflist;/*弱引用列表*/PyObject*func_module;/*the__module_attribute,可以是任意*/PyObject*func_notation;/*Annotation,a dict or null*/PyObject*func_qualname;/*限定名称*/Vector torcallfunc Vector torcall;}PyFunctionObject;

在执行代码对象时,VM必须跟踪变量的值和不断变化的值堆栈。它还需要记住它在哪里停止执行当前代码对象以执行另一个代码对象,以及返回到哪里。CPython将此信息存储在Frame对象中,或简单地存储在Frame中。框架提供了可以执行代码对象的状态。因为我们越来越习惯于源代码,所以我也把Frame对象的定义留在这里:

Struct_frame{PyObject_VAR_Head struct_frame*f_back;/*上一帧,或NULL*/PyCodeObject*f_code;/*代码段*/PyObject*f_builtins;/*内置符号表(PyDictObject)*/PyObject*f_globals;/*全局符号表(PyDictObject)*/PyObject*f_locals;/*本地符号表(任意映射)*/PyObject。/*最后一个本地后的点*/PyObject**f_stacktop;/*f_valuestack中的下一个空闲插槽。...*/PyObject*f_trace;/*trace函数*/char f_trace_lines;/*发出逐行跟踪事件?*/char f_trace_opcode;/*发出逐操作码跟踪事件?*//*借用生成器引用,或null*/PyObject*f_gen;int f_lasti;/*调用最后一条指令*//*...*/int f_lineno;/*当前行号*/int f_iblock;/*index in f_block stack*/char f_Executing;/*帧是否还在执行*/PyTryBlock f_block stack[CO_MAXBLOCKS];/*for try and loop block*/PyObject*f_localplus[1];/*LOCALS+STACK,动态调整大小*/};

创建第一个帧是为了执行模块的代码对象。每当CPython需要执行另一个代码对象时,它都会创建一个新框架。每个帧都有对前一帧的引用。因此,框架形成框架堆栈,也称为调用堆栈,当前框架位于顶部。调用函数时,会将一个新帧推送到堆栈上。从当前执行的帧返回时,CPython通过记住其最后处理的指令来继续执行前一帧。在某种意义上,CPythonVM除了构造和执行帧之外什么也不做。但正如我们很快就会看到的那样,委婉地说,这个总结隐藏了一些细节。

线程状态是包含线程特定数据的数据结构,包括调用堆栈、异常状态和调试设置。不应将其与操作系统线程混淆。不过,它们是紧密相连的。考虑一下使用标准踏步模块在单独的线程中运行函数时会发生什么情况:

T.start()实际上通过调用OS函数(在类似UNIX的系统上是pthreadcreate,在Windows上是_eginthreadadex)创建了一个新的OS线程。新创建的线程从负责调用目标的_thread模块调用函数。此函数不仅接收目标和目标的参数,还接收要在新OS线程中使用的新线程状态。OS线程以其自己的线程状态进入求值循环,因此它始终处于可用状态。

我们是

Python解释器不是完全线程安全的。为了支持多线程Python程序,有一个全局锁,称为全局解释器锁或GIL,当前线程必须持有该锁才能安全地访问Python对象。如果没有锁,即使是最简单的操作也可能在多线程程序中造成问题:例如,当两个线程同时递增同一对象的引用计数时,引用计数最终可能只递增一次,而不是递增两次。

要管理多个线程,需要有比线程状态更高级别的数据结构。

实际上,有两种状态:解释器状态和运行时状态。两者的需要似乎不会立即显现出来。但是,任何程序的执行都至少有一个实例,这是有充分理由的。

解释器状态是一组线程以及特定于该组的数据。线程共享加载的模块(sys.module)、内置(builtins.__dict__)和导入系统(Import Lib)。

运行时状态是一个全局变量。它存储特定于进程的数据。这包括CPython状态(是否已初始化?)。和GIL机制。

通常,一个进程的所有线程都属于同一个解释器。然而,在极少数情况下,可能需要创建子解释器来隔离一组线程。Mod_WSGI就是一个例子,它使用不同的解释器来运行WSGI应用程序。隔离最明显的效果是,每组线程都有自己版本的所有模块,包括__main__,这是一个全局名称空间。

CPython没有提供一种简单的方法来创建类似于线程模块的新解释器。该功能仅通过Python/C API支持,但这一点有朝一日可能会改变。

让我们快速总结一下CPython的架构,看看它们是如何组合在一起的。可以将解释器视为分层结构。下面总结了这些层的含义:

计算循环:执行代码对象,该对象告诉代码块做什么,并包含字节码和变量名。

这些层由相应的数据结构表示,我们已经看到了这一点。在某些情况下,它们不是等同的,是强硬的。例如,内存分配机制是使用全局变量实现的。它不是运行时状态的一部分,但肯定是CPython运行时层的一部分。

在这一部分中,我们已经概述了Python是如何执行Python程序的。我们已经看到它分三个阶段工作:

解释器中负责字节码执行的部分称为虚拟机。CPythonVM有几个特别重要的概念:代码对象、框架对象、线程状态、解释器状态和运行时。这些数据结构构成了CPython体系结构的核心。

我们还没有谈到很多事情。我们避免深入研究源代码。初始化和编译阶段完全超出了我们的范围。相反,我们从VM的广泛概述开始。这样,我想我们可以更好地看到每个阶段的责任。现在我们知道了CPython将源代码编译成什么-编译成代码对象。下次我们将看看它是如何做到这一点的。

如果您有任何问题、意见或建议,请随时通过电子邮件[email protected]与我联系。

2020年9月4日更新:我已经列出了我用来学习CPython内部结构的资源列表