Python——GIL 简介 01
1.前言
每个 CPU 在同一时间只能执行一个线程。
在单核 CPU 下的多线程其实都只是并发,不是并行,并发和并行从宏观上来讲都是同 时处理多路请求的概念。
并行是指两个或者多个事件在同一时刻发生;
并发是指两个或多个事件在同一时间间隔内发生。
由于物理上得限制,各 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 这种方式会更优雅。
2.GIL 是什么?
首先需要明确的一点是 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 完全可以不依赖于 GIL
官方解释: 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.) 一个防止多线程并发执行机器码的一个 Mutex
Parallel execution is forbidden
There is a "global interpreter lock"
The GIL ensures that only one thread runs in the interpreter at once
Simplifies many low-level details (memory management, callouts to C extensions, etc.)
在 Python 多线程下,每个线程的执行方式:
- 获取 GIL
- 执行代码直到 sleep 或者是 python 虚拟机将其挂起。
- 释放 GIL
可见,某个线程想要执行,必须先拿到 GIL,我们可以把 GIL 看作是“通行证”,并且在一个 python 进程中,GIL 只有一个。拿不到通行证的线程,就不允许进入 CPU执行。
在 python2.x 里,GIL 的释放逻辑是当前线程遇见 IO 操作或者 ticks 计数达到 100 (ticks 可以看作是 python 自身的一个计数器,专门做用于 GIL,每次释放后归零, 这个计数可以通过 sys.setcheckinterval 来调整),进行释放。 而每次释放 GIL 锁,线程进行锁竞争、切换线程,会消耗资源。并且由于 GIL 锁存 在,python 里一个进程永远只能同时执行一个线程(拿到 GIL 的线程才能执行),这就是为什么在多核 CPU 上,python 的多线程效率并不高。
而在 python3.x 中,GIL 不使用 ticks 计数,改为使用计时器(执行时间达到阈值 后,当前线程释放 GIL),这样对 CPU 密集型程序更加友好,但依然没有解决 GIL 导 致的同一时间只能执行一个线程的问题,所以效率依然不尽如人意。
3.线程互斥锁和 GIL 的区别
线程互斥锁是 Python 代码层面的锁,解决 Python 程序中多线程共享资源的问题 (线程数据共共享,当各个线程访问数据资源时会出现竞争状态,造成数据混乱);
GIL 是 Python 解释层面的锁,解决解释器中多个线程的竞争资源问题(多个子线 程在系统资源竞争是,都在等待对象某个部分资源解除占用状态,结果谁也不愿意 先解锁,然后互相等着,程序无法执行下去)。
在 Cpython 解释器下,GIL(全局解释器锁)导致了同一进程下的多个线程不能利用 多核
假设只有一个进程,这个进程中有两个线程 Thread1,Thread2, 要修改共享的数据 date, 并且有互斥锁:
假设 Thread1 获得 GIL 可以使用 cpu,这时 Thread1 获得互斥锁 lock,Thread1 可以改 date 数据(但并没有开始修改数据);
Thread1 线程在修改 date 数据前发生了 i/o 操作 或者 ticks 计数满 100((注意就 是没有运行到修改 data 数据),这个时候 Thread1 让出了 Gil,Gil 锁可以被竞争);
Thread1 和 Thread2 开始竞争 Gil (注意:如果 Thread1 是因为 i/o 阻塞让出的Gil,Thread2 必定拿到 Gil,如果 Thread1 是因为 ticks 计数满 100 让出 Gil 这个时候 Thread1 和 Thread2 公平竞争);
假设 Thread2 正好获得了 GIL, 运行代码去修改共享数据 date,由于 Thread1 有互斥锁 lock,所以 Thread2 无法更改共享数据 date,这时 Thread2 让出 Gil 锁, GIL 锁再次发生竞争;
假设 Thread1 又抢到 GIL,由于其有互斥锁 Lock 所以其可以继续修改共享数据 data,当 Thread1 修改完数据释放互斥锁 lock,Thread2 在获得 GIL 与 lock 后才可对 data 进行修改
4.GIL 的影响
从上文的介绍和官方的定义来看,GIL 无疑就是一把全局排他锁。毫无疑问全局锁的 存在会对多线程的效率有不小影响。甚至就几乎等于 Python 是个单线程的程序。 那 么读者就会说了,全局锁只要释放的勤快效率也不会差啊。只要在进行耗时的 IO 操作 的时候,能释放 GIL,这样也还是可以提升运行效率的嘛。或者说再差也不会比单线 程的效率差吧。理论上是这样,而实际上呢?Python 比你想的更糟。
下面我们就对比下 Python 在多线程和单线程下得效率对比。测试方法很简单,一个循 环 1 亿次的计数器函数。一个通过单线程执行两次,一个多线程执行。最后比较执行 总时间。注:为了减少线程库本身性能损耗对测试结果带来的影响,这里单线程的代 码同样使用了线程。只是顺序的执行两次,模拟单线程。
单线程:single_thread.pyimport time
import time
from threading import Thread
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()```
多线程:multi_threads.pyimport time
```python
import time
from threading import Thread
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()
环境:2C4G 虚机,python 3.8
执行结果:
multi_threads:
Total time: 9.535321950912476
single_thread.py:
Total time: 8.799328088760376
基于 python2 对照的,但也可以看出来,单线程的效率确实比多线程(没有锁的情况下)效率高。
8C16G:
single_thread.py
Total time: 9.410213470458984
multi_thread.py
Total time: 32.43910241127014
5.GIL 设计的缺陷
基于 pcode 数量的调度方式
按照 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 */
/* Give Operating System a chance to do thread scheduling */
个人理解:上述伪代码中,while True其实是在一个python的进程里面(它可认为是PVM),进程里面存在多个python线程抢占GIL,当thread1抢占了GIL后,其它线程只能等待,当thread1释放了GIL后,由于在大循环里release-->acquire直接没有任何其它动作,导致thread1比其它thread更快获取GIL,其它thread哪怕被唤醒了,又再次等待。。。情况如下:
这种模式在只有一个CPU核心的情况下毫无问题。任何一个线程被唤起时都能成功获得到GIL(因为只有释放了GIL才会引发线程调度)。但当CPU有多个核心的时候,问题就来了。从伪代码可以看到,从release GIL到acquire GIL之间几乎是没有间隙的。所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到GIL了。这个时候被唤醒执行的线程只能白白的浪费CPU时间,看着另一个线程拿着GIL欢快的执行着。然后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。
多核多线程比单核多线程更差,原因是单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行,但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸(thrashing),导致效率更低
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而大幅下降。
7.如何避免受到 GIL 的影响
GIL这么烂,有没有办法绕过呢?我们来看看有哪些现成的方案。
● 用multiprocessing替代Thread
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的多线程就完全没用了呢?
在这里我们进行分类讨论:
1、CPU密集型代码(各种循环处理、计数等等),在这种情况下,ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以python下的多线程对CPU密集型代码并不友好。
2、IO密集型代码(文件处理、网络爬虫等),多线程能够有效提升效率(单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率)。所以python的多线程对IO密集型代码比较友好。
8.总结
Python GIL 其实是功能和性能之间权衡后的产物,它尤其存在的合理性,也有较难改
变的客观因素。从本分的分析中,我们可以做以下一些简单的总结:
因为 GIL 的存在,只有 IO Bound 场景下得多线程会得到较好的性能
如果对并行计算性能较高的程序可以考虑把核心部分也成 C 模块,或者索性用其
他语言实现
GIL 在较长一段时间内将会继续存在,但是会不断对其进行改进
多核下,想做并行提升效率,比较通用的方法是使用多进程,能够有效提高执行效
9.参考
http://www.dabeaz.com/python/UnderstandingGIL.pdf
https://realpython.com/python-gil/ 【腾讯文档】Python——GIL简介01 https://docs.qq.com/doc/DYUhPTVRBdWRranJk
评论区