- 原文地址: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 提交的记录:彼时,线程仍是很晦涩难懂的东西。且听我慢慢道来。html
在 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 tstate
android
个人第一句评论:ios
在我看来这是
PyEval_InitThreads()
的一个 bug 呀。git
两年内我我就忘了这个 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 系统上时不时会奔溃。最终我成功找到重现路径,如下例子是第三次运行时奔溃:
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()
。
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()
的另外一个修复方案:老是在 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()
单元测试。
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、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。