python多线程GIL的问题记录

为什么会有GIL

由于物理上得限制,各CPU厂商在核心频率上的比赛已经被多核所取代。为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。即使在CPU内部的Cache也不例外,为了有效解决多份缓存之间的数据同步时各厂商花费了不少心思,也不可避免的带来了一定的性能损失。

Python当然也逃不开,为了利用多核,Python开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁。 于是有了GIL这把超级大锁。

 

GIL的缺陷和原理

1)按照Python社区的想法,操作系统本身的线程调度已经非常成熟稳定了,没有必要自己搞一套。所以Python的线程就是C语言的一个pthread,并通过操作系统调度算法进行调度(例如linux是CFS)。

2)python为了让各个线程能够平均利用CPU时间,会计算当前已执行的微代码数量,达到一定阈值后就强制释放GIL。而这时也会触发一次操作系统的线程调度(当然是否真正进行上下文切换由操作系统自主决定,也就是说真正线程调度还是由操作系统来自主决定。那么也会出现调度到另一个线程,但是还没拿到GIL锁,因为第一个线程还没释放,然后就只能等待)。

3)任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。

4)这种模式在只有一个CPU核心的情况下毫无问题。任何一个线程被唤起时都能成功获得到GIL(因为只有释放了GIL才会引发线程调度)。但当CPU有多个核心的时候,问题就来了。假设有一个核心上的线程一直拿GIL锁执行计算,然后释放GIL,然后又拿锁,之间几乎是没有间隙的放锁拿锁执行,所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到GIL了。这个时候被唤醒执行的线程只能白白的浪费CPU时间(又没抢到锁,浪费唤醒执行你的时间),看着另一个线程拿着GIL欢快的执行着。然后达到切换时间后进入待调度状态,再被唤醒,再拿不到锁等待,以此往复恶性循环,

ps:多核CPU情况下(只有一个CPU核心的情况下毫无问题),这样感觉就像单线程执行一样,反而比单线程还多了唤醒调度另一个线程的步骤;

下面可以测试对比下Python在多线程和单线程下得效率对比:

顺序执行的单线程(single_thread.py)

#! /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()

 

同时执行的两个并发线程(multi_thread.py)

#! /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多线程GIL的问题记录

 

可以看到python在多线程的情况下居然比单线程整整慢了45%

python中有两种多任务处理:

1. 协同式多任务处理:一个线程无论何时开始睡眠或等待网络 I/O,就会释放GIL锁

2. 抢占式多任务处理:如果一个线程不间断地在 Python 2 中运行 1000 字节码指令,或者不间断地在 Python 3 运行15 毫秒,那么它便会放弃 GIL,而其他线程可以运行

这样解释之后感觉GIL没啥影响啊,反正会切换的嘛,那为什么都说由于GIL的存在,导致python的多线程比单线程还慢,按我的理解,在单核CPU下没什么不一样(也有可能有性能损失),但是在多核CPU下问题就大了,不同核心上的线程同一时刻也只能执行一个,所以不能够利用多核CPU的优势,反而在不同核心间切换时会造成资源浪费,反而比单核CPU更慢。

如何避免受到GIL的影响

用multiprocessing替代Thread

multiprocessing库的出现很大程度上是为了弥补thread库因为GIL而低效的缺陷。它完整的复制了一套thread所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的GIL,因此也不会出现进程之间的GIL争抢。

当然multiprocessing也不是万能良药。它的引入会增加程序实现时线程间数据通讯和同步的困难。就拿计数器来举例子,如果我们要多个线程累加同一个变量,对于thread来说,申明一个global变量,用thread.Lock的context包裹住三行就搞定了。而multiprocessing由于进程之间无法看到对方的数据,只能通过在主线程申明一个Queue,put再get或者用share memory的方法。这个额外的实现成本使得本来就非常痛苦的多线程程序编码,变得更加痛苦了。

 

有了GIL全局解释器锁为什么还需要线程锁lock:

1)举个例子,两个线程分别对一个全局变量total进行加和减1000000次,但是结果并不是0!而是每次运行结果都不相同。

造成这个结果的原因是GIL事实上是会释放的,解决这种情况就要加线程锁;

 

2)假设只有一个进程,这个进程中有两个线程 Thread1,Thread2, 要修改共享的数据date, 并且有互斥锁(线程锁)

1、多线程运行,假设Thread1获得GIL可以使用cpu,这时Thread1获得 互斥锁lock,Thread1可以改date数据(但并

没有开始修改数据)

2、Thread1线程在修改date数据前发生了 i/o操作 或者 ticks计数满100 (注意就是没有运行到修改data数据),这个
时候 Thread1 让出了Gil,Gil锁可以被竞争

3、 Thread1 和 Thread2 开始竞争 Gil (注意:如果Thread1是因为 i/o 阻塞 让出的Gil Thread2必定拿到Gil,如果
Thread1是因为ticks计数满100让出Gil 这个时候 Thread1 和 Thread2 公平竞争)

4、假设 Thread2正好获得了GIL, 运行代码去修改共享数据date,由于Thread1有互斥锁lock,所以Thread2无法更改共享数据
date,这时Thread2让出Gil锁 , GIL锁再次发生竞争

5、假设Thread1又抢到GIL,由于其有互斥锁Lock所以其可以继续修改共享数据data,当Thread1修改完数据释放互斥锁lock,
Thread2在获得GIL与lock后才可对data进行修改

 

总结:

1)多线程可以使用在IO密集型(比如网络请求等),因在遇到 I/O 操作时会释放这把锁。如果是纯计算的程序,没有 I/O 操作,解释器会每隔 100 次操作就释放这把锁,让别的线程有机会执行,但在多核cpu可能会造成只有一个核的线程一直霸占GIL锁;

2)在多核cpu下使用多线程做CPU密集型运算线程效果不如单一线程,要么用c扩展,要么用多进程解决(多进程通信也是麻烦)

参考:https://www.cnblogs.com/cjaaron/p/9166538.html

 

 

上一篇:python中的GIL


下一篇:Python中的GIL全局解释器锁