1. 程式人生 > >GIL鎖、程序池與執行緒池

GIL鎖、程序池與執行緒池

1.什麼是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.)
''' 釋義: 在CPython中,這個全域性直譯器鎖,也稱為GIL,是一個互斥鎖,防止多個執行緒在同一時間執行Python位元組碼,這個鎖是非常重要的,
因為CPython的記憶體管理非執行緒安全的,很多其他的特性依賴於GIL,所以即使它影響了程式效率也無法將其直接去除 總結: 在CPython中,GIL會把執行緒的並行變成序列,導致效率降低

GIL指的是全域性直譯器鎖,本質就是一把互斥鎖,是加在直譯器上的互斥鎖。首先需要明確的是GIL並不是Python的特性,它是在實現CPython直譯器時所引入的概念,GIL也僅僅存在於CPython中,直譯器並不是只有CPython,還有PyPy,JPython等等,這些就不依賴GIL。

2.為什麼需要GIL?

由於CPython的記憶體管理是非執行緒安全的,於是CPython就給直譯器加了GIL鎖,解決了安全問題,但是降低了效率(即使在多核處理器下,也無法實現真正的並行)。另外雖然有解決的方案,但是CPython應用比較廣泛,牽扯太多,一旦修改,那麼很多以前的基於GIL的程式都需要修改,所以這個問題就變成了歷史遺留問題。

GIL與GC的孽緣

在使用Python中進行程式設計時,程式設計師無需參與記憶體的管理工作,這是因為Python有自帶的記憶體管理機制,簡稱GC。那麼GC與GIL有什麼關聯?

要搞清楚這個問題,需先了解GC的工作原理,Python中記憶體管理使用的是引用計數,每個數會被加上一個整型的計數器,表示這個資料被引用的次數,當這個整數變為0時則表示該資料已經沒有人使用,成了垃圾資料。

當記憶體佔用達到某個閾值時,GC會將其他執行緒掛起,然後執行垃圾清理操作,垃圾清理也是一串程式碼,也就需要一條執行緒來執行。

示例程式碼:

from threading import  Thread
def task():
   a = 10
   print(a)

# 開啟三個子執行緒執行task函式
Thread(target=task).start()
Thread(target=task).start()
Thread(target=task).start()

上述程式碼記憶體結構如下:

通過上圖可以看出,GC與其他執行緒都在競爭直譯器的執行權,而CPU何時切換,以及切換到哪個執行緒都是無法預支的,這樣一來就造成了競爭問題,假設執行緒1正在定義變數a=10,而定義變數第一步會先到到記憶體中申請空間把10存進去,第二步將10的記憶體地址與變數名a進行繫結,如果在執行完第一步後,CPU切換到了GC執行緒,GC執行緒發現10的地址引用計數為0則將其當成垃圾進行了清理,等CPU再次切換到執行緒1時,剛剛儲存的資料10已經被清理掉了,導致無法正常定義變數。

當然其他一些涉及到記憶體的操作同樣可能產生問題問題,為了避免GC與其他執行緒競爭直譯器帶來的問題,CPython簡單粗暴的給直譯器加了互斥鎖,如下圖所示:

有了GIL後,多個執行緒將不可能在同一時間使用直譯器,從而保證瞭解釋器的資料安全。

GIL的加鎖與解鎖時機

加鎖的時機:在呼叫直譯器時立即加鎖

解鎖時機:

  • 當前執行緒遇到了IO時釋放

  • 當前執行緒執行時間超過設定值時釋放,直譯器會檢測執行緒的執行時間,一旦到達某個閾值,通知執行緒儲存狀態切換執行緒,以此來保證資料安全

  •  

3.GIL帶來的問題

首先必須明確執行一個py檔案,分為三個步驟

  1. 從硬碟載入Python直譯器到記憶體

  2. 從硬碟載入py檔案到記憶體

  3. 直譯器解析py檔案內容,交給CPU執行

其次需要明確的是每當執行一個py檔案,就會立即啟動一個python直譯器,

當執行test.py時其記憶體結構如下:

GIL,叫做全域性直譯器鎖,加到了直譯器上,並且是一把互斥鎖,那麼這把鎖對應用程式到底有什麼影響?

這就需要知道直譯器的作用,以及直譯器與應用程式程式碼之間的關係

py檔案中的內容本質都是字串,只有在被直譯器解釋時,才具備語法意義,直譯器會將py程式碼翻譯為當前系統支援的指令交給系統執行。

當程序中僅存在一條執行緒時,GIL鎖的存在沒有不會有任何影響,但是如果程序中有多個執行緒時,GIL鎖就開始發揮作用了。

開啟子執行緒時,給子執行緒指定了一個target表示該子執行緒要處理的任務即要執行的程式碼。程式碼要執行則必須交由直譯器,即多個執行緒之間就需要共享直譯器,為了避免共享帶來的資料競爭問題,於是就給直譯器加上了互斥鎖!

由於互斥鎖的特性,程式序列,保證資料安全,降低執行效率,GIL將使得程式整體效率降低!

4.GIL的效能討論

GIL的優點:

  • 保證了CPython中的記憶體管理是執行緒安全的

GIL的缺點:

  • 互斥鎖的特性使得多執行緒無法並行

但我們並不能因此就否認Python這門語言,其原因如下:

  1. GIL僅僅在CPython直譯器中存在,在其他的直譯器中沒有,並不是Python這門語言的缺點

  2. 在單核處理器下,多執行緒之間本來就無法真正的並行執行

  3. 在多核處理下,運算效率的確是比單核處理器高,但是要知道現代應用程式多數都是基於網路的(qq,微信,爬蟲,瀏覽器等等),CPU的執行效率是無法決定網路速度的,而網路的速度是遠遠比不上處理器的運算速度,則意味著每次處理器在執行運算前都需要等待網路IO,這樣一來多核優勢也就沒有那麼明顯了

    舉個例子:

    任務1 從網路上下載一個網頁,等待網路IO的時間為1分鐘,解析網頁資料花費,1秒鐘

    任務2 將使用者輸入資料並將其轉換為大寫,等待使用者輸入時間為1分鐘,轉換為大寫花費,1秒鐘

 

單核CPU下:1.開啟第一個任務後進入等待。2.切換到第二個任務也進入了等待。一分鐘後解析網頁資料花費1秒解析完成切換到第二個任務,轉換為大寫花費1秒,那麼總耗時為:1分+1秒+1秒 = 1分鐘2秒

多核CPU下:1.CPU1處理第一個任務等待1分鐘,解析花費1秒鐘。1.CPU2處理第二個任務等待1分鐘,轉換大寫花費1秒鐘。由於兩個任務是並行執行的所以總的執行時間為1分鐘+1秒鐘 = 1分鐘1秒

可以發現,多核CPU對於總的執行時間提升只有1秒,但是這邊的1秒實際上是誇張了,轉換大寫操作不可能需要1秒,時間非常短!

上面的兩個任務都是需要大量IO時間的,這樣的任務稱之為IO密集型,與之對應的是計算密集型即沒有IO操作全都是計算任務。

對於計算密集型任務,Python多執行緒的確比不上其他語言!為了解決這個弊端,Python推出了多程序技術,可以良好的利用多核處理器來完成計算密集任務。

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

計算密集型:多程序效率高
計算密集型,多程序效率高
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))

I/O密集型:多執行緒效率高
I/O密集型,多執行緒效率高

總結:

1.在單核情況下,無論是I/O密集型還是計算密集型,GIL都不會產生影響。

2.如果是多核情況下,I/O密集型會受到GIL的影響,但是很明顯I/O的速度比計算速度慢,所以影響不大。

3.I/O密集型用多執行緒(執行緒開銷小,節省資源),計算密集型使用多程序(因為CPython多執行緒是無法並行的)。

另外之所以廣泛採用CPython直譯器,就是因為大量的應用程式都是I/O密集型的,還有一個重要的原因是CPython可以無縫對接各種C語言實現的庫,對於一些數學計算相關的應用程式就可以直接使用各種現成的演算法。

5.GIL與自定義執行緒鎖的區別

GIL保護的是直譯器級別的資料安全,比如物件的引用計數,垃圾分代資料等等(垃圾回收機制)。

對於程式中自己定義的資料沒有任何的保護效果,這一點在沒有介紹GIL前我們就已經知道了,所以當程式中出現了共享自定義的資料時就要自己加鎖,如下例:

from threading import Thread,Lock
import time

a = 0
def task():
    global a
    temp = a
    time.sleep(0.01) 
    a = temp + 1

t1 = Thread(target=task)
t2 = Thread(target=task)
t1.start()
t2.start()
t1.join()
t2.join()
print(a)

過程分析:

1.執行緒1獲得CPU執行權,並獲取GIL鎖執行程式碼,得到a的值為0後進入睡眠,釋放CPU並釋放GIL。

2.執行緒2獲得CPU執行權,並獲取GIL鎖執行程式碼,得到a的值為0後進入睡眠,釋放CPU並釋放GIL。

3.執行緒1睡醒後獲得CPU執行權,並獲取GIL執行程式碼 ,將temp的值0+1後賦給a,執行完畢釋放CPU並釋放GIL。

4.執行緒2睡醒後獲得CPU執行權,並獲取GIL執行程式碼 ,將temp的值0+1後賦給a,執行完畢釋放CPU並釋放GIL,最後a的值也就是1。

之所以出現問題是因為兩個執行緒在併發的執行同一段程式碼,解決方案就是加鎖!

from threading import Thread,Lock
import time

lock = Lock()
a = 0
def task():
    global a
    lock.acquire()
    temp = a
    time.sleep(0.01)
    a = temp + 1
    lock.release()

t1 = Thread(target=task)
t2 = Thread(target=task)
t1.start()
t2.start()
t1.join()
t2.join()
print(a)

過程分析:

1.執行緒1獲得CPU執行權,並獲取GIL鎖執行程式碼 ,得到a的值為0後進入睡眠,釋放CPU並釋放GIL,不釋放lock

2.執行緒2獲得CPU執行權,並獲取GIL鎖,嘗試獲取lock失敗,無法執行,釋放CPU並釋放GIL

3.執行緒1睡醒後獲得CPU執行權,並獲取GIL繼續執行程式碼 ,將temp的值0+1後賦給a,執行完畢釋放CPU釋放GIL,釋放lock,此時a的值為1

4.執行緒2獲得CPU執行權,獲取GIL鎖,嘗試獲取lock成功,執行程式碼,得到a的值為1後進入睡眠,釋放CPU並釋放GIL,不釋放lock

5.執行緒2睡醒後獲得CPU執行權,獲取GIL繼續執行程式碼 ,將temp的值1+1後賦給a,執行完畢釋放CPU釋放GIL,釋放lock,此時a的值為2

 6.程序池與執行緒池

1.什麼是程序/執行緒池?

池表示一個容器,本質上就是一個儲存程序或執行緒的列表。

2.何時使用程序池、執行緒池?

I/O密集型任務使用執行緒池,計算密集型任務使用程序池。

3.為什麼需要程序/執行緒池?

在很多情況下需要控制程序或執行緒的數量在一個合理的範圍,例如TCP程式中,一個客戶端對應一個執行緒,雖然執行緒的開銷小,但肯定不能無限的開,否則系統資源遲早被耗盡,解決的辦法就是控制執行緒的數量。

執行緒/程序池不僅幫我們控制執行緒/程序的數量,還幫我們完成了執行緒/程序的建立,銷燬,以及任務的分配。

from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
import time,os

# 建立程序池,指定最大程序數為3,此時不會建立程序,不指定數量時,預設為CPU和核數
pool = ProcessPoolExecutor(3)

def task():
    time.sleep(1)
    print(os.getpid(),"working..")

if __name__ == '__main__':
    for i in range(10):
        pool.submit(task) # 提交任務時立即建立程序

    # 任務執行完成後也不會立即銷燬程序
    time.sleep(2)

    for i in range(10):
        pool.submit(task) #再有新任務是 直接使用之前已經建立好的程序來執行
程序池的使用
from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
from threading import current_thread,active_count
import time,os

# 建立程序池,指定最大執行緒數為3,此時不會建立執行緒,不指定數量時,預設為CPU和核數*5
pool = ThreadPoolExecutor(3)
print(active_count()) # 只有一個主線

def task():
    time.sleep(1)
    print(current_thread().name,"working..")

if __name__ == '__main__':
    for i in range(10):
        pool.submit(task) # 第一次提交任務時立即建立執行緒

    # 任務執行完成後也不會立即銷燬
    time.sleep(2)

    for i in range(10):
        pool.submit(task) #再有新任務是 直接使用之前已經建立好的執行緒來執行
執行緒池的使用

7.同步與非同步

同步和非同步指的是提交任務的方式。

同步(呼叫/執行/任務/提交):發起任務後必須等待任務結束,拿到一個結果才能繼續執行。

非同步:發起任務後不需要關心任務的執行過程,可以繼續往下執行。

非同步的效率高於同步,但是並不是所有任務都可以非同步執行,判斷一個任務是否可以非同步的條件是:任務發起方是否立即需要執行結果。

同步會有等待的效果,但是,這和阻塞時完全不同的,阻塞時程式會被剝奪CPU執行權,而同步呼叫則不會,因為同步在等待任務執行結束,得到結果,CPU還在執行中。沒有切換。

from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time

pool = ThreadPoolExecutor(3)
def task(i):
    time.sleep(0.01)
    print(current_thread().name,"working..")
    return i ** i

if __name__ == '__main__':
    objs = []
    for i in range(3):
        res_obj = pool.submit(task,i) # 非同步方式提交任務# 會返回一個物件用於表示任務結果
        objs.append(res_obj)

# 該函式預設是阻塞的 會等待池子中所有任務執行結束後執行
pool.shutdown(wait=True)

# 從結果物件中取出執行結果
for res_obj in objs:
    print(res_obj.result())
print("over")
非同步呼叫並獲取結果
from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time

pool = ThreadPoolExecutor(3)
def task(i):
    time.sleep(0.01)
    print(current_thread().name,"working..")
    return i ** i

if __name__ == '__main__':
    objs = []
    for i in range(3):
        res_obj = pool.submit(task,i) # 會返回一個物件用於表示任務結果
        print(res_obj.result()) #result是同步的一旦呼叫就必須等待 任務執行完成拿到結果
print("over")
同步呼叫並獲取結果