原文:Python, the GIL, and Pyston
最近,我一直在思考Pyston中对并行的支持 —— 这在我的“愿望清单”里待了很长一段时间。CPython中的并行状态是个痛点,因为GIL(“全局解释锁”)基本上是强制单线程执行的。应当指出的是,GIL并非CPython特有的:其他实现,例如PyPy,也有(虽然PyPy有他们自己的STM努力来摆脱它),而其他语言运行时也有。从技术上讲,GIL是实现的一个特性,而不是一种语言,因此看起来,实现应该可以自由地使用基于非GIL的策略。
“使用非基于GIL的策略”最棘手的部分是,我们仍然必须为语言提供正确的语义。而且,随着我进一步深入,有一些GIL衍生的语义已经成为了Python语言的一部分,并且无论它们是否实际上使用了GIL,都必须兼容实现。这里有一些我一直在思考的问题:
想象一下,你有两个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,虽然有点让人反感,但是它确实在保持锁开销低的同时很好的提供了线程安全。
有一些大多数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实现开始出现时,我们不得不戒掉立即释放这个习惯。我觉得,虽然内存模型更不容易改变,但是这并不是说我们应该改变。
Pyston的目标之一是支持未修改的CPython C扩展;不幸的是,这造成了相当大的并行性问题。对于Python代码,我们只需要保证每个字节码是原子的,而可以在任意两个字节码直接释放GIL。对于C扩展代码,需要做一个更大的承诺:不会释放GIL,除非C代码明确要求。这意味着,C扩展可以自由的选择是否线程安全,因为除非要求,否则他们绝不并行运行。因此,尽管我猜并不是很多扩展明确的利用GIL存在这一事实,但我强烈怀疑所有的C扩展代码,一点都不考虑线程安全的话,会最终奇迹般的变成线程安全。所以,不管Python级别的代码是如何处理的,我们都必须(默认地)串行运行C扩展代码。
所以,任何线程实现必须满足不少限制,这通过使用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有关;是时候提高多线程性能调试了。