Python之路(第四十三篇)线程的生命周期、全局解释器锁
一、线程的生命周期(新建、就绪、运行、阻塞和死亡)
当线程被创建并启动以后,它既不是一启动就进入执行状态的,也不是一直处于执行状态的,在线程的生命周期中,它要经过新建(new)、就绪(Ready)、运行(Running)、阻塞(Blocked)和死亡(Dead)5 种状态。
尤其是当线程启动以后,它不可能一直“霸占”着 CPU 独自运行,所以 CPU 需要在多个线程之间切换,于是线程状态也会多次在运行、就绪之间转换。
线程的新建和就绪状态
当程序创建了一个 Thread 对象或 Thread 子类的对象之后,该线程就处于新建状态,和其他的Python 对象一样,此时的线程对象并没有表现出任何线程的动态特征,程序也不会执行线程执行体。
当线程对象调用 start() 方法之后,该线程处于就绪状态,Python 解释器会为其创建方法调用栈和程序计数器,处于这种状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于 Python 解释器中线程调度器的调度。
二、全局解释器锁
简单来说,Python全局解释器锁(Global Interpreter Lock)或GIL是一个互斥锁,它只允许一个线程来控制Python解释器。
这意味着在任何时间点只有一个线程可以处于执行状态。执行单线程程序的开发人员感受不到GIL的影响,但它可能是CPU限制型和多线程代码中的性能瓶颈。
由于即使在具有多个CPU核心的多线程架构中,GIL一次也只允许一个线程执行,因此GIL已经成为Python“臭名昭着”的特性。
在CPython中
-
IO密集型,某个线程阻塞,就会调度其他就绪线程;
-
CPU密集型,当前线程可能会连续的获得GIL,导致其它线程几乎无法使用CPU。
在CPython中由于有GIL存在,IO密集型,使用多线程较为合算;CPU密集型,使用多进程,要绕开GIL。
新版CPython正在努力优化GIL的问题,但不是移除。 如果在意多线程的效率问题,请绕行,选择其它语言erlang、Go等。
为什么需要GIL
Python使用引用计数进行内存管理。这意味着在Python中创建的对象具有引用计数变量,该变量用于跟踪指向该对象的引用数。当此计数达到零时,释放对象占用的内存。
让我们看一个简短的代码示例来演示引用计数的工作原理:
>>>
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount (a)
3
在上面的示例中,空列表对象的引用计数为3。列表对象由a,b引用并且参数传递给sys.getrefcount()。
回到GIL:
问题是这个引用计数变量需要保护竞争条件。如果其中两个线程同时增加或减少其值,如果发生这种情况,它可能导致从未释放的内存泄漏,或者更糟糕的是,在对该对象的引用仍然存在时错误地释放内存。这可能会导致Python程序中出现崩溃或其他“怪异”错误。通过向跨线程共享的所有数据结构添加锁,可以保持此引用计数变量的安全性,从而不会对它们进行不一致的修改。
但是为每个对象或对象组添加一个锁意味着将存在多个锁,这可能导致另一个问题 - 死锁(死锁只有在有多个锁时才会发生)。另一个副作用是由于重复获取和释放锁而导致性能下降。
注意
需要注意的点包括:
第一,GIL 不属于 Python 语言定义,而是 CPython 解释器实现的一部分;
第二,其他 Python 解释器不一定有 GIL。例如 Jython (JVM) 和 IronPython (CLR) 没有 GIL,而 PyPy 有 GIL;
第三,GIL 并不是 Python 的专利。其他语言也有 GIL,尤其是动态语言,如 Ruby MRI。
在多线程环境中,Python 按以下方式执行:
a、设置 GIL;
b、切换到一个线程去运行;
c、运行指定数量的字节码指令或者线程主动让出控制(可以调用 time.sleep(0));
d、把线程设置为睡眠状态;
e、解锁 GIL;
d、再次重复以上所有步骤。 在调用外部代码(如 C/C++扩展函数)的时候,GIL将会被锁定,直到这个函数结束为止(由于在这期间没有Python的字节码被运行,所以不会做线程切换)编写扩展的程序员可以主动解锁GIL。
至于GIL,不要认为它在那的存在就是静态的和未经分析过的。Antoine Pitrou 在Python 3.2中实现了一个新的GIL,并且带着一些积极的结果。这是自1992年以来,GIL的一次最主要改变。这个改变非常巨大,很难在这里解释清楚,但是从一个更高层次的角度来说,旧的GIL通过对Python指令进行计数来确定何时放弃GIL。这样做的结果就是,单条Python指令将会包含大量的工作,即它们并没有被1:1的翻译成机器指令。在新的GIL实现中,用一个固定的超时时间来指示当前的线程以放弃这个锁。在当前线程保持这个锁,且当第二个线程请求这个锁的时候,当前线程就会在5ms后被强制释放掉这个锁(这就是说,当前线程每5ms就要检查其是否需要释放这个锁)。
然而,这并不是一个完美的改变。对于在各种类型的任务上有效利用GIL这个领域里,最活跃的研究者可能就是David Beazley了。除了对Python 3.2之前的GIL研究最深入,他还研究了这个最新的GIL实现,并且发现了很多有趣的程序方案。对于这些程序,即使是新的GIL实现,其表现也相当糟糕。他目前仍然通过一些实际的研究和发布一些实验结果来引领并推进着有关GIL的讨论。
为什么还没有删除GIL?
Python的开发人员对此有很多抱怨,但是像Python这样流行的语言不会带来像删除GIL那样重要的变化而不会导致向后不兼容问题。
显然可以删除GIL,过去开发人员和研究人员已多次执行此操作,但所有这些尝试都破坏了现有的C扩展,这些扩展在很大程度上依赖于GIL提供的解决方案。
当然,还有其他解决方案可以解决GIL解决的问题,但有些解决方案会降低单线程和多线程I / O绑定程序的性能,其中一些程序太难了。毕竟,你不希望现有的Python程序在新版本发布后运行得更慢,对吧?
Python的创建者和BDFL,Guido van Rossum,在2007年9月的文章“删除GIL并不容易”中给出了社区的答案:
“ 只有当单线程程序(以及多线程但 I / O绑定程序)的性能不降低时,我才欢迎使用Py3k中的一组补丁”
此后的任何尝试都没有实现这一条件。
GIL的解决方案
如果GIL导致您出现问题,可以尝试以下几种方法:
多进程与多线程:最流行的方法是使用多方法,使用多个进程而不是线程。
替代Python解释器: Python有多个解释器实现。分别用C,Java,C#和Python编写的CPython,Jython,IronPython和PyPy是最受欢迎的。GIL仅存在于CPython的原始Python实现中。如果您的程序及其库可用于其他实现之一,那么您也可以尝试它们。
所以没救了么?
当然Python社区也在非常努力的不断改进GIL,甚至是尝试去除GIL。并在各个小版本中有了不少的进步。
-
将切换颗粒度从基于opcode计数改成基于时间片计数
-
避免最近一次释放GIL锁的线程再次被立即调度
-
新增线程优先级功能(高优先级线程可以迫使其他线程释放所持有的GIL锁)
总结
Python GIL其实是功能和性能之间权衡后的产物,它尤其存在的合理性,也有较难改变的客观因素。从本分的分析中,我们可以做以下一些简单的总结:
-
因为GIL的存在,只有IO Bound场景下得多线程会得到较好的性能
-
如果对并行计算性能较高的程序可以考虑把核心部分也改成C模块,或者索性用其他语言实现
-
GIL在较长一段时间内将会继续存在,但是会不断对其进行改进
参考资料
posted on 2019-07-31 22:23 Nicholas-- 阅读(385) 评论(0) 编辑 收藏 举报