深入理解 python 虛擬機器:GIL 原始碼分析——天使還是魔鬼?

一無是處的研究僧發表於2023-10-14

深入理解 python 虛擬機器:GIL 原始碼分析——天使還是魔鬼?

在目前的 CPython 當中一直有一個臭名昭著的問題就是 GIL (Global Interpreter Lock ),就是全域性直譯器鎖,他限制了 Python 在多核架構當中的效能,在本篇文章當中我們將詳細分析一下 GIL 的利弊和 GIL 的 C 的原始碼。

選擇 GIL 的原因

GIL 對 Python 程式碼的影響

簡單來說,Python 全域性直譯器鎖或 GIL 是一個互斥鎖,只允許一個執行緒保持 Python 直譯器的控制權,也就是說在同一個時刻只能夠有一個執行緒執行 Python 程式碼,如果整個程式是單執行緒的話,這也無傷大雅,但是如果你的程式是多執行緒計算密集型的程式的話,這對程式的影響就很大了。

因為整個虛擬機器都有一把大鎖進行保護,所以虛擬的程式碼就可以認為是單執行緒執行的,因此不需要做執行緒安全的防護,直接按照單執行緒的邏輯就行了。不僅僅是虛擬機器,Python 層面的程式碼也是這樣,對於有些 Python 層面的多執行緒程式碼也可以不用鎖保護,因為本身就是執行緒安全的:

import threading

data = []


def add_data(n):
	for i in range(n):
		data.append(i)


if __name__ == '__main__':
	ts = [threading.Thread(target=add_data, args=(10,)) for _ in range(10)]
	for t in ts:
		t.start()
	for t in ts:
		t.join()

	print(data)
	print(len(data))
	print(sum(data))

在上面的程式碼當中,當程式執行完之後 len(data) 的值永遠都是 100,sum(data) 的值永遠都是 450,因為上面的程式碼是執行緒安全的,可能你會有所疑惑,上面的程式碼啟動了 10 個執行緒同時往列表當中增加資料,如果兩個執行緒同時增加資料的時候就有可能存線上程之間覆蓋的情況,最終的 len(data) 的長度應該小於 100 ?

上面的程式碼之所以是執行緒安全的原因是因為 data.append(i) 執行 append 只需要虛擬機器的一條位元組碼,而在前面介紹 GIL 時候已經談到了,每個時刻只能夠有一個執行緒在執行虛擬機器的位元組碼,這就保證了每個 append 的操作都是原子的,因為只有一個 append 操作執行完成之後其他的執行緒才能夠執行 append 操作。

我們來看一下上面程式的位元組碼:

  5           0 LOAD_GLOBAL              0 (range)
              2 LOAD_FAST                0 (n)
              4 CALL_FUNCTION            1
              6 GET_ITER
        >>    8 FOR_ITER                14 (to 24)
             10 STORE_FAST               1 (i)

  6          12 LOAD_GLOBAL              1 (data)
             14 LOAD_METHOD              2 (append)
             16 LOAD_FAST                1 (i)
             18 CALL_METHOD              1
             20 POP_TOP
             22 JUMP_ABSOLUTE            8
        >>   24 LOAD_CONST               0 (None)
             26 RETURN_VALUE

在上面的位元組碼當中 data.append(i) 對應的位元組碼為 (14, 16, 18) 這三條位元組碼,而 (14, 16) 是不會產生資料競爭的問題的,因為他只是載入物件的方法和區域性變數 i 的值,讓 append 執行的方法是位元組碼 CALL_METHOD,而同一個時刻只能夠有一個位元組碼在執行,因此這條位元組碼也是執行緒安全的,所以才會有上面的程式碼是執行緒安全的情況出現。

我們再來看一個非執行緒安全的例子:

import threading
data = 0
def add_data(n):
	global data
	for i in range(n):
		data += 1

if __name__ == '__main__':
	ts = [threading.Thread(target=add_data, args=(100000,)) for _ in range(20)]
	for t in ts:
		t.start()
	for t in ts:
		t.join()
	print(data)

在上面的程式碼當中對於 data += 1 這個操作就是非執行緒安全的,因為這行程式碼彙編編譯成 3 條位元組碼:

  9          12 LOAD_GLOBAL              1 (data)
             14 LOAD_CONST               1 (1)
             16 INPLACE_ADD

首先 LOAD_GLOBAL,載入 data 資料,LOAD_CONST 載入常量 1,最後執行 INPLACE_ADD 進行加法操作,這就可能出現執行緒1執行完 LOAD_GLOBAL 之後,執行緒 2 連續執行 3 條位元組碼,那麼這個時候 data 的值已經發生變化了,而執行緒 1 拿的還是舊的資料,因此最終執行的之後會出現執行緒不安全的情況。(實際上虛擬機器在執行的過程當中,發生資料競爭比這個複雜很多,這裡只是簡單說明一下)

GIL 對於虛擬機器的影響

除了上面 GIL 對於 Python 程式碼層面的影響,GIL 對於虛擬機器來說還有一個非常好的作用就是他不會讓虛擬機器產生死鎖的現象,因為整個虛擬機器只有一把鎖?。

對於虛擬機器的記憶體管理和垃圾回收來說,GIL 可以說極大的簡化了 CPython 內部的記憶體管理和垃圾回收的實現。我們現在舉一個記憶體管理和垃圾回收的多執行緒情況會出現資料競爭的場景:

在 Python 當中的垃圾回收是採用引用計數的方式進行處理,如果沒有 GIL 那麼就會存在多個執行緒同時對一個 CPython 物件的引用計數進行增加,而現在因為 GIL 的存在也就不需要進行考慮這個問題了。

另外一個比較重要的場景就是記憶體的申請和釋放:在虛擬機器內部並不是直接呼叫 malloc 進行實現的,在 CPython 內部自己實現了一個記憶體池進行記憶體的申請和釋放(這麼做的原因主要是節省記憶體),因為是自己實現記憶體池,因此需要保證執行緒安全,而現在因為有 GIL 的存在,虛擬機器實現記憶體池只需要管單執行緒的情況,所以使得整個記憶體管理變得更加簡單。

GIL 對與 Python 的第三方 C 庫開發人員來說也是非常友好的,當他們在進行第三方庫開發的時候不需要去考慮在修改 CPython 物件的執行緒安全問題,因為已經有 GIL 了。從這個角度來說 GIL 在一定程度上推動了 Python 的發展和普及。

GIL 帶來的問題

GIL 帶來的最主要的問題就是當你的程式是計算密集型的時候,比如數學計算、影像處理,GIL 就會帶來效能問題,因為他無法在同一個時刻跑多個執行緒。

之所以沒有在 Python 當中刪除 GIL,最主要的原因就是目前很多 CPython 第三方庫是依賴 GIL 這個特性的,如果直接在虛擬機器層面移除 GIL,就會破壞 CPython C-API 的相容性,這會導致很多依賴 GIL 的第三方 C 庫發生錯誤。而向後相容這個特性對於社群來說非常重要,這就是目前 CPython 還保留 GIL 最主要的原因。

GIL 原始碼分析

在本小節當中為了更好的說明 GIL 的設計和原始碼分析,本小節使用 CPython2.7.6 的 GIL 原始碼進行分析(這種實現方式在 Python 3.2 以後被最佳化改進了,在本文當中先不提及),我還翻了一下更早的 CPython 原始碼,都是使用這種方式實現的,可能細節方面可以會有點差異,我們現在來分析一下 GIL 具體是如何實現的,下面的程式碼是一 GIL 加鎖和解鎖的程式碼以及鎖的資料結構表示:

// PyThread_type_lock 就是 void* 的 typedef
void 
PyThread_release_lock(PyThread_type_lock lock)
{
	pthread_lock *thelock = (pthread_lock *)lock;
	int status, error = 0;
  // dprintf 一個宏定義 都是列印訊息的,不需要關心,而且預設是不列印
	dprintf(("PyThread_release_lock(%p) called\n", lock));
  // 上鎖
	status = pthread_mutex_lock( &thelock->mut );
	CHECK_STATUS("pthread_mutex_lock[3]");
  // 釋放全域性直譯器鎖
	thelock->locked = 0;
  // 解鎖
	status = pthread_mutex_unlock( &thelock->mut );
	CHECK_STATUS("pthread_mutex_unlock[3]");
  // 因為釋放了全域性直譯器鎖,現在需要喚醒一個被阻塞的執行緒
	/* wake up someone (anyone, if any) waiting on the lock */
	status = pthread_cond_signal( &thelock->lock_released );
	CHECK_STATUS("pthread_cond_signal");
}

// waitflag 表示如果沒有獲取鎖是否需要等待,如果不為 0 就表示沒獲取鎖就等待,即執行緒被掛起
int 
PyThread_acquire_lock(PyThread_type_lock lock, int waitflag)
{
	int success;
	pthread_lock *thelock = (pthread_lock *)lock;
	int status, error = 0;

	dprintf(("PyThread_acquire_lock(%p, %d) called\n", lock, waitflag));

	status = pthread_mutex_lock( &thelock->mut );
	CHECK_STATUS("pthread_mutex_lock[1]");
	success = thelock->locked == 0;
  // 如果沒有上鎖,則獲取鎖成功,並且上鎖
	if (success) thelock->locked = 1;
	status = pthread_mutex_unlock( &thelock->mut );
	CHECK_STATUS("pthread_mutex_unlock[1]");

	if ( !success && waitflag ) {
		/* continue trying until we get the lock */

		/* mut must be locked by me -- part of the condition
		 * protocol */
		status = pthread_mutex_lock( &thelock->mut );
		CHECK_STATUS("pthread_mutex_lock[2]");
    // 如果現在已經有執行緒獲取到鎖了,就將當前執行緒掛起
		while ( thelock->locked ) {
			status = pthread_cond_wait(&thelock->lock_released,
						   &thelock->mut);
			CHECK_STATUS("pthread_cond_wait");
		}
    // 當執行緒被喚醒之後,就說明執行緒只有當前執行緒在執行可以直接獲取鎖
		thelock->locked = 1;
		status = pthread_mutex_unlock( &thelock->mut );
		CHECK_STATUS("pthread_mutex_unlock[2]");
		success = 1;
	}
	if (error) success = 0;
	dprintf(("PyThread_acquire_lock(%p, %d) -> %d\n", lock, waitflag, success));
	return success;
}

pthread_lock 的結構體如下所示:

其中鎖的結構體如下所示:

typedef struct {
	char             locked; /* 0=unlocked, 1=locked */
	/* a <cond, mutex> pair to handle an acquire of a locked lock */
	pthread_cond_t   lock_released;
	pthread_mutex_t  mut;
} pthread_lock;

熟悉 pthread 程式設計的話,上面的程式碼應該很輕易可以看懂,我們現在來分析一下這個資料結構:

  • locked,表示全域性直譯器鎖 GIL 是否有執行緒獲得鎖,0 表示沒有,1 則表示目前有執行緒獲取到了這把鎖。

  • lock_released,主要是用於執行緒的阻塞和喚醒的,如果當前有執行緒獲取到全域性直譯器鎖了,也就是 locked 的值等於 1,就將執行緒阻塞(執行pthread_cond_wait),當執行緒執行釋放鎖的程式碼 (PyThread_release_lock) 的時候就會將這個被阻塞的執行緒喚醒(執行 pthread_cond_signal )。

  • mut,這個主要是進行臨界區保護的,因為對於 locked 這個變數的訪問是執行緒不安全的,因此需要用鎖進行保護。

在上面的程式碼當中我們詳細介紹了 GIL 的實現原始碼,但是還沒有介紹虛擬機器是如何使用它的。虛擬機器在使用 GIL 的時候會有一個問題,那就是如果多個執行緒同時在虛擬機器當中跑的時候,一個執行緒獲取到鎖了之後如果一直執行的話,那麼其他執行緒不久飢餓了嗎?因此虛擬機器需要有一種機制保證當有多個執行緒同時獲取鎖的時候不會讓執行緒飢餓。

在 CPython 當中為了不讓執行緒飢餓有一個機制,就是虛擬機器會有一個 _Py_Ticker 記錄當前執行緒執行的位元組碼的個數,讓執行的位元組碼個數超過 _Py_CheckInterval (虛擬機器這隻這個值為 100) 的時候就會釋放鎖,然後重新獲取鎖,在這釋放和獲取之間就能夠讓其他執行緒有機會獲得鎖從而進行位元組碼的執行過程。相關的原始碼如下所示:

if (--_Py_Ticker < 0) { // 每執行完一個位元組碼就進行 -- 操作,這個值初始化為 _Py_CheckInterval
    if (*next_instr == SETUP_FINALLY) {
        /* Make the last opcode before
           a try: finally: block uninterruptible. */
        goto fast_next_opcode;
    }
    _Py_Ticker = _Py_CheckInterval; // 重新將這個值設定成 100
    tstate->tick_counter++;
#ifdef WITH_TSC
    ticked = 1;
#endif
    // 這個主要是處理異常訊號的 不用管
    if (pendingcalls_to_do) {
        if (Py_MakePendingCalls() < 0) {
            why = WHY_EXCEPTION;
            goto on_error;
        }
        if (pendingcalls_to_do)
            /* MakePendingCalls() didn't succeed.
               Force early re-execution of this
               "periodic" code, possibly after
               a thread switch */
            _Py_Ticker = 0;
    }
#ifdef WITH_THREAD
    // 如果有 GIL 存在
    if (interpreter_lock) {
        /* Give another thread a chance */

        if (PyThreadState_Swap(NULL) != tstate)
            Py_FatalError("ceval: tstate mix-up");
        PyThread_release_lock(interpreter_lock); // 首先釋放鎖
        /* 其他執行緒的程式碼在這就能夠執行了 */
        /* Other threads may run now */
        // 然後獲取鎖
        PyThread_acquire_lock(interpreter_lock, 1);
        if (PyThreadState_Swap(tstate) != NULL)
            Py_FatalError("ceval: orphan tstate");
    }
#endif
}

GIL 的掙扎

在上面的內容當中我們詳細講述了 GIL 的原理,我們可以很明顯的發現其中的問題,就是一個時刻只有一個執行緒在執行,限制了整個虛擬機器的效能,但是整個虛擬機器還有一個地方可以極大的提高整個虛擬機器的效能,就是在進行 IO 操作的時候首先釋放 GIL,然後在 IO 操作完成之後重新獲取 GIL,這個 IO 操作是廣義上的 IO 操作,也包括網路相關的 API,只要和裝置進行互動就可以釋放 GIL,然後操作執行完成之後重新獲取 GIL。

在虛擬機器的自帶的標準庫模組當中,就有很多地方使用了這種方法,比如檔案的讀寫和關閉,我們以檔案關閉為例看一下 CPython 是如何操作的:

static int
internal_close(fileio *self)
{
    int err = 0;
    int save_errno = 0;
    if (self->fd >= 0) {
        int fd = self->fd;
        self->fd = -1;
        /* fd is accessible and someone else may have closed it */
        if (_PyVerify_fd(fd)) {
            // 釋放全域性直譯器鎖 這是一個宏 會呼叫前面的釋放鎖的函式
            Py_BEGIN_ALLOW_THREADS
            err = close(fd);
            if (err < 0)
                save_errno = errno;
            // 重新獲取全域性直譯器鎖 也是一個宏 會呼叫前面的獲取鎖的函式
            Py_END_ALLOW_THREADS
        } else {
            save_errno = errno;
            err = -1;
        }
    }
    if (err < 0) {
        errno = save_errno;
        PyErr_SetFromErrno(PyExc_IOError);
        return -1;
    }
    return 0;
}

這就會使得 Python 雖然有 GIL ,但是在 IO 密集型的程式上還是能打的,比如在網路資料採集等領域, Python 還是有很大的比重。

總結

在本篇文章當中詳細介紹了 CPython 選擇 GIL 的原因,以及 GIL 對於 Python 程式和虛擬機器的影響,最後詳細分析了一個早期版本的 GIL 原始碼實現。GIL 可以很大程度上簡化虛擬機器的設計與實現,因為有一把全域性鎖,整個虛擬機器的開發就會變得更加簡單,這種簡單對於大型專案來說是非常重要的。同時這對 CPython 第三方庫的開發者來說也是福音。最後討論了 CPython 當中 GIL 的實現和使用方式以及 CPython 使用 ticker 來保證執行緒不會飢餓的問題。


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演演算法與資料結構)知識。

相關文章