[譯] 我是如何修復 Python 3.7 中一個非常古老的 GIL 競態條件 bug 的

kezhenxu94發表於2019-02-21

著名的 Python GIL (Global Interpreter Lock, 全域性解析器鎖) 庫中一個嚴重的 bug 花了我 4 年的時間去修復,Python GIL 是 Python 中最容易出錯的部分之一。我不得不鑽入 Git 的提交歷史裡面,找到 26 年前 Guido van Rossum 提交的記錄:彼時,執行緒還是很晦澀難懂的東西。且聽我慢慢道來。

由 C 執行緒和 GIL 引起的 Python 致命錯誤

在 2014 年 3 月份的時候, Steve Dower 報告了一個當 “C 語言執行緒“ 使用 Python C API 時產生的 bug bpo-20891

在 Python 3.4rc3 中,在一個不是用 Python 建立的執行緒中呼叫 PyGILState_Ensure() 方法,但不呼叫 PyEval_InitThreads() 方法時,會導致程式出現嚴重錯誤,並退出:

Fatal Python error: take_gil: NULL tstate

我的第一句評論:

在我看來這是 PyEval_InitThreads() 的一個 bug 呀。

Release the GIL!

PyGILState_Ensure() 修復方案

兩年內我我就忘了這個 bug 。到了 2016 年 3 月份,我修改了 Steve 的測試程式碼,以相容 Linux (當時的測試程式碼是在 Windows 上寫的)。我成功地在我的電腦上重現了這個 bug ,然後寫了個 PyGILState_Ensure() 的修復補丁。

一年後,也就是 2017 年 11 月,Marcin Kasperski 問道:

這個修復補丁釋出了嗎?我在更改日誌裡面沒有看到…

糟糕,我又一次完全忘了這個問題!這次,我不僅提交了我對 PyGILState_Ensure() 的修復補丁,還寫了單元測試 test_embed.test_bpo20891()

好了,這個 bug 已經在 Python 2.7, 3.6 和主分支(後來的 3.7)上修復啦。在 3.6 和 master 上,這個補丁還帶了單元測試呢。

我在主分支上的修復提交, 提交 b4d1e1f7

bpo-20891: Fix PyGILState_Ensure() (#4650)

When PyGILState_Ensure() is called in a non-Python thread before
PyEval_InitThreads(), only call PyEval_InitThreads() after calling
PyThreadState_New() to fix a crash.

Add an unit test in test_embed.
複製程式碼

然後我就關了這個 issue bpo-20891 了…

單元測試在 macOS 上隨機奔潰

一切都安好…… 直到一週之後,我意識到我新加的單元測試在 macOS 系統上時不時會奔潰。最終我成功找到重現路徑,以下例子是第三次執行時奔潰:

macbook:master haypo$ while true; do ./Programs/_testembed bpo20891 ||break; date; done
Lun  4 déc 2017 12:46:34 CET
Lun  4 déc 2017 12:46:34 CET
Lun  4 déc 2017 12:46:34 CET
Fatal Python error: PyEval_SaveThread: NULL tstate

Current thread 0x00007fffa5dff3c0 (most recent call first):
Abort trap: 6
複製程式碼

test_embed.test_bpo20891() 在 macOS 的 PyGILState_Ensure() 出現了一個競態條件:GIL 鎖自身的構建……沒有鎖保護!新增一個鎖來檢測 Python 當前有沒有 GIL 鎖顯然毫無意義……

我提出了修復 PyThread_start_new_thread() 的一個不是很完整的建議:

我找到一個可行的修復方案:在 PyThread_start_new_thread() 中呼叫 PyEval_InitThreads()。這樣 GIL 就能夠在第二個執行緒一產生時就建立好了。當有兩個執行緒在執行的時候就不能再建立 GIL 了。但至少在“是不是用 python”這種非黑即白的情況下,如果一個執行緒不是用 Python 建立的,這種修復方案會失效,但此時這個執行緒又會呼叫 PyGILState_Ensure()

為什麼不一開始就建立 GIL?

Antoine Pitrou 問了一個簡單的問題:

為什麼不在解析器初始化時就呼叫 PyEval_InitThreads()?有什麼不好之處嗎?

多虧了 git blamegit log 命令,我找到了“按需建立 GIL”程式碼的發源地,26 年前的一個變更

commit 1984f1e1c6306d4e8073c28d2395638f80ea509b
Author: Guido van Rossum <guido@python.org>
Date:   Tue Aug 4 12:41:02 1992 +0000

    * Makefile adapted to changes below.
    * split pythonmain.c in two: most stuff goes to pythonrun.c, in the library.
    * new optional built-in threadmodule.c, build upon Sjoerd's thread.{c,h}.
    * new module from Sjoerd: mmmodule.c (dynamically loaded).
    * new module from Sjoerd: sv (svgen.py, svmodule.c.proto).
    * new files thread.{c,h} (from Sjoerd).
    * new xxmodule.c (example only).
    * myselect.h: bzero -> memset
    * select.c: bzero -> memset; removed global variable

(...)

+void
+init_save_thread()
+{
+#ifdef USE_THREAD
+       if (interpreter_lock)
+               fatal("2nd call to init_save_thread");
+       interpreter_lock = allocate_lock();
+       acquire_lock(interpreter_lock, 1);
+#endif
+}
+#endif
複製程式碼

我猜測這種動態建立 GIL 的意圖是為了避免那些只使用了一個執行緒(即永遠不會新建執行緒)的應用“過早”建立 GIL 的情況。

幸運的是,Guido van Rossum 當時也在,能夠和我一起找出根本原因:

是的,最初的原因就是執行緒是很晦澀難懂的,也沒有多少程式碼裡面會用執行緒,那時,由於 GIL 程式碼中的 bug ,我們肯定會覺得頻繁使用 GIL 會導致(微小的)效能下降奔潰風險的上升。現在瞭解到我們不再需要擔心這兩方面的問題了,可以盡情地使用初始化它了

Py_Initialize() 的第二個修復方案的提出

我提議了 Py_Initialize()另一個修復方案:總是在 Python 一啟動的時候就建立 GIL ,不再“按需”建立,以避免競態條件發生的風險:

+    /* Create the GIL */
+    PyEval_InitThreads();
複製程式碼

Nick Coghlan 問我是否能夠在我的補丁上執行一下效能基準測試。我在我的 PR 4700 上執行了 pyperformance,差距高達 5%:

haypo@speed-python$ python3 -m perf compare_to \
    2017-12-18_12-29-master-bd6ec4d79e85.json.gz \
    2017-12-18_12-29-master-bd6ec4d79e85-patch-4700.json.gz \
    --table --min-speed=5

+----------------------+--------------------------------------+-------------------------------------------------+
| Benchmark            | 2017-12-18_12-29-master-bd6ec4d79e85 | 2017-12-18_12-29-master-bd6ec4d79e85-patch-4700 |
+======================+======================================+=================================================+
| pathlib              | 41.8 ms                              | 44.3 ms: 1.06x slower (+6%)                     |
+----------------------+--------------------------------------+-------------------------------------------------+
| scimark_monte_carlo  | 197 ms                               | 210 ms: 1.07x slower (+7%)                      |
+----------------------+--------------------------------------+-------------------------------------------------+
| spectral_norm        | 243 ms                               | 269 ms: 1.11x slower (+11%)                     |
+----------------------+--------------------------------------+-------------------------------------------------+
| sqlite_synth         | 7.30 us                              | 8.13 us: 1.11x slower (+11%)                    |
+----------------------+--------------------------------------+-------------------------------------------------+
| unpickle_pure_python | 707 us                               | 796 us: 1.13x slower (+13%)                     |
+----------------------+--------------------------------------+-------------------------------------------------+

Not significant (55): 2to3; chameleon; chaos; (...)
複製程式碼

哇,5 個基準降低了。效能迴歸測試在 Python 中很受歡迎:我們一直都致力於讓 Python 跑得更快

聖誕前夕跳過失敗的測試

我沒有料到有 5 個基準測試效能都降低了。這需要更深層的探究,但我沒有時間去做這些探究,如果要做效能迴歸測試,我又得對此負責,感覺太害羞/羞愧了。

在聖誕節假期之前,我還下不定決心,然而 test_embed.test_bpo20891() 還是一如既往地在 macOS 系統上隨機奔潰。讓我在假期前的兩週時間內去接觸 Python 中最最容易出錯的部分 —— GIL 著實讓我感到很難受。所以我決定跳過 test_bpo20891() 的單元測試直到過完假期再說。

Python 3.7 ,沒有彩蛋。

Sad Christmas tree

執行新的基準測試,第二個修復補丁合併到主分支

在 2018 年的 1 月末,我再一次執行了我 PR 中效能降下來的那 5 個基準測試。我在我的筆記本上手動執行這些基準測試,讓不同的測試使用獨立的 CPU :

vstinner@apu$ python3 -m perf compare_to ref.json patch.json --table
Not significant (5): unpickle_pure_python; sqlite_synth; spectral_norm; pathlib; scimark_monte_carlo
複製程式碼

好了,根據 Python “效能”基準測試套件,現在證明了我的第二個修復方案其實並沒有對效能產生多大的影響

我決定把我的修復方案推送到主分支,提交 2914bb32

bpo-20891: Py_Initialize() now creates the GIL (#4700)

The GIL is no longer created "on demand" to fix a race condition when
PyGILState_Ensure() is called in a non-Python thread.
複製程式碼

然後我在主分支上重新啟動了 test_embed.test_bpo20891() 單元測試。

對不起,Python 2.7 和 3.6 沒有第二個修復補丁!

Antoine Pitrou 想過要把補丁移植到 Python 3.6 不能合併

我覺得沒必要。大家已經可以呼叫 PyEval_InitThreads() 了。

Guido van Rossum 也不想移植這個補丁。所以我就從 3.6 的主分支中移除了 test_embed.test_bpo20891()

由於同樣的原因,我也沒有在 Python 2.7 中應用我的第二個補丁,此外,Python 2.7 沒有單元測試,因為移植太難了。

但至少,Python 2.7 和 3.6 應用了我的第一個補丁,PyGILState_Ensure()

總結

Python 在一些邊界情況下仍然有一些競態條件。這種 bug 是在 C 執行緒使用 Python API 建立 GIL 時發現的。我推送了第一個補丁,但另一個新的競態條件在 macOS 上出現了。

我不得不鑽進 Python GIL 非常古老的提交歷史(1992 年)中。幸運的是 Guido van Rossum 能夠幫忙一起找到 bug 的根本原因。

在一次基準測試小故障後,我們意見達成一致,在 Python 3.7 中總是一啟動解析器就建立 GIL,而不是“按需”建立。這種變更沒有對效能產生明顯的影響。

同時我們也決定保持 Python 2.7 和 3.6 不變,以防止任何迴歸測試的風險:繼續“按需”建立 GIL。

著名的 Python GIL (Global Interpreter Lock, 全域性解析器鎖) 庫中一個嚴重的 bug 花了我 4 年的時間去修復,Python GIL 是 Python 中最容易出錯的部分之一。很開心現在這個 bug 已經被我們甩開了:在即將釋出的 Python 3.7 中已經被完全修復了!

bpo-20891 檢視完整的故事。感謝幫助我修復這個 bug 的所有開發者!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章