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

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

由 C 線程和 GIL 引發的 Python 致命錯誤

在 2014 年 3 月份的時候, Steve Dower 報告了一個當 「C 語言線程「 使用 Python C API 時產生的 bug bpo-20891前端

在 Python 3.4rc3 中,在一個不是用 Python 建立的線程中調用 PyGILState_Ensure() 方法,但不調用 PyEval_InitThreads() 方法時,會致使程序出現嚴重錯誤,並退出:python

Fatal Python error: take_gil: NULL tstateandroid

個人第一句評論:ios

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

Release the GIL!

PyGILState_Ensure() 修復方案

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

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

這個修復補丁發佈了嗎?我在更改日誌裏面沒有看到…json

糟糕,我又一次徹底忘了這個問題!此次,我不只提交了我對 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索