Python-分布式计算-全-

Python 分布式计算(全)

零、序言 (Distributed Computing with Python)


序言
第 1 章 并行和分布式计算介绍
第 2 章 异步编程
第 3 章 Python 的并行计算
第 4 章 Celery 分布式应用
第 5 章 云平台部署 Python
第 6 章 超级计算机群使用 Python
第 7 章 测试和调试分布式应用
第 8 章 继续学习


Python 分布式计算


作者简介

Francesco Pierfederici 是一名喜爱 Python 的软件工程师。过去 20 年间,他的工作领域涉及天文学、生物学和气象预报。

他搭建过上万 CPU 核心的大型分布式系统,并在世界上最快的超级计算机上运行过。他还写过用处不大,但极为有趣的应用。他总是喜欢创造新事物。

“我要感谢我的妻子 Alicia,感谢她在成书过程中的耐心。我还要感谢 Packt 出版社的 Parshva Sheth 和 Aaron Lazar,以及技术审稿人 James King,他们让这本书变得更好。” —— Francesco Pierfederici


审稿人简介

James King 是一名有丰富分布式系统开发经验的工程师。他是许多开源项目的贡献者,包括 OpenStack 和 Mozilla Firefox。他喜欢数学、与孩子们骑马、游戏和艺术。


序言

并行和分布式计算是一个具有吸引力的课题,几年之前,只有大公司和国家实验室的开发者才能接触到。这十年间,情况发生了改变:现在所有人都可以使用各种语言搭建中小型的分布式应用,这些语言中自然包括我们的最爱:Python。

这本书是为搭建分布式系统的 Python 开发者而写的实践指导。它首先介绍了关于并行和分布式计算的基础理论。然后,用 Python 的标准库做了几个并行计算示例。接着,不再使用一台计算机,而是使用第三方库,包括 Celery 和 Pyro,扩展到更多节点。

剩下的章节探讨了分布式应用的部署方案,包括云平台和超级计算机群(High Performance Computing,HPC),分析了各自的优势和难点。

最后,分析了一些难点,监控、登录、概述和调试。

总之,这是一本关注实践的书,它将教会你使用一些流行的框架和方法,使用 Python 搭建并行分布系统。

本书的内容

第 1 章,并行和分布式计算介绍,介绍基础理论。
第 2 章,异步编程,介绍两种分布式应用的编程风格:同步和异步。
第 3 章,Python 的并行计算,介绍使用 Python 的标准库,实现同一时间完成多项任务。
第 4 章,Celery 分布式应用,介绍如何使用 Celery 搭建最简单的分布式应用,以及 Celery 的竞争对手 Python-RQ 和 Pyro。
第 5 章,云平台使用 Python,展示如何使用 AWS 将 Python 应用部署到云平台。
第 6 章,超级计算机群使用 Python,介绍将 Python 应用部署到超级计算机群,多应用于大学和国家实验室。
第 7 章,测试和调试分布式应用,讲解了 Python 分布式应用在测试、概述和调试中的难点。
第 8 章,继续学习,回顾前面所学,向感兴趣的读者介绍继续学习的路径。


序言
第 1 章 并行和分布式计算介绍
第 2 章 异步编程
第 3 章 Python 的并行计算
第 4 章 Celery 分布式应用
第 5 章 云平台部署 Python
第 6 章 超级计算机群使用 Python
第 7 章 测试和调试分布式应用
第 8 章 继续学习


一、并行和分布式计算介绍 (Distributed Computing with Python)

本书示例代码适用于 Python 3.5 及以上。


当代第一台数字计算机诞生于上世纪 30 年代末 40 年代初(Konrad Zuse 1936 年的 Z1 存在争议),也许比本书大多数读者都要早,比作者本人也要早。过去的七十年见证了计算机飞速地发展,计算机变得越来越快、越来越便宜,这在整个工业领域中是独一无二的。如今的手机,iPhone 或是安卓,比 20 年前最快的电脑还要快。而且,计算机变得越来越小:过去的超级计算机能装下整间屋子,现在放在口袋里就行了。

这其中包括两个重要的发明。其一是主板上安装多块处理器(每个处理器含有多个核心),这使得计算机能真正地实现并发。我们知道,一个处理器同一时间只能处理同一事务;后面章节我们会看到,当处理器快到一定程度,就可以给出同一时间进行多项任务的假象。若要真正实现同一时间多任务,就需要多个处理器。

另一项发明是高速计算机网络。它首次让无穷多的电脑实现了相互通讯。联网的电脑可能处于同一地点(称为局域网 LAN)或分布在不同地点(称为广域网 WAN)。

如今,我们都已熟悉多处理器/多核心计算机,事实上,我们的手机、平板电脑、笔记本电脑都是多核心的。显卡,或图形处理器(GPU),往往是大规模并行机制,含有数百乃至上千个处理单元。我们周围的计算机网络无处不在,包括:Internet、WiFi、4G 网络。

本章剩余部分会探讨一些定义。我们会介绍并行和分布式计算的概念。给出一些常见的示例。探讨每个架构的优缺点,和编程的范式。

在开始介绍概念之前,先澄清一些东西。在剩余部分中,除非特别指明,我们会交叉使用处理器和 CPU 核心。这在概念上显然是不对的:一个处理器会有一个或多个核,每台计算机会有一个或多个处理器。取决于算法和性能要求,在多处理器或单处理器多核的计算机上运行可能会有速度上的不同,假定算法是并行的。然而,我们会忽略这些差异,将注意力集中于概念本身。

并行计算

并行计算的概念很多。本书提供一个简洁的概念:

并行计算是同时使用多个处理器处理事务。

典型的,这个概念要求这些处理器位于同一块主板,以区别于分布式计算。

分工的历史和人类文明一样久远,也适用于数字世界,当代计算机安装的计算单元越来越多。

并行计算是有用且必要的原因很多。最简单的原因是性能;如果我们要把一个冗长的计算分成小块、打包给不同的处理器,就可以在相同时间内完成更多工作。

或者,并行计算在处理一项任务时,还可以向用户呈现反馈界面。记住一个处理器同一时间只能处理一项任务。有 GUIs 的应用需要将任务交付给另一个处理器的独立线程,以让另一个处理器能更新 GUI,并对输入进行反馈。

下图展示了这个常见的架构,主线程使用事件循环(Event Loop)处理用户和系统输入。需要长时间处理的任务和会阻塞 GUI 的任务会被移交给后台或 worker 线程:

一个该并行架构的实际案例可以是一个图片应用。当我们将数码相机或手机连接到电脑上时,图片应用会进行一系列动作,同时它的用户界面要保持交互。例如,应用要将图片从设备拷贝到硬盘上、建立缩略图、提取元数据(拍摄日期及时间)、生成索引、最后更新图片库。与此同时,我们仍然可以浏览以前传输的图片,打开图片、进行编辑等等。

当然,整个过程在单处理器上可能是顺序依次进行的,这个处理器也要处理 GUI。这就会造成用户界面反应迟缓,整个应用会变慢。并行运行可以使这个过程流畅,提高用户体验。

敏锐的读者此时可能指出,以前的只有单处理器单核的旧电脑也可以(通过多任务)同时处理多个事件。即使如今,也可以让运行的任务数超过计算机的处理器数目。其实,这是因为一个正在运行的任务被从 CPU 移出(这可能是自发或被操作系统强制的,例如,响应 IO 事件),好让另一个任务可以在 CPU 上运行。类似的中断会时而发生,在应用运行中,各种任务会相继获得会被移出 CPU。切换通常很快,这样,用户就会有计算机并行运行任务的感觉。实际上,只有一个任务在特定的时间运行。

通常在并行应用中运行的工具是线程。系统(比如 Python)通常对线程有严格的限制(见第 3 章),开发者要转而使用子进程 subprocess(通常的方法是分叉)。子进程取代(或配合)线程,与主进程同时运行。

第一种方法是多线程编程(multithreaded programming)。第二种方法是多进程(multiprocessing)。多进程可以看做是多线程的变通。

许多情况下,多进程要优于多线程。有趣的是,尽管二者都在单机运行,多线程是共享内存架构的例子,而多进程是分布式内存架构的例子(参考本书后续内容)。

分布式计算

本书采用如下对分布式计算的定义:

分布式计算是指同一时间使用多台计算机处理一个任务。

一般的,与并行计算类似,这个定义也有限制。这个限制通常是要求,对于使用者,这些计算机可以看做一台机器,进而掩盖应用的分布性。本书中,我们更喜欢这个广义的定义。

显然,只有当计算机之间互相连接时,才可以使用分布式计算。事实上,许多时候,这只是对我们在之前部分的并行计算的概念总结。

搭建分布式系统的理由有很多。通常的原因是,要做的任务量太大,一台计算机难以完成,或是不能在一定时间内完成。一个实际的例子就是皮克斯或梦工厂的 3D 动画电影渲染。

考虑到整部电影要渲染的总帧数(电影两个小时,每秒有 30 帧),电影工作室需要将海量的工作分配到多台计算机(他们称其为计算机农场)。

另外,应用本身需要分布式的环境。例如,即时聊天和视频会议应用。对于这些应用,性能不是最重要的。最关键的是,应用本身要是分布式的。下图中,我们看到一个非常常见的网络应用架构(另一个分布式应用例子),多个用户与网站相连。同时,应用本身要与 LAN 中不同主机的系统(例如数据库服务器)通讯:

另一个分布式系统的例子,可能有点反直觉,就是 CPU-GPU。如今,显卡本身就是很复杂的计算机。它们高并行运行,处理海量计算密集型任务,不仅是为了在显示器上显示图像。有大量的工具和库(例如 NVIDIA 的 CUDA,OpenCL 和 OpenAcc)可以让开发者对 GPU 进行开发,来做广义计算任务。(译者注:比如在比特币中,使用显卡编程来挖矿。)

然而,CPU 和 GPU 组成的系统实际上就是一个分布式系统,网络被 PCI 总线取代了。任何要使用 CPU 和 GPU 的应用都要处理数据在两个子系统之间的移动,就像传统的在网络中运行的应用。

将现存的代码移植到计算机网络(或 GPU)不是一件轻松的工作。要移植的话,我发现先在一台计算机上使用多进程完成,是一个很有用的中间步骤。我们会在第 3 章看到,Python 有强大的功能完成这项任务(参考concurrent.futures模块)。

一旦完成多进程并行运行,就可以考虑将这些进程分拆给独立的应用,这就不是重点了。

特别要注意的是数据,在哪里存储、如何访问。简单情况下,共享式的文件系统(例如,UNIX 的 NFS)就足够了;其余情况,就需要数据库或是消息队列。我们会在第 4 章中看几个这样的实例。要记住,真正的瓶颈往往是数据而不是 CPU。

共享式内存 vs 分布式内存

在概念上,并行计算和分布计算很像,毕竟,二者都是要将总计算量分解成小块,再在处理器上运行。有些读者可能会想,一种情况下,使用的处理器全部位于一台计算机之内,另一种情况下,处理器位于不同的计算机。那么,这种技术是不是有点多余?

答案是,可能。正如我们看到的,一些应用本身是分布式的。其它应用则需要更多的性能。对于这些应用,答案就可能是“有点多余”——应用本身不关心算力来自何处。然而,考虑到所有情况,硬件资源的物理存放地点还是有一定意义的。

也许,并行和分布式计算的最明显的差异就是底层的内存架构和访问方式不同。对于并行计算,原则上,所有并发任务可以访问同一块内存空间。这里,我们必须要说原则上,因为我们已经看到并行不一定非要用到线程(线程是可以访问同一块内存空间)。

下图中,我们看到一个典型的共享式内存架构,四个处理器可以访问同一内存地址。如果应用使用线程,如果需要的话,线程就可以访问同一内存空间:

然而,对于分布式应用,不同的并发任务不能正常访问同一内存。原因是,一些任务是在这一台计算机运行,一些任务是在另一台计算机运行,它们是物理分隔的。

因为计算机之间可以靠网络通讯,可以想象写一个软件层(中间件),以一个统一的内存逻辑空间呈现应用。这些中间件就是分布式共享内存架构。此书不涉及这样的系统。

下图中,我们还有有四个 CPU,它们处于共享内存架构中。每个 CPU 都有各自的私有内存,看不到其它 CPU 的内存空间。四台计算机(包围 CPU 和内存的方框)通过网络通讯,通过网络进行数据传输:

现实中,计算机是我们之前讲过的两种极端情况的结合体。计算机网络通讯就像一个纯粹的分布式内存架构。然而,每台计算机有多个处理器,运行着共享式内存架构。下图描述了这样的混合式架构:

这些架构有各自的优缺点。对于共享式内存系统,在单一文件的并发线程中分享数据速度极快,远远快过网络传输。另外,使用单一内存地址可以简化编程。

同时,还要注意不要让各个线程发生重叠,或是彼此改变参数。

分布式内存系统扩展性强、组建成本低:需要更高性能,扩展即可。另一优点是,处理器可以访问各自的内存,不必担心发生竞争条件(竞争条件指多个线程或者进程在读写一个共享数据时,结果依赖于它们执行的相对时间的情形)。它的缺点是,开发者需要手动写数据传输的策略,需要考虑数据存储的位置。另外,不是所有算法都能容易移植到这种架构。

阿姆达尔定律

本章最后一个重要概念是阿姆达尔定律。简言之,阿姆达尔定律是说,我们可以尽情并行化或分布化计算,添加算力资源获得更高性能。然而,最终代码的速度不能比运行在单处理器的单序列(即非并行)的组件要快。

更正式的,阿姆达尔定律有如下公式。考虑一个部分并行的算法,称P为并行分量,S为序列分量(即非并行分量),P+S=100%T(n)为运行时间,处理器数量为n。有如下关系:

这个公式转化成白话就是:在n个处理器上运行这个算法的时间大于等于,单处理器上运行序列分量的时间S*T(1)加上,并行分量在单处理器上运行的时间P*T(1)除以n

当提高处理器的数量n,等式右边的第二项变得越来越小,与第一项对比,逐渐变得可以忽略不计。此时,这个公式近似于:

这个公式转化成白话就是:在无限个处理器上运行这个算法的时间近似等于序列分量在单处理器上的运行时间S*T(1)

现在,思考一下阿姆达尔定律的意义。此处的假定过于简单,通常,我们不能使算法完全并行化。

也就是说,大多情况下,我们不能让S=0。原因有很多:我们可能必须要拷贝数据和/或代码到不同的处理器可以访问的位置。我们可能必须要分隔数据,将数据块在网络中传输。可能要收集所有并发任务的结果,并进行进一步处理,等等。

无论原因是什么,如果不能使算法完全并行化,最终的运行时间取决于序列分量的表现。并行化的程度越高,加速表现越不明显。

题外话,完全并行通常称作密集并行(Embarrassingly parallel),或者用政治正确的话来说,愉悦并行(pleasantly parallel),它拥有最佳的扩展性能(速度与处理器的数量呈线性关系)。当然,对此不必感到尴尬!不幸的是,密集并行很少见。

让我们给阿姆达尔定律添加一些数字。假定,算法在单处理器耗时 100 秒。再假设,并行分量为 99%,这个数值已经很高了。添加处理器的数量以提高速度。来看以下计算:

我们看到,随着n的提高,加速的效果不让人满意。使用 10 个处理器,是 9.2 倍速。使用 100 个处理器,则是 50 倍速。使用 1000 个处理器,仅仅是 91 倍速。

下图描述了倍速与处理器数量的关系。无论使用多少处理器,也无法大于 100 倍速,即运行时间小于 1 秒,即小于序列分量运行的时间。

阿姆达尔定律告诉我们两点:我们最快可以将倍速提高到多少;收益减少时,何时应该减少硬件资源的投入。

另一有趣的地方是阿姆达尔定律适用于分布式系统和混合并行-分布式系统。这时,n等于所有计算机的处理器总数目。

随着能接触的系统的性能变得越来越高,如果能使用剩余性能,还可以缩短分布式算法运行的时间。

随着应用的的执行时间变短,我们就倾向于处理更复杂的问题。对于这样的算法进化,即问题规模的扩大(计算要求的扩大)达到可接受的性能时,称作古斯塔夫森定律。

混合范式

我们现在能买到的电脑大多是多处理器多核的,我们将要写的分布式应用就是要这样的电脑上运行。这使得我们可以既开发分布式计算,也可以开发并行式计算。这种混合分布-并行范式是如今开发网络分布应用的事实标准。现实通常是混合的。

总结

这一章讲了基础概念。我们学习了并行和分布式计算,以及两个架构的例子,探讨了优缺点。分析了它们是如何访问内存,并指出现实通常是混合的。最后讲了阿姆达尔定律,它对扩展性能的意义,硬件投入的经济考量。下一章会将概念转化为实践,并写 Python 代码!

二、异步编程 (Distributed Computing with Python)

从本章开始,终于开始写代码了!本书中所有的代码都适用于 Python 3.5 及以上版本。当模块、语句或语法结构不适用于以前的版本时(比如 Python 2.7),会在本章中指出。进行一些修改,本书代码也可以运行在 Python 2.x 版本上。

先回顾下上一章的知识。我们已经学到,改变算法的结构可以让其运行在本地计算机,或运行在集群上。即使是在一台计算机上运行,我们也可以使用多线程或多进程,让子程序运行在多个 CPU 上。

现在暂时不考虑多 CPU,先看一下单线程/进程。与传统的同步编程相比,异步编程或非阻塞编程,可以使性能获得极大提高。

任何包含多任务的程序,它的每个每个任务都在执行一个操作。我们可以把这些任务当做功能或方法,也可以把几个任务合并看做一个功能。例如,将总任务细分、在屏幕打印内容、或从网络抓取信息,等等。

看一下传统程序中的这些任务是如何使用一个 CPU 的。考虑一个原生的实例,它有四个任务:A、B、C、D。这些任务具体是做什么在这里不重要。我们可以假设这四个任务是关于计算和 I/O 操作的。安排这四个任务的最直观的方式是序列化。下图展示了这四个任务对 CPU 的使用:

我们看到,当每个任务都执行 I/O 操作时,CPU 处于空闲状态,等待任务进行计算。这使得 CPU 大部分时间处于闲置状态。

重点是,从不同组件,例如硬盘、内存和网络,向 CPU 传递数据的速度相差极大(几个数量级)。

这就会造成任何进行大量 I/O 操作(访问硬盘、网络通讯等等)的代码都极有可能造成 CPU 大部分时间闲置,如上图所示。

理想的状态应该是安排一下任务,当一个任务等待 I/O 时,它处于悬停状态,就让另一个任务接管 CPU。这就是异步(也称为事件驱动)编程。

下图生动地展示了用异步编程的方式安排四个任务:

任务仍然是序列的,但是不再各自占用 CPU 直到任务结束,任务不需要计算时,它们会自发地放弃 CPU。尽管 CPU 仍有闲置,程序的总运行时间明显缩短了。

使用多线程在不同的线程并行运行,也可以达到同样的效果。但是,有一个显著的不同:使用多线程时,是由操作系统决定哪个线程处于运行或悬停。然而,在异步编程中,每个任务可以自己决定是否放弃 CPU。

另外,单单使用异步编程,我们不能做出真正的并发:同一时间仅仅有一个任务在运行,消除了竞争条件。当然,我们可以混合使用多线程/多进程和异步编程。

另一点要注意的是,异步编程更善于处理 I/O 密集型任务,而不是 CPU 密集型任务(暂停任务不会使性能提高)。

协程

在 Python 中,让一个功能中途暂停的关键是使用协程。为了理解协程,先要理解生成器 generator,要理解生成器,先要理解迭代器 iterator。

大部分 Python 开发者都熟悉对类进行迭代(例如,字符串、列表、元组、文件对象等等):

>>> for i in range(3):
...     print(i)
...
1
2
>>> for line in open('exchange_rates_v1.py'):
...     print(line, end='')
... 
#!/usr/bin/env python3
import itertools
import time
import urllib.request
… 

我们可以将各种对象(不仅仅是列表和字符串)进行迭代的原因是迭代协议。迭代协议定义了迭代的标准格式:一个执行__iter____next__(或 Python 2.x 中的 __iter__next)的对象就是一个迭代器,可以进行迭代操作,如下所示:

class MyIterator(object):
    def __init__(self, xs):
        self.xs = xs

    def __iter__(self):
        return self

    def __next__(self):
        if self.xs:
            return self.xs.pop(0)
        else:
            raise StopIteration

for i in MyIterator([0, 1, 2]):
    print(i) 

结果如下所示:

1
2
3 

我们能对MyIterator中的实例进行循环的原因是,它用__iter____next__方法,运行了迭代协议:前者返回了迭代的对象,后者逐个返回了序列中的元素。

为了进一步理解协议是如何工作的,我们手动分解这个循环,如下所示:

itrtr = MyIterator([3, 4, 5, 6])

print(next(itrtr))
print(next(itrtr))
print(next(itrtr))
print(next(itrtr))

print(next(itrtr)) 

运行结果如下:

3
4
5
6
Traceback (most recent call last):
  File "iteration.py", line 32, in <module>
    print(next(itrtr))
  File "iteration.py", line 19, in __next__
  raise StopIteration
StopIteration 

我们实例化了MyIterator,然后为了获取它的值,我们多次调用了next()。当序列到头时,next()会抛出异常StopIteration。Python 中的for循环使用了同样的机制,它调用迭代器的next(),通过获取异常StopIteration得知何时停止。

生成器就是一个 callable,它生成一个结果序列,而不是返回结果。这是通过产生(通过yield关键字)值而不是返回值,见下面的例子(generators.py):

def mygenerator(n):
    while n:
        n -= 1
        yield n

if __name__ == '__main__':
    for i in mygenerator(3):
        print(i) 

结果如下:

2
1
0 

这是一个使用yield使mygenerator成为生成器的简单例子,它的功能并不简单。调用generator函数并不开始生成序列,只是产生一个generator对象,见如下 shell 语句:

>>> from generators import mygenerator
>>> mygenerator(5)
<generator object mygenerator at 0x101267b48> 

为了激活generator对象,需要调用next(),见如下代码:

>>> g = mygenerator(2)
>>> next(g)
1
>>> next(g)
0
>>> next(g)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration 

每个next()从生成的序列产生一个值,直到序列为空,也就是获得异常StopIteration时。迭代器的行为也是类似的。本质上,生成器是简化的迭代器,免去了定义类中__iter____next__的方法。

另外,生成器是一次性操作,不能重复生成的序列。若要重复序列,必须再次调用generator函数。

用来在generator函数中产生序列值的yield表达式,还可以在等号右边使用,以消除值。这样就可以得到协程。协程就是一类函数,它可以通过yield,在指定位置暂停或继续任务。

需要注意,尽管协程是强化的生成器,在概念意义上并不等于生成器。原因是,协程与迭代无关。另一不同点,生成器产生值,而协程消除值。

让我们做一些协程,看看如何使用。协程有三种主要的结构,如下所示:

  • yield(): 用来暂停协程的执行
  • send(): 用来向协程传递数据(以让协程继续执行)
  • close():用来关闭协程

下面代码展示了协程的使用(coroutines.py):

def complain_about(substring):
    print('Please talk to me!')
    try:
        while True:
            text = (yield)
            if substring in text:
                print('Oh no: I found a %s again!'
                      % (substring))
    except GeneratorExit:
        print('Ok, ok: I am quitting.') 

我们先定义个一个协程,它就是一个函数,名字是complain_about,它有一个参数:一个字符串。打印一句话之后,进入一个无限循环,由try except控制退出,即只有通过异常才能退出。利用异常GeneratorExit,当获得这个异常时就会退出。

循环的主体十分简单,使用yield来获取数据,存储在变量text中。然后,我们检测substring是否在text中。如果在的话,弹出一条新语句。

下面代码展示了在 shell 中如何使用这个协程:

>>> from coroutines import complain_about
>>> c = complain_about('Ruby')
>>> next(c)
Please talk to me!
>>> c.send('Test data')
>>> c.send('Some more random text')
>>> c.send('Test data with Ruby somewhere in it')
Oh no: I found a Ruby again!
>>> c.send('Stop complaining about Ruby or else!')
Oh no: I found a Ruby again!
>>> c.close()
Ok, ok: I am quitting. 

执行complain_about('Ruby')产生了协程。为了使用新建的协程,我们用next()调用它,与在生成器中所做的相同。只有调用next()之后,才在屏幕上看到Please talk to me!

这时,协程到达了text = (yield),意味着它暂停了执行。控制点返回了 shell,我们就可以向协程发送数据了。我们使用send()方法发送数据,如下所示:

>>> c.send('Test data')
>>> c.send('Some more random text')
>>> c.send('Test data with Ruby somewhere in it')
Oh no: I found a Ruby again! 

每次调用send()方法都使代码到达下一个 yield。在我们的例子中,到达while循环的下一次迭代,返回text = (yield)。这里,控制点返回 shell。

我们可以调用close()方法停止协程,它可以在协程内部抛出异常GeneratorExit。此时,协程唯一能做的就是清理数据并退出。下面的代码展示了如何结束协程:

>>> c.close()
Ok, ok: I am quitting. 

如果将try except部分注释掉,就不会获得GeneratorExit异常。但是协程还是会停止,如下所示:

>>> def complain_about2(substring):
...     print('Please talk to me!')
...     while True:
...         text = (yield)
...         if substring in text:
...             print('Oh no: I found a %s again!'
...                   % (substring))
... 
>>> c = complain_about2('Ruby')
>>> next(c)
Please talk to me!
>>> c.close()
>>> c.send('This will crash')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> next(c)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration 

我们看到,一旦关闭协程,对象仍会保持,但是用途为零:不能向它发送数据,也不能调用next()使用它。

当使用协程时,许多人觉得必须要用next()很繁琐,转而使用装饰器,避免多余的调用,如下所示:

>>> def coroutine(fn):
...     def wrapper(*args, **kwargs):
...         c = fn(*args, **kwargs)
...         next(c)
...         return c
...     return wrapper
... 
>>> @coroutine
... def complain_about2(substring):
...     print('Please talk to me!')
...     while True:
...         text = (yield)
...         if substring in text:
...             print('Oh no: I found a %s again!'
...                   % (substring))
... 
>>> c = complain_about2('JavaScript')
Please talk to me!
>>> c.send('Test data with JavaScript somewhere in it')
Oh no: I found a JavaScript again!
>>> c.close() 

协程的层级结构可以很复杂,可以让一个协程向其它多个协程发送数据,或从多个源接收数据。这在网络集群编程和系统编程中很有用(为了提高性能),可以用纯 Python 高效代替大多数 Unix 工具。

一个异步实例

为了简单又有趣,让我们写一个工具,可以对指定的文件,统计某个词的出现次数。使用之前的协程做基础,再添加一些功能。

在 Linux 和 Mac OS X 上,可以使用grep命令获得同样的结果。我们先下载一个大的文本文件,用作输入的数据。我们选择的是 Project Gutenberg 上列夫托尔斯泰所写的《战争与和平》,它的地址是http://www.gutenberg.org/cache/epub/2600/pg2600.txt

下面代码展示了如何下载(译者注:win 上使用 Git Bash):

$ curl -sO http://www.gutenberg.org/cache/epub/2600/pg2600.txt
$ wc pg2600.txt
   65007  566320 3291648 pg2600.txt 

接下来,我们统计 love 一词出现的次数,忽略大小写,如下所示(译者注:会有编码问题):

$ time (grep -io love pg2600.txt | wc -l)
677
(grep -io love pg2600.txt) 0.11s user 0.00s system 98% cpu 0.116 total 

现在使用 Python 的协程来做(grep.py):

def coroutine(fn):
    def wrapper(*args, **kwargs):
        c = fn(*args, **kwargs)
        next(c)
        return c
    return wrapper

def cat(f, case_insensitive, child):
    if case_insensitive:
        line_processor = lambda l: l.lower()
    else:
        line_processor = lambda l: l

    for line in f:
        child.send(line_processor(line))

@coroutine
def grep(substring, case_insensitive, child):
    if case_insensitive:
        substring = substring.lower()
    while True:
        text = (yield)
        child.send(text.count(substring))

@coroutine
def count(substring):
    n = 0
    try:
        while True:
            n += (yield)
    except GeneratorExit:
        print(substring, n)

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('-i', action='store_true',
                        dest='case_insensitive')
    parser.add_argument('pattern', type=str)
    parser.add_argument('infile', type=argparse.FileType('r'))

    args = parser.parse_args()

    cat(args.infile, args.case_insensitive,
        grep(args.pattern, args.case_insensitive,
             count(args.pattern))) 

分析代码之前,我们先运行一下,和grep进行比较:

$ time python3.5 grep.py -i love pg2600.txt
love 677
python3.5 grep.py -i love pg2600.txt  0.09s user 0.01s system 97% cpu 0.097 total 

可以看到,使用协程的纯 Python 版本与使用grepwc命令的 Unix 相比,十分具有竞争力。当然,Unix 的grep命令远比 Python 版本强大。不能简单宣称 Python 比 C 语言快!但是,Python 的结果也是让人满意的。

来分析下代码。首先,再次执行coroutine的装饰器。之后,将总任务分解成三块:

  • 逐行读取文件(通过cat函数)
  • 统计每行中substring的出现次数(grep协程)
  • 求和并打印数据(count协程)

在脚本文件的主体部分,我们解析命令行选项,将cat结果传给grep,将grep结果传给count,就像操作普通的 Unix 工具。

实现这个链条极其简单。我们将接收数据的协程当做参数(前面例子的child),传递给产生数据的函数或协程。然后,在数据源中,调用协程的send方法。

第一个函数cat,作为整个函数的数据源,它逐行读取文件,将每行发送给grep
child.send(line))。如果匹配是大小写不敏感的,不需要进行转换;如果大小写敏感,则都转化为小写。

grep命令是我们的第一个协程。这里,进入一个无限循环,持续获取数据(text = (yield)),统计substringtext中的出现次数,,将次数发送给写一个协程(即count):child.send(text.count(substring)))

count协程用总次数n,从grep获取数据,对总次数进行求和,n += (yield)。它捕获发送给各个协程关闭时的GeneratorExit异常(在我们的例子中,到达文件最后就会出现异常),以判断何时打印这个substringn

当把协程组织为更复杂的结构时,会更有趣。比如,我们可以统计多个单词出现的次数。

下面的代码展示了一种这样做的方法,通过一个额外的协程负责广播,将输入数据发送给任意数目的子协程(mgrep.py):

def coroutine(fn):
    def wrapper(*args, **kwargs):
        c = fn(*args, **kwargs)
        next(c)
        return c
    return wrapper

def cat(f, case_insensitive, child):
    if case_insensitive:
        line_processor = lambda l: l.lower()
    else:
        line_processor = lambda l: l

    for line in f:
        child.send(line_processor(line))

@coroutine
def grep(substring, case_insensitive, child):
    if case_insensitive:
        substring = substring.lower()
    while True:
        text = (yield)
        child.send(text.count(substring))

@coroutine
def count(substring):
    n = 0
    try:
        while True:
            n += (yield)
    except GeneratorExit:
        print(substring, n)

@coroutine
def fanout(children):
    while True:
        data = (yield)
        for child in children:
            child.send(data)

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('-i', action='store_true', dest='case_insensitive')
    parser.add_argument('patterns', type=str, nargs='+',)
    parser.add_argument('infile', type=argparse.FileType('r'))

    args = parser.parse_args()

    cat(args.infile, args.case_insensitive,
        fanout([grep(p, args.case_insensitive,
                     count(p)) for p in args.patterns])) 

代码看上去和之前的例子差不多。让我们分析一下差别。我们定义了一个广播器:fanoutfanout()协程使用一列协程作为输入,自身位于一个无限循环中。当收到数据后(data = (yield)),便将数据分发给注册的协程(for child in children: child.send(data))。

不用修改catgrepcount的代码,我们就可以利用原有的代码来搜索任意个数的字符串了!

它的性能依旧很好,如下所示:

$ time python3.5 mgrep.py -i love hate hope pg2600.txt
hate 103
love 677
hope 158
python3.5 mgrep.py -i love hate hope pg2600.txt  0.16s user 0.01s system 98% cpu 0.166 total 

总结

Python 从 1.5.2 版本之后引入了asyncoreasynchat模块,开始支持异步编程。2.5 版本引入了yield,可以向协程传递数据,简化了代码、加强了性能。Python 3.4 引入了一个新的库进行异步 I/O,称作asyncio

Python 3.5 通过async defawait,引入了真正的协程类型。感兴趣的读者可以继续研究 Python 的新扩展。一句警告:异步编程是一个强大的工具,可以极大地提高 I/O 密集型代码的性能。但是异步编程也是存在问题的,而且还相当复杂。

任何异步代码都要精心选择非阻塞的库,以防使用阻塞代码。并且要运行一个协程规划期(因为 OS 不能像规划线程一样规划协程),包括写一个事件循环和其它事务。读异步代码会有一定困难,即使我们的最简单的例子也很难一眼看懂。所以,一定要小心!

三、Python 的并行计算 (Distributed Computing with Python)

我们在前两章提到了线程、进程,还有并发编程。我们在很高的层次,用抽象的名词,讲了如何组织代码,已让其部分并发运行,在多个 CPU 上或在多台机器上。

本章中,我们会更细致的学习 Python 是如何使用多个 CPU 进行并发编程的。具体目标是加速 CPU 密集型任务,提高 I/O 密集型任务的反馈性。

好消息是,使用 Python 的标准库就可以进行并发编程。这不是说不用第三方的库或工具。只是本章中的代码仅仅利用到了 Python 的标准库。

本章介绍如下内容:

  • 多线程
  • 多进程
  • 多进程队列

多线程

Python 从 1.4 版本开始就支持多线程了。它在threading模块中还提供了一个高级界面给系统本地(Linux 和 Mac OS X 中的POSIX)线程,本章的例子会使用threading

要注意在单 CPU 系统中,使用多线程并不是真正的并发,在给定时间只有一个线程在运行。只有在多 CPU 计算机上,线程才是并发的。本章假设使用的计算机是多处理器的。

让我们写一个简单的例子,使用多线程从网络下载数据。使用你的编辑器,新建一个 Python 文件,currency.py,代码如下:

from threading import Thread
from queue import Queue
import urllib.request

URL = 'http://finance.yahoo.com/d/quotes.csv?s={}=X&f=p'
def get_rate(pair, outq, url_tmplt=URL):
    with urllib.request.urlopen(url_tmplt.format(pair)) as res:
        body = res.read()
    outq.put((pair, float(body.strip())))

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('pairs', type=str, nargs='+')
    args = parser.parse_args()

    outputq = Queue()
    for pair in args.pairs:
        t = Thread(target=get_rate,
                   kwargs={'pair': pair,
                           'outq': outputq})
        t.daemon = True
        t.start()

    for _ in args.pairs:
        pair, rate = outputq.get()
        print(pair, rate)
        outputq.task_done()
    outputq.join() 

这段代码十分简单。我们先从标准库引入需要的模块(threadingqueueurllib.request)。然后定义一个简单的函数get_rate,用以得到货币对(即 EURUSD 代表欧元兑美元,CHFAUS 代表瑞士法郎兑澳元),和一个线程安全型队列(即,一个 Python 的queue模块Queue实例),用以链接 Yahoo!Finance,并下载最新的汇率。

调用 Yahoo!Finance API 会返回包括数字的白文本(或者一个包含信息的 CSV 文件)。这意味着,我们不必解析 HTML,直接可以在文本中找到需要的汇率。

此段代码使用了argparse模块,解析命令行参数。然后构造了一个队列(outputq),来保存各个线程下载的汇率的数据。一旦有了输出队列,我们就可以为每个汇率对新建一个工作线程。每个线程运行get_rate函数,使用汇率对和输出队列作为参数。

因为这些线程只是fireforget线程,可以将它们做成守护进程,也就是说,Python 主程序退出时不会等待它们退出(进程术语join)。

正确理解最后的守护进程和队列是十分重要的。使用线程的最大难点是,我们无法判断某个线程何时进行读取或写入与其它线程共享的数据。

这就会造成所谓的竞争条件。一方面,系统的正确执行取决于某些动作按顺序执行;另一方面,不能保证这些动作按照这些动作按照设计的顺序执行。

竞争条件的一个简单例子是引用计数算法。引用计数中,垃圾回收解释器如CPython(Python 的标准解释器),每个对象都有一个计数器,用于跟踪引用的次数。

每一次引用一个对象时,对应的计数器增加 1。每一次删除一个引用时,计数器减 1。当计数器为 0 时,对象就被删除了。尝试使用被删除的对象,会发生语法错误。

这意味着,我们必须强制给计数器的增加和减少添加一个顺序。设想两个线程获取一个对象的引用一段时间,然后删除。如果两个线程在同一时间访问同一个引用计数器,它们就会复写值,如下图所示:

解决此类同步问题的方法之一是使用锁。线程安全队列是一个简易的使用锁数据结构的例子,使用它可以组织数据的访问。

因为每个线程都向同一个输出队列写入,我们最好监督队列,好知道何时有了结果,进而退出。在前面的代码中,我们的实现方法是从每个汇率对的队列取出一个结果(args.pairs循环),等待队列来加入(outputq。join()),即取得多有数据之后(更准确的,当每个get()方法之后都调用task_done())。这样,就可以保证程序不提前退出。

尽管这个代码只是示例,没有进行查错、重试、处理缺省值或无效数值,它仍然是一个有用的、以队列为基础的架构。但是,要记住,使用锁的队列控制数据访问、避免竞争条件,取决于应用,可能花费很高。

下图展示了这个例子的架构,有三个工作线程,用以获取三个汇率值的数据,并将名字和数值存储到输出队列:

当然,我们可以不用线程,依次调用get_rate()函数取得每个汇率值。打开 Python shell,我们可以如下实现:

>>> from currency import get_rate
>>> import queue
>>> from time import time
>>> q = queue.Queue()
>>> pairs = ('EURUSD', 'GBPUSD', 'CHFEUR')
>>> t0 = time(); [get_rate(p, q) for p in pairs]; dt = time() - t0
[None, None, None]
>>> dt
1.1785249710083008
>>> [q.get() for p in pairs]
[('EURUSD', 1.1042), ('GBPUSD', 1.5309), ('CHFEUR', 0.9176)] 

每次使用一个请求,取得三个汇率,耗时 1.2 秒。

让我们运行下使用线程的例子:

$ time python3.5 currency.py EURUSD GBPUSD CHFEUR
EURUSD 1.1042
GBPUSD 1.5309
CHFEUR 0.9176
python3.5 currency.py EURUSD GBPUSD CHFEUR  0.08s user 0.02s system 26% cpu 0.380 total 

后者总耗时 0.4 秒,为什么它的速度是前者的三倍呢?原因是,使用线程,可以并行运行三个请求。当然,还有一个主线程和队列(根据阿姆达尔定律,它们都属于序列分量),但是通过并发,还是使性能得到了极大提高。另外,我们可以像上一章一样,在单 CPU 上使用协程和非阻塞 socket。

让我们看另一个例子,虽然使用了线程,性能却没有提高。用下面的代码新建一个文件(fib.py):

from threading import Thread

def fib(n):
    if n <= 2:
        return 1
    elif n == 0:
        return 0
    elif n < 0:
        raise Exception('fib(n) is undefined for n < 0')
    return fib(n - 1) + fib(n - 2)

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('-n', type=int, default=1)
    parser.add_argument('number', type=int, nargs='?', default=34)
    args = parser.parse_args()

    assert args.n >= 1, 'The number of threads has to be > 1'
    for i in range(args.n):
        t = Thread(target=fib, args=(args.number,))
        t.start() 

这段代码很好理解。先引入线程模块,然后让每个线程计算斐波那契额数args.number。我们并不关心斐波那契额数(不进行存储),只是想进行一些 CPU 密集型计算,计算菲波那切数列是一个很好的例子。

用不同并发程度,运行这个例子,如下所示:

$ time python3.5 ./fib.py -n 1 34
python3.5 ./fib.py -n 1 34  2.00s user 0.01s system 99% cpu 2.013 total
$ time python3.5 ./fib.py -n 2 34
python3.5 ./fib.py -n 2 34  4.38s user 0.04s system 100% cpu 4.414 total
$ time python3.5 ./fib.py -n 3 34
python3.5 ./fib.py -n 3 34  6.28s user 0.08s system 100% cpu 6.354 total
$ time python3.5 ./fib.py -n 4 34
python3.5 ./fib.py -n 4 34  8.47s user 0.11s yousystem 100% cpu 8.541 total 

有趣的是,当用两个线程计算前 34 个斐波那契数时,耗时是单线程的两倍。增加线程的数目,会线性的增加耗时。很明显,并行运行的线程发生了错误。

Python 底层有个东西影响着我们的 CPU 制约型进程,它就是全局锁(Global Interpreter Lock)。正如它的名字,全局锁控制引用计数始终合理。尽管 Python 的线程是 OS 原生的,全局锁却使特定时间只有一个是运行的。

有人会说 Python 是单线程的,这并不正确。但也不全部错误。刚刚我们看到的,和之前的协程很像。在协程的例子中,在给定时间只有一段代码才能运行,当一个协程或进程等待 I/O 时,让另一个运行 CPU,也可以达到并发的效果。当一个任务需要占用 CPU 大量时间时,就像菲波那切数列这个 CPU 制约型任务,就不会有多大提高。

与协程很像,在 Python 中使用线程是可取的。并行 I/O 可以极大提高性能,无论是对多线程还是协程。GUI 应用也可以从使用线程受益,一个线程可以处理更新 GUI,另一个在后台运行,而不必使前台死机。只需要注意全局锁,做好应对。另外,并不是所有 Python 解释器都有全局锁,Jython就没有。

多进程

传统上,Python 开发者为了避免全局锁对 CPU 制约型线程的影响,使用的是多进程而不是多线程。多进程有一些缺点,它必须启动 Python 的多个实例,启动时间长,耗费内存多。

同时,使用多进程并行运行任务,有一些极好的优点。

多进程有它们各自的内存空间,使用的是无共享架构,数据访问十分清晰。也更容易移植到分布式系统中。

Python 的标准库中有两个模块,可以用来实现并行进程,两个模块都很优秀。其中之一是multiprocessing,另一个是concurrent.futuresconcurrent.futures模块构建在multiprocessingthreading模块之上,提供更优的功能。

我们在下一个例子中使用的是concurrent.futures。Python 2.x 用户可以用外部包的方式安装,即futures

我们还是使用之前的菲波那切数列例子,这次使用多进程。同时,会快速介绍concurrent.futures模块。

使用下面代码新建一个文件(mpfib.py):

import concurrent.futures as cf

def fib(n):
    if n <= 2:
        return 1
    elif n == 0:
        return 0
    elif n < 0:
        raise Exception('fib(n) is undefined for n < 0')
    return fib(n - 1) + fib(n - 2)

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('-n', type=int, default=1)
    parser.add_argument('number', type=int, nargs='?', default=34)
    args = parser.parse_args()

    assert args.n >= 1, 'The number of threads has to be > 1'
    with cf.ProcessPoolExecutor(max_workers=args.n) as pool:
        results = pool.map(fib, [args.number] * args.n) 

这段代码很紧凑,也很易读。看一下它与多线程的不同,我们得到命令行参数之后,创建了一个ProcessPoolExecutor实例,调用它的map()方法进行并行计算。

根据直觉,我们建立了一个工作进程池args.n,使用这个进程池对每个输入(args.number重复args.n次)执行fib函数,以并行方式运行(取决于 CPU 的数目)。

(在一个四处理器的计算机上)运行这段代码,结果如下:

$ time python3.5 ./mpfib.py -n 1 34
python3.5 ./mpfib.py -n 1 34  1.89s user 0.02s system 99% cpu 1.910 total
$ time python3.5 ./mpfib.py -n 2 34
python3.5 ./mpfib.py -n 2 34  3.76s user 0.02s system 196% cpu 1.928 total
$ time python3.5 ./mpfib.py -n 3 34
python3.5 ./mpfib.py -n 3 34  5.70s user 0.03s system 291% cpu 1.964 total
$ time python3.5 ./mpfib.py -n 4 34
python3.5 ./mpfib.py -n 4 34  7.71s user 0.03s system 386% cpu 2.006 total 

我们看到,在四处理器的计算机上运行时,可以实现真正的并行,运行一次到四次,时间差不多。

进程数比处理器数目多时,性能会急剧下降,如下所示:

$ time python3.5 ./mpfib.py -n 8 34
python3.5 ./mpfib.py -n 8 34  30.23s user 0.06s system 755% cpu 4.011 total
$ time python3.5 ./mpfib.py -n 16 34
python3.5 ./mpfib.py -n 16 34  63.78s user 0.13s system 758% cpu 8.424 total 

再看一下代码的最后两行,这里的内容不少。首先,使用concurrent.futures模块导出的ProcessPoolExecutor类。它是被导出的两个类之一,另一个是ThreadPoolExecutor,用它来建立线程池,而不是进程池。

ProcessPoolExecutorThreadPoolExecutor有相同的 API(实际上,二者都是同一个类的子类),它们有三个主要方法,如下:

  • submit(f, *args, **kwargs):用来规划异步调用f(*args, **kwargs),并返回一个Future实例作为结果占位符。
  • map(f, *arglist, timeout=None, chunksize=1):它等价于内建的(f, *arglist)方法,它返回的是一个列表的Future对象,而不是map那样的结果。

第三种方法shutdown(wait=True)用来当所有Executor对象运行完毕时,释放资源。之前,则一直在等待(if wait=True)。运行这个方法之后再使用Executor对象,会抛出RuntimeError异常。

Executor对象还可以用来当做上下文管理(context manager),正如例子中,使用cf.ProcessPoolExecutor(max_workers=args.n)构建pool。上下文管理退出时,会默认阻塞调用Executor shutdown方法。这意味着,一旦上下文管理退出,我们访问results列表只会得到一些整数而不是Future实例。

Future实例是concurrent.futures包导出的另一个主要的类,它是异步调用的结果占位符。我们可以用它检测是否调用仍在运行,是否抛出异常,等等。我们调用一个Future实例的result()方法,来访问它的值。

不用上下文管理,再来运行一下这个例子。这样,就可以观察运行的Future类。结果如下:

>>> from mpfib import fib
>>> from concurrent.futures import ProcessPoolExecutor
>>> pool = ProcessPoolExecutor(max_workers=1)
>>> fut = pool.submit(fib, 38)
>>> fut
<Future at 0x101b74128 state=running>
>>> fut.running()
True
>>> fut.done()
False
>>> fut.result(timeout=0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/concurrent/futures/_base.py", line 407, in result
    raise TimeoutError()
concurrent.futures._base.TimeoutError
>>> fut.result(timeout=None)
39088169
>>> fut
<Future at 0x101b74128 state=finished returned int>
>>> fut.done()
True
>>> fut.running()
False
>>> fut.cancelled()
False
>>> fut.exception() 

这里,我们看到如何使用concurrent.futures包创建工作池(使用ProcessPoolExecutor类),并给它分配工作(pool.submit(fib, 38))。正如所料,submit返回了一个Future对象(代码中的fut),它是还没产生结果时的占位符。

我们检测fut以确认它的状态,运行(fut.running()),完毕(fut.done()),取消(fut.cancelled())。如果没有产生结果(fut.result(timeout=0)),就检测,会抛出异常TimeoutError。意味着,我们必须要么等待Future对象可用,或不设置超时的情况下,询问它的值。这就是我们做的,fut.result(timeout=None),它会一直等待Future对象。因为代码没有错误,fut.exception()返回的是None

我们可以只修改一行多进程的例子代码,就将它编程多线程的,将ProcessPoolExecutor换成ThreadPoolExecutor。快速写一个例子,将之前的例子(mpfib.py),更换下行:

with cf. ProcessPoolExecutor (max_workers=args.n) as pool: 

为:

with cf.ThreadPoolExecutor(max_workers=args.n) as pool: 

新文件(mtfib.py)的性能和之前的fib.py的性能差不多,如下所示:

$ time python3.5 ./mtfib.py -n 1 34 
python3.5 ./mtfib.py -n 1 34  2.04s user 0.01s system 99% cpu 2.059 total
$ time python3.5 ./mtfib.py -n 2 34
python3.5 ./mtfib.py -n 2 34  4.43s user 0.04s system 100% cpu 4.467 total
$ time python3.5 ./mtfib.py -n 3 34
python3.5 ./mtfib.py -n 3 34  6.69s user 0.06s system 100% cpu 6.720 total
$ time python3.5 ./mtfib.py -n 4 34
python3.5 ./mtfib.py -n 4 34  8.98s user 0.10s system 100% cpu 9.022 total 

多进程队列

多进程要解决的问题是,如何在工作进程之间交换数据。multiprocessing模块提供的方法是队列和管道。接下来,我们来看多进程队列。

multiprocessing.Queue 类是按照queue.Queue类建模的,不同之处是多进程队列中的 items 要求是可选取的。为了展示如何使用队列,新建一个文件(queues.py),它的代码如下:

import multiprocessing as mp

def fib(n):
    if n <= 2:
        return 1
    elif n == 0:
        return 0
    elif n < 0:
        raise Exception('fib(n) is undefined for n < 0')
    return fib(n - 1) + fib(n - 2)

def worker(inq, outq):
    while True:
        data = inq.get()
        if data is None:
            return
        fn, arg = data
        outq.put(fn(arg))

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('-n', type=int, default=1)
    parser.add_argument('number', type=int, nargs='?', default=34)
    args = parser.parse_args()

    assert args.n >= 1, 'The number of threads has to be > 1'

    tasks = mp.Queue()
    results = mp.Queue()
    for i in range(args.n):
        tasks.put((fib, args.number))

    for i in range(args.n):
        mp.Process(target=worker, args=(tasks, results)).start()

    for i in range(args.n):
        print(results.get())

    for i in range(args.n):
        tasks.put(None) 

到这里,你应该对代码很熟悉了。我们还是用递归方法计算计算菲波那切数列。我们使用两个队列的架构,一个队列运行任务(调用函数和参数),另一个队列保存结果(整数)。

在任务队列中使用一个哨兵值(None),给工作进程发消息,好让其退出。工作进程是一个简单的multiprocessing.Process实例,它的目标是worker函数。

这个队列的例子的性能和无队列例子(mpfib.py)的性能相同,如下所示:

$ time python3.5 ./queues.py -n 1 34
5702887
python3.5 ./queues.py -n 1 34  1.87s user 0.02s system 99% cpu 1.890 total
$ time python3.5 ./queues.py -n 4 34
5702887 (repeated 4 times)
python3.5 ./queues.py -n 4 34  7.66s user 0.03s system 383% cpu 2.005 total
$ time python3.5 ./queues.py -n 8 34
5702887 (repeated 8 times)
python3.5 ./queues.py -n 8 34  30.46s user 0.06s system 762% cpu 4.003 total 

对于我们的例子,添加几个队列不会产生明显的性能下降。

一些思考

开发并行应用的主要难点就是控制数据访问,避免竞争条件或篡改共享数据。有时,发生异常很容易发现错误。其他时候,就不容易发现,程序持续运行,但结果都是错的。

检测程序和内部函数是很重要的。对于并行应用,检测更为重要,因为想要建立一个逻辑图十分困难。

并行开发的另一难点是,要明确何时停止。阿姆达尔定律指出,并行开发是收益递减的。并行化可能耗时巨大。一定要知道,哪段代码是需要并行化的,理论加速上限又是多少。

只有这样,我们才能知道何时该停止继续投入。其它时候,使用现存的并行库(如 Numpy),可以提供更好的收益。

另外,避免收益递减的方法是增加任务量,因为计算机的性能是不断提高的。

当然,随着任务量增大,创建、协调、清洗的贡献就变小了。这是古斯塔夫森定律的核心。

总结

我们学习了一些可以让 Python 加速运行或是在多个 CPU 上运行的方法。其一是使用多线程,另一个是多进程。这两个都是 Python 的标准库支持的。

我们学习了三个模块:开发多线程应用的threading,开发并行多进程的multiprocessing,还有更高级的异步模块concurrent.futures

随着技术的发展,Python 中开发并行应用不仅只有这三个模块。其它的包封装了并行策略,可以解放开发者。可能,最有名的就是 NumPy,Python 处理 array 和 matrix 标准包。依赖 BLAS 库,NumPy 可以用多线程加速运行复杂运算(比如矩阵的点乘)。

multiprocessing模块可以让 Python 运行在计算机集群上。特别的,它有几个Manager类(即BaseManagerSyncManager)。它使用 socket 服务器管理数据和队列,并在网络中共享。感兴趣的读者可以继续阅读多进程模块的文档https://docs.python.org/3/library/multiprocessing.html#managers

另一个值得关注的是 Cython,一个类似 Python 的原因,它可以建立C模块,现在非常流行。Cython 对OpenMP(一个基于指令的 C、C++、Fortran 的 API)支持很好,可以让开发者方便地使用多线程。

四、Celery 分布式应用 (Distributed Computing with Python)

本章是前面某些知识点的延续。特别的,本章以实例详细的探讨了异步编程和分布式计算。本章关注Celery,一个复杂的用于构建分布应用的 Python 框架。最后,对比了 Celery 的对手:PyroPython-RQ

此时,你应该已经明白了并行、分布和异步编程的基本含义。如果没有的话,最好再学习下前面几章。

搭建多机环境

学习 Celery 和其它 Python 包之前,先来搭建测试环境。我们开发的是分布应用,因此需要多机环境。

可以使用至少两台联网机器的读者可以跳过这部分。其余读者,请继续阅读。对于后者,仍然有免费或便宜的解决方案。

其一是在主机上使用虚拟机 VM(例如 VirtualBox,https://www.virtualbox.org)。创建几个 VM,安装 Linux,让它们在后台运行。因为它们不需要图像化桌面,所以可以很轻量,使用少量 RAM 和 CPU 即可。

另一方法是买几个便宜的小型计算机主板,比如树莓派(https://www.raspberrypi.org),在它上面安装 Linux,连上局域网。

第三种方案是用云服务器,比如 Amazon EC2,使用它的虚拟机。如果使用这种方法,要确认这些包的端口在防火墙是打开的。

无论是用哪种方法,紧跟着的问题就是没有在集群上安装完整的 DNS。最便捷的方法是在所有机器上编辑/etc/hosts文件。查看 IP 地址,为每台机器起一个名字,并将它们添加到/etc/hosts

我在 Mac 主机上使用了两个虚拟机,这是我的 hosts 文件:

$ cat /etc/hosts
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1             localhost 
fe80::1%lo0 localhost

# Development VMs
192.168.123.150 ubuntu1 ubuntu1.local
192.168.123.151 ubuntu2 ubuntu2.local 

相似的,这是我的两个虚拟机(运行 Ubuntu 15.04)上的 host 文件:

$ cat /etc/hosts
127.0.0.1 localhost
192.168.123.151 ubuntu2
192.168.123.150 ubuntu1

# The following lines are desirable for IPv6 capable hosts
::1     ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters 

你要确保 hosts 文件上的 IP 地址和名字是要使用的机器。本书,会命名这些机器命名为 HOST1、HOST2、HOST3 等等。

搭建好多机环境之后,就可以开始写分布应用了。

安装 Celery

目前为止,我们用的都是 Python 的标准库,Celery(http://www.celeryproject.org)是用到的第一个第三方库。Celery 是一个分布任务队列,就是一个以队列为基础的系统,和之前的某些例子很像。它还是分布式的,意味着工作进程和保存结果的和请求的队列,在不同机器上。

首先安装 Celery 和它的依赖。在每台机器上建立一个虚拟环境(起名为book),代码如下(环境是 Unix):

$ pip install virtualenvwrapper 

如果这个命令被拒绝,可以加上sudo,用超级用户权限来安装virtualenvwrapper,代码如下:

$ sudo pip install virtualenvwrapper 

sudo命令会向你询问 Unix 用户密码。或者,可以用下面代码安装virtualenvwrapper

$ pip install --user virtualenvwrapper 

不管使用哪种方法,完成安装virtualenvwrapper之后,都需要配置它,定义三个环境变量(用于 bash 类的 shell,假定virtualenvwrapper安装在/usr/local/bin):

$ export WORKON_HOME=$HOME/venvs
$ export PROJECT_HOME=$HOME/workspace
$ source /usr/local/bin/virtualenvwrapper.sh 

你需要修改前置路径,来决定虚拟环境所在的位置($WORKON_HOME)和代码的根目录($PROJECT_HOME)。virtualenvwrapper.sh的路径也可能需要变动。这三行代码最好添加到相关的 shell 启动文件(例如,~/.bashrc~/.profile)。

做好了前面的设置,我们就可以创建要使用的虚拟环境了,如下所示:

$ mkvirtualenv book --python=`which python3.5` 

这个命令会在$WORKON_HOME之下建立新的虚拟环境,名字是book,使用的是 Python 3.5。以后,可以用下面命令启动这个虚拟环境:

$ workon book 

使用虚拟环境的好处是,可以在里面安装所有需要的包,而不污染系统的 Python。以后不再需要这个虚拟环境时,可以方便的删除(参考rmvirtualenv命令)。

现在就可以安装 Celery 了。和以前一样,(在每台机器上)使用pip

$ pip install celery 

该命令可以在激活的虚拟环境中下载、解压、安装所有的依赖。

快完成了,现在只需安装配置一个中间代理,Celery 用它主持任务队列,并向工作进程(只有一台机器,HOST1)发送消息。从文档中可以看到,Celery 支持多种中间代理,包括SQLAlchemyhttp://www.sqlalchemy.org),用以本地开发和测试。这里推荐使用的中间代理是RabbitMQhttps://www.rabbitmq.com)。

https://www.rabbitmq.com上有安装指导、文档和下载。在 Mac 主机上,安装的最简方法是使用homebrewhttp://brew.sh),如下所示:

$ brew install rabbitmq 

对于 Windows 用户,最好使用官方的安装包。对于 Linux,官方也提供了安装包。

安装好RabbitMQ之后,就可以立即使用了。这里还有一个简单的配置步骤,因为在例子中,访问队列不会创建用户和密码。只要编辑 RabbitMQ 的配置文件(通常位于/usr/local/etc/rabbitmq/rabbitmq.config),添加下面的条目,允许网络中的默认guest账户:

[
  {rabbit, [{loopback_users, []}]}
]. 

手动启动 RabbitMQ,如下所示(服务器脚本可能不在$PATH环境,通常存储在/usr/local/sbin):

$ sudo rabbitmq-server 

sudo会向你询问用户密码。对于我们的例子,我们不会进一步配置中间代理,使用默认访客账户就行。

注意:感兴趣的读者可以在http://www.rabbitmq.com/admin-guide.html阅读 RabbitMQ 的管理指导。

到这里,我们就安装好了所有需要的东西,可以开始使用 Celery 了。有另外一个依赖,也值得考虑安装,尽管不是严格需要的,尤其是我们只想使用 Celery。它是结果后台,即 Celery 的工作进程用其存储计算的结果。它就是 Redis(http://redis.io)。安装 Redis 是非必须的,但极力推荐安装,和 RabbitMQ 类似,Redis 运行在另一台机器上,称作 HOST2。

Redis 的安装十分简单,安装代码适用于 Linux,Mac OS X 和 Windows。我们在 Mac 上用 homebrew 安装,如下:

$ brew install redis 

在其它操作系统上,例如 Linux,可以方便的用二进制码安装(例如对于 Ubuntu,sudo apt-get install redis-server)。

启动 Redis 的命令如下:

$ sudo redis-server 

本章剩下的部分会假定结果后台存在,如果没有安装,会到时指出配置和代码的不同。同时,任何在生产环境中使用 Celery 的人,都应该考虑使用结果后台。

测试安装

快速尝试一个例子,以验证 Celery 是正确安装的。我们需要四个终端窗口,三个不同的机器(命名为 HOST1、HOST2、HOST3 和 HOST4)。在 HOST1 的窗口启动 RabbitMQ(确保rabbitmq-server路径正确):

HOST1 $ sudo /usr/local/sbin/rabbitmq-server 

在 HOST2 的窗口,启动 Redis(没安装的话,跳到下一段):

HOST2 $ sudo /usr/local/bin/redis-server 

最后,在 HOST3 的窗口,创建如下 Python 文件(记得使用workon book激活虚拟环境),命名为test.py

import celery

app = celery.Celery('test',
                        broker='amqp://HOST1',
                        backend='redis://HOST2')

@app.task
def echo(message):
    return message 

这段代码很简单。先引入了Celery包,然后定义了一个 Celery 应用(app),名字是test。这个应用使用 HOST1 的中间代理 RabbitMQ 和 HOST2 的 Redis 数据库的默认账户和消息队列。

要是想用 RabbitMQ 作为结果后台而不用 Redis,需要修改前面的代码,将backend进行如下修改:

import celery

app = celery.Celery('test',
                        broker='amqp://HOST1',
                        backend=amqp://HOST1')

@app.task
def echo(message):
    return message 

有了应用实例,就可以用它装饰远程的 worker(使用装饰器@app.task)。在这个例子中,我们装饰一个简单的函数,它可以返回传递给它的消息(echo)。

之后,在终端 HOST3,建立 worker 池,如下所示:

HOST3 $ celery -A test worker --loglevel=info 

记得要在test.py的目录(或将PYTHONPATH环境变量指向test.py的目录),好让 Celery 可以引入代码。

celery命令会默认启动 CPU 数目相同的 worker 进程。worker 会使用test模块中的应用app(我们可以使用实例的名字celery -A test.app worker),并使用INFO等级在控制台显示日志。在我的电脑上(有HyperThreading的四核电脑),Celery 默认启用了八个 worker 进程。

在 HOST4 终端,复制test.py代码,启动book虚拟环境,在test.py目录打开 Python shell,如下所示:

HOST4 $ python3.5
Python 3.5.0 (v3.5.0:374f501f4567, Sep 12 2015, 11:00:19)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information. 

从复制的test模块引入echo函数,如下:

>>> from test import echo 

我们现在可以像普通 Python 函数一样调用echoecho可以直接在本地(即 HOST4)运行,如下所示:

>>> res = echo('Python rocks!')
>>> print(res)
Python rocks! 

为了让 HOST3 的 worker 进程运行echo()函数,我们不能像之前那样直接调用。我们需要调用它的delay方法(装饰器@app.task注入的),见下面的命令:

>>> res = echo.delay('Python rocks!'); print(type(res)); print(res)
<class 'celery.result.AsyncResult'>
1423ec2b-b6c7-4c16-8769-e62e09c1fced
>>> res.ready()
True
>>> res.result
'Python rocks!' 

我们看到,调用echo.delay('Python rocks!')不会返回字符串。相反,它在任务队列(运行在 HOST1 的 RabbitMQ 服务器)中安排了一个请求以执行echo函数,并返回Future,准确的说是AsyncResult(Celery 的 Future)。正如concurrent.futures模块,这个对象是一个异步调用结果的占位符。在我们的例子中,异步调用的是我们安插在任务队列的echo函数,调用它的是其它位置的 Celery 的 worker 进程(我们的例子中是 HOST3)。

我们可以查询AsyncResult对象来确定它们是否预备好。如果是的话,我们可以访问它们的结果,在我们的例子中是字符串'Python rocks!'。

切换到启用 worker 进程的窗口,我们可以看到 worker 池接收到了echo任务请求,如下所示:

[2015-11-10 08:30:12,869: INFO/MainProcess] Received task: test.echo[1423ec2b-b6c7-4c16-8769-e62e09c1fced]
[2015-11-10 08:30:12,886: INFO/MainProcess] Task test.echo[1423ec2b-b6c7-4c16-8769-e62e09c1fced] succeeded in 0.01469148206524551s: 'Python rocks!' 

我们现在可以退出 Python shell 和 worker 进程(在发起celery worker命令的终端窗口按CTRL+C):Celery 安装成功。

Celery 介绍

什么是分布式任务队列,Celery 是怎么运行分布式任务队列的呢?分布式任务队列这种架构已经存在一定时间了。这是一种 master-worker 架构,有一个中间件层,中间件层使用多个任务请求队列(即任务队列),和一个用于存储结果的队列(即结果后台)。

主进程(也叫作clientproducer)将任务请求安插到某个任务队列,从结果后台获取数据。worker 进程订阅任务队列以明确任务是什么,并把结果放到结果后台。

这是一个简单灵活的架构。主进程不需要知道有多少个可用的 worker,也不需要知道 worker 运行在哪台机器。它只需要知道队列在哪,以及如何发送任务请求。

worker 进程也是如此。它们不需要知道任务请求来自何处,也不需要知道结果用来做什么。它们只需知道从哪里取得任务,存储在哪里。

这样的优点是 worker 的数量、种类、形态可以随意变化,而不对总系统的功能产生影响(但会影响性能和延迟)。分布式任务队列可以方便地进行扩展(添加新 worker),规划优先级(给队列定义不同的优先级,给不同的队列安排不同数量的 worker)。

另一个优点是,这个去耦合化的系统在原则上,worker 和 producer 可以用不同语言来写。例如,Python 代码生成的工作由 C 语言写的 worker 进程来做,这样性能是最高的。

Celery 使用了第三方、健壮的、实地验证的系统来做它的队列和结果后台。推荐的中间代理是 RabbitMQ,我们之前用过。RabbitMQ 是一个非常复杂的消息代理,有许多特性,本书不会对它做深入探索。结果后台也是如此,它可以是一个简单的 RabbitMQ 队列,或者更优的,使用专门的服务比如 Redis。

下图展示了典型的使用 RabbitMQ 和 Redis 的 Celery 应用架构:

每个方框中的进程(即 RabbitMQ、Redis、worker 和master.py)都可以运行在不同的机器上。小型的安装方案是将 RabbitMQ 和 Redis 放在同一个主机上,worker 几点可能只有一个或两个。大型方案会使用更多的机器,或者专门的服务器。

更复杂的 Celery 应用

我们用 Celery 做两个简单有趣的应用。第一个仿照第 3 章中汇率例子,第二个是一个分布式排序算法。

我们还是使用四台机器(HOST1、HOST2、HOST3、HOST4)。和以前一样,HOST1 运行 RabbitMQ,HOST2 运行 Redis,HOST3 运行 Celery 的 worker,HOST 运行主代码。

先从简单的例子开始。创建一个 Python 文件(celery/currency.py),代码如下(如果你没有使用 Redis,记得修改backend'amqp://HOST1'):

import celery
import urllib.request

app = celery.Celery('currency',
                    broker='amqp://HOST1',
                    backend='redis://HOST2')

URL = 'http://finance.yahoo.com/d/quotes.csv?s={}=X&f=p'

@app.task
def get_rate(pair, url_tmplt=URL):
    with urllib.request.urlopen(url_tmplt.format(pair)) as res:
        body = res.read()
    return (pair, float(body.strip()))

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('pairs', type=str, nargs='+')
    args = parser.parse_args()

    results = [get_rate.delay(pair) for pair in args.pairs]
    for result in results:
        pair, rate = result.get()
        print(pair, rate) 

这段代码和第 3 章的多线程版本差不多。主要的区别是,因为使用的是 Celery,我们不需要创建队列,Celery 负责建立队列。另外,除了为每个汇率对建一个线程,我们只需让 worker 负责从队列获取任务请求,执行相应的函数请求,完毕之后返回结果。

探讨调用的行为是有益的,比如成功的调用、由于缺少 worker 而不工作的调用、失败且抛出异常的调用。我们从成功的调用开始。

echo的例子一样,在各自的终端启动 RabbitMQ 和 Redis(通过redis-serverrabbitmq-server命令)。

然后,在 worker 主机(HOST3)上,复制currency.py文件,切换到它的目录,创建 worker 池(记住,Celery 启动的 worker 数目尽可能和 CPU 核数一样多):

HOST3 $ celery -A currency worker --loglevel=info 

最后,复制相同的文件到 HOST4,并运行如下:

HOST4 $ python3.5 currency.py EURUSD CHFUSD GBPUSD GBPEUR CADUSD CADEUR
EURUSD 1.0644
CHFUSD 0.986
GBPUSD 1.5216
GBPEUR 1.4296
CADUSD 0.751
CADEUR 0.7056 

一切工作正常,我么得到了五个汇率。如果查看启动 worker 池的主机(HOST3),我们会看到类似下图的日志:

这是日志等级loglevel=info时,Celery worker 的日志。每个任务都被分配了一个独立 ID(例如 GBP 兑 USD 的任务 ID 是 f8658917-868c-4eb5-b744-6aff997c6dd2),基本的时间信息也被打印了出来。

如果没有可用的 worker 呢?最简单的方法是停止 worker(在终端窗口按CTRL+C),返回 HOST4 的currency.py,如下所示:

OST4 $ python3.5 currency.py EURUSD CHFUSD GBPUSD GBPEUR CADUSD CADEUR 

什么都没发生,currency.py一直处于等待 worker 的状态。这样的状态可能也可能不是我们想要的:其一,让文件等待而不发生崩溃,是很方便的;其二,我们可能想在一定时间后,停止等待。可以在result.get()timeout参数。

例如,修改代码,使用result.get(timeout=1),会有如下结果(还是在没有 worker 的情况下):

HOST4 $ python3.5 currency.py EURUSD CHFUSD GBPUSD GBPEUR CADUSD CADEUR
 Traceback (most recent call last):
  File "currency.py", line 29, in <module>
    pair, rate = result.get(timeout=1)
  File "/venvs/book/lib/python3.5/site-packages/celery/result.py", line 169, in get
    no_ack=no_ack,
  File " /venvs/book/lib/python3.5/site-packages/celery/backends/base.py", line 226, in wait_for
    raise TimeoutError('The operation timed out.')
celery.exceptions.TimeoutError: The operation timed out. 

当然,我们应该总是使用超时,以捕获对应的异常,作为错误处理的策略。

要记住,默认下,任务队列是持续的,它的日志不会停止(Celery 允许用户定制)。这意味着,如果我们现在启动了一些 worker,它们就会开始从队列获取悬挂的任务,并返回结果。我们可以用如下命令清空队列:

HOST4 $ celery purge
WARNING: This will remove all tasks from queue: celery.
         There is no undo for this operation!

(to skip this prompt use the -f option)

Are you sure you want to delete all tasks (yes/NO)? yes
Purged 12 messages from 1 known task queue. 

接下来看任务产生异常的情况。修改 HOST3 的currency.py文件,让get_rate抛出一个异常,如下所示:

@app.task
def get_rate(pair, url_tmplt=URL):
    raise Exception('Booo!') 

现在,重启 HOST3 的 worker 池(即HOST3 $ celery -A currency worker --loglevel=info),然后在 HOST4 启动主程序:

HOST4 $ python3.5 currency.py EURUSD CHFUSD GBPUSD GBPEUR CADUSD CADEUR
Traceback (most recent call last):
  File "currency.py", line 31, in <module>
    pair, rate = result.get(timeout=1)
  File "/Users/fpierfed/Documents/venvs/book/lib/python3.5/site-packages/celery/result.py", line 175, in get
    raise meta['result']
Exception: Booo! 

所有的 worker 都抛出了异常,异常传递到了调用的代码,在首次调用result.get()返回。

任务抛出任何异常,我们都要小心。远程运行的代码失败的原因可能有很多,不一定和代码本身有关,因此需要谨慎应对。

Celery 可以用如下的方法提供帮助:我们可以用timeout获取结果;重新提交失败的任务(参考task装饰器的retry参数)。还可以取消任务请求(参考任务的apply_async方法的expires参数,它比之前我们用过的delay功能强大)。

有时,任务图会很复杂。一项任务的结果还要传递给另一个任务。Celery 支持复杂的调用方式,但是会有性能损耗。

用第二个例子来探讨:一个分布式的归并排序算法。这是包含两个文件的长代码:一个是算法本身(mergesory.py),一个是主代码(main.py)。

归并排序是一个简单的基于递归二分输入列表的算法,将两个部分排序,再将结果合并。建立一个新的 Python 文件(celery/mergesort.py),代码如下:

import celery

app = celery.Celery('mergesort',
                        broker='amqp://HOST1',
                        backend='redis://HOST2')

@app.task
def sort(xs):
    lenxs = len(xs)
    if(lenxs <= 1):
        return(xs)

    half_lenxs = lenxs // 2
    left = xs[:half_lenxs]
    right = xs[half_lenxs:]
    return(merge(sort(left), sort(right)))

def merge(left, right):
    nleft = len(left)
    nright = len(right)

    merged = []
    i = 0
    j = 0
    while i < nleft and j < nright:
        if(left[i] < right[j]):
            merged.append(left[i])
            i += 1
        else:
            merged.append(right[j])
            j += 1
    return merged + left[i:] + right[j:] 

这段代码很直白。Celery 应用命名为app,它使用 RabbitMQ 作为任务队列,使用 Redis 作为结果后台。然后,定义了sort算法,它使用了附属的merge函数以合并两个排好序的子列表,成为一个排好序的单列表。

对于主代码,另建一个文件(celery/main.py),它的代码如下:

#!/usr/bin/env python3.5
import random
import time
from celery import group
from mergesort import sort, merge

# Create a list of 1,000,000 elements in random order.
sequence = list(range(1000000))
random.shuffle(sequence)

t0 = time.time()

# Split the sequence in a number of chunks and process those 
# independently.
n = 4
l = len(sequence) // n
subseqs = [sequence[i * l:(i + 1) * l] for i in range(n - 1)]
subseqs.append(sequence[(n - 1) * l:])

# Ask the Celery workers to sort each sub-sequence.
# Use a group to run the individual independent tasks as a unit of work.
partials = group(sort.s(seq) for seq in subseqs)().get()

# Merge all the individual sorted sub-lists into our final result.
result = partials[0]
for partial in partials[1:]:
    result = merge(result, partial)

dt = time.time() - t0
print('Distributed mergesort took %.02fs' % (dt))

# Do the same thing locally and compare the times.
t0 = time.time()
truth = sort(sequence)
dt = time.time() - t0
print('Local mergesort took %.02fs' % (dt))

# Final sanity checks.
assert result == truth
assert result == sorted(sequence) 

我们先生成一个足够长的无序(random.shuffle)整数序列(sequence = list(range(1000000)))。然后,分成长度相近的子列表(n=4)。

有了子列表,就可以对它们进行并行处理(假设至少有四个可用的 worker)。问题是,我们要知道什么时候这些列表排序好了,好进行合并。

Celery 提供了多种方法让任务协同执行,group是其中之一。它可以在一个虚拟的任务里,将并发的任务捆绑执行。group的返回值是GroupResult(与类AsyncResult的层级相同)。如果没有结果后台,GroupResult get()方法是必须要有的。当组中所有的任务完成并返回值,group方法会获得一个任务签名(用参数调用任务s()方法,比如代码中的sort.s(seq))的列表。任务签名是 Celery 把任务当做参数,传递给其它任务(但不执行)的机制。

剩下的代码是在本地合并排好序的列表,每次合并两个。进行完分布式排序,我们再用相同的算法重新排序原始列表。最后,对比归并排序结果与内建的sorted调用。

要运行这个例子,需要启动 RabbitMQ 和 Redis。然后,在 HOST3 启动一些 worker,如下所示:

HOST3 $ celery -A mergesort worker --loglevel=info 

记得拷贝mergesort.py文件,并切换到其目录运行(或者,定义PYTHONPATH指向它所在的位置)。

之后,在 HOST4 上运行:

HOST4 $ python3.5 main.py
Distributed mergesort took 10.84s
Local mergesort took 26.18s 

查看 Celery 日志,我们看到 worker 池接收并执行了 n 个任务,结果发回给了 caller。

性能和预想的不一样。使用多进程(使用multiprocessingconcurrent.futures)来运行,与前面相比,可以有 n 倍的性能提升(7 秒,使用四个 worker)。

这是因为 Celery 同步耗时长,最好在只有不得不用的时候再使用。Celery 持续询问组中的部分结果是否准备好,好进行后续的工作。这会非常消耗资源。

生产环境中使用 Celery

下面是在生产环境中使用 Celery 的 tips。

第一个建议是在 Celery 应用中使用配置模块,而不要在 worker 代码中进行配置。假设,配置文件是config.py,可以如下将其传递给 Celery 应用:

import celery
app = celery.Celery('mergesort')
app.config_from_object('config') 

然后,与其他可能相关的配置指令一起,在config.py中添加:

BROKER_URL = 'amqp://HOST1'
CELERY_RESULT_BACKEND = 'redis://HOST2' 

关于性能的建议是,使用至少两个队列,好让任务按照执行时间划分优先级。使用多个队列,将任务划分给合适的队列,是分配 worker 的简便方法。Celery 提供了详尽的方法将任务划分给队列。分成两步:首先,配置 Celery 应用,启动 worker,如下所示:

# In config.py
CELERY_ROUTES = {project.task1': {'queue': 'queue1'},
                    'project.task2': {'queue': 'queue2'}} 

为了在队列中启动 worker,在不同的机器中使用下面的代码:

HOST3 $ celery –A project worker –Q queue1
HOST5 $ celery –A project worker –Q queue2 

使用 Celery 命令行工具的-c标志,可以控制 worker 池的大小,例如,启动一个有八个 worker 的池:

HOST3 $ celery –A project worker –c 8 

说道 worker,要注意,Celery 默认使用多进程模块启动 worker 池。这意味着,每个 worker 都是一个完整的 Python 进程。如果某些 worker 只处理 I/O 密集型任务,可以将它们转换成协程或多线程,像前面的例子。这样做的话,可以使用-P标志,如下所示:

$ celery –A project worker –P threads 

使用线程和协程可以节省资源,但不利于 CPU 制约型任务,如前面的菲波那切数列的例子。

谈到性能,应该尽量避免同步原语(如前面的group()),除非非用不可。当同步无法回避时,好的方法是使用结果后台(如 Redis)。另外,如果可能的话,要避免传递复杂的对象给远程任务,因为这些对象需要序列化和去序列化,通常很耗时。

额外的,如果不需要某个任务的结果,应该确保 Celery 不去获取这些结果。这是通过装饰器@task(ignore_result=True)来做的。如果所有的任务结果都忽略了,就不必定义结果后台。这可以让性能大幅提高。

除此之外,还要指出,如何启动 worker、在哪里运行 worker、如何确保它们持续运行是很重要的。默认的方法是使用工具,例如supervisord (http://supervisord.org) ,来管理 worker 进程。

Celery 带有一个 supervisord 的配置案例(在安装文件的extra/supervisord目录)。一个监督的优秀方案是flower(https://github.com/mher/flower),一个 worker 的网络控制和监督工具。

最后,RabbitMQ 和 Redis 结合起来,是一个很好的中间代理和结果后台解决方案,适用于大多数项目。

Celery 的替代方案:Python-RQ

Celery 的轻量简易替代方案之一是 Python-RQ (http://python-rq.org)。它单单基于 Redis 作为任务队列和结果后台。没有复杂任务或任务路由,使用它很好。

因为 Celery 和 Python-RQ 在概念上很像,让我们立即重写一个之前的例子。新建一个文件(rq/currency.py),代码如下:

import urllib.request

URL = 'http://finance.yahoo.com/d/quotes.csv?s={}=X&f=p'

def get_rate(pair, url_tmplt=URL):
    # raise Exception('Booo!')

    with urllib.request.urlopen(url_tmplt.format(pair)) as res:
        body = res.read()
    return (pair, float(body.strip())) 

这就是之前的汇率例子的代码。区别是,与 Celery 不同,这段代码不需要依赖 Python-RQ 或 Redis。将这段代码拷贝到 worker 节点(HOST3)。

主程序也同样简单。新建一个 Python 文件(rq/main.py),代码如下:

#!/usr/bin/env python3
import argparse
import redis
import rq
from currency import get_rate

parser = argparse.ArgumentParser()
parser.add_argument('pairs', type=str, nargs='+')
args = parser.parse_args()

conn = redis.Redis(host='HOST2')
queue = rq.Queue(connection=conn)

jobs = [queue.enqueue(get_rate, pair) for pair in args.pairs]

for job in jobs:
    while job.result is None:
        pass
    print(*job.result) 

我们在这里看到 Python-RQ 是怎么工作的。我们需要连接 Redis 服务器(HOST2),然后将新建的连接对象传递给Queue类构造器。结果Queue对象用来向其提交任务请求。这是通过传递函数对象和其它参数给queue.enqueue

函数排队调用的结果是job实例,它是个异步调用占位符,之前见过多次。

因为 Python-RQ 没有 Celery 的阻塞AsyncResult.get()方法,我们要手动建一个事件循环,持续向job实例查询,以确认是否它们的result不是None这种方法不推荐在生产环境中使用,因为持续的查询会浪费资源,查询不足会浪费时间,但对于这个简易例子没有问题。

为了运行代码,首先要安装 Python-RQ,用 pip 进行安装:

$ pip install rq 

在所有机器上都要安装。然后,在 HOST2 运行 Redis:

$ sudo redis-server 

在 HOST3 上,启动一些 worker。Python-RQ 不自动启动 worker 池。启动多个 worker 的简易的方法是使用一个文件(start_workers.py):

#!/usr/bin/env python3
import argparse
import subprocess

def terminate(proc, timeout=.5):
    """
    Perform a two-step termination of process `proc`: send a SIGTERM
    and, after `timeout` seconds, send a SIGKILL. This should give 
    `proc` enough time to do any necessary cleanup.
    """
    if proc.poll() is None:
        proc.terminate()
        try:
            proc.wait(timeout)
        except subprocess.TimeoutExpired:
            proc.kill()
    return

parser = argparse.ArgumentParser()
parser.add_argument('N', type=int)
args = parser.parse_args()

workers = []
for _ in range(args.N):
    workers.append(subprocess.Popen(['rqworker',
                                            '-u', 'redis://yippy']))
try:
    running = [w for w in workers if w.poll() is None]
    while running:
        proc = running.pop(0)
        try:
            proc.wait(timeout=1.)
        except subprocess.TimeoutExpired:
            running.append(proc)
except KeyboardInterrupt:
    for w in workers:
        terminate(w) 

这个文件会启动用户指定书目的 Python-RQ worker 进程(通过使用rqworker脚本,Python-RQ 源码的一部分),通过Ctrl+C杀死进程。更健壮的方法是使用类似之前提过的 supervisord 工具。

在 HOST3 上运行:

HOST3 $ ./start_workers.py 6 

现在可以运行代码。在 HOST4,运行main.py

HOST4 $ python3.5 main.py EURUSD CHFUSD GBPUSD GBPEUR CADUSD CADEUR
EURUSD 1.0635
CHFUSD 0.9819
GBPUSD 1.5123
GBPEUR 1.422
CADUSD 0.7484
CADEUR 0.7037 

效果与 Celery 相同。

Celery 的替代方案:Pyro

Pyro (http://pythonhosted.org/Pyro4/)的意思是 Python Remote Objects,是 1998 年创建的一个包。因此,它十分稳定,且功能完备。

Pyro 使用的任务分布方法与 Celery 和 Python-RQ 十分不同,它是在网络中将 Python 对象作为服务器。然后创建它们的代理对象,让调用代码可以将其看做本地对象。这个架构在 90 年代末的系统很流行,比如 COBRA 和 Java RMI。

Pyro 掩盖了代码中的对象是本地还是远程的,是让人诟病的一点。原因是,远程代码运行错误的原因很多,当远程代码隐藏在代理对象后面执行,就不容易发现错误。

另一个诟病的地方是,Pyro 在点对点网络(不是所有主机名都可以解析)中,或者 UDP 广播无效的网络中,很难正确运行。

尽管如此,大多数开发者认为 Pyro 非常简易,在生产环境中足够健壮。

Pyro 安装很简单,它是纯 Python 写的,依赖只有几个,使用 pip:

$ pip install pyro4 

这个命令会安装 Pyro 4.x 和 Serpent,后者是 Pyro 用来编码和解码 Python 对象的序列器。

用 Pyro 重写之前的汇率例子,要比用 Python-RQ 复杂,它需要另一个软件:Pyro nameserver。但是,不需要中间代理和结果后台,因为 Pyro 对象之间可以直接进行通讯。

Pyro 运行原理如下。每个远程访问的对象都封装在处于连接监听的 socket 服务器框架中。每当调用远程对象中的方法,被调用的方法,连同它的参数,就被序列化并发送到适当的对象/服务器上。此时,远程对象执行被请求的任务,经由相同的连接,将结果发回到(同样是序列化的)调用它的代码。

因为每个远程对象自身就可以调用远程对象,这个架构可以是相当去中心化的。另外,一旦建立通讯,对象之间就是 p2p 的,这与分布式任务队列的轻度耦合架构十分不同。另一点,每个远程对象既可以做 master,也可以做 worker。

接下来重写汇率的例子,来看看具体是怎么运行的。建立一个 Python 文件(pyro/worker.py),代码如下:

import urllib.request
import Pyro4

URL = 'http://finance.yahoo.com/d/quotes.csv?s={}=X&f=p'

@Pyro4.expose(instance_mode="percall")
class Worker(object):
    def get_rate(self, pair, url_tmplt=URL):
        with urllib.request.urlopen(url_tmplt.format(pair)) as res:
            body = res.read()
        return (pair, float(body.strip()))

# Create a Pyro daemon which will run our code.
daemon = Pyro4.Daemon()
uri = daemon.register(Worker)
Pyro4.locateNS().register('MyWorker', uri)

# Sit in an infinite loop accepting connections
print('Accepting connections')
try:
    daemon.requestLoop()
except KeyboardInterrupt:
    daemon.shutdown()
print('All done') 

worker 的代码和之前的很像,不同点是将get_rate函数变成了Worker类的一个方法。变动的原因是,Pyro 允许导出类的实例,但不能导出函数。

剩下的代码是 Pyro 特有的。我们需要一个Daemon实例(它本质上是后台的网络服务器),它会获得类,并在网络上发布,好让其它的代码可以调用方法。分成两步来做:首先,创建一个类Pyro4.Daemon的实例,然后添加类,通过将其传递给register方法。

每个 Pyro 的Daemon实例可以隐藏任意数目的类。内部,需要的话,Daemon对象会创建隐藏类的实例(也就是说,如果没有代码需要这个类,相应的Daemon对象就不会将其实例化)。

每一次网络连接,Daemon对象默认会实例化一次注册的类,如果要进行并发任务,这样就不可以。可以通过装饰注册的类修改,@Pyro4.expose(instance_mode=...)

instance_mode支持的值有三个:singlesessionpercall。使用single意味Daemon只为类创建一个实例,使用它应付所有的客户请求。也可以通过注册一个类的实例(而不是类本身)。

使用session可以采用默认模式:每个 client 连接都会得到一个新的实例,client 始终都会使用它。使用instance_mode="percall",会为每个远程方法调用建立一个新实例。

无论创建实例的模式是什么,用Daemon对象注册一个类(或实例)都会返回一个唯一的识别符(即 URI),其它代码可以用识别符连接对象。我们可以手动传递 URI,但更方便的方法是在 Pyro nameserver 中存储它,这样通过两步来做。先找到 nameserver,然后给 URI 注册一个名字。在前面的代码中,是通过下面来做的:

Pyro4.locateNS().register('MyWorker', uri) 

nameserver 的运行类似 Python 的字典,注册两个名字相同的 URI,第二个 URI 就会覆盖第一个。另外,我们看到,client 代码使用存储在 nameserver 中的名字控制了许多远程对象。这意味着,命名需要特别的留意,尤其是当许多 worker 进程提供的功能相同时。

最后,在前面的代码中,我们用daemon.requestLoop()进入了一个Daemon事件循环。Daemon对象会在无限循环中服务 client 的请求。

对于 client,创建一个 Python 文件(pyro/main.py),它的代码如下:

#!/usr/bin/env python3
import argparse
import time
import Pyro4

parser = argparse.ArgumentParser()
parser.add_argument('pairs', type=str, nargs='+')
args = parser.parse_args()

# Retrieve the rates sequentially.
t0 = time.time()
worker = Pyro4.Proxy("PYRONAME:MyWorker")

for pair in args.pairs:
    print(worker.get_rate(pair))
print('Sync calls: %.02f seconds' % (time.time() - t0))

# Retrieve the rates concurrently.
t0 = time.time()
worker = Pyro4.Proxy("PYRONAME:MyWorker")
async_worker = Pyro4.async(worker)

results = [async_worker.get_rate(pair) for pair in args.pairs]
for result in results:
    print(result.value)
print('Async calls: %.02f seconds' % (time.time() - t0)) 

可以看到,client 把相同的工作做了两次。这么做的原因是展示 Pyro 两种调用方式:同步和异步。

来看代码,我们使用argparse包从命令行获得汇率对。然后,对于同步的方式,通过名字worker = Pyro4.Proxy("PYRONAME:MyWorker")获得了一些远程worker对象。前缀PYRONAME:告诉 Pyro 在 nameserver 中该寻找哪个名字。这样可以避免手动定位 nameserver。

一旦有了worker对象,可以把它当做本地的worker 类的实例,向其调用方法。这就是我们在第一个循环中做的:

for pair in args.pairs:
    print(worker.get_rate(pair)) 

对每个worker.get_rate(pair)声明,Proxy 对象会用它的远程Daemon对象连接,发送请求,以运行get_rate(pair)。我们例子中的Daemon对象,每次会创建一个Worker类的的实例,并调用它的get_rate(pair)方法。结果序列化之后发送给 client,然后打印出来。每个调用都是同步的,任务完成后会封锁。

在第二个循环中,做了同样的事,但是使用的是异步调用。我们需要向远程的类创建一个 Proxy 对象,然后,将它封装在一个异步 handler 中。这就是下面代码的功能:

worker = Pyro4.Proxy("PYRONAME:MyWorker")
async_worker = Pyro4.async(worker) 

我们现在可以在后台用async_worker获取汇率。每次调用async_worker.get_rate(pair)是非阻塞的,会返回一个Pyro4.futures.FutureResult的实例,它和concurrent.futures模块中Future对象很像。访问它的value需要等待,直到相应的异步调用完成。

为了运行这个例子,需要三台机器的三个窗口:一个是 nameserver(HOST1),一个是Worker类和它的 Daemon(HOST2),第三个(HOST3)是 client(即main.py)。

在第一个终端,启动 nameserver,如下:

HOST1 $ pyro4-ns --host 0.0.0.0
Broadcast server running on 0.0.0.0:9091
NS running on 0.0.0.0:9090 (0.0.0.0)
Warning: HMAC key not set. Anyone can connect to this server!
URI = PYRO:Pyro.NameServer@0.0.0.0:9090 

简单来说,nameserver 绑定为 0.0.0.0,任何人都可以连接它。我们没有设置认证,因此在倒数第二行弹出了一个警告。

nameserver 运行起来了,在第二个终端启动 worker:

HOST2 $ python3.5 worker.py
Accepting connections 

Daemon对象接收连接,现在去第三个终端窗口运行 client 代码,如下:

HOST3 $ python3.5 main.py EURUSD CHFUSD GBPUSD GBPEUR CADUSD CADEUR
('EURUSD', 1.093)
('CHFUSD', 1.0058)
('GBPUSD', 1.5141)
('GBPEUR', 1.3852)
('CADUSD', 0.7493)
('CADEUR', 0.6856)
Sync calls: 1.55 seconds
('EURUSD', 1.093)
('CHFUSD', 1.0058)
('GBPUSD', 1.5141)
('GBPEUR', 1.3852)
('CADUSD', 0.7493)
('CADEUR', 0.6856)
Async calls: 0.29 seconds 

结果和预想一致,IO 限制型代码可以方便的进行扩展,异步代码的速度六倍于同步代码。

这里,还有几个提醒。第一是,Pyro 的Daemon实例要能解析主机的名字。如果不能解析,那么它只能接受 127.0.0.1 的连接,这意味着,不能被远程连接(只能本地连接)。解决方案是将其与运行的主机进行 IP 绑定,确保它不是环回地址。可以用下面的 Python 代码选择一个可用的 IP:

from socket import gethostname, gethostbyname_ex

ips = [ip for ip in gethostbyname_ex(gethostname())[-1] 
        if ip != '127.0.0.1']
ip = ips.pop() 

另一个要考虑的是:作为 Pyro 使用“直接连接被命名对象”方法的结果,很难像 Celery 和 Python-RQ 那样直接启动一批 worker。在 Pyro 中,必须用不同的名字命名 worker,然后用名字进行连接(通过代理)。这就是为什么,Pyro 的 client 用一个 mini 的规划器来向可用的 worker 分配工作。

另一个要注意的是,nameserver 不会跟踪 worker 的断开,因此,用名字寻找一个 URI 对象不代表对应的远程Daemon对象是真实运行的。最好总是这样对待 Pyro 调用:远程服务器的调用可能成功,也可能不成功。

记住这些点,就可以用 Pyro 搭建复杂的网络和分布式应用。

总结

这一章很长。我们学习了 Celery,他是一个强大的包,用以构建 Python 分布式应用。然后学习了 Python-RQ,一个轻量且简易的替代方案。两个包都是使用分布任务队列架构,它是用多个机器来运行相同系统的分布式任务。

然后介绍了另一个替代方案,Pyro。Pyro 的机理不同,它使用的是代理方式和远程过程调用(RPC)。

两种方案都有各自的优点,你可以选择自己喜欢的。

下一章会学习将分布式应用部署到云平台,会很有趣。

五、云平台部署 Python (Distributed Computing with Python)

上一章介绍了创建 Python 分布式应用的 Celery 和其它工具。我们学习了不同的分布式计算架构:分布任务队列和分布对象。然而,还有一个课题没有涉及。这就时在多台机器上部署完成的应用。本章就来学习。

这里,我们来学习 Amazon Web Services (AWS),它是市场领先的云服务产品,以在上面部署分布式应用。云平台不是部署应用的唯一方式,下一章,我们会学习另一种部署方式,HPC 集群。部署到 AWS 或它的竞品是一个相对廉价的方式。

云计算和 AWS

AWS 是云计算的领先提供商,它的产品是基于互联网的按需计算和存储服务,通常是按需定价。

通过接入庞大的算力资源池(虚拟或现实的)和云平台存储,可以让应用方便的进行水平扩展(添加更多机器)或垂直扩展(使用性能更高的硬件)。在非常短的时间内,通过动态添加或减少资源(减少花费),就可以让用户下载应用。配置资源的简易化、使用庞大的云平台资源、云平台的高可用性、低廉的价格,都是进行云平台部署的优点,尤其是对小公司和个人。

云平台的两种主要服务是计算机节点和存储。也会提供其它服务,包括可扩展的数据库服务器(关系型和非关系型数据库)、网络应用缓存、特殊计算框架(例如 Hadoop/MapReduce),以及应用服务(比如消息队列或电子邮件服务)。所有这些服务都可以进行动态扩展,以适应使用量的增加,当使用量减小时,再缩小规模。

AWS 提供前面所有的服务,然而这章只关注一些主要服务:计算机节点 Amazon Elastic Compute Cloud (EC2),计算机节点虚拟硬盘存储 Amazon Elastic Block Store (EBS),存储应用数据 AmazonSimple Storage Server(S3),应用部署 Amazon Elastic Beanstalk。

创建 AWS 账户

为了使用 AWS,需要创建一个账户。使用一定量资源的首年是免费的,之后按照标准价格收费。

要创建账户,打开页面https://aws.amazon.com,点击 Create a Free Account,如下面截屏所示:

注册需要基本的联系信息,支付手段(必须要用信用卡注册),和一些其它信息。

一旦账户激活,就可以登录管理页面,如下面截图所示:

控制台页面功能繁杂,有许多图标,对应着 50 多项服务。本章会讲如何使用 EC2、Elastic Beanstalk,S3 和 Identity and Access Management 服务,它们的图标在下图中标出:

在创建虚拟运行应用和用存储仓保存数据之前,我们需要创建至少一个用户和一个用户组。要这么做,点击第二栏的 Identity and Access Management,或打开网页https://console.aws.amazon.com/iam/。点击左边栏的 Groups,然后点击 Create New Group 按钮。

然后会让你输入新用户组的名字。我通常使用Wheel作为管理组的名字。填入用户组名字之后,点击 Next Step 按钮。

然后,我们需要选择这个用户组的预定义规则。在这个页面,我们可以选择 AdministratorAccess。然后点击 Next Step 按钮。

在下一页检查之前的选项,如果没有问题,可以点击 Create Group。Group 页面就会列出新创建的用户组了,如下所示:

点击用户组的名字(Wheel),然后在 Permissions 栏就会显示这个组的规则,如下图所示:

现在,创建至少一个用户,即登录虚拟机的账户。左侧栏点击 Users,然后点击页面上方的 Create New Users,在打开的页面中,一次最多可以创建五个用户。

现在来创建一个用户。在第一个空格(数字 1 旁边)输入用户名,确保勾选了选项框 Generate an access key for each user,然后点击 Create 按钮,如下图所示(我选的用户名是 bookuser):

下面的一页很重要,呈现在我们面前的是一个用户创建流程的概括,可以在这里下载用户整数。一定要点击 Download Credentials 按钮。如果没有做,或将证书(一个 csv 文件)放错了位置,你可以创建一个新用户,再下载一个证书。

现在,我们需要将创建的用户添加到用户组。要这么做,返回 Groups 页面(点击左侧栏的 Groups),选择之前创建的管理组(Wheel),点击页面上方的 Group Actions,在弹出的页面点击 Add Users to Group。如果这个条目不能使用,确保勾选了组名旁边的选择框。

来到一个列出所有用户的新页面。点击刚刚创建的用户旁边的勾选框,然后点击页面底部的 Add Users。在下一页,点击组名,然后在 Users 栏会显示刚刚添加的用户,如下图所示:

现在,创建密码。返回 Users 页面(点击左侧导航栏的 Users),点击用户名,在打开的页面点击 Security Credentials 栏。会看到类似下图的页面:

这里,在 Sign-In-Credentials 下面的 Manage Password 根据提示设置密码,我们可以让 AWS 为我们设置密码,或自定义一个密码。

快完成了。剩下的是为用户创建 SSH 密钥,以让用户能不用密码就登录 EC2 实例。这也可以用管理台来做。

登出管理台,用刚才创建的用户再次登录。为了这么做,使用刚才页面的 URL,https://console.aws.amazon.com/iam,它的形式是https://<ACCOUNT NUMBER>.signin.aws.amazon.com/console/

现在,在管理台页面,点击 EC2 图标,然后在左上方的弹出框选择实例的地理位置(我选择的是 Ireland)。Amazon EC2 虚拟机有多个区域,涵盖美国、欧洲、亚洲和南美。SSH 密钥是和区域有关的,也就是说,要使用两个不同区域的机器,我们要为每个区域创建两个不同的 SSH 密钥对。

选择完区域之后,点击 Key Pairs,如下所示:

点击 Create Key Pair,给其命名(我起的名字是 bookuser-ireland-key),然后点击 Create 按钮。新创建的私钥会自动下载到你的电脑,格式是pem(有事下载的文件的后缀名是.pem.txt,可以将其重命名为.pem)。

确保将其安全的存放,进行备份,因为不会再次下载。如果放的位置不对,你需要使用 AWS 控制台新建一个,以删除这个密钥对。

我把密钥保存在$HOME.ssh目录。我通常将密钥复制到这里,重命名它的后缀为.pem,并且只有我才能访问(即 chmod 400 ~/.ssh/bookuser-ireland-key.pem)。

创建一个 EC2 实例

做完了所有的配置,现在可以创建第一个虚拟机了。从我们选择的地理区域开始(记得为每个创建密钥),然后登陆运行的实例。我们现在只是用网页控制台来做。

如果你不在控制台,使用创建的用户登陆(可以使用 URL:https://<ACCOUNTNUMBER>.signin.aws.amazon.com/console/),然后点击 EC2 图标。

打开的 EC2 控制台如下所示:

点击页面中间的蓝色按妞 Launch Instance。接下来创建虚拟机。首先,选择 Amazon Machine Image (AMI),它是底层的操作系统,和默认的虚拟机软件包集合。

可选的配置有很多。我们选择一个免费的 AMI。我喜欢 64 位的 Ubuntu 服务器镜像,从上往下数的第四个(你也可以选其它的):

然后,要选择虚拟硬件。Amazon 提供多种配置,取决于用户的需求。例如,如果我们想运行深度学习代码,我们就要选择 GPU 强大的实例。在我们的例子中,我们选择 Free tier eligible t2.micro,下面截图中的第一个:

点击 Next: Configure Instance Details 会打开一个新页面,在上面可以配置将要创建的实例的一些特性。现在,只使用其默认值,但要关注一下 Purchasing option。我们现在使用的是 Spot instance,意味着如果使用了更高级的实例需要硬件资源,就会关闭之前的虚拟机。

我们现在不需要对这一项进行设置,但是要知道 Spot instance 是一种可以降低花费的手段。点击 Next: Add Storage。

这里,我们可以配置存储选项。现在,还是使用默认值,只是看一下选项的内容。Delete on Termination 是默认勾选的,它的作用是当结束实例时,和其相关的数据也会被删除。因为在默认情况下,实例是暂停而非终止,这么设置就可以。然后点击 Next: Tag Instance。我们现在不创建任何 tag,所以继续点击 Next: Configure Security Group。

这个打开的页面很重要,如下所示:

在这一页上,我们来配置实例的服务(网络端口)和登录 VM 的 IP 地址。现在,我们只是改变 SSH 的规则,以允许从 My IP 的连接(在弹出菜单的 Source 标题,SSH 行)。

例如,向 Anywhere 打开 TCP 的 80 端口,以运行一个网络服务器,或是 5672 端口(使用 RabbitMQ 的 Celery 的端口),供 Celery 应用的 IP 使用。

现在不建立任何规则,使用默认的 SSH 访问规则。设置页面如下:

最后,点击 Review and Launch,如果没有问题的话,再点击 Launch。确保可以访问正确的.pem文件,以访问 EC2 实例,然后点击 Launch Instances。

Amazon 现在会启动实例,需要几分钟时间。通过点击页面底部的 View Instances,可以看到实例的运行或准备状态:

一旦 VM 运行了,就可以用 SSH 连接它。通过终端进行连接,使用实例 OS 的正确 Unix 用户名(即,Amazon Linux 是ec2-user,Ubuntu 是ubuntu,SUSE 是rootec2-user,Fedora Linux 是fedoraec2-user)。

在我们的例子中,登录窗口如下:

VM 中包含了一些预先安装的软件,包括 Python 2.7 和 3.4。为了实用,这个 VM 是一台 Linux 服务器。试验结束之后,可以在 Actions 弹出窗中点击 Stop 结束实例,选中实例的名字,如下图所示:

关于 EC2 实例,特别要注意虚拟的存储和虚拟机在重启、停止、关闭时,存储设备的行为。因为,无论停止还是关闭虚拟机,它的 IP 地址都会失效,下次启动时会分配新的 IP 地址。

我们创建的实例(t2.micro)使用存储在 EBS 的虚拟硬盘,它是 EC2 实例的高性能和高可靠性的存储。

默认情况下,当对应的实例关闭时,存储在 EBS 的虚拟硬盘会被删除(除非 Add Storage 页面的 Delete on Termination 选项没有勾选),但实例停止时,存储不会删除。停止实例会导致存储费用,而关闭实例不会。

重启一个关闭的实例是不可能的,必须要从头新建一个实例,这比重启暂停的 VM 要花费更长的时间。因为这个原因,如果想重新使用实例,最好停止而不是关闭。然而,保持 EBS 存储是一笔可观的花费,所以应该使用时间不长的实例应该关闭。

重启、关闭状态下,使应用数据保存在 EBS 的方法之一是新建一个 EBS 卷,当相关的 EC2 实例运行时,将新的卷分配给这个实例。这是通过点击 EC2 Dashboard 页面的 Volumes 链接,然而根据提示操作。要记住,初次使用一个卷时,需要进行格式化,这可以通过在运行 EC2 实例内使用专门的工具,如下图所示:

Linux 内核重新映射了 EBS 卷的设备名字,/dev/sdf to /dev/xvdf

分配一个卷就像将硬盘链接电脑,它们的数据在重启之后也会保存,并可以从一个实例移动到另一个实例。要记住,每创建一个卷都要花钱,无论是否使用。

另一种(花费较低的)存储应用数据的方法是使用 S3,接下来讨论它。

使用 Amazon S3 存储数据

Amazon Simple Storage Service,S3,是一个存储、读取数据的网络服务。各种文件都可以存储到 S3,上到 5TB 的数据,或是源代码。

S3 远比 EBS 便宜,但是它不提供文件层,而是一个 REST API。另一个不同点是,EBS 卷一次只能分配一个运行的实例,S3 对象可以在多个实例间共享,取决于许可协议,可以网络各处访问。

使用 S3 很简单,你需要在某个地理区域(为了降低访问时间)创建一些桶(即 S3 的容器),然后添加数据。过程如下:登录 AWS 管理台,点击 Storage & Content Delivery 下面的 S3 图标,点击 Create Bucket 按钮,给桶起名字,然后给它选择区域。

对于这个例子,我们起的名气是 book-123-456,区域是爱尔兰,如下图所示:

点击 Create 按钮。因为桶的名字实在 S3 用户间分享的,像 book 这样的名字都被使用过了。因此,起的名字最好加上一些识别符。

下一页显示了创建的 S3 桶列表,见下图(点击桶名字左侧的图标,以显示桶的属性):

从这页开始,在桶页面上就可以查看桶的内容、上传数据、重命名、或删除,见下面截图:

Amazon S3 有一个复杂的许可协议,可以根据每个对象、每个桶执行访问。现在,向桶传一些文件,并修改访问权限。

创建一个文本文件夹,并存储一些文本文件。在我的例子中,我创建了一个文件index.html,内容是"Hi there!"。使用 Upload,上传到 S3.

我们可以检查这个文件的属性(包括访问权),通过选择文件,并点击右上角的 Properties。从下页可以看到,默认情况下,刚刚上传的文件只能被我们访问到:

我们可以从终端师徒访问文件(使用文件名属性下方的 URL),但是会有错误 Access Denied。我们可以添加一个许可,让任何人可以对这个文件进行读写,如下图所示(记得 Save 访问规则):

创建这个许可之后,刚上传的文件就是面向公众可读的了,例如,作为网页的静态文件。在 S3 上存储文件相对便宜,但不是完全免费。

Amazon elastic beanstalk

Amazon Elastic Beanstalk (EB) 是将应用部署到 AWS 的简便方法,不必分别处理 EC2 和 S3.Amazon EB 功能完备,支持 Python。

最好在 Python 虚拟环境中,用命令行(使用 awsebcli 包)使用 EB。要点是,你需要创建一个 Python 应用的虚拟环境,以部署到 AWS。应用本身保存在一个文件夹内,用来打包。

使用eb命令,就可以创建一个初始化部署配置(eb init),通过写额外的配置文件(文件夹.ebextensions)来进行自定义,配置选项,例如需要的环境变量,或需要进行的推迟安装。

应用在本地测试完毕之后,就可以使用eb create部署到 AWS,使用eb terminate命令进行销毁。

AWS 网站有关于部署的教程,例如,一个稍显复杂的 Django 网页应用(http://docs.aws.amazon.com/elasticbeanstalk/latest/dg/create-deploy-python-django.html#python-django-configure-for-eb),可以让你学习更多的 EB。

尽管 EB 文档大部分都是关于网页应用的,它的应用不局限于 HTTP 服务器和 WSGI 应用。

创建私有云平台

AWS 对大多数个人和公司都是一个不错的选择。但是,使用 AWS 也会增加成本。或者,公司的政策,或从数据的隐私性考虑,不能使用云平台。

这就需要搭建一个内部的私有云平台。使用现有的硬件,运行虚拟机(EC2)和数据存储中间件(类似于 S3),再加上其它服务,比如负载均衡、数据库等等。

这样的免费开源工具很多,比如OpenStack(http://www.openstack.org), CloudStack(https://cloudstack.apache.org),和 Eucalyptus(http://www.eucalyptus.com)。

Eucalyptus 可以和 AWS(EC2 和 S3)交互。使用它可以构建类似 AWS 的 API。这样,就可以扩展私有云平台,或是迁移到 EC2 和 S3,而不用重新创建虚拟机镜像、工具和管理脚本文件。

另外,Python 的与 AWS 交互的 boto 工具包(pip install boto)是与 Eucalyptus 兼容的。

总结

通过 AWS,我们学习了利用云平台进行计算和存储,用户按需支付,只需要给使用的资源付款。

这些平台在开发阶段和运行阶段,都可以用于我们的分布式应用。特别是进行伸缩性测试,让我们自己一下提供许多台机器是不现实的。更不用说,还可以使用海量的云平台资源,以及可靠性保障。

同时,云平台服务是收费。而且,通常定价不菲。另外,从时间和精力,云平台限制颇多,我们不能管理资源、不能安装软件,也不能学习某个软件工具和它的特性。从一个云平台迁移到另一个,还往往很费事。

知道了这些,就可以更好的让云平台适合我们的总体设计、开发、测试、部署。

例如,一个简单的策略是将分布式应用部署到自建的平台上,只在流量增加时使用云平台。所以,要时刻更新 VM 镜像,并引入到 Amazon EC2.

下一章,我们会学习研究者和实验室/大学人员的场景,在大型的高性能计算机(HPC)群上运行 Python。

六、超级计算机群使用 Python (Distributed Computing with Python)

本章,我们学习另一种部署分布式 Python 应用的的方法。即使用高性能计算机(HPC)群(也叫作超级计算机),它们通常价值数百万美元(或欧元),占地庞大。

真正的 HPC 群往往位于大学和国家实验室,创业公司和小公司因为资金难以运作。它们都是系统巨大,有上万颗 CPU、数千台机器。

经常超算中心的集群规模通常取决于电量供应。使用几兆瓦的 HPC 系统很常见。例如,我使用过有 160000 核、7000 节点的机群,它的功率是 4 兆瓦!

想在 HPC 群运行 Python 的开发者和科学家可以在本章学到有用的东西。不使用 HPC 群的读者,也可以学到一些有用的工具。

典型的 HPC 群

HPC 系统有多种形式和规模,然而,它们有一些相同点。它们是匀质的,大量相同的、装在架子上的计算机,处于同一个房间内,通过高速网络相连。有时,在升级的时候,HPC 群会被分成两个运行体系。此时,要特别注意规划代码,以应对两个部分的性能差异。

集群中的大部分机器(称作节点),运行着相同的系统和相同的软件包,只运行计算任务。用户不能直接使用这些机器。

少部分节点的算力不如计算节强大,但是允许用户登录。它们称作服务节点(或登录节点或头节点),只运行用户脚本、编译文件、任务管理软件。用户通常登录这些节点,以访问机群。

另一些节点,介于服务节点和计算节点之间,它们运行着全套计算节点的操作系统,但是由多个用户共享,而纯粹的计算节点的每个核只运行一个线程。

这些节点是用来运行小型的序列化任务,而不需要计算节点的全部资源(安装应用和清理)。例如在 Cray 系统上,这些节点称作 Multiple Application, Multiple User (MAMU)节点。

下图是 NASA 的 2004 Columbia 超级计算机,它有 10240 个处理器,具有一定代表性:

如何在 HPC 群上运行代码呢?通常是在服务节点登录,使用任务规划器(job scheduler)。任务规划器是一个中间件,给它一些代码,它就可以寻找一些计算节点运行代码。

如果此时没有可用的硬件资源,代码就会在一个队列中等待,直到有可用的资源。等待时间可能很长,对于短代码,等待时间可能比运行时间还长。

HPC 系统使用任务规划器,视为了确保各部门和项目可以公平使用,最大化利用机群。

商用和开源的规划器有很多。最常用的是 PBS 和它的衍生品(例如 Torque 和 PBS Pro),HTCondor,LoadLeveler,SLURM、Grid Engine 和 LSF。这里,我们简短介绍下其中两个:HTCondor 和 PBS Pro。

任务规划器

如前所述,你不能直接在 HPC 群上运行代码,你必须将任务请求提交给任务规划器。任务规划器会分配算力资源,在分配的节点上运行应用。

这种间接的方法会造成额外的开销,但可以保证每个用户都能公平的分享使用计算机群,同时任务是有优先级的,大多数处理器都处于忙碌状态。

下图展示了任务规划器的基本组件,和任务提交到执行的事件顺序:

首先,先来看一些定义:

  • 任务:这是应用的元数据,例如它的可执行文件、输入和输出、它的硬件和软件需求,它的执行环境,等等;
  • 机器:这是最小的任务执行硬件。取决于集群的配置,它可能是一部分节点(例如,一台多核电脑的一个核心)和整个节点。

从概念层面,任务规划器的主要部分有:

  • 资源管理器
  • 一个或多个任务队列
  • 协调器

为了提交一个任务请求到任务规划器,需要编写元数据对象,它描述了我们想运行的内容,运行的方式和位置。它往往是一个特殊格式的文本文件,后面有一个例子。

然后,用户使用命令行或库提交任务描述文件(上图中的步骤 1)到任务规划器。这些任务先被分配到一个或多个队列(例如,一个队列负责高优先级任务,一个负责低优先级任务,一个负责小任务)。

同时,资源管理器保持监督(步骤 2)所有计算节点,以确定哪台空闲哪台繁忙。它还监督着正在运行的任务的优先级,在必要时可以释放一些空间给高优先级的任务。另外,它还监督着每台机器的性能指标,能运行什么样的任务(例如,有的机器只能让特定用户使用)。

另外一个守护进程,萧条期,持续监督着任务队列的闲置任务(步骤 2),并将它们分配给何时的机器(步骤 3),其间要考虑用户的优先级、任务优先级、任务需求和偏好、和机器的性能和偏好。如果在这一步(称作协调循环)没有可用的资源来运行任务,任务就保存在队列中。

一旦指派了运行任务的资源,规划器会在分配的机器上运行可执行文件(步骤 4)。有的规划器(例如 HTCondor)会复制可执行文件,向执行机器发送文件。如果不是这样,就必须让代码和数据是在共享式文件系统,或是复制到机器上。

规划器(通常使用监督进程)监督所有的运行任务,如果任务失败则重启任务。如果需要的话,还可以发送任务成功或失败的 email 通知邮件。

大多数系统支持任务间依赖,只有达到一定条件时(比如,新的卷),任务才能执行。

使用 HTCondor 运行 Python 任务

这部分设定是用 HTCondor 任务规划器,接入机群。安装 HTCondor 不难(参考管理文档https://research.cs.wisc.edu/htcondor/manual/),这里就不介绍了。

HTCondor 有一套命令行工具,可以用来向机群提交任务(condor_submit),查看提交任务的状态(condor_q),销毁一个任务(condor_rm),查看所有机器的状态(condor_status)。还有许多其它工具,总数超过 60。但是,我们这里只关注主要的四个。

另一种与 HTCondor 机群交互的方法是使用 Distributed Resource Management Application API (DRMAA),它内置于多数 HTCondor 安装包,被打包成一个共享库(例如,Linux 上的libdrmma.so)。

DRMAA 有任务规划器的大部分功能,所以原则上,相同的代码还可以用来提交、监督和控制机群和规划器的任务。Python 的drmaa模块(通过pip install drmaa安装),提供了 DRMAA 的功能,包括 HTCondor 和 PBS 的功能。

我们关注的是命令行工具,如何用命令行工具运行代码。我们先创建一个文本文件(称作任务文件或提交文件),描述任务的内容。

打开文本编辑器,创建一个新文件。任务文件的名字不重要。我们现在要做的是在机群上运行下面的代码:

$ python3.5 –c "print('Hello, HTCondor!')" 

任务文件(htcondor/simple/simple.job)的代码如下:

# Simple Condor job file
# There is no requirement or standard on job file extensions.
# Format is key = value
# keys and values are case insensitive, with the exception of
# paths and file names (depending on the file system).
# Usage: shell> condor_submit simple.job
# Universe is the execution environment for our jobs
# vanilla is the one for shell scripts etc.
Universe = vanilla
# Executable is the path to the executable to run
Executable = /usr/local/bin/python3.5
# The arguments string is passed to the Executable
# The entire string is enclosed in double-quotes
# Arguments with spaces are in single quotes
# Single & double quotes are escaped by repeating them
Arguments = "-c 'print(''Hello, HTCondor!'')'"
# Output is the file where STDOUT will be redirected to
Output = simple_stdout.txt
# Error is the file where STDERR will be redirected to
Error = simple_stderr.txt
# Log is the HTCondor log, not the log for our app
Log = simple.log
# Queue tells HTCondor to enqueue our job
Queue 

这段代码很简单。

让人疑惑的可能是Output指令,它指向文件进行STDOUT重定向,而不是执行代码的结果输出。

另一个会让人疑惑的是Log指令,它不知想应用的日志文件,而是任务专门的 HTCondor 日志。指令Arguments句法特殊,也要注意下。

我们可以用condor_submit提交任务,如下图所示,提交任务之后,立即用condor_q查看状态:

HTCondor 给任务分配了一个数字识别符,形式是cluster id.process id(这里,进程 ID 专属于 HTCondor,与 Unix 进程 ID 不完全相同)。因为可以向一个任务文件提交多个任务(可以通过Queue命令,例如Queue 5000,可以启动 5000 个任务的实例),HTCondor 会将其当做集群处理。

每个集群都有一个唯一的识别符,集群中的每个进程都有一个 0 到 N-1 之间的识别符,N 是集群的总进程数(任务实例的数量)。我们的例子中,只提交一个任务,它的识别符是 60.0。

注意:严格的讲,前面的任务识别符只是在任务队列/提交奇迹中是唯一的,在整个集群不是唯一的。唯一的是GlobalJobId,它是一连串事件的 ID,包括主机名、集群 ID、进程 ID 和任务提交的时间戳。可以用condor_q -log显示GlobalJobId,和其它内部参数。

取决于 HTCondor 的配置,以及机群的繁忙程度,任务可以立即运行,或是在队列中等待。我们可以用condor_q查询状态,idle(状态 I),running(状态 R),suspended(状态 H),killed(状态 X)。最近添加了两个新的状态:in the process of transferring data to the execute node(<)和transferring data back to the submit host(>)。

如果一切正常,任务会在队列中等待一段时间,然后状态变为运行,最后退出(成功或出现错误),从队列消失。

一旦任务完成,查看当前目录,我们可以看到三个新文件:simple.logsimple_stderr.txtsimple_stdout.txt。它们是任务的日志文件,任务的标准错误,和标准输出流。

日志文件有许多有用的信息,包括任务提交的时间和从哪台机器提交的,在队列中等待的时间,运行的时间和机器,退出代码和利用的资源。

我们的 Python 任务退出状态是 0(意味成功),在STDERR上没有输出(即simple_stderr.txt是空的),然后向STDOUT写入Hello,HTCondor!(即simple_stdout.txt)。如果不是这样,就要进行调试。

现在提交一个简单的 Python 文件。新的任务文件很相似,我们只需更改ExecutableArguments。我们还要传递一些环境变量给任务,提交 100 个实例。

创建一个新任务文件(htcondor/script/script.job),代码如下:

# Simple Condor job file
# There is no requirement or standard on job file extensions.
# Format is key = value
# keys and values are case insensitive, with the exception of
# paths and file names (depending on the file system).
# Usage: shell> condor_submit script.job

# Universe is the execution environment for our jobs
# vanilla is the one for shell scripts etc.
Universe = vanilla
# Executable is the path to the executable to run
Executable = test.py
# The arguments string is passed to the Executable
# The entire string is enclosed in double-quotes
# Arguments with spaces are in single quotes
# Single & double quotes are escaped by repeating them
Arguments = "--clusterid=$(Cluster) --processid=$(Process)"
# We can specify environment variables for our jobs as
# by default jobs execute in a very restricted environment
Environment = "MYVAR1=foo MYVAR2=bar"
# We can also pass our entire environment to the job
# By default this is not the case (i.e. GetEnv is False)
GetEnv = True
# Output is the file where STDOUT will be redirected to
# We will have one file per process otherwise each
# process will overwrite the same file.
Output = script_stdout.$(Cluster).$(Process).txt
# Error is the file where STDERR will be redirected to
Error = script_stderr.$(Cluster).$(Process).txt
# Log is the HTCondor log, not the log for our app
Log = script.log
# Queue tells HTCondor to enqueue our job
Queue 100 

接下来写要运行的 Python 文件。创建一个新文件(htcondor/script/test.py),代码如下:

#!/usr/bin/env python3.5
import argparse
import getpass
import os
import socket
import sys
ENV_VARS = ('MYVAR1', 'MYVAR2')

parser = argparse.ArgumentParser()
parser.add_argument('--clusterid', type=int)
parser.add_argument('--processid', type=int)
args = parser.parse_args()

cid = args.clusterid
pid = args.processid

print('I am process {} of cluster {}'
      .format(pid, cid))
print('Running on {}'
      .format(socket.gethostname()))
print('$CWD = {}'
      .format(os.getcwd()))
print('$USER = {}'
      .format(getpass.getuser()))

undefined = False
for v in ENV_VARS:
    if v in os.environ:
        print('{} = {}'
              .format(v, os.environ[v]))
    else:
        print('Error: {} undefined'
              .format(v))
        undefined = True
if undefined:
    sys.exit(1)
sys.exit(0) 

这段简单的代码很适合初次使用 HPC 机群。它可以清晰的显示任务在哪里运行,和运行的账户。

这是在写 Python 任务时需要知道的重要信息。某些机群有在所有计算节点上都有常规账户,在机群上分享用户的主文件夹。对于我们的例子,用户在登录节点上提交之后就会运行。
在其他机群上,任务都运行在低级用户下(例如,nobody用户)。这时,特别要注意许可和任务执行环境。

注意:HTCondor 可以在提交主机和执行节点之间高效复制数据文件和/或可执行文件。可以是按需复制,或是总是复制的模式。感兴趣的读者可以查看指令should_transfer_filestransfer_executabletransfer_input_files,和transfer_output_files

前面的任务文件(htcondor/script/script.job)有一些地方值得注意。首先,要保证运行任务的用户可以找到 Python 3.5,它的位置可能和不同。我们可以让 HTCondor 向运行的任务传递完整的环境(通过指令GetEnv = True)。

我们还提交了 100 个实例(Queue 100)。这是数据并行应用的常用方式,数据代码彼此独立运行。

我们需要自定义文件的每个实例。我们可以在任务文件的等号右边用两个变量,\((Process)和\)(Cluster)。在提交任务的时候,对于每个进程,HTCondor 用响应的集群 ID 和进程 ID 取代了这两个变量。

像之前一样,提交这个任务:

$ condor_submit script.job 

任务提交的结果显示在下图中:

当所有的任务都完成之后,在当前目录,我们会有 100 个STDOUT文件和 100 个STDERR文件,还有一个 HTCondor 生成的日志文件。

如果一切正常,所有的STDERR文件都会是空的,所有的STDOUT文件都有以下的文字:

I am process 9 of cluster 61
Running on somehost
$CWD = /tmp/book/htcondor/script
$USER = bookuser
MYVAR1 = foo
MYVAR2 = bar 

留给读者一个有趣的练习,向test.py文件插入条件化的错误。如下所示:

if pid == 13:
    raise Exception('Booo!')
else:
    sys.exit(0) 

或者:

if pid == 13:
    sys.exit(2)
else:
    sys.exit(0) 

然后,观察任务集群的变化。

如果做这个试验,会看到在第一种情况下(抛出一个异常),响应的STDERR文件不是空的。第二种情况的错误难以察觉。错误是静默的,只是出现在script.log文件,如下所示:

005 (034.013.000) 01/09 12:25:13 Job terminated.
    (1) Normal termination (return value 2)
        Usr 0 00:00:00, Sys 0 00:00:00  -  Run Remote Usage
        Usr 0 00:00:00, Sys 0 00:00:00  -  Run Local Usage
        Usr 0 00:00:00, Sys 0 00:00:00  -  Total Remote Usage
        Usr 0 00:00:00, Sys 0 00:00:00  -  Total Local Usage
    0  -  Run Bytes Sent By Job
    0  -  Run Bytes Received By Job
    0  -  Total Bytes Sent By Job
    0  -  Total Bytes Received By Job
    Partitionable Resources :    Usage  Request Allocated
       Cpus                 :                 1         1
       Disk (KB)            :        1        1  12743407
       Memory (MB)          :        0        1      2048 

注意到Normal termination (return value 2)此行,它说明发生了错误。

习惯上,我们希望发生错误时,会有这样的指示。要这样的话,我们在提交文件中使用下面的指令:

Notification = Error
Notify_User = email@example.com 

这样,如果发生错误,HTCondor 就会向email@example.com发送报错的电子邮件。通知的可能的值有Complete(即,无论退出代码,当任务完成时,发送 email),Error(即,退出代码为非零值时,发送 email),和默认值Never

另一个留给读者的练习是指出我们的任务需要哪台机器,任务偏好的机器又是哪台。这两个独立的请求是分别通过指令RequirementsRankRequirements是一个布尔表达式,Rank是一个浮点表达式。二者在每个协调循环都被评估,以找到一批机器以运行任务。

对于所有Requirements被评为True的机器,被选中的机器都有最高的Rank值。

笔记:当然,机器也可以对任务定义RequirementsRank(由系统管理员来做)。因此,一个任务只在两个RequirementsTrue的机器上运行,二者Rank值结合起来一定是最高的。

如果不定义任务文件的Rank,它就默认为0.0.Requirements。默认会请求相同架构和 OS 作为请求节点,和族都的硬盘保存可执行文件。

例如,我们可以进行一些试验,我们请求运行 64 位 Linux、大于 64GB 内存的机器,倾向于快速机器:

Requirements = (Target.Memory > 64) && (Target.Arch == "X86_64") && (Target.OpSys == "LINUX")
Rank = Target.KFlops 

笔记:对于RequirementsRank的可能的值,你可以查看附录 A 中的 Machine ClassAd Atributes。最可能用到的是Target.MemoryTarget.ArchTarget.OpSysTarget.DiskTarget.SubnetTarget.KFlops

最后,实践中另一个强大的功能是,为不同的任务定义依赖。往往,我们的应用可以分解成一系列步骤,其中一些可以并行执行,其余的不能(可能由于需要等待中间结果)。当只有独立的步骤时,我们可以将它们组织成几个任务集合,就像前面的例子。

HTCondor DAGMan(无回路有向图管理器 Directed Acyclic Graph Manager 的缩写)是一个元规划器,是一个提交任务、监督任务的工具,当任务完成时,它会检查哪个其它的任务准备好了,并提交它。

为了在 DAG 中组织任务,我们需要为每一个任务写一个提交文件。另外,我们需要另写一个文本文件,描述任务的依赖规则。

假设我们有四个任务(单进程或多进程集合)。称为 A、B、C、D,它们的提交文件是a.jobb.jobc.jobd.job。比如,我们想染 A 第一个运行,当 A 完成时,同时运行 B 和 C,当 B 和 C 都完成时,再运行 D。

下图,显示了流程:

DAG 文件(htcondor/dag/simple.dag)的代码如下所示:

# Simple Condor DAG file
# There is no requirement or standard on DAG file extensions.
# Usage: shell> condor_submit_dag simple.dag

# Define the nodes in the DAG
JOB A a.job
JOB B b.job
JOB C c.job
JOB D d.job

# Define the relationship between nodes
PARENT A CHILD B C
PARENT B C CHILD D 

四个提交文件没有那么重要。我们可以使用下面的内容(例如,任务 A 和其它三个都可以使用):

Universe = vanilla
Executable = /bin/echo
Arguments = "I am job A"
Output = a_stdout.txt
Log = a.log
Queue 

提交完整的 DAG,是使用condor_submit_dag命令:

$ condor_submit_dag simple.dag 

这条命令创建了一个特殊提交文件(simple.dag.condor.sub)到condor_dagman可执行文件,它的作用是监督运行的任务,在恰当的时间规划任务。

DAGMan 元规划器有还有许多这里没写的功能,包括类似 Makefile 的功能,可以继续运行由于错误停止的任务。

关于性能,你还需要注意几点。DAG 中的每个节点,当被提交时,都要经过一个协调循环,就像一个通常的 HTCondor 任务。这些一系列的循环会导致损耗,损耗与节点的数量成正比。通常,协调循环会与计算重叠,所以在实践中很少看到损耗。

另一点,condor_dagman的效率非常高,DAGs 有百万级别甚至更多的节点都很常见。

笔记:推荐感兴趣的读者阅读 HTCondor 一章的 DAGMan Applications。

短短一章放不下更多关于 HTCondor 的内容,它的完整手册超过 1000 页!这里介绍的覆盖了日常使用。我们会在本章的末尾介绍调试的方法。接下来,介绍另一个流行的任务规划器:PBS。

使用 PBS 运行 Python 任务

Portable Batch System (PBS)是 90 年代初,NASA 开发的。它现在有三个变体:OpenPBS,Torque 和 PBS Pro。这三个都是原先代码的分叉,从用户的角度,它们三个的外观和使用感受十分相似。

这里我们学习 PBS Pro(它是 Altair Engineering 的商用产品,http://www.pbsworks.com),它的特点和指令在 Torque 和 OpenPBS 上也可以使用,只是有一点不同。另外,为了简洁,我们主要关注 HTCondor 和 PBS 的不同。

从概念上,PBS 和 HTCondor 很像。二者有相似的架构,一个主节点(pbs_server),一个协调器和规划器(pbs_sched),执行节点的任务监督器(pbs_mom)。

用户将任务提交到队列。通常,对不同类型的任务(例如,序列 vsMPI 并行)和不同优先级的任务有多个队列。相反的,HTCondor 对每个提交主机只有一个队列。用户可用命令行工具、DRMAA 和 Python 的 drmaa 模块(pip install drmaa)与 PBS 交互。

PBS 任务文件就是一般的可以本地运行的文件(例如,Shell 或 Python 文件)。它们一般都有专门的内嵌的 PBS 指令,作为文件的注释。这些指令的 Windows 批处理脚本形式是#PBS 或 REM PBS (例如,#PBS -q serial or REM PBS –q serial)。

使用qsub命令(类似condor_submit),将任务提交到合适的任务队列。一旦成功提交一个任务,qsub会打印出任务 ID(形式是integer.server_hostname),然后退出。任务 ID 也可以作为任务的环境变量$PBS_JOBID

资源需求和任务特性,可以在qsub中指出,或在文件中用指令标明。推荐在文件中用指令标明,而不用qsub命令,因为可以增加文件的可读性,也是种记录。

例如,提交我们之前讨论过的simple.job,你可以简单的写一个最小化的 shell 文件(pbs/simple/simple.sh):

#!/bin/bash
/usr/local/bin/python3.5 -c "print('Hello, HTCondor!')" 

我们看到,没有使用 PBS 指令(它适用于没有需求的简单任务)。我们可以如下提交文件:

$ qsub simple.sh 

因为没必要为这样的一个简单任务写 Shell 文件,qsub用行内参数就可以了:

$ qsub -- /usr/local/bin/python3.5 -c "print('Hello, HTCondor!')" 

但是,不是所有的 PBS 都有这个特性。

在有多个任务队列/规划器的安装版本上,我们可以指定队列和规划器,可以用命令行(即qsub –q queue@scheduler_name)或用文件中的指令(即,#PBS –q queue@scheduler_name)。

前面的两个示例任务显示了 PBS 和 HTCondor 在提交任务时的不同。使用 HTCondor,我们需要写一个任务提交文件,来处理运行什么以及在哪里运行。使用 PBS,可以直接提交任务。

笔记:从 8.0 版本开始,HTCondor 提供了一个命令行工具,condor_qsub,像是qsub的简化版,非常适合从 PBS 向 HTCondor 转移。

提交成功后,qsub会打印出任务 ID,它的形式是integer.servername(例如8682293.pbshead)。PBS 将任务标准流重新转到scriptname.oInteger(给STDOUT)和scriptname.eInteger(给STDERR),Integer是任务 ID 的整数部分(例如,我们例子中的 simple.sh.e8682293 和 script.sh.o8682293)。

任务通常(在执行节点)运行在提交账户之下,在一个 PBS 创建的临时目录,之后会自动删除。目录的路径是环境变量$PBS_TMPDIR

通常,PBS 定义定义了许多环境变量,用于运行的任务。一些设定了提交任务的账户的环境,它们的名字通常是PBS_0开头(例如,$PBS_O_HOME$PBS_O_PATH)。其它是专门用于任务的,如$PBS_TMPDIR

笔记:现在,PBS Pro 定义了 30 个任务环境变量。可以在 PBS Professional Reference Guide 的 PBS Environment Variables 一章查到完整列表。

使用指令#PBS –J start-end[:step]提交任务数组(命令行或在文件中使用指令)。为了获得提交者的环境,可以使用-V指令,或者传递一个自定义环境到任务,使用#PBS -v "ENV1=VAL1, ENV2=VAL2, …"

例如,前面例子的任务数组,可以这样写(pbs/script/test.py):

#!/usr/bin/env python3.5
#PBS -J 0-99
#PBS -V
import argparse
import getpass
import os
import socket
import sys

ENV_VARS = ('MYVAR1', 'MYVAR2')

if 'PBS_ENVIRONMENT' in os.environ:
    # raw_cid has the form integer[].server
    raw_cid = os.environ['PBS_ARRAY_ID']
    cid = int(raw_cid.split('[')[0])
    pid = int(os.environ['PBS_ARRAY_INDEX'])
else:
    parser = argparse.ArgumentParser()
    parser.add_argument('--clusterid', type=int)
    parser.add_argument('--processid', type=int)
    args = parser.parse_args()

    cid = args.clusterid
    pid = args.processid

print('I am process {} of cluster {}'
      .format(pid, cid))
print('Running on {}'
      .format(socket.gethostname()))
print('$CWD = {}'
      .format(os.getcwd()))
print('$USER = {}'
      .format(getpass.getuser()))

undefined = False
for v in ENV_VARS:
    if v in os.environ:
        print('{} = {}'
              .format(v, os.environ[v]))
    else:
        print('Error: {} undefined'
              .format(v))
        undefined = True
if undefined:
    sys.exit(1)
sys.exit(0) 

我们完全不需要提交文件。用qsub提交,如下所示:

$ MYVAR1=foo MYVAR2=bar qsub test.py 

分配的任务 ID 的形式是integer[].server(例如8688459[].pbshead),它可以指示提交了任务数组,而不是一个简单的任务。这是 HTCondor 和 PBS 的另一不同之处:在 HTCondor 中,一个简单任务是一个任务集合(即,任务数组),只有一个进程。另一不同点是,PBS 任务访问集合 ID 和进程 ID 的唯一方式是通过环境变量,因为没有任务提交文件(提交任务时可以提交变量)。

使用 PBS,我们还需要做一些简单解析以从$PBS_ARRAY_ID提取任务数组 ID。但是,我们可以通过检测是否定义了$PBS_ENVIRONMENT,来判断代码是否运行。

使用指令-l指明资源需求。例如,下面的指令要求 20 台机器,每台机器有 32 核和 16GB 内存:

#PBS –l select=20:ncpus=32:mem=16gb 

也可以指定任务的内部依赖,但不如 HTCondor 简单:依赖的规则需要任务 ID,只有在提交任务之后才会显示出来。之前的 DAGdiamond可以用如下的方法执行(pbs/dag/dag.sh):

#!/bin/bash
A=`qsub -N A job.sh`
echo "Submitted job A as $A"

B=`qsub -N B -W depend=afterok:$A job.sh`
C=`qsub -N C -W depend=afterok:$A job.sh`
echo "Submitted jobs B & C as $B, $C"

D=`qsub -N D -W depend=afterok:$B:$C job.sh`
echo "Submitted job D as $D" 

这里,任务文件是:

#!/bin/bash
echo "I am job $PBS_JOBNAME" 

这个例子中,使用了$PBS_JOBNAME获取任务名,并使用指令-W depend=强制了任务执行顺序。

一旦提交了任务,我们可以用命令qstat监控,它等同于condor_q。销毁一个任务(或在运行之前,将队伍从队列移除),是通过qdel(等价于condor_rm)。

PBS Pro 和 HTCondor 一样,是一个复杂的系统,功能很多。这里介绍的只是它的表层,但是作为想要在 PBS HPC 机群上操作的人,作为入门足够了。

一些人觉得用 Python 和 Shell 文件提交到 PBS 而不用任务文件非常有吸引力。其他人则喜欢 HTCondor 和 DAGMan 的工具处理任务内依赖。二者都是运行在 HPC 机群的强大系统。

调试

一切正常是再好不过,但是,运气不会总是都好。分布式应用,即使是远程运行的简单任务,都很难调试。很难知道任务运行在哪个账户之下,运行的环境是什么,在哪里运行,使用任务规划器,很难预测何时运行。

当发生错误时,通过几种方法,可以知道发生了什么当使用任务规划器时,首先要做的是查看任务提交工具返回错误信息(即,condor_submitcondor_submit_dagor qsub)。然后要看任务STDOUTSTDERR和日志文件。

通常,任务规划器本身就有诊断错误任务的工具。例如,HTCondor 提供了condor_q -better-analyze,检查为什么任务会在队列中等待过长时间。

通常,任务规划器导致的问题可以分成以下几类:

  • 权限不足
  • 环境错误
  • 受限的网络通讯
  • 代码依赖问题
  • 任务需求
  • 共享 vs 本地文件系统

头三类很容易检测,只需提交一个测试任务,打印出完整的环境、用户名等等,剩下的很难检测到,尤其是在大集群上。

对于这些情况,可以关注任务是在哪台机器运行的,然后启动一个交互 session(即 qsub –Icondor_submit – interactivecondor_ssh_to_job),然后一步一步再运行代码。

如果任务需求的资源不足(例如,需要一个特定版本的 OS 或软件包,或其它特别的硬件)或资源过多,任务规划器就需要大量时间找到合适的资源。

任务规划期通常提供工具以检查哪个资源符合任务的需求(例如,condor_status –constrain)。如果任务分配给计算节点的时间不够快,就需要进行检测。

另一个产生问题的来源是提交主机的文件系统的代码、数据不能适用于全部的计算节点。这种情况下,推荐使用数据转移功能(HTCondor 提供),数据阶段的预处理文件。

Python 代码的常用方法是使用虚拟环境,在虚拟环境里先安装好所有的依赖(按照指定的安装版本)。完成之后,再传递给任务规划器。

在有些应用中,传输的数据量十分大,要用许多时间。这种情况下,最好是给数据分配进程。如果不能的话,应该像普通任务一样规划数据的移动,并使用任务依赖,保证数据准备好之后再开始计算。

总结

我们在本章学习了如何用任务规划器,在 HPC 机群上运行 Python 代码。

但是由于篇幅的限制,还有许多内容没有涉及。也许,最重要的就是 MPI(Message Passing Interface),它是 HPC 任务的进程间通讯标准库。Python 有 MPI 模块,最常使用的是 mpi4py, http://pythonhosted.org/mpi4py/,和 Python 包目录https://pypi.python.org/pypi/mpi4py/

另一个没涉及的是在 HPC 机群运行分布式任务队列。对于这种应用,可以提交一系列的任务到机群,一个任务作为消息代理,其它任务启动 worker,最后一个任务启动应用。特别需要注意连接 worker 和应用到消息代理,提交任务的时候不能确定代理是在哪一台机器。与 Pyro 类似的一个策略是使用 nameserver,解决这个问题。

然而,计算节点上有持续的进程是不推荐的,因为不符合任务规划器的原则。大多数系统都会在几个小时之后退出长时间运行的进程。对于长时间运行的应用,最好先咨询机群的管理者。

任务规划器(包括 MPI)是效率非常高的工具,在 HPC 之外也有用途。其中许多都是开源的,并且有活跃的社区,值得一看。

下一章会讲分布式应用发生错误时该怎么做。

七、测试和调试分布式应用 (Distributed Computing with Python)

无论大小的分布式应用,测试和调试的难度都非常大。因为是分布在网络中的,各台机器可能十分不同,地理位置也可能不同。

进一步的,使用的电脑可能有不同的用户账户、不同的硬盘、不同的软件包、不同的硬件、不同的性能。还可能在不同的时区。对于错误,分布式应用的开发者需要考虑所有这些。查错的人需要面对所有的这些挑战。

目前为止,本书没有花多少时间处理错误,而是关注于开发和部署应用的工具。

在本章,我们会学习开发者可能会碰到的错误。我们还会学习一些解决方案和工具。

概述

测试和调试一个单体应用并不简单,但是有许多工具可以使其变得简单,包括 pdb 调试器,各种分析工具(有 cProfile 和 line_profile),纠错器(linter),静态代码分析工具,和许多测试框架,其中许多都包括于 Python 3.3 及更高版本的标准库。

调试分布式应用的困难是,单进程应用调试的工具处理多进程时就失去了一部分功能,特别是当进程运行在不同的机器上时。

调试、分析用 C、C++、Fortran 语言写成的分布式应用可以用工具,例如 Intel VTune、Allinea MAP 和 DDT。但是 Python 开发者可用的工具极少,甚至没有。

编写小型和中型的分布式应用并不难。与单线程应用相比,写多线程应用的难点是后者有许多依赖间组件,组件通常运行在不同的硬件上,必须要协调网络。这就是为什么监控和调试分布代码如此困难。

幸运的是,还是可以在 Python 分布式应用上使用熟悉的调试工具和代码分析工具。但是,这些工具的作用有限,我们必须使用登录和打印语句,以搞清错误在哪里。

常见错误——时钟和时间

时间是一个易用的变量。例如,当将不同的数据流整合、数据库排序、重建事件的时间线,使用时间戳是非常自然的。另外,一些工具(比如GMU make)单纯的依赖文件修改的时间,很容易被不同机器的错误时间搞混。

因为这些原因,在所有的机器进行时间同步是非常重要的。如果机器位于不同的时区,不仅要同步时间,还要根据 UTC 时间进行校准。当不能将时间调整为 UTC 时间时,建议代码内部都是按照 UTC 来运行,只是在屏幕显示的时候再转化为本地时间。

通常,在分布式系统中进行时间同步是一个复杂的课题,超出了本书的范畴。大多数读者,可以使用网络时间协议(NTP),这是一个完美的同步解决方案。大多数操作系统都支持 NTP。

关于时间,另一个需要考虑的是周期动作的计时,例如轮询循环和定时任务。许多应用需要每隔一段时间就产生进程或进行动作(例如,发送 email 确认或检查新的数据是否可用)。

常用的方法是使用定时器(使用代码或使用 OS 工具),在某一时刻让所有定时器启动,通常是在某刻和一定时间段之后。这种方法的危险之处是,进程同一时刻开始工作,可能使系统过载。

一个常见的例子是启动许多进程,这些进程都需要从一个共享硬盘读取配置或数据。这种情况下,所有一切正常,知道进程的数量变得太大,以至于共享硬盘无法处理数据传输,就会导致应用变慢。

常见的解决方法是把计时器延迟,让计时器分布在一个范围之内。通常,因为我们不总是控制所有使用的代码,让计时器随机延迟几分钟是可行的。

另一个例子是图片处理服务,需要给隔一段时间就检测新的数据。当发现新的图片,就复制这些图片、重命名、缩放、并转换成常见的格式,最后存档。如果不小心,同一时间上传过多图片,就会很容易使系统过载。

更好的方法是限制应用(使用队列架构),只加载合理数量的图片,而不使系统过载。

常见错误——软件环境

另一个常见的问题是所有机器上安装的软件是一致的,升级也是一致的。

不过,往往用几小时调试一个分布式系统,最后发现因为一些未知的原因,一些电脑上的代码或软件是旧版的。有时,还会发现该有的代码反而没有。

软件存在差异的原因很多:可能是加载失败,或部署过程中的错误,或者仅仅是人为的错误。

HPC 中常用的解决方法是,在启动应用之前,将代码安装在虚拟环境里。一些项目倾向于静态的依赖链接,以免从动态库加载出现错误。

当和安装完整环境、软件依赖和应用本身相比,这种方法适用于运行时较长的应用。实际意义不大。

幸好,Python 可以创建虚拟环境。可以使用两个工具pyvenv(Python 3.5 以上的标准库支持)和virtualenv(PyPI 支持)。另外,使用pip命令,可以指定包的版本。联合使用这些工具,就可以控制执行环境。

但是,错误往往在细节,不同的节点可能有相同的虚拟环境,但是有不兼容的第三方库。

对于这些问题,可以使用容器技术,例如 Docker,或有版本控制的虚拟环境。

如果不能使用容器技术,就想到了 HPC 机群,最好的方法不是依赖系统软件,而是自己管理环境和软件栈。

常见问题——许可和环境

不同的电脑可能是在不同的用户账户下运行我们的代码,我们的应用可能想在一个特定的目录下读取文件或写入数据,然后碰到了一个许可错误。即使我们的代码使用的账户都是相同的,它们的环境可能是不同的。因此,设定的环境变量的值可能是错误的。

当我们的代码使用特殊的低级用户账号运行时,这种问题就很常见。防御性的代码,尤其是访问环境碰到未定义值时,能返回默认设置是十分必要的。

一个常见的方法是,只在特定的用户账号下运行,这个账号由自己控制,指定环境变量,和应用启动文件(它的版本也是受控的)。

但是,一些系统不仅是在极度受限的账户下运行任务,而且还是限制在沙盒内。大多数时候,连接外网也是禁止的。此时,唯一的办法就是本地设置完整环境,并复制到共享硬盘。其它的数据可以来自用户搭建的,运行小任务的服务器。

通常来说,许可错误和用户环境问题与软件环境问题类似,应该协同处理。开发者往往想让代码尽可能独立于环境,用虚拟环境装下代码和环境变量。

常见问题——硬件资源可用性

在给定的时间,我们的应用需要的硬件资源可能,也可能不可用。即使可用,也不能保证在相当长的时间内都可用。当网络出现故障时,就容易碰到这个问题,并且很常见(尤其是对于移动 app)。在实际中,很难将这种错误和机器或应用崩溃进行区分。

使用分布式框架和任务规划器的应用经常需要依靠框架处理常见的错误。当发生错误或机器不可用时,一些任务规划器还会重新提交任务。

但是,复杂的应用需要特别的策略应对硬件问题。有时,最好的方法是当资源可用时,再次运行应用。

其它时候,重启的代价很大。此时,常用的方法是从检查点重启。也就是说,应用会周期的记录状态,所以可以从检查点重启。

如果从检查点重启,你需要平衡从中途重启和记录状态造成的性能损失。另一个要考虑的是,增加了代码的复杂性,尤其是使用多个进程或线程读写状态信息。

好的方法是,可以快速重新创建的数据和结果不要写入检查点。或者,一些进程需要花费大量时间,此时使用检查点非常合适。

例如,气象模拟可能运行数周或数月。此时,每隔几个小时就写入检查点是非常重要的,因为从头开始成本太高。另外,上传图片和创建缩略图的进程,它的运行特别快,就不需要检查点。

安全起见,状态的写入和更新应该是不可分割的(例如,写入临时文件,只有在写入完全的时候才能取代原先的文件)。

与 HPC 和 AWS 竞价实例很相似,进程中的一部分会被从运行的机器驱赶出来。当这种情况发生时,通常会发送一个警告(信号SIGQUIT),几秒之后,这些进程就会被销毁(信号SIGKILL)。对于 AWS 竞价实例,可以通过实例元数据的服务确定销毁的时间。无论哪种情况,我们的应用都有时间来记录状态。

Python 有强大的功能捕获和处理信号(参考signal模块)。例如,下面的示例代码展示了一个检查点策略:

#!/usr/bin/env python3.5
"""
Simple example showing how to catch signals in Python
"""
import json
import os
import signal
import sys

# Path to the file we use to store state. Note that we assume
# $HOME to be defined, which is far from being an obvious
# assumption!
STATE_FILE = os.path.join(os.environ['HOME'],
                               '.checkpoint.json')

class Checkpointer:
    def __init__(self, state_path=STATE_FILE):
        """
        Read the state file, if present, and initialize from that.
        """
        self.state = {}
        self.state_path = state_path
        if os.path.exists(self.state_path):
            with open(self.state_path) as f:
                self.state.update(json.load(f))
        return

    def save(self):
        print('Saving state: {}'.format(self.state))
        with open(self.state_path, 'w') as f:
            json.dump(self.state, f)
        return

    def eviction_handler(self, signum, frame):
        """
        This is the function that gets called when a signal is trapped.
        """
        self.save()

        # Of course, using sys.exit is a bit brutal. We can do better.
        print('Quitting')
        sys.exit(0)
        return

if __name__ == '__main__':
    import time

    print('This is process {}'.format(os.getpid()))

    ckp = Checkpointer()
    print('Initial state: {}'.format(ckp.state))

    # Catch SIGQUIT
    signal.signal(signal.SIGQUIT, ckp.eviction_handler)
    # Get a value from the state.
    i = ckp.state.get('i', 0)
    try:
        while True:
            i += 1
            ckp.state['i'] = i
            print('Updated in-memory state: {}'.format(ckp.state))
            time.sleep(1)
    except KeyboardInterrupt:
        ckp.save() 

我们可以在一个终端运行这段代码,然后在另一个终端,我们发送一个信号SIGQUIT(例如,-s SIGQUIT <process id>)。如果这么做的话,我们可以看到检查点的动作,如下图所示:

笔记:使用分布式应用通常需要在性能不同、硬件不同、软件不同的机器上运行。

即使有任务规划器,帮助我们廁何时的软件和硬件环境,我们必须记录各台机器的环境和性能。在高级的架构中,这些性能指标可以提高任务规划的效率。

例如,PBS Pro,再次执行提交任务时就考虑了历史性能。HTCondor 持续给每台机器打分,用于选择节点和排名。

最让人没有办法的情况是网络问题或服务器过载,网络请求的时间太长,就会导致代码超时。这可能会导致我们认为服务使不可用的。这些暂时性的问题,是很难调试的。

困难——开发环境

另一个分布式系统常见的困难是搭建一个有代表性的开发和测试环境,尤其是对于个人小型团队。开发环境最好能代表最糟糕的开发环境,可以让开发者测试常见的错误,例如硬盘溢出、网络延迟、间歇性网络断开,硬件、软件失效等实际中会发生的故障。

大型团队拥有开发和测试集群的资源,他们总是有专门的软件质量团队对我们的代码进行压力测试。

不幸的是,小团队常常被迫在笔记本电脑上编写代码,并使用非常简单(最好的情况!)的由两台或三台虚拟机组成环境,它们运行在笔记本电脑上以模拟真实系统。

这种务实的方案是可行的,绝对比什么都没有要好。然而,我们应该记住,虚拟机运行在同一主机上表现出不切实际的高可用性和较低的网络延迟。此外,没有人会意外升级它们,而不通知我们或使用错误的操作系统。这个环境太易于控制和稳定,不够真实。

更接近现实的设置是创建一个小型开发集群,比如 AWS,使用相同的 VM 镜像,使用生产环境中相同的软件栈和用户帐户。

简而言之,很难找到替代品。对于基于云平台的应用,我们至少应该在部署版本的小型版本上测试我们的代码。对于 HPC 应用程序,我们应该使用测试集群、或集群的一部分,用于测试和开发。

理想情况下,我们最好在操作系统的一个克隆版本上进行开发。但是,考虑成本和简易性,我们还是会使用虚拟机:因为它够简单,基本上是免费的,不用网络连接,这一点很重要。

然而,我们应该记住分布式应用并不是很难编写的,只是它们的故障模式比单机模式多的多。其中一些故障(特别是与数据访问相关的),所以需要仔细地选择架构。

在开发阶段后期,纠正由错误假设所导致的架构选择代价高昂。说服管理者尽早给我们提供所需的硬件资源通常是困难的。最后,这是一种微妙的平衡。

有效策略——日志

通常情况下,日志就像备份或吃蔬菜,我们都知道应该这样做,但大多数人都忘记了。在分布式应用程序中,我们没有其他选择,日志是必不可少的。不仅如此,记录一切都是必要的。

由于有许多不同的进程在远程资源上运行,理解发生了什么的唯一方法是获得日志信息并使其随时可用,并且以易于检索的格式/系统存储。

在最低限度,我们应该记录进程的启动和退出时间、退出代码和异常(如果有的话),所有的输入参数,输出,完整执行环境、执行主机名和 IP,当前工作目录,用户帐户以及完整应用配置,和所有的软件版本。

如果出了问题,我们应该能够使用这些信息登录到同一台机器(如果仍然可用),转到同一目录,并复制我们的代码,重新运行。当然,完全复制执行环境可能做不到(通常是因为需要管理员权限)。

然而,我们应该始终努力模拟实际环境。这是任务规划器的优点所在,它允许我们选择指定的机器,并指定完整的任务环境,这使得复制错误更少。

记录软件版本(不仅是 Python 版本,还有使用的所有包的版本)可以诊断远程机器上过时的软件栈。Python 包管理器,pip,可以容易的获取安装的包:import pip; pip.main(['list'])import sys; print(sys.executable, sys.version_info)可以显示 Python 的位置和版本。

创建一个系统,使所有的类和函数调用发出具有相同等级的日志,而且是在对象生命周期的同一位置。常见的方法包括使用装饰器、元类。这正是 Python 模块autologging (PyPI 上有)的作用。

一旦日志就位,我们面临的问题是在哪里存储这些日志,对于大型应用,传输日志占用资源很多。简单的应用可以将日志写入硬盘的文本文件。更复杂的应用程序可能需要在数据库中存储这些信息(可以通过创建一个 Python 日志模块的自定义处理程序完成)或专门的日志聚合器,如 Sentry(https://getsentry.com)。

与日志密切相关的是监督。分布式应用程序可以有许多可移动组件,并且需要知道哪些机器处于繁忙状态,以及哪些进程或任务当前正在运行、等待,或处于错误状态。知道哪些进程比平时花费更长的时间,往往是一个重要的警告信号,表明可能有错误。

Python 有一些监督方案(经常与日志系统集成)。比如 Celery,推荐使用 flower(http://flower.readthedocs.org)作为监督和控制。另外,HPC 任务规划器,往往缺少通用的监督方案。

在潜在问题变严重之前,最好就监测出来。实际上,监视资源(如可用硬盘空间和触发器动作),甚至是简单的 email 警告,当它们低于阈值时,监督是有用的。许多部门监督硬件性能和硬盘智能数据,以发现潜在问题。

这些问题更可能是运营而不是开发者感兴趣的,但最好记住。监督也可以集成在我们的应用程序以执行适当的策略,来处理性能下降的问题。

有效策略——模拟组件

一个好的,虽然可能耗费时间和精力,测试策略是模拟系统的一些或全部组件。原因是很多:一方面,模拟软件组件使我们能够更直接地测试接口。此时,mock 测试库,如unittest.mock(Python 3.5 的标准库),是非常有用的。

另一个模拟软件组件的原因是,使组件发生错误以观察应用的响应。例如,我们可以将增加 REST API 或数据库的服务的响应时间,看看会发生什么。有时,超时会让应用误以为服务器崩溃。

特别是在设计和开发复杂分布式应用的早期阶段,人们可能对网络可用性、性能或服务响应时间(如数据库或服务器)做出过于乐观的假设。因此,使一个服务完全失效或修改它的功能,可以检测出代码中的错误。

Netflix Chaos Monkey (https://github.com/Netflix/SimianArmy)可以随机使系统中的组件失效,用于测试应用的反应。

总结

用 Python 编写或运行小型或中型分布式应用程序并不困难。我们可以利用许多高质量框架,例如,Celery、Pyro、各种任务规划期,Twisted、,MPI 绑定(本书中没有讨论),或标准库的模块multiprocessing

然而,真正的困难在于监视和调试应用,特别是因为大部分代码并行运行在许多不同的、通常是远程的计算机上。

潜藏最深的 bug 是那些最终产生错误结果的 bug(例如,由于数据在过程中被污染),而不是引发一个异常,大多数框架都能捕获并抛出。

遗憾的是,Python 的监视和调试工具不像用来开发相同代码的框架和库那么功能完备。其结果是,大型团队可以使用自己开发的、通常是非常专业的分布式调试系统,小团队主要依赖日志和打印语句。

分布式应用和动态语言(尤其是 Python)需要更多的关于调试方面的工作。

八、继续学习 (Distributed Computing with Python)


序言
第 1 章 并行和分布式计算介绍
第 2 章 异步编程
第 3 章 Python 的并行计算
第 4 章 Celery 分布式应用
第 5 章 云平台部署 Python
第 6 章 超级计算机群使用 Python
第 7 章 测试和调试分布式应用
第 8 章 继续学习


这本书是一个简短但有趣的用 Python 编写并行和分布式应用的旅程。这本书真正要做的是让读者相信使用 Python 编写一个小型或中型分布式应用不仅是大多数开发者都能做的,而且也是非常简单的。

即使是一个简单的分布式应用也有许多组件,远多于单体应用。也有更多的错误方式,不同的机器上同一时间发生的事情也更多。

但是,幸好可以使用高质量的 Python 库和框架,来搭建分布式系统,使用起来也比多数人想象的简单。

另外,并行和分布式计算正逐渐变为主流,随着多核 CPU 的发展,如果还继续遵守摩尔定律,编写并行代码是必须的。

Celery、Python-RQ、Pyro 等工具,只需要极少的精力,就可以获得性能极大地提高。

但是,必须要知道,分布式应用缺少强大的调试器和分析器,这个问题不局限于 Python。监督和日志可以检测性能的瓶颈,进而查找到错误。现在这种缺少调试工具的状况,需要改善。

本章剩下的部分回顾了前面的所学,还给感兴趣的读者提了继续学习哪些工具和课题的建议。

前两章

本书的最初章节讲解了一些并行和分布式计算的基本理论。引入了一些重要的概念,如共享内存和分布式内存架构以及它们之间的差异。

这两章还用阿姆达尔定律研究了并行加速的基本算法。讨论的收获是,投入并行计算的收益是递减的。另外,绕过阿姆达尔定律的方法之一是增加的问题的规模,使并行代码所占的份额更大(古斯塔夫森定律)。

另一个收获是,尽量保持进程间通讯越小越好。最好让各个进程都是独立的。进程之间的通讯越少,代码越简单,损耗越少。

大多数实际场景都需要一系列扇出和扇入同步/还原步骤,大多数框架都能合理有效地处理这些步骤。然而,并行步骤中的数据依赖或大量消息传递通常会成为严重的问题。

提到的另一种架构是数据列车或数据并行。这是一种处理方式,其中一个启动大量的 worker 进程,超过可用硬件资源的数量。正如所看到的,数据并行的主要优点是很好的伸缩性和更简单的代码。此外,大多数操作系统和任务规划器在交错 I/O 和计算方面会做得很好,从而掩盖系统延迟。

我们还研究了两种完全不同的编程范式:同步和异步编程。我们看到 Python 对 futures、回调、协程的支持很好,这是异步编程的核心。

正如我们所讨论的,异步代码具有避免,或者减少了竞争条件,因为只有一段代码可以在给定的时间点运行。这意味着,数据访问模式被大大简化了,但代码和调试变复杂了;当使用回调和协程,很难跟踪执行路径。

在本书中,我们看到了使用线程、多进程、协程的并行代码的性能。对于 I/O 操作,我们看到这三个并发策略可以实现显着的加速。然而,由于 Python 全局锁,CPU 操作并没有获得加速,除非使用多个进程。

同步和异步编程都有其优点。使用的越多,越会发现线程和系统编程的 C 和 C++很像。协程的优点之一就是避免竞争条件。多个进程,虽然在一台机器上相当笨重,但为更一般的分布式计算架构铺平了道路。使用哪种风格取决于个人喜好和必须使用的特定库。

工具

在第 3 章中,我们学习了 Python 的标准库模块,来编写并行应用。我们使用了threadingmultiprocessing模块,还使用了更为高级的concurrent.futures模块。

我们看到 Python 为分布式并行应用构建了一个坚固的基础。前面的是哪个模块都是 Python 安装包自带的,没有外部依赖,因此很受欢迎。

我们在第 4 章学习了一些第三方 Python 模块,包括 Celery、Python-RQ 和 Pyro。我们学习了怎么使用它们,并看到它们都很容易使用。

它们都需要一些内部组件,比如消息代理、数据库或 nameserver,它们可能不适用于所有情况。同时,它们都可以让开发者轻易地开发小型和中型的分布应用。它们都有活跃的社区给予支持。

关于代码的性能,最重要的是分析哪些代码是值得优化的。如果使用更快的解释器,比如 pypy,不能使性能提高,就要考虑更优化的库,比如对数值代码使用 Numpy,或使用 C 或 Cython,它们都可以使性能提高。

如果这些方法不成,还可以考虑并发、并行和分布式结算,但会提高复杂性。

一个简单的办法是使用数据并行(例如,对不同的数据启用多个代码实例)。可以使用任务规划器,比如 HTCondor。

稍微复杂一点的办法是使用concurrent.futures或 Celery,使代码并行化。高级用户,特别是 HP 用户,还可以考虑使用 MPI 作为进程间通讯框架。

但是,并非所有的分布式应用都要用到 Celery、Python-RQ 和 Pyro。特别是当应用需要复杂、高性能、分布式图片处理,使用 Celery 就不好。

此时,开发者可以使用工作流管理系统,例如Luigi (https://github.com/spotify/luigi),或流处理,比如 Apache Spark 或 Storm。对于专门的 Python 工具,可以参考https://spark.apache.org/docs/0.9.1/python-programming-guide.html andhttps://github.com/Parsely/streamparse

云平台和 HPC

第 5 章简要介绍了云计算和 AWS。这是现在的热点,原因很简单:只要很少的投入,几乎不需要等待,就可以租用一些虚拟机,还可以租数据库和数据存储。如果需要更多的性能,可以方便地进行扩展。

Things, unfortunately, are never as simple as vendor brochures like to depict, especially when outsourcing a critical piece of infrastructure to a third party whose interests might not be perfectly aligned with ours.

不过,事情不像销售商手册描述的那样简单,特别是当把一个重要的工作外包给一个可能与我们的利益不完全一致的第三方的时候。

我的建议是总是设想最坏的情况,并在本地自动备份整个应用及其软件栈(至少在单独的个体上)。理想情况下(但实际上并不是这样),人们会在一个完全独立的云平台上运行一个缩减的、但最新的完整应用的拷贝,作为发生错误的保险。

使用第三方服务时,进行本地备份是非常重要的。用户虚拟机和数据被删除,不可找回,这种错误绝不要发生。还要考虑过度依赖某个服务商,当应用过大时,迁移到另一个服务商几乎是不可能的。

只使用最小公分母(例如,只使用 EC2 和 AWS)既有吸引力,也可能让人沮丧,只能使用 AWS 提供的功能。

总之,云平台是一把双刃剑。对于小团队和小应用来说,它无疑是方便和低成本的。对于大型应用程序或处理大量数据的应用来说,它可能是相当昂贵的,因为带宽往往非常贵。

此外,学术界、政府部门或政府机构的团队可能很难获得支付云平台所需的资金。事实上,在这些部门,通常更容易获得资金购买设施自建而不是服务。

另一个关于严重限制了云计算在许多情况下的适用性问题,就是数据隐私和数据托管问题。例如,大公司往往不愿意在别人的机器上存放他们私有的,通常是机密的数据。

医疗数据,这类与客户或患者唯一相关的数据,对它应该存储在哪里以及如何使用有它自己的一套法律限制。最近美国有关国家监管部门要求欧洲公司使用云平台时,加大对其数据的隐私权和法律管辖权管理。

HPC 使用的工具,在这几十年来还是只限于自身的范围,没怎么用到其他领域。

虽然有若干原因导致了这个问题,还是要学习下任务规划器,如 HTCondor,和如何使用它。HTCondor 可以在许多不同的环境中使用。它是一个强大的分布式计算中间件,适用于小型和大型应用。

现在的任务规划器提供了大量的功能,它们在容错、工作流管理和数据移动规划等领域尤其强大。它们都支持运行任何可执行文件,这意味着它们可以轻易的规划和运行 Python 代码。

让人感兴趣的可能是用云平台虚拟机动态扩展 HPC 系统。有些任务规划器自身支持使用适配器,如 Eucalyptus。

高级 HPC 用户可能希望将其应用指定运行在机群的某些机器上。事实上,事实上,HPC 系统中的网络结构是按层次结构组织的:高速网络连接同一级上的节点。下一个性能层连接同一个机柜中或一组机柜。InfiniBand 等级连接剩下的机柜租,最后,较慢的以太网连接机群,彼此连接和连接外部。

结果是,应用程序需要大量的进程间通信和/或数据迁移,使用较少数量的位于同一级的处理器,而不是多个等级的处理器,就可以使性能大幅提高。类似的方法也适用于所使用的网络文件系统,以及是否为元文件的大量操作付出性能的代价。

当然这些优化,缺点是它们是不可移植的,这是由于 HPC 系统的声明周期只有几年,因此需要尽量使用最高性能的代码(这是 HPC 集群存在的理由)。

调试和监控

第 7 章中介绍少的日志、监控、分析和吊事分布式系统,即使放在现在,也是困难的工作,尤其是使用的语言不是 C、C++或 Fortran。这里没有什么要说的了,除了有一个重要的空白要填补。

多数的中大型团队使用日志聚合器如 Sentry (https://getsentry.com),和监控方案如 Ganglia (http://ganglia.sourceforge.net)。

对于 Python 应用,可以使用 IO 监控工具,如 Darshan (http://www.mcs.anl.gov/research/projects/darshan/),和分布式分析工具 MAP (http://www.allinea.com/products/map)。

继续学习

正如我们所看到的,用 Python 中构建小型、中型分布式应用并不是特别困难。一旦分布式系统发展到更大的规模,所需的设计和开发工作量也将以超线性方式增长。

在这种情况下,就需要更牢固的分布式系统理论。在线和离线都有许多可用的资源。许多大学都开设有关这个课程,其中一些是在线免费的。

一个例子就是 ETH 的《分布式计算原理》(http://dcg.ethz.ch/lectures/podc_allstars/index.html),它包含了一些基本原理,包括同步、一致性和最终一致性(包括著名的 CAP 定理)。

最后要说,初学者应该感到鼓舞。用几行代码的一个简单框架,如 Python-RQ,就可以让代码性能大幅提升!


序言
第 1 章 并行和分布式计算介绍
第 2 章 异步编程
第 3 章 Python 的并行计算
第 4 章 Celery 分布式应用
第 5 章 云平台部署 Python
第 6 章 超级计算机群使用 Python
第 7 章 测试和调试分布式应用
第 8 章 继续学习


posted @ 2025-09-19 10:36  绝不原创的飞龙  阅读(91)  评论(0)    收藏  举报