歡迎光臨
每天分享高質量文章

我用4年時間解決了Python GIL的一個bug…

來源:Python程式員

ID:pythonbuluo

作為Python最關鍵的組成部分之一:GIL(全域性直譯器鎖),我花了4年時間修複了其中的一個令人討厭的bug。為了修複這個bug,我不得不深挖Git的歷史,才找出26年前Guido van Rossum (龜叔,Python創立者) 所做的一處更改。那個時候,執行緒還是很深奧的東西。

我的故事是這樣的。

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

2014年3月,Steve Dower報告了bug bpo-20891。這個bug發生在“C執行緒”使用Python C API時:

在Python 3.4rc3版本中,從一個非Python建立的執行緒中呼叫PyGILState_Ensure(),並且完全沒有呼叫 PyEval_InitThreads()的情況下,將產生一個致命的退出:

發生致命的Python錯誤:take_gil:NULL tstate

我的第一個評論是:

以我之愚見,這是PyEval_InitThreads()中的一個Bug。

修複PyGILState_Ensure() 

2年的時間裡,我完全不記得這個bug了。 2016年3月,我修改了Steve的測試程式,使其與Linux相容(該測試是為Windows編寫的)。 我成功地重現了我電腦上的錯誤,並且為PyGILState_Ensure()寫了一個修複程式。

一年後,2017年11月,卡辛斯基問道:

此修複釋出了嗎? 我在更新日誌中找不到…

哎呀,我又完全忘記了這個問題! 這一次,我不僅安裝了我的PyGILState_Ensure()修複,還編寫了單元測試test_embed.test_bpo20891():

好的,這個bug現在已經在Python 2.7, 3.6 和master(將來的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.

於是我關閉了問題bpo-20891 …

macOS上測試發生隨機崩潰

一切都很好……但一週後,我註意到我新增加的單元測試在macOS buildbots上發生了隨機崩潰。 我成功地手動重現了這個bug,第三次執行時崩潰的例子:

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

macOS上的test_embed.test_bpo20891()在PyGILState_Ensure() 中顯示有競態條件(race condition):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
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的“開銷”。這些GIL用於那些只使用單個Python執行緒的應用程式(永遠不會產生新的Python執行緒)。

幸運的是,Guido van Rossum在我附近,能夠對基本原理加以闡述:

是的,最初的理由是執行緒是深奧的,不為大多數程式碼所使用,並且當時我們一定覺得:總是使用GIL會導致(微小的)速度放緩,並增加由於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 buildbots上仍然是隨機失敗。 在離開兩個星期之前,我對於觸及Python的關鍵部分,即GIL,並沒有太多把握。 所以我決定,等到我回來之前,先跳過test_bpo20891()。

沒有聖誕禮物給你了:Python 3.7。

執行新的基準測試,和應用於master的第二個修複

在2018年1月底,我再次運行了那5個由於我的PR(Pull request)而變慢的基準測試。 我使用了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效能基準套件,我的第二個修複對效能沒有顯著的影響。

我決定將我的修複程式推送到master分支,提交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.

然後我在master分支上重新啟用了test_embed.test_bpo20891()。

沒有適用於Python 2.7和3.6的第二個修複,抱歉!

Antoine Pitrou認為,不應該合併Python 3.6的backport (註:backport是將一個軟體的補丁應用到比此補丁所對應的版本更老的版本的行為):

我不這麼認為。 人們可能已經呼叫PyEval_InitThreads()。

Guido van Rossum也不想把這一修改做backport。 所以我只從3.6的分支中刪除了test_embed.test_bpo20891()。

由於相同的原因,我沒有將我的第二個修複應用於Python 2.7。 而且,Python 2.7沒有單元測試,因為它很難backport。

至少,Python 2.7和3.6獲得了我的第一個PyGILState_Ensure()修複。

結論

在少數案例中,Python仍然存在一些競態條件。 當一個C執行緒開始使用Python API時,在建立GIL時就可以發現這樣的Bug。 我推出了第一個修複程式,但在macOS上發現了一個新的不同的競態條件。

我不得不深入研究Python GIL的歷史(1992年)。 幸運的是,Guido van Rossum也能夠闡述其基本原理。

在基準測試出現故障後,我們同意修改Python 3.7,以便始終建立GIL,而不是按需建立GIL。 該變化對效能沒有顯著的影響。

我們還決定讓Python 2.7和3.6保持不變,以防止任何回退風險:可以繼續按需建立GIL。

我花了4年的時間修複了Python GIL中的一個令人討厭的bug。 在接觸Python中如此關鍵的部分時,我從未自信滿滿。 現在,我很高興這個bug被我們甩在了身後:現在,它已經在未來的Python 3.7中完全修複了!

完整的故事見bpo-20891。 感謝幫助我解決這個Bug的所有開發人員!

英文原文:https://vstinner.github.io/python37-gil-change.html?utm_source=mybridge&utm;_medium=web&utm;_campaign=read_more  
 譯者:泰然  

《Python人工智慧和全棧開發》2018年07月23日即將在北京開課,120天衝擊Python年薪30萬,改變速約~~~~

*宣告:推送內容及圖片來源於網路,部分內容會有所改動,版權歸原作者所有,如來源資訊有誤或侵犯權益,請聯絡我們刪除或授權事宜。

– END –


更多Python好文請點選【閱讀原文】哦

↓↓↓

贊(0)

分享創造快樂