玩轉python中的GIL前世今生與核心用法剖析

1.GIL的前世今生

1.1GIL的是什麼?

       python是解釋型語言,不用編譯,運行時可以直接通過解釋器進行解釋執行了。類似linux中的bash解釋器,所以python中也有很多解釋器,如cpython(C語言實現),jpython等,只是默認的解釋器Cpython,所以大家一般使用的python環境都是基於Cpython的。

        我們所說的Python GIL是Global Interpreter Lock,翻譯過來就是:全局解釋器鎖,我們從GIL的名字就可看出其是一個解釋器鎖,針對的主題是解釋器。所以GIL並不是Python的特性,它是在實現Python解析器(Cpython)時所引入的一個概念,而同樣作爲python解釋器的Jpython就沒有GIL。那麼爲什麼Cpython需要GIL,而Jpython不需要GIL呢?GIL又是幹啥的呢?

        查看python官網文檔,發現對GIL出現描述如下:

        在Cpython中GIL是一個防止解釋器多線程併發執行機器碼的一個全局互斥鎖。其存在主要是因爲在代碼執行過程中,CPython的內存管理不是線程安全的。

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.)

1.2GIL解決了python中的什麼問題?

      玩過C語言的都知道,C語言需要手動進行內存分配,釋放,否則會出現內存泄露的問題。cpython中利用引用計數來進行內存管理,這就意味着在Python中創建的對象都有一個引用計數變量來追蹤指向該對象的引用數量。當數量爲0時,該對象佔用的內存即被釋放。如下:

>>> import sys
>>> a = [1,2,3]
>>> b = a
>>> sys.getrefcount(a),sys.getrefcount(b)  #查看列表[1,2,3]的引用次數。
(3, 3)
>>> a.append(4) #對列表追加一個元素
>>> a
[1, 2, 3, 4]
>>> sys.getrefcount(a),sys.getrefcount(b)
(4, 4)
>>> del a  #刪除a以後,列表的引用減少了1位。
>>> a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'a' is not defined
>>> b
[1, 2, 3, 4]
>>> sys.getrefcount(b)
3

       如上,對於同一個變量,如果讓兩個線程同時操作他,那麼問題就來了。這個變量的引用計數不能被同時增加或者減少,也就說任意時刻都必須保證這個變量的引用計數的全局一致性。否則變量的引用計數有可能不準確,這樣的結果會導致泄露的內存永遠不會被釋放,抑或更嚴重的是當一個對象的引用仍然存在的情況下錯誤地釋放內存。這可能會導致Python程序崩潰或帶來各種詭異的bug。

       那麼這個時候怎麼辦呢?可以通過對跨線程分享的數據結構添加鎖定以至於數據不會不一致地被修改,這樣做可以很好的保證引用計數變量的安全。但是對每一個對象或者對象組添加鎖意味着會存在多個鎖,這也就導致了另外一個問題——死鎖(只有當存在多個鎖時纔會發生)。而另一個副作用是由於重複獲取和釋放鎖而導致的性能下降。所以看來使用多鎖雖然能解決全局變量的一致性,但是對性能也有很大的影響,怎麼辦呢?

       這個時候GIL就閃亮登場了。GIL是全局解釋器鎖是一個單一鎖,它增加的一條規則要求任何Python字節碼的執行都需要獲取解釋鎖。這有效地防止了死鎖(因爲只存在一個鎖)並且不會帶來太多的性能開銷。

      此外人們針對於C庫中那些被Python所需的功能寫了許多擴展,爲了防止不一致變化,這些C擴展需要線程安全內存管理,而這些正是GIL所提供的。GIL是非常容易實現而且很容易添加到Python中。因爲只需要管理一個鎖,所以對於單線程任務來說帶來了性能提升。非線程安全的C庫變得更容易集成,而這些C擴展則成爲Python強大的功能之一。

1.3GIL的出生與發展

          雖然說GIL其最早存在主要是因爲在代碼執行過程中,CPython的內存管理不是線程安全的因爲隨着時代的發展,計算機硬件開始往多核多線程方向發展了,爲了更有效的利用多核處理器的性能,就出現了多線程的編程方式,而隨之帶來的就是線程間數據一致性和狀態同步的困難。即使在CPU內部的Cache也不例外,爲了有效解決多份緩存之間的數據同步時各廠商花費了不少心思,也不可避免的帶來了一定的性能損失。

        Python當然也逃不開,爲了利用多核,Python開始支持多線程。而解決多線程之間數據完整性和狀態同步的最簡單方法自然就是加鎖。 同樣還是GIL這把超級自動大鎖,讓python支持的多線程實現了安全。而當越來越多的代碼庫開發者接受了這種設定後,他們開始大量依賴這種特性(因爲默認加了GIL自動鎖後,相當於python中是多線程安全的,這樣開發者在實際開發中就不需要關心線程安全和鎖的問題了,以至於後來尾大不掉,想刪除GIL鎖已經很難更改了)。

        查看python官網對於GIL在多線程中的使用說明如下:

        Python解釋器(Cpython)不是完全線程安全的。爲了支持多線程Python程序,有一個全局鎖,稱爲全局解釋器鎖GIL。當前線程必須持有該鎖才能允許其訪問Python對象。如果沒有鎖定,即使最簡單的操作也可能導致多線程程序出現問題:例如,當兩個線程同時遞增同一對象的引用計數時,引用計數最終只能遞增一次而不是兩次。

        因此規定只有獲取GIL的線程可以在Python對象上操作或調用Python / C API函數。爲了模擬執行的併發性,解釋器會定期嘗試切換線程(請參閱參考資料sys.setswitchinterval())。鎖也會在讀取或寫入文件等潛在阻塞I / O操作時釋放,以便其他Python線程可以同時運行。

        其實說到底就是一句話,在Cpython解釋器的多線程程序中,爲了保證線程操作安全,默認使用了一個GIL鎖,該鎖GIL是一個阻止多線程同時執行的互斥鎖,保證任意時刻只有一個線程在正在執行,其餘線程處於等待狀態,只是不同線程執行時切換的很快,雖然是併發狀態,但看上去像是並行。所以說在Cpython中多線程實際來說是“僞多線程”。

1.4GIL鎖的釋放機制

        Python解釋器進程內的多線程是合作多任務方式執行。當一個線程遇到I/O任務時,將釋放GIL。計算密集型(CPU-bound)的線程在執行大約100次解釋器的計步(ticks)時,將釋放GIL。計步(ticks)可粗略看作Python虛擬機的指令。計步實際上與時間片長度無關。可以通過sys.setcheckinterval()設置計步長度。

        Python 3.2開始使用新的GIL。在新的GIL實現中,用一個固定的超時時間來指示當前的線程放棄全局鎖。在當前線程保持這個鎖,且其他線程請求這個鎖的時候,當前線程就會在5ms後被強制釋放掉這個鎖。

2.GIL在多線程中使用與注意事項

2.1python中使用多線程和單線程執行效率分析

    一般來說,比如Java中多線程程序的執行效率一般要比單線程的高,但是在在Cpython中多線程實際上是“僞多線程”,那麼其同樣一個程序用多線程和單線程執行的結果又如何呢?

 A1.單線程執行同一個程序調用,耗時84.12s

import time

def counter1():
    for i in range(100000000):
        i = i + 1
    print("this is i:",i+5)

def counter2():
    for j in range(100000000):
        j = j + 1
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        counter2()
        counter1()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is j: 100000010
this is i: 100000005
this is j: 100000010
this is i: 100000005
Total time: 84.12183594703674
'''

  A2.多線程執行同一個程序,耗時89.27s。

from threading import Thread
import time

def counter1():
    for i in range(100000000):
        i = i + 1
    print("this is i:",i+5)

def counter2():
    for j in range(100000000):
        j = j + 1
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        t1 = Thread(target = counter2)
        t2 = Thread(target=counter1)
        t1.start()
        t2.start()
        t2.join()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))

if __name__ == '__main__':
    main()

'''
this is i: 100000005
this is j: 100000010
this is i: 100000005
Total time: 89.27375364303589
this is j: 100000010
'''

尖叫提示1:顯然上面兩個案例看出同一個程序,在python中 (Cpthon)單線程反而要比多線程執行的快,因爲GIL鎖的緣故,多線程實際上需要頻繁切換進行併發操作,尤其對於多核CPU來說,存在嚴重的線程顛簸(thrashing)。​​​​儘管如此,那麼是不是說python中單線程就一定比多線程效率高呢?請看下面案例。

B1.同樣使用單線程執行同一個程序,注意同樣是上面的程序,這裏在代碼中增加了sleep(0.01)耗時操作。結果這個時候單線程 執行完程序耗時:42.91s.

import time

def counter1():
    for i in range(1000):
        i = i + 1
        time.sleep(0.01)
    print("this is i:",i+5)

def counter2():
    for j in range(1000):
        j = j + 1
        time.sleep(0.01)
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        counter2()
        counter1()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is j: 1010
this is i: 1005
this is j: 1010
this is i: 1005
Total time: 42.90534329414368

'''

B2.同樣使用多線程執行同一個程序,注意同樣是上面的程序,這類在代碼中增加了sleep(0.01)耗時操作。結果這個時候多線程 執行完程序耗時:21.78s。

from threading import Thread
import time

def counter1():
    for i in range(1000):
        i = i + 1
        time.sleep(0.01)
    print("this is i:",i+5)

def counter2():
    for j in range(1000):
        j = j + 1
        time.sleep(0.01)
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        t1 = Thread(target = counter1)
        t2 = Thread(target=counter2)
        t1.start()
        t2.start()
        t2.join()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is i: 1005
this is j: 1010
this is j: 1010
Total time: 21.78059458732605
this is i: 1005

'''

尖叫提示2:爲什麼同樣一個程序,增加了sleep耗時操作以後在python中多線程的操作又比單線程執行的更快了呢?這不就和上面的結果矛盾了嗎?這其實說到底就是GIL鎖的釋放機制了。如上:當一個線程遇到I/O任務時,將釋放GIL。計算密集型(CPU-bound)的線程在執行大約100次解釋器的計步(ticks)時,將釋放GIL。所以說我們增加了sleep耗時操作,相當於將計算型的程序變成了耗時等待的I/O程序,這個時候GIL鎖遇到I/O任務時,不會繼續等待耗時操作,而是立馬釋放鎖,給其他線程去執行,這樣的話效率會比單線程高很多(因爲單線程需要等待耗時結束才能繼續執行)。

2.2python中GIL與多線程的使用總結

很顯然通過上滿A1,A2,B1,B24個案例的結果我們得出如下結論:

  1. python多線程適合做io密集型程序,因爲有延時,可以GIL自動解阻塞,所以效率更高。相反,如果是計算密集型程序,python中單線程因爲沒有線程切換的延時,效率更高。
  2. 實際開發中,如果是計算密集型程序,一般使用多進程,多進程可以並行適合計算密集型,發揮多核cpu。計算密集型程序來說,進程效率>單線程>多線程。
  3. GIL在較長一段時間內將會繼續存在,但是會不斷對其進行改進,所以乾脆還是使用multiprocessing替代Thread或者使用協程吧。
  4. 協程適合IO密集型,只用單核。效率要比單線程高。
  5. IO密集型:涉及到網絡、磁盤IO的任務都是IO密集型任務,這類任務的特點是CPU消耗很少,任務的大部分時間都在等待IO操作完成(因爲IO的速度遠遠低於CPU和內存的速度)。對於IO密集型任務,任務越多,CPU效率越高,但也有一個限度。常見的大部分任務都是IO密集型任務,比如Web應用。當然我們在Python中可以利用sleep達到IO密集型任務的目的。
  6. 計算密集型任務的特點是要進行大量的計算,消耗CPU資源,比如計算圓周率、對視頻進行高清解碼等等,全靠CPU的運算能力。這種計算密集型任務雖然也可以用多任務完成,但是任務越多,花在任務切換的時間就越多,CPU執行任務的效率就越低,所以,要最高效地利用CPU,計算密集型任務同時進行的數量應當等於CPU的核心數。
     

 

 

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