跳转至

CPython虚拟机的工作原理

原文链接: https://tenthousandmeters.com/blog/python-behind-the-scenes-1-how-the-cpython-vm-works/

介绍

python script.py
当你在终端里用以上命令执行一个Python脚本的时候,你是否想过Python都做了哪些事?本文开启了一个旨在回答这个问题的系列文章。 我们将深入了解CPython的内部结构,这是Python最流行的实现。通过这样做,我们将在更深层次上理解语言本身。 这是本系列的主要目标。如果你熟悉Python并能轻松阅读C,但对于CPython源代码没有很深的了解,那么你很有可能会发现这篇文章很有趣。

CPython是什么以及为什么有人想要了解它

让我们首先陈述一些众所周知的事实。CPython是用C语言编写的Python解释器。它是Python实现之一,与PyPy、Jython、IronPython和许多其他实现一起。 CPython的突出之处在于它是原创的、维护最多的和最受欢迎的。

CPython实现了Python,但什么是Python?人们可能会简单地回答——Python是一种编程语言。但是如果换一种问法,答案就会变得更加微妙: 究竟什么定义了Python是什么?与C等语言不同,Python没有正式的规范。最接近它的是Python语言参考,它开头是这么说的:

While I am trying to be as precise as possible, I chose to use English rather than formal specifications for everything except syntax and lexical analysis. This should make the document more understandable to the average reader, but will leave room for ambiguities. Consequently, if you were coming from Mars and tried to re-implement Python from this document alone, you might have to guess things and in fact you would probably end up implementing quite a different language. On the other hand, if you are using Python and wonder what the precise rules about a particular area of the language are, you should definitely be able to find them here.

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

这样的推理可能看起来很迂腐,但我认为澄清我们将要研究的主题的关键作用至关重要。 不过,你可能仍然想知道,为什么我们应该研究它。除了单纯的好奇,还有以下原因: - 更深入地了解语言。如果您了解Python的实现细节,则更容易掌握Python的某些特性。 - 在实践中,语言的实施细节很重要。当人们想要了解语言的适用性及其局限性、估计性能或 检测低效率时,对象如何存储、垃圾收集器如何工作以及多线程如何协调是非常重要的主题。 - CPython提供了Python/C的API,他允许用C扩展Python并将Python嵌入到C中。 要有效地使用这个API,程序员需要很好地理解CPython的工作原理。

了解CPython的工作原理需要什么

CPython被设计为易于维护。新手当然可以期望能够阅读源代码并理解它的作用。但是这可能需要一些时间。 通过写这个系列,我希望能帮助你缩短它。

这个系列文章怎么展开

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

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

概览

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

  • 初始化
  • 编译
  • 解释

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

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

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

def g(x):
    return x + 3
CPython 将函数g()的主体转换为以下字节序列:[124, 0, 100, 1, 23, 0, 83, 0]。如果我们运行标准 的dis模块来反汇编它,我们会得到:
$ python -m dis example1.py
...
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),其中的两个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))

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

$ python -m dis example2.py

1           0 LOAD_CONST               0 (<code object f at 0x10bffd1e0, 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
...
在第1行,我们通过从称为代码对象的东西中创建函数并将名称f绑定到它来定义函数f()。我们没有看到返回递增参数的函数 f()的字节码。

作为单个单元(如模块或函数体)执行的代码段称为代码块。CPython将有关代码块功能的信息存储在称为代码对象的结构中。 它包含字节码和诸如块内使用的变量名称列表之类的东西。运行模块或调用函数意味着开始解释运行相应的代码对象。

函数对象

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

Function objects and code objects should not be confused with each other:

Function objects are created by the execution of the 'def' statement. They reference a code object in their __code__ attribute, which is a purely syntactic object, i.e. nothing more than a compiled version of some source code lines. There is one code object per source code "fragment", but each code object can be referenced by zero or many function objects depending only on how many times the 'def' statement in the source was executed so far.

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

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_4()add_5()是使用相同的代码对象 作为参数调用此指令的结果。但是有一个不同的参数——x的值。每个函数都通过单元变量机制获得自己的功能, 该机制允许我们创建像add_4()add_5()这样的闭包。关于单元变量机制,可以参考本系列的第五篇文章。

在我们进入下一个概念之前,先看一下代码和函数对象的定义,以更好地了解它们是什么。

struct PyCodeObject {
    PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */
    int co_posonlyargcount;     /* #positional only arguments */
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_flags;               /* CO_..., see below */
    int co_firstlineno;         /* first source line number */
    PyObject *co_code;          /* instruction opcodes */
    PyObject *co_consts;        /* list (constants used) */
    PyObject *co_names;         /* list of strings (names used) */
    PyObject *co_varnames;      /* tuple of strings (local variable names) */
    PyObject *co_freevars;      /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */

    Py_ssize_t *co_cell2arg;    /* Maps cell vars which are arguments. */
    PyObject *co_filename;      /* unicode (where it was loaded from) */
    PyObject *co_name;          /* unicode (name, for reference) */
        /* ... more members ... */
};
typedef struct {
    PyObject_HEAD
    PyObject *func_code;        /* A code object, the __code__ attribute */
    PyObject *func_globals;     /* A dictionary (other mappings won't do) */
    PyObject *func_defaults;    /* NULL or a tuple */
    PyObject *func_kwdefaults;  /* NULL or a dict */
    PyObject *func_closure;     /* NULL or a tuple of cell objects */
    PyObject *func_doc;         /* The __doc__ attribute, can be anything */
    PyObject *func_name;        /* The __name__ attribute, a string object */
    PyObject *func_dict;        /* The __dict__ attribute, a dict or NULL */
    PyObject *func_weakreflist; /* List of weak references */
    PyObject *func_module;      /* The __module__ attribute, can be anything */
    PyObject *func_annotations; /* Annotations, a dict or NULL */
    PyObject *func_qualname;    /* The qualified name */
    vectorcallfunc vectorcall;
} PyFunctionObject;

帧对象

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

struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* previous frame, or NULL */
    PyCodeObject *f_code;       /* code segment */
    PyObject *f_builtins;       /* builtin symbol table (PyDictObject) */
    PyObject *f_globals;        /* global symbol table (PyDictObject) */
    PyObject *f_locals;         /* local symbol table (any mapping) */
    PyObject **f_valuestack;    /* points after the last local */

    PyObject **f_stacktop;      /* Next free slot in f_valuestack.  ... */
    PyObject *f_trace;          /* Trace function */
    char f_trace_lines;         /* Emit per-line trace events? */
    char f_trace_opcodes;       /* Emit per-opcode trace events? */

    /* Borrowed reference to a generator, or NULL */
    PyObject *f_gen;

    int f_lasti;                /* Last instruction if called */
    /* ... */
    int f_lineno;               /* Current line number */
    int f_iblock;               /* index in f_blockstack */
    char f_executing;           /* whether the frame is still executing */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
};
第一帧是为了执行模块的代码对象而被创建。每当需要执行另一个代码对象时,CPython都会创建一个新帧。每个帧都有对前一帧的引用。 因此,帧形成帧堆栈,也称为调用堆栈,当前帧位于顶部。当一个函数被调用时,一个新的帧被压入堆栈。从当前执行的帧返回时, CPython通过记住其最后处理的指令来继续执行前一帧。从某种意义上说,CPython VM 除了构造和执行帧之外什么都不做。然而, 我们很快就会看到,这个总结,委婉地说,隐藏了一些细节。

线程、解释器、运行时

我们已经看过三个重要的概念:

  • 代码对象
  • 函数对象
  • 帧对象

CPython 还有三个:

  • 线程状态
  • 解释器状态
  • 运行时状态

线程状态

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

from threading import Thread

def f():
    """Perform an I/O-bound task"""
    pass

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

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

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

The Python interpreter is not fully thread-safe. In order to support multi-threaded Python programs, there’s a global lock, called the global interpreter lock or GIL, that must be held by the current thread before it can safely access Python objects. Without the lock, even the simplest operations could cause problems in a multi-threaded program: for example, when two threads simultaneously increment the reference count of the same object, the reference count could end up being incremented only once instead of twice.

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

解释器和运行时状态

事实上,有两种状态:解释器状态和运行时状态。对两者同时存在的需求似乎并不明显。然而,任何程序的执行都至少有一个实例, 这是有充分理由的。

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

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

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

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

架构总结

让我们快速总结一下CPython的架构,看看一切是如何组合在一起的。解释器可以看作是一个分层结构。下面总结了层是什么:

  1. 运行时:进程的全局状态;这包括GIL和内存分配机制。
  2. 解释器:一组线程和它们共享的一些数据,例如导入的模块。
  3. 线程:特定于单个操作系统线程的数据;这包括调用堆栈。
  4. 帧:调用栈的一个元素;一个帧包含一个代码对象并提供一个状态来执行它。
  5. 评估循环:执行框架对象的地方。

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

总结

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

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

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

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

更新

2020年9月4日更新:我列出了用于了解CPython内部结构的资源列表

评论