
为什么有人会说 Python 多线程是噱头?知乎上有人提出这种一个问题,在我们知识中,多进程、多线程都是通过并发的方法充分利用硬件资源提升程序的运行效益,怎么在 Python 中似乎成了鸡肋?
有同学也许明白答案,因为 Python 中臭名昭著的 GIL,GIL 是哪个?为什么会有 GIL?多线程真的是噱头吗? GIL 可以除去吗?带着很多疑问,我们一起往下看,同时还要你有一点点耐心。
多线程是不是鸡肋,我们先做个实验,实验比较简单,就是将数字 “1亿” 递减,减到 0 程序就终止,这个任务一旦我们使用单线程来执行,完成时间会是多少?使用多线程又会是多少?
show me the code
# 任务
def decrement(n):
while n > 0:
n -= 1
单线程
import time
start = time.time()
decrement(100000000)
cost = time.time() - start
>>> 6.541690826416016
在我的4核 CPU 计算机中,单线程所花的时间是 6.5 秒。可能有人会问,线程在那里?其实任何程序运行时,默认就会有一个主线程在执行。

多线程
import threading
start = time.time()
t1 = threading.Thread(target=decrement, args=[50000000])
t2 = threading.Thread(target=decrement, args=[50000000])
t1.start() # 启动线程,执行任务
t2.start() # 同上
t1.join() # 主线程阻塞,直到t1执行完成,主线程继续往后执行
t2.join() # 同上
cost = time.time() - start
>>>6.85541033744812
创建两个子线程 t1、t2,每个线程各执行 5 千万次减操作,等两个线程都执行完后,主线程中止程序运行。结果,两个线程以合作的方法执行是 6.8 秒,反而变慢了。按理来说,两个线程同时并行地运行在两个 CPU 之上,时间需要减半才对,现在不减反增。
是哪个因素造成多线程不快反慢的呢?
在于 GIL ,在 Cpython 解释器(Python语言的主流解释器)中,有一把全局解释锁(Global Interpreter Lock).
在解释器解释执行 Python 代码时,先要给与这把锁,意味着,任何时候只或许有一个线程在执行代码,其它线程要想获取 CPU执行代码指令,就需要先拿到这把锁,如果锁被其他线程占用了,那么该线程就没法等待,直到占有该锁的线程释放锁才有执行代码指令的或许。
因此,这也就是为什么两个线程一起执行甚至非常慢的缘由,因为同一时刻,只有一个线程在运行,其它线程没法等待,即使是多核CPU,也没办法让多个线程「并行」地同时执行代码,只能是交替执行,因为多线程涉及到上线文切换、锁模式处理(获取锁,释放锁等),所以,多线程执行不快反慢。
什么时候 GIL 被释放呢?

当一个线程遇到 I/O 任务时,将释放GIL。
计算密集型(CPU-bound)线程执行 100 次解释器的计步(ticks)时(计步可粗略看作 Python 虚拟机的指令),也会传递 GIL。可以通过sys.setcheckinterval()设置计步长度,sys.getcheckinterval()查看计步长度。
相比单线程,这些多是多线程带来的额外开销。
CPython 解释器为什么要这么设计?
多线程是为了适应现代计算机软件高速发展充分利用多核处理器的产物,通过多线程使得 CPU 资源可以被高效运用出来。
Python 诞生于1991年,那时候硬件配置远没有今天这么高档,现在一台普通服务器32核64G内存都不是什么司空见惯的事,但是多线程有个难题,怎么缓解共享数据的同步、一致性问题,因为,对于多个线程访问共享数据时,可能有两个线程同时设置一个数据状况,如果没有合适的体系确保数据的一致性,那么程序最后造成异常,所以多线程死锁的伪代码,Python之父就搞了个全局的线程锁,不管你数据有没有同步问题,反正一刀切,上个全局锁,保证数据安全。这也就是多线程鸡肋的缘由,因为它没有细粒度的控制数据的安全,而是用一种简单粗暴的方法来解决。
这种解决方法放到90年代,其实是没哪个问题的,毕竟,那时候的硬件配置还很昂贵,单核 CPU 还是主流,多线程的应用画面也不多,大部分时候而是以单线程的方法运行,单线程不要涉及线程的上下文切换,效率甚至比多线程更高(在多核环境下,不适用此规则)。所以,采用 GIL 的方法来确保数据的一致性和安全,未必不可取,至少在当年是一种成本很低的推动方法。
那么把 GIL 去掉可行吗?

还真有人这么干多,但是结果令人失望,在1999年Greg Stein 和Mark Hammond 两位大哥就创建了一个去掉 GIL 的 Python 分支,在所有可变数据结构上把 GIL 替换为更为细粒度的锁。然而,做过了基准测试期间,去掉GIL的 Python 在单线程条件下执行效益将近慢了2倍。
Python之父表示:基于以下的考量,去掉GIL没有太大的价值而不必花很多精力。
Python 的面对很简单,以不变应万变。在最新的 python 3 中仍然有 GIL。之所以不除去,原因嘛,不外以下几点:
那不仅切掉 GIL 外多线程死锁的伪代码,果然也有办法让 Python 在多核时代活的滋润?让我们重回本文最初的哪个问题:如何能让这个死循环的 Python 脚本在双核机器上占用 100% 的 CPU?其实最简单的答案必须是:运行两个 python 死循环的程序!也就是说,用两个分别占满一个 CPU 内核的 python 进程来做到。确实,多进程也有借助多个 CPU 的好办法。只是进程间内存地址空间独立,互相协同通信要比多线程麻烦太多。有感于此,Python 在 2.6 里新引入了 multiprocessing这个多进程标准库,让多进程的 python 程序编写简化到类似多线程的程度,大大减轻了 GIL 带来的不能利用多核的尴尬。
这还仅仅一个方法,如果不想用多进程这么重量级的解决方案,还有个更彻底的方案,放弃 Python,改用 C/C++。当然,你也不用做的如此绝,只应该把关键部分用 C/C++ 写成 Python 扩展,其它部份还是用 Python 来写,让 Python 的归 Python,C 的归 C。一般计算密集性的程序就会用 C 代码编写并借助扩展的方法集成到 Python 脚本里(如 NumPy 模块)。在扩展里就完全可以用 C 创建原生线程,而且不用锁 GIL,充分利用 CPU 的计算资源了。不过,写 Python 扩展总是让人认为很复杂。好在 Python 还有另一种与 C 模块进行互通的模式 : ctypes,利用 ctypes 绕过 GIL
ctypes 与 Python 扩展不同,它可以让 Python 直接调用任意的 C 动态库的导入变量。你所要做的也是用 ctypes 写些 python 代码就能。最酷的是,ctypes 会在读取 C 函数前释放 GIL。所以,我们可以借助 ctypes 和 C 动态库来让 python 充分利用物理内核的计算能力。让我们来实际验证一下,这次我们用 C 写一个死循环函数
extern"C"
{
void DeadLoop()
{
while (true);
}
}
用下面的 C 代码编译生成动态库 libdead_loop.so (Windows 上是 dead_loop.dll)

,接着就要利用 ctypes 来在 python 里 load 这个动态库,分别在主句柄和新建线程里读取其中的 DeadLoop
from ctypes import *
from threading import Thread
lib = cdll.LoadLibrary("libdead_loop.so")
t = Thread(target=lib.DeadLoop)
t.start()
lib.DeadLoop()
这回再说说 system monitor,Python 解释器进程有两个线程在跑,而且双核 CPU 全被占满了,ctypes 确实很给力!需要提醒的是,GIL 是被 ctypes 在读取 C 函数前释放的。但是 Python 解释器还是会在执行任意一段 Python 代码时锁 GIL 的。如果你使用 Python 的代码做为 C 函数的 callback,那么即使 Python 的 callback 方法被执行时,GIL 还是会跳出来的。比如上面的举例:
extern"C"
{
typedef void Callback();
void Call(Callback* callback)
{
callback();
}
}
from ctypes import *
from threading import Thread
def dead_loop():
while True:
pass
lib = cdll.LoadLibrary("libcall.so")
Callback = CFUNCTYPE(None)
callback = Callback(dead_loop)
t = Thread(target=lib.Call, args=(callback,))
t.start()
lib.Call(callback)
注意此处与上个示例的不同之处,这次的死循环是出现在 Python 代码里 (DeadLoop 函数) 而 C 代码也是负责去读取这个 callback 而已。运行这个示例,你会看到 CPU 占用率还是只有 50% 不到。GIL 又起作用了。
其实,从后面的事例,我们能够看出 ctypes 的一个应用,那就是用 Python 写自动化测试用例,通过 ctypes 直接读取 C 模块的接口来对这个组件进行黑盒测试,哪怕是有关该组件 C 接口的多线程安全方面的测试,ctypes 也一样能做到。
结语
虽然 CPython 的线程库封装了操作系统的原生线程,但却由于 GIL 的存在造成多线程不能利用多个 CPU 内核的计算能力。
好在现在 Python 有了易经筋(multiprocessing), 吸星大法(C 语言扩展模式)和独孤九剑(ctypes),足以应付多核时代的挑战,GIL 切还是不切终于不重要了,不是吗。
本文来自电脑杂谈,转载请注明本文网址:
http://www.pc-fly.com/a/jisuanjixue/article-121334-1.html
我爷爷一月2300
他那问题跟阿富汗现在是国际贩毒中心之一一样