醋醋百科网

Good Luck To You!

python中GIL的影响_python对社会的影响

python中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),依此类推。在没有 GIL 的语言(如 C)中,我们会看到随着线程数量的增加,速度会提高。在我的具有两个核心和超线程的 MacBook Pro 上运行 Python,我看到了以下结果:

这些时间没有变化。实际上,多线程程序可能会因为上下文切换相关的开销而运行得更慢。默认的切换间隔是 5ms,所以上下文切换不会经常发生。但是,如果我们减少切换间隔,我们会看到减速。稍后我们将讨论为什么可能需要这样做。 虽然 Python 线程无法帮助我们加速 CPU 密集型代码,但在我们想要同时执行多个 I/O 受限任务时,它们很有用。考虑一个服务器,它侦听传入的连接,并在收到连接时在单独的线程中运行处理程序函数。处理程序函数通过读取和写入客户端套接字与客户端进行通信。当从套接字读取时,线程会一直挂起,直到客户端发送一些内容。这是多线程的用武之地:另一个线程可以在此期间运行。 为了允许持有 GIL 的线程等待 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。这允许我们使用多线程实际加速调用此类函数的 Python 代码。 假设我们要计算八个 128MB 消息的 SHA-256 哈希值。我们可以在单个线程中为每个消息计算 hashlib.sha256(message),但我们也可以将工作分布在多个线程中。如果我在我的机器上进行比较,我得到以下结果:

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

CPU bound和IO bound的影响及差异

我们已经提到了 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 会发生什么变化?略有下降?减少两倍?减少十倍?不,RPS 下降到了 100,也就是说减少了 300 倍!如果你习惯了操作系统调度线程的方式,这会让你感到非常惊讶。

独立进程执行cpu密集型任务测试

为了说明我的意思,让我们将服务器和 CPU 密集型线程作为单独的进程运行,以便它们不受 GIL 的影响。我们可以将代码分为两个不同的文件,或者像这样使用多处理标准模块来生成一个新进程:

from multiprocessing import Process

# ... the same server code

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

这将产生大约 20k 的 RPS。此外,如果我们启动两个、三个或四个 CPU 密集型进程,RPS 仍然保持不变。操作系统调度程序会优先考虑 I/O 密集型线程,这是正确的做法。

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

这个问题实际上在 CPython 开发人员中是众所周知的。他们将其称为‘护航效应(convoy effect)’。David Beazley 在 2010 年对此进行了演讲,并在 bugs.python.org 上打开了一个相关的问题。2021 年,也就是 11 年后,这个问题被关闭了。然而,它还没有被修复。

什么是convoy effect

‘护航效应(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 操作完成时,I/O 绑定的线程才能被调度,因此它更有可能无法首先获取 GIL。如果操作非常快,例如非阻塞的 send(),在单核机器上,操作系统必须决定要调度哪个线程,这种情况下获取 GIL 的机会实际上是相当大的。

在多核机器上,操作系统不必决定要调度哪个线程。它可以在不同的核心上同时调度两个线程。结果是 CPU 密集型线程几乎肯定会首先获取 GIL,每个 I/O 操作在 I/O 绑定的线程中都要额外花费 5 毫秒。

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

那么 5 毫秒是多少呢?这取决于 I/O 操作需要多长时间。如果线程等待数秒钟直到套接字上的数据可供读取,那么额外的 5 毫秒并不重要。但有些 I/O 操作非常快。例如,send() 仅在发送缓冲区已满时阻塞,否则立即返回。因此,如果 I/O 操作需要微秒级别的时间,那么等待 GIL 的毫秒级别的时间可能会产生巨大的影响。

没有 CPU 密集型线程的回显服务器处理 30k RPS,这意味着单个请求大约需要 1/30k ≈ 30 微秒。加上 CPU 密集型线程,recv() 和 send() 每个请求都会额外添加 5 毫秒 = 5,000 微秒,单个请求现在需要 10,030 微秒。这约为 300 倍。因此,吞吐量减少了 300 倍。数字相符。

你可能会问:护航效应在实际应用中是问题吗?我不知道。我从未遇到过它,并且没有找到任何其他人遇到的证据。人们没有抱怨,这也是问题没有得到解决的原因之一。

但是,如果护航效应确实在你的应用程序中导致性能问题,有两种方法可以解决它,将会在后面篇章中介绍。

AddOn

如果您对我分享的python小知识感兴趣,也想快速了解python的核心技术知识点,那么可以关注下我的技术专栏《python tricks》,每周至少更新一篇python核心知识点及其深入剖析各种应用技巧。专栏如下:


当然了如果您对python asyncio的异步高性能编程感兴趣,也可以关注我近期持续更新的《python asyncio从入门到精通》的专栏,asyncio当前已经几乎成为python web编程的主流技术,为了写aio的服务和异步框架几乎是必备技能之一。专栏如下:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言