python解释器的工作原理_Python GIL全局解释器锁详解(深度剖析)
通過前面的學(xué)習(xí),我們了解了 Pyton 并發(fā)編程的特性以及什么是多線程編程。其實除此之外,Python 多線程還有一個很重要的知識點,就是本節(jié)要講的 GIL。
GIL,中文譯為全局解釋器鎖。在講解 GIL 之前,首先通過一個例子來直觀感受一下 GIL 在 Python 多線程程序運行的影響。
首先運行如下程序:
import time
start = time.clock()
def CountDown(n):
while n > 0:
n -= 1
CountDown(100000)
print("Time used:",(time.clock() - start))
運行結(jié)果為:
Time used: 0.0039529000000000005
在我們的印象中,使用多個(適量)線程是可以加快程序運行效率的,因此可以嘗試將上面程序改成如下方式:
import time
from threading import Thread
start = time.clock()
def CountDown(n):
while n > 0:
n -= 1
t1 = Thread(target=CountDown, args=[100000 // 2])
t2 = Thread(target=CountDown, args=[100000 // 2])
t1.start()
t2.start()
t1.join()
t2.join()
print("Time used:",(time.clock() - start))
運行結(jié)果為:
Time used: 0.006673
可以看到,此程序中使用了 2 個線程來執(zhí)行和上面代碼相同的工作,但從輸出結(jié)果中可以看到,運行效率非但沒有提高,反而降低了。
如果使用更多線程進(jìn)行嘗試,會發(fā)現(xiàn)其運行效率和 2 個線程效率幾乎一樣(本機器測試使用 4 個線程,其執(zhí)行效率約為 0.005)。這里不再給出具體測試代碼,有興趣的讀者可自行測試。
是不是和你猜想的結(jié)果不一樣?事實上,得到這樣的結(jié)果是肯定的,因為 GIL 限制了 Python 多線程的性能不會像我們預(yù)期的那樣。
那么,什么是 GIL 呢?GIL 是最流程的 CPython 解釋器(平常稱為 Python)中的一個技術(shù)術(shù)語,中文譯為全局解釋器鎖,其本質(zhì)上類似操作系統(tǒng)的 Mutex。GIL 的功能是:在 CPython 解釋器中執(zhí)行的每一個 Python 線程,都會先鎖住自己,以阻止別的線程執(zhí)行。
當(dāng)然,CPython 不可能容忍一個線程一直獨占解釋器,它會輪流執(zhí)行 Python 線程。這樣一來,用戶看到的就是“偽”并行,即 Python 線程在交替執(zhí)行,來模擬真正并行的線程。
有讀者可能會問,既然 CPython 能控制線程偽并行,為什么還需要 GIL 呢?其實,這和 CPython 的底層內(nèi)存管理有關(guān)。
CPython 使用引用計數(shù)來管理內(nèi)容,所有 Python 腳本中創(chuàng)建的實例,都會配備一個引用計數(shù),來記錄有多少個指針來指向它。當(dāng)實例的引用計數(shù)的值為 0 時,會自動釋放其所占的內(nèi)存。
舉個例子,看如下代碼:
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3
可以看到,a 的引用計數(shù)值為 3,因為有 a、b 和作為參數(shù)傳遞的 getrefcount 都引用了一個空列表。
假設(shè)有兩個 Python 線程同時引用 a,那么雙方就都會嘗試操作該數(shù)據(jù),很有可能造成引用計數(shù)的條件競爭,導(dǎo)致引用計數(shù)只增加 1(實際應(yīng)增加 2),這造成的后果是,當(dāng)?shù)谝粋€線程結(jié)束時,會把引用計數(shù)減少 1,此時可能已經(jīng)達(dá)到釋放內(nèi)存的條件(引用計數(shù)為 0),當(dāng)?shù)?2 個線程再次視圖訪問 a 時,就無法找到有效的內(nèi)存了。
所以,CPython 引進(jìn) GIL,可以最大程度上規(guī)避類似內(nèi)存管理這樣復(fù)雜的競爭風(fēng)險問題。
Python GIL底層實現(xiàn)原理
圖 1 GIL 工作流程示意圖
上面這張圖,就是 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 輪流執(zhí)行,每一個線程在開始執(zhí)行時,都會鎖住 GIL,以阻止別的線程執(zhí)行;同樣的,每一個線程執(zhí)行完一段后,會釋放 GIL,以允許別的線程開始利用資源。
讀者可能會問,為什么 Python 線程會去主動釋放 GIL 呢?畢竟,如果僅僅要求 Python 線程在開始執(zhí)行時鎖住 GIL,且永遠(yuǎn)不去釋放 GIL,那別的線程就都沒有運行的機會。其實,CPython 中還有另一個機制,叫做間隔式檢查(check_interval),意思是 CPython 解釋器會去輪詢檢查線程 GIL 的鎖住情況,每隔一段時間,Python 解釋器就會強制當(dāng)前線程去釋放 GIL,這樣別的線程才能有執(zhí)行的機會。
注意,不同版本的 Python,其間隔式檢查的實現(xiàn)方式并不一樣。早期的 Python 是 100 個刻度(大致對應(yīng)了 1000 個字節(jié)碼);而 Python 3 以后,間隔時間大致為 15 毫秒。當(dāng)然,我們不必細(xì)究具體多久會強制釋放 GIL,讀者只需要明白,CPython 解釋器會在一個“合理”的時間范圍內(nèi)釋放 GIL 就可以了。
整體來說,每一個 Python 線程都是類似這樣循環(huán)的封裝,來看下面這段代碼:
for (;;) {
if (--ticker < 0) {
ticker = check_interval;
/* Give another thread a chance */
PyThread_release_lock(interpreter_lock);
/* Other threads may run now */
PyThread_acquire_lock(interpreter_lock, 1);
}
bytecode = *next_instr++;
switch (bytecode) {
/* execute the next instruction ... */
}
}
從這段代碼中可以看出,每個 Python 線程都會先檢查 ticker 計數(shù)。只有在 ticker 大于 0 的情況下,線程才會去執(zhí)行自己的代碼。
Python GIL不能絕對保證線程安全
注意,有了 GIL,并不意味著 Python 程序員就不用去考慮線程安全了,因為即便 GIL 僅允許一個 Python 線程執(zhí)行,但別忘了 Python 還有 check interval 這樣的搶占機制。
比如,運行如下代碼:
import threading
n = 0
def foo():
global n
n += 1
threads = []
for i in range(100):
t = threading.Thread(target=foo)
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print(n)
執(zhí)行此代碼會發(fā)現(xiàn),其大部分時候會打印 100,但有時也會打印 99 或者 98,原因在于 n+=1 這一句代碼讓線程并不安全。如果去翻譯 foo 這個函數(shù)的字節(jié)碼就會發(fā)現(xiàn),它實際上是由下面四行字節(jié)碼組成:
>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL 0 (n)
LOAD_CONST 1 (1)
INPLACE_ADD
STORE_GLOBAL 0 (n)
而這四行字節(jié)碼中間都是有可能被打斷的!所以,千萬別以為有了 GIL 程序就不會產(chǎn)生線程問題,我們?nèi)匀恍枰⒁饩€程安全。
總結(jié)
以上是生活随笔為你收集整理的python解释器的工作原理_Python GIL全局解释器锁详解(深度剖析)的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: python制作文本编辑器_Python
- 下一篇: python网络爬虫系列(十一)——JS