本文转载地址: http://cenalulu.github.io/python/gil-in-python/html
首先须要明确的一点是GIL
并非Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。就比如C++是一套语言(语法)标准,可是能够用不一样的编译器来编译成可执行代码。有名的编译器例如GCC,INTEL C++,Visual C++等。Python也同样,一样一段代码能够经过CPython,PyPy,Psyco等不一样的Python执行环境来执行。像其中的JPython 就没有GIL。然而由于CPython是大部分环境下默认的Python执行环境。因此在不少人的概念里CPython就是Python,也就想固然的把GIL
归结为Python语言的缺陷。因此这里要先明确一点:GIL并非Python的特性,Python彻底能够不依赖于GILpython
那么CPython实现中的GIL又是什么呢?GIL全称Global Interpreter Lock
为了不误导,咱们仍是来看一下官方给出的解释:linux
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)git
好吧,是否是看上去很糟糕?一个防止多线程并发执行机器码的一个Mutex,乍一看就是个BUG般存在的全局锁嘛!别急,咱们下面慢慢的分析。github
因为物理上得限制,各CPU厂商在核心频率上的比赛已经被多核所取代。为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。即便在CPU内部的Cache也不例外,为了有效解决多份缓存之间的数据同步时各厂商花费了很多心思,也不可避免的带来了必定的性能损失。算法
Python固然也逃不开,为了利用多核,Python开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法天然就是加锁。 因而有了GIL这把超级大锁,而当愈来愈多的代码库开发者接受了这种设定后,他们开始大量依赖这种特性(即默认python内部对象是thread-safe的,无需在实现时考虑额外的内存锁和同步操做)。编程
慢慢的这种实现方式被发现是蛋疼且低效的。但当你们试图去拆分和去除GIL的时候,发现大量库代码开发者已经重度依赖GIL而很是难以去除了。有多难?作个类比,像MySQL这样的“小项目”为了把Buffer Pool Mutex这把大锁拆分红各个小锁也花了从5.5到5.6再到5.7多个大版为期近5年的时间,而且仍在继续。MySQL这个背后有公司支持且有固定开发团队的产品走的如此艰难,那又更况且Python这样核心开发和代码贡献者高度社区化的团队呢?缓存
因此简单的说GIL的存在更多的是历史缘由。若是推到重来,多线程的问题依然仍是要面对,可是至少会比目前GIL这种方式会更优雅。多线程
从上文的介绍和官方的定义来看,GIL无疑就是一把全局排他锁。毫无疑问全局锁的存在会对多线程的效率有不小影响。甚至就几乎等于Python是个单线程的程序。那么读者就会说了,全局锁只要释放的勤快效率也不会差啊。只要在进行耗时的IO操做的时候,能释放GIL,这样也仍是能够提高运行效率的嘛。或者说再差也不会比单线程的效率差吧。理论上是这样,而实际上呢?Python比你想的更糟。并发
下面咱们就对比下Python在多线程和单线程下得效率对比。测试方法很简单,一个循环1亿次的计数器函数。一个经过单线程执行两次,一个多线程执行。最后比较执行总时间。测试环境为双核的Mac pro。注:为了减小线程库自己性能损耗对测试结果带来的影响,这里单线程的代码一样使用了线程。只是顺序的执行两次,模拟单线程。
#! /usr/bin/python from threading import Thread import time def my_counter(): i = 0 for _ in range(100000000): i = i + 1 return True def main(): thread_array = {} start_time = time.time() for tid in range(2): t = Thread(target=my_counter) t.start() t.join() end_time = time.time() print("Total time: {}".format(end_time - start_time)) if __name__ == '__main__': main()
#! /usr/bin/python from threading import Thread import time def my_counter(): i = 0 for _ in range(100000000): i = i + 1 return True def main(): thread_array = {} start_time = time.time() for tid in range(2): t = Thread(target=my_counter) t.start() thread_array[tid] = t for i in range(2): thread_array[i].join() end_time = time.time() print("Total time: {}".format(end_time - start_time)) if __name__ == '__main__': main()
下图就是测试结果
能够看到python在多线程的状况下竟然比单线程整整慢了45%。按照以前的分析,即便是有GIL全局锁的存在,串行化的多线程也应该和单线程有同样的效率才对。那么怎么会有这么糟糕的结果呢?
让咱们经过GIL的实现原理来分析这其中的缘由。
按照Python社区的想法,操做系统自己的线程调度已经很是成熟稳定了,没有必要本身搞一套。因此Python的线程就是C语言的一个 pthread,并经过操做系统调度算法进行调度(例如linux是CFS)。为了让各个线程可以平均利用CPU时间,python会计算当前已执行的微代码数量,达到必定阈值后就强制释放GIL。而这时也会触发一次操做系统的线程调度(固然是否真正进行上下文切换由操做系统自主决定)。
伪代码
while True: acquire GIL for i in 1000: do something release GIL /* Give Operating System a chance to do thread scheduling */
这种模式在只有一个CPU核心的状况下毫无问题。任何一个线程被唤起时都能成功得到到GIL(由于只有释放了GIL才会引起线程调度)。但当CPU有多个核心的时候,问题就来了。从伪代码能够看到,从release GIL
到acquire GIL
之间几乎是没有间隙的。因此当其余在其余核心上的线程被唤醒时,大部分状况下主线程已经又再一次获取到GIL了。这个时候被唤醒执行的线程只能白白的浪费 CPU时间,看着另外一个线程拿着GIL欢快的执行着。而后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。
PS:固然这种实现方式是原始而丑陋的,Python的每一个版本中也在逐渐改进GIL和线程调度之间的互动关系。例如先尝试持有GIL在作线程上下文切换,在IO等待时释放GIL等尝试。可是没法改变的是GIL的存在使得操做系统线程调度的这个原本就昂贵的操做变得更奢侈了。 关于GIL影响的扩展阅读
为了直观的理解GIL对于多线程带来的性能影响,这里直接借用的一张测试结果图(见下图)。图中表示的是两个线程在双核CPU上得执行状况。两个线程均为CPU密集型运算线程。绿色部分表示该线程在运行,且在执行有用的计算,红色部分为线程被调度唤醒,可是没法获取GIL致使没法进行有效运算等待的时间。
由图可见,GIL的存在致使多线程没法很好的当即多核CPU的并发处理能力。
那么Python的IO密集型线程可否从多线程中受益呢?咱们来看下面这张测试结果。颜色表明的含义和上图一致。白色部分表示IO线程处于等待。可见,当IO线程收到数据包引发终端切换后,仍然因为一个CPU密集型线程的存在,致使没法获取GIL锁,从而进行无尽的循环等待。
简单的总结下就是:Python的多线程在多核CPU上,只对于IO密集型计算产生正面效果;而当有至少有一个CPU密集型线程存在,那么多线程效率会因为GIL而大幅降低
说了那么多,若是不说解决方案就仅仅是个科普帖,然并卵。GIL这么烂,有没有办法绕过呢?咱们来看看有哪些现成的方案。
multiprocessing库的出现很大程度上是为了弥补thread库由于GIL而低效的缺陷。它完整的复制了一套thread所提供的接口方便迁移。惟一的不一样就是它使用了多进程而不是多线程。每一个进程有本身的独立的GIL,所以也不会出现进程之间的GIL争抢。
固然multiprocessing也不是万能良药。它的引入会增长程序实现时线程间数据通信和同步的困难。就拿计数器来举例子,若是咱们要多个线程累加同一个变量,对于thread来讲,申明一个global变量,用thread.Lock的context包裹住三行就搞定了。而 multiprocessing因为进程之间没法看到对方的数据,只能经过在主线程申明一个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实际上是功能和性能之间权衡后的产物,它尤为存在的合理性,也有较难改变的客观因素。从本分的分析中,咱们能够作如下一些简单的总结: - 由于GIL的存在,只有IO Bound场景下得多线程会获得较好的性能 - 若是对并行计算性能较高的程序能够考虑把核心部分也成C模块,或者索性用其余语言实现 - GIL在较长一段时间内将会继续存在,可是会不断对其进行改进
Python’s hardest problem Official documents about GIL Revisiting thread priorities and the new GIL