注册
登录
新闻动态
其他科技
返回
Python 幕后故事 #13:GIL 及其对 Python 多线程的影响
作者:
糖果
发布时间:
2024-09-22 03:36:23 (1天前)
来源:
effects-on-python-multithreading/
您可能知道,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()函数开始执行。主线程接下来所做的所有事情可以总结为三个步骤: 初始化解释器; 将 Python 代码编译为字节码; 进入求值循环执行字节码。 主线程是执行已编译 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 ) { // ...声明局部变量和其他无聊的东西 //评价环 为 (;;) { // `eval_breaker` 告诉我们是否应该暂停字节码执行 // 例如其他线程请求 GIL if ( _Py_atomic_load_relaxed ( eval_breaker )) { // `eval_frame_handle_pending()` 暂停字节码执行 // 例如,当另一个线程请求 GIL 时, // 该函数丢弃 GIL 并再次等待 GIL if ( eval_frame_handle_pending ( tstate ) != 0 ) { goto error ; } } // 获取下 一条字节码指令NEXTOPARG (); 开关 (操作码) { case TARGET (NOP ) { FAST_DISPATCH (); // 下一次迭代 } case TARGET ( LOAD_FAST ) { // ... 加载局部变量的代码 FAST_DISPATCH (); // 下一次迭代 } // ... 每个可能的操作码都有 117 种情况 } // ... 错误处理 } // ... 终止 } 在单线程 Python 程序中,主线程是唯一的线程,并且从不释放 GIL。现在让我们看看在多线程程序中会发生什么。我们使用threading标准模块来启动一个新的 Python 线程: 进口 螺纹 def f ( a , b , c ): # 做某事 通过 t = 穿线。线程( target = f , args = ( 1 , 2 ), kwargs = { 'c' : 3 }) t 。开始() 实例的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 倒计时( n ): 而 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 操作: 释放 GIL; 执行操作,例如write(), recv(), accept(); 获得 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 回显服务器,它侦听传入的连接,并在客户端连接时生成一个新线程来处理客户端: 从 线程 导入 线程 导入 套接字 def run_server ( host = '127.0.0.1' , port = 33333 ): sock = socket 。套接字() 袜子。setsockopt的(插座。SOL_SOCKET , 插座。SO_REUSEADDR , 1 ) 袜子。绑定((主机, 端口)) 袜子。listen () while True : client_sock , addr = sock. 接受() 打印('连接来自' , 地址) 线程(目标= handle_client , args = (client_sock ,))。开始() def handle_client ( sock ): while True : received_data = sock 。recv ( 4096 ) 如果 没有 收到_数据: 打破 袜子。sendall ( received_data ) 打印('客户端已断开:' , 袜子。getpeername ()) 袜子。关闭() 如果 __name__ == '__main__' : run_server () 这台服务器每秒可以处理多少个请求?我写了一个简单的客户端程序,它只是尽可能快地向服务器发送和接收 1 字节的消息,并获得大约 30k RPS 的信息。这很可能不是一个准确的度量,因为客户端和服务器运行在同一台机器上,但这不是重点。重点是看看当服务器在单独的线程中执行某些 CPU 密集型任务时 RPS 是如何下降的。 考虑完全相同的服务器,但有一个额外的虚拟线程,它在无限循环中递增和递减变量(任何受 CPU 限制的任务都将执行相同的操作): # ...相同的服务器代码 def 计算(): n = 0 而 True : n += 1 n -= 1 如果 __name__ == '__main__' : 线程(目标=计算)。开始() run_server () 您预计 RPS 将如何变化?轻微地?少 2 倍?少 10 倍?不。RPS 下降到 100,减少了 300 倍!如果您习惯了操作系统调度线程的方式,这将非常令人惊讶。为了明白我的意思,让我们将服务器和 CPU 绑定线程作为单独的进程运行,以便它们不受 GIL 的影响。我们可以将代码拆分为两个不同的文件,或者只使用multiprocessing标准模块来生成一个新进程,如下所示: 从 多处理 导入 过程 # ...相同的服务器代码 if __name__ == '__main__' : Process ( target = compute ) 。开始() 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 年后,该问题被关闭。然而,它还没有被修复。在本文的其余部分,我们将尝试找出原因。 车队效应 护航效应的发生是因为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 毫秒无关紧要。但是有些 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 之后,CPython 开发人员进行了多次尝试来解决它。Beazley 自己提出了一个简单的补丁。简而言之,此补丁允许 I/O 绑定线程抢占 CPU 绑定线程。默认情况下,所有线程都被视为 I/O 绑定。一旦一个线程被迫释放 GIL,它就会被标记为 CPU-bound。当一个线程自愿释放 GIL 时,该标志被重置,并且该线程再次被认为是 I/O 绑定的。 Beazley 的补丁解决了我们今天讨论的所有 GIL 问题。怎么还没合并?共识似乎是,在某些病理情况下,任何简单的 GIL 实施都会失败。最多,您可能需要更加努力地找到它们。一个合适的解决方案必须像操作系统一样进行调度,或者像 Nir Aides 所说的那样: ... Python 真的需要一个调度程序,而不是一个锁。 所以 Aides 在他的补丁中实现了一个成熟的调度器。该补丁有效,但调度程序从来都不是一件小事,因此将其合并到 CPython 需要付出很多努力。最后,这项工作被放弃了,因为当时没有足够的证据表明该问题会导致生产代码出现问题。有关更多详细信息,请参阅讨论。 GIL 从未有过庞大的粉丝群。我们今天所看到的只会让情况变得更糟。我们回到一直以来的问题。 我们不能删除 GIL 吗? 删除 GIL 的第一步是了解它为什么存在。想想为什么您通常会在多线程程序中使用锁,您就会得到答案。从其他线程的角度来看,它是为了防止竞争条件并使某些操作具有原子性。假设您有一系列修改某些数据结构的语句。如果你没有用锁包围序列,那么另一个线程可以在修改中间的某个地方访问数据结构,并得到一个破碎的不完整视图。 或者说您从多个线程增加相同的变量。如果增量操作不是原子的并且不受锁保护,那么变量的最终值可以小于增量的总数。这是典型的数据竞赛: 线程 1 读取值x。 线程 2 读取值x。 线程 1 写回值x + 1。 线程 2 写回值x + 1,从而丢弃线程 1 所做的更改。 在 Python 中,+=操作不是原子的,因为它由多个字节码指令组成。要查看它如何导致数据竞争,请将切换间隔设置为0.000001并在多个线程中运行以下函数: 总和 = 0 DEF ˚F (): 全球 总和 为 _ 在 范围(1000 ): 总和 + = 1 类似地,在 C 中递增整数类似于x++或++x不是原子的,因为编译器将此类操作转换为机器指令序列。线程之间可以交错。 GIL 非常有用,因为 CPython 递增和递减整数,这些整数可以在所有线程之间共享。这是 CPython 进行垃圾收集的方式。每个 Python 对象都有一个引用计数字段。此字段计算引用对象的位置数:其他 Python 对象、局部和全局 C 变量。多一位会增加引用计数。少一个地方减少它。当引用计数达到零时,对象被释放。如果不是 GIL,一些递减可能会相互覆盖,并且对象将永远留在内存中。更糟糕的是,被覆盖的增量可能会导致具有活动引用的释放对象。 GIL 还简化了内置可变数据结构的实现。列表、字典和集合在内部不使用锁定,但由于 GIL,它们可以安全地用于多线程程序。类似地,GIL 允许线程安全地访问全局和解释器范围的数据:加载的模块、预分配的对象、内部字符串等等。 最后,GIL 简化了 C 扩展的编写。开发人员可以假设在任何给定时间只有一个线程运行他们的 C 扩展。因此,他们不需要使用额外的锁定来使代码线程安全。当他们确实想要并行运行代码时,他们可以释放 GIL。 综上所述,GIL 所做的是使以下线程安全: 引用计数; 可变数据结构; 全球和解释器范围的数据; C 扩展。 要移除 GIL 并仍然有一个可以工作的解释器,您需要找到线程安全的替代机制。过去人们曾尝试这样做。最著名的尝试是 Larry Hastings 于 2016 年开始的 Gilectomy 项目。 Hastings 对CPython 进行了分叉,移除了 GIL,修改了引用计数以使用原子增量和减量,并放置了大量细粒度锁来保护可变数据结构和解释器范围数据。 Gilectomy 可以运行一些 Python 代码并并行运行。但是,CPython 的单线程性能受到了影响。仅原子增量和减量就增加了大约 30% 的开销。Hastings 试图通过实现缓冲引用计数来解决这个问题。简而言之,这种技术将所有引用计数更新限制在一个特殊线程中。其他线程只将增量和减量提交到日志,特殊线程读取日志。这有效,但开销仍然很大。 最后,很明显,Gilectomy 不会被合并到 CPython 中。黑斯廷斯停止了该项目的工作。不过,这并非完全失败。它告诉我们为什么从 CPython 中删除 GIL 很难。有两个主要原因: 基于引用计数的垃圾收集不适合多线程。唯一的解决方案是实现一个跟踪垃圾收集器,JVM、CLR、Go 和其他没有 GIL 的运行时实现。 删除 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是一个显着的例子。 今天的口译员受到他们必须共享 GIL 的限制。只有当所有的全局状态都是每个解释器时,这才能改变。工作正朝着这个方向进行,但很少有事情是全局的:一些内置类型、单例,如None、True和False,以及部分内存分配器。C 扩展还需要摆脱全局状态,然后才能使用子解释器。 Eric Snow 编写了PEP 554,将interpreters模块添加到标准库中。这个想法是将现有的解释器 C API 暴露给 Python 并提供解释器之间的通信机制。该提案针对 Python 3.9,但被推迟到每个解释器都制定了 GIL。即使这样也不能保证成功。此事争论是Python中是否真的需要另一个并发模型。 现在正在进行的另一个令人兴奋的项目是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 问题可能会变得比以往任何时候都更加重要。 聚苯乙烯 本文中使用的基准测试可在 GitHub 上找到。特别感谢 David Beazley的精彩演讲。Larry Hastings 关于 GIL 和 Gilectomy(一、二、三)的演讲也很有趣。为了了解现代操作系统调度程序的工作原理,我阅读了 Robert Love 的书Linux Kernel Development。强烈推荐它! 如果你想更详细地研究 GIL,你应该阅读源代码。该Python/ceval_gil.h文件是一个完美的起点。为了帮助您进行这项冒险,我写了以下奖金部分。 GIL的实现细节* 从技术上讲,GIL 是一个标志,指示 GIL 是否被锁定,一组互斥体和控制如何设置此标志的条件变量,以及一些其他实用程序变量,如开关间隔。所有这些东西都存储在_gil_runtime_state结构中: struct _gil_runtime_state { /* 微秒(尽管 Python API 使用秒)*/ unsigned long interval ; /* 最后持有的 PyThreadState / 持有 GIL。这有助于我们 了解在我们放弃 GIL 后是否安排了其他人。*/ _Py_atomic_address last_holder ; /* GIL 是否已经被占用(-1 如果未初始化)。这是 原子性的,因为它可以在 ceval.c 中不加锁的情况下读取。*/ _Py_atomic_int 锁定; /* 自开始以来的 GIL 切换次数。*/ unsigned long switch_number ; /* 这个条件变量允许一个或多个线程等待 直到 GIL 被释放。此外,互斥锁还保护 了上述变量。*/ PyCOND_T 条件; PyMUTEX_T 互斥锁; #ifdef FORCE_SWITCHING /* 这个条件变量帮助释放 GIL 的线程等待 一个等待 GIL 的线程被调度并获取 GIL。*/ PyCOND_T switch_cond ; PyMUTEX_T switch_mutex ; #endif }; 该_gil_runtime_statestuct是全球状态的一部分。它存储在_ceval_runtime_state结构体中,而结构体又是_PyRuntimeState所有 Python 线程都可以访问的一部分: 结构 _ceval_runtime_state { _Py_atomic_int signals_pending ; 结构体 _gil_runtime_state gil ; }; typedef struct pyruntimestate { // ... struct _ceval_runtime_state ceval ; 结构体 _gilstate_runtime_state gilstate ; // ... } _PyRuntimeState ; 请注意,这_gilstate_runtime_state是一个不同于_gil_runtime_state. 它存储有关 GIL 持有线程的信息: struct _gilstate_runtime_state { /* bpo-26558: 禁用 PyGILState_Check() 的标志。 如果设置为非零,PyGILState_Check() 总是返回 1。 */ int check_enabled ; /* 假设当前线程持有 GIL,这是 当前线程 的PyThreadState。*/ _Py_atomic_address tstate_current ; /* 此进程的 GILState 实现 使用的单个 PyInterpreterState */ /* TODO: 给定 interp_main,可能会 终止 此引用 */ PyInterpreterState * autoInterpreterState ; Py_tss_t autoTSSkey ; }; 最后,有一个_ceval_state结构体,它是PyInterpreterState. 它存储eval_breaker和gil_drop_request标志: 结构 _ceval_state { int recursion_limit ; int 跟踪可能; /* 这个单一的变量整合了所有 在 eval 循环中 跳出快速路径的请求。*/ _Py_atomic_int eval_breaker ; /* 请求删除 GIL */ _Py_atomic_int gil_drop_request ; struct _pending_calls 挂起; }; Python/C API 提供了PyEval_RestoreThread()和PyEval_SaveThread()函数来获取和释放 GIL。这些功能也负责设置gilstate->tstate_current。在引擎盖下,所有的工作都是由take_gil()和drop_gil()函数完成的。当 GIL 持有线程暂停字节码执行时,它们会被调用: /* 处理信号、挂起调用、GIL 丢弃请求 和异步异常 */ static int eval_frame_handle_pending ( PyThreadState * tstate ) { _PyRuntimeState * const runtime = & _PyRuntime ; struct _ceval_runtime_state * ceval = &运行时-> ceval ; /* 待处理信号 */ // ... / *待定呼叫* / 结构 _ceval_state * ceval2 = &TSTATE - >的interp - > ceval ; // ... /* GIL drop request */ if ( _Py_atomic_load_relaxed ( & ceval2 -> gil_drop_request )) { /* 给另一个线程一个机会 */ if ( _PyThreadState_Swap ( & runtime -> gilstate , NULL ) != tstate ) { Py_FatalError ( "tstate mix -向上" ); } drop_gil ( ceval , ceval2 , tstate ); /* 其他线程现在可以运行 */ take_gil ( tstate ); if ( _PyThreadState_Swap ( & runtime -> gilstate , tstate ) != NULL ) { Py_FatalError ( "orphan tstate" ); } } /* 检查异步异常。*/ // ... } 在类 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()。被唤醒的线程再次锁定互斥锁并继续。以下是条件变量的通常使用方式: # 等待线程 互斥锁。lock () while not condition : cond_wait ( cond_variable , mutex ) # ... condition is True, do something mutex 。解锁() # 信号线程 互斥锁。lock () # ... 做一些事情并使条件为真 cond_signal ( cond_variable ) 互斥锁。解锁() 请注意,等待线程应该在循环中检查条件,因为在通知之后不能保证它为真。互斥体确保等待线程不会错过从假到真的条件。 该take_gil()和drop_gil()功能使用gil->cond条件变量通知GIL-等待线程的GIL已被释放,gil->switch_cond其他线程拿着GIL通知GIL控股线程。这些条件变量由两个互斥锁保护:gil->mutex和gil->switch_mutex。 以下是步骤take_gil(): 锁定 GIL 互斥锁:pthread_mutex_lock(&gil->mutex). 看看gil->locked。如果不是,请转到步骤 4。 等待 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。 获取 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). 重置ceval->gil_drop_request。 重新计算ceval->eval_breaker。 解锁 GIL 互斥锁:pthread_mutex_unlock(&gil->mutex). 请注意,当一个线程等待 GIL 时,另一个线程可以使用它,因此有必要检查gil->switch_number以确保刚刚使用 GIL 的线程不会被迫丢弃它。 最后,这里的步骤drop_gil(): 锁定 GIL 互斥锁:pthread_mutex_lock(&gil->mutex). 重置gil->locked。 通知GIL-等待线程我们放弃了GIL: pthread_cond_signal(&gil->cond)。 解锁 GIL 互斥锁:pthread_mutex_unlock(&gil->mutex). 如果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。
收藏
举报
1 条回复
动动手指,沙发就是你的了!
登录
后才能参与评论