Python全局解释器锁(GIL)
以如下代码为例:
1 | def CountDown(n): |
首先测试其在单线程情况下的运行时长,在我的机器上,上述代码的运行时间为6s左右。
下面测试上述代码的多线程版本的运行时间:
1 | from threading import Thread |
多线程版本的运行时间为9s左右。
从结果对比看,多线程反而比单线程耗时更久。那么,原因是什么?
实际上,正是Python中的全局解释器锁(GIL)导致这一问题的出现。
为什么会有GIL?
GIL是最流行的Python解释器CPython中的一个技术术语,本质上是类似操作系统的Mutex。每一个Python线程,在CPython解释器中运行时,都会先锁住自己的线程,阻止别的线程执行。
但是为了模仿并行,CPython会轮流执行Python线程,在多个线程之间不断切换。CPython这么做的原因在于Python的内存管理机制,CPython使用引用计数来管理内存,所有Python脚本中创建的实例,都会有一个引用计数来记录有多少个指针指向它。当引用计数为0时,则会自动释放内存。如下述代码所示:
1 | import sys |
其中,a
的引用计数为3,因为有a
,b
和作为参数传递的getrefcount
三个指针指向该空列表。
那么,如果有两个Python线程同时引用了a
,就会导致对引用计数的race condition,引用计数可能只会增加1,导致内存被污染。当第一个线程结束时,就会把引用计数减一,如果达到了内存释放条件,当第二个线程试图访问a时,就无法找到有效的内存。
因而,CPython引入GIL主要有以下两个原因:
- 设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition);
- 因为CPython大量使用C语言库,大部分C语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。
GIL的工作原理
如下图所示,Thread1、2、3轮流执行,当每一个线程开始执行时都会锁住GIL,以阻止别的线程执行;同样,当每一个线程执行完成后,会释放GIL,以允许别的线程开始利用资源。
但是如果一个线程一直锁住GIL,其他线程岂不是永远没有执行的机会?为了解决这个问题,CPython中还有另一个机制,check_interval。在程序运行过程中,CPython解释器会轮询检查线程GIL的锁住情况。每个一段时间,Python解释器会强制当前线程释放GIL,以给别的线程运行机会。
Python的线程安全
有了GIL并不意味着Python就一定是线程安全的。即使GIL仅允许一个Python线程执行,但是由于check interval抢占机制的存在,同样有可能发生竞争风险问题。以如下代码为例:
1 | import threading |
在上述代码中,大部分时间下,输出值都是100,但仍旧存在一些情况,输出值为99或98。
其中,n+=1
这句代码会导致线程不安全。打印这句话的字节码:
1 | import dis |
Check interval有可能发生在这四行代码的任何一句之后。
因而,在编写Python多线程程序时仍旧要注意线程安全问题。
GIL的设计主要是为了方便CPython解释器层面的编写者,而不是为了Python应用层面的程序员。在使用Python时,还是要使用lock等工具来确保线程安全。
1 | n = 0 |
如何绕过GIL?
Python的GIL是通过CPython的解释器添加的限制,如果程序不需要CPython解释器来执行,就不受GIL的限制。因而,绕过GIL可以有以下两种方式:
- 不适用CPython解释器,转而使用别的解释器;
- 将关键性能代码使用其它语言实现。
思考题
为什么开头的cpu-bound的程序,多线程会比单线程慢?
该程序不涉及I/O耗时环节,CPU都是被充分利用的。但是相比于单线程,多线程多了线程的切换,因而性能不如单线程。