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
那么CPython实现中的GIL又是什么呢?GIL全称Global Interpreter Lock。
官方解释
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.)
核心意思就是,无论你启动多少个线程,你有多少个cpu,python在执行的时候会在同一时刻只允许一个线程允许。
为什么会有GIL?
为了利用多核,Python开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁。 于是有了GIL这把超级大锁。
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的影响
说了那么多,如果不说解决方案就仅仅是个科普帖,然并卵。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
。
线程锁(MUTEX)
这里引入一个线程锁的概念,既然有GIL,python在执行的时候会在同一时刻只允许一个线程允许,那我们还需要线程锁来维持数据的同步嘛?答案是肯定的,那就是需要
num = 0
def run(n):
global num #修改全局变量
num += 1
def run_thread(n):
for i in range(100000): #对全局变量进行100000加操作
run(n)
thread_array = []
for n in range(2): # 并发2线程
t = threading.Thread(target=run_thread,args=("t-%s{}".format(n),))
t.start()
thread_array.append(t)
for tid in thread_array:
tid.join()
print("total num is ",num)
截取一部分允许图,理论上来讲应该是200000
大概允许三次就会出现1到2次不正常,那对我们来说这个结果肯定是不能接受的,那为什么会出现这个情况呢?其实简单理解,因为共享数据没有被保护,当多个线程对其操作时,因为涉及到线程的上下文切换,可能第一个线程加1操作还没完成,就切换到线程2,在这之间不停的切换,如果数据量很大,肯定会出现错误。详见下面这张图
GIL VS Lock
由于线程的调度是由操作系统决定的,高级语言的一条语句在CPU执行时是若干条语句
- pthread1拿到公共数据count
- 申请gil锁,解释器同一时间只允许一个线程
- 调用系统的原生线程
- 在CPU上执行
- 执行时间到了,被要求释放GIL,这里可能比较难理解。(可以这么理解,虽然加1是个很简单的操作,但CPU会把它划分为很多个指令,但是刚执行到一半,还没加完呢,就切换到下一个线程执行,为什么会这样子呢,因为要让用户感觉是并发的,不然如果你一个线程要等待5分钟,其它线程都卡死了,这肯定是不科学的,所以在每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行)
- pthread2也要修改数据,但此时可能第一个线程还没完成加1的操作,此时pthread2取到的数据还是没加之前的数据。接下去的步骤和上面差不多
加线程锁例子
num = 0
lock = threading.Lock()
def run(n):
global num #修改全局变量
num += 1
def run_thread(n):
for i in range(100000): #对全局变量进行100000加操作
lock.acquire() # 获取锁
try :
run(n)
finally:
lock.release() #释放锁
thread_array = []
for n in range(2): # 并发2线程
t = threading.Thread(target=run_thread,args=("t-%s{}".format(n),))
t.start()
thread_array.append(t)
for tid in thread_array:
tid.join()
print("total num is ",num)
#此时允许结果就很完美了
可以用with来管理锁
num = 0
lock = threading.Lock()
def run(n):
global num #修改全局变量
num += 1
def run_thread(n):
for i in range(100000): #对全局变量进行100000加操作
with lock: #上锁和释放
run(i)
thread_array = []
for n in range(2): # 并发2线程
t = threading.Thread(target=run_thread,args=("t-%s{}".format(n),))
t.start()
thread_array.append(t)
for tid in thread_array:
tid.join()
print("total num is ",num)
k当多个线程同时执行lock.acquire()
时,只有一个线程能成功地获取锁,然后继续执行代码,其他线程就继续等待直到获得锁为止。
获得锁的线程用完后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程。所以我们用try...finally
来确保锁一定会被释放。
锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处当然也很多,首先是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。其次,由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止。
递归锁
import threading, time
def run1():
print("grab the first part data")
lock.acquire()
global num
num += 1
lock.release()
return num
def run2():
print("grab the second part data")
lock.acquire()
global num2
num2 += 1
lock.release()
return num2
def run3():
lock.acquire()
res = run1()
print('--------between run1 and run2-----')
res2 = run2()
lock.release()
print(res, res2)
if __name__ == '__main__':
num, num2 = 0, 0
lock = threading.RLock() #递归锁,这里如果没有递归锁的话会出现死循环
for i in range(5):
t = threading.Thread(target=run3)
t.start()
while threading.active_count() != 1: #判断线程是否都已经执行完成
print(threading.active_count())
else:
print('----all threads done---')
print(num, num2)
信号量(semaphore)
信号量也是一种锁。
信号量同步基于内部计数器,每调用一次acquire(),计数器减1;每调用一次release(),计数器加1.当计数器为0时,acquire()调用被阻塞。这是迪科斯彻(Dijkstra)信号量概念P()和V()的Python实现。信号量同步机制适用于访问像服务器这样的有限资源。
semaphore = threading.BoundedSemaphore(3) #允许3个线程
def run(n):
semaphore.acquire()
time.sleep(1)
print("run the thread:{}".format(n))
semaphore.release()
for i in range(8):
t = threading.Thread(target=run,args=(i,))
t.start()
while threading.active_count() != 1:
pass
else:
print("{:-^20}".format("all thread done"))
#运行结果可以看到3个线程为一组依次允许。
Events
An event is a simple synchronization object;
the event represents an internal flag, and threads
can wait for the flag to be set, or set or clear the flag themselves.
event = threading.Event()
# a client thread can wait for the flag to be set 客户端端等待标志位被设定
event.wait()
# a server thread can set or reset it 服务端设置或清除标志位
event.set()
event.clear()
If the flag is set, the wait method doesn’t do anything.
If the flag is cleared, wait will block until it becomes set again.
Any number of threads may wait for the same event.
通过Event来实现两个或多个线程间的交互,下面是一个红绿灯的例子,即起动一个线程做交通指挥灯,生成几个线程做车辆,车辆行驶按红灯停,绿灯行的规则。
import threading,random
def lighter():
if not event.is_set():
event.set() #wait就不阻塞,绿灯状态
count = 0
while 1:
if count < 10:
print("\033[42;1m--green light on--\033[0m")
elif count <13:
print("\033[43;1m--yellow light on--\033[0m")
elif count < 20:
if event.is_set():
event.clear() #清空标志位,等待将会阻塞,直到flag重新被设置
print("\033[41;1m--red light on--\033[0m")
else:
count = 0
event.set()
time.sleep(1)
count += 1
def car(n):
while 1:
time.sleep(random.randrange(10))
if event.is_set():
print("car {} is running..".format(n))
else:
print("car {} is waiting for the red light..".format(n))
if __name__ == "__main__":
event = threading.Event()
light = threading.Thread(target=lighter)
light.start()
for i in range(3):
t = threading.Thread(target=car,args=(i,))
t.start()
Timer
This class represents an action that should be run only after a certain amount of time has passed
Timers are started, as with threads, by calling their start()
method. The timer can be stopped (before its action has begun) by calling thecancel()
method. The interval the timer will wait before executing its action may not be exactly the same as the interval specified by the user.
def run():
print("Hello World!")
t = threading.Timer(5.0,run)
t.start()
http://yoyzhou.github.io/blog/2013/02/28/python-threads-synchronization-locks/