[译] 我是如何修复 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索