Python的快速并行版本:PyParallel

jopen 10年前

PyParallel是Trent Nelson发起的一个研究项目,其目标是以提供高性能异步支持的方式将Windows I/O完成端口(IOCP)的强大功能移到Python中。

Python的异步支持多少有点问题。它是围绕Unix/Linux的异步、非阻塞I/O理念设计的。线程会持续轮询进入的数据,然后相应进行分 发。尽管Linux针对该模式进行了调优,但在Windows机器上,这种处理方式是性能的灾难。将数据从轮询线程复制到真正处理任务的线程,非常昂贵。

PyParallel带来的就是使用了原生IOCP的真正的异步。在IOCP模型下,每个核有一个线程。每个线程负责处理完成I/O请求(比如,从网卡复制数据)和执行请求关联的应用层回调。

只有这一点尚不足以横向扩展Python;还需要解决GIL(Global Interpreter Lock,全局解释器锁)带来的问题。否则我们仍然被限制于每次执行一个线程。使用细粒度的锁替换GIL,结果会更糟糕;像PyPy中的软件事务内存往往 最终会导致1个线程继续推进,N-1个线程持续重试的问题。所以我们需要别的解决方案。

对PyParallel团队来说,这个解决方案就是不允许自由创建线程。换言之,应用不能随意创建新线程。相反,并行操作被绑定到异步回调机制和并行上下文(parallel context)的概念。

在深入并行上下文之前,我们先反过来看一下。当并行上下文不运行的时候,主线程会运行;反之亦然。主线程就是你进行正常的Python开发所考虑的东西。主线程持有GIL,对全局命名空间具有完全的访问权限。

                                         

相反,并行上下文对全局命名空间只能进行只读访问。这意味着,开发者需要注意某个事物是主线程对象还是并行上下文对象。处理过套间线程模型(apartment threading models)的COM程序员对其中的痛苦是再清楚不过了。

对于非I/O任务,主线程使用async.submit_work函数对任务进行排队,然后使用async.run 函数切换到并行上下文。这会挂起主线程,并激活并行解释器。多个并行上下文可以同时运行,由Windows操作系统处理线程池的管理。

与GIL并行

有一点非常重要,需要注意一下,这里并没有创建多个进程。尽管多进程技术在Python开发中很常用,但PyParallel将所有东西都放在了一个进程中,以减少跨进程通信的代价。这通常是不允许的,因为CPython解释器不是线程安全的,这包括:

  • 全局静态数据会频繁用到

  • 引用计数不是原子的

  • 对象没有用锁保护

  • 垃圾收集不是线程安全的

  • 拘留字符串(Interned string)的创建不是线程安全的

  • bucket内存分配器不是线程安全的

  • arena内存分配器不是线程安全的

Greg Stein曾尝试通过向Python 1.4中加入细粒度的锁来解决该问题,但是在单线程代码中,他的项目导致速度下降40%,所以被拒绝了。因此Trent Nelson决定采用不同的方案。在主线程中,GIL和原来一样运作。但是当在并行上下文中运行的时候,会使用线程安全的替换方案代替核心函数来运行。

Trent的方案的代价是0.01%,比Greg的方案好得多。至于PyPy的软件事务内存,对单线程模型而言,其代价大概是200~500%。

该设计的一个有趣的地方是,在并行上下文中运行的代码,当要从全局命名空间中的对象中读取数据时,不需要获得锁。不过它只有读的能力。

PyParallel没有垃圾收集器

为了避免处理内存分配、存取和垃圾收集相关的锁,PyParallel使用了一种无共享模式。每个并行上下文都有自己的堆,没有垃圾收集器。就是这样,没有与并行上下文关联的垃圾收集器。因此实际上是这样:

  • 内存分配使用一个简单的块分配器完成。每次内存分配只是调整一下指针。

  • 根据需要分配4K或2MB大小的新页面,这由并行上下文的大页面设置控制。

  • 不使用引用计数。

  • 当并行上下文结束时,与它关联的所有页面同时释放。

这种设计避免了线程安全的垃圾收集器或线程安全的引用计数的代价。另外,它支持前面提到的块分配器,这可能是最快的内存分配方式了。

PyParallel团队认为这种设计可以成功,因为并行上下文意在支持生命周期较短、范围较为有限的应用。一个很好的例子是并行排序算法或Web页面请求处理程序。

为使这种设计正常工作,在并行上下文中创建的对象不能逃逸到主线程中。这是通过只读访问全局命名空间这一限制来保证的。

引用计数与主线程对象

这时我们有两类对象:主线程对象和并行上下文对象。主线程对象会使用引用计数进行管理,因为它们在某一时刻需要回收。但并行上下文对象没有使用引用计数。但如果两类对象有相互作用,那该如何处理呢?

因为并行上下文对象不能修改主线程对象,所以它就不能改变主线程对象的引用计数。但是又因为,当并行上下文运行时,主线程的垃圾收集器无法运行,所以这就不是问题了。当主线程的垃圾收集启动时,所有的并行上下文对象都已经销毁,所以没有从它们指回到主线程对象的东西。

这一切的最终结果是,在并行上下文中执行的代码通常比在主线程中执行的代码快。

并行上下文与异步I/O

当考虑异步I/O调用时,上面讨论的内存模型就有问题了。这些调用会使并行上下文存活的时间比系统设计的存活时间长得多。像Web页面请求处理程序这样的情况,调用的数目是没有限制的。

为处理这一问题,Trent加入了快照(snapshot)的概念。当一个异步回调开始时,就为并行上下文的内存保存一个快照。在回调的最后,所有 改变都会被恢复,新分配的内存也会释放。这比较适合无状态应用,比如Web页面请求处理程序,但是对需要保持数据的应用就不合适了。

快照最多可以嵌套64层深,但是Trent没有详细描述其处理细节。

平衡同步与异步I/O

异步I/O不是免费的午餐。如果想获得最大的吞吐量,同时保持最低的延迟,同步I/O实际上更快。但只有并发请求数比可用的核数少时,这才成立。

因为开发者不一定会随时了解负载情况,所以指望他决策可能是不合理的。因此PyParallel提供了一个套接字(socket)库,可以在运行时 根据活动的客户端数做出决策。只要活动客户端的数目比核数少,就执行同步代码。如果客户端数超过了核数,该库会自动切换到异步模式。不管哪种方式,这里的 修改对应用都是透明的。

异步HTTP服务器

作为概念验证的一部分,PyParallel还提供了一个异步HTTP服务器,它基于stdlib 中的SimpleHttpServer。它的一个主要特性是支持Win32函数TransmitFile,允许数据直接从文件缓存发送到套接字。

未来计划

未来,Trent希望继续改进内存模型,准备通过引入一组新的互锁(interlocked)数据类型和使用上下文管理器控制内存分配协议来实现。

Numba的集成也正在进行之中。想法是异步启动Numba,当Numba完成时,将CPython交换出去,换为本机生成的代码。

另一个计划中的变化是支持可插拔的PxSocket_IOLoop端点。这就允许不同的协议以流水线方式链到一起。在可能的情况下,他想使用管道代替套接字,因为这可以减少在多个步骤之间所要复制的必要数据的量。

更多信息,可以查看Trent Nelson的演讲:PyParallel - How We Removed the GIL and Exploited All Cores (Without Needing to Remove the GIL at all)

来自 InfoQ