作者sustainer123 (溫水佳樹的兄長大人)
標題py
時間2024-10-02 12:13:45
※ 引述《sustainer123 (溫水佳樹的兄長大人)》之銘言:
: 全局鎖(global interpreter lock,GIL)
: 討論python的thread 我們就不得不提到GIL
: 這邊必須先聲明 GIL不是python的特性
: 這是cpython的產物
: cpython是python的直譯器
: 假如您想避開GIL 您可以考慮其他直譯器 諸如jpython
: 但cpython是大多數情況的默認環境
: 所以大多數python使用者還是得面對GIL
: GIL創立的目的是為了解決多線程的安全問題
: 這邊要先介紹cpython的記憶體管理機制
: cpython有一種管理方式叫引用記數
: cpython的object的struct裡面有個東西叫ob_refcnt
: 當你創建或引用變數時 ob_refcnt會+1
: 反之-1
: 當我們使用多線程時 線程會共用記憶體
: 這意味著線程會共用全局變數
: 當不同線程同時修改同個變數的引用記數
: 這就可能導致內存洩漏或內存提前被釋放
: 總之 cpython考慮線程安全 才會引進GIL
: GIL的具體運作方式
: 線程執行前 必須取得GIL 取得後才能開始執行任務
: 如果線程進入IO任務或執行超過一定時間(通常15毫秒)或執行完成
: 釋放GIL
: 所以一個線程會經歷三個步驟:
: 1.爭取GIL
: 2.執行任務
: 3.釋放GIL
: 因為只有一個GIL 所以一段時間內只會有一個線程運行
: 假設是多核心的情況 其他進程本來能在其他核心運行
: 因為GIL的緣故 其他核心只能在一旁閒置
: 所以python的多線程才會被說實際是單線程
: 從執行的效率來說 有GIL的多線程甚至不如單線程
: 因為每當GIL釋放 系統需要執行線程切換
: 線程切換是保存線程的狀態 並載入下一個執行的線程
: 再者 GIL釋放時 線程會被喚醒競爭GIL
: 當GIL頻繁被釋出 上述行為重複執行
: 這耗費了大量的CPU資源
: 反觀單線程就不需要執行上述行為
: 所以在CPU密集型任務 單線程優於多線程
: PY:
: import time
: def countdown(n):
: while (n > 0):
: n = n - 1
: start = time.time()
: countdown(100000000)
: end = time.time()
: print('總時間:', end - start)
: result:
: 總時間: 2.3528549671173096
: py:
: import time
: import threading
: def countdown(n):
: while (n > 0):
: n = n - 1
: t1 = threading.Thread(target=countdown, args=(100000000/2,))
: t2 = threading.Thread(target=countdown, args=(100000000/2,))
: start = time.time()
: t1.start()
: t2.start()
: t1.join()
: t2.join()
: end = time.time()
: print('總時間:', end - start)
: result:
: 總時間: 2.818242073059082
: 上述結果可以證明在cpu密集型任務 單線程優於多線程
: 先這樣 後面應該會寫gil爭搶的可能問題 gil實際並沒有線程安全
: 怎解決gil問題ㄅ
: 累了 剩下明天再寫
: 參考資料:
: https://wiki.python.org/moin/GlobalInterpreterLock
: https://yhtechnote.com/global-interpreter-lock/
接續上面
在python中 線程進入I/O任務也會釋放GIL
因為I/O任務需要等待其他系統回應
這段等待時間就會釋放I/O
其他線程就能執行任務
所以在I/O任務 多線程會優於單線程
py:
import time
import requests
def request(url):
response = requests.get(url)
start = time.time()
request('
https://google.com')
request('
https://google.com')
end = time.time()
print('總時間:', end - start)
result:
總時間: 1.0379688739776611
py:
import time
import threading
import requests
def request(url):
response = requests.get(url)
t1 = threading.Thread(target=request, args=('
https://google.com',))
t2 = threading.Thread(target=request, args=('
https://google.com',))
start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print('總時間:', end - start)
result:
總時間: 0.7394030094146729
所以 在I/O任務使用多線程沒啥問題
線程安全
我們一開始提到 創建GIL的目的為了線程安全
但 GIL其實並沒有完全保證線程安全
因為線程可能會被迫釋放GIL
假設你的操作不是atomic operation 線程安全就會出問題
atomic operation是指在Bytecode層面上不可分的操作
我們可以使用dis看到程式碼在Bytecode層面的情況
舉例來說:
import dis
lst = [4, 1, 3, 2]
def foo():
lst.sort()
dis.dis(foo)
result:
5 0 RESUME 0
6 2 LOAD_GLOBAL 0 (lst)
14 LOAD_METHOD 1 (sort)
36 PRECALL 0
40 CALL 0
50 POP_TOP
52 LOAD_CONST 0 (None)
54 RETURN_VALUE
這段程式碼是調用sort()對list排序
一開始先載入list 之後載入sort() 再來執行sort()
sort()在Bytecode只有一行程式碼 這就代表這是不可分的
這代表sort()排到一半的時候不會被中斷而釋放GIL
這就叫atomic operation
對比另一個程式:
import dis
n = 0
def foo():
global n
n += 1
dis.dis(foo)
result:
4 0 RESUME 0
6 2 LOAD_GLOBAL 0 (n)
14 LOAD_CONST 1 (1)
16 BINARY_OP 13 (+=)
20 STORE_GLOBAL 0 (n)
22 LOAD_CONST 0 (None)
24 RETURN_VALUE
這段程式碼是將n+1並更新n
一開始載入全域變數n 第二步載入常數1
第三步 執行二元運算 n+1 最後把加完的數存回n
我們可以發現這裡比上一個例子多了一步 把加完的數存回n
所以有可能執行運算後被中斷 n沒有被更新
結果就可能不如我們預期
綜合上述 在非atomic operation的情況下 GIL鎖就不能保證線程安全
解決方法是自行上鎖 在讀寫可變的共用變數時加個鎖 可以避免大多數問題
結論:
上面談了很多GIL的問題
其實結論就一句話
I/O任務請用多線程 CPU密集型任務請用多進程
參考資料:
https://opensource.com/article/17/4/grok-gil
--
※ 發信站: 批踢踢實業坊(ptt.org.tw), 來自: 114.136.134.162 (臺灣)
※ 文章網址: https://ptt.org.tw/Marginalman/M.1727842428.A.094
推 cities516: 我看完了 推 10/02 12:27