1. FlyPython首页
  2. Python高级话题

Python高级话题:CPython VM的工作方式

介绍

您是否曾经想过python当您运行一个程序时会做什么?

$ python script.py 

本文打开了一个系列,试图回答这个问题。我们将深入研究CPython(Python最流行的实现)的内部。这样,我们将更深入地了解语言本身。这是本系列的主要目标。如果您熟悉Python并熟悉C语言,但没有太多使用CPython源代码的经验,那么您很有可能会发现本文有趣。

什么是CPython以及为什么有人想学习它

让我们首先说明一些众所周知的事实。CPython是用C语言编写的Python解释器。它是PyPy,Jython,IronPython等许多Python实现之一。CPython的独特之处在于它是原始的,维护最久,最受欢迎的。

CPython实现了Python,但是Python是什么呢?一个人可能会简单地回答– Python是一种编程语言。当正确地提出相同的问题时,答案变得更加细微:什么定义了Python是什么?Python与C等语言不同,它没有正式的规范。最接近它的是Python语言参考,它以以下单词开头:

当我尝试尽可能精确时,我选择对语法和词法分析以外的所有内容使用英语而不是正式的规范。这应该使普通读者更容易理解该文档,但会留下歧义的余地。因此,如果您是来自Mars并试图仅通过本文档重新实现Python,则您可能不得不猜测,实际上您可能最终会实现完全不同的语言。另一方面,如果您正在使用Python,并且想知道关于该语言特定区域的确切规则是什么,那么您肯定可以在这里找到它们。

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

这种推论似乎有些古怪,但我认为弄清我们将要研究的主题的关键作用至关重要。但是,您可能仍然想知道为什么我们应该研究它。除了好奇心外,我还发现以下原因:

  • 拥有全貌可以更深入地了解该语言。如果您了解Python的某些实现细节,那么掌握Python的某些特性就容易得多。
  • 实施细节在实践中很重要。当人们想了解语言的适用性及其局限性,估计性能或发现效率低下时,对象的存储方式,垃圾收集器的工作方式以及如何协调多个线程是非常重要的主题。
  • CPython提供了Python / C API,该API允许使用C扩展Python并将Python嵌入C中。要有效地使用此API,程序员需要对CPython的工作方式有充分的了解。

了解CPython如何工作需要什么

CPython的设计易于维护。新手当然可以期望能够阅读源代码并了解其功能。但是,可能需要一些时间。通过编写本系列文章,希望对您有所帮助。

该系列的布置方式

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

注意:在本文中,我指的是CPython 3.9。随着CPython的发展,某些实现细节肯定会发生变化。我将尝试跟踪重要的更改并添加更新说明。

目录

Python程序的执行大致包括三个阶段:

  1. 初始化
  2. 汇编
  3. 解释

在初始化阶段,CPython初始化运行Python所需的数据结构。它还准备诸如内置类型,配置和加载内置模块,设置导入系统之类的事情,并做许多其他事情。这是非常重要的阶段,由于其服务性质,CPython的浏览器常常忽略了这一阶段。

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

在进行下一阶段之前,我们需要了解什么是字节码。字节码是一系列指令。每条指令由两个字节组成:一个字节用于操作码,一个字节用于自变量。考虑一个例子:

def g(x): 
  return x + 3

CPython将函数的主体转换为g以下字节序列:[124, 0, 100, 1, 23, 0, 83, 0]。如果我们运行一个标准dis模块来分解它,那么我们将得到:

2            0  LOAD_FAST             0  (x )
            2  LOAD_CONST            1  (3 )
            4  BINARY_ADD 
            6  RETURN_VALUE

LOAD_FAST操作码对应于一个字节124并具有一个参数0LOAD_CONST操作码对应于一个字节100并具有一个参数1BINARY_ADDRETURN_VALUE指令总是被编码为(23, 0)和,(83, 0)因为它们不需要参数。

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

字节码执行发生在巨大的评估循环中,该循环在有指令执行时运行。它停止产生值或发生错误。

这样的简短概述引起了很多问题:

  • 什么做参数的操作码LOAD_FASTLOAD_CONST是什么意思?他们是指数吗?他们索引什么?
  • VM是否在堆栈上放置值或对对象的引用?
  • CPython如何知道这x是一个局部变量?
  • 如果参数太大而无法容纳单个字节怎么办?
  • 将两个数字相加的指令是否与连接两个字符串相同?如果是,那么VM如何区分这些操作?

为了回答这些以及其他有趣的问题,我们需要研究CPython VM的核心概念。

代码对象,功能对象,框架

代码对象

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

考虑一个程序:

def f(x):
    return x + 1

print(f(1))




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

1            0  LOAD_CONST                0  (< code object f at 0 x10bffd1e0 , file“ example.py” , line 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通过从称为代码对象的函数中创建函数并将其绑定名称f来定义函数。我们看不到f返回增量参数的函数的字节码。

作为模块或功能体之类的单个单元执行的代码段称为代码块。CPython将有关代码块功能的信息存储在称为代码对象的结构中。它包含字节码以及该块内使用的变量名称列表之类的内容。运行模块或调用函数意味着开始评估相应的代码对象。

功能对象

但是,功能不仅是代码对象。它必须包括其他信息,例如名称,文档字符串,默认参数以及在封闭范围内定义的变量的值。该信息与代码对象一起存储在功能对象中。MAKE_FUNCTION指令用于创建它。CPython源代码中的函数对象结构的定义以下列注释开头:

函数对象和代码对象不应相互混淆:

函数对象是通过执行’def’语句创建的。他们在其__code__属性中引用了一个代码对象,该对象是纯语法对象,即仅是某些源代码行的编译版本。每个源代码“片段”都有一个代码对象,但是每个代码对象可以被零个或多个函数对象引用,这取决于到目前为止源代码中的“ def”语句执行了多少次。

几个功能对象如何引用一个代码对象?这是一个例子:

def  make_add_x (x ):
    def  add_x (y ):
        return x  +  y 
    return add_x

add_4  =  make_add_x (4 )
add_5  =  make_add_x (5 )

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

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

struct  PyCodeObject  { 
    PyObject_HEAD 
    int  co_argcount ;             / *#参数,除了* args * / 
    int  co_posonlyargcount ;      / *#仅位置参数* / 
    int  co_kwonlyargcount ;       / *#仅
    int  co_nlocals ;              / *#局部变量* / 
    int  co_stacksize ;            / *#评估堆栈所需的条目* / 
    int  co_flags ;                / * CO _...,请参见下文* / 
    int  co_firstlineno ;          / *第一个源行号* / 
    PyObject  * co_code ;          / *指令操作码* / 
    PyObject  * co_consts ;         / *列表(使用常量)* / 
    PyObject  * co_names ;          / *字符串列表(使用的名称)* / 
    PyObject  * co_varnames ;       / *字符串元组(局部变量名称)* / 
    PyObject  * co_freevars ;       / *字符串元组(自由变量名)* / 
    PyObject  * co_cellvars ;       / *字符串元组(单元变量名称)* /

    Py_ssize_t  * co_cell2arg ;     / *映射作为参数的单元格变量。* / 
    PyObject  * co_filename ;       / * Unicode(从中加载)* / 
    PyObject  * co_name ;           / * unicode(名称,仅供参考)* / 
        / * ...更多成员... * / 
};
typedef  struct  { 
    PyObject_HEAD 
    PyObject  * func_code ;         / *一个代码对象,__code__属性* / 
    PyObject  * func_globals ;      / *一个字典(其他映射不会做)* / 
    PyObject  * func_defaults ;     / * NULL或元组* / 
    PyObject  * func_kwdefaults ;   / * NULL或dict * / 
    PyObject  * func_closure ;      / * NULL或单元格对象的元组* / 
    PyObject  * func_doc ;          / * __doc__属性可以是* / 
    PyObject  *func_name ;         / * __name__属性,一个字符串对象* / 
    PyObject  * func_dict ;         / * __dict__属性,一个dict或NULL * / 
    PyObject  * func_weakreflist ;  / *弱引用列表* / 
    PyObject  * func_module ;       / * __module__属性可以是* / 
    PyObject  * func_annotations ;  / *注释,字典或NULL * / 
    PyObject  * func_qualname ;     / *限定名称* / 
    vectorcallfunc  vectorcall ; 
}  PyFunctionObject ;

框架对象

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

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  ** f_valuestack ;     / *指向最后一个本地* /

    PyObject  ** f_stacktop ;           / * f_valuestack中的下一个空闲插槽。... * / 
    PyObject  * f_trace ;           / *跟踪功能* / 
    char  f_trace_lines ;          / *发出每行跟踪事件?* / 
    char  f_trace_opcodes ;        / *发出每个操作码跟踪事件?* /

    / *借用对生成器的引用,或者为NULL * / 
    PyObject  * f_gen ;

    int  f_lasti ;                 / *如果调用了最后
   
    int  f_lineno ;                / *当前行号* / 
    int  f_iblock ;                / * f_blockstack中的索引* / 
    char  f_executing ;            / *框架是否仍在执行* / 
    PyTryBlock  f_blockstack [ CO_MAXBLOCKS ];  / *用于尝试循环块* / 
    PyObject  * f_localsplus [ 1 ];   / * locals + stack,动态大小* / 
};

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

线程,解释器,运行时

我们已经研究了三个重要概念:

  • 一个代码对象
  • 功能对象;和
  • 框架对象。

CPython还有另外三个:

  • 线程状态
  • 解释国;和
  • 运行时状态。

线程状态

线程状态是一种数据结构,其中包含特定于线程的数据,包括调用堆栈,异常状态和调试设置。不应将其与OS线程混淆。但是它们紧密相连。考虑使用标准treading模块在单独的线程中运行函数时会发生什么:

from threading import Thread

def  f ():
    “”“执行I / O绑定任务”“” 
    pass

t  =  Thread(target = f )
t.start ()
t.join()

t.start()实际上是通过调用OS函数(pthread_create在类似UNIX的系统和_beginthreadexWindows上)创建一个新的OS线程。新创建的线程从_thread负责调用目标的模块调用该函数。该函数不仅接收目标和目标的参数,还接收要在新OS线程中使用的新线程状态。OS线程以其自己的线程状态进入评估循环,因此总是可以使用它。

我们在这里可能还记得著名的GIL(全局解释器锁),它防止多个线程同时进入评估循环。这样做的主要原因是在不引入更多细粒度的锁的情况下保护CPython的状态免受损坏。《 Python / C API参考》清楚地解释了GIL:

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

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

解释器和运行时状态

实际上,它们有两个:解释器状态和运行时状态。两者的需求似乎并不立即明显。但是,任何程序的执行至少每个都有一个实例,这有充分的理由。

解释器状态是一组线程以及该组特定的数据。线程共享诸如已加载的模块(sys.modules),内置插件(builtins.__dict__)和导入系统(importlib)之类的东西。

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

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

CPython没有提供一种简便的方法来创建threading模块的新解释器类似物。仅通过Python / C API支持此功能,但是一天可能会更改

架构摘要

让我们快速总结一下CPython的体系结构,看看一切如何融合在一起。解释器可以看作是分层结构。以下总结了这些层是什么:

  1. 运行时:进程的全局CPython状态;包括GIL和内存分配机制
  2. 解释器:一组线程和它们共享的一些数据,例如导入的模块。
  3. 线程:特定于单个OS线程的数据;这包括调用堆栈。
  4. 框架:调用堆栈的元素;提供执行代码对象的状态。
  5. 评估循环:执行一个代码对象,该代码对象告诉代码块做什么,并包含字节码和变量名称。

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

结论

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

  1. 初始化Python运行时
  2. 将源代码编译为模块的代码对象;和
  3. 执行代码对象的字节码。

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

我们没有涉及很多事情。我们避免深入研究源代码。初始化和编译阶段完全超出了我们的范围。相反,我们从虚拟机的概述开始。我认为,通过这种方式,我们可以更好地了解每个阶段的职责。现在,我们知道了CPython将源代码编译到的代码对象。下次,我们将看到它是如何做到的。

原创文章,作者:flypython,如若转载,请注明出处:http://flypython.com/advanced-python/380.html