编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

Python 幕后故事:GIL 及其对 Python 多线程的影响

wxchong 2024-08-13 12:31:29 开源技术 15 ℃ 0 评论

更多互联网精彩资讯、工作效率提升关注【飞鱼在浪屿】(日更新)

您可能知道,GIL 代表全局解释器锁,它的工作是使 CPython 解释器线程安全。GIL 在任何给定时间只允许一个操作系统线程执行 Python 字节码,其结果是不可能通过在多个线程之间分配工作来加速 CPU 密集型 Python 代码。然而,这并不是 GIL 的唯一负面影响。GIL 引入了使多线程程序变慢的开销,更令人惊讶的是,它甚至会影响 I/O 绑定线程。

在这篇文章中,你将了解到更多关于 GIL 的非明显负面影响。在此过程中,我们将讨论 GIL 到底是什么、它为什么存在、它是如何工作的,以及它在未来将如何影响 Python 并发性。

注意:在这篇文章中,指的是 CPython 3.9。随着 CPython 的发展,一些实现细节肯定会发生变化。


操作系统线程、Python 线程和 GIL

让我首先提醒您 Python 线程是什么以及多线程在 Python 中是如何工作的。当您运行python可执行文件时,操作系统会启动一个新进程,其中包含一个称为主线程的执行线程。与任何其他 C 程序的情况一样,主线程python通过输入其main()函数开始执行。主线程接下来所做的所有事情可以总结为三个步骤:

  1. 初始化解释器;
  2. 将 Python 代码编译为字节码;
  3. 进入求值循环执行字节码。

主线程是执行已编译 C 代码的常规 OS 线程。它的状态包括 CPU 寄存器的值和 C 函数的调用堆栈。然而,Python 线程必须捕获 Python 函数的调用堆栈、异常状态和其他与 Python 相关的东西。所以 CPython 所做的就是将这些东西放在一个线程状态结构中,并将线程状态与 OS 线程相关联。换句话说,Python thread = OS thread + Python thread state。

无限循环包含对所有可能的字节码指令的巨大切换。要进入循环,线程必须持有 GIL。主线程在初始化的时候拿了GIL,所以可以自由进入。当它进入循环时,它只是一一执行字节码指令。

有时,线程必须暂停字节码执行。它会在评估循环的每次迭代开始时检查是否有任何理由这样做。我们对这样的一个原因感兴趣:另一个线程请求了 GIL。下面是这个逻辑在代码中的实现方式:

PyObject*
_PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
    // ... declaration of local variables and other boring stuff

    // the evaluation loop
    for (;;) {

        // `eval_breaker` tells whether we should suspend bytecode execution
        // e.g. other thread requested the GIL
        if (_Py_atomic_load_relaxed(eval_breaker)) {

            // `eval_frame_handle_pending()` suspends bytecode execution
            // e.g. when another thread requests the GIL,
            // this function drops the GIL and waits for the GIL again
            if (eval_frame_handle_pending(tstate) != 0) {
                goto error;
            }
        }

        // get next bytecode instruction
        NEXTOPARG();

        switch (opcode) {
            case TARGET(NOP) {
                FAST_DISPATCH(); // next iteration
            }

            case TARGET(LOAD_FAST) {
                // ... code for loading local variable
                FAST_DISPATCH(); // next iteration
            }

            // ... 117 more cases for every possible opcode
        }

        // ... error handling
    }

    // ... termination
}

在单线程 Python 程序中,主线程是唯一的线程,并且从不释放 GIL。现在让我们看看在多线程程序中会发生什么。我们使用threading标准模块来启动一个新的 Python 线程:

import threading

def f(a, b, c):
    # do something
    pass

t = threading.Thread(target=f, args=(1, 2), kwargs={'c': 3})
t.start()

实例的start()方法Thread创建一个新的操作系统线程。在包括 Linux 和 macOS 在内的类 Unix 系统上,调用pthread_create()函数。新创建的线程开始执行t_bootstrap()带有boot参数的函数。该boot参数是包含目标函数,传递的参数,并为新的操作系统线程的线程状态的结构。该t_bootstrap()函数做了很多事情,但最重要的是,它获取 GIL,然后进入求值循环执行目标函数的字节码。

为了获得 GIL,线程首先检查是否有其他线程持有 GIL。如果不是这种情况,线程会立即获取 GIL。否则,它会等到 GIL 被释放。它等待一个固定的时间间隔,称为切换间隔(默认为 5 毫秒),如果在这段时间内没有释放 GIL,它会设置eval_breaker和gil_drop_request标志。该eval_breaker标志告诉持有 GIL 的线程暂停字节码执行,并gil_drop_request解释原因。当 GIL 持有线程开始评?估循环的下一次迭代并释放 GIL 时,它会看到这些标志。它通知等待 GIL 的线程,其中一个线程获取 GIL。由操作系统决定唤醒哪个线程,因此它可能是也可能不是设置标志的线程。

这是我们需要了解的 GIL 的最低层面。现在让我说说之前谈到的它的影响。如果您发现它们很有趣,请继续下一节,我们将在其中更详细地研究 GIL。


GIL 的影响

GIL 的第一个效果是众所周知的:多个 Python 线程不能并行运行。因此,即使在多核机器上,多线程程序也不比它的单线程程序快。作为并行化 Python 代码的简单尝试,请考虑以下 CPU 绑定函数,该函数执行给定次数的递减操作:

def countdown(n):
    while n > 0:
        n -= 1

现在假设我们要执行 100,000,000 次递减。我们可以countdown(100_000_000)在一个线程中运行,或者countdown(50_000_000)在两个线程中运行,或者countdown(25_000_000)在四个线程中运行,等等。在像 C 这样没有 GIL 的语言中,随着线程数量的增加,我们会看到加速。在具有两个内核和超线程的MacBook Pro 上运行 Python ,我看到以下内容:

线程数

每个线程的递减量 (n)

以秒为单位的时间(最好的 3 个)

1

100,000,000

6.52

2

50,000,000

6.57

4

25,000,000

6.59

8

12,500,000

6.58

时间不会变。事实上,由于与上下文切换相关的开销,多线程程序可能运行得更慢。默认切换间隔为 5 毫秒,因此上下文切换不会经常发生。但是如果我们减少切换间隔,我们会看到速度变慢。

尽管 Python 线程不能帮助我们加速 CPU 密集型代码,但当我们想要同时执行多个 I/O 密集型任务时,它们很有用。考虑一个服务器,它侦听传入的连接,并在接收到连接时在单独的线程中运行处理程序函数。处理程序函数通过读取和写入客户端的套接字来与客户端对话。从套接字读取时,线程只是挂起,直到客户端发送一些东西。这就是多线程有用之处:另一个线程可以同时运行。

为了在持有 GIL 的线程等待 I/O 时允许其他线程运行,CPython 使用以下模式实现所有 I/O 操作:

  1. 释放 GIL;
  2. 执行操作,例如write(), recv(), accept();
  3. 获得 GIL。

因此,一个线程可以在另一个线程设置eval_breaker和之前自愿释放 GIL gil_drop_request。通常,线程仅在与 Python 对象一起工作时才需要持有 GIL。因此,CPython 不仅将释放-执行-获取模式应用于 I/O 操作,还应用于其他阻塞调用到操作系统,如select()和pthread_mutex_lock(),以及纯 C 中的大量计算。例如,散列函数中的hashlib标准模块发布 GIL。这使我们能够实际加速使用多线程调用此类函数的 Python 代码。

假设我们要计算八个 128 MB 消息的 SHA-256 哈希值。我们可以hashlib.sha256(message)在单个线程中为每条消息计算,但我们也可以在多个线程之间分配工作。如果我在我的机器上进行比较,我会得到以下结果:

线程数

每个线程的消息总大小

以秒为单位的时间(3 个线程最好)

1

1 GB

3.30

2

512 MB

1.68

4

256 MB

1.50

8

128 MB

1.60

从一个线程到两个线程几乎是 2 倍的加速,因为线程并行运行。添加更多线程并没有多大帮助,因为我的机器只有两个物理内核。这里的结论是,如果代码调用释放 GIL 的 C 函数,则可以使用多线程来加速 CPU 密集型 Python 代码。请注意,此类函数不仅可以在标准库中找到,还可以在计算量大的第三方模块(如NumPy )中找到。您甚至可以编写一个C 扩展来自己发布 GIL。

我们已经提到过 CPU 密集型线程——大部分时间进行计算的线程,以及 I/O 密集型线程——大部分时间等待 I/O 的线程。当我们将两者混合时,GIL 最有趣的效果就会发生。考虑一个简单的 TCP 回显服务器,它侦听传入的连接,并在客户端连接时生成一个新线程来处理客户端:

from threading import Thread
import socket


def run_server(host='127.0.0.1', port=33333):
    sock = socket.socket()
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((host, port))
    sock.listen()
    while True:
        client_sock, addr = sock.accept()
        print('Connection from', addr)
        Thread(target=handle_client, args=(client_sock,)).start()


def handle_client(sock):
    while True:
        received_data = sock.recv(4096)
        if not received_data:
            break
        sock.sendall(received_data)

    print('Client disconnected:', sock.getpeername())
    sock.close()


if __name__ == '__main__':
    run_server()

这台服务器每秒可以处理多少个请求?我编写了一个简单的客户端程序,它以最快的速度向服务器发送和接收 1 字节的消息,并获得大约 30k RPS 的数据。这很可能不是一个准确的度量,因为客户端和服务器运行在同一台机器上,但这不是重点。重点是看看当服务器在单独的线程中执行某些 CPU 密集型任务时 RPS 是如何下降的。

考虑完全相同的服务器,但有一个额外的虚拟线程,它在无限循环中递增和递减变量(任何受 CPU 限制的任务都将执行相同的操作):

# ... the same server code

def compute():
    n = 0
    while True:
        n += 1
        n -= 1

if __name__ == '__main__':
    Thread(target=compute).start()
    run_server()

您预计 RPS 将如何变化?轻微地?少 2 倍?少 10 倍?不。RPS 下降到 100,减少了 300 倍!如果您习惯了操作系统调度线程的方式,这将非常令人惊讶。为了明白我的意思,让我们将服务器和 CPU 绑定线程作为单独的进程运行,以便它们不受 GIL 的影响。我们可以将代码拆分为两个不同的文件,或者只使用multiprocessing标准模块来生成一个新进程,如下所示:

from multiprocessing import Process

# ... the same server code

if __name__ == '__main__':
    Process(target=compute).start()
    run_server()

这产生了大约 20k RPS。此外,如果我们启动两个、三个或四个受 CPU 限制的进程,RPS 几乎保持不变。OS 调度程序优先考虑 I/O 绑定线程,这是正确的做法。

在服务器示例中,I/O 绑定线程等待套接字准备好读取和写入,但任何其他 I/O 绑定线程的性能也会下降。考虑一个等待用户输入的 UI 线程。如果您将它与受 CPU 限制的线程一起运行,它会定期冻结。显然,这不是正常操作系统线程的工作方式,原因是 GIL。它会干扰操作系统调度程序。

这个问题实际上在 CPython 开发人员中是众所周知的。他们将其称为车队效应。David Beazley在 2010 年就它进行了一次演讲,并在 bugs.python.org 上打开了一个相关问题。2021 年,也就是 11 年后,该问题被关闭。然而,它还没有被修复。在本文的其余部分,我们将尝试找出原因。

https://bugs.python.org/issue7946


convoy effect/车队效应

车队效应的发生是因为I/O绑定线程每次执行I/O操作时,都会释放GIL,而当它在操作后尝试重新获取GIL时,GIL很可能已经被CPU占用了绑定线程。因此,I/O 密集型线程必须等待至少 5 毫秒才能设置eval_breaker并gil_drop_request强制 CPU 密集型线程释放 GIL。

一旦 I/O 密集型线程释放 GIL,操作系统就可以调度 CPU 密集型线程。I/O-bound 线程只有在 I/O 操作完成时才能被调度,所以它优先占用 GIL 的机会较少。如果操作真的很快,例如非阻塞send(),那么机会实际上非常好,但仅限于操作系统必须决定调度哪个线程的单核机器上。

在多核机器上,操作系统不必决定要调度两个线程中的哪一个。它可以在不同的内核上调度两者。结果是,CPU-bound 线程几乎可以保证首先获取 GIL,而 I/O-bound 线程中的每个 I/O 操作都会额外花费 5 ms。

请注意,强制释放 GIL 的线程会等待,直到另一个线程获取它,因此 I/O 绑定线程在一个切换间隔后获取 GIL。如果没有这个逻辑,车队效应会更加严重。

现在,5 毫秒是多少?这取决于 I/O 操作花费的时间。如果一个线程等待几秒钟直到套接字上的数据可供读取,那么额外的 5 ms 没有多大关系。但是有些 I/O 操作真的很快。例如,send()仅在发送缓冲区已满时阻塞,否则立即返回。因此,如果 I/O 操作需要微秒,那么等待 GIL 的毫秒数可能会产生巨大影响。

没有 CPU 绑定线程的回显服务器处理 30k RPS,这意味着单个请求大约需要 1/30k ≈ 30 μs。使用受 CPU 限制的线程,recv()并send()为每个请求添加额外的 5 ms = 5,000 μs,单个请求现在需要 10,030 μs。这大约是 300 倍。因此,吞吐量减少了 300 倍。

您可能会问:护航效应在实际应用中是一个问题吗?我不知道。我从来没有遇到过,也找不到其他人遇到过的证据。人们不会抱怨,这也是问题没有得到解决的部分原因。

但是,如果护航效应确实导致您的应用程序出现性能问题怎么办?这里有两种方法可以修复它。


修复车队效应

由于问题是 I/O-bound 线程等待切换间隔,直到它请求 GIL,我们可能会尝试将切换间隔设置为较小的值。Pythonsys.setswitchinterval(interval)为此目的提供了该功能。的interval参数是表示秒数的浮点值。切换间隔以微秒为单位,因此最小值为0.000001。这是我改变切换间隔和 CPU 线程数后得到的 RPS:

以秒为单位的切换间隔

没有 CPU 线程的 RPS

一个 CPU 线程的 RPS

带有两个 CPU 线程的 RPS

具有四个 CPU 线程的 RPS

0.1

30,000

5

2

0

0.01

30,000

50

30

15

0.005

30,000

100

50

30

0.001

30,000

500

280

200

0.0001

30,000

3,200

1,700

1000

0.00001

30,000

11,000

5,500

2,800

0.000001

30,000

10,000

4,500

2,500

结果显示了几件事:

  • 如果 I/O 绑定线程是唯一线程,则切换间隔无关紧要。
  • 当我们添加一个受 CPU 限制的线程时,RPS 显着下降。
  • 当我们将受 CPU 限制的线程数量增加一倍时,RPS 减半。
  • 随着我们减少切换间隔,RPS 几乎成比例增加,直到切换间隔变得太小。这是因为上下文切换的成本变得很大。

较小的切换间隔使 I/O 密集型线程响应更快。但是过小的切换间隔会引入大量上下文切换导致的大量开销。调用countdown()函数。我们看到我们不能用多线程来加速它。如果我们将切换间隔设置得太小,那么我们也会看到速度变慢:

以秒为单位的切换间隔

以秒为单位的时间(线程数:1)

时间以秒为单位(线程:2)

以秒为单位的时间(线程:4)

以秒为单位的时间(线程:8)

0.1

7.29

6.80

6.50

6.61

0.01

6.62

6.61

7.15

6.71

0.005

6.53

6.58

7.20

7.19

0.001

7.02

7.36

7.56

7.12

0.0001

6.77

9.20

9.36

9.84

0.00001

6.68

12.29

19.15

30.53

0.000001

6.89

17.16

31.68

86.44

同样,如果只有一个线程,切换间隔并不重要。此外,如果切换间隔足够大,线程数并不重要。小的切换间隔和多个线程是性能不佳的时候。

结论是更改开关间隔是修复车队效应的一个选项,但您应该小心衡量更改如何影响您的应用程序。

修复车队效应的第二种方法更加笨拙。由于问题在单核机器上不那么严重,我们可以尝试将所有 Python 线程限制为单核。这将强制操作系统选择要调度的线程,并且 I/O 绑定线程将具有优先级。

并非每个操作系统都提供将一组线程限制到某些内核的方法。据我了解,macOS 仅提供一种机制来向OS 调度程序提供提示。我们需要的机制在 Linux 上是可用的。这是pthread_setaffinity_np()功能。它需要一个线程和一个 CPU 内核掩码,并告诉操作系统仅在掩码指定的内核上调度线程。

pthread_setaffinity_np()是一个 C 函数。要从 Python 调用它,您可以使用类似ctypes. 我不想惹麻烦ctypes,所以我只是修改了 CPython 源代码。然后我编译了可执行文件,在双核 Ubuntu 机器上运行 echo 服务器并得到以下结果:

CPU 绑定线程数

0

1

2

4

8

RPS

24k

12k

3k

30

10

服务器可以很好地容忍一个 CPU 密集型线程。但是由于 I/O-bound 线程需要与所有 CPU-bound 线程竞争 GIL,随着我们添加更多线程,性能会大幅下降。修复更像是一个黑客。为什么 CPython 开发人员不只是实现一个合适的 GIL?


适当的 GIL

GIL 的根本问题是它会干扰操作系统调度程序。理想情况下,您希望在 I/O 绑定线程等待的 I/O 操作完成后立即运行该线程。这就是操作系统调度程序通常所做的。然而,在 CPython 中,线程会立即陷入等待 GIL 的状态,因此操作系统调度程序的决定实际上没有任何意义。您可能会尝试摆脱切换时间间隔,以便想要 GIL 的线程毫不延迟地获得它,但是您会遇到 CPU 绑定线程的问题,因为它们一直都需要 GIL。

正确的解决方案是区分线程。一个 I/O-bound 线程应该能够在不等待的情况下从一个 CPU-bound 线程中拿走 GIL,但是具有相同优先级的线程应该互相等待。操作系统调度程序已经区分了线程,但您不能依赖它,因为它对 GIL 一无所知。似乎唯一的选择是在解释器中实现调度逻辑。

在 David Beazley 打开issue 之后,(https://bugs.python.org/issue7946)

CPython 开发人员进行了多次尝试来解决它(http://dabeaz.blogspot.com/2010/02/revisiting-thread-priorities-and-new.html)。Beazley 自己提出了一个简单的补丁。简而言之,此补丁允许 I/O 绑定线程抢占 CPU 绑定线程。默认情况下,所有线程都被视为 I/O 绑定。一旦一个线程被迫释放 GIL,它就会被标记为 CPU-bound。当一个线程自愿释放 GIL 时,该标志被重置,并且该线程再次被认为是 I/O 绑定的。

Beazley 的补丁解决了我们今天讨论的所有 GIL 问题。怎么还没合并?共识似乎是,在某些病理情况下,任何简单的 GIL 实施都会失败。最多,您可能需要更加努力地找到它们。一个合适的解决方案必须像操作系统一样进行调度,或者像 Nir ??Aides 所说的那样:

... Python 真的需要一个调度程序,而不是一个锁。

所以 Aides 在他的补丁中实现了一个成熟的调度器。

https://bugs.python.org/issue7946#msg101612

该补丁有效,但调度程序从来都不是一件小事,因此将其合并到 CPython 需要付出很多努力。最后,这项工作被放弃了,因为当时没有足够的证据表明该问题会导致生产代码出现问题。有关更多详细信息,请参阅讨论。https://bugs.python.org/issue7946。

GIL 从未有过庞大的粉丝群。我们今天所看到的只会让情况变得更糟。我们回到一直以来的问题。


我们不能删除 GIL 吗?

删除 GIL 的第一步是了解它为什么存在。想想为什么您通常会在多线程程序中使用锁,您就会得到答案。从其他线程的角度来看,它是为了防止竞争条件并使某些操作具有原子性。假设您有一系列修改某些数据结构的语句。如果你没有用锁包围序列,那么另一个线程可以在修改中间的某个地方访问数据结构,并得到一个破碎的不完整视图。

或者说您从多个线程增加相同的变量。如果增量操作不是原子的并且不受锁保护,那么变量的最终值可以小于增量的总数。这是典型的数据竞赛:

  1. 线程 1 读取值x。
  2. 线程 2 读取值x。
  3. 线程 1 写回值x + 1。
  4. 线程 2 写回值x + 1,从而丢弃线程 1 所做的更改。

在 Python 中,+=操作不是原子的,因为它由多个字节码指令组成。要查看它如何导致数据竞争,请将切换间隔设置为0.000001并在多个线程中运行以下函数:

sum = 0

def f():
    global sum
    for _ in range(1000):
        sum += 1

类似地,在 C 中递增整数类似于x++或++x不是原子的,因为编译器将此类操作转换为机器指令序列。线程之间可以交错。

GIL 非常有用,因为 CPython 递增和递减整数,这些整数可以在所有线程之间共享。这是 CPython 进行垃圾收集的方式。每个 Python 对象都有一个引用计数字段。此字段计算引用对象的位置数:其他 Python 对象、局部和全局 C 变量。多一位会增加引用计数。少一个地方减少它。当引用计数达到零时,对象被释放。如果不是 GIL,一些递减可能会相互覆盖,并且对象将永远留在内存中。更糟糕的是,被覆盖的增量可能会导致具有活动引用的对象被释放。

GIL 还简化了内置可变数据结构的实现。列表、字典和集合在内部不使用锁定,但由于 GIL,它们可以安全地用于多线程程序中。类似地,GIL 允许线程安全地访问全局和解释器范围的数据:加载的模块、预分配的对象、内部字符串等等。

最后,GIL 简化了 C 扩展的编写。开发人员可以假设在任何给定时间只有一个线程运行他们的 C 扩展。因此,他们不需要使用额外的锁定来使代码线程安全。当他们确实想要并行运行代码时,他们可以释放 GIL。

综上所述,GIL 所做的是使以下线程安全:

  1. 引用计数;
  2. 可变数据结构;
  3. 全球和解释器范围的数据;
  4. C 扩展。

要移除 GIL 并仍然有一个可以工作的解释器,您需要找到线程安全的替代机制。过去人们曾尝试这样做。最著名的尝试是 Larry Hastings 于 2016 年开始的 Gilectomy 项目。

https://github.com/larryhastings/gilectomy

Hastings 对CPython 进行了fork,移除了 GIL,

https://github.com/larryhastings/gilectomy/commit/4a1a4ff49e34b9705608cad968f467af161dcf02

修改了引用计数以使用原子增量和减量,

https://gcc.gnu.org/onlinedocs/gcc-4.1.1/gcc/Atomic-Builtins.html

并放置了大量细粒度锁来保护可变数据结构和解释器范围数据。

Gilectomy 可以并行运行一些 Python 代码。但是,CPython 的单线程性能受到了影响。仅原子增量和减量就增加了大约 30% 的开销。Hastings 试图通过实现缓冲引用计数来解决这个问题。简而言之,这种技术将所有引用计数更新限制在一个特殊线程中。其他线程只将增量和减量提交到日志,特殊线程读取日志。这有效,但开销仍然很大。

https://mail.python.org/archives/list/python-dev@python.org/message/YJDRVOUSRVGCZTKIL7ZUJ6ITVWZTC246/

最后,很明显,Gilectomy 不会被合并到 CPython 中。黑斯廷斯停止了该项目的工作。不过,这并非完全失败。它告诉我们为什么从 CPython 中删除 GIL 很难。有两个主要原因:

  1. 基于引用计数的垃圾收集不适合多线程。唯一的解决方案是实现一个跟踪垃圾收集器,JVM、CLR、Go 和其他没有 GIL 的运行时实现了。
  2. 删除 GIL 会破坏现有的 C 扩展。没有其他办法了。

现在没有人认真考虑删除 GIL。这是否意味着我们将永远与 GIL 一起生活?


GIL 和 Python 并发的未来

这听起来很可怕,但 CPython 有很多 GIL 的可能性比根本没有 GIL 的可能性要大得多。从字面上看,有一项倡议将多个 GIL 引入 CPython。它被称为子解释器。这个想法是在同一进程中有多个解释器。一个解释器中的线程仍然共享 GIL,但多个解释器可以并行运行。不需要 GIL 来同步解释器,因为它们没有通用的全局状态并且不共享 Python 对象。所有全局状态都是针对每个解释器进行的,解释器仅通过消息传递进行通信。最终目标是将基于 Go 和 Clojure 等语言中的顺序进程通信的并发模型引入 Python。

自 1.5 版以来,解释器一直是 CPython 的一部分,但仅作为一种隔离机制。它们存储特定于一组线程的数据:加载的模块、内置程序、导入设置等。它们不在 Python 中公开,但 C 扩展可以通过 Python/C API 使用它们。不过,有些人确实这样做了,这mod_wsgi是一个显着的例子。

https://modwsgi.readthedocs.io/en/develop/index.html

今天的解释器受到他们必须共享 GIL 的限制。只有当所有的全局状态都是每个解释器时,这才能改变。现在工作正朝着这个方向进行,但很少有事情是全局的:一些内置类型、单例,如None、True和False,以及部分内存分配器。C 扩展还需要摆脱全局状态,然后才能使用子解释器。

Eric Snow 编写了PEP 554,

https://www.python.org/dev/peps/pep-0554/

将interpreters模块添加到标准库中。这个想法是将现有的解释器 C API 暴露给 Python 并提供解释器之间的通信机制。该提案针对 Python 3.9,但被推迟到每个解释器都制定了 GIL。即使这样也不能保证成功。此事争论是Python中是否真的需要另一个并发模型。

现在正在进行的另一个令人兴奋的项目是Faster CPython。

https://github.com/faster-cpython

2020 年 10 月,Mark Shannon 提出了一项计划,在几年内使 CPython 的速度提升 ≈5 倍。它实际上比听起来要现实得多,因为 CPython 有很大的优化潜力。单独添加 JIT 可以带来巨大的性能提升。

以前也有过类似的项目,但由于缺乏适当的资金或专业知识而失败了。这一次,微软自愿赞助 Faster CPython,并让 Mark Shannon、Guido van Rossum 和 Eric Snow 参与该项目。增量更改已经进入 CPython——它们不会在分叉中陈旧。

Faster CPython 专注于单线程性能。该团队没有更改或删除 GIL 的计划。尽管如此,如果该项目成功,Python 的一个主要痛点将得到解决,GIL 问题可能会变得比以往任何时候都更加重要。


如果你想更详细地研究 GIL,你应该阅读源代码。该Python/ceval_gil.h文件是一个完美的起点。为了帮助您进行这项冒险,我写了以下奖金部分。

GIL的实现细节*

从技术上讲,GIL 是一个标志,指示 GIL 是否被锁定,一组互斥体和控制如何设置此标志的条件变量,以及一些其他实用程序变量,如开关间隔。所有这些东西都存储在_gil_runtime_state结构中:

struct _gil_runtime_state {
    /* microseconds (the Python API uses seconds, though) */
    unsigned long interval;
    /* Last PyThreadState holding / having held the GIL. This helps us
       know whether anyone else was scheduled after we dropped the GIL. */
    _Py_atomic_address last_holder;
    /* Whether the GIL is already taken (-1 if uninitialized). This is
       atomic because it can be read without any lock taken in ceval.c. */
    _Py_atomic_int locked;
    /* Number of GIL switches since the beginning. */
    unsigned long switch_number;
    /* This condition variable allows one or several threads to wait
       until the GIL is released. In addition, the mutex also protects
       the above variables. */
    PyCOND_T cond;
    PyMUTEX_T mutex;
#ifdef FORCE_SWITCHING
    /* This condition variable helps the GIL-releasing thread wait for
       a GIL-awaiting thread to be scheduled and take the GIL. */
    PyCOND_T switch_cond;
    PyMUTEX_T switch_mutex;
#endif
};

该_gil_runtime_statestuct是全全局状态的一部分。它存储在_ceval_runtime_state结构体中,而结构体又是_PyRuntimeState所有 Python 线程都可以访问的一部分:

struct _ceval_runtime_state {
    _Py_atomic_int signals_pending;
    struct _gil_runtime_state gil;
};
typedef struct pyruntimestate {
    // ...
    struct _ceval_runtime_state ceval;
    struct _gilstate_runtime_state gilstate;

    // ...
} _PyRuntimeState;

请注意,这_gilstate_runtime_state是一个不同于_gil_runtime_state. 它存储有关 GIL 持有线程的信息:

struct _gilstate_runtime_state {
    /* bpo-26558: Flag to disable PyGILState_Check().
       If set to non-zero, PyGILState_Check() always return 1. */
    int check_enabled;
    /* Assuming the current thread holds the GIL, this is the
       PyThreadState for the current thread. */
    _Py_atomic_address tstate_current;
    /* The single PyInterpreterState used by this process'
       GILState implementation
    */
    /* TODO: Given interp_main, it may be possible to kill this ref */
    PyInterpreterState *autoInterpreterState;
    Py_tss_t autoTSSkey;
};

最后,有一个_ceval_state结构体,它是PyInterpreterState. 它存储eval_breaker和gil_drop_request标志:

struct _ceval_state {
    int recursion_limit;
    int tracing_possible;
    /* This single variable consolidates all requests to break out of
       the fast path in the eval loop. */
    _Py_atomic_int eval_breaker;
    /* Request for dropping the GIL */
    _Py_atomic_int gil_drop_request;
    struct _pending_calls pending;
};

Python/C API 提供了PyEval_RestoreThread()和PyEval_SaveThread()函数来获取和释放 GIL。这些功能也负责设置gilstate->tstate_current。在引擎盖下,所有的工作都是由take_gil()和drop_gil()函数完成的。当 GIL 持有线程暂停字节码执行时,它们会被调用:

/* Handle signals, pending calls, GIL drop request
   and asynchronous exception */
static int
eval_frame_handle_pending(PyThreadState *tstate)
{
    _PyRuntimeState * const runtime = &_PyRuntime;
    struct _ceval_runtime_state *ceval = &runtime->ceval;

    /* Pending signals */
    // ...

    /* Pending calls */
    struct _ceval_state *ceval2 = &tstate->interp->ceval;
    // ...

    /* GIL drop request */
    if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) {
        /* Give another thread a chance */
        if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) {
            Py_FatalError("tstate mix-up");
        }
        drop_gil(ceval, ceval2, tstate);

        /* Other threads may run now */

        take_gil(tstate);

        if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) {
            Py_FatalError("orphan tstate");
        }
    }

    /* Check for asynchronous exception. */
    // ...
}

在类 Unix 系统上,GIL 的实现依赖于pthreads库提供的原语。这些包括互斥体和条件变量。简而言之,它们的工作方式如下。线程调用pthread_mutex_lock(mutex)锁定互斥锁。当另一个线程做同样的事情时,它会阻塞。操作系统将它放在等待互斥锁的线程队列中,并在第一个线程调用时将其唤醒pthread_mutex_unlock(mutex)。一次只有一个线程可以运行受保护的代码。

条件变量允许一个线程等待另一个线程使某些条件成立。要等待条件变量,线程会锁定互斥锁并调用pthread_cond_wait(cond, mutex)or pthread_cond_timedwait(cond, mutex, time)。这些调用原子地解锁互斥锁并使线程阻塞。操作系统将线程放在等待队列中,并在另一个线程调用时将其唤醒pthread_cond_signal()。被唤醒的线程再次锁定互斥锁并继续。以下是条件变量的通常使用方式:

# awaiting thread

mutex.lock()
while not condition:
    cond_wait(cond_variable, mutex)
# ... condition is True, do something
mutex.unlock()
# signaling thread

mutex.lock()
# ... do something and make condition True
cond_signal(cond_variable)
mutex.unlock()

请注意,等待线程应该在循环中检查条件,因为在通知之后不能保证它为真。互斥体确保等待线程不会错过从假到真的条件。

该take_gil()和drop_gil()功能使用gil->cond条件变量通知GIL-等待线程的GIL已被释放,gil->switch_cond其他线程拿着GIL通知GIL控股线程。这些条件变量由两个互斥锁保护:gil->mutex和gil->switch_mutex。

以下是步骤take_gil():

  1. 锁定 GIL 互斥锁:pthread_mutex_lock(&gil->mutex).
  2. 看看gil->locked。如果不是,请转到步骤 4。
  3. 等待 GIL。虽然gil->locked:记住gil->switch_number。等待GIL控股线程放弃了GIL: pthread_cond_timedwait(&gil->cond, &gil->mutex, switch_interval)。如果超时,并且gil->locked并且gil->switch_number没有改变,告诉持有 GIL 的线程删除 GIL: set ceval->gil_drop_requestand ceval->eval_breaker。
  4. 获取 GIL 并通知持有 GIL 的线程我们已获取它:锁定开关互斥锁:pthread_mutex_lock(&gil->switch_mutex).设置gil->locked。如果我们不是gil->last_holder线程,则 updategil->last_holder和 increment gil->switch_number。通知GIL释放线程,我们采取了GIL: pthread_cond_signal(&gil->switch_cond)。解锁开关互斥锁:pthread_mutex_unlock(&gil->switch_mutex).
  5. 重置ceval->gil_drop_request。
  6. 重新计算ceval->eval_breaker。
  7. 解锁 GIL 互斥锁:pthread_mutex_unlock(&gil->mutex).

请注意,当一个线程等待 GIL 时,另一个线程可以使用它,因此有必要检查gil->switch_number以确保刚刚使用 GIL 的线程不会被迫丢弃它。

最后,这里的步骤drop_gil():

  1. 锁定 GIL 互斥锁:pthread_mutex_lock(&gil->mutex).
  2. 重置gil->locked。
  3. 通知GIL-等待线程我们放弃了GIL: pthread_cond_signal(&gil->cond)。
  4. 解锁 GIL 互斥锁:pthread_mutex_unlock(&gil->mutex).
  5. 如果ceval->gil_drop_request,等待另一个线程获取 GIL:锁定开关互斥锁:pthread_mutex_lock(&gil->switch_mutex).如果我们还在gil->last_holder,请等待:pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex)。解锁开关互斥锁:pthread_mutex_unlock(&gil->switch_mutex).

请注意,释放 GIL 的线程不需要等待循环中的条件。它调用pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex)只是为了确保它不会立即重新获取 GIL。如果发生了切换,这意味着另一个线程占用了 GIL,可以再次竞争 GIL。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表