什么是Python全局锁(GIL),如何避开GIL限制?

一、什么是Python 全局锁

1、什么是全局锁?

简单来说,Python 全局解释器锁(Global Interpreter Lock, 简称 GIL) 是一个互斥锁(或锁),只允许一个线程保持 Python 解释器的控制权。

这意味着在任何时间点都只能有一个线程处于执行状态。执行单线程程序的开发人员看不到 GIL 的影响,但它可能是 CPU 密集型和多线程代码中的性能瓶颈。

由于 GIL 一次只允许一个线程执行,即使在具有多个 CPU 内核的平台上也是如此,因此 GIL 获得了 Python “臭名昭著”功能的声誉。

在本文中,您将了解 GIL 如何影响 Python 程序的性能,以及如何减轻它可能对代码产生的影响。

2、GIL为Python解决了什么问题?

Python 使用引用计数进行内存管理。这意味着在 Python 中创建的对象具有一个引用计数变量,该变量跟踪指向该对象的引用数量。当此计数达到零时,将释放对象占用的内存。

让我们看一个简短的代码示例,以演示引用计数的工作原理:

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

在上面的示例中,空列表对象 [] 的引用计数为 3。列表对象由 a, b 引用,参数传递给 sys.getrefcount()。

回到GIL, 问题在于:此引用计数变量需要保护,以免受两个线程同时增加或减少其值时的负面影响。如果发生这种情况,可能会导致内存变量无法释放,或者更糟糕的是,当内存对象的引用计数不为零时,错误地释放了该对象。这可能会导致 Python 程序中出现崩溃或其他“奇怪”的错误。

可以通过向跨线程共享的所有数据变量添加来确保此引用计数变量的安全,以避免不一致地修改它们。

但是,向每个对象或对象组添加一个锁意味着将存在多个锁,这很容易导致另一个问题 – 死锁。另一个副作用是,重复获取和释放锁会导致性能下降。

GIL 是解释器本身上的单个锁,它添加了一个规则,即执行1个Python 文件必须要获取解释器锁。这可以防止死锁(因为只有一个锁),并且不会引入太多的性能开销。但它有效地使任何CPU绑定的Python程序成为单线程。

GIL 虽然被解释者用于其他语言(如 Ruby),但并不是这个问题的唯一解决方案。某些语言通过使用引用计数以外的方法(如垃圾回收)来避免线程安全内存管理的 GIL 要求,但这意味,这些语言通常必须通过添加其他性能提升功能(如 JIT 编译器)来补偿 GIL 的单线程性能优势的损失。

3、为什么选择 GIL 作为解决方案?

那么,为什么在Python中使用了一种看似如此阻碍性能提升的方法呢?这是Python开发人员的错误决定吗?

用Larry Hastings的话来说,GIL的设计决策是使Python像今天一样流行的因素之一。

Python于1991年诞生,从操作系统没有线程概念的时代就已经存在了。Python被设计为易于使用,以使开发更快,越来越多的开发人员开始使用它。

当时Python使用了许多C库,而且这些库的功能在Python中是必需的。为了防止不一致的更改,这些 C 扩展需要 GIL 提供的线程安全内存管理。

GIL易于实现,并且很容易添加到Python中。它为单线程程序提供了性能提升,因为只需要管理一个锁。

非线程安全的 C 库变得更容易集成。而这些C扩展成为Python容易被不同社区采用的原因之一。

正如你所看到的,GIL是CPython开发人员在Python早期面临的一个难题的务实解决方案。

4、对多线程 Python 程序的影响

当您查看典型的 Python 程序或任何计算机程序时,性能受 CPU 限制的程序和受 I/O 限制的程序之间存在差异。

CPU 密集型程序是那些将 CPU 推向极限的程序。这包括进行数学计算的程序,如矩阵乘法、搜索、图像处理等。

I/O 绑定程序是花时间等待输入/输出的程序,这些输入/输出可能来自用户、文件、数据库、网络等。I/O 绑定程序有时必须等待很长时间,直到它们从源获得所需的内容,因为源可能需要在输入/输出准备就绪之前进行自己的处理,例如,用户考虑在输入提示或在其自己的进程中运行的数据库查询中输入什么。

让我们看一下执行倒计时的简单 CPU 密集型程序:
“py

# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

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

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)

4核CPU的测试机上运行结果如下

$ python single_threaded.py
Time taken in seconds - 6.20024037361145

现在修改了代码,以使用两个线程并行执行相同的倒计时代码:

# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

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

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)

再次运行,结果如下:

$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

如您所见,两个版本完成所需的时间几乎相同。在多线程版本中,GIL 阻止 CPU 绑定线程并行执行。

GIL 对 I/O 绑定的多线程程序的性能没有太大影响,因为在线程等待 I/O 时,锁在线程之间共享。

但是,线程完全受 CPU 限制的程序,例如,使用线程在部分中处理图像的程序,不仅会因为锁而变成单线程,而且执行时间也会增加,如上例所示,与编写为完全单线程的情况相比,多线程增加的时间是由获取锁和释放锁带来的开销。

为什么不放弃 GIL ?

Python 的开发人员收到了很多关于此的投诉,但像 Python 这样流行的语言无法带来与删除 GIL 一样重要的变化,而不会导致向后不兼容问题。

显然可以删除 GIL,开发人员和研究人员过去已经多次这样做,但所有这些尝试都破坏了现有的 C 扩展,这些扩展在很大程度上依赖于 GIL 提供的解决方案。

当然,对于 GIL 解决的问题还有其他解决方案,但其中一些会降低单线程和多线程 I/O 绑定程序的性能,其中一些太难了。毕竟,你不会希望你现有的Python程序在新版本发布后运行得更慢,对吧?

Python的创建者和BDFL Guido van Rossum在2007年9月的文章“It isn’t Easy to remove the GIL” 中向社区给出了答案:

“只有当单线程程序(以及多线程但 I/O 绑定的程序)的性能不降低时,我才会欢迎一组补丁进入 Py3k”

从那以后的任何尝试都没有满足这个条件。

为什么在Python 3中没有删除它?

Python 3确实有机会从头开始启动许多功能,并在此过程中破坏了一些现有的C扩展,然后需要更新和移植更改以与Python 3一起使用。这就是为什么早期版本的Python 3被社区采用得较慢的原因。

与 Python 2 相比,删除 GIL 会使 Python 3 在单线程性能方面变慢,您可以想象这会导致什么。您无法与 GIL 的单线程性能优势争论。所以结果是 Python 3 仍然有 GIL。

但是Python 3确实给现有的GIL带来了重大改进——

我们讨论了 GIL 对“仅 CPU 绑定”和“仅 I/O 绑定”多线程程序的影响,但是某些线程受 I/O 约束而某些线程受 CPU 约束的程序呢?

在此类程序中,众所周知,Python 的 GIL 不会让 I/O 绑定线程缺乏,因为它们没有机会从 CPU 绑定线程获取 GIL。

这是因为 Python 中内置了一种机制,该机制强制线程在固定的连续使用间隔后释放 GIL,如果没有其他人获得 GIL,则同一线程可以继续使用。

>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100

此机制中的问题是,大多数情况下,CPU 绑定线程会在其他线程获取 GIL 之前重新获取 GIL 本身。这是大卫·比兹利(David Beazley)研究的,可视化可以在这里找到。

这个问题在 2009 年的 Python 3.2 中由 Antoine Pitrou 修复,他添加了一种机制,可以查看其他线程丢弃的 GIL 获取请求的数量,并且不允许当前线程在其他线程有机会运行之前重新获取 GIL。

二、如何摆脱 Python 的全局锁的限制

如果 GIL 给您带来了问题,您可以尝试以下几种方法来避开全局锁的限制

1、使用多进程编程

最流行的方法是使用多进程方法,其中使用多个进程而不是线程。每个 Python 进程都有自己的 Python 解释器和内存空间,因此 GIL 不会成为问题。Python 有一个多进程multiprocessing 模块,它让我们可以轻松地创建这样的进程:

from multiprocessing import Pool
import time

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

if __name__ == '__main__':
    pool = Pool(processes=2)
    start = time.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end = time.time()
    print('Time taken in seconds -', end - start)

output:

$ python multiprocess.py
Time taken in seconds - 4.060242414474487

与多线程版本相比,性能有所提高,对吧?

时间没有下降到我们上面看到的一半,因为流程管理有自己的开销。多个进程比多个线程重,因此请记住,这可能会成为扩展瓶颈。

2、使用 cython 来避开全局锁

cython通常用于处理计算密集型的任务,以加快python程序总体运行速度。

如果你希望C风格的cython函数避开GIL限制,只需简单地使用with nogil 参数

cdef void some_func() noexcept nogil:
    # 函数功能代码
    ....

cython的纯 Python实现方式工,用装饰器来避开GIL

@cython.nogil
@cython.cfunc
@cython.noexcept
def some_func() -> None:
    ...

在cython函数内,如果希望部分代码块不使用GIL,使用with nogil: 语句

def use_add(n):
    result = 1
    with nogil:
        for i in range(n):
            result = add(result, result)
    return result

在cython函数中,可以指定一部分代码避免GIL, 另一部分使用GIL


with nogil:
    ...  # some code that runs without the GIL
    with gil:
        ...  # some code that runs with the GIL
    ...  # some more code without the GIL

3、使用替代Python解释器

Python有多个解释器实现。CPython,Jython,IronPython和PyPy,分别用C,Java,C#和Python编写,是最受欢迎的。GIL 只存在于 CPython 的原始 Python 实现中。如果您的程序及其库可用于其他实现之一,那么您也可以尝试它们。
当然使用非官方解释器的代价是:无法使用Cython.

总结

Python GIL通常被认为是一个困难的话题。但python程序员通常只有在编写CPU密集型多线程代码时,才会受到它的影响。

可以使用3种方法避免全局锁的限制: 多进程,cython,使用非CPython解释器。

Python未来版本传来好消息 ,据 Python开发者透露:

  • 3.12版本, PEP703中新增了功能,Cython 会将GIL做为可选项,即可以在cython中关闭 GIL,而不是只在部分代码中通过with nogil 来临时关闭.
  • 3.13 中正在讨论通过在子线程增加 subinterpreter 方式,允许子线程运行在多个CPU内核上,听上去这个方式是可行的,虽然增加了一些开销,但对于多线程的性能提升还是明显的。

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
心中带点小风骚的头像心中带点小风骚普通用户
上一篇 2023年9月12日
下一篇 2023年9月12日

相关推荐