您好,登錄后才能下訂單哦!
本篇內容介紹了“Python中的GIL怎么實現”的有關知識,在實際案例的操作過程中,不少人都會遇到這樣的困境,接下來就讓小編帶領大家學習一下如何處理這些情況吧!希望大家仔細閱讀,能夠學有所成!
GIL 本質上是一把鎖,學過操作系統的同學都知道鎖的引入是為了避免并發訪問造成數據的不一致。CPython 中有很多定義在函數外面的全局變量,比如內存管理中的 usable_arenas 和 usedpools,如果多個線程同時申請內存就可能同時修改這些變量,造成數據錯亂。另外 Python 的垃圾回收機制是基于引用計數的,所有對象都有一個 ob_refcnt字段表示當前有多少變量會引用當前對象,變量賦值、參數傳遞等操作都會增加引用計數,退出作用域或函數返回會減少引用計數。同樣地,如果有多個線程同時修改同一個對象的引用計數,就有可能使 ob_refcnt 與真實值不同,可能會造成內存泄漏,不會被使用的對象得不到回收,更嚴重可能會回收還在被引用的對象,造成 Python 解釋器崩潰。
CPython 中 GIL 的定義如下
struct _gil_runtime_state { unsigned long interval; // 請求 GIL 的線程在 interval 毫秒后還沒成功,就會向持有 GIL 的線程發出釋放信號 _Py_atomic_address last_holder; // GIL 上一次的持有線程,強制切換線程時會用到 _Py_atomic_int locked; // GIL 是否被某個線程持有 unsigned long switch_number; // GIL 的持有線程切換了多少次 // 條件變量和互斥鎖,一般都是成對出現 PyCOND_T cond; PyMUTEX_T mutex; // 條件變量,用于強制切換線程 PyCOND_T switch_cond; PyMUTEX_T switch_mutex; };
最本質的是 mutex 保護的 locked 字段,表示 GIL 當前是否被持有,其他字段是為了優化 GIL 而被用到的。線程申請 GIL 時會調用 take_gil() 方法,釋放 GIL時 調用 drop_gil() 方法。為了避免饑餓現象,當一個線程等待了 interval 毫秒(默認是 5 毫秒)還沒申請到 GIL 的時候,就會主動向持有 GIL 的線程發出信號,GIL 的持有者會在恰當時機檢查該信號,如果發現有其他線程在申請就會強制釋放 GIL。這里所說的恰當時機在不同版本中有所不同,早期是每執行 100 條指令會檢查一次,在 Python 3.10.4 中是在條件語句結束、循環語句的每次循環體結束以及函數調用結束的時候才會去檢查。
申請 GIL 的函數 take_gil() 簡化后如下
static void take_gil(PyThreadState *tstate) { ... // 申請互斥鎖 MUTEX_LOCK(gil->mutex); // 如果 GIL 空閑就直接獲取 if (!_Py_atomic_load_relaxed(&gil->locked)) { goto _ready; } // 嘗試等待 while (_Py_atomic_load_relaxed(&gil->locked)) { unsigned long saved_switchnum = gil->switch_number; unsigned long interval = (gil->interval >= 1 ? gil->interval : 1); int timed_out = 0; COND_TIMED_WAIT(gil->cond, gil->mutex, interval, timed_out); if (timed_out && _Py_atomic_load_relaxed(&gil->locked) && gil->switch_number == saved_switchnum) { SET_GIL_DROP_REQUEST(interp); } } _ready: MUTEX_LOCK(gil->switch_mutex); _Py_atomic_store_relaxed(&gil->locked, 1); _Py_ANNOTATE_RWLOCK_ACQUIRED(&gil->locked, /*is_write=*/1); if (tstate != (PyThreadState*)_Py_atomic_load_relaxed(&gil->last_holder)) { _Py_atomic_store_relaxed(&gil->last_holder, (uintptr_t)tstate); ++gil->switch_number; } // 喚醒強制切換的線程主動等待的條件變量 COND_SIGNAL(gil->switch_cond); MUTEX_UNLOCK(gil->switch_mutex); if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) { RESET_GIL_DROP_REQUEST(interp); } else { COMPUTE_EVAL_BREAKER(interp, ceval, ceval2); } ... // 釋放互斥鎖 MUTEX_UNLOCK(gil->mutex); }
整個函數體為了保證原子性,需要在開頭和結尾分別申請和釋放互斥鎖 gil->mutex。如果當前 GIL 是空閑狀態就直接獲取 GIL,如果不空閑就等待條件變量 gil->cond interval 毫秒(不小于 1 毫秒),如果超時并且期間沒有發生過 GIL 切換就將 gil_drop_request 置位,請求強制切換 GIL 持有線程,否則繼續等待。一旦獲取 GIL 成功需要更新 gil->locked、gil->last_holder 和 gil->switch_number 的值,喚醒條件變量 gil->switch_cond,并且釋放互斥鎖 gil->mutex。
釋放 GIL 的函數 drop_gil() 簡化后如下
static void drop_gil(struct _ceval_runtime_state *ceval, struct _ceval_state *ceval2, PyThreadState *tstate) { ... if (tstate != NULL) { _Py_atomic_store_relaxed(&gil->last_holder, (uintptr_t)tstate); } MUTEX_LOCK(gil->mutex); _Py_ANNOTATE_RWLOCK_RELEASED(&gil->locked, /*is_write=*/1); // 釋放 GIL _Py_atomic_store_relaxed(&gil->locked, 0); // 喚醒正在等待 GIL 的線程 COND_SIGNAL(gil->cond); MUTEX_UNLOCK(gil->mutex); if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request) && tstate != NULL) { MUTEX_LOCK(gil->switch_mutex); // 強制等待一次線程切換才被喚醒,避免饑餓 if (((PyThreadState*)_Py_atomic_load_relaxed(&gil->last_holder)) == tstate) { assert(is_tstate_valid(tstate)); RESET_GIL_DROP_REQUEST(tstate->interp); COND_WAIT(gil->switch_cond, gil->switch_mutex); } MUTEX_UNLOCK(gil->switch_mutex); } }
首先在 gil->mutex 的保護下釋放 GIL,然后喚醒其他正在等待 GIL 的線程。在多 CPU 的環境下,當前線程在釋放 GIL 后有更高的概率重新獲得 GIL,為了避免對其他線程造成饑餓,當前線程需要強制等待條件變量 gil->switch_cond,只有在其他線程獲取 GIL 的時候當前線程才會被喚醒。
受 GIL 約束的代碼不能并行執行,降低了整體性能,為了盡量降低性能損失,Python 在進行 IO 操作或不涉及對象訪問的密集 CPU 計算的時候,會主動釋放 GIL,減小了 GIL 的粒度,比如
讀寫文件
網絡訪問
加密數據/壓縮數據
所以嚴格來說,在單進程的情況下,多個 Python 線程時可能同時執行的,比如一個線程在正常運行,另一個線程在壓縮數據。
GIL 是為了維護 Python 解釋器內部變量的一致性而產生的鎖,用戶數據的一致性不由 GIL 負責。雖然 GIL 在一定程度上也保證了用戶數據的一致性,比如 Python 3.10.4 中不涉及跳轉和函數調用的指令都會在 GIL 的約束下原子性的執行,但是數據在業務邏輯上的一致性需要用戶自己加鎖來保證。
下面的代碼用兩個線程模擬用戶集碎片得獎
from threading import Thread def main(): stat = {"piece_count": 0, "reward_count": 0} t1 = Thread(target=process_piece, args=(stat,)) t2 = Thread(target=process_piece, args=(stat,)) t1.start() t2.start() t1.join() t2.join() print(stat) def process_piece(stat): for i in range(10000000): if stat["piece_count"] % 10 == 0: reward = True else: reward = False if reward: stat["reward_count"] += 1 stat["piece_count"] += 1 if __name__ == "__main__": main()
假設用戶每集齊 10 個碎片就能得到一次獎勵,每個線程收集了 10000000 個碎片,應該得到 9999999 個獎勵(最后一次沒有計算),總共應該收集 20000000 個碎片,得到 1999998 個獎勵,但是在我電腦上一次運行結果如下
{'piece_count': 20000000, 'reward_count': 1999987}
總的碎片數量與預期一致,但是獎勵數量卻少了 12 個。碎片數量正確是因為在 Python 3.10.4 中,stat["piece_count"] += 1 是在 GIL 約束下原子性執行的。由于每次循環結束都可能切換執行線程,那么可能線程 t1 在某次循環結束時將 piece_count 加到 100,但是在下次循環開始模 10 判斷前,Python 解釋器切換到線程 t2 執行,t2 將 piece_count 加到 101,那么就會錯過一次獎勵。
附:如何避免受到GIL的影響
說了那么多,如果不說解決方案就僅僅是個科普帖,然并卵。GIL這么爛,有沒有辦法繞過呢?我們來看看有哪些現成的方案。
用multiprocess替代Thread
multiprocess庫的出現很大程度上是為了彌補thread庫因為GIL而低效的缺陷。它完整的復制了一套thread所提供的接口方便遷移。唯一的不同就是它使用了多進程而不是多線程。每個進程有自己的獨立的GIL,因此也不會出現進程之間的GIL爭搶。
當然multiprocess也不是萬能良藥。它的引入會增加程序實現時線程間數據通訊和同步的困難。就拿計數器來舉例子,如果我們要多個線程累加同一個變量,對于thread來說,申明一個global變量,用thread.Lock的context包裹住三行就搞定了。而multiprocess由于進程之間無法看到對方的數據,只能通過在主線程申明一個Queue,put再get或者用share memory的方法。這個額外的實現成本使得本來就非常痛苦的多線程程序編碼,變得更加痛苦了。具體難點在哪有興趣的讀者可以擴展閱讀這篇文章
用其他解析器
之前也提到了既然GIL只是CPython的產物,那么其他解析器是不是更好呢?沒錯,像JPython和IronPython這樣的解析器由于實現語言的特性,他們不需要GIL的幫助。然而由于用了Java/C#用于解析器實現,他們也失去了利用社區眾多C語言模塊有用特性的機會。所以這些解析器也因此一直都比較小眾。畢竟功能和性能大家在初期都會選擇前者,Done is better than perfect。
所以沒救了么?
當然Python社區也在非常努力的不斷改進GIL,甚至是嘗試去除GIL。并在各個小版本中有了不少的進步。有興趣的讀者可以擴展閱讀這個Slide
另一個改進Reworking the GIL
將切換顆粒度從基于opcode計數改成基于時間片計數
避免最近一次釋放GIL鎖的線程再次被立即調度
新增線程優先級功能(高優先級線程可以迫使其他線程釋放所持有的GIL鎖)
“Python中的GIL怎么實現”的內容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業相關的知識可以關注億速云網站,小編將為大家輸出更多高質量的實用文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。