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) 。
关于作者
Jonathan Allen从 2006 年开始就为 InfoQ 编写新闻报道了, 目前他是.NET 板块的主编。如果你有兴趣为 InfoQ 编写新闻和技术文章,请通过jonathan@infoq.com 联系他。
评论