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。

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