你見過Python的GIL嗎

GIL是/Global Interpreter Lock/的簡稱,翻譯爲中文是/全局解釋器鎖/,維基百科的解釋爲:git

全局解釋器鎖是計算機程序設計語言解釋器用於同步線程的一種機制,它使得任什麼時候刻僅有一個線程在執行。即使在多核心處理器上,使用 GIL 的解釋器也只容許同一時間執行一個線程。github

關於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的機器上也能夠運行併發程序,卻不能運行並行程序。scrapy

使用加速比證實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的存在吧~

相關文章
相關標籤/搜索