首頁  >  文章  >  後端開發  >  Python中的GIL是什麼

Python中的GIL是什麼

王林
王林轉載
2023-05-14 14:40:061623瀏覽

為什麼需要 GIL

GIL 本質上是一把鎖,學過作業系統的同學都知道鎖的引入是為了避免並發存取造成資料的不一致。 CPython 中有很多定義在函數外面的全域變量,例如記憶體管理中的 usable_arenas 和 usedpools,如果多個執行緒同時申請記憶體就可能同時修改這些變量,造成資料錯亂。另外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->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 的執行緒。在多CP​​U 的環境下,當前線程在釋放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的影響

說了那麼多,如果不說解決方案就只是個科普帖,然並卵。 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計數改成基於時間片計數

#&ndash ; 避免最近一次釋放GIL鎖定的執行緒再次被立即調度

– 新增執行緒優先權功能(高優先權執行緒可以迫使其他執行緒釋放所持有的GIL鎖定)

以上是Python中的GIL是什麼的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:yisu.com。如有侵權,請聯絡admin@php.cn刪除