- 原文地址:How I fixed a very old GIL race condition in Python 3.7
- 原文作者:Victor Stinner
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:kezhenxu94
- 校對者:Starrier
著名的 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 呀。
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 blame
和 git 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 ,沒有彩蛋。
執行新的基準測試,第二個修復補丁合併到主分支
在 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 的所有開發者!
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。