Python解析器Cpython的GIL解釋器鎖工作機制

本節重點

  • 掌握Cpython的GIL解釋器鎖的工作機制
  • 掌握GIL與互斥鎖
  • 掌握Cpython下多線程與多進程各自的應用場景

本節時長需控制在45分鐘內

一 引子

定義:

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

結論:在Cpython解釋器中,同一個進程下開啟的多線程,同一時刻隻能有一個線程執行,無法利用多核優勢

首先需要明確的一點是GIL並不是Python的特性,它是在實現Python解析器(CPython)時所引入的一個概念。就好比C++是一套語言(語法)標準,但是可以用不同的編譯器來編譯成可執行代碼。

有名的編譯器例如GCC,INTEL C++,Visual C++等。Python也一樣,同樣一段代碼可以通過CPython,PyPy,Psyco等不同的Python執行環境來執行。

像其中的JPython就沒有GIL。然而因為CPython是大部分環境下默認的Python執行環境。

所以在很多人的概念裡CPython就是Python,也就想當然的把GIL歸結為Python語言的缺陷。

所以這裡要先明確一點:GIL並不是Python的特性,Python完全可以不依賴於GIL

二 GIL介紹

GIL本質就是一把互斥鎖,既然是互斥鎖,所有互斥鎖的本質都一樣,都是將並發運行變成串行,以此來控制同一時間內共享數據隻能被一個任務所修改,進而保證數據安全。

可以肯定的一點是:保護不同的數據的安全,就應該加不同的鎖。

要想瞭解GIL,首先確定一點:每次執行python程序,都會產生一個獨立的進程。例如python test.py,python aaa.py,python bbb.py會產生3個不同的python進程

驗證python test.py隻會產生一個進程

#test.py內容
import os,time
print(os.getpid())
time.sleep(1000)
#打開終端執行
python3 test.py
#在windows下查看
tasklist |findstr python
#在linux下下查看
ps aux |grep python

在一個python的進程內,不僅有test.py的主線程或者由該主線程開啟的其他線程,還有解釋器開啟的垃圾回收等解釋器級別的線程,總之,所有線程都運行在這一個進程內,毫無疑問

1、所有數據都是共享的,這其中,代碼作為一種數據也是被所有線程共享的(test.py的所有代碼以及Cpython解釋器的所有代碼)

例如:test.py定義一個函數work(代碼內容如下圖),在進程內所有線程都能訪問到work的代碼,於是我們可以開啟三個線程然後target都指向該代碼,能訪問到意味著就是可以執行。

2、所有線程的任務,都需要將任務的代碼當做參數傳給解釋器的代碼去執行,即所有的線程要想運行自己的任務,首先需要解決的是能夠訪問到解釋器的代碼。

綜上:

如果多個線程的target=work,那麼執行流程是

多個線程先訪問到解釋器的代碼,即拿到執行權限,然後將target的代碼交給解釋器的代碼去執行

解釋器的代碼是所有線程共享的,所以垃圾回收線程也可能訪問到解釋器的代碼而去執行,這就導致瞭一個問題:對於同一個數據100,可能線程1執行x=100的同時,而垃圾回收執行的是回收100的操作,解決這種問題沒有什麼高明的方法,就是加鎖處理,如下圖的GIL,保證python解釋器同一時間隻能執行一個任務的代碼

三 GIL與Lock

機智的同學可能會問到這個問題:Python已經有一個GIL來保證同一時間隻能有一個線程來執行瞭,為什麼這裡還需要lock?

首先,我們需要達成共識:鎖的目的是為瞭保護共享的數據,同一時間隻能有一個線程來修改共享的數據

然後,我們可以得出結論:保護不同的數據就應該加不同的鎖。

最後,問題就很明朗瞭,GIL 與Lock是兩把鎖,保護的數據不一樣,前者是解釋器級別的(當然保護的就是解釋器級別的數據,比如垃圾回收的數據),後者是保護用戶自己開發的應用程序的數據,很明顯GIL不負責這件事,隻能用戶自定義加鎖處理,即Lock,如下圖

分析:

1、100個線程去搶GIL鎖,即搶執行權限

2、肯定有一個線程先搶到GIL(暫且稱為線程1),然後開始執行,一旦執行就會拿到lock.acquire()

3、極有可能線程1還未運行完畢,就有另外一個線程2搶到GIL,然後開始運行,但線程2發現互斥鎖lock還未被線程1釋放,於是阻塞,被迫交出執行權限,即釋放GIL

4、直到線程1重新搶到GIL,開始從上次暫停的位置繼續執行,直到正常釋放互斥鎖lock,然後其他的線程再重復2 3 4的過程

代碼示范

from threading import Thread,Lock
import os,time
def work():
    global n
    lock.acquire()
    temp=n
    time.sleep(0.1)
    n=temp-1
    lock.release()
if __name__ == '__main__':
    lock=Lock()
    n=100
    l=[]
    for i in range(100):
        p=Thread(target=work)
        l.append(p)
        p.start()
    for p in l:
        p.join()
    print(n) #結果肯定為0,由原來的並發執行變成串行,犧牲瞭執行效率保證瞭數據安全,不加鎖則結果可能為99

四 GIL與多線程

有瞭GIL的存在,同一時刻同一進程中隻有一個線程被執行

聽到這裡,有的同學立馬質問:進程可以利用多核,但是開銷大,而python的多線程開銷小,但卻無法利用多核優勢,也就是說python沒用瞭,php才是最牛逼的語言?

別著急啊,老娘還沒講完呢。

要解決這個問題,我們需要在幾個點上達成一致:

1、cpu到底是用來做計算的,還是用來做I/O的?

2、多cpu,意味著可以有多個核並行完成計算,所以多核提升的是計算性能

3、每個cpu一旦遇到I/O阻塞,仍然需要等待,所以多核對I/O操作沒什麼用處

一個工人相當於cpu,此時計算相當於工人在幹活,I/O阻塞相當於為工人幹活提供所需原材料的過程,工人幹活的過程中如果沒有原材料瞭,則工人幹活的過程需要停止,直到等待原材料的到來。

如果你的工廠幹的大多數任務都要有準備原材料的過程(I/O密集型),那麼你有再多的工人,意義也不大,還不如一個人,在等材料的過程中讓工人去幹別的活,

反過來講,如果你的工廠原材料都齊全,那當然是工人越多,效率越高

結論:

1、對計算來說,cpu越多越好,但是對於I/O來說,再多的cpu也沒用

2、當然對運行一個程序來說,隨著cpu的增多執行效率肯定會有所提高(不管提高幅度多大,總會有所提高),這是因為一個程序基本上不會是純計算或者純I/O,所以我們隻能相對的去看一個程序到底是計算密集型還是I/O密集型,從而進一步分析python的多線程到底有無用武之地

假設我們有四個任務需要處理,處理方式肯定是要玩出並發的效果,解決方案可以是:

方案一:開啟四個進程

方案二:一個進程下,開啟四個線程

  • 單核情況下,分析結果:

如果四個任務是計算密集型,沒有多核來並行計算,方案一徒增瞭創建進程的開銷,方案二勝

如果四個任務是I/O密集型,方案一創建進程的開銷大,且進程的切換速度遠不如線程,方案二勝

  • 多核情況下,分析結果:

如果四個任務是計算密集型,多核意味著並行計算,在python中一個進程中同一時刻隻有一個線程執行用不上多核,方案一勝

如果四個任務是I/O密集型,再多的核也解決不瞭I/O問題,方案二勝

結論:

現在的計算機基本上都是多核,python對於計算密集型的任務開多線程的效率並不能帶來多大性能上的提升,甚至不如串行(沒有大量切換),但是,對於IO密集型的任務效率還是有顯著提升的。

五 多線程性能測試

如果並發的多個任務是計算密集型:多進程效率高

from multiprocessing import Process
from threading import Thread
import os,time
def work():
    res=0
    for i in range(100000000):
        res*=i
if __name__ == '__main__':
    l=[]
    print(os.cpu_count()) #本機為4核
    start=time.time()
    for i in range(4):
        p=Process(target=work) #耗時5s多
        p=Thread(target=work) #耗時18s多
        l.append(p)
        p.start()
    for p in l:
        p.join()
    stop=time.time()
    print('run time is %s' %(stop-start))

如果並發的多個任務是I/O密集型:多線程效率高

from multiprocessing import Process
from threading import Thread
import threading
import os,time
def work():
    time.sleep(2)
    print('===>')
if __name__ == '__main__':
    l=[]
    print(os.cpu_count()) #本機為4核
    start=time.time()
    for i in range(400):
        # p=Process(target=work) #耗時12s多,大部分時間耗費在創建進程上
        p=Thread(target=work) #耗時2s多
        l.append(p)
        p.start()
    for p in l:
        p.join()
    stop=time.time()
    print('run time is %s' %(stop-start))

應用:

多線程用於IO密集型,如socket,爬蟲,web

多進程用於計算密集型,如金融分析

以上就是Python解析器Cpython的GIL解釋器鎖工作機制的詳細內容,更多關於Python Cpython解釋器鎖GIL的資料請關註WalkonNet其它相關文章!

推薦閱讀: