最令人头疼的Python问题

Python中由于使用了全局解释锁(GIL)的原因,代码并不能同时在多核上并发的运行,也就是说,Python的多线程不能并发,很多人会发现使用多线程来改进自己的Python代码后,程序的运行效率却下降了。这篇文章对Python中的全局解释锁(GIL)进行了介绍。作者认为这是Python中最令人头疼的问题。

十年多年来,Python 的全局解释器锁(GIL)给新手和专家们带来了巨大的挫折感和好奇心。

悬而未决的问题

每个领域都会有这么一个问题:它难度大、耗时多,仅仅是尝试解决这个问题都会让人震惊。整个社区在很久以前就放弃了这个问题,现在只有少数人在努力试图解决它。对于初学者来说,解决这样高难度的问题,会给他带来足够的声誉。计算机科学领域中的 P = NP 就是这样的问题。如果能用多项式时间复杂度解决这个问题,那简直就可以改变世界了。Python 中最困难的问题比 P = NP 要容易一些,不过迄今仍然没有一个满意的答案,解决这个问题和解决 P = NP 问题一样具有革命性。正因为如此, Python 社区会有如此多的人关注于这个的问题: “对于全局解释器锁(GIL)能做什么?”

Python 的底层

要理解 GIL 的含义,我们需要从 Python 的基础说起。像 C++ 这样的语言属于编译型语言,顾名思义,该类型语言的代码输入到编译器,由编译器根据语言的语法进行解析,生成与语言无关的中间表示,最后链接成由高度优化的机器码组成的可执行程序。因为编译器可以获取全部代码(或者是一大段相对独立的代码),所以编译器可以对代码进行深度优化。这使得它可以对不同的语言结构之间的交互进行推理,从而做出更有效的优化。

相反,Python 是解释型语言。代码被输入到解释器来运行。解释器在执行之前对代码一无所知;它只知道 Python 的规则,以及如何在执行过程中动态地应用这些规则。它也有一些优化,但是和编译型语言的优化完全不同。由于解释器不能很好地对代码进行推导,Python 的大部分优化其实是解释器本身的优化。更快的解释器自然意味着更快的程序运行速度,而这种优化对开发者来说是免费的。也就是说,解释器优化后,开发者不用修改 Python 代码就可以坐享优化带来的好处。

这是非常重要的一点,这里有必要在强调一下。在同等条件下,Python 程序的运行速度与解释器的“速度”直接相关相关。无论开发者怎样优化自己的代码,程序的执行速度还是受限于解释器的执行效率。很明显,这就是为什么做了如此多的工作去优化 Python 解释器。这大概是离 Python 开发者最近的免费的午餐。

免费午餐结束了

还是没有结束?摩尔定律告诉了我们硬件提速的时间表,同时,整整一代程序员学会了如何在摩尔定律下编写代码。如果程序员写了比较慢的代码,最简单的办法通常是稍稍等待一下更快的处理器问世即可。事实上,摩尔定律仍然是并且会在很长一段时间内是有效的,不过它生效的方式有了根本的变化。时钟频率不会稳定增长到一个高不可攀的速度,取而代之的是通过多核来利用晶体管密度提高带来的好处。想要程序能够充分利用新处理器的性能,就必须按照并发方式对代码进行重写。

大部分开发者听到“并发”通常会马上想到多线程程序。目前,多线程仍是利用多核系统最常见的方式。多线程编程比传统的“顺序”编程要难很多,不过仔细的程序员可以在代码中充分利用多线程的并发性。既然几乎所有应用广泛的现代编程语言都支持多线程编程,语言在多线程方面的实现应该是事后添加上去的。

意外的事实

现在我们来看一下问题的症结所在。想要利用多核系统,Python 必须支持多线程。作为解释型语言,Python 的解释器对多线程的支持必须是既安全又高效的。我们都知道多线程编程带来的问题。解释器必须避免不同的线程操作内部共享的数据。同时还要保证用户线程能完成尽量多的计算。

那么在不同线程同时访问数据时,怎样才能保护数据呢?答案是全局解释器锁。顾名思义,这是一个加在解释器上的全局锁(从互斥量或者类似意义上来看)。这种方式是很安全,但是(对于 Python 初学者来说)这也就意味着:对于任何 Python 程序,不论有多少线程,多少处理器,任何时候都只有一个线程在执行。

许多人都是偶然发现这个事实。网上的讨论组和留言板充斥着来自 Python 初学者和专家提出的类似的问题:为什么我全新的多线程 Python 程序运行得比其只有一个线程的时候还要慢?在问这个问题时,许多人还觉得自己像个傻瓜,因为如果程序确实是可并行的,那么两个线程的程序显然要比单线程要快。事实上,问及这个问题的次数实在太多了,Python 的专家们已经为它准备了一个标准答案:不要使用多线程,请使用多进程。但这个答案比问题本身更加让人困惑:难道我不能在 Python 中使用多线程?在 Python 这样流行的语言中使用多线程究竟是有多糟糕,连专家都建议不要使用。是我哪里没有搞明白吗?

很遗憾,并不是。由于 Python 解释器的设计,使用多线程以提高性能可以算是一个困难的任务。在最坏的情况下,多线程反而会降低(有时很明显)程序的运行速度。一个计算机科学专业的新生就可以告诉你:当多个线程竞争一个共享资源时将会发生什么。结果通常不理想。很多情况下多线程都能很好地工作,对于解释器的实现和内核开发人员来说,不要对 Python 多线程性能有太多抱怨可能是他们最大的心愿。

现在该怎么办呢?慌了吗?

我们现在能做什么呢?难道作为 Python 开发人员的我们要放弃使用多线程来实现并行吗?为什么 GIL 在某一时刻只允许一个线程在运行呢?在并发访问时,难道不可以用粒度更细的锁来保护多个独立对象?为什么没有人做过类似的尝试呢?

这些问题很实用,它们的答案也十分有趣。GIL 为很多对象的访问提供这保护,比如当前线程状态和为垃圾回收而用的堆分配对象。这对 Python 语言来说没什么奇怪的,它需要使用一个 GIL 。这是该实现的一种产物。现在也有不使用 GIL 的 Python 解释器(和编译器)。但是对于 CPython 来说,从其产生到现在 GIL 就一直在存在了。

那么为什么我们不抛弃 GIL 呢?许多人也许不知道,1999年的时候,Greg Stein 针对 Python 1.5 提交了一个名为“free threading”的补丁,这个补丁经常被提到却不怎么被人理解。这个补丁就尝试了将 GIL 完全移除,并用细粒度的锁来代替。然而,GIL 移除的代价是单线程程序的执行速度下降,下降的幅度大概有 40%。使用两个线程可以让速度有所提升,但是速度的提升并没有随着核数的增加而线性增长。由于执行速度的降低,这一补丁没有被接受了,并且几乎被人遗忘。

GIL 让人头痛,我们还是想点其他办法吧

尽管“free threading”这个补丁没有被接受,但是它还是有启发性意义。它证明了一个关于 Python 解释器的基本要点:移除 GIL 是非常困难的。比起该补丁发布的时候,现在的解释器依赖的全局状态变得更多了,这使得移除 GIL 变得更加困难。值得一提的是,也正是因为这个原因,许多人对移除 GIL 变得更感兴趣了。困难的问题通常都很有趣。

但是这可能有点被误导了。我们假设一下:如果我们有这样一个神奇的补丁,它其移除了 GIL ,并且没有使单线程的 Python 代码性能下降,我们会得到一直想要的东西:一个能并发使用所有处理器的线程 API。现在我们已经获得了我们希望的,但这确实是件好事吗?

基于线程的编程是困难的。当一个人觉得自己了解关于线程的一切,总会有一些新问题出现。一些非常知名的语言设计者和研究者站出来反对线程模型,因为在这方面想要得到合理的一致性真的是太难了。就像任何一个写过多线程应用程序的人可以告诉你的一样,不管是多线程应用的开发还是调试难度都会是单线程的应用的指数倍。程序员的思维模型往往适应顺序执行模型,恰恰与并行执行模型不匹配。GIL 的出现无意中帮助了开发者免于陷入困境。在使用多线程时仍然需要同步原语,GIL 事实上帮助我们保证不同线程之间的数据一致性。

这么说起来 Python 最难的问题似乎有点问错了问题。Python 专家推荐使用多进程代替多线程是有道理的,而不是想要给 Python 线程实现遮羞。Python 的这种实现方式促使开发者使用更安全也更直观的方式实现并发模型,同时保留使用多线程进行开发,让开发者在必要的时候使用。大多数人可能并不清楚什么是最好的并行编程模型。但是大多数人都清楚多线程的方式并不是最好的并行模型。

不要认为 GIL 是一成不变或者毫无道理的。Antoine Pitrou 在 Python 3.2 中实现了一个新的 GIL ,比较显著地改进的 Python 解释器。这是1992年以来,针对 GIL 最主要的一次改进。这个改变非常巨大,很难在这里解释清楚,但是从高层次来看,旧的 GIL 通过对 Python 指令进行计数来确定何时释放 GIL。由于 Python 指令和翻译成的机器指令并非一一对应的关系,这使得单条 Python 指令可能包含大量工作。新的 GIL 用一个固定的超时时间来指示当前的线程释放锁。在当前线程持有锁且第二个线程请求这个锁的时候,当前线程就会在 5 ms 后被强制释放这个锁(这就是说,当前线程每 5 ms 就要检查其是否需要释放这个锁)。在任务可以执行的情况下,这使得预测线程间的切换变得更容易。

然而,这并不是一个完美的改进。对于不同类型任务执行过程中 GIL 的作用的研究,David Beazley 可能是最活跃的一个。除了对 Python 3.2 之前的 GIL 研究最深入,他还研究了这个最新的 GIL 实现,并且发现了很多有趣的程序方案:在这些方案中,即使是新的 GIL 实现,表现也相当糟糕。他目前仍然通过实践研究来推动着有关 GIL 的讨论,并发布实践结果。

不管人们对 Python 的 GIL 看法如何,它仍然是 Python 语言里最困难的技术挑战。想要理解它的实现需要对操作系统设计、多线程编程、C 语言、解释器设计和 CPython 解释器的实现有着非常透彻的理解。单是这些前提就妨碍了很多开发者去更彻底地研究 GIL。然而并没有任何迹象表明 GIL 会在不久之后远离我们。目前,它将继续给那些新接触 Python 并对解决技术难题感兴趣的人带来困惑和惊喜。

以上内容是基于我目前对 Python 解释器的研究。我打算写一些关于解释器其它方面的内容,但是没有比 GIL 知名度更高的了。虽然这些技术细节来自我对 CPython 代码库的彻底研究,但是仍有可能存在不准确的地方。如果你发现了不准确的内容,请及时告知我,我会尽快修正。

1 2 收藏 评论

关于作者:Coureur

简介还没来得及写 :) 个人主页 · 我的文章 · 10

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部