什么是 Python GIL,它是如何工作的,以及它如何影响 gunicorn。
生产环境我应该选择哪种 Gunicorn worker类型?
Python 有一个全局锁 (GIL),它只允许一个线程运行(即解释字节码)。在我看来,如果你想优化你的 Python 服务,理解 Python 如何处理并发是必不可少的。
Python 和 gunicorn 为您提供了处理并发的不同方法,并且由于没有涵盖所有用例的灵丹妙药,因此最好了解每个选项的选项、权衡和优势。
Gunicorn worker类型
Gunicorn 以“workers types”的概念公开了这些不同的选项。每种类型都适用于一组特定的用例。
- sync——将进程分叉成 N 个并行运行的进程来处理请求。
- gthread——产生 N 个线程来并发服务请求。
- eventlet/gevent——产生绿色线程来并发服务请求。
Gunicorn sync worker
这是最简单的工作类型,其中唯一的并发选项是分叉N个进程,它们将并行地服务请求。
它们可以很好地工作,但会招致大量开销(例如内存和CPU上下文切换),而且如果您的大部分请求时间都在等待I/O,那么伸缩性就不好。
Gunicorn gthread worker
gthread worker 通过允许您为每个进程创建 N 个线程来改进这一点。这提高了 I/O 性能,因为您可以同时运行更多代码实例。这是受 GIL 影响的四个中唯一一个。
Gunicorn eventlet and gevent workers
eventlet/gevent workers试图通过运行轻量级用户线程(又名绿色线程、greenlets 等)来进一步改进 gthread 模型。
与系统线程相比,这允许您以很少的成本拥有数千个所述的greenlet。 另一个区别是它遵循协作工作模型而不是抢占式,允许不间断工作,直到它们阻塞为止。我们将首先分析 gthread 工作线程在处理请求时的行为以及它如何受 GIL 影响。
与每个请求直接由一个进程提供服务的sync不同,使用 gthread,每个进程都有 N 个线程,以便更好地扩展,而无需产生多个进程的开销。由于您在同一个进程中运行多个线程,GIL 将阻止它们并行运行。
GIL 不是进程或特殊线程。它只是一个布尔变量,其访问受互斥锁保护,用于确保每个进程内只有一个线程在运行。它的工作方式可以在上图中看到。在这个例子中,我们可以看到我们有 2 个系统线程并发运行,每个线程处理 1 个请求。流程是这样的:
- 线程 A 持有 GIL 开始服务请求。
- 过了一会儿,线程 B 尝试提供请求,但无法持有 GIL。
- B 设置超时以强制释放 GIL,如果在达到超时之前不会发生这种情况。
- A 在达到超时之前不会释放 GIL。
- B 设置 gil_drop_request 标志以强制 A 立即释放 GIL。
- A 释放 GIL 并将等待直到另一个线程抓取 GIL,以避免出现 A 会不断释放并抓取 GIL 而其他线程无法抓取它的情况。
- B 开始运行。
- B 在阻塞 I/O 的同时释放 GIL。
- A 开始运行。
- B 尝试再次运行但被暂停。
- A 在达到超时之前完成。
- B 运行完毕。
相同的场景,但使用 gevent
在不使用进程的情况下增加并发性的另一个选择是使用 greenlets。该worker产生“用户线程”而不是“系统线程”以增加并发性。
尽管这意味着它们不受 GIL 的影响,但这也意味着您仍然无法增加并行度,因为它们无法由 CPU 并行调度。
- Greenlet A将开始运行,直到发生I/O事件或执行完毕。
- Greenlet B将等待直到Greenlet A释放事件循环。
- A结束。
- B开始。
- B释放事件循环以等待I/O。
- B完成。
对于这种情况,很明显,拥有一个 greenlet 类型的worker并不理想。我们最终让第二个请求等到第一个请求完成,然后再次空闲等待 I/O。
在这些场景中,greenlet 协作模型真的很出色,因为您不会在上下文切换上浪费时间并避免运行多个系统线程的开销。
我们将在本文末尾的基准测试中见证这一点。现在,这引出了以下问题:
- 更改线程上下文切换超时是否会影响服务延迟和吞吐量?
- 当您混合使用 I/O 和 CPU 工作时,如何在 gevent/eventlet 和 gthread 之间进行选择。
- 如何使用 gthread worker 选择线程数。
- 我应该只使用sync worker并增加分叉进程的数量来避免 GIL 吗?
要回答这些问题,您需要进行监控以收集必要的指标,然后针对这些相同的指标运行量身定制的基准测试。运行与您的实际使用模式零相关性的综合基准测试是没有用的 下图显示了不同场景的延迟和吞吐量指标,让您了解这一切是如何协同工作的。
对 GIL 切换间隔进行基准测试
在这里我们可以看到更改 GIL 线程切换间隔/超时如何影响请求延迟。正如预期的那样,IO 延迟随着切换间隔的降低而变得更好。发生这种情况是因为受 CPU 限制的线程被迫更频繁地释放 GIL 并允许其他线程完成它们的工作。
但这不是灵丹妙药。减少切换间隔将使 CPU 绑定线程需要更长的时间才能完成。我们还可以看到总延迟增加,由于恒定线程切换的开销增加,超时时间减少。如果您想自己尝试,可以使用以下代码更改切换间隔:
使用 CPU 绑定请求对 gthread 与 gevent 延迟进行基准测试
总的来说,我们可以看到基准测试反映了我们之前对 GIL 绑定线程和 greenlet 如何工作的分析所产生的直觉。
由于切换间隔迫使长时间运行的线程释放,gthread 对于 IO 绑定请求具有更好的平均延迟。
gevent CPU 绑定请求比 gthread 具有更好的延迟,因为它们不会被中断以服务其他请求。
使用 CPU 绑定请求对 gthread 与 gevent 吞吐量进行基准测试
这里的结果也反映了我们之前对 gevent 比 gthread 具有更好吞吐量的直觉。这些基准高度依赖于完成的工作类型,不一定直接转化为您的用例。
这些基准测试的主要目标是为您提供一些有关测试和测量内容的指南,以便最大限度地提高将服务于请求的每个 CPU 内核。
由于所有 gunicorn worker 都允许您指定将运行的进程数,因此更改的是每个进程如何处理并发连接。因此,请确保使用相同数量的worker以使测试公平。现在让我们尝试使用从我们的基准测试中收集的数据来回答前面的问题。
更改线程上下文切换超时是否会影响服务延迟和吞吐量?
确实如此。然而,对于绝大多数工作负载来说,它并没有改变游戏规则。
当您混合使用 I/O 和 CPU 工作时,如何在 gevent/eventlet 和 gthread 之间进行选择? 正如我们所看到的,当您有更多 CPU 密集型工作时,ghtread 往往允许更好的并发性。
如何选择gthread worker的线程数?
只要您的基准测试能够模拟类似生产的行为,您就会清楚地看到峰值性能,然后它会因线程过多而开始下降。
我应该只使用同步工作者并增加分叉进程的数量来避免 GIL 吗?
除非您的 I/O 几乎为零,否则仅使用进程进行扩展并不是最佳选择。
结论
Coroutines/Greenlets 可以提高 CPU 效率,因为它们避免了线程之间的中断和上下文切换。协程用延迟换取吞吐量。
如果您混合使用 IO 和 CPU 绑定端点,协程可能会导致更难以预测的延迟——CPU 绑定端点不会被中断以服务其他传入请求。如果您花时间正确配置 gunicorn,GIL 不是问题。