1. 程式人生 > >GIL 已經被殺死了麼?

GIL 已經被殺死了麼?

本文原創並首發於公眾號【Python貓】,未經授權,請勿轉載。

原文地址:https://mp.weixin.qq.com/s/8KvQemz0SWq2hw-2aBPv2Q

花下貓語: Python 中最廣為人詬病的一點,大概就是它的 GIL 了。由於 GIL 的存在,Python 無法實現真正的多執行緒程式設計,因此很多人都把這視作 Python 最大的軟肋。

PEP-554 提出後(2017年9月),大夥似乎看到了一線改善的曙光。然而,GIL 真的可以被徹底殺死麼,如果可以的話,它會怎麼實現呢,為什麼等了一年多還沒實現,仍需要我們等待多長時間呢?


英文 | Has the Python GIL been slain?

【1】

作者 | Anthony Shaw

譯者 | 豌豆花下貓

宣告 :本文獲得原作者授權翻譯,轉載請保留原文出處,請勿用於商業或非法用途。

2003 年初,Intel 公司推出了全新的奔騰 4 “HT” 處理器,該處理器的主頻(譯註:CPU 核心工作的時鐘頻率)為 3 GHz,採用了“超執行緒”技術。

在接下來的幾年中,Intel 和 AMD 激烈競爭,通過提高匯流排速度、L2 快取大小和減小晶片尺寸以最大限度地減少延遲,努力地實現最佳的桌上型電腦效能。3Ghz 的 HT 在 2004 年被“Prescott”的 580 型號取代,該型號的主頻高達 4 GHz。

似乎提升效能的最好方法就是提高處理器的主頻,但 CPU 卻受到高功耗和散熱會影響全球變暖的困擾。

你電腦上有 4Ghz 的 CPU 嗎?不太可能,因為效能的前進方式是更高的匯流排速度和更多的核心。Intel 酷睿 2 代在 2006 年取代了奔騰 4 ,主頻遠低於此。

除了釋出消費級的多核 CPU,2006 年還發生了其它事情,Python 2.5 釋出了!Python 2.5 帶來了人見人愛的 with 語句的 beta 版本 。

在使用 Intel 的酷睿 2 或 AMD 的 Athlon X2 時,Python 2.5 有一個重要的限制——GIL

什麼是 GIL?

GIL 即全域性直譯器鎖(Global Interpreter Lock),是 Python 直譯器中的一個布林值,受到互斥保護。這個鎖被 CPython 中的核心位元組碼用來評估迴圈,並調節用來執行語句的當前執行緒。

CPython 支援在單個直譯器中使用多執行緒,但執行緒們必須獲得 GIL 的使用權才能執行操作碼(做低階操作)。這樣做的好處是,Python 開發人員在編寫非同步程式碼或多執行緒程式碼時,完全不必操心如何獲取變數上的鎖,也不需擔心程序因為死鎖而崩潰。

GIL 使 Python 中的多執行緒程式設計變得簡單。

GIL 還意味著雖然 CPython 可以是多執行緒的,但在任何給定的時間裡只能執行 1 個執行緒。這意味著你的四核 CPU 會像上圖一樣工作 (減去藍屏,但願如此)。

當前版本的 GIL 是在2009年編寫的 【2】,用於支援非同步功能,幾乎沒被改動地存活了下來,即使曾經多次試圖刪除它或減少對它的依賴。

所有提議移除 GIL 的訴求是,它不應該降低單執行緒程式碼的效能。任何曾在 2003 年啟用超執行緒(Hyper-Threading)的人都會明白為什麼 這很重要 【3】。

在 CPython 中避免使用 GIL

如果你想在 CPython 中使用真正的併發程式碼,則必須使用多程序。

在 CPython 2.6 中,標準庫裡增加了 multiprocessing 模組。multiprocessing 是 CPython 大量產生的程序的包裝器(每個程序都有自己的GIL)——

from multiprocessing import Process

def f(name):
    print 'hello', name

if __name__ == '__main__':
    p = Process(target=f, args=('bob',))
    p.start()
    p.join()

程序可以從主程序中“孵出”,通過編譯好的 Python 模組或函式傳送命令,然後重新納入主程序。

multiprocessing 模組還支援通過佇列或管道共享變數。它有一個 Lock 物件,用於鎖定主程序中的物件,以便其它程序能夠寫入。

多程序有一個主要的缺陷:它在時間和記憶體使用方面的開銷很大。CPython 的啟動時間,即使沒有非站點(no-site),也是 100-200ms(參見 這個連結 【4】)。

因此,你可以在 CPython 中使用併發程式碼,但是你必須仔細規劃那些長時間執行的程序,這些程序之間極少共享物件。

另一種替代方案是使用像 Twisted 這樣的三方庫。

PEP-554 與 GIL 的死亡?

小結一下,CPython 中使用多執行緒很容易,但它並不是真正的併發,多程序雖然是併發的,但開銷卻極大。

有沒有更好的方案呢?

繞過 GIL 的線索就在其名稱中,全域性 直譯器 鎖是全域性直譯器狀態的一部分。 CPython 的程序可以有多個直譯器,因此可以有多個鎖,但是此功能很少使用,因為它只通過 C-API 公開。

在為 CPython 3.8 提出的特性中有個 PEP-554,提議實現子直譯器(sub-interpreter),以及在標準庫中提供一個新的帶有 API 的 interpreters 模組。

這樣就可以在 Python 的單個程序中創建出多個直譯器。Python 3.8 的另一個改動是直譯器都將擁有單獨的 GIL ——

因為直譯器的狀態包含記憶體分配競技場(memory allocation arena),即所有指向 Python 物件(局地和全域性)的指標的集合,所以 PEP-554 中的子直譯器無法訪問其它直譯器的全域性變數。

與多程序類似,在直譯器之間共享物件的方法是採用 IPC 的某種形式(網路、磁碟或共享記憶體)來做序列化。在 Python 中有許多方法可以序列化物件,例如 marshal 模組、 pickle 模組、以及像 jsonsimplexml 這樣更標準化的方法 。這些方法褒貶不一,但無一例外會造成額外的開銷。

最佳方案是開闢一塊共享的可變的記憶體空間,由主程序來控制。這樣的話,物件可以從主直譯器傳送,並由其它直譯器接收。這將是 PyObject 指標的記憶體管理空間,每個直譯器都可以訪問它,同時由主程序擁有對鎖的控制權。

這樣的 API 仍在制定中,但它可能如下所示:

import _xxsubinterpreters as interpreters
import threading
import textwrap as tw
import marshal

# Create a sub-interpreter
interpid = interpreters.create()

# If you had a function that generated some data
arry = list(range(0,100))

# Create a channel
channel_id = interpreters.channel_create()

# Pre-populate the interpreter with a module
interpreters.run_string(interpid, "import marshal; import _xxsubinterpreters as interpreters")

# Define a
def run(interpid, channel_id):
    interpreters.run_string(interpid,
                            tw.dedent("""
        arry_raw = interpreters.channel_recv(channel_id)
        arry = marshal.loads(arry_raw)
        result = [1,2,3,4,5] # where you would do some calculating
        result_raw = marshal.dumps(result)
        interpreters.channel_send(channel_id, result_raw)
        """),
               shared=dict(
                   channel_id=channel_id
               ),
               )

inp = marshal.dumps(arry)
interpreters.channel_send(channel_id, inp)

# Run inside a thread
t = threading.Thread(target=run, args=(interpid, channel_id))
t.start()

# Sub interpreter will process. Feel free to do anything else now.
output = interpreters.channel_recv(channel_id)
interpreters.channel_release(channel_id)
output_arry = marshal.loads(output)

print(output_arry)

此示例使用了 numpy ,並通過使用 marshal 模組對其進行序列化來在通道上傳送 numpy 陣列 ,然後由子直譯器來處理資料(在單獨的 GIL 上),因此這會是一個計算密集型(CPU-bound)的併發問題,適合用子直譯器來處理。

這看起來效率低下

marshal 模組相當快,但仍不如直接從記憶體中共享物件那樣快。

PEP-574 提出了一種新的 pickle 【5】協議(v5),它支援將記憶體緩衝區與 pickle 流的其餘部分分開處理。對於大型資料物件,將它們一次性序列化,再由子直譯器反序列化,這會增加很多開銷。

新的 API 可以( 假想 ,並沒有合入)像這樣提供介面:

import _xxsubinterpreters as interpreters
import threading
import textwrap as tw
import pickle

# Create a sub-interpreter
interpid = interpreters.create()

# If you had a function that generated a numpy array
arry = [5,4,3,2,1]

# Create a channel
channel_id = interpreters.channel_create()

# Pre-populate the interpreter with a module
interpreters.run_string(interpid, "import pickle; import _xxsubinterpreters as interpreters")

buffers=[]

# Define a
def run(interpid, channel_id):
    interpreters.run_string(interpid,
                            tw.dedent("""
        arry_raw = interpreters.channel_recv(channel_id)
        arry = pickle.loads(arry_raw)
        print(f"Got: {arry}")
        result = arry[::-1]
        result_raw = pickle.dumps(result, protocol=5)
        interpreters.channel_send(channel_id, result_raw)
        """),
                            shared=dict(
                                channel_id=channel_id,
                            ),
                            )

input = pickle.dumps(arry, protocol=5, buffer_callback=buffers.append)
interpreters.channel_send(channel_id, input)

# Run inside a thread
t = threading.Thread(target=run, args=(interpid, channel_id))
t.start()

# Sub interpreter will process. Feel free to do anything else now.
output = interpreters.channel_recv(channel_id)
interpreters.channel_release(channel_id)
output_arry = pickle.loads(output)

print(f"Got back: {output_arry}")

這看起來像極了很多樣板

確實,這個例子使用的是低階的子直譯器 API。如果你使用了多程序庫,你將會發現一些問題。它不像 threading 那麼簡單,你不能想著在不同的直譯器中使用同一串輸入來運行同一個函式(目前還不行)。

一旦合入了這個 PEP,我認為 PyPi 中的其它一些 API 也會採用它。

子直譯器需要多少開銷?

簡版回答 :大於一個執行緒,少於一個程序。

詳版回答 :直譯器有自己的狀態,因此雖然 PEP-554 可以使建立子直譯器變得方便,但它還需要克隆並初始化以下內容:

  • main 名稱空間與 importlib 中的模組
  • sys 字典的內容
  • 內建的方法(print、assert等等)
  • 執行緒
  • 核心配置

核心配置可以很容易地從記憶體克隆,但匯入的模組並不那麼簡單。在 Python 中匯入模組的速度很慢,因此,如果每次建立子直譯器都意味著要將模組匯入另一個名稱空間,那麼收益就會減少。

那麼 asyncio 呢?

標準庫中 asyncio 事件迴圈的當前實現是建立需要求值的幀(frame),但在主直譯器中共享狀態(因此共享 GIL)。

在 PEP-554 被合入後,很可能是在 Python 3.9,事件迴圈的替代實現 可能 是這樣(儘管還沒有人這樣幹):在子直譯器內執行 async 方法,因此會是併發的。

聽起來不錯,發貨吧!

額,還不可以。

因為 CPython 已經使用單直譯器的實現方案很長時間了,所以程式碼庫的許多地方都在使用“執行時狀態”(Runtime State)而不是“直譯器狀態”(Interpreter State),所以假如要將當前的 PEP-554 合入的話,將會導致很多問題。

例如,垃圾收集器(在 3.7 版本前)的狀態就屬於執行時。

PyCon sprint 期間(譯註:PyCon 是由 Python 社群舉辦的大型活動,作者指的是官方剛在美國舉辦的這場,時間是2019年5月1日至5月9日。sprint 是為期 1-4 天的活動,開發者們自願加入某個專案,進行“衝刺”開發。該詞被敏捷開發團隊使用較多,含義與形式會略有不同),更改已經開始 【6】將垃圾收集器的狀態轉到直譯器,因此每個子直譯器將擁有它自己的 GC(本該如此)。

另一個問題是在 CPython 程式碼庫和許多 C 擴充套件中仍殘存著一些“全域性”變數。因此,當人們突然開始正確地編寫併發程式碼時,我們可能會遭遇到一些問題。

還有一個問題是檔案控制代碼屬於程序,因此當你在一個直譯器中讀寫一個檔案時,子直譯器將無法訪問該檔案(不對 CPython 作進一步更改的話)。

簡而言之,還有許多其它事情需要解決。

結論:GIL 死亡了嗎?

對於單執行緒的應用程式,GIL 仍然存活。因此,即便是合併了 PEP-554,如果你有單執行緒的程式碼,它也不會突然變成併發的。

如果你想在 Python 3.8 中使用併發程式碼,那麼你就會遇到計算密集型的併發問題,那麼這可能是張入場券!

什麼時候?

Pickle v5 和用於多程序的共享記憶體可能是在 Python 3.8(2019 年 10 月)實現,子直譯器將介於 3.8 和 3.9 之間。

如果你現在想要使用我的示例,我已經構建了一個分支,其中包含所有 必要的程式碼 【7】

References

[1] Has the Python GIL been slain? https://hackernoon.com/has-the-python-gil-been-slain-9440d28fa93d [2] 是在2009年編寫的: https://github.com/python/cpython/commit/074e5ed974be65fbcfe75a4c0529dbc53f13446f [3] 這很重要: https://arstechnica.com/features/2002/10/hyperthreading [4] 這個連結 : https://hackernoon.com/which-is-the-fastest-version-of-python-2ae7c61a6b2b [5] PEP-574 提出了一種新的 pickle : https://www.python.org/dev/peps/pep-0574/ [6] 更改已經開始: https://github.com/python/cpython/pull/13219 [7] 必要的程式碼 : https://github.com/tonybaloney/cpython/tree/subinterpreters

公眾號【Python貓】, 本號連載優質的系列文章,有喵星哲學貓系列、Python進階系列、好書推薦系列、技術寫作、優質英文推薦與翻譯等等,歡迎關注哦。後臺回覆“愛學習”,免費獲得一