i007.cc

i007.cc

优先队列-降维打击

05.价值资料

译|Python幕后(13):GIL 及其影响

cc from


你可能知道,GIL即全局解释器锁,用于保证 CPython 解释器的线程安全。在 GIL 的控制下,同一时间,只能有一个系统线程执行 Python 字节码,因此,用户无法通过多线程来提升 CPU 密集型任务的运行速度。但这并非 GIL 的唯一问题。它还导致多线程效率降低,甚至包括 I/O 密集型线程。

本文会聚焦于 GIL 的一些比较隐蔽的影响。讨论过程中,会涉及 GIL 是什么,为什么要有 GIL,它的工作原理是什么,以及未来对 Python 并发的影响等等。

注意:文章中默认使用 CPython 3.9,随着 CPython 的演化,一些实现细节可能会有所调整,我会尽力留意一些重要变化并更新文章内容。

1. 系统线程、Python 线程与 GIL

首先一起来回顾下 Python 中的线程与多线程。运行 python 可执行文件时,操作系统会启动一个新进程,其中运行着一个线程,称主线程。和其它 C 程序一样,这个主线程也是从 main() 函数开始执行的,其工作逻辑可以总结为三个步骤:

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

主线程是一个普通的系统线程,执行的是编译好的 C 代码,其线程状态包括 CPU 寄存器的值以及 C 函数的调用栈。而 Python 线程必须同时保存 Python 函数的调用栈、执行状态以及其它与 Python 相关的数据。CPython 会将这些数据保存在一个数据结构中,即线程状态,并把它和对应的系统线程状态关联起来。简单地说,Python 线程 = 系统线程 + Python 线程状态。

求值循环是一个无限循环,其代码包含一个巨大的条件分支,处理各种字节码指令。进入循环的线程必须持有 GIL。主线程在初始化时一直控制着 GIL,因此可以进入求值循环。进入循环后,线程开始基于条件分支,逐个执行字节码指令。

每次循环开始前,线程都会检查是否需要停止执行。我们特别感兴趣的是别的线程请求 GIL 导致本线程停止的情况。其代码逻辑如下:

PyObject*
_PyEval_EvalFrameDefault(PyThreadState*tstate,PyFrameObject*f,intthrowflag)
{
// ... 局部变量声明及其它无聊的工作

// 求值循环
for(;;){

// `eval_breaker` 标识是否需要停止执行
// 比如其它线程请求 GIL
if(_Py_atomic_load_relaxed(eval_breaker)){

    // `eval_frame_handle_pending()` 停止字节码执行
    // 比如其它线程请求 GIL
    // 本函数释放并重新等待 GIL
    if(eval_frame_handle_pending(tstate)!=0){
        goto error;
    }
}

// 获取下一个字节码指令
NEXTOPARG();

switch(opcode){
    case TARGET(NOP){
        FAST_DISPATCH();// 下一次循环
    }

    case TARGET(LOAD_FAST){
        // ... 加载局部变量的字节码
        FAST_DISPATCH();// 下一次循环
    }

    // ... 117 个其它字节码的处理
}

// ... 错误处理
}

// ... 结束程序
}

在单线程 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()

Thread 实例的 start() 方法会创建一个新的系统线程。在包括 Linux 和 macOS 的类 UNIX 系统上,即调用 pthread_create() 函数。新创建的线程会使用 boot 参数调用 t_bootstrap() 函数。boot 参数是一个数据结构,保存着目标函数、传递的参数以及新线程的线程状态。t_bootstrap() 做的最关键的动作就是获取 GIL 并进入求值循环,开始执行目标函数的字节码。

为获取 GIL,线程首先检查是否有其它线程控制着 GIL,如果没有,则立即持有,否则等待 GIL 被释放。等待有一个固定时间,即切换间隔(switch interval,默认为 5ms)。如果 GIL 没有在固定时间内被释放,线程会设置 eval_breaker 与 gil_drop_request 标记,eval_breaker 表示持有 GIL 的线程应该停止执行,而 gil_drop_request 表示具体原因。当前持有 GIL 的线程在下一次循环中看到这两个标记,于是释放 GIL,通知正在等待的线程。如果有多个等待线程,则操作系统会判断具体要唤醒哪个线程,可能是也可能不是之前设置标记的线程。

以上就是 GIL 的基本逻辑。下面来看看它的影响。

2. GIL 的影响

GIL 的第一个影响是众所周知的:多个 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 上,双核带超线程,运行结果如下:

线程数 每个线程的递减值 (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

可以看到,不同线程数所花的时间并没有多少区别。多线程因为上下文切换的成本,所花时间还增加了。Python 线程的切换间隔默认为 5ms,因此上下文切换不算特别频繁,如果减小切换间隔,速度会更慢,具体原因我们后面会提到。

虽然 Python 线程无法加速 CPU 密集型任务,但对 I/O 密集型任务还是有用的。假设有一个服务器,监听并处理网络连接,通过读写 socket 与客户端通信,线程经常需要等待网络数据才能继续读写。此时,多线程的益处还是很明显的:其它线程可以同时处理其它连接。

为使某个线程等待 I/O 时其它线程可以运行,CPython 中的所有 I/O 操作都涉及以下几步:

  1. 释放 GIL;
  2. 执行操作,如 write()、 recv()、 accept()
  3. 请求 GIL;

也就是说,在其它线程设置 eval_breaker 与 gil_drop_request 标记前,线程可以主动释放 GIL。通常来说,线程只有在处理 Python 对象时需要持有 GIL,因此,CPython 不仅在 I/O 操作中,也在处理其它阻塞性系统调用,如 select() 或 pthread_mutex_lock(),或执行大计算量的纯 C 语言代码,如执行 hashlib 标准库中的哈希函数时,会先释放 GIL,再执行操作,最后重新请求 GIL。在类似任务中,多线程都可以提高执行速度。

假设我们要为 8 个 128M 大小的消息计算 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

双线程比单线程快了近1倍,因为可以并行执行,而更多线程带来的提升有限,因为我的电脑只有两个物理内核。从这个例子可以看出,如果涉及的纯 C 语言调用会释放 GIL,多线程是可以提高 CPU 密集型任务的执行速度的。值得注意的是,不仅标准库中有这样的调用,一些计算密集型的第三方库,如 NumPy 等,也有类似的调用。当然,你也可以自己写一个能释放 GIL 的 C 扩展

至此,我们提到了 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 数会减少多少?

在同一台服务器上增加一个新线程,在无限循环中执行变量增减(或其它任意计算密集型任务):

# ... the same server code

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

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

你预计 RPS 会减少多少呢?稍微减少?减少1倍?10倍?事实上,RPS 降到了 100,减少了 300 倍!这个结果令人惊讶。作为对比,我们可以用多进程形式分别执行这两个任务,避开 GIL 的影响。可以把代码拆分到不同文件中,或者直接用 multiprocessing 标准库启动新进程:

from multiprocessing import Process

# ... the same server code

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

此时,服务器处理速度约为 20K RPS。即便我们开启更多进程处理 CPU 密集型任务,RPS 依然差不多。操作系统会优先调度 I/O 密集型线程,这是绝对正确的。

在这个例子中,我们处理的是 socket 读写,而在其它 I/O 任务中,受影响的情况也类似。如果是 UI 线程,同时有个 CPU 密集型线程在跑,也会时不时卡顿一下。显然,操作系统的线程调度逻辑不会导致这个问题,罪魁祸首还是 GIL。GIL 会影响到系统线程的调度。

这个问题在 CPython 开发者中广为人知,被称为 convoy 效应(convoy effect,译者注:来自 lock convoy,指频繁切换锁的控制权导致的性能降低,有译为锁封护的,并不直白)。David Beazley 曾在 2010 年有一次针对这个问题的演讲,并在 bugs.python.org 上提了问题单。而在 11 年后的 2021 年,这个问题单已经关闭了,但并没有解决,具体原因会在后文分析。

3. convoy 效应

convoy 效应发送的原因是,I/O 密集型线程在 I/O 操作时会释放 GIL,而在重新获取时,GIL 大概率已经被 CPU 密集型线程占有了,因而必须等待 5ms 然后设置 eval_breaker 和 gil_drop_request 标记,强制对方释放 GIL。

对 CPU 密集型线程来说,每次 I/O 密集型线程释放 GIL 都能获得调度,而 I/O 密集型线程必须等待 I/O 操作完成才能被调度,因而实际获得的时间片比较少。如果 I/O 操作足够快,比如使用非阻塞 send(),并且使用的是单核设备,两类线程的调度机会还比较公平,因为操作系统会必须决定两个线程先调度哪一个。

而在多核设备中,操作系统可以让不同线程运行在不同核上,因此 CPU 密集型线程几乎总是能成功获取 GIL,但 I/O 线程却必须在每次 I/O 操作之后等待 5ms。

当然,Python 线程会在别的线程有需要时强制释放 GIL,使 I/O 密集型线程能在一次切换间隔后成功获取 GIL,否则 convoy 效应会更严重。

那么,5ms 算不算久呢?取决于 I/O 操作所花的时间。如果每次 socket 操作都要等待数秒钟,多个 5ms 也无所谓。但有些 I/O 操作其实非常快,比如对 send() 来说,除非缓存已满,否则都会立即返回。那么,I/O 操作只花费数微秒,几毫秒的等待就影响很大了。

在前面的例子中,没有 CPU 密集型线程时,服务器性能为 30k RPS,即单次请求处理时间为 1/30k ≈ 30 µs。增加 CPU 密集型线程后,每次 recv() 和 send() 操作都需要额外等待 5ms = 5,000 µs,则单次请求花费 10,030 µs,也就是比原来多了 300 倍。因此,测试的结果是吞吐率下降了 300 倍,数据和理论预期一致。

你可能会问:现实中的应用是否有 convoy 效应的例子呢?我不清楚。我自己没有碰到过,也没听谁提过这个问题。实践中没有人抱怨过这个问题,也是它一直未被解决的原因之一。

万一 convoy 效应真的影响到你的应用性能,也有两种应对办法。

4. 应对 convoy 效应

既然 I/O 密集型必须等待一个切换间隔后才能获取 GIL,我们可以缩短这个间隔时间。用 sys.setswitchinterval(interval) 函数可以设置切换间隔,interval 参数是以秒为单位的浮点值,而切换间隔是以微秒计的,因此,interval 的最小值为 0.000001。下面是修改切换间隔后的测试结果:

切换间隔/秒 RPS/无CPU密集型线程 RPS/1CPU线程 RPS/2CPU线程 RPS/4CPU线程
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

可以看到:

  • 如果没有 CPU 密集型线程的影响,切换间隔没有影响;
  • 添加一个 CPU 密集型线程后,RPS 显著减少;
  • CPU 密集型线程的数量增加一倍,RPS 减半;
  • 缩短切换间隔可以有效增加 RPS,直到切换间隔过短,导致上下文切换成本的影响变大;

更短的切换间隔可以提高 I/O 密集型线程的表现,但切换间隔太短也导致上下文切换成本更高。前面我们看到,无法通过多线程提高 countdown() 函数的执行速度。如果缩短切换间隔,其执行速度还会降低:

切换间隔/秒 时间/秒 (单线程) 时间/秒 (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

单线程场景,切换间隔依然没有影响。如果切换间隔足够长,线程数增加也没什么影响。只有在切换间隔短且多线程的情况下,性能才会明显降低。

总结来说,修改切换间隔是应对 convoy 效应的一个办法,但必须仔细衡量它在具体应用中的实际表现。

第二个应对 convoy 效应的办法就更偏门了。既然在单核设备上,这个问题的影响较小,我们就干脆把所有 Python 线程都绑定到一个核心上。此时,操作系统必须选择线程进行调度,而 I/O 密集型线程在操作系统中的优先级更高。

这种操作在有些系统上没法实现。就我所知,macOS 只有一种非强制机制来影响线程调度。不过,Linux 上是有这个机制的,即 pthread_setaffinity_np() 函数,用户可以指定线程和 CPU 核心掩码,让该线程只运行在掩码标识的核心上。

pthread_setaffinity_np() 是一个 C 函数,在 Python 中调用必须通过 ctypes 之类的方法,我不想搞这么复杂,因此直接修改的 CPython 源码,编译完成后,在一台双核 Ubuntu 设备上测试前面的服务器代码,结果如下:

CPU密集型线程数量 0 1 2 4 8
RPS 24k 12k 3k 30 10

我们看到,服务器受 CPU 密集型线程的影响变小了。不过 I/O 线程还是要和多个 CPU 密集型线程抢 GIL,所以随着线程数增加,性能也快速下降。这种应对方法算是一种野路子。你可能会问了,CPython 开发者就不能搞一个更好用的 GIL 吗?

2021年10月7日更新:把线程绑定到单个核心的方法只在客户端线程也被绑定在该核心时才有效,具体请参考文末附注。

5. 更好用的 GIL

GIL 的核心问题是它影响到了系统线程调度。理想情况下,I/O 密集型线程在 I/O 操作完成后立即运行,这也是系统线程调度的逻辑。而在 CPython 中,它还会被 GIL 阻塞,等于系统线程调度逻辑失效了。用户可以缩小切换间隔,但这样的话,CPU 密集型线程就会一直请求 GIL。

一个合理的方案是区分不同类型的线程。允许 I/O 密集型线程立即获取 CPU 线程的 GIL,而同类线程间互相等待。系统线程实际上有做类型区分,只是它不知道 GIL 的存在,没有发挥作用。唯一的办法似乎是在解释器中实现这个调度逻辑。

David Beazley 提单之后,CPython 开发者尝试过一些方案。Beazley 自己也提了一个简单的补丁,允许 I/O 密集型线程抢占 CPU 密集型线程的 GIL。默认情况下,所有线程都是 I/O 密集型线程,一旦被强制释放 GIL,则标记为 CPU 密集型线程,如果它又主动释放 GIL,则重新标记为 I/O 密集型线程。

Beazley 的补丁解决了我们前面提到的问题,但并没有被合入版本中,原因似乎是任何简单的 GIL 实现都会在一些特殊场景下遇到问题。真正的方案必须是和操作系统一样进行线程调度,或者如 Nir Aides 所说:

… Python 真正需要的是一个调度器,而不是锁。

Aides 在自己的补丁中实现了一个完整的调度器。但引入调度器可不简单,这个补丁要合入 CPython 还得做许多工作,最终还是被放弃了,毕竟当时没有足够的证据显示生产代码中会出现这个问题。具体可以参考这个讨论

The GIL never had a huge fanbase. What we’ve seen today only makes it worse. We come back to the all time question.

GIL 一直不太受欢迎,前面提到的问题使它更受诟病。因此,我们又来到了最初的问题。

6. 不能取消 GIL 吗?

移除 GIL 前,首先要理解它为什么存在。在多线程程序中,我们经常用到锁,防止竞态条件,确保操作原子性。比如你要通过几条语句修改某个数据,如果不用锁保护,其它线程就可能在执行过程中访问该数据,导致不一致。

假设你要在多个线程中递增同一个变量,如果递增操作是非原子性的,也没有锁保护,则最终结果可能小于你期望的值。这是一种典型的竞态条件:

  1. 线程 1 读取 x 的值;
  2. 线程 2 读取 x 的值;
  3. 线程 1 写回 x + 1
  4. 线程 2 忽略线程 1 写回的结果,也写回 x + 1

Python 中,+= 涉及多条字节码指令,并非原子性操作。将切换间隔设为 0.000001 ,在多线程中运行下面的代码,可以看到竞态条件的影响:

sum = 0

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

同样,在 C 语言中,x++ 和 ++x 等整数自增操作也是非原子性的,编译器会将它们编译为多条机器码指令,多个线程的执行可能会互相交错。

由于其垃圾回收机制,CPython 中到处都是跨线程的整数增减操作,GIL 显然非常重要。每个 Python 对象都有一个引用计数,表示其它 Python 对象、局部或全局变量等引用该对象的次数,多一个引用,计数就加 1,少一个引用,计数就减 1,当引用计数变为 0 时,该对象就会被销毁。如果没有 GIL,减少操作可能互相影响,导致对象无法销毁,或者更麻烦的,增加操作可能互相影响,导致还需要用到的对象被销毁。

GIL 也简化了内置可变对象的实现。列表、字典、集合等数据结构的实现并没有用到锁,但由于 GIL 的存在,它们可以安全地跨线程使用。同样,GIL 也使多个线程可以安全地访问全局的、同一个解释器范围内的数据,包括已加载模块、预分配对象、interned 字符串等。

另外,GIL 也使 C 扩展的使用变得更容易。开发者可以假定 C 扩展是单线程运行的,不用为确保线程安全而加锁。如果需要并行执行,也可以手动释放 GIL。

总的来说,GIL 的主要作用是确保以下数据的线程安全:

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

要想移除 GIL 的同时不影响解释器的功能,就必须找到一个替代的线程安全机制。许多人已经试过了,其中最值得注意的是 Larry Hastings 的 Gilectomy 项目。他 forked 了一份 CPython 代码库,然后移除 GIL,用原子性操作增减引用计数,通过许多细粒度的锁保护可变数据结构和解释器范围内的变量。

Gilectomy 可以并行执行 Python 代码,但单线程性能受到影响,仅原子性增减操作就会导致 30% 的性能恶化。Hastings 试图通过缓存来解决这个问题,简单地说,就是由一个特定进程完成所有引用计数的更新,其它线程只提交日志。这个思路是有效的,但性能恶化依然很严重

后来,基本已经明确,Gilectomy 不会合入 CPython 中,Hastings 也就停掉了这个项目。不过这个项目不能算失败,至少它让我们知道移除 GIL 是很困难的,主要原因有两个:

  1. 多线程下,采用引用计数的垃圾回收机制是不合适的,唯一的办法是采用追踪式垃圾回收机制,正如 JVM、CLR、Go 等所做的那样。
  2. 移除 GIL 会导致既有 C 扩展无法运行,这个问题无法解决。

现在已经没人认为移除 GIL 是好的方案了。但这是不是意味着 GIL 会伴随我们到永远呢?

7. GIL 与 Python 并发的未来

说起来可能会吓到你,和取消 GIL 相比,CPython 更有可能引入多个 GIL。事实上,现在就有使用多个 GIL 的考虑,称为子解释器,即在同一个进程中使用多个解释器。其中,共用一个解释器的多个线程依然共享GIL,但多个解释器可以并行执行。不同解释器之间没有共享的全局状态,无需同步。解释器之间通过消息传递机制通信,最终目标是和 Go 或 Clojure 一样,引入一个基于通信顺序进程的并发模型。

1.5 版本之后,解释器成为 CPython 的一部分,但只是作为一种隔离机制,用于存储一组线程的共享数据:加载的模块、内置数据、引入配置等等。解释器不暴露给 Python 代码,不过 C 扩展可以通过 Python/C API 调用其接口,一个典型的例子就是 mod_wsgi 模块。

当前的问题是解释器之间需要共享GIL,解决的办法是让每个解释器自己管理状态。这方面的工作其实已经在进行了,但有些内容依然是全局的:一些内置类型、None / True / False 等单例,以及内存管理器的某些部分等。另外,C 扩展要使用子解释器也需要处理各种全局变量问题

Eric Snow 在 PEP 554 增加了一个 interpreters 标准库,把现有解释器的 C API 暴露到 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 问题的优先顺序。

P.S.

本文所用的性能测试代码放在 GitHub 上。特别感谢 David Beazley 的演说。Larry Hasting 关于 GIL 和 Gilectomy 的演说()也很有趣。为理解当代操作系统的调度逻辑,我看了 Robert Love 的《Linux 内核开发》,这本书也非常推荐。

如果想了解 GIL 的更多细节,可以阅读源码。建议从 Python/ceval_gil.h 开始。为帮助大家更好地理解,我特意附加了后面的章节。

8. GIL 的实现细节*

技术上说,GIL 就是一个标识,表示 GIL 是否上锁。有一些互斥量和条件变量控制着标识的具体值以及相关变量,如切换间隔等。相关变量都存储在 _gil_runtime_state 中:

struct _gil_runtime_state{
    /* 微秒 (Python API 中用的是秒) */
    unsigned long interval;
    /* 最近控制 GIL 的 PyThreadState. 帮助判断释放 GIL 后由谁控制 */
    _Py_atomic_address last_holder;
    /* GIL 是否被控制(未初始化则为-1). 是一个原子变量,因为在 ceval.c 可以随意读取值 */
    _Py_atomic_int locked;
    /* GIL 切换次数 */
    unsigned long switch_number;
    /* 等待 GIL 的条件变量,互斥量也保护了前面那些变量 */
    PyCOND_T cond;
    PyMUTEX_T mutex;
#ifdef FORCE_SWITCHING
    /* 让释放者等待下一个线程获取 GIL 的条件变量 */
    PyCOND_T switch_cond;
    PyMUTEX_T switch_mutex;
#endif
};

_gil_runtime_state 是全局状态的一部分,存储在 _ceval_runtime_state 中,而 _ceval_runtime_state 又是 _PyRuntimeState 的一部分,每个 Python 线程都可以访问:

struct _ceval_runtime_state{
    _Py_atomic_int signals_pending;
    struct _gil_runtime_stategil;
};
typedef struct pyruntimestate{
    // ...
    struct _ceval_runtime_stateceval;
    struct _gilstate_runtime_stategilstate;

    // ...
}_PyRuntimeState;

注意区别 _gil_runtime_state 和 _gilstate_runtime_state,后者存储的是控制 GIL 的线程信息:

struct _gilstate_runtime_state{
    /* bpo-26558: Flag to disable PyGILState_Check().
       如果非零, PyGILState_Check() 总是返回 1. */
    int check_enabled;
    /* 如果当前线程控制 GIL, 这是当前线程的 PyThreadState */
    _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;
};

PyInterpreterState 中包含一个 _ceval_state,存储着 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_callspending;
};

Python/C API 提供了PyEval_RestoreThread() 和 PyEval_SaveThread() 接口以请求和释放 GIL,同时管理 gilstate->tstate_current。实际上干活的是 take_gil() 和 drop_gil() 函数,线程挂起字节码执行时,会调用这两个函数:

/* 处理信号, 挂起调用, 释放GIL,检查异步异常 */
static int
eval_frame_handle_pending(PyThreadState*tstate)
{
    _PyRuntimeState * const runtime = &_PyRuntime;
    struct _ceval_runtime_state * ceval = &runtime->ceval;

    /* 挂起信号 */
    // ...

    /* 挂起调用 */
    struct _ceval_state * ceval2 = &tstate->interp->ceval;
    // ...

    /* 释放GIL */
    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");
        }
    }

    /* 检查异步异常 */
    // ...
}

类 Unix 系统上,GIL 的实现依赖 pthreads 库提供的一些接口,包括互斥量和条件变量等。简单地说,其逻辑如下:线程调用 pthread_mutex_lock(mutex) 锁定一个互斥量,当另一个线程也试图锁定互斥量时会阻塞,同时,操作系统会把它放入等待互斥量的线程队列中,并在第一个线程调用 pthread_mutex_unlock(mutex) 后将其唤醒。同一时间只有一个线程执行被保护的代码。

利用条件变量,线程可以等待另一个线程改变条件状态。具体地说,线程会锁定一个互斥量并调用 pthread_cond_wait(cond, mutex) 或 pthread_cond_timedwait(cond, mutex, time),这两个函数会以原子操作解锁互斥量并阻塞线程。然后操作系统将其放入一个等待队列中,直到另一个线程调用 pthread_cond_signal() 时将其唤醒。被唤醒的线程会重新锁定互斥量并继续工作。以下是条件变量的典型用法:

# 等待线程

mutex.lock()
while not condition:
    cond_wait(cond_variable, mutex)
# ... 条件为真,继续干活
mutex.unlock()
# 另一个线程

mutex.lock()
# ... 修改条件
cond_signal(cond_variable)
mutex.unlock()

需要注意的是,等待线程必须循环检查条件状态,因为系统通知的时候,不确保其值为真。互斥量的使用确保了该线程不会错过条件变化的时机。

take_gil() 与 drop_gil() 函数通过 gil->cond 条件变量通知等待线程 GIL 已释放,利用 gil->switch_cond 条件变量通知原线程 GIL 已被其它线程占用。这两个条件变量分别由 gil->mutex 和 gil->switch_mutex 互斥量保护。

下面是 take_gil() 的执行步骤:

  1. 锁定 GIL 互斥量: pthread_mutex_lock(&gil->mutex)
  2. 检查 gil->locked,如果未锁定,到步骤 4;
  3. 等待 GIL,如果 gil->locked
    1. 记录 gil->switch_number
    2. 等待当前线程释放 GIL: pthread_cond_timedwait(&gil->cond, &gil->mutex, switch_interval)
    3. 如果超时且 gil->locked,而 gil->switch_number 未改变,则让当前线程释放 GIL: 设置 ceval->gil_drop_request 与 ceval->eval_breaker
    4. 占用 GIL 并通知之前的线程:
    5. 锁定切换互斥量: pthread_mutex_lock(&gil->switch_mutex)
    6. 设置 gil->locked
    7. 如果本线程不是 gil->last_holder ,更新 gil->last_holder 并增加 gil->switch_number 的值;
    8. 通知之前线程 GIL 已被占用: pthread_cond_signal(&gil->switch_cond)
    9. 解锁切换互斥量:pthread_mutex_unlock(&gil->switch_mutex)
    10. 重置 ceval->gil_drop_request
    11. 重新计算 ceval->eval_breaker
    12. 解锁 GIL 互斥量: pthread_mutex_unlock(&gil->mutex)

注意,线程等待 GIL 的时候,有可能其它线程也在等待并占用 GIL,因此,必须检查 gil->switch_number 以确保才获取 GIL 的线程不会被强制释放 GIL。

drop_gil() 的执行步骤如下:

  1. 锁定 GIL 互斥量:pthread_mutex_lock(&gil->mutex)
  2. 重置 gil->locked
  3. 通知等待线程 GIL 已释放: pthread_cond_signal(&gil->cond)
  4. 解锁 GIL 互斥量: pthread_mutex_unlock(&gil->mutex)
  5. 如果 ceval->gil_drop_request,等待另一个线程占用 GIL:
    1. 锁定切换互斥量: pthread_mutex_lock(&gil->switch_mutex)
    2. 如果本线程为 gil->last_holder,等待: pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex)
    3. 解锁切换互斥量: pthread_mutex_unlock(&gil->switch_mutex)

释放 GIL 的线程并不需要循环判断条件变量,它调用 pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex) 只是为了确保自己不会马上重新占用 GIL。如果别的线程占用了 GIL,本线程依然可以等待 GIL。

2021年10月7日更新:把所有线程绑定在一个核上并不能真正解决 convoy 效应的问题。它会强制操作系统进行线程调度,而 I/O 线程的优先级也确实更高,更有机会获取 GIL。但如果 I/O 操作是阻塞的,这都没什么意义,此时 I/O 密集型线程还要等待,因此操作系统实际上会调度 CPU 密集型线程。

在服务器的例子中,每次 recv() 操作都是阻塞的——服务器必须等待客户端读取响应并发送下一条信息。把线程限制到一个核上没什么意义。但测试时却看到 RPS 提升了,怎么回事呢?其实是性能测试代码的问题。我在同一台电脑上跑的客户端,并且和服务器线程绑定到同一个核上。这就使得每次 recv() 操作阻塞的时候,系统会在服务器的 CPU 密集型线程和客户端线程之间做选择,而客户端线程更可能被优先调度,并同样阻塞在 recv() 操作。此时,服务器的 I/O 密集型线程就就绪了,从而与 CPU 密集型线程竞争优先级。简单地说,客户端也运行在同一个核上,使系统在 recv() 阻塞时依然要从 I/O 密集型和 CPU 密集型线程间进行选择调度。

另外,绑核操作并不需要修改 CPython 源码或使用 ctypes 库。Linux 中的 pthread_setaffinity_np() 函数是基于 sched_setaffinity() 系统调用实现的,而 Python 中可以通过 os 标准库使用这个系统调用。

除此之外,taskset 命令也可以把进程绑定到特定核上。只需这样运行程序:

$ taskset -c {cpu_list} python program.py

2021年10月16日更新:最近,Sam Gross 发布了一个移除 GIL 的 CPython 分支,可以看做 Gilectomy 2.0:移除 GIL,用其它机制保证线程安全,和 Gilectomy 不同的是,单线程性能并没有受到影响。事实上,Gross 对解释器做了很多优化,使单线程性能甚至比 CPython 3.9 还好。

这个项目似乎是移除 GIL 最有希望的一次尝试,我相信 Gross 的一些想法会进入主分支。更多信息可以参考设计文档和 GitHub 分支。也可以参考 LWN 上的说明文章

发布于 2022-09-12 13:57
Python

发表回复