7. Interpreter and Thread States

解释器与线程状态

如前几章所述,在 python 解释器的引导过程中,步骤之一是解释器状态和线程状态数据结构的初始化。 在本章中,我们将详细研究这些数据结构,并解释这些数据结构的重要性。

7.1 解释器状态

Python/pylifecycle.c 模块中的 Py_Initialize 函数是在初始化 python 解释器时调用的引导程序函数之一。 该函数处理 python 运行时的设置以及解释器状态和线程状态数据结构的初始化等。

解释器状态是一个非常简单的数据结构,它捕获由 python 进程中的一组协作执行线程共享的全局状态。 清单7.0中提供了部分该数据结构的定义,以提供对该非常重要的数据结构的一些了解。

代码清单7.0: 解释器状态数据结构的部分定义
typedef struct _is {

    struct _is *next;
    struct _ts *tstate_head;

    int64_t id;
    int64_t id_refcount;
    PyThread_type_lock id_mutex;

    PyObject *modules;
    PyObject *modules_by_index;
    PyObject *sysdict;
    PyObject *builtins;
    PyObject *importlib;
    ...
    PyObject *codec_search_path;
    PyObject *codec_search_cache;
    PyObject *codec_error_registry;
    int codecs_initialized;
    int fscodec_initialized;
    ...
    PyObject *builtins_copy;
    PyObject *import_func;
    ...
} PyInterpreterState;

清单7.0中显示的字段可能是熟悉到目前为止所有的内容并且使用 python 已有相当长时间的任何人所比较熟悉的。 我们再次讨论解释器状态数据结构的某些字段。

  1. *next:运行 python 可执行文件的单个 OS 进程中可以有多个解释器状态。 这个 *next 字段引用 python 进程中的另一个解释器状态数据结构(如果存在的话),它们形成解释器状态的链表,如图7.0所示。 每个解释器状态都有其自己的变量集,这些变量将由引用该解释器状态的执行线程使用。 但是,该进程中的所有解释器线程都共享该进程可用的内存和全局解释器锁。

  2. *tstate_head:该字段引用当前正在执行的线程的线程状态,或者在多线程程序的情况下,引用当前持有全局解释器锁(GIL)的线程。 这是一个映射到正在执行的操作系统线程的数据结构。

其余字段是由解释器状态的所有合作线程共享的变量。 modules 字段是已安装的 python 模块的列表。我们稍后将在讨论 import 系统时看到解释器如何找到这些模块,builtins 字段是对内置 sys 模块的引用。该模块的内容是 lenenumerate 等内置函数的集合,而 Python/ bltinmodule.c 模块包含该模块大部分内容的实现。 importlib 是一个引用 import 机制的实现的字段,当我们详细讨论 import 系统时,我们会详细介绍这一点。 *codec_search_path *codec_search_cache *codec_error_registry *codecs_initialized*fscodec_initialized 是与 python 用来编码和解码字节和文本的编解码器相关的字段。这些字段中的值用于查找此类编解码器以及处理可能与使用此类编解码器有关的错误。一个正在执行的 python 程序由一个或多个执行线程组成。解释器必须为每个执行线程维护某种状态,并且能够通过为每个执行线程维护线程状态数据结构来做到这一点。接下来我们看一下这个数据结构。

7.2 线程数据结构

直接进入清单7.2中所示的线程状态数据结构的探索,可以看到线程状态数据结构是比解释器状态数据结构更复杂的数据结构。

typedef struct _ts {
    /* See Python/ceval.c for comments explaining most fields */

    struct _ts *prev;
    struct _ts *next;
    PyInterpreterState *interp;
 
    struct _frame *frame;
    int recursion_depth;
    char overflowed; /* The stack has overflowed. Allow 50 more calls
                        to handle the runtime error. */
    char recursion_critical; /* The current calls must not cause
                                a stack overflow. */
    int stackcheck_counter;

    /* 'tracing' keeps track of the execution depth when tracing/profiling.
       This is to prevent the actual trace/profile code from being recorded in
       the trace/profile. */
    int tracing;
    int use_tracing;
    
    Py_tracefunc c_profilefunc;
    Py_tracefunc c_tracefunc;
    PyObject *c_profileobj;
    PyObject *c_traceobj;

    /* The exception currently being raised */
    PyObject *curexc_type;
    PyObject *curexc_value;
    PyObject *curexc_traceback;

    /* The exception currently being handled, if no coroutines/generators
     * are present. Always last element on the stack referred to be exc_info.
     */
    _PyErr_StackItem exc_state;

    /* Pointer to the top of the stack of the exceptions currently
     * being handled */
    _PyErr_StackItem *exc_info;
    
    ...
} PyThreadState;

线程状态数据结构的 nextprevious 字段引用在给定线程状态之前和之后创建的线程状态。 这些字段形成一个共享一个解释器状态的线程状态的双链表。 interp 字段引用线程状态所属的解释器状态。 frame 字段引用当前执行 frame,当执行的代码对象更改时,此字段引用的值也会更改。

了解线程状态和实际线程之间的区别很重要。 线程状态只是一个数据结构,它封装了正在执行的线程的某些状态。 每个线程状态都与正在运行的 python 进程内的本机 OS 线程相关联。 图7.1是这种关系的图形说明。 我们可以清楚地看到,单个 python 进程是至少一个解释器状态的宿主,而每个解释器状态是一个或多个线程状态的宿主,并且这些线程状态中的每一个都映射到操作系统的执行线程。

操作系统线程和相关的 python 线程状态是在解释器初始化期间或是在线程模块被调用时创建的。 即使在 python 进程中存在多个线程,在任何给定时间,只有一个线程可以主动执行 CPU 密集的任务。 这是因为执行线程必须持有全局解释器锁(GIL)才能在 python 虚拟机中执行字节代码。 如果深入著名的或者说是臭名昭著的 GIL 概念,本章将不完整,因此我们将在下一部分中继续进行介绍。

全局解释器锁 GIL

尽管 python 线程是操作系统线程,但是除非该线程持有 GIL,否则该线程无法执行 python 字节码。 操作系统可能会调度一个不运行 GIL 的线程,但正如我们将看到的,此类线程实际上可以做的就是等待获取 GIL,并且只有当它持有 GIL 时,它才能执行字节码。 我们看一下整个过程。

GIL 的必要性

在开始对 GIL 进行任何讨论之前,值得提出一个问题,为什么我们需要一个可能会对线程产生不利影响的全局锁? GIL 与之相关的原因有很多。但是,首先,重要的是要了解 GIL 是 CPython 的实现细节,而不是实际的语言细节,在 Java 虚拟机上实现的 python 的 Jython 中没有GIL的概念。 GIL 存在的主要原因是为了简化 CPython 虚拟机的实现。实现单个全局锁比实现细粒度锁要容易得多,而且核心开发人员已选择这样做。但是,已经有一些项目在 python 虚拟机中实现细粒度的锁定,但是这些项目有时会降低单线程程序的速度。执行某些任务时,全局锁还提供了非常必要的同步。采取 CPython 用于内存管理的引用计数机制,如果没有 GIL 的概念,则可能使两个线程交错引用计数的递增和递减导致内存处理的严重问题。锁定的另一个原因是 CPython 调用的某些 C 库本来就不是线程安全的,因此在使用它们时需要某种同步。

在解释器启动时,将创建单个执行主线程,并且由于周围没有其他线程,因此 GIL 没有争用,因此主线程不会费心去获取锁。 当使用 python 线程模块生成另一个线程时,GIL 起作用。 清单7.3中的代码片段来自 Modules/_threadmodule.c,它提供了有关在创建新线程时该过程如何进行的思路。

代码清单7.3: 新线程创建的部分代码
    boot->interp = PyThreadState_GET()->interp;
    boot->func = func;
    boot->args = args;
    boot->keyw = keyw;
    boot->tstate = _PyThreadState_Prealloc(boot->interp);
    if (boot->tstate == NULL) {
        PyMem_DEL(boot);
        return PyErr_NoMemory();
    }
    Py_INCREF(func);
    Py_INCREF(args);
    Py_XINCREF(keyw);
    PyEval_InitThreads(); /* Start the interpreter's thread-awareness */
    ident = PyThread_start_new_thread(t_bootstrap, (void*) boot);

清单7.3中的代码片段来自 thread_PyThread_start_new_thread 函数,该函数被调用以创建新线程。 boot 是一个数据结构,其中包含新线程需要执行的所有信息。 _PyThreadState_Prealloc 函数调用为尚未创建的线程创建新的线程状态。 在实际创建线程之前,执行主线程必须获取 GIL; 调用 PyEval_InitThreads 即可解决此问题。 现在有了解释器线程的概念,并且主线程保存了 GIL,就可以调用 PyThread_start_new_thread 来实际创建新的操作系统线程。 当生成新线程时,该线程在活动时应调用的函数将传递给该线程。 在这种情况下,该函数是 Modules/_threadmodule.c 模块中的 t_bootstrap 函数。 清单7.4显示了部分该引导程序函数。

代码清单7.4: 线程自举函数
static void
t_bootstrap(void *boot_raw)
{
    struct bootstate *boot = (struct bootstate *) boot_raw;
    PyThreadState *tstate;
    PyObject *res;

    tstate = boot->tstate;
    tstate->thread_id = PyThread_get_thread_ident();
    _PyThreadState_Init(tstate);
    PyEval_AcquireThread(tstate);
    tstate->interp->num_threads++;
    res = PyObject_Call(boot->func, boot->args, boot->keyw);
    ...

注意清单7.4中对 PyEval_AcquireThread 函数的调用。 PyEval_AcquireThread 函数是在Python/ceval.c 模块中定义的,它调用 take_gil 函数,后者是试图获取 GIL 的实际函数。 以下文本中引用了源文件中提供的有关此过程的说明

GIL 只是一个布尔变量(gil_locked),其访问受到互斥锁(gil_mutex)的保护,并且其更改由条件变量(gil_cond)发出信号。 gil_mutex 的使用时间很短,因此几乎没有竞争。在 GIL 保持线程中,主循环(PyEval_EvalFrameEx)必须能够根据另一个线程的需要释放 GIL。为此使用了一个临时的布尔变量(gil_drop_request),该变量在每次 eval 循环时都会检查。在 gil_cond 上等待间隔微秒后,将设置该变量。 【 实际上,使用了另一个临时的布尔变量(eval_breaker),该变量将多个条件进行或运算。由于 Python 仅在高速缓存相关的体系结构上运行,因此,可变布尔值就足以作为线程间信号传递的手段。】这鼓励了定义的周期性切换,但由于操作码可能需要花费任意时间来执行,因此不强制执行。用户可以使用Python API sys.{get,set}switchinterval() 读取和修改时间间隔值。当一个线程释放 GIL 并设置了 gil_drop_request 时,该线程将确保安排另一个等待 GIL 的线程。它通过等待条件变量(switch_cond)直到 gil_last_holder 的值更改为其自己的线程状态指针以外的值来进行操作,这表明另一个线程能够使用 GIL。这是为了禁止多核计算机上的延迟潜伏行为,在多核计算机上,一个线程会推测性地释放 GIL,但仍然运行并最终成为第一个重新获取 GIL 的对象,这使得“时间片”比预期的长得多。

以上对于新产生的线程意味着什么? 清单7.4中的 t_bootstrap 函数调用 PyEval_AcquireThread 函数,该函数处理对 GIL 的请求。 因此,当提出此请求时会发生什么情况的一般解释是,假设 A 是持有 GIL 的执行主线程,而 B 是正在产生的新线程:

  1. 生成 B 时,将调用 take_gil。 这将检查是否设置了条件 gil_cond 变量。 如果未设置,则线程开始等待。

  2. 等待时间过后,将设置 gil_drop_request

  3. 在求值循环上执行的线程 A 检查循环的每次迭代是否设置了 gil_drop_request 变量。

  4. 线程 A 在检测到已设置 gil_drop_request 变量时会丢弃 GIL,然后还会设置 gil_cond 变量。

  5. 线程 A 还等待另一个变量 switch_cond,直到 gil_last_holder 的值设置为除线程 A 的线程状态指针以外的值,该值指示另一个线程已采用 GIL。

  6. 线程 B 现在具有 GIL,可以继续执行字节码。

  7. 线程 A 等待给定时间,设置 gil_drop_request,然后循环继续。

GIL 与性能

GIL 是大多数情况下为什么在 python 中增加线程数无法加速 CPU 密集型计算的主要原因。 实际上,与单线程程序相比,添加线程会对程序的性能产生不利影响,因为增加了线程切换和等待相关的成本。

在结束本章之前,我们将回顾到目前为止到目前为止在 python 虚拟机上创建的模型。 当使用包含某些有效源代码内容的文件调用 python 可执行文件时,首先会初始化解释器和线程状态,然后将源文件编译为代码对象。 然后将代码对象传递到解释器循环模块,在该模块中,为了执行代码对象,创建了一个 frame 对象并将其附加到执行主线程。 因此,我们有一个 python 进程,该进程可能包含一个或多个解释器状态,并且每个解释器状态可能具有一个或多个线程状态,并且每个线程状态都引用了一个 frame,该 frame 可以引用另一个 frame,依此类推,形成一个 frame 堆。 图7.2提供了此顺序的图形表示。

在下一章中,我们将展示我们所描述的所有部分如何实现 python 代码对象的执行。

Last updated