[译] 我是如何修复 Python 3.7 中一个非常古老的 GIL 竞态条件 bug 的

8,111 阅读5分钟

著名的 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 呀。

Release the GIL!

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