終於有人把GIL全局解釋器說清楚了

GIL初體驗程序員

先來看下面的代碼web

def reduce_num(n):
    while n > 0:
        n -= 1
複製代碼

如今,假設一個很大的數字 n = 100000000,咱們先來試試單線程的狀況下執行 reduce_num(n)。在我手上這臺號稱 8 核的 MacBook 上執行後,我發現它的耗時爲 5.3s。編程

這時,咱們想要用多線程來加速,好比下面這幾行操做:安全

from threading import Thread
n = 100000000
t1 = Thread(target=reduce_num, args=[n // 2])
t2 = Thread(target=reduce_num, args=[n // 2])
t1.start()
t2.start()
t1.join()
t2.join()
複製代碼

我又在同一臺機器上跑了一下,結果發現,這不只沒有獲得速度的提高,反而讓運行變慢,總共花了 9.4s。多線程

我仍是不死心,決定使用四個線程再試一次,結果發現運行時間仍是 9.8s,和 2 個線程的結果幾乎同樣。app

這是怎麼回事呢?難道是我買了假的 MacBook 嗎?你能夠先本身思考一下這個問題,也能夠在本身電腦上測試一下。我固然也要自我反思一下,而且提出了下面兩個猜測。編輯器

第一個懷疑:個人機器出問題了嗎?這不得不說也是一個合理的猜測。所以我又找了一個單核 CPU 的臺式機,跑了一下上面的實驗。此次我發現,在單核 CPU 電腦上,單線程運行須要 11s 時間,2 個線程運行也是 11s 時間。函數

雖然不像第一臺機器那樣,多線程反而比單線程更慢,可是這兩次總體效果幾乎同樣呀!看起來,這不像是電腦的問題,而是 Python 的線程失效了,沒有起到並行計算的做用。工具

瓜熟蒂落,我又有了第二個懷疑:Python 的線程是否是假的線程?性能

Python 的線程,的的確確封裝了底層的操做系統線程,在 Linux 系統裏是 Pthread(全稱爲 POSIX Thread),而在 Windows 系統裏是 Windows Thread。

另外,Python 的線程,也徹底受操做系統管理,好比協調什麼時候執行、管理內存資源、管理中斷等等。

爲何會有GIL

看來個人兩個猜測,都不能解釋開頭的這個未解之謎。那究竟誰纔是「罪魁禍首」呢?事實上,正是咱們今天的主角,也就是 GIL,致使了 Python 線程的性能並不像咱們指望的那樣。

GIL,是最流行的 Python 解釋器 CPython 中的一個技術術語。它的意思是全局解釋器鎖,本質上是相似操做系統的 Mutex。每個 Python 線程,在 CPython 解釋器中執行時,都會先鎖住本身的線程,阻止別的線程執行。

固然,CPython 會作一些小把戲,輪流執行 Python 線程。這樣一來,用戶看到的就是「僞並行」——Python 線程在交錯執行,來模擬真正並行的線程。

CPython 使用引用計數來管理內存,全部 Python 腳本中建立的實例,都會有一個引用計數,來記錄有多少個指針指向它。當引用計數只有 0 時,則會自動釋放內存。

什麼意思呢?咱們來看下面這個例子:

import sys
a = []
b = a
sys.getrefcount(a)
輸出結果爲3
複製代碼

這個例子中,a 的引用計數是 3,由於有 a、b 和做爲參數傳遞的 getrefcount 這三個地方,都引用了一個空列表。

這樣一來,若是有兩個 Python 線程同時引用了 a,就會形成引用計數的 race condition,引用計數可能最終只增長 1,這樣就會形成內存被污染。由於第一個線程結束時,會把引用計數減小 1,這時可能達到條件釋放內存,當第二個線程再試圖訪問 a 時,就找不到有效的內存了。

因此說,CPython 引進 GIL 其實主要就是這麼兩個緣由:

一是設計者爲了規避相似於內存管理這樣的複雜的競爭風險問題(race condition);

二是由於 CPython 大量使用 C 語言庫,但大部分 C 語言庫都不是原生線程安全的(線程安全會下降性能和增長複雜度)。

GIL 是如何工做的?

下面這張圖,就是一個 GIL 在 Python 程序的工做示例。其中,Thread 一、二、3 輪流執行,每個線程在開始執行時,都會鎖住 GIL,以阻止別的線程執行;一樣的,每個線程執行完一段後,會釋放 GIL,以容許別的線程開始利用資源。

細心的你可能會發現一個問題:爲何 Python 線程會去主動釋放 GIL 呢?畢竟,若是僅僅是要求 Python 線程在開始執行時鎖住 GIL,而永遠不去釋放 GIL,那別的線程就都沒有了運行的機會。

沒錯,CPython 中還有另外一個機制,叫作 check_interval,意思是 CPython 解釋器會去輪詢檢查線程 GIL 的鎖住狀況。每隔一段時間,Python 解釋器就會強制當前線程去釋放 GIL,這樣別的線程纔能有執行的機會。

不一樣版本的 Python 中,check interval 的實現方式並不同。早期的 Python 是 100 個 ticks,大體對應了 1000 個 bytecodes;而 Python 3 之後,interval 是 15 毫秒。固然,咱們沒必要細究具體多久會強制釋放 GIL,這不該該成爲咱們程序設計的依賴條件,咱們只需明白,CPython 解釋器會在一個「合理」的時間範圍內釋放 GIL 就能夠了。

總體來講,每個 Python 線程都是相似這樣循環的封裝,咱們來看下面這段代碼:

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 計數。只有在 ticker 大於 0 的狀況下,線程纔會去執行本身的 bytecode。

Python 的線程安全

不過,有了 GIL,並不意味着咱們 Python 編程者就不用去考慮線程安全了。即便咱們知道,GIL 僅容許一個 Python 線程執行,但前面我也講到了,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)
複製代碼

本文使用 mdnice 排版

若是你執行的話,就會發現,儘管大部分時候它可以打印 100,但有時侯也會打印 99 或者 98。

這其實就是由於,n+=1這一句代碼讓線程並不安全。若是你去翻譯 foo 這個函數的 bytecode,就會發現,它實際上由下面四行 bytecode 組成:

>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL              0 (n)
LOAD_CONST               1 (1)
INPLACE_ADD
STORE_GLOBAL             0 (n)
複製代碼

而這四行 bytecode 中間都是有可能被打斷的!

因此,千萬別想着,有了 GIL 你的程序就能夠高枕無憂了,咱們仍然須要去注意線程安全。正如我開頭所說,GIL 的設計,主要是爲了方便 CPython 解釋器層面的編寫者,而不是 Python 應用層面的程序員。做爲 Python 的使用者,咱們仍是須要 lock 等工具,來確保線程安全。好比我下面的這個例子:

n = 0
lock = threading.Lock()
def foo():
    global n
    with lock:
        n += 1
複製代碼

相關文章
相關標籤/搜索