Python中的GIL是什么(gil,python,开发技术)

时间:2024-05-05 07:08:46 作者 : 石家庄SEO 分类 : 开发技术
  • TAG :

    Python%E4%B8%AD%E7%9A%84GIL%E6%98%AF%E4%BB%80%E4%B9%88

GIL 本质上是一把锁,学过操作系统的同学都知道锁的引入是为了避免并发访问造成数据的不一致。CPython 中有很多定义在函数外面的全局变量,比如内存管理中的usable_arenas和usedpools,如果多个线程同时申请内存就可能同时修改这些变量,造成数据错乱。另外 Python 的垃圾回收机制是基于引用计数的,所有对象都有一个ob_refcnt字段表示当前有多少变量会引用当前对象,变量赋值、参数传递等操作都会增加引用计数,退出作用域或函数返回会减少引用计数。同样地,如果有多个线程同时修改同一个对象的引用计数,就有可能使ob_refcnt与真实值不同,可能会造成内存泄漏,不会被使用的对象得不到回收,更严重可能会回收还在被引用的对象,造成 Python 解释器崩溃。

CPython 中 GIL 的定义如下

最本质的是 mutex 保护的 locked 字段,表示 GIL 当前是否被持有,其他字段是为了优化 GIL 而被用到的。线程申请 GIL 时会调用take_gil()方法,释放 GIL时 调用drop_gil()方法。为了避免饥饿现象,当一个线程等待了 interval 毫秒(默认是 5 毫秒)还没申请到 GIL 的时候,就会主动向持有 GIL 的线程发出信号,GIL 的持有者会在恰当时机检查该信号,如果发现有其他线程在申请就会强制释放 GIL。这里所说的恰当时机在不同版本中有所不同,早期是每执行 100 条指令会检查一次,在 Python 3.10.4 中是在条件语句结束、循环语句的每次循环体结束以及函数调用结束的时候才会去检查。

申请 GIL 的函数take_gil()简化后如下

整个函数体为了保证原子性,需要在开头和结尾分别申请和释放互斥锁gil->mutex。如果当前 GIL 是空闲状态就直接获取 GIL,如果不空闲就等待条件变量gil->condinterval 毫秒(不小于 1 毫秒),如果超时并且期间没有发生过 GIL 切换就将gil_drop_request置位,请求强制切换 GIL 持有线程,否则继续等待。一旦获取 GIL 成功需要更新gil->locked、gil->last_holder和gil->switch_number的值,唤醒条件变量gil->switch_cond,并且释放互斥锁gil->mutex。

释放 GIL 的函数drop_gil()简化后如下

首先在gil->mutex的保护下释放 GIL,然后唤醒其他正在等待 GIL 的线程。在多 CPU 的环境下,当前线程在释放 GIL 后有更高的概率重新获得 GIL,为了避免对其他线程造成饥饿,当前线程需要强制等待条件变量gil->switch_cond,只有在其他线程获取 GIL 的时候当前线程才会被唤醒。

受 GIL 约束的代码不能并行执行,降低了整体性能,为了尽量降低性能损失,Python 在进行 IO 操作或不涉及对象访问的密集 CPU 计算的时候,会主动释放 GIL,减小了 GIL 的粒度,比如

读写文件

网络访问

加密数据/压缩数据

所以严格来说,在单进程的情况下,多个 Python 线程时可能同时执行的,比如一个线程在正常运行,另一个线程在压缩数据。

GIL 是为了维护 Python 解释器内部变量的一致性而产生的锁,用户数据的一致性不由 GIL 负责。虽然 GIL 在一定程度上也保证了用户数据的一致性,比如 Python 3.10.4 中不涉及跳转和函数调用的指令都会在 GIL 的约束下原子性的执行,但是数据在业务逻辑上的一致性需要用户自己加锁来保证。

下面的代码用两个线程模拟用户集碎片得奖

假设用户每集齐 10 个碎片就能得到一次奖励,每个线程收集了 10000000 个碎片,应该得到 9999999 个奖励(最后一次没有计算),总共应该收集 20000000 个碎片,得到 1999998 个奖励,但是在我电脑上一次运行结果如下

总的碎片数量与预期一致,但是奖励数量却少了 12 个。碎片数量正确是因为在 Python 3.10.4 中,stat["piece_count"] += 1是在 GIL 约束下原子性执行的。由于每次循环结束都可能切换执行线程,那么可能线程 t1 在某次循环结束时将piece_count加到 100,但是在下次循环开始模 10 判断前,Python 解释器切换到线程 t2 执行,t2 将piece_count加到 101,那么就会错过一次奖励。

附:如何避免受到GIL的影响

说了那么多,如果不说解决方案就仅仅是个科普帖,然并卵。GIL这么烂,有没有办法绕过呢?我们来看看有哪些现成的方案。

用multiprocess替代Thread

multiprocess库的出现很大程度上是为了弥补thread库因为GIL而低效的缺陷。它完整的复制了一套thread所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的GIL,因此也不会出现进程之间的GIL争抢。

当然multiprocess也不是万能良药。它的引入会增加程序实现时线程间数据通讯和同步的困难。就拿计数器来举例子,如果我们要多个线程累加同一个变量,对于thread来说,申明一个global变量,用thread.Lock的context包裹住三行就搞定了。而multiprocess由于进程之间无法看到对方的数据,只能通过在主线程申明一个Queue,put再get或者用share memory的方法。这个额外的实现成本使得本来就非常痛苦的多线程程序编码,变得更加痛苦了。具体难点在哪有兴趣的读者可以扩展阅读这篇文章

用其他解析器

之前也提到了既然GIL只是CPython的产物,那么其他解析器是不是更好呢?没错,像JPython和IronPython这样的解析器由于实现语言的特性,他们不需要GIL的帮助。然而由于用了Java/C#用于解析器实现,他们也失去了利用社区众多C语言模块有用特性的机会。所以这些解析器也因此一直都比较小众。毕竟功能和性能大家在初期都会选择前者,Done is better than perfect。

所以没救了么?

当然Python社区也在非常努力的不断改进GIL,甚至是尝试去除GIL。并在各个小版本中有了不少的进步。有兴趣的读者可以扩展阅读这个Slide

另一个改进Reworking the GIL

– 将切换颗粒度从基于opcode计数改成基于时间片计数

– 避免最近一次释放GIL锁的线程再次被立即调度

– 新增线程优先级功能(高优先级线程可以迫使其他线程释放所持有的GIL锁)

本文:Python中的GIL是什么的详细内容,希望对您有所帮助,信息来源于网络。
上一篇:如何快速搭建一个springboot项目下一篇:

4 人围观 / 0 条评论 ↓快速评论↓

(必须)

(必须,保密)

阿狸1 阿狸2 阿狸3 阿狸4 阿狸5 阿狸6 阿狸7 阿狸8 阿狸9 阿狸10 阿狸11 阿狸12 阿狸13 阿狸14 阿狸15 阿狸16 阿狸17 阿狸18