Python之线程的GIL问题

1.GIL是什么

      GIL(Global Interpreter Lock)并不是python的特性,而是Python解释器Cpython引入的一个概念。而python的解释器不仅仅只有Cpython,若解释器为Jpython,那么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.)

2.产生的原因

      由于物理上得限制,各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这种方式会更优雅。

3.导致的问题

      由于python解释器设计中加入了解释器锁,导致python解释器同一时刻只能解释执行一个线程,大大降低了线程的执行效率。 导致后果: 因为遇到阻塞时线程会主动让出解释器,去解释其他线程。所以python多线程在执行多阻塞高延迟IO时可以提升程序效率,其他情况并不能对效率有所提升。

示例:

from multiprocessing import Process
from threading import Thread
import time


# 计算密集型函数
def count(x, y):
    c = 0
    while c < 7000000:
        c += 1
        x += 1
        y += 1


# IO密集型函数
def io():
    write()
    read()


def write():
    f = open("test_file", "w")
    for i in range(1500000):
        f.write("hello world\n")
    f.close()


def read():
    f = open("test_file")
    lines = f.readlines()
    f.close()


def t1_10():
    begin = time.time()
    print("开始时间:", begin)
    for i in range(10):
        # count(1, 1)
        io()
    end = time.time()
    print("结束时间:", end)
    # print("【单线程】执行【十次】【计算密集型函数】耗时:%.2f 秒" % (end - begin))
    print("【单线程】执行【十次】【IO密集型函数】耗时:%.2f 秒" % (end - begin))


def t10_1():
    jobs = []
    begin = time.time()
    print("开始时间:", begin)
    for i in range(10):
        # t = Thread(target=count, args=(1, 1))
        t = Thread(target=io)
        t.start()
        jobs.append(t)
    for i in jobs:
        i.join()
    end = time.time()
    print("结束时间:", end)
    # print("【十线程】执行【一次】【计算密集型函数】耗时:%.2f 秒" % (end - begin))
    print("【十线程】执行【一次】【IO密集型函数】耗时:%.2f 秒" % (end - begin))


def p10_1():
    jobs = []
    begin = time.time()
    print("开始时间:", begin)
    for i in range(10):
        p = Process(target=count, args=(1, 1))
        # p = Process(target=io)
        p.start()
        jobs.append(p)
    for p in jobs:
        p.join()
    end = time.time()
    print("结束时间:", end)
    # print("【十进程】执行【一次】【计算密集型函数】耗时:%.2f 秒" % (end - begin))
    print("【十进程】执行【一次】【IO密集型函数】耗时:%.2f 秒" % (end - begin))


if __name__ == '__main__':
    # 【单线程/进程】执行【十次】【计算密集型函数】 耗时:8秒
    # 【单线程/进程】执行【十次】【IO密集型函数】 耗时:8.3秒
    # t1_10()

    # 【十线程】执行【一次】【计算密集型函数】耗时:9.07 秒
    # 【十线程】执行【一次】【IO密集型函数】耗时:8.1 秒
    # t10_1()

    # 【十进程】执行【一次】【计算密集型函数】耗时:2.35 秒
    # 【十进程】执行【一次】【IO密集型函数】耗时:2.27 秒
    # p10_1()

结论 : 在无阻塞状态下,多线程程序和单线程程序执行效率几乎差不多,甚至还不如单线程效率。但是多进程运行相同内容却可以有明显的效率提升。

4、如何避免受到GIL的影响

    用multiprocessing(进程)替代Thread(线程)

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

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

    用其他解析器

      之前也提到了既然GIL只是CPython的产物,那么其他解析器是不是更好呢?没错,像JPython和IronPython这样的解析器由于实现语言的特性,他们不需要GIL的帮助。然而由于用了Java/C#用于解析器实现,他们也失去了利用社区众多C语言模块有用特性的机会。所以这些解析器也因此一直都比较小众。毕竟功能和性能大家在初期都会选择前者,Done is better than perfect。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章