深入理解python虛擬機GIL詳解
選擇 GIL 的原因
GIL 對 Python 代碼的影響
簡單來說,Python 全局解釋器鎖或 GIL 是一個互斥鎖,只允許一個線程保持 Python 解釋器的控制權,也就是說在同一個時刻只能夠有一個線程執(zhí)行 Python 代碼,如果整個程序是單線程的話,這也無傷大雅,但是如果你的程序是多線程計算密集型的程序的話,這對程序的影響就很大了。
因為整個虛擬機都有一把大鎖進行保護,所以虛擬的代碼就可以認為是單線程執(zhí)行的,因此不需要做線程安全的防護,直接按照單線程的邏輯就行了。不僅僅是虛擬機,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))
在上面的代碼當中,當程序執(zhí)行完之后 len(data) 的值永遠都是 100,sum(data) 的值永遠都是 450,因為上面的代碼是線程安全的,可能你會有所疑惑,上面的代碼啟動了 10 個線程同時往列表當中增加數據,如果兩個線程同時增加數據的時候就有可能存在線程之間覆蓋的情況,最終的 len(data) 的長度應該小于 100 ?
上面的代碼之所以是線程安全的原因是因為 data.append(i) 執(zhí)行 append 只需要虛擬機的一條字節(jié)碼,而在前面介紹 GIL 時候已經談到了,每個時刻只能夠有一個線程在執(zhí)行虛擬機的字節(jié)碼,這就保證了每個 append 的操作都是原子的,因為只有一個 append 操作執(zhí)行完成之后其他的線程才能夠執(zhí)行 append 操作。
我們來看一下上面程序的字節(jié)碼:
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在上面的字節(jié)碼當中 data.append(i) 對應的字節(jié)碼為 (14, 16, 18) 這三條字節(jié)碼,而 (14, 16) 是不會產生數據競爭的問題的,因為他只是加載對象的方法和局部變量 i 的值,讓 append 執(zhí)行的方法是字節(jié)碼 CALL_METHOD,而同一個時刻只能夠有一個字節(jié)碼在執(zhí)行,因此這條字節(jié)碼也是線程安全的,所以才會有上面的代碼是線程安全的情況出現。
我們再來看一個非線程安全的例子:
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 條字節(jié)碼:]
9 12 LOAD_GLOBAL 1 (data)
14 LOAD_CONST 1 (1)
16 INPLACE_ADD首先 LOAD_GLOBAL,加載 data 數據,LOAD_CONST 加載常量 1,最后執(zhí)行 INPLACE_ADD 進行加法操作,這就可能出現線程1執(zhí)行完 LOAD_GLOBAL 之后,線程 2 連續(xù)執(zhí)行 3 條字節(jié)碼,那么這個時候 data 的值已經發(fā)生變化了,而線程 1 拿的還是舊的數據,因此最終執(zhí)行的之后會出現線程不安全的情況。(實際上虛擬機在執(zhí)行的過程當中,發(fā)生數據競爭比這個復雜很多,這里只是簡單說明一下)
GIL 對于虛擬機的影響
除了上面 GIL 對于 Python 代碼層面的影響,GIL 對于虛擬機來說還有一個非常好的作用就是他不會讓虛擬機產生死鎖的現象,因為整個虛擬機只有一把鎖??。
對于虛擬機的內存管理和垃圾回收來說,GIL 可以說極大的簡化了 CPython 內部的內存管理和垃圾回收的實現。我們現在舉一個內存管理和垃圾回收的多線程情況會出現數據競爭的場景:
在 Python 當中的垃圾回收是采用引用計數的方式進行處理,如果沒有 GIL 那么就會存在多個線程同時對一個 CPython 對象的引用計數進行增加,而現在因為 GIL 的存在也就不需要進行考慮這個問題了。
另外一個比較重要的場景就是內存的申請和釋放:在虛擬機內部并不是直接調用 malloc 進行實現的,在 CPython 內部自己實現了一個內存池進行內存的申請和釋放(這么做的原因主要是節(jié)省內存),因為是自己實現內存池,因此需要保證線程安全,而現在因為有 GIL 的存在,虛擬機實現內存池只需要管單線程的情況,所以使得整個內存管理變得更加簡單。
GIL 對與 Python 的第三方 C 庫開發(fā)人員來說也是非常友好的,當他們在進行第三方庫開發(fā)的時候不需要去考慮在修改 CPython 對象的線程安全問題,因為已經有 GIL 了。從這個角度來說 GIL 在一定程度上推動了 Python 的發(fā)展和普及。
GIL 帶來的問題
GIL 帶來的最主要的問題就是當你的程序是計算密集型的時候,比如數學計算、圖像處理,GIL 就會帶來性能問題,因為他無法在同一個時刻跑多個線程。
之所以沒有在 Python 當中刪除 GIL,最主要的原因就是目前很多 CPython 第三方庫是依賴 GIL 這個特性的,如果直接在虛擬機層面移除 GIL,就會破壞 CPython C-API 的兼容性,這會導致很多依賴 GIL 的第三方 C 庫發(fā)生錯誤。而向后兼容這個特性對于社區(qū)來說非常重要,這就是目前 CPython 還保留 GIL 最主要的原因。
GIL 源代碼分析
在本小節(jié)當中為了更好的說明 GIL 的設計和源代碼分析,本小節(jié)使用 CPython2.7.6 的 GIL 源代碼進行分析(這種實現方式在 Python 3.2 以后被優(yōu)化改進了,在本文當中先不提及),我還翻了一下更早的 CPython 源代碼,都是使用這種方式實現的,可能細節(jié)方面可以會有點差異,我們現在來分析一下 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,就將線程阻塞(執(zhí)行pthread_cond_wait),當線程執(zhí)行釋放鎖的代碼 (PyThread_release_lock) 的時候就會將這個被阻塞的線程喚醒(執(zhí)行 pthread_cond_signal )。
mut,這個主要是進行臨界區(qū)保護的,因為對于 locked 這個變量的訪問是線程不安全的,因此需要用鎖進行保護。
在上面的代碼當中我們詳細介紹了 GIL 的實現源代碼,但是還沒有介紹虛擬機是如何使用它的。虛擬機在使用 GIL 的時候會有一個問題,那就是如果多個線程同時在虛擬機當中跑的時候,一個線程獲取到鎖了之后如果一直執(zhí)行的話,那么其他線程不久饑餓了嗎?因此虛擬機需要有一種機制保證當有多個線程同時獲取鎖的時候不會讓線程饑餓。
在 CPython 當中為了不讓線程饑餓有一個機制,就是虛擬機會有一個 _Py_Ticker 記錄當前線程執(zhí)行的字節(jié)碼的個數,讓執(zhí)行的字節(jié)碼個數超過 _Py_CheckInterval (虛擬機這只這個值為 100) 的時候就會釋放鎖,然后重新獲取鎖,在這釋放和獲取之間就能夠讓其他線程有機會獲得鎖從而進行字節(jié)碼的執(zhí)行過程。相關的源代碼如下所示:
if (--_Py_Ticker < 0) { // 每執(zhí)行完一個字節(jié)碼就進行 -- 操作,這個值初始化為 _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 的原理,我們可以很明顯的發(fā)現其中的問題,就是一個時刻只有一個線程在運行,限制了整個虛擬機的性能,但是整個虛擬機還有一個地方可以極大的提高整個虛擬機的性能,就是在進行 IO 操作的時候首先釋放 GIL,然后在 IO 操作完成之后重新獲取 GIL,這個 IO 操作是廣義上的 IO 操作,也包括網絡相關的 API,只要和設備進行交互就可以釋放 GIL,然后操作執(zhí)行完成之后重新獲取 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 可以很大程度上簡化虛擬機的設計與實現,因為有一把全局鎖,整個虛擬機的開發(fā)就會變得更加簡單,這種簡單對于大型項目來說是非常重要的。同時這對 CPython 第三方庫的開發(fā)者來說也是福音。最后討論了 CPython 當中 GIL 的實現和使用方式以及 CPython 使用 ticker 來保證線程不會饑餓的問題。
以上就是深入理解python虛擬機GIL詳解的詳細內容,更多關于python虛擬機GIL的資料請關注腳本之家其它相關文章!
相關文章
淺談python中的@以及@在tensorflow中的作用說明
這篇文章主要介紹了淺談python中的@以及@在tensorflow中的作用說明,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-03-03
python實現多進程并發(fā)控制Semaphore與互斥鎖LOCK
本文主要介紹了python實現多進程并發(fā)控制Semaphore與互斥鎖LOCK,通過實例來介紹互斥鎖和進程并發(fā)控制 semaphore的具體使用,感興趣的同學可以了解一下2021-05-05
python多進程執(zhí)行方法apply_async使用說明
這篇文章主要介紹了python多進程執(zhí)行方法apply_async使用說明,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-03-03

