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

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

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

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 ,没有彩蛋。

运行新的基准测试,第二个修复补丁合并到主分支

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,036评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,046评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,411评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,622评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,661评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,521评论 1 304
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,288评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,200评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,644评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,837评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,953评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,673评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,281评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,889评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,011评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,119评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,901评论 2 355

推荐阅读更多精彩内容