来源:Python程序员
ID:pythonbuluo
作为Python最关键的组成部分之一:GIL(全局解释器锁),我花了4年时间修复了其中的一个令人讨厌的bug。为了修复这个bug,我不得不深挖Git的历史,才找出26年前Guido van Rossum (龟叔,Python创立者) 所做的一处更改。那个时候,线程还是很深奥的东西。
我的故事是这样的。
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。
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 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()。
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()的第二个修复,以便在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。
在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()。
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的所有开发人员!