Python、GIL 和 Pyston
最近,我一直在思考 Pyston 中对并行的支持 —— 这在我的“愿望清单”里待了很长一段时间。CPython 中的并行状态是个痛点,因为 GIL(“全局解释锁”)基本上是强制单线程执行的。应当指出的是,GIL 并非 CPython 特有的:其他实现,例如 PyPy,也有(虽然 PyPy 有他们自己的 STM 努力来摆脱它),而其他语言运行时也有。从技术上讲,GIL 是实现的一个特性,而不是一种语言,因此看起来,实现应该可以自由地使用基于非 GIL 的策略。
“使用非基于 GIL 的策略”最棘手的部分是,我们仍然必须为语言提供正确的语义。而且,随着我进一步深入,有一些 GIL 衍生的语义已经成为了 Python 语言的一部分,并且无论它们是否实际上使用了 GIL,都必须兼容实现。这里有一些我一直在思考的问题:
问题 1:数据结构线程安全
想象一下,你有两个 Python 线程,它们都试图往一个列表追加一个项。比方说,一开始该列表是空的,而线程分别试图追加"1"和"2":
l = []
def thread1():
l.append(1)
def thread2():
l.append(2)
那么,之后该列表允许的内容是什么呢?显然,"[1, 2]"和"[2, 1]"是允许的。允许"[1]"吗?那"[1, 1]"呢?"[1, ]"呢?除了"[1, 2]"和"[2, 1]",我想这些答案都不是,特别不是最后一个。Python 中的数据结构当前保证是线程安全的,而大多数的基本操作,例如“追加”,目前保证是原子的。
即使我们可以以某种方式说服大家,内置的列表不应该是一个线程安全的数据结构,但是完全将所有的同步抛出窗口外当然不行:我们最终会让列表中出现带垃圾的不一致的数据结构,打破该语言的内存安全性。所以,不管是什么,都需要让所有的内置类型都具有一定的线程安全。
人们一直在为线程构建线程安全的数据结构,因此解决这个问题并不需要任何激进的新思路。不过,这个问题是,由于它应该应用到 Python 程序可能需要的所有操作,有可能会有一个非常大的锁定/同步开销。一个 GIL,虽然有点让人反感,但是它确实在保持锁开销低的同时很好的提供了线程安全。
问题 2:内存模型
有一些大多数 Python 程序员不用考虑的东西(因为他们不必考虑),但是“内存模型”指定了允许一个线程观察另一个线程影响的潜在方式。比方说,我们有一个线程运行:
a = b = 0
def thread1():
global a, b
a = 1
b = 2
然后,我们有第二个线程:
def thread2():
print b
print a
允许 thread2 打印什么?由于没有同步,它可以清楚地打印"0, 0", "0, 1", 或者"2, 1"。虽然,在许多编程语言中,对于 thread2 打印"2, 0"是可接受的,但这似乎是一个矛盾:如果 a 还没有设置值,那 b 怎么会有值?答案是,内存模型通常是,不保证线程看到任何顺序的其他线程的修改,除非有某种同步。(在这种特殊情况下,我想 x86 内存模型任务这并不会发送,但这是另外一回事了。)回到 CPython,GIL 规定,我们需要的“某种同步”(GIL 释放然后 GIL 持有将会迫使所有的更新被看到),所以保证我们不会看到任何重新排序的有趣业务:CPython 有一个名为“顺序一致性”的强大的内存模型。
虽然这在技术上可以被认为只是 CPython 的一个特性,而似乎有这实际上是语言规范的一部分这种共识。虽然可以而且应该对这是否应该是指定的内存模型进行争论,但是我想,问题的事实是,必须有依赖于顺序一致性的模型的代码在那里,而 Pyston 将必须提供它。
有一些改变语言保证的先例 —— 当 GC'd 实现开始出现时,我们不得不戒掉立即释放这个习惯。我觉得,虽然内存模型更不容易改变,但是这并不是说我们应该改变。
问题 3:C 扩展
Pyston 的目标之一是支持未修改的 CPython C 扩展;不幸的是,这造成了相当大的并行性问题。对于 Python 代码,我们只需要保证每个字节码是原子的,而可以在任意两个字节码直接释放 GIL。对于 C 扩展代码,需要做一个更大的承诺:不会释放 GIL,除非 C 代码明确要求。这意味着,C 扩展可以自由的选择是否线程安全,因为除非要求,否则他们绝不并行运行。因此,尽管我猜并不是很多扩展明确的利用 GIL 存在这一事实,但我强烈怀疑所有的 C 扩展代码,一点都不考虑线程安全的话,会最终奇迹般的变成线程安全。所以,不管 Python 级别的代码是如何处理的,我们都必须(默认地)串行运行 C 扩展代码。
潜在实现策略:GRWL
所以,任何线程实现必须满足不少限制,这通过使用 GIL 可以容易并自然的满足。正如我所提到的,这些问题并不是特别新颖;有完善的(虽然也许难以实现)解决方法。但问题是,由于我们必须在语言运行层次来解决,因此对所有的代码,我们将承担这些同步成本,而且目前还不清楚这是否最终会比使用 GIL 拥有更好的性能权衡。你可以潜在得到更好的并行性,虽然受内存模型所限,以及 C 扩展必须串行的事实,但你很有可能必须牺牲一定量的单线程性能。
目前,我正在考虑使用全局读写锁(Global Read- Write Lock,GRWL)来实现这些功能。我们的想法是,通常允许线程并行运行,除了强制顺序执行这种特定的情况下(C 扩展代码,GC 收集器代码)。这自然而然表示为读写锁:正常的 Python 代码在一个 GRWL 中保存读锁,而串行代码必须获得一个写锁。(还有一种允许它完全不持有锁的代码,例如做 IO 的时候)。这似乎是从语言的语义到同步原语的一个非常直接的映射,所以我觉得这是一个很好的 API。
Pyston 中,我有一个原型实现; 这不错,因为 GRWL API 是 GIL API 的一个超集,这意味着简单地改变一些编译时标志就可以在它们之间进行代码库切换。迄今的结果并不那么明显:与 GIL 实现相比,GRWL 具有更糟的单线程性能和并行性 —— 两个运行线程的吞吐量相当于在一个线程的总吞吐量的 45%,而 GIL 实现有 75% [好吧,显然对这两种方案都有一些改进]。
但它能用!(只要你只使用列表,因为我还没有添加锁到其它类型)。这只是表明,简单地删除 GIL 并不难 —— 难的是让更换速度更快。我会花一点点时间分析为什么表现不如我所想的那样,因为现在它似乎看起来有点可笑。希望不久会有好消息,但话又说回来,如果结论是,GIL 提供了一个无与伦比的付出-获得的权衡,那么我并不会感到惊讶。
更新:基准
所以我花了一些时间调整一些东西; 第一个变化是,我替换了互斥实现的选择。默认的 glibc pthread 互斥是 PTHREAD_MUTEX_TIMED_NP,这显然是为了提供 POSIX 规范的特性而牺牲吞吐量。在我做了一些分析后,我注意到,我们把时间都花在了在内核总做 futex 的操作,所以我切换到 PTHREAD_MUTEX_ADAPTIVE_NP,它在推迟到内核进行处理之前,在用户空间中做一些操作。性能提升是相当不错的(约 50%速度的提升),但我想我们失去了一些调度的公平性。
我修改的第二件事是降低了用来检查我们是否应该释放 GRWL 的频率。我不明白为什么这样有用,因为很少有等待者(waiter),检查是否有其他的等待者应该是非常快(并不需要一个原子操作)。但是,这使得它获得额外的 100%的速度提升。
这里有一些让我激动的结果。这里测试了三个 Pyston 版本:作为基线的一个没有 GIL 或者 GRWL 的“不安全”的版本,一个 GIL 版本,和一个 GRWL 版本。我将其运行在几个不同的微基准上:
unsafe GIL GRWL
raytrace.py [single threaded] 12.3s 12.3s 12.8s
contention_test.py, 1 thread N/A 3.4s 4.0s
contention_test.py, 2 threads N/A 3.4s 4.3s
uncontended_test.py, 1 thread N/A 3.0s 3.1s
uncontended_test.py, 2 threads N/A 3.0s 3.6s
所以…事情好起来了,但即使在无竞争的测试中,其中 GRWL 应该要脱颖而出,但是它仍然比 GIL 差。我觉得这跟 GC 有关;是时候提高多线程性能调试了。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论