python GIL,
今日得到: 三人行,必有我師焉,擇其善者而從之,其不善者而改之。
今日看源碼才了解到現在已經是2020年了,而在2010年的時候,大佬David Beazley就做了講座講解Python GIL的設計相關問題,10年間相信也在不斷改善和優化,但是并沒有将GIL從CPython中移除,可想而知,GIL已經深入CPython,難以移除。就目前來看,工作中常用的還是協程,多線程來處理高并發的I/O密集型任務。CPU密集型的大型計算可以用其他語言來實作。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsISM9AnYldnJwAzN9c3PnBnauQ0MlQ0MlcnW3BXbMNjQq10M4YUT4FkaNFTUU90dRR1Ts50VNBzaq50aSRlWwkFVPRTUqllMJd0TpZEVPlHMp1kM5MUT0gzUiZnTtxkbxcVYvBnbMlXTXF2d5kHT20ESjBjUIF2Lc12bj5SYphXa5VWen5WY35iclN3Ztl2Lc9CX6MHc0RHaiojIsJye.jpg)
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.) ----- Global Interpreter Lock
為了防止多線程共享記憶體出現競态問題,設定的防止多線程并發執行機器碼的一個Mutex。
2. python32 之前-基于opcode數量的排程方式
在python3.2版本之前,定義了一個tick計數器,表示目前線程在釋放gil之前連續執行的多少個位元組碼(實際上有部分執行較快的位元組碼并不會被計入計數器)。如果目前的線程正在執行一個 CPU 密集型的任務, 它會在 tick 計數器到達 100 之後就釋放 gil, 給其他線程一個獲得 gil 的機會。
(圖檔來自 Understanding the Python GIL(youtube))
以opcode個數為基準來計數,如果有些opcode代碼複雜耗時較長,一些耗時較短,會導緻同樣的100個tick,一些線程的執行時間總是執行的比另一些長。是不公平的排程政策。
(圖檔來自Understanding-the-python-gil)
如果目前的線程正在執行一個 IO密集型的 的任務, 你執行
sleep/recv/send(...etc)
這些會阻塞的系統調用時, 即使 tick 計數器的值還沒到 100, gil 也會被主動地釋放。至于下次該執行哪一個線程這個是作業系統層面的,線程排程算法優先級排程,開發者沒辦法控制。
在多核機器上, 如果兩個線程都在執行 CPU 密集型的任務, 作業系統有可能讓這兩個線程在不同的核心上運作, 也許會出現以下的情況, 當一個擁有了 gil 的線程在一個核心上執行 100 次 tick 的過程中, 在另一個核心上運作的線程頻繁的進行搶占 gil, 搶占失敗的循環, 導緻 CPU 瞎忙影響性能。 如下圖:綠色部分表示該線程在運作,且在執行有用的計算,紅色部分為線程被排程喚醒,但是無法擷取GIL導緻無法進行有效運算等待的時間。
由圖可見,GIL的存在導緻多線程無法很好的利用多核CPU的并發處理能力。
3. python3.2 之後-基于時間片的切換
由于在多核機器下可能導緻性能下降, gil的實作在python3.2之後做了一些優化 。python在初始化解釋器的時候就會初始化一個gil,并設定一個
DEFAULT_INTERVAL=5000, 機關是微妙,即0.005秒(在 C 裡面是用 微秒 為機關存儲, 在 python 解釋器中以秒來表示)
這個間隔就是GIL切換的标志。
// Python\ceval_gil.h
#define DEFAULT_INTERVAL 5000
static void _gil_initialize(struct _gil_runtime_state *gil)
{
_Py_atomic_int uninitialized = {-1};
gil->locked = uninitialized;
gil->interval = DEFAULT_INTERVAL;
}
python中檢視gil切換的時間
In [7]: import sys
In [8]: sys.getswitchinterval()
Out[8]: 0.005
如果目前有不止一個線程, 目前等待 gil 的線程在超過一定時間的等待後, 會把全局變量 gil_drop_request 的值設定為 1, 之後繼續等待相同的時間, 這時擁有 gil 的線程看到了 gil_drop_request 變為 1, 就會主動釋放 gil 并通過
condition variable
通知到在等待中的線程, 第一個被喚醒的等待中的線程會搶到 gil 并執行相應的任務, 将gil_drop_request設定為1的線程不一定能搶到gil
4 condition variable相關字段
- locked : locked 的類型是
, 值-1表示還未初始化,0表示目前的gil處于釋放狀态,1表示某個線程已經占用了gil,這個值的類型設定為原子類型之後在_Py_atomic_int
就可以不加鎖的對這個值進行讀取。ceval.c
- interval:是線程在設定
這個變量之前需要等待的時長,預設是5000毫秒gil_drop_request
- last_holder:存放了最後一個持有 gil 的線程的 C 中對應的 PyThreadState 結構的指針位址, 通過這個值我們可以知道目前線程釋放了 gil 後, 是否有其他線程獲得了 gil(可以采取措施避免被自己重新獲得)
- switch_number: 是一個計數器, 表示從解釋器運作到現在, gil 總共被釋放獲得多少次
- mutex:是一把互斥鎖, 用來保護
,locked
,last_holder
還有switch_number
中的其他變量_gil_runtime_state
- cond:是一個 condition variable, 和 mutex 結合起來一起使用, 目前線程釋放 gil 時用來給其他等待中的線程發送信号
- ** switch_cond and switch_mutex**
switch_cond 是另一個 condition variable, 和 switch_mutex 結合起來可以用來保證釋放後重新獲得 gil 的線程不是同一個前面釋放 gil 的線程, 避免 gil 切換時線程未切換浪費 cpu 時間
這個功能如果編譯時未定義
FORCE_SWITCHING
則不開啟
static void
drop_gil(struct _ceval_runtime_state *ceval, PyThreadState *tstate)
{
...
#ifdef FORCE_SWITCHING
if (_Py_atomic_load_relaxed(&ceval->gil_drop_request) && tstate != NULL) {
MUTEX_LOCK(gil->switch_mutex);
/* Not switched yet => wait */
if (((PyThreadState*)_Py_atomic_load_relaxed(&gil->last_holder)) == tstate)
{
/* 如果 last_holder 是目前線程, 釋放 switch_mutex 這把互斥鎖, 等待 switch_cond 這個條件變量的信号 */
RESET_GIL_DROP_REQUEST(ceval);
/* NOTE: if COND_WAIT does not atomically start waiting when
releasing the mutex, another thread can run through, take
the GIL and drop it again, and reset the condition
before we even had a chance to wait for it. */
/* 注意, 如果 COND_WAIT 不在互斥鎖釋放後原子的啟動,
另一個線程有可能會在這中間拿到 gil 并釋放,
'并且重置這個條件變量, 這個過程發生在了 COND_WAIT 之前 */
COND_WAIT(gil->switch_cond, gil->switch_mutex);
}
MUTEX_UNLOCK(gil->switch_mutex);
}
#endif
}
4. gil在main_loop中的展現
//
main_loop:
for (;;) {
/* 如果 gil_drop_request 被其他線程設定為 1 */
/* 給其他線程一個獲得 gil 的機會 */
if (_Py_atomic_load_relaxed(&ceval->gil_drop_request)) {
/* Give another thread a chance */
if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) {
Py_FatalError("ceval: tstate mix-up");
}
drop_gil(ceval, tstate);
/* Other threads may run now */
take_gil(ceval, tstate);
/* Check if we should make a quick exit. */
exit_thread_if_finalizing(runtime, tstate);
if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) {
Py_FatalError("ceval: orphan tstate");
}
}
/* Check for asynchronous exceptions. */
/* 忽略 */
fast_next_opcode:
switch (opcode) {
case TARGET(NOP): {
FAST_DISPATCH();
}
/* 忽略 */
case TARGET(UNARY_POSITIVE): {
PyObject *value = TOP();
PyObject *res = PyNumber_Positive(value);
Py_DECREF(value);
SET_TOP(res);
if (res == NULL)
goto error;
DISPATCH();
}
/* 忽略 */
}
/* 忽略 */
}
這個很大的
for loop
會按順序逐個的加載 opcode, 并委派給中間很大的
switch statement
去進行執行,
switch statement
會根據不同的 opcode 跳轉到不同的位置執行
for loop
在開始位置會檢查
gil_drop_request
變量, 必要的時候會釋放
gil
不是所有的 opcode 執行之前都會檢查
gil_drop_request
的, 有一些 opcode 結束時的代碼為
FAST_DISPATCH()
, 這部分 opcode 會直接跳轉到下一個 opcode 對應的代碼的部分進行執行
而另一些
DISPATCH()
結尾的作用和
continue
類似, 會跳轉到
for loop
頂端, 重新檢測
gil_drop_request
, 必要時釋放
gil
。
5 如何解決GIL
GIL隻會對CPU密集型的程式産生影響,規避GIL限制主要有兩種常用政策:一是使用多程序,二是使用C語言擴充,把計算密集型的任務轉移到C語言中,使其獨立于Python,在C代碼中釋放GIL。當然也可以使用其他語言編譯的解釋器如
Jpython
、
PyPy
。
6.總結
- Python語言和GIL沒有半毛錢關系,僅僅是由于曆史原因在CPython解釋器中難以移除GIL
- GIL:全局解釋器鎖,每個線程在執行的過程都需要先擷取GIL,確定同一時刻僅有一個線程執行代碼,是以python的線程無法利用多核。
- 線程在I/O操作等可能引起阻塞的system call之前,可以暫時釋放GIL,執行完畢後重新擷取GIL,python3.2以後使用時間片來切換線程,時間門檻值是0.005秒,而python3.2之前是使用opcode執行的數量(tick=100)來切換的。
- Python的多線程在多核CPU上,隻對于IO密集型計算産生正面效果;而當有至少有一個CPU密集型線程存在,那麼多線程效率會由于GIL而大幅下降
參考
Cpython-gil講解-zpoint
Python的GIL是什麼鬼-盧鈞轶(cenalulu)
Youtube-Understanding the Python GIL