你見過Python的GIL嗎

guerbai發表於2018-10-19

GIL是/Global Interpreter Lock/的簡稱,翻譯為中文是/全域性直譯器鎖/,維基百科的解釋為:

全域性直譯器鎖是計算機程式設計語言直譯器用於同步執行緒的一種機制,它使得任何時刻僅有一個執行緒在執行。即便在多核心處理器上,使用 GIL 的直譯器也只允許同一時間執行一個執行緒。

關於Python多執行緒與GIL的思考

問題的提出

學過Python的人大都知道這個解釋性語言最通用的實現(CPython)採用了GIL的方式,因此在網上可以看到一些言論說“Python因為有GIL存在,多執行緒就算了,還是多程式吧”。
可這並不符合使用Python程式設計的實際體驗,的確會讓人產生一些疑惑。
Python有其自帶的多執行緒模組,而且著名的爬蟲框架scrapy可以同時爬多個網站,感覺上其並沒有受到GIL的限制。
與Java對比的話,Java也支援多執行緒也可以寫爬蟲,而Java並沒有GIL,這與Python看起來好像沒有什麼區別,那麼GIL到底有沒有發揮作用呢?

能否使用Java和Python分別寫一段語義上一樣的程式碼,通過兩段程式的output有著明顯的不同來證明GIL的確存在並且起了一定的作用呢?
要做這個事情首先要進行理論上的更進一步探索,才能進行程式碼的實現與output的設計。

關於併發的知識鋪墊

<CSAPP>上提到了三種不同層面的 *併發程式設計技術*,分別為:

  1. 程式級別的併發;
  2. I/O多路複用;
  3. 執行緒級別的併發。

顯然此篇的討論應該歸到第三種型別。

接下來,還要明確另一對容易搞錯的概念, 併發並行
併發 指的是邏輯控制流在時間上的重疊,而 並行 則是指對多核CPU的利用。
並行只是併發的一個真子集,有種說法是“併發是基於邏輯上的同時發生,而並行是基於物理上的同時發生”。
所以,在只有一個CPU的機器上也可以執行併發程式,卻不能執行並行程式。

使用加速比證明GIL存在的假設

根據以上關於併發與並行的基本知識,Python與Java在併發程式上的本質區別便可以得知。
即,因為有GIL的存在,Python無法利用到多核處理器的並行性,但依然可以編寫除此之外的併發程式,並獲得效率提升。而Java則無此限制。

CSAPP中提到了對於並行程式效能的衡量標準– 加速比

img
上述公式中,Sp稱為加速比,其中p是處理器核的數量,Tp是指在p個核上程式的執行時間,當T1是程式順序執行版本的執行時間時,Sp稱為絕對加速比,而當Sp為程式並行版本在一個核上的執行時間時,Sp稱為相對加速比。

所以,可以使用絕對加速比來證明GIL的存在。 預期是,寫一段無IO的計算密集性任務,分別交給Python與Java的一個(順序執行)、多個執行緒(並行版本)去執行,算出各自的加速比,如果Python版本加速比小於1,而Java版本的加速比在計算機核心數左右,則說明是GIL起了作用,導致Python程式無法發揮多核的並行性。

證明過程

依然使用書中的例子: 做一個加法任務,從0加到0x7fffffff求和,通過設定執行緒數n,將數字加和任務平均拆分為n份,給到各執行緒做自己的一份,最後將子任務的和再加和求得最後的結果。
那麼當n等於1時,即為順序版本,n大於1時則為並行版本。
書中程式碼使用C語言實現,此處分別改寫為Python與Java兩個版本。

入口為:

def main():
    thread_num1 = 1
    thread_num2 = 2
    thread_num4 = 4
    thread_num8 = 8
    print ("sum_task with thread_num1 cost time: " + str(measure_time_cost(thread_num1)) + "s in Python version.")
    print ("sum_task with thread_num2 cost time: " + str(measure_time_cost(thread_num2)) + "s in Python version.")
    print ("sum_task with thread_num4 cost time: " + str(measure_time_cost(thread_num4)) + "s in Python version.")
    print ("sum_task with thread_num8 cost time: " + str(measure_time_cost(thread_num4)) + "s in Python version.")
複製程式碼

分別用嘗試1,2,4,8個執行緒下執行結果,measure_time_cost 主要用來建立目標數量的執行緒,給各執行緒分配自己的計算任務,然後等待各執行緒全部返回,再加和,同時返回耗時,該函式實現為:

def measure_time_cost(thread_nums):
    nums = 99999999 # Python加到0x7fffffff要太久,改一個小一點的值。
    num_per_thread = int((nums + 1) / thread_nums)
    thread_list = [None] * thread_nums
    task_list = [None] * thread_nums
    start_at = time.time()
    for i in range(thread_nums):
        ct = SumTask()
        thread_list[i] = threading.Thread(target=ct.run, args=(i, num_per_thread))
        thread_list[i].start()
        task_list[i] = ct
    for i in range(thread_nums):
        thread_list[i].join()
    end_at = time.time()
    result = 0
    for i in range(thread_nums):
        result += task_list[i].get_result()
    print (result)
    return end_at - start_at
複製程式碼

用到的SumTask就是一個簡單的類用來處理返回值,不想去用queue,全域性變數什麼的。

由於筆者的mac只有兩核,無法看到4核、8核等更明顯的效果,Python版本的程式跑下來結果為:

img

而Java版本的相同實現,跑下來的結果為:

img

由於電腦核少,故主要看2核情況的對比,Python版本使用2核並沒有得到明顯的增速,加速比小於1。而Java版則差不多為2,發揮到了多核的效用,提高了計算密集性任務的效率。
隨著執行緒數的增加,由於沒有那麼多核,執行緒切換的副作用體現了出來,後面時間會增加到比單執行緒還多。

之後,在知乎上有網友利用8核電腦做了驗證,依然與預期相符,Java的最大加速比為0.701/0.168=4.17,而Python的加速比均小於0.5。

img

Java程式碼就是Executor提交任務,然後通過繼承Callable利用Future得到結果。 完整版程式碼在這裡,直接複製進code runner跑就可以看到結果,很方便。

這,可能是很多人第一次感受到GIL的存在吧~

相關文章