我用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 <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的“开销”。这些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的所有开发人员!

原文发布于微信公众号 - 马哥Linux运维(magedu-Linux)

原文发表时间:2018-05-19

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏中国Android研究院

番外篇-Flutter初识三问

在Android中,您可以通过直接对view进行改变来更新视图。然而,在Flutter中Widget是不可变的,不会直接更新,而必须使用Widget的状态。

1073
来自专栏coding

听说,撸代码,ide与vim更配哦vim折腾记vim常用命令

在选择编辑器上面,我是一个纠结的人,曾经年少的我执着地追求一款万能的编辑器,可以支持所有编辑语言,灵活可定制,可纯粹用键盘操作。符合这种条件的编辑器,非vim莫...

802
来自专栏Java帮帮-微信公众号-技术文章全总结

学Java-Spring使用Quartz任务调度定时器

睁开眼看一看窗外的阳光,伸一个懒腰,拿起放在床一旁的水白开水,甜甜的味道,晃着尾巴东张西望的猫猫,在窗台上舞蹈。你向生活微笑,生活也向你微笑。 请你不要询问我...

2763
来自专栏Golang语言社区

KCP-GO源码解析

ARQ:自动重传请求(Automatic Repeat-reQuest,ARQ)是OSI模型中数据链路层的错误纠正协议之一. RTO:Retransmissio...

3373
来自专栏Python小屋

Python不使用scrapy框架而编写的网页爬虫程序

本文代码节选(略有改动)自《Python程序设计(第2版)》(董付国编著,清华大学出版社),没有使用scrapy爬虫框架,而是使用标准库urllib访问网页实现...

3465
来自专栏开源FPGA

基于basys2驱动LCDQC12864B的verilog设计图片显示

  话不多说先上图 ? 前言        在做这个实验的时候在网上找了许多资料,都是关于使用单片机驱动LCD显示,确实用单片机驱动是要简单不少,记得在FPGA...

2195
来自专栏王亚昌的专栏

C++多线程编程学习三 [FIFO队列设计]

   在网络编程中,FIFO队列是经常使用到的一个数据缓冲机制,同时这也是一个生产者与消费者问题,在设计过程中要注意以下几点。 队列大小设计要科学,对于服务的强...

950
来自专栏Crossin的编程教室

Python 爬虫爬取美剧网站

一直有爱看美剧的习惯,一方面锻炼一下英语听力,一方面打发一下时间。之前是能在视频网站上面在线看的,可是自从广电总局的限制令之后,进口的美剧英剧等貌似就不在像以前...

3557
来自专栏杨建荣的学习笔记

system表空间不足的问题分析(r6笔记第66天)

很多事情见多了也就有了麻木的感觉,报警短信就是如此,每天总能收到不少的报警短信,可能很多时候就扫一眼,如果没有严重的问题自己是不会情愿打开电脑处理的。 对于此,...

2534
来自专栏偏前端工程师的驿站

Thinking in React Implemented by Reagent

993

扫码关注云+社区