對 Python 中 GIL 的一點理解

zikcheng發表於2022-05-28

GIL(Global Interpreter Lock),全域性直譯器鎖,是 CPython 為了避免在多執行緒環境下造成 Python 直譯器內部資料的不一致而引入的一把鎖,讓 Python 中的多個執行緒交替執行,避免競爭。

需要說明的是 GIL 不是 Python 語言規範的一部分,只是由於 CPython 實現的需要而引入的,其他的實現如 Jython 和 PyPy 是沒有 GIL 的。那麼為什麼 CPython 需要 GIL 呢,下面我們就來一探究竟(基於 CPython 3.10.4)。

為什麼需要 GIL

GIL 本質上是一把鎖,學過作業系統的同學都知道鎖的引入是為了避免併發訪問造成資料的不一致。CPython 中有很多定義在函式外面的全域性變數,比如記憶體管理中的 usable_arenasusedpools,如果多個執行緒同時申請記憶體就可能同時修改這些變數,造成資料錯亂。另外 Python 的垃圾回收機制是基於引用計數的,所有物件都有一個 ob_refcnt 欄位表示當前有多少變數會引用當前物件,變數賦值、引數傳遞等操作都會增加引用計數,退出作用域或函式返回會減少引用計數。同樣地,如果有多個執行緒同時修改同一個物件的引用計數,就有可能使 ob_refcnt 與真實值不同,可能會造成記憶體洩漏,不會被使用的物件得不到回收,更嚴重可能會回收還在被引用的物件,造成 Python 直譯器崩潰。

GIL 的實現

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->lockedgil->last_holdergil->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 優化

受 GIL 約束的程式碼不能並行執行,降低了整體效能,為了儘量降低效能損失,Python 在進行 IO 操作或不涉及物件訪問的密集 CPU 計算的時候,會主動釋放 GIL,減小了 GIL 的粒度,比如

  • 讀寫檔案
  • 網路訪問
  • 加密資料/壓縮資料

所以嚴格來說,在單程式的情況下,多個 Python 執行緒時可能同時執行的,比如一個執行緒在正常執行,另一個執行緒在壓縮資料。

使用者資料的一致性不能依賴 GIL

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 是 CPython 為了在多執行緒環境下為了維護直譯器內部資料一致性而引入的,為了儘可能降低 GIL 的粒度,在 IO 操作和不涉及物件訪問的 CPU 計算時會主動釋放 GIL。最後,使用者資料的一致性不能依賴 GIL,可能需要使用者使用 LockRLock() 來保證資料的原子性訪問。

參考文件

https://realpython.com/python-gil/
https://github.com/python/cpython/commit/074e5ed974be65fbcfe75a4c0529dbc53f13446f
https://mail.python.org/pipermail/python-dev/2009-October/093321.html
https://www.backblaze.com/blog/the-python-gil-past-present-and-future/

相關文章