Python-并行编程秘籍第二版-全-

Python 并行编程秘籍第二版(全)

原文:zh.annas-archive.org/md5/7b21344f8b9a93e8b4b323b6ffeec37b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

计算机行业的特点是寻求不断提高和高效性能,从网络、电信、航空电子领域的高端应用,到桌面计算机、笔记本电脑和视频游戏中的低功耗嵌入式系统。这种发展路径导致了多核系统,其中双核、四核和八核处理器只是未来向不断增加的计算核心数量扩展的起点。

然而,这种扩展不仅对半导体行业,也对能够进行并行计算的应用程序的开发构成了挑战。

并行计算实际上代表了同时使用多个计算资源来解决处理问题,这样它就可以在多个 CPU 上执行,将问题分解成可以同时处理的离散部分,其中每个部分进一步分解成可以在不同的 CPU 上串行执行的指令序列。

计算资源可以包括具有多个处理器的单个计算机、通过网络连接的任意数量的计算机,或者这两种方法的组合。并行计算一直被认为是计算技术的极致或未来,直到几年前,它还受到复杂系统和各种领域相关情况的数值模拟的推动:天气和气候预测、化学反应和核反应、人类基因组图谱、地震和地质活动、机械装置的行为(从假肢到航天飞机)、电子电路和制造过程。

现在,然而,越来越多的商业应用正日益要求开发速度更快的计算机来以复杂的方式处理大量数据。这类应用包括数据挖掘、并行数据库、石油勘探、网络搜索引擎、网络化商业服务、计算机辅助医疗诊断、跨国公司的管理、高级图形和虚拟现实(尤其是在视频游戏行业)、多媒体和视频网络技术,以及协作工作环境。

最后但同样重要的是,并行计算代表了尝试最大化那种无限但同时也越来越宝贵和稀缺的资源——时间。这就是为什么并行计算正从只为少数人保留的昂贵超级计算机的世界转向基于多个处理器、图形处理单元GPUs)或少量互联计算机的经济解决方案,这些计算机可以克服串行计算的约束和单个 CPU 的限制。

为了介绍并行编程的概念,选择了最受欢迎的编程语言之一——Python。Python 的流行部分归因于其灵活性,因为它是一种被网页和桌面开发者、系统管理员和代码开发者、以及最近的数据科学家和机器学习工程师经常使用的语言。

从技术角度来看,在 Python 中,没有像 C 语言那样的独立编译阶段(例如,从源代码生成可执行文件)。由于其伪解释特性,Python 成为了一种可移植的语言。一旦编写了源代码,它就可以在大多数当前使用的平台上进行解释和执行,无论是来自苹果(macOS X)还是 PC(Microsoft Windows 和 GNU/Linux)。

Python 的另一个优点是其易于学习。任何人都可以在几天内学会使用它并编写他们的第一个应用程序。在这种情况下,语言的开放结构起着基本的作用,没有冗余的声明,因此与口语非常相似。最后,Python 是免费软件:不仅 Python 解释器和 Python 在我们应用程序中的使用是免费的,而且 Python 也可以根据完全开源许可证的规则自由修改和重新分发。

《Python 并行编程食谱,第二版》包含大量示例,为读者提供了解决实际问题的机会。它考察了并行架构的软件设计原则,强调程序清晰性的重要性,并避免使用复杂术语,而是使用清晰直接示例。

每个主题都作为完整的、可工作的 Python 程序的一部分进行介绍,始终跟随相关程序的输出。各个章节的模块化组织提供了一个经过验证的路径,沿着这个路径可以从最简单的论点过渡到最先进的论点,但它也适合只想学习几个特定问题的人。

本书面向对象

《Python 并行编程食谱,第二版》旨在为希望使用并行编程技术编写强大和高效代码的软件开发人员编写。阅读本书将使您掌握并行计算的基本和高级方面。

Python 编程语言易于使用,并允许非专业人士轻松应对并理解本书概述的主题。

本书涵盖内容

第一章,Python 并行计算入门,概述了并行编程架构和编程模型。本章介绍了 Python 编程语言,讨论了该语言的特点、易学易用性、可扩展性以及丰富的软件库和应用程序,所有这些都使 Python 成为任何应用程序,尤其是并行计算的有价值工具。

第二章,基于线程的并行性,讨论了使用threading Python 模块的线程并行性。读者将通过完整的编程示例学习如何同步和操作线程以实现他们的多线程应用程序。

第三章,基于过程的并行性,引导读者了解如何通过基于过程的途径并行化一个程序。一系列完整的示例将展示读者如何使用multiprocessing Python 模块。

第四章,消息传递,专注于消息传递交换通信系统。特别是,将用大量的应用示例来描述mpi4py库。

第五章,异步编程,解释了用于并发编程的异步模型。在某些方面,它比线程模型更简单,因为有一个单一的指令流,任务明确地放弃控制权而不是任意挂起。本章展示了读者如何使用asyncyio模块将每个任务组织成一系列必须以异步方式执行的小步骤。

第六章,分布式 Python,向读者介绍了分布式计算,这是将多个计算单元聚合起来以透明和一致的方式协同运行单个计算任务的过程。特别是,本章提供的示例应用程序描述了使用socket和 Celery 模块来管理分布式任务。

第七章,云计算,提供了与 Python 编程语言相关的主要云计算技术的概述。PythonAnywhere平台对于在云上部署 Python 应用程序非常有用,本章将对其进行考察。本章还包含示例应用程序,展示了容器无服务器技术的使用。

第八章,异构计算,探讨了现代 GPU 在增加编程复杂性的代价下为数值计算提供突破性性能。实际上,GPU 的编程模型要求程序员手动管理 CPU 和 GPU 之间的数据传输。本章将通过编程示例和用例,教读者如何利用PyCUDANumbaPyOpenCL强大的 Python 模块来发挥 GPU 卡的计算能力。

第九章,Python 调试和测试,是介绍软件工程中两个重要主题的最后一章:调试和测试。特别是,以下 Python 框架将被描述:winpdb-reborn用于调试,以及unittestnose用于软件测试。

要充分利用这本书

本书是自包含的:在开始阅读之前,唯一的基本要求是对编程的热情和对本书涵盖主题的好奇心。

下载示例代码文件

您可以从 www.packt.com 的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com 登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载完成后,请确保您使用最新版本的以下软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

书籍的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Python-Parallel-Programming-Cookbook-Second-Edition。我们还有其他来自我们丰富的图书和视频目录的代码包可供使用,网址为github.com/PacktPublishing/。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781789533736_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“可以通过使用terminate方法立即终止进程。”

代码块设置如下:

import socket
port=60000
s =socket.socket()
host=socket.gethostname()

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

 p = multiprocessing.Process(target=foo)
 print ('Process before execution:', p, p.is_alive())
 p.start()

任何命令行输入或输出都应如下所示:

> python server.py

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“转到系统属性 | 环境变量 | 用户或系统变量 | 新建。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

部分

在这本书中,您将找到几个频繁出现的标题(准备工作如何做…它是如何工作的…更多内容…参考以下内容)。

要清楚地说明如何完成食谱,请按以下方式使用这些部分:

准备工作

本节告诉您在食谱中可以期待什么,并描述如何设置任何软件或任何为食谱所需的初步设置。

如何做…

本节包含遵循食谱所需的步骤。

它是如何工作的…

本节通常包含对上一节发生情况的详细解释。

更多内容…

本节包含有关食谱的附加信息,以便您对食谱有更深入的了解。

参考以下内容

本节提供了对食谱有用的其他信息的链接。

联系我们

我们欢迎读者的反馈。

一般反馈: 发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者: 如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问packtpub.com.

第一章:开始使用并行计算和 Python

并行分布式计算模型基于同时使用不同的处理单元来执行程序。尽管并行计算和分布式计算之间的区别非常微妙,其中一种可能的定义将并行计算模型与共享内存计算模型相关联,将分布式计算模型与消息传递模型相关联。

从现在开始,我们将使用术语并行计算来指代并行和分布式计算模型。

以下几节提供了并行编程架构和编程模型的概述。这些概念对于第一次接触并行编程技术的经验不足的程序员来说很有用。此外,它也可以作为经验丰富的程序员的基本参考。并行系统的双重特征也被介绍。第一种特征基于系统架构,而第二种特征基于并行编程范式。

本章以对 Python 编程语言的简要介绍结束。该语言的特点、易用性和学习性,以及软件库和应用的扩展性和丰富性使 Python 成为任何应用的宝贵工具,也是并行计算的宝贵工具。线程和进程的概念在它们在语言中的应用中被介绍。

在本章中,我们将涵盖以下食谱:

  • 我们为什么需要并行计算?

  • 飞利浦分类法

  • 内存组织

  • 并行编程模型

  • 评估性能

  • 介绍 Python

  • Python 和并行编程

  • 介绍进程和线程

我们为什么需要并行计算?

现代计算机提供的计算能力的增长导致我们在相对较短的时间内面临越来越复杂的计算问题。直到 2000 年代初,复杂性是通过增加晶体管的数量以及单处理器系统的时钟频率来处理的,这些系统的峰值达到了 3.5-4 GHz。然而,晶体管数量的增加导致了处理器本身功耗的指数级增长。本质上,因此存在一个物理限制,阻止了单处理器系统性能的进一步改进。

因此,在近年来,微处理器制造商将注意力集中在多核系统上。这些系统基于几个物理处理器共享相同内存的核心,从而绕过了之前描述的功耗问题。近年来,四核八核系统也已成为普通桌面和笔记本电脑配置的标准。

另一方面,这种重大的硬件变化也导致了软件结构的演变,软件结构一直是设计为在单个处理器上顺序执行的。为了利用增加处理器数量所提供的更多计算资源,现有的软件必须重新设计成适合 CPU 并行结构的适当形式,以便通过同时执行同一程序的多个部分的单个单元来获得更高的效率。

飞利浦的分类法

飞利浦的分类法是一个用于分类计算机架构的系统。它基于两个主要概念:

  • 指令流:一个拥有 n 个 CPU 的系统有 n 个程序计数器,因此有 n 个指令流。这对应于一个程序计数器。

  • 数据流:一个计算数据列表上函数的程序有一个数据流。计算多个不同数据列表上相同函数的程序有更多的数据流。这由一组操作数组成。

由于指令和数据流是独立的,因此有四种并行机类别:单指令单数据SISD)、单指令多数据SIMD)、多指令单数据MISD)和多指令多数据MIMD):

飞利浦的分类法

单指令单数据(SISD)

SISD 计算系统类似于冯·诺伊曼机器,这是一种单处理器机器。正如你在 Flynn's taxonomy 图中可以看到的,它执行一个操作单个数据流的指令。在 SISD 中,机器指令是顺序处理的。

在一个时钟周期内,CPU 执行以下操作:

  • 取指令:CPU 从一个内存区域取数据和指令,这个区域称为寄存器

  • 解码:CPU 解码指令。

  • 执行:指令在数据上执行。操作的结果存储在另一个寄存器中。

一旦执行阶段完成,CPU 将自己设置为开始另一个 CPU 周期:

取指令、解码和执行周期

在这类计算机上运行的算法是顺序的(或串行的),因为它们不包含任何并行性。SISD 计算机的一个例子是具有单个 CPU 的硬件系统。

这些架构的主要元素(即冯·诺伊曼架构)如下:

  • 中央存储单元:用于存储指令和程序数据。

  • CPU:这是用来从内存单元获取指令和/或数据,它解码指令并依次执行它们。

  • I/O 系统:这指的是程序的输入和输出数据。

传统的单处理器计算机被归类为 SISD 系统:

SISD 架构方案

下图具体显示了 CPU 在取指、解码和执行阶段所使用的区域:

图片

取指-解码-执行阶段的 CPU 组件

多指令单数据(MISD)

在这个模型中,n个处理器,每个都有自己的控制单元,共享一个单一的内存单元。在每个时钟周期,从内存接收到的数据由所有处理器同时处理,每个处理器根据其控制单元接收到的指令进行处理。

在这种情况下,通过在相同的数据上执行多个操作来获得并行性(指令级并行性)。在这些架构中可以有效地解决的问题类型相当特殊,例如数据加密。因此,MISD 计算机在商业领域没有找到空间。MISD 计算机更多的是一种智力练习,而不是实际配置。

单指令多数据(SIMD)

一个 SIMD 计算机由n个相同的处理器组成,每个处理器都有自己的局部内存,可以存储数据。所有处理器在单个指令流的控制下工作。此外,还有n个数据流,每个处理器一个。处理器在每一步同时工作,执行相同的指令,但针对不同的数据元素。这是一个数据级并行的例子。

SIMD 架构比 MISD 架构更灵活。SIMD 计算机上的并行算法可以解决广泛应用的众多问题。另一个有趣的特点是,这些计算机的算法相对容易设计、分析和实现。局限性在于,只有那些可以分解为多个子问题(这些子问题都是相同的,每个子问题将通过同一组指令同时解决)的问题才能用 SIMD 计算机解决。

根据这种范例开发的超级计算机,我们必须提到连接机(思考机器,1985)和MPP(NASA,1983)。

正如我们将在第六章“分布式 Python”和第七章“云计算”中看到的,现代图形卡(GPU)的出现,这些图形卡由许多 SIMD 嵌入式单元构建,导致了这种计算范例的更广泛使用。

多指令多数据(MIMD)

根据弗林分类,这类并行计算机是最通用和最强大的,它包含n个处理器、n个指令流和n个数据流。每个处理器都有自己的控制单元和局部内存,这使得 MIMD 架构比 SIMD 架构在计算上更强大。

每个处理器在其控制单元发出的指令流的控制下运行。因此,处理器可以潜在地运行不同的程序,使用不同的数据,这使得它们可以解决不同且可以是单个更大问题一部分的子问题。在 MIMD 中,通过线程和/或进程的并行级别来实现架构。这也意味着处理器通常以异步方式运行。

现在,这种架构已应用于许多个人电脑、超级计算机和计算机网络。然而,你需要考虑的一个反例是:异步算法难以设计、分析和实现:

SIMD 架构(A)和 MIMD 架构(B)

通过考虑 SIMD 机器可以分为两个子组,Flynn 的分类法可以扩展:

  • 数值超级计算机

  • 向量机

另一方面,MIMD 可以分为具有共享内存的机器和具有分布式内存的机器。

事实上,下一节将重点介绍 MIMD 机器内存组织的最后一个方面。

内存组织

为了评估并行架构,我们需要考虑的另一个方面是内存组织,或者说数据访问的方式。无论处理单元有多快,如果内存不能以足够的速度维护和提供指令和数据,那么性能就不会有所提高。

我们需要克服的主要问题,使内存的响应时间与处理器的速度相匹配,是内存周期时间,它定义为两次连续操作之间经过的时间。处理器的周期时间通常比内存的周期时间短得多。

当处理器启动对内存的传输时,处理器的资源将在整个内存周期内保持占用;此外,在此期间,由于正在进行的传输,没有任何其他设备(例如,I/O 控制器、处理器或甚至请求该资源的处理器)能够使用内存:

MIMD 架构中的内存组织

解决内存访问问题的解决方案导致了 MIMD 架构的二分法。第一种系统,称为共享内存系统,具有高虚拟内存,所有处理器都可以平等地访问该内存中的数据和指令。另一种系统是分布式内存模型,其中每个处理器都有本地内存,其他处理器无法访问。

分布式内存共享内存的特点是内存访问的管理,这由处理单元执行;这种区别对于程序员来说非常重要,因为它决定了并行程序的不同部分必须如何通信。

特别是,分布式内存机器必须在每个本地内存中复制共享数据。这些副本是通过从一个处理器向另一个处理器发送包含要共享的数据的消息来创建的。这种内存组织的缺点是,有时这些消息可以非常大,并且需要相对较长的时间来传输,而在共享内存系统中,没有消息交换,主要问题在于同步访问共享资源。

共享内存

下图显示了共享内存多处理器系统的架构。这里的物理连接相当简单:

图片

共享内存架构图

在这里,总线结构允许任意数量的设备(如图中所示的前一个图中CPU + Cache)共享相同的通道(主内存,如图中所示的前一个图)。总线协议最初是为了允许单个处理器和一台或多台磁盘或磁带控制器通过这里的共享内存进行通信而设计的。

每个处理器都关联了缓存内存,因为假设处理器需要将数据或指令保留在本地内存中的概率非常高。

当处理器修改其他处理器同时使用的内存系统中存储的数据时,问题就出现了。新的值将从已更改的处理器缓存传递到共享内存。然而,稍后它还必须传递给所有其他处理器,以便它们不与过时的值一起工作。这个问题被称为缓存一致性问题——这是内存一致性问题的特例,它需要能够处理并发问题和同步的硬件实现,类似于线程编程。

共享内存系统的主要特点如下:

  • 所有处理器的内存都是相同的。例如,所有与相同数据结构相关的处理器都将使用相同的逻辑内存地址,从而访问相同的内存位置。

  • 通过读取各种处理器的任务并允许共享内存来获得同步。实际上,处理器一次只能访问一个内存。

  • 在另一个任务访问它时,共享内存位置不得从任务中更改。

  • 在任务之间共享数据非常快。所需通信的时间是其中一个任务读取单个位置所需的时间(取决于内存访问的速度)。

共享内存系统中的内存访问如下:

  • 统一内存访问UMA):该系统的基本特征是每个处理器和任何内存区域的访问时间都是恒定的。因此,这些系统也被称为对称多处理器SMPs)。它们相对容易实现,但可扩展性不强。程序员负责通过在管理资源的程序中插入适当的控制、信号量、锁等来管理同步。

  • 非统一内存访问NUMA):这些架构将内存划分为分配给每个处理器的高速访问区域,以及用于数据交换的公共区域,访问速度较慢。这些系统也被称为分布式共享内存DSM)系统。它们可扩展性很强,但开发复杂。

  • 无远程内存访问NoRMA):内存物理上分布在处理器之间(本地内存)。所有本地内存都是私有的,只能访问本地处理器。处理器之间的通信是通过用于交换消息的通信协议进行的,这被称为消息传递协议

  • 仅缓存内存架构COMA):这些系统只配备了缓存内存。在分析 NUMA 架构时,注意到这种架构将数据的本地副本存储在缓存中,并且这些数据在主内存中以副本的形式存储。这种架构消除了副本,只保留缓存内存;内存物理上分布在处理器之间(本地内存)。所有本地内存都是私有的,只能访问本地处理器。处理器之间的通信也是通过消息传递协议进行的。

分布式内存

在具有分布式内存的系统中,内存与每个处理器相关联,处理器只能访问其自己的内存。一些作者将这种类型的系统称为多计算机,反映了系统元素本身是小型且完整的处理器和内存系统的事实,如下面的图所示:

分布式内存架构方案

这种组织方式有几个优点:

  • 在通信总线或交换机层面没有冲突。每个处理器都可以使用其本地内存的全部带宽,而不会受到其他处理器的干扰。

  • 没有公共总线意味着处理器数量的内在限制。系统的规模仅受连接处理器的网络大小限制。

  • 没有缓存一致性方面的问题。每个处理器负责其自己的数据,不必担心升级任何副本。

主要缺点是处理器之间的通信更难实现。如果一个处理器需要另一个处理器的内存中的数据,那么这两个处理器不一定要通过消息传递协议交换消息。这引入了两个减速源:从一个处理器向另一个处理器构建和发送消息需要时间,而且,任何处理器都应该停止以管理从其他处理器接收到的消息。设计用于在分布式内存机器上工作的程序必须组织成一组独立任务,这些任务通过消息进行通信:

基本消息传递

分布式内存系统的主要特点如下:

  • 内存在处理器之间物理分布;每个本地内存只能由其处理器直接访问。

  • 通过在处理器之间移动数据(即使只是消息本身)来实现同步。

  • 数据在本地内存中的细分会影响机器的性能——确保细分准确是至关重要的,以便最小化 CPU 之间的通信。此外,协调这些分解和组合操作的处理器必须有效地与操作数据结构各个部分的处理器进行通信。

  • 使用消息传递协议,以便 CPU 可以通过交换数据包相互通信。消息是离散的信息单元,从它们有明确的身份这一意义上说,它们总是可以相互区分。

大规模并行处理(MPP)

MPP 机器由数百个处理器(在某些机器中,处理器数量可以高达数十万个)组成,这些处理器通过通信网络连接。世界上最快的计算机基于这些架构;这些架构系统的例子包括地球模拟器、蓝基因、ASCI White、ASCI Red、ASCI Purple 和 Red Storm。

工作站集群

这些处理系统基于通过通信网络连接的经典计算机。计算集群属于这一分类。

在集群架构中,我们将节点定义为参与集群的单个计算单元。对于用户来说,集群是完全透明的——所有硬件和软件的复杂性都被掩盖,数据和应用程序都可以像来自单个节点一样访问。

在这里,我们已经确定了三种类型的集群:

  • 故障转移集群:在这种情况下,节点的活动会持续监控,当某个节点停止工作时,另一台机器将接管这些活动的责任。目的是通过架构的冗余确保连续的服务。

  • 负载均衡集群:在这个系统中,任务请求被发送到活动较少的节点。这确保了处理任务所需的时间更少。

  • 高性能计算集群:在这个集群中,每个节点都配置为提供极高的性能。过程也被划分为多个节点上的多个任务。任务被并行化,并将被分配到不同的机器上。

异构架构

在同构的超级计算世界中引入 GPU 加速器已经改变了超级计算机的使用和编程的本质。尽管 GPU 提供了高性能,但它们不能被视为一个自主的处理单元,因为它们应该始终伴随着 CPU 的组合。因此,编程范式非常简单:CPU 接管控制并以串行方式计算,将计算成本高且具有高度并行性的任务分配给图形加速器。

CPU 和 GPU 之间的通信不仅可以通过使用高速总线进行,还可以通过共享物理或虚拟内存的单个区域进行。实际上,在两种设备都没有配备自己的内存区域的情况下,可以使用由各种编程模型(如CUDAOpenCL)提供的软件库来引用一个公共内存区域。

这些架构被称为异构架构,其中应用程序可以在单个地址空间中创建数据结构,并将任务发送到设备硬件,这对于任务的解决是合适的。由于原子操作,几个处理任务可以在同一区域安全地运行,以避免数据一致性问题的发生。

因此,尽管 CPU 和 GPU 看起来似乎没有高效地协同工作,但使用这种新的架构,我们可以优化它们与并行应用程序的交互以及性能:

图片

异构架构方案

在下一节中,我们将介绍主要的并行编程模型。

并行编程模型

并行编程模型作为硬件和内存架构的抽象而存在。实际上,这些模型并不特定,也不指向任何特定的机器或内存架构。它们可以在任何类型的机器上实现(至少在理论上)。与之前的细分相比,这些编程模型在更高的层面上进行,代表了软件必须以何种方式实现以执行并行计算。每个模型都有其与其它处理器共享信息的方式,以便访问内存并分配工作。

从绝对意义上讲,没有一种模型比另一种更好。因此,最佳解决方案将非常依赖于程序员应该解决和解决的问题。最广泛使用的并行编程模型如下:

  • 共享内存模型

  • 多线程模型

  • 分布式内存/消息传递模型

  • 数据并行模型

在这个菜谱中,我们将为您概述这些模型。

共享内存模型

在这个模型中,任务共享一个单一的内存区域,我们可以异步地读取和写入。有机制允许编码者控制对共享内存的访问;例如,锁或信号量。这种模型的优势在于编码者不必明确任务间的通信。从性能的角度来看,一个重要的缺点是它变得难以理解和管理数据局部性。这指的是将数据保留在处理器的本地,以节省内存访问、缓存刷新和当多个处理器使用相同数据时发生的总线流量。

多线程模型

在这个模型中,一个进程可以有多个执行流。例如,创建一个顺序部分,随后创建一系列可以并行执行的任务。通常,这种类型的模型用于共享内存架构。因此,对于我们来说,管理线程之间的同步非常重要,因为它们在共享内存上操作,程序员必须防止多个线程同时更新同一位置。

当代 CPU 在软件和硬件层面都支持多线程。POSIX(即可移植操作系统接口)线程是软件层面实现多线程的典型例子。英特尔 Hyper-Threading 技术通过在某个线程停滞或等待 I/O 时切换到另一个线程,在硬件层面实现多线程。即使数据对齐是非线性的,从这个模型中也可以实现并行性。

消息传递模型

消息传递模型通常应用于每个处理器都有自己的内存(分布式内存系统)的情况。更多的任务可以驻留在同一台物理机器上或任意数量的机器上。编码者负责确定通过消息发生的并行性和数据交换,并且需要在代码中请求和调用函数库。

一些例子自 1980 年代以来就存在,但直到 1990 年代中期才创建了一个标准化的模型,从而产生了被称为消息传递接口MPI)的事实上的标准。

MPI 模型显然是为分布式内存设计的,但作为并行编程的模型,多平台模型也可以与共享内存机器一起使用:

图片

消息传递范式模型

数据并行模型

在此模型中,我们拥有更多在相同数据结构上操作的任务,但每个任务操作的是数据的不同部分。在共享内存架构中,所有任务都通过共享内存和分布式内存架构访问数据,其中数据结构被分割并驻留在每个任务的本地内存中。

为了实现此模型,编码者必须开发一个程序,该程序指定了数据的分布和对齐;例如,当前一代 GPU 只有在数据(任务 1任务 2任务 3)对齐时才能高效运行,如下面的图所示:

图片

数据并行范式模型

设计并行程序

利用并行性的算法设计基于一系列操作,这些操作必须执行,以便程序能够正确执行任务而不会产生部分或错误的结果。为了正确并行化一个算法,必须执行以下宏观操作:

  • 任务分解

  • 任务分配

  • 聚合

  • 映射

任务分解

在这个第一阶段,软件程序被分割成任务或一组指令,然后可以在不同的处理器上执行以实现并行化。为了执行这种细分,使用了两种方法:

  • 域分解:在此,问题的数据被分解。该应用对所有在数据的不同部分上工作的处理器都是通用的。当我们必须处理大量数据时,我们使用这种方法。

  • 功能分解:在这种情况下,问题被分割成任务,每个任务将对所有可用数据进行特定的操作。

任务分配

在此步骤中,指定了任务将在各个进程之间如何分配的机制。这一阶段非常重要,因为它建立了不同处理器之间的工作负载分配。负载均衡在这里至关重要;实际上,所有处理器都必须连续工作,避免长时间处于空闲状态。

为了执行此操作,编码者会考虑到系统可能的异构性,并尝试将更多任务分配给性能更好的处理器。最后,为了提高并行化的效率,有必要尽可能减少处理器之间的通信,因为它们往往是减速和资源消耗的来源。

聚合

聚合是将较小的任务与较大的任务结合起来的过程,以提高性能。如果设计过程的先前两个阶段将问题分割成远超过可用处理器数量的任务,并且如果计算机没有专门设计来处理大量的小任务(一些架构,如 GPU,处理这些任务很好,并且确实从运行数百万甚至数十亿的任务中受益),那么设计可能会非常低效。

通常,这是因为任务需要传达给处理器或线程,以便它们计算所述任务。大多数通信的成本与传输的数据量不成比例,但每次通信操作(如设置 TCP 连接时固有的延迟)都会产生固定成本。如果任务太小,那么这种固定成本很容易使设计变得低效。

映射

在并行算法设计过程的映射阶段,我们指定每个任务将在何处执行。目标是使总执行时间最小化。在这里,你通常必须做出权衡,因为两种主要策略往往相互冲突:

  • 应该将频繁通信的任务放置在同一处理器中,以增加局部性。

  • 应该将可以并发执行的任务放置在不同的处理器中,以增强并发性。

这被称为映射问题,并且已知它是NP 完全的。因此,在一般情况下,不存在该问题的多项式时间解。对于大小相等且具有易于识别的通信模式的任务,映射是直接的(我们也可以在这里进行聚簇,以将映射到同一处理器的任务组合在一起)。然而,如果任务的通信模式难以预测或每个任务的工作量不同,那么设计一个有效的映射和聚簇方案就很难。

对于这类问题,可以在运行时使用负载均衡算法来识别聚簇和映射策略。最困难的问题是那些在程序执行过程中通信量或任务数量发生变化的问题。对于这类问题,可以使用动态负载均衡算法,这些算法在执行期间定期运行。

动态映射

对于各种问题,存在许多负载均衡算法:

  • 全局算法:这些算法需要全局了解正在进行的计算,这通常会增加很多开销。

  • 本地算法:这些算法仅依赖于与所讨论任务局部相关的信息,与全局算法相比,这降低了开销,但它们通常在寻找最佳聚簇和映射方面表现较差。

然而,降低开销可能会减少执行时间,尽管映射本身可能更差。如果任务很少在执行的开始和结束时进行通信,那么通常会使用任务调度算法,该算法简单地将任务映射到空闲的处理器。在任务调度算法中,维护一个任务池。任务被放置在这个池中,并由工作者从池中取出。

在这个模型中有三种常见的方法:

  • 管理/工作员:这是所有工作员连接到一个集中式管理员的基动态映射方案。管理员反复向工作员发送任务并收集结果。这种策略可能适用于相对较少的处理器。通过提前获取任务,可以改进基本策略,以便通信和计算重叠。

  • 分层管理/工作员:这是具有半分布式布局的管理员/工作员变体。工作员被分成组,每组都有自己的管理员。这些组管理员与中央管理员(以及可能彼此之间)通信,而工作员从组管理员那里请求任务。这样可以在几个管理员之间分散负载,并且可以处理更多的处理器,如果所有工作员都从同一个管理员那里请求任务。

  • 去中心化:在这个方案中,一切都是去中心化的。每个处理器维护自己的任务池,并与其他处理器通信以请求任务。处理器如何选择其他处理器来请求任务各不相同,并且基于问题来确定。

评估并行程序的性能

并行编程的发展产生了对性能指标的需求,以便决定其使用是否方便。事实上,并行计算的重点是在相对较短的时间内解决大型问题。有助于实现这一目标的因素包括,例如,使用的硬件类型、问题的并行程度以及采用的并行编程模型。为了便于此,引入了基本概念的分析,它比较了从原始序列获得的并行算法。

通过分析和量化使用的线程数和/或进程数来实现性能。为了分析这一点,让我们引入一些性能指标:

  • 加速

  • 效率

  • 扩展性

并行计算的局限性是由阿姆达尔定律引入的。为了评估顺序算法并行化的效率程度,我们有古斯塔夫森定律

加速

加速是显示并行解决问题益处的度量。它定义为在单个处理元素(Ts)上解决问题所需的时间与在 p 个相同处理元素(Tp)上解决问题所需时间的比率。

我们如下表示加速:

图片

我们有一个线性加速,如果 S=p,这意味着执行速度会随着处理器数量的增加而增加。当然,这是一个理想的情况。当 Ts 是最佳顺序算法的执行时间时,加速是绝对的;当 Ts 是单个处理器的并行算法的执行时间时,加速是相对的。

让我们回顾这些条件:

  • S = p 是线性或理想加速。

  • S < p 是一个真实加速。

  • S > p 是一个超线性加速。

效率

在一个理想的世界里,具有 p 个处理单元的并行系统可以给我们一个等于 p 的加速。然而,这很少实现。通常,一些时间浪费在空闲或通信上。效率是衡量处理单元将多少执行时间用于有用工作的度量,表示为所花费时间的分数。

我们用 E 来表示它,并可以如下定义:

具有线性加速的算法其值为 E = 1。在其他情况下,它们的值小于 1。以下是对三种情况进行的识别:

  • E = 1 时,这是一个线性情况。

  • E < 1 时,这是一个真实情况。

  • E << 1 时,这是一个低效率可并行化的问题。

规模化

规模化定义为在并行机器上保持效率的能力。它按处理器数量比例识别计算能力(执行速度)。通过增加问题的大小和同时增加处理器数量,在性能方面将不会有所损失。

可扩展的系统,根据不同因素的增量,可能保持相同的效率或提高效率。

阿姆达尔定律

阿姆达尔定律是一个广泛使用的定律,用于设计处理器和并行算法。它指出,可以达到的最大加速比受程序串行部分限制:

1 – P 表示程序中(未并行化)的串行部分。

这意味着,例如,如果一个程序中 90%的代码可以并行化,但 10%必须保持串行,那么即使对于无限数量的处理器,最大可达到的加速比也是 9。

古斯塔夫森定律

古斯塔夫森定律表述如下:

在这里,正如我们在方程中指出的,以下适用:

  • P处理器数量

  • S加速 因子。

  • α 是任何并行过程中 不可并行化部分

古斯塔夫森定律与阿姆达尔定律形成对比,正如我们描述的那样,阿姆达尔定律假设程序的整体工作量不会随着处理器数量的增加而改变。

事实上,古斯塔夫森定律建议程序员首先设定并行解决一个问题的 时间,然后基于这个(即时间)来调整问题的大小。因此,并行系统越快,在相同的时间内可以解决的问题就越多。

古斯塔夫森定律的影响是将计算机研究的目标指向选择或重新表述问题,以便在相同的时间内解决更大的问题仍然是可能的。此外,该定律重新定义了 效率 的概念,即需要 至少减少程序中的顺序部分,尽管 工作量增加

介绍 Python

Python 是一种强大、动态和解释型编程语言,广泛应用于各种应用。其一些特性如下:

  • 清晰且易于阅读的语法。

  • 一个非常广泛的标准库,通过额外的软件模块,我们可以添加数据类型、函数和对象。

  • 易于学习的快速开发和调试。在 Python 中开发 Python 代码可以比在 C/C++代码中快 10 倍。代码也可以作为原型,然后翻译成 C/C++。

  • 基于异常的错误处理。

  • 强大的内省功能。

  • 文档的丰富性和软件社区。

Python 可以被视为一种粘合语言。使用 Python,可以开发出更好的应用程序,因为不同类型的程序员可以一起在项目上工作。例如,在构建科学应用时,C/C++程序员可以实现高效的数值算法,而同一项目中的科学家可以编写 Python 程序来测试和使用这些算法。科学家不必学习低级编程语言,C/C++程序员也不需要理解涉及的科学。

您可以从www.python.org/doc/essays/omg-darpa-mcc-position了解更多相关信息。

让我们看看一些非常基本的代码示例,以了解 Python 的功能。

以下部分可以成为大多数人的复习资料。我们将在第二章基于线程的并行性和第三章基于进程的并行性中实际使用这些技术。

帮助函数

Python 解释器已经提供了一个有效的帮助系统。如果您想了解如何使用一个对象,只需键入help(object)

例如,让我们看看如何使用help函数在整数0上:

>>> help(0)
Help on int object:

class int(object)
 | int(x=0) -> integer
 | int(x, base=10) -> integer
 | 
 | Convert a number or string to an integer, or return 0 if no 
 | arguments are given. If x is a number, return x.__int__(). For 
 | floating point numbers, this truncates towards zero.
 | 
 | If x is not a number or if base is given, then x must be a string,
 | bytes, or bytearray instance representing an integer literal in the
 | given base. The literal can be preceded by '+' or '-' and be
 | surrounded by whitespace. The base defaults to 10\. Valid bases are 0 
 | and 2-36.
 | Base 0 means to interpret the base from the string as an integer 
 | literal.
>>> int('0b100', base=0)

int对象的描述后面跟着一个适用于它的方法列表。前五种方法如下:

 | Methods defined here:
 | 
 | __abs__(self, /)
 | abs(self)
 | 
 | __add__(self, value, /)
 | Return self+value.
 | 
 | __and__(self, value, /)
 | Return self&value.
 | 
 | __bool__(self, /)
 | self != 0
 | 
 | __ceil__(...)
 | Ceiling of an Integral returns itself.

同样有用的是dir(object),它列出了对象可用的方法:

>>> dir(float)
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']

最后,对象的相应文档由.__doc__函数提供,如下例所示:

>>> abs.__doc__
'Return the absolute value of the argument.'

语法

Python 不采用语句终止符,代码块通过缩进来指定。需要缩进级别的语句必须以冒号(:)结尾。这导致以下情况:

  • Python 代码更清晰、更易于阅读。

  • 程序结构始终与缩进一致。

  • 任何列表中的缩进风格都是统一的。

错误的缩进可能导致错误。

以下示例展示了如何使用if构造:

print("first print")
if condition:
    print(“second print”)
print(“third print”)

在这个例子中,我们可以看到以下内容:

  • 以下语句:print("first print")if condition:print("third print")具有相同的缩进级别,并且总是被执行。

  • if语句之后,有一个更高缩进级别的代码块,其中包含print ("second print")语句。

  • 如果if的条件为真,则执行print ("second print")语句。

  • 如果if的条件为假,则不执行print ("second print")语句。

因此,注意缩进非常重要,因为在程序解析过程中始终会评估缩进。

注释

注释以井号(#)开头,并且位于单行上:

# single line comment

多行字符串用于多行注释:

""" first line of a multi-line comment
second line of a multi-line comment."""

赋值

使用等号(=)进行赋值。对于相等性测试,使用相同的量(==)。你可以使用+=-=运算符增加和减少一个值,后面跟一个附加项。这适用于许多数据类型,包括字符串。你可以在同一行上赋值和使用多个变量。

一些例子如下:

>>> variable = 3
>>> variable += 2
>>> variable
5
>>> variable -= 1
>>> variable
4

>>> _string_ = "Hello"
>>> _string_ += " Parallel Programming CookBook Second Edition!"
>>> print (_string_) 
Hello Parallel Programming CookBook Second Edition!

数据类型

Python 中最显著的结构是列表元组字典。集合自 Python 2.5 版本以来已集成(旧版本可在sets库中找到):

  • 列表:这些类似于一维数组,但你可以创建包含其他列表的列表。

  • 字典:这些是包含键值对的数组(哈希表)。

  • 元组:这些是不可变的单维对象。

数组可以是任何类型,因此你可以将整数和字符串等变量混合到你的列表、字典和元组中。

任何类型数组的第一个对象的索引始终为零。允许使用负索引,并从数组的末尾开始计数;-1表示数组的最后一个元素:

#let's play with lists
list_1 = [1, ["item_1", "item_1"], ("a", "tuple")]
list_2 = ["item_1", -10000, 5.01]

>>> list_1
[1, ['item_1', 'item_1'], ('a', 'tuple')]

>>> list_2
['item_1', -10000, 5.01]

>>> list_1[2]
('a', 'tuple')

>>>list_1[1][0]
['item_1', 'item_1']

>>> list_2[0]
item_1

>>> list_2[-1]
5.01

#build a dictionary 
dictionary = {"Key 1": "item A", "Key 2": "item B", 3: 1000}
>>> dictionary 
{'Key 1': 'item A', 'Key 2': 'item B', 3: 1000} 

>>> dictionary["Key 1"] 
item A

>>> dictionary["Key 2"]
-1

>>> dictionary[3]
1000

你可以使用冒号(:)获取数组范围:

list_3 = ["Hello", "Ruvika", "how" , "are" , "you?"] 
>>> list_3[0:6] 
['Hello', 'Ruvika', 'how', 'are', 'you?'] 

>>> list_3[0:1]
['Hello']

>>> list_3[2:6]
['how', 'are', 'you?']

字符串

Python 字符串使用单引号(')或双引号(")表示,并且可以在由另一个分隔的字符串中使用一种表示法:

>>> example = "she loves ' giancarlo"
>>> example
"she loves ' giancarlo"

在多行中,它们被三重引号(或三个单引号)包围('''多行字符串'''):

>>> _string_='''I am a 
multi-line 
string'''
>>> _string_
'I am a \nmulti-line\nstring'

Python 也支持 Unicode;只需使用u "This is a unicode string"语法:

>>> ustring = u"I am unicode string"
>>> ustring
'I am unicode string'

要在字符串中输入值,请输入%运算符和一个元组。然后,每个%运算符被从左到右的元组元素替换:*

>>> print ("My name is %s !" % ('Mr. Wolf'))
My name is Mr. Wolf!

流控制

流控制指令是ifforwhile

在下一个例子中,我们检查数字是正数、负数还是零,并显示结果:

num = 1

if num > 0:
    print("Positive number")
elif num == 0:
    print("Zero")
else:
    print("Negative number")

以下代码块使用for循环找出存储在列表中的所有数字的总和:

numbers = [6, 6, 3, 8, -3, 2, 5, 44, 12]
sum = 0
for val in numbers:
    sum = sum+val
print("The sum is", sum)

我们将执行while循环,直到条件结果为真来迭代代码。由于我们不知道迭代次数,我们将使用这个循环而不是for循环。在这个例子中,我们使用while来计算自然数之和sum = 1+2+3+...+n

n = 10
# initialize sum and counter
sum = 0
i = 1
while i <= n:
    sum = sum + i
    i = i+1 # update counter

# print the sum
print("The sum is", sum)

前三个示例的输出如下:

Positive number
The sum is 83
The sum is 55
>>>

函数

Python 函数使用def关键字声明:

def my_function():
    print("this is a function")

要运行一个函数,使用函数名称,后跟括号,如下所示:

>>> my_function()
this is a function

参数必须在函数名之后、括号内指定:

def my_function(x):
    print(x * 1234)

>>> my_function(7)
8638

多个参数必须用逗号分隔:

def my_function(x,y):
    print(x*5+ 2*y)

>>> my_function(7,9)
53

使用等号来定义默认参数。如果你不传递参数调用函数,则将使用默认值:

def my_function(x,y=10):
    print(x*5+ 2*y)

>>> my_function(1)
25

>>> my_function(1,100)
205

函数的参数可以是任何类型的数据(例如字符串、数字、列表和字典)。在这里,以下列表 lcities 被用作 my_function 的参数:

def my_function(cities):
    for x in cities:
        print(x)

>>> lcities=["Napoli","Mumbai","Amsterdam"]
>>> my_function(lcities)
Napoli
Mumbai
Amsterdam

使用 return 语句从函数返回一个值:

def my_function(x,y):
    return x*y >>> my_function(6,29)
174 

Python 支持一种有趣的语法,允许你即时定义小型、单行的函数。这些 lambda 函数源自 Lisp 编程语言,可以在需要函数的地方使用。

一个 lambda 函数 functionvar 的示例如下所示:

# lambda definition equivalent to def f(x): return x + 1

functionvar = lambda x: x * 5
>>> print(functionvar(10))
50

Python 支持类的多重继承。传统上(不是语言规则),私有变量和方法通过在前面加上两个下划线(__)来声明。我们可以将任意属性(属性)分配给类的实例,如下例所示:

class FirstClass:
    common_value = 10
    def __init__ (self):
        self.my_value = 100
    def my_func (self, arg1, arg2):
        return self.my_value*arg1*arg2

# Build a first instance
>>> first_instance = FirstClass()
>>> first_instance.my_func(1, 2)
200

# Build a second instance of FirstClass
>>> second_instance = FirstClass()

#check the common values for both the instances
>>> first_instance.common_value
10

>>> second_instance.common_value
10

#Change common_value for the first_instance
>>> first_instance.common_value = 1500
>>> first_instance.common_value
1500

#As you can note the common_value for second_instance is not changed
>>> second_instance.common_value
10

# SecondClass inherits from FirstClass. 
# multiple inheritance is declared as follows:
# class SecondClass (FirstClass1, FirstClass2, FirstClassN)

class SecondClass (FirstClass):
    # The "self" argument is passed automatically
    # and refers to the class's instance
    def __init__ (self, arg1):
        self.my_value = 764
        print (arg1)

>>> first_instance = SecondClass ("hello PACKT!!!!")
hello PACKT!!!!

>>> first_instance.my_func (1, 2)
1528

异常

Python 中的异常通过 try-except 块(exception_name)来管理:

def one_function():
     try:
         # Division by zero causes one exception
         10/0
     except ZeroDivisionError:
         print("Oops, error.")
     else:
         # There was no exception, we can continue.
         pass
     finally:
         # This code is executed when the block
         # try..except is already executed and all exceptions
         # have been managed, even if a new one occurs
         # exception directly in the block.
         print("We finished.")

>>> one_function()
Oops, error.
We finished

导入库

使用 import [library name] 来导入外部库。或者,你可以使用 from [library name] import [function name] 语法来导入特定的函数。以下是一个示例:

import random
randomint = random.randint(1, 101)

>>> print(randomint)
65

from random import randint
randomint = random.randint(1, 102)

>>> print(randomint)
46

文件管理

为了让我们能够与文件系统交互,Python 提供了内置的 open 函数。这个函数可以被调用以打开一个文件并返回一个文件对象。后者允许我们对文件执行各种操作,如读取和写入。当我们完成与文件的交互后,我们必须最后记得使用 file.close 方法来关闭它:

>>> f = open ('test.txt', 'w') # open the file for writing
>>> f.write ('first line of file \ n') # write a line in file
>>> f.write ('second line of file \ n') # write another line in file
>>> f.close () # we close the file
>>> f = open ('test.txt') # reopen the file for reading
>>> content = f.read () # read all the contents of the file
>>> print (content)
first line of the file
second line of the file
>>> f.close () # close the file

列表推导式

列表推导式是创建和操作列表的有力工具。它们由一个表达式组成,后面跟着一个 for 子句,然后是一个或多个 if 子句。列表推导式的语法很简单,如下所示:

[expression for item in list]

然后,执行以下操作:

#list comprehensions using strings
>>> list_comprehension_1 = [ x for x in 'python parallel programming cookbook!' ]
>>> print( list_comprehension_1)

['p', 'y', 't', 'h', 'o', 'n', ' ', 'p', 'a', 'r', 'a', 'l', 'l', 'e', 'l', ' ', 'p', 'r', 'o', 'g', 'r', 'a', 'm', 'm', 'i', 'n', 'g', ' ', 'c', 'o', 'o', 'k', 'b', 'o', 'o', 'k', '!']

#list comprehensions using numbers
>>> l1 = [1,2,3,4,5,6,7,8,9,10]
>>> list_comprehension_2 = [ x*10 for x in l1 ]
>>> print( list_comprehension_2)

[10, 20, 30, 40, 50, 60, 70, 80, 90, 100]

运行 Python 脚本

要执行 Python 脚本,只需调用 Python 解释器后跟脚本名称,在这种情况下,my_pythonscript.py。或者,如果我们处于不同的工作目录,则使用其完整地址:

> python my_pythonscript.py 

从现在开始,对于每次调用 Python 脚本,我们将使用前面的表示法;即,python 后跟 script_name.py,假设启动 Python 解释器的目录是脚本要执行的目录。

使用 pip 安装 Python 包

pip 是一个工具,允许我们搜索、下载和安装在 Python 包索引上找到的 Python 包,该索引是一个包含成千上万用 Python 编写的包的存储库。这也允许我们管理已下载的包,使我们能够更新或删除它们。

安装 pip

pip 已经包含在 Python 版本 ≥ 3.4 和 ≥ 2.7.9 中。要检查此工具是否已安装,我们可以运行以下命令:

C:\>pip

如果 pip 已经安装,则此命令将显示已安装的版本。

更新 pip

建议检查您所使用的 pip 版本是否始终是最新的。要更新它,我们可以使用以下命令:

 C:\>pip install -U pip

使用 pip

pip 支持一系列命令,允许我们执行各种操作,包括 搜索、下载、安装、更新删除 包。

要安装 PACKAGE,只需运行以下命令:

C:\>pip install PACKAGE 

介绍 Python 并行编程

Python 提供了许多库和框架,这些库和框架有助于高性能计算。然而,由于 全局解释器锁GIL),使用 Python 进行并行编程可能会相当微妙。

事实上,最广泛和最常用的 Python 解释器 CPython 是用 C 编程语言开发的。CPython 解释器需要 GIL 以进行线程安全操作。使用 GIL 意味着当您尝试访问线程中包含的任何 Python 对象时,您将遇到全局锁。并且一次只有一个线程可以获取 Python 对象或 C API 的锁。

幸运的是,事情并没有那么严重,因为,在 GIL 的领域之外,我们可以自由地使用并行性。这一类别包括我们在下一章中将要讨论的所有主题,包括多进程、分布式计算和 GPU 计算。

因此,Python 并非真正的多线程。那么什么是线程?什么是进程?在接下来的章节中,我们将介绍这两个基本概念以及 Python 编程语言如何处理它们。

进程和线程

线程 可以与轻量级进程相比,从某种意义上说,它们提供了与进程类似的优势,但不需要进程的典型通信技术。线程允许您将程序的主要控制流程划分为多个并发运行的控制流。相比之下,进程有自己的 地址空间 和自己的资源。因此,在运行在不同进程上的代码部分之间的通信只能通过适当的管理机制进行,包括管道、代码 FIFO、邮箱、共享内存区域和消息传递。另一方面,线程允许创建程序的并发部分,其中每个部分都可以访问相同的地址空间、变量和常量。

以下表格总结了线程和进程之间的主要区别:

线程 进程
共享内存。 不共享内存。
开始/更改计算成本较低。 开始/更改计算成本较高。
需要较少的资源(轻量级进程)。 需要更多的计算资源。
需要同步机制来正确处理数据。 不需要内存同步。

在这简短的介绍之后,我们最终可以展示进程和线程是如何操作的。

尤其是我们想比较以下函数的串行、多线程和多进程执行时间,该函数do_something执行一些基本计算,包括构建一个随机选择的整数列表(do_something.py文件):

import random

def do_something(count, out_list):
  for i in range(count):
    out_list.append(random.random())

接下来,是串行(serial_test.py)实现。让我们从相关的导入开始:

from do_something import *
import time 

注意导入模块time,它将用于评估执行时间,在本例中,以及do_something函数的串行实现。要构建的列表size等于10000000,而do_something函数将被执行10次:

if __name__ == "__main__":
    start_time = time.time()
    size = 10000000 
    n_exec = 10
    for i in range(0, exec):
        out_list = list()
        do_something(size, out_list)

    print ("List processing complete.")
    end_time = time.time()
    print("serial time=", end_time - start_time)   

接下来,我们有多线程实现(multithreading_test.py)。

导入相关库:

from do_something import *
import time
import threading

注意导入threading模块的重要性,以便操作 Python 的多线程功能。

在这里,是do_something函数的多线程执行。我们不会深入评论以下代码中的指令,因为它们将在第二章中更详细地讨论,基于线程的并行性

然而,也应该注意,在这种情况下,列表的长度显然与串行情况相同,size = 10000000,而定义的线程数是 10,threads = 10,这也是do_something函数必须执行的次数:

if __name__ == "__main__":
    start_time = time.time()
    size = 10000000
    threads = 10 
    jobs = []
    for i in range(0, threads):

还要注意通过threading.Thread方法构建单个线程:

out_list = list()
thread = threading.Thread(target=list_append(size,out_list))
jobs.append(thread)

我们启动线程并立即停止它们的循环序列如下:

    for j in jobs:
        j.start()
    for j in jobs:
        j.join()

    print ("List processing complete.")
    end_time = time.time()
    print("multithreading time=", end_time - start_time)

最后,是多进程实现(multiprocessing_test.py)。

我们首先导入必要的模块,特别是multiprocessing库,其功能将在第三章中深入解释,基于进程的并行性

from do_something import *
import time
import multiprocessing

与前例一样,要构建的列表长度、do_something函数的大小和执行次数保持不变(procs = 10):

if __name__ == "__main__":
    start_time = time.time()
    size = 10000000 
    procs = 10 
    jobs = []
    for i in range(0, procs):
        out_list = list()

在这里,通过multiprocessing.Process方法调用实现单个进程受以下影响:

        process = multiprocessing.Process\
                  (target=do_something,args=(size,out_list))
        jobs.append(process)

接下来,启动进程并立即停止它们的循环序列执行如下:

    for j in jobs:
        j.start()

    for j in jobs:
        j.join()

    print ("List processing complete.")
    end_time = time.time()
    print("multiprocesses time=", end_time - start_time)

然后,我们打开命令行并运行前面描述的三个函数。

前往已复制函数的文件夹,然后输入以下内容:

> python serial_test.py

在具有以下特性的机器上获得的结果——CPU Intel i7/8 GB 的内存,如下所示:

List processing complete.
serial time= 25.428767204284668

多线程实现的情况下,我们有以下内容:

> python multithreading_test.py

输出如下:

List processing complete.
multithreading time= 26.168917179107666

最后,是多进程的实现:

> python multiprocessing_test.py

其结果如下:

List processing complete.
multiprocesses time= 18.929869890213013

如所示,串行实现(即使用serial_test.py)的结果与使用多线程实现(使用multithreading_test.py)获得的结果相似,其中线程实际上是依次启动的,优先考虑一个然后是另一个,直到结束,而我们在使用 Python 多进程能力(使用multiprocessing_test.py)方面获得了执行时间上的好处。

第二章:基于线程的并行

目前,在软件应用程序中管理并发最广泛使用的编程范式是基于多线程的。通常,一个应用程序由一个单一进程组成,该进程被划分为多个独立的线程,这些线程代表不同类型的活动,它们并行运行并相互竞争。

现在,使用多线程的现代应用程序已经被大规模采用。事实上,所有当前的处理器都是多核的,这样它们就可以执行并行操作并利用计算机的计算资源。

因此,多线程编程无疑是实现并发应用的好方法。然而,多线程编程往往隐藏一些非平凡的困难,这些困难必须得到适当的处理,以避免死锁或同步问题等错误。

我们将首先定义基于线程和多线程编程的概念,然后介绍multithreading库。我们将学习线程定义、管理和通信的主要指令。

通过multithreading库,我们将看到如何通过不同的技术解决问题,例如RLock信号量条件事件屏障队列

在本章中,我们将涵盖以下食谱:

  • 什么是线程?

  • 如何定义线程

  • 如何确定当前线程

  • 如何在子类中使用线程

  • 使用锁进行线程同步

  • 使用 RLock 进行线程同步

  • 使用信号量进行线程同步

  • 使用条件进行线程同步

  • 使用事件进行线程同步

  • 使用屏障进行线程同步

  • 使用队列进行线程通信

我们还将探讨 Python 提供的用于线程编程的主要选项。为此,我们将专注于使用threading模块。

什么是线程?

线程是一个可以与其他系统中的线程并行和并发执行的独立执行流程。

多个线程可以共享数据和资源,利用所谓的共享信息空间。线程和进程的具体实现取决于你计划在哪个操作系统上运行应用程序,但一般来说,可以这样说,线程包含在进程内部,并且同一进程中的不同线程条件共享一些资源。相比之下,不同的进程不会与其他进程共享它们自己的资源。

线程由三个元素组成:程序计数器、寄存器和栈。与同一进程中的其他线程共享的资源基本上包括数据操作系统资源。此外,线程有自己的执行状态,即线程状态,并且可以与其他线程同步

线程状态可以是就绪、运行或阻塞:

  • 当线程被创建时,它进入就绪状态。

  • 线程由操作系统(或运行时支持系统)调度执行,当轮到它时,它进入运行状态开始执行。

  • 线程可以等待一个条件发生,从运行状态转换为阻塞状态。一旦锁定条件终止,阻塞线程将返回到就绪状态:

图片

线程生命周期

多线程编程的主要优势在于性能,因为进程之间的上下文切换比同一进程内的线程之间的上下文切换要重得多。

在下一节中,直到本章结束,我们将检查 Python 的threading模块,通过编程示例介绍其主要功能。

Python 线程模块

Python 使用 Python 标准库提供的threading模块来管理线程。此模块提供了一些非常有趣的功能,使得基于线程的方法变得非常简单;实际上,threading模块提供了几个非常简单的同步机制。

线程模块的主要组件如下:

  • thread对象

  • lock对象

  • RLock对象

  • semaphore对象

  • condition对象

  • event对象

在下面的菜谱中,我们通过不同的应用示例检查threading库提供的功能。对于下面的示例,我们将参考 Python 3.5.0 发行版(www.python.org/downloads/release/python-350/)。

定义线程

使用线程的最简单方法是使用目标函数实例化它,然后调用 start 方法让它开始工作。

准备工作

Python 的threading模块提供了一个Thread类,用于在不同的线程中运行进程和函数:

class threading.Thread(group=None, 
                       target=None, 
                       name=None, 
                       args=(), 
                       kwargs={})  

这里是Thread类的参数:

  • group:这是group值,应该是None;这是为未来的实现保留的。

  • target:这是启动线程活动时要执行的功能。

  • name:这是线程的名称;默认情况下,分配给它一个唯一的名称,形式为Thread-N

  • args:这是要传递给目标函数的参数元组。

  • kwargs:这是用于target函数的关键字参数字典。

在下一节中,我们将学习如何定义线程。

如何实现...

我们将通过传递一个数字来定义线程,这个数字代表线程号,最后将打印出结果:

  1. 使用以下 Python 命令导入threading模块:
import threading
  1. main程序中,使用名为my_func的目标函数实例化一个Thread对象。然后,将包含在输出消息中的函数参数传递:
t = threading.Thread(target=function , args=(i,))
  1. 线程只有在调用start方法后才开始运行,join方法使调用线程等待,直到线程完成执行,如下所示:
import threading

def my_func(thread_number):
    return print('my_func called by thread N°\
        {}'.format(thread_number))

def main():
    threads = []
    for i in range(10):
        t = threading.Thread(target=my_func, args=(i,))
        threads.append(t)
        t.start()
        t.join()

if __name__ == "__main__":
    main()

它是如何工作的...

main程序中,我们初始化线程列表,并将创建的每个线程的实例添加到该列表中。创建的线程总数为 10,而 i^(th)线程的i-索引作为参数传递给 i^(th)线程:

my_func called by thread N°0
my_func called by thread N°1
my_func called by thread N°2
my_func called by thread N°3
my_func called by thread N°4
my_func called by thread N°5
my_func called by thread N°6
my_func called by thread N°7
my_func called by thread N°8
my_func called by thread N°9

还有更多...

所有当前处理器都是多核的,因此提供了执行多个并行操作的可能性,并充分利用计算机的计算资源。尽管这是真的,但多线程编程隐藏了许多非平凡困难,这些困难必须得到适当的处理,以避免死锁或同步问题等错误。

确定当前线程

使用参数来识别或命名线程是繁琐且不必要的。每个Thread实例都有一个name,具有默认值,可以在创建线程时更改。

在具有多个服务线程处理不同操作的服务器进程中,命名线程是有用的。

准备工作

这个threading模块提供了currentThread().getName()方法,它返回当前线程的名称。

以下部分展示了我们如何使用此功能来确定哪个线程正在运行。

如何做...

让我们看看以下步骤:

  1. 要确定哪个线程正在运行,我们创建了三个target函数,并导入time模块以引入两秒的暂停执行:
import threading
import time

def function_A():
    print (threading.currentThread().getName()+str('-->\
        starting \n'))
    time.sleep(2)
    print (threading.currentThread().getName()+str( '-->\
        exiting \n'))

def function_B():
    print (threading.currentThread().getName()+str('-->\
        starting \n'))
    time.sleep(2)
    print (threading.currentThread().getName()+str( '-->\
        exiting \n'))

def function_C():
    print (threading.currentThread().getName()+str('-->\
        starting \n'))
    time.sleep(2)
    print (threading.currentThread().getName()+str( '-->\
        exiting \n'))

  1. 使用target函数创建了三个线程。然后,我们传递要打印的名称,如果没有定义,则使用默认名称。然后,对每个线程调用start()join()方法:
if __name__ == "__main__":

    t1 = threading.Thread(name='function_A', target=function_A)
    t2 = threading.Thread(name='function_B', target=function_B)
    t3 = threading.Thread(name='function_C',target=function_C) 

    t1.start()
    t2.start()
    t3.start()

    t1.join()
    t2.join()
    t3.join()

它是如何工作的...

我们将设置三个线程,每个线程都分配了一个target函数。当target函数执行并终止时,将适当地打印出函数名称。

对于这个例子,输出应该看起来像这样(即使显示的顺序可能不同):

function_A--> starting 
function_B--> starting 
function_C--> starting 

function_A--> exiting 
function_B--> exiting 
function_C--> exiting

定义线程子类

创建一个线程可能需要定义一个子类,该子类从Thread类继承。正如在定义线程部分中解释的那样,后者包含在threading模块中,然后必须导入该模块。

准备工作

我们将在下一节定义的类,它代表我们的线程,遵循一个精确的结构:我们首先必须定义__init__方法,但最重要的是,我们必须重写run方法。

如何做...

涉及的步骤如下:

  1. 我们定义了MyThreadClass类,我们可以使用它来创建我们想要的任何线程。这种类型的每个线程都将由run方法中定义的操作所特征化,在这个简单的例子中,它限制自己在执行开始和结束时打印一个字符串:
import time
import os
from random import randint
from threading import Thread

class MyThreadClass (Thread):
  1. 此外,在__init__方法中,我们指定了两个初始化参数,分别是nameduration,它们将在run方法中使用:
def __init__(self, name, duration):
      Thread.__init__(self)
      self.name = name
      self.duration = duration 

   def run(self):
      print ("---> " + self.name +\
             " running, belonging to process ID "\
             + str(os.getpid()) + "\n")
      time.sleep(self.duration)
      print ("---> " + self.name + " over\n")
  1. 这些参数将在创建线程时设置。特别是,duration参数使用randint函数计算,该函数输出一个介于110之间的随机整数。从MyThreadClass的定义开始,让我们看看如何实例化更多线程,如下所示:
def main():

    start_time = time.time()

    # Thread Creation
    thread1 = MyThreadClass("Thread#1 ", randint(1,10))
    thread2 = MyThreadClass("Thread#2 ", randint(1,10))
    thread3 = MyThreadClass("Thread#3 ", randint(1,10))
    thread4 = MyThreadClass("Thread#4 ", randint(1,10))
    thread5 = MyThreadClass("Thread#5 ", randint(1,10))
    thread6 = MyThreadClass("Thread#6 ", randint(1,10))
    thread7 = MyThreadClass("Thread#7 ", randint(1,10))
    thread8 = MyThreadClass("Thread#8 ", randint(1,10)) 
    thread9 = MyThreadClass("Thread#9 ", randint(1,10))

    # Thread Running
    thread1.start()
    thread2.start()
    thread3.start()
    thread4.start()
    thread5.start()
    thread6.start()
    thread7.start()
    thread8.start()
    thread9.start()

    # Thread joining
    thread1.join()
    thread2.join()
    thread3.join()
    thread4.join()
    thread5.join()
    thread6.join()
    thread7.join()
    thread8.join()
    thread9.join()

    # End 
    print("End")

    #Execution Time
    print("--- %s seconds ---" % (time.time() - start_time))

if __name__ == "__main__":
    main()

它是如何工作的...

在这个例子中,我们创建了九个线程,每个线程都有自己的nameduration属性,这些属性根据__init__方法的定义。

我们然后使用start方法运行它们,该方法仅限于执行先前定义的run方法的内容。请注意,每个线程的进程 ID 是相同的,这意味着我们处于一个多线程进程。

此外,请注意,start方法不是阻塞的:当它执行时,控制权立即转到下一行,而线程则在后台启动。实际上,正如您所看到的,线程的创建并不按照代码指定的顺序进行。同样,线程的终止受duration参数值的约束,该参数使用randint函数评估,并通过每个线程创建实例的参数传递。要等待线程完成,必须执行join操作。

输出看起来像这样:

---> Thread#1 running, belonging to process ID 13084
---> Thread#5 running, belonging to process ID 13084
---> Thread#2 running, belonging to process ID 13084
---> Thread#6 running, belonging to process ID 13084
---> Thread#7 running, belonging to process ID 13084
---> Thread#3 running, belonging to process ID 13084
---> Thread#4 running, belonging to process ID 13084
---> Thread#8 running, belonging to process ID 13084
---> Thread#9 running, belonging to process ID 13084

---> Thread#6 over
---> Thread#9 over
---> Thread#5 over
---> Thread#2 over
---> Thread#7 over
---> Thread#4 over
---> Thread#3 over
---> Thread#8 over
---> Thread#1 over

End

--- 9.117518663406372 seconds ---

还有更多...

与面向对象编程(OOP)最常相关联的特性是继承,这是将一个新类定义为已存在类的修改版本的能力。继承的主要优势是您可以在不修改原始定义的情况下向类中添加新方法。

原始类通常被称为父类,而派生类被称为子类。继承是一个强大的特性,某些程序可以编写得更加容易和简洁,提供了在不修改原始类的情况下自定义类行为的可能性。实际上,继承结构能够反映问题的结构,在某些情况下,可以使程序更容易理解。

然而(为了提醒用户注意!),继承可能会使程序更难阅读。这是因为,在调用方法时,并不总是清楚这个方法是在代码的哪个地方定义的,而这个代码需要在多个模块中追踪,而不是在一个定义良好的地方。

通常,可以使用继承完成的事情,即使没有它也可以优雅地管理,因此只有在问题的结构需要时才应该使用继承。如果使用不当,那么继承可能造成的危害可能会超过使用它的好处。

使用锁进行线程同步

threading模块还包括一个简单的锁机制,这允许我们在线程之间实现同步。

准备工作

不过是一个通常可以被多个线程访问的对象,线程在进入程序受保护部分的执行之前必须拥有它。这些锁是通过执行Lock()方法创建的,该方法定义在threading模块中。

一旦创建了锁,我们可以使用两种方法来同步两个(或更多)线程的执行:acquire()方法用于获取锁控制,release()方法用于释放它。

acquire()方法接受一个可选参数,如果未指定或设置为True,将强制线程暂停其执行,直到锁被释放并可以获取。另一方面,如果acquire()方法以等于False的参数执行,则它立即返回一个布尔结果,如果锁已被获取,则为True,否则为False

在以下示例中,我们通过修改上一节中引入的代码,定义线程子类,来展示锁机制。

如何做到...

涉及的步骤如下:

  1. 如以下代码块所示,MyThreadClass类已被修改,在run方法中引入了acquire()release()方法,而Lock()的定义则位于类定义之外:
import threading
import time
import os
from threading import Thread
from random import randint

# Lock Definition
threadLock = threading.Lock()

class MyThreadClass (Thread):
   def __init__(self, name, duration):
      Thread.__init__(self)
      self.name = name
      self.duration = duration
   def run(self):
      #Acquire the Lock
      threadLock.acquire() 
      print ("---> " + self.name + \
             " running, belonging to process ID "\
             + str(os.getpid()) + "\n")
      time.sleep(self.duration)
      print ("---> " + self.name + " over\n")
      #Release the Lock
      threadLock.release()
  1. 与之前的代码示例相比,main()函数没有发生变化:
def main():
    start_time = time.time()
    # Thread Creation
    thread1 = MyThreadClass("Thread#1 ", randint(1,10))
    thread2 = MyThreadClass("Thread#2 ", randint(1,10))
    thread3 = MyThreadClass("Thread#3 ", randint(1,10))
    thread4 = MyThreadClass("Thread#4 ", randint(1,10))
    thread5 = MyThreadClass("Thread#5 ", randint(1,10))
    thread6 = MyThreadClass("Thread#6 ", randint(1,10))
    thread7 = MyThreadClass("Thread#7 ", randint(1,10))
    thread8 = MyThreadClass("Thread#8 ", randint(1,10))
    thread9 = MyThreadClass("Thread#9 ", randint(1,10))

    # Thread Running
    thread1.start()
    thread2.start()
    thread3.start()
    thread4.start()
    thread5.start()
    thread6.start()
    thread7.start()
    thread8.start()
    thread9.start()

    # Thread joining
    thread1.join()
    thread2.join()
    thread3.join()
    thread4.join()
    thread5.join()
    thread6.join()
    thread7.join()
    thread8.join()
    thread9.join()

    # End 
    print("End")
    #Execution Time
    print("--- %s seconds ---" % (time.time() - start_time))

if __name__ == "__main__":
    main()

它是如何工作的...

我们通过使用锁修改了上一节的代码,以便线程将按顺序执行。

第一个线程获取锁并执行其任务,而其他八个线程则保持等待状态。在第一个线程执行结束后,即执行release()方法后,第二个线程将获取锁,而三到八个线程将继续等待,直到执行结束(即再次,只有在运行release()方法之后)。

锁获取锁释放的执行会重复进行,直到第九个线程,最终结果是,由于锁机制,此执行以顺序模式进行,如下面的输出所示:

---> Thread#1 running, belonging to process ID 10632
---> Thread#1 over
---> Thread#2 running, belonging to process ID 10632
---> Thread#2 over
---> Thread#3 running, belonging to process ID 10632
---> Thread#3 over
---> Thread#4 running, belonging to process ID 10632
---> Thread#4 over
---> Thread#5 running, belonging to process ID 10632
---> Thread#5 over
---> Thread#6 running, belonging to process ID 10632
---> Thread#6 over
---> Thread#7 running, belonging to process ID 10632
---> Thread#7 over
---> Thread#8 running, belonging to process ID 10632
---> Thread#8 over
---> Thread#9 running, belonging to process ID 10632
---> Thread#9 over

End

--- 47.3672661781311 seconds ---

还有更多...

acquire()release()方法的插入点决定了整个代码的执行。因此,您花时间分析您想要使用的线程以及您想要如何同步它们非常重要。

例如,我们可以将MyThreadClass类中release()方法的插入点更改如下:

import threading
import time
import os
from threading import Thread
from random import randint

# Lock Definition
threadLock = threading.Lock()

class MyThreadClass (Thread):
   def __init__(self, name, duration):
      Thread.__init__(self)
      self.name = name
      self.duration = duration
   def run(self):
      #Acquire the Lock
      threadLock.acquire() 
      print ("---> " + self.name + \
             " running, belonging to process ID "\ 
             + str(os.getpid()) + "\n")
      #Release the Lock in this new point
      threadLock.release()
      time.sleep(self.duration)
      print ("---> " + self.name + " over\n")

在这种情况下,输出发生了相当显著的变化:

---> Thread#1 running, belonging to process ID 11228
---> Thread#2 running, belonging to process ID 11228
---> Thread#3 running, belonging to process ID 11228
---> Thread#4 running, belonging to process ID 11228
---> Thread#5 running, belonging to process ID 11228
---> Thread#6 running, belonging to process ID 11228
---> Thread#7 running, belonging to process ID 11228
---> Thread#8 running, belonging to process ID 11228
---> Thread#9 running, belonging to process ID 11228

---> Thread#2 over
---> Thread#4 over
---> Thread#6 over
---> Thread#5 over
---> Thread#1 over
---> Thread#3 over
---> Thread#9 over
---> Thread#7 over
---> Thread#8 over

End
--- 6.11468243598938 seconds ---

如您所见,只有线程创建是以顺序模式发生的。一旦线程创建完成,新线程获取锁,而前一个线程则在后台继续计算。

使用 RLock 进行线程同步

可重入锁,或简称 RLock,是一种可以被同一线程多次获取的同步原语。

它使用了专有线程的概念。这意味着在锁定状态下,一些线程拥有锁,而在未锁定状态下,锁不被任何线程拥有。

下一个示例演示了如何通过RLock()机制管理线程。

准备就绪

RLock是通过threading.RLock()类实现的。它提供了与threading.Lock()类语法相同的acquire()release()方法。

RLock块可以被同一线程多次获取。其他线程将无法获取RLock块,直到拥有它的线程为每个之前的acquire()调用执行了release()调用。实际上,RLock块必须被释放,但只能由获取它的线程释放。

如何做到这一点...

涉及的步骤如下:

  1. 我们引入了Box类,它提供了add()remove()方法,这些方法通过访问execute()方法来执行添加或删除项目的操作。对execute()方法的访问由RLock()调节:
import threading
import time
import random

class Box:
    def __init__(self):
        self.lock = threading.RLock()
        self.total_items = 0

    def execute(self, value):
        with self.lock:
            self.total_items += value

    def add(self):
        with self.lock:
            self.execute(1)

    def remove(self):
        with self.lock:
            self.execute(-1)
  1. 以下函数由两个线程调用。它们有box类和要添加或删除的总items数量作为参数:
def adder(box, items):
    print("N° {} items to ADD \n".format(items))
    while items:
        box.add()
        time.sleep(1)
        items -= 1
        print("ADDED one item -->{} item to ADD \n".format(items))

def remover(box, items):
    print("N° {} items to REMOVE\n".format(items))
    while items:
        box.remove()
        time.sleep(1)
        items -= 1
        print("REMOVED one item -->{} item to REMOVE\
            \n".format(items))
  1. 在这里,设置要添加到或从盒子中移除的项目总数。如您所见,这两个数字将不同。当adderremover方法完成其任务时,执行结束:
def main():
    items = 10
    box = Box()

    t1 = threading.Thread(target=adder, \
                          args=(box, random.randint(10,20)))
    t2 = threading.Thread(target=remover, \
                          args=(box, random.randint(1,10)))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

if __name__ == "__main__":
    main()

它是如何工作的...

main程序中,t1t2两个线程已经与adder()remover()函数相关联。如果项目的数量大于零,则函数是活跃的。

RLock()的调用是在Box类的__init__方法中进行的:

class Box:
    def __init__(self):
        self.lock = threading.RLock()
        self.total_items = 0

两个adder()remover()函数分别与Box类的项目交互,并调用Box类的add()remove()方法。

在每次方法调用中,通过在_init_方法中设置的lock参数捕获资源,然后释放资源。

这里是输出结果:

N° 16 items to ADD 
N° 1 items to REMOVE 

ADDED one item -->15 item to ADD 
REMOVED one item -->0 item to REMOVE 

ADDED one item -->14 item to ADD 
ADDED one item -->13 item to ADD 
ADDED one item -->12 item to ADD 
ADDED one item -->11 item to ADD 
ADDED one item -->10 item to ADD 
ADDED one item -->9 item to ADD 
ADDED one item -->8 item to ADD 
ADDED one item -->7 item to ADD 
ADDED one item -->6 item to ADD 
ADDED one item -->5 item to ADD 
ADDED one item -->4 item to ADD 
ADDED one item -->3 item to ADD 
ADDED one item -->2 item to ADD 
ADDED one item -->1 item to ADD 
ADDED one item -->0 item to ADD 
>>>

还有更多...

lockRLock之间的区别如下:

  • 一个在必须释放之前只能被获取一次。然而,RLock可以从同一线程多次获取;必须以相同的方式释放相同次数,才能释放。

  • 另一个区别是,获取的锁可以被任何线程释放,而获取的RLock只能由获取它的线程释放。

使用信号量进行线程同步

信号量是一种由操作系统管理的高级数据类型,用于同步多个线程对共享资源和数据的访问。它包含一个内部变量,用于标识与它关联的资源并发访问的数量。

准备就绪

信号量的操作基于两个函数:acquire()release(),如这里所述:

  • 当一个线程想要访问与信号量关联的给定资源时,它必须调用 acquire() 操作,这将减少信号量的内部变量,如果这个变量的值看起来是非负的,则允许访问资源。如果值是负的,则线程将被挂起,并且另一个线程释放资源的操作将被暂停。

  • 使用完共享资源后,线程通过 release() 指令释放资源。这样,信号量的内部变量就会增加,为等待的线程(如果有的话)提供了访问新释放资源的机遇。

信号量是计算机科学历史上最古老的同步原语之一,由早期荷兰计算机科学家 Edsger W. Dijkstra 发明。

以下示例展示了如何通过信号量同步线程。

如何实现...

以下代码描述了一个问题,其中我们有两个线程,producer()consumer(),它们共享一个公共资源,即项目。producer() 的任务是生成项目,而 consumer() 线程的任务是使用已经被生产的项目。

如果项目尚未由 consumer() 线程生产,那么它必须等待。一旦项目被生产,producer() 线程通知消费者资源应该被使用:

  1. 通过将信号量初始化为 0,我们获得了一个所谓的信号量事件,其唯一目的是同步两个或更多线程的计算。在这里,一个线程必须同时使用数据或共享资源:
semaphore = threading.Semaphore(0)
  1. 这个操作与锁机制的描述非常相似。producer() 线程创建项目,然后通过调用 release() 方法释放资源:
semaphore.release()
  1. 同样,consumer() 线程通过 acquire() 方法获取数据。如果信号量的计数器等于 0,则它将阻塞条件的 acquire() 方法,直到它被另一个线程通知。如果信号量的计数器大于 0,则它将减少该值。当生产者创建一个项目时,它释放信号量,然后消费者获取它并消费共享资源:
semaphore.acquire()
  1. 通过信号量完成的同步过程在以下代码块中展示:
import logging
import threading
import time
import random

LOG_FORMAT = '%(asctime)s %(threadName)-17s %(levelname)-8s %\
              (message)s'
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)

semaphore = threading.Semaphore(0)
item = 0

def consumer():
    logging.info('Consumer is waiting')
    semaphore.acquire()
    logging.info('Consumer notify: item number {}'.format(item))

def producer():
    global item
    time.sleep(3)
    item = random.randint(0, 1000)
    logging.info('Producer notify: item number {}'.format(item))
    semaphore.release()

#Main program
def main():
    for i in range(10):
        t1 = threading.Thread(target=consumer)
        t2 = threading.Thread(target=producer)

        t1.start()
        t2.start()

        t1.join()
        t2.join()

if __name__ == "__main__":
    main()

它是如何工作的...

然后将在标准输出上打印获取的数据:

print ("Consumer notify : consumed item number %s " %item)

这是我们在 10 次运行后得到的结果:

2019-01-27 19:21:19,354 Thread-1 INFO Consumer is waiting
2019-01-27 19:21:22,360 Thread-2 INFO Producer notify: item number 388
2019-01-27 19:21:22,385 Thread-1 INFO Consumer notify: item number 388
2019-01-27 19:21:22,395 Thread-3 INFO Consumer is waiting
2019-01-27 19:21:25,398 Thread-4 INFO Producer notify: item number 939
2019-01-27 19:21:25,450 Thread-3 INFO Consumer notify: item number 939
2019-01-27 19:21:25,453 Thread-5 INFO Consumer is waiting
2019-01-27 19:21:28,459 Thread-6 INFO Producer notify: item number 388
2019-01-27 19:21:28,468 Thread-5 INFO Consumer notify: item number 388
2019-01-27 19:21:28,476 Thread-7 INFO Consumer is waiting
2019-01-27 19:21:31,478 Thread-8 INFO Producer notify: item number 700
2019-01-27 19:21:31,529 Thread-7 INFO Consumer notify: item number 700
2019-01-27 19:21:31,538 Thread-9 INFO Consumer is waiting
2019-01-27 19:21:34,539 Thread-10 INFO Producer notify: item number 685
2019-01-27 19:21:34,593 Thread-9 INFO Consumer notify: item number 685
2019-01-27 19:21:34,603 Thread-11 INFO Consumer is waiting
2019-01-27 19:21:37,604 Thread-12 INFO Producer notify: item number 503
2019-01-27 19:21:37,658 Thread-11 INFO Consumer notify: item number 503
2019-01-27 19:21:37,668 Thread-13 INFO Consumer is waiting
2019-01-27 19:21:40,670 Thread-14 INFO Producer notify: item number 690
2019-01-27 19:21:40,719 Thread-13 INFO Consumer notify: item number 690
2019-01-27 19:21:40,729 Thread-15 INFO Consumer is waiting
2019-01-27 19:21:43,731 Thread-16 INFO Producer notify: item number 873
2019-01-27 19:21:43,788 Thread-15 INFO Consumer notify: item number 873
2019-01-27 19:21:43,802 Thread-17 INFO Consumer is waiting
2019-01-27 19:21:46,807 Thread-18 INFO Producer notify: item number 691
2019-01-27 19:21:46,861 Thread-17 INFO Consumer notify: item number 691
2019-01-27 19:21:46,874 Thread-19 INFO Consumer is waiting
2019-01-27 19:21:49,876 Thread-20 INFO Producer notify: item number 138
2019-01-27 19:21:49,924 Thread-19 INFO Consumer notify: item number 138
>>>

更多内容...

信号量的一个特定用途是互斥锁。互斥锁不过是一个内部变量初始化为 1 的信号量,它允许实现对数据和资源的互斥访问。

信号量在多线程编程语言中仍然被广泛使用;然而,它们有两个主要问题,我们已经在以下内容中讨论过:

  • 它们并不能阻止线程在同一个信号量上执行更多等待操作的可能性。很容易忘记与执行等待操作的数量相关的所有必要的信号。

  • 你可能会遇到死锁的情况。例如,当 t1 线程在 s1 信号量上执行等待操作,而 t2 线程在 t1 线程上执行等待操作,然后等待 s2t2,最后等待 s1 时,就会创建一个死锁情况。

使用条件进行线程同步

条件 识别应用程序中的状态变化。它是一种同步机制,其中线程等待特定条件,而另一个线程通知该 条件已经发生

一旦条件成立,线程 获取 锁以获得对共享资源的 独占访问

准备工作

通过再次查看生产者/消费者问题,可以很好地说明这种机制。如果缓冲区不满,producer 类将写入缓冲区,如果缓冲区已满,consumer 类将从缓冲区中取出数据(从后者中删除)。producer 类将通知消费者缓冲区不为空,而消费者将向生产者报告缓冲区不为空。

如何做到这一点...

涉及的步骤如下:

  1. consumer 类通过 items[] 列表获取共享资源:
condition.acquire()
  1. 如果列表长度等于 0,则消费者将处于等待状态:
if len(items) == 0:
   condition.wait()
  1. 然后它从项目列表中执行一个 pop 操作:
items.pop()
  1. 因此,将消费者状态通知给生产者,并释放共享资源:
condition.notify()
  1. producer 类获取共享资源,然后它验证列表是否完全填满(在我们的例子中,我们放置了项目列表中可以包含的最大项目数,10)。如果列表已满,则生产者将处于等待状态,直到列表被消耗:
condition.acquire()
if len(items) == 10:
   condition.wait()
  1. 如果列表未满,则添加一个单独的项目。状态被通知,资源被释放:
condition.notify()
condition.release()
  1. 为了向您展示条件机制,我们将再次使用 消费者/生产者 模型:
import logging
import threading
import time

LOG_FORMAT = '%(asctime)s %(threadName)-17s %(levelname)-8s %\
             (message)s'
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)

items = []
condition = threading.Condition()

class Consumer(threading.Thread):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def consume(self):

        with condition:

            if len(items) == 0:
                logging.info('no items to consume')
                condition.wait()

            items.pop()
            logging.info('consumed 1 item')

            condition.notify()

    def run(self):
        for i in range(20):
            time.sleep(2)
            self.consume()

class Producer(threading.Thread):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def produce(self):

        with condition:

            if len(items) == 10:
                logging.info('items produced {}.\
                    Stopped'.format(len(items)))
                condition.wait()

            items.append(1)
            logging.info('total items {}'.format(len(items)))

            condition.notify()

    def run(self):
        for i in range(20):
            time.sleep(0.5)
            self.produce()

它是如何工作的...

producer 持续生成项目并将其存储在缓冲区中。同时,consumer 使用生产出的数据,并时不时地从缓冲区中移除它。

一旦 consumer 从缓冲区中取走一个对象,它将唤醒 producer,然后 producer 将开始再次填充缓冲区。

类似地,如果缓冲区为空,consumer 将会挂起。一旦 producer 将数据下载到缓冲区,consumer 将会唤醒。

如您所见,即使在在这种情况下,使用 condition 指令也能使线程得到适当的同步。

单次运行后得到的结果如下:

2019-08-05 14:33:44,285 Producer INFO total items 1
2019-08-05 14:33:44,786 Producer INFO total items 2
2019-08-05 14:33:45,286 Producer INFO total items 3
2019-08-05 14:33:45,786 Consumer INFO consumed 1 item
2019-08-05 14:33:45,787 Producer INFO total items 3
2019-08-05 14:33:46,287 Producer INFO total items 4
2019-08-05 14:33:46,788 Producer INFO total items 5
2019-08-05 14:33:47,289 Producer INFO total items 6
2019-08-05 14:33:47,787 Consumer INFO consumed 1 item
2019-08-05 14:33:47,790 Producer INFO total items 6
2019-08-05 14:33:48,291 Producer INFO total items 7
2019-08-05 14:33:48,792 Producer INFO total items 8
2019-08-05 14:33:49,293 Producer INFO total items 9
2019-08-05 14:33:49,788 Consumer INFO consumed 1 item
2019-08-05 14:33:49,794 Producer INFO total items 9
2019-08-05 14:33:50,294 Producer INFO total items 10
2019-08-05 14:33:50,795 Producer INFO items produced 10\. Stopped
2019-08-05 14:33:51,789 Consumer INFO consumed 1 item
2019-08-05 14:33:51,790 Producer INFO total items 10
2019-08-05 14:33:52,290 Producer INFO items produced 10\. Stopped
2019-08-05 14:33:53,790 Consumer INFO consumed 1 item
2019-08-05 14:33:53,790 Producer INFO total items 10
2019-08-05 14:33:54,291 Producer INFO items produced 10\. Stopped
2019-08-05 14:33:55,790 Consumer INFO consumed 1 item
2019-08-05 14:33:55,791 Producer INFO total items 10
2019-08-05 14:33:56,291 Producer INFO items produced 10\. Stopped
2019-08-05 14:33:57,791 Consumer INFO consumed 1 item
2019-08-05 14:33:57,791 Producer INFO total items 10
2019-08-05 14:33:58,292 Producer INFO items produced 10\. Stopped
2019-08-05 14:33:59,791 Consumer INFO consumed 1 item
2019-08-05 14:33:59,791 Producer INFO total items 10
2019-08-05 14:34:00,292 Producer INFO items produced 10\. Stopped
2019-08-05 14:34:01,791 Consumer INFO consumed 1 item
2019-08-05 14:34:01,791 Producer INFO total items 10
2019-08-05 14:34:02,291 Producer INFO items produced 10\. Stopped
2019-08-05 14:34:03,791 Consumer INFO consumed 1 item
2019-08-05 14:34:03,792 Producer INFO total items 10
2019-08-05 14:34:05,792 Consumer INFO consumed 1 item
2019-08-05 14:34:07,793 Consumer INFO consumed 1 item
2019-08-05 14:34:09,794 Consumer INFO consumed 1 item
2019-08-05 14:34:11,795 Consumer INFO consumed 1 item
2019-08-05 14:34:13,795 Consumer INFO consumed 1 item
2019-08-05 14:34:15,833 Consumer INFO consumed 1 item
2019-08-05 14:34:17,833 Consumer INFO consumed 1 item
2019-08-05 14:34:19,833 Consumer INFO consumed 1 item
2019-08-05 14:34:21,834 Consumer INFO consumed 1 item
2019-08-05 14:34:23,835 Consumer INFO consumed 1 item

还有更多...

看到 Python 内部的条件同步机制非常有趣。内部class _Condition在类构造函数未传递现有锁时创建一个RLock()对象。此外,当调用acquire()released()时,将管理锁:

class _Condition(_Verbose):
    def __init__(self, lock=None, verbose=None):
        _Verbose.__init__(self, verbose)
        if lock is None:
            lock = RLock()
        self.__lock = lock

使用事件进行线程同步

事件是一个用于线程间通信的对象。一个线程等待信号,而另一个线程输出它。基本上,一个event对象管理一个内部标志,可以通过clear()将其设置为false,通过set()将其设置为true,并通过is_set()进行测试。

一个线程可以通过wait()方法持有信号,该方法通过set()方法发送调用。

准备工作

要通过事件对象理解线程同步,让我们看看生产者/消费者问题。

如何做...

再次,为了解释如何通过事件同步线程,我们将参考生产者/消费者问题。该问题描述了两个进程,一个生产者和一个消费者,他们共享一个固定大小的公共缓冲区。生产者的任务是生成项并将它们存入连续的缓冲区。同时,消费者将使用生产的项,并时不时地从缓冲区中移除它们。

问题在于确保生产者在缓冲区满时不处理新数据,而消费者在缓冲区空时不寻找数据。

现在,让我们看看如何通过使用事件语句进行线程同步来实现消费者/生产者问题:

  1. 在这里,相关库按如下方式导入:
import logging
import threading
import time
import random
  1. 然后,我们定义日志输出格式。这有助于清楚地可视化正在发生的事情:
LOG_FORMAT = '%(asctime)s %(threadName)-17s %(levelname)-8s %\
             (message)s'
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
  1. 设置items列表。此参数将由ConsumerProducer类使用:
items = []
  1. event参数定义如下。此参数将用于同步线程间的通信:
event = threading.Event()
  1. Consumer类使用项列表和Event()函数初始化。在run方法中,消费者等待要消费的新项。当项到达时,它从item列表中弹出:
class Consumer(threading.Thread):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def run(self):
        while True:
            time.sleep(2)
            event.wait()
            item = items.pop()
            logging.info('Consumer notify: {} popped by {}'\
                        .format(item, self.name))
  1. Producer类使用项列表和Event()函数初始化。与使用condition对象的示例不同,项列表不是全局的,而是作为参数传递:
class Producer(threading.Thread):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
  1. 在为每个创建的项的run方法中,Producer类将其追加到项列表中,然后通知事件:
    def run(self):
        for i in range(5):
            time.sleep(2)
            item = random.randint(0, 100)
            items.append(item)
            logging.info('Producer notify: item {} appended by\ 
                         {}'\.format(item, self.name))
  1. 您需要采取两个步骤来完成此操作,第一步如下:
            event.set()
            event.clear()
  1. t1线程将值追加到列表中,然后设置事件以通知消费者。消费者的wait()调用停止阻塞,并从列表中检索整数:
if __name__ == "__main__":
    t1 = Producer()
    t2 = Consumer()

    t1.start()
    t2.start()

    t1.join()
    t2.join()

它是如何工作的...

所有在ProducerConsumer类之间的操作都可以通过以下方案轻松恢复:

使用事件对象进行线程同步

特别是,ProducerConsumer类具有以下行为:

  • Producer获取一个锁,向队列中添加一个项目,并通过set event通知Consumer。然后它休眠,直到收到要添加的新项目。

  • Consumer获取一个块并开始在一个连续循环中监听元素。当事件到达时,消费者放弃该块,从而允许其他生产者/消费者进入并获取该块。如果Consumer被重新激活,那么它将通过安全地处理队列中的新项目来重新获取锁:

2019-02-02 18:23:35,125 Thread-1 INFO Producer notify: item 68 appended by Thread-1
2019-02-02 18:23:35,133 Thread-2 INFO Consumer notify: 68 popped by Thread-2
2019-02-02 18:23:37,138 Thread-1 INFO Producer notify: item 45 appended by Thread-1
2019-02-02 18:23:37,143 Thread-2 INFO Consumer notify: 45 popped by Thread-2
2019-02-02 18:23:39,148 Thread-1 INFO Producer notify: item 78 appended by Thread-1
2019-02-02 18:23:39,153 Thread-2 INFO Consumer notify: 78 popped by Thread-2
2019-02-02 18:23:41,158 Thread-1 INFO Producer notify: item 22 appended by Thread-1
2019-02-02 18:23:43,173 Thread-1 INFO Producer notify: item 48 appended by Thread-1
2019-02-02 18:23:43,178 Thread-2 INFO Consumer notify: 48 popped by Thread-2

使用屏障进行线程同步

有时,一个应用程序可以被划分为阶段,规则是如果首先,该进程的所有线程都完成了它们自己的任务,则没有进程可以继续。一个屏障实现了这个概念:一个完成了其阶段的线程调用原始屏障并停止。当所有涉及的线程都完成了它们的执行阶段并也调用了原始屏障后,系统将解锁它们,允许线程移动到后续阶段。

准备就绪

Python 的 threading 模块通过**Barrier**类实现屏障。在下一节中,我们将学习如何在一个非常简单的示例中使用这种同步机制。

如何做到这一点...

在这个例子中,我们模拟了一场有三名参与者HueyDeweyLouie的跑步比赛,其中屏障被类比为终点线。

此外,当所有三名参赛者都越过终点线时,比赛可以自行结束。

屏障是通过Barrier类实现的,其中必须指定要完成的线程数作为参数,以便移动到下一阶段:

from random import randrange
from threading import Barrier, Thread
from time import ctime, sleep

num_runners = 3
finish_line = Barrier(num_runners)
runners = ['Huey', 'Dewey', 'Louie']

def runner():
    name = runners.pop()
    sleep(randrange(2, 5))
    print('%s reached the barrier at: %s \n' % (name, ctime()))
    finish_line.wait()

def main():
    threads = []
    print('START RACE!!!!')
    for i in range(num_runners):
        threads.append(Thread(target=runner))
        threads[-1].start()
    for thread in threads:
        thread.join()
    print('Race over!')

if __name__ == "__main__":
    main()

它是如何工作的...

首先,我们将跑者的数量设置为num_runners = 3,以便通过Barrier指令在下一行设置最终目标。跑者被设置在跑者列表中;每个跑者的到达时间将在runner函数中使用randrange指令确定。

当一名跑者到达终点线时,调用wait方法,这将阻塞所有已经调用该方法的跑者(线程)。输出如下:

START RACE!!!!
Dewey reached the barrier at: Sat Feb 2 21:44:48 2019 

Huey reached the barrier at: Sat Feb 2 21:44:49 2019 

Louie reached the barrier at: Sat Feb 2 21:44:50 2019 

Race over!

在这种情况下,Dewey赢得了比赛。

使用队列进行线程通信

当线程需要共享数据或资源时,多线程可能会变得复杂。幸运的是,threading 模块提供了许多同步原语,包括信号量、条件变量、事件和锁。

然而,使用queue模块被认为是一种最佳实践。实际上,队列更容易处理,并且使得线程编程更加安全,因为它有效地将所有对单个线程资源的访问引导到一个方向,并允许设计出更清晰、更易读的模式。

准备就绪

我们将简单地考虑这些队列方法:

  • put(): 将一个项目放入队列

  • get(): 从队列中移除并返回一个项目

  • task_done(): 需要在每次处理完一个项目时调用

  • join(): 阻塞直到所有项目都已被处理

如何做到这一点...

在这个例子中,我们将看到如何使用threading模块与queue模块。此外,这里有两个实体试图共享一个公共资源,一个队列。代码如下:

from threading import Thread
from queue import Queue
import time
import random

class Producer(Thread):
    def __init__(self, queue):
        Thread.__init__(self)
        self.queue = queue
    def run(self):
        for i in range(5):
            item = random.randint(0, 256)
            self.queue.put(item)
            print('Producer notify : item N°%d appended to queue by\ 
                  %s\n'\
                  % (item, self.name))
            time.sleep(1)

class Consumer(Thread):
    def __init__(self, queue):
        Thread.__init__(self)
        self.queue = queue

    def run(self):
        while True:
            item = self.queue.get()
            print('Consumer notify : %d popped from queue by %s'\
                  % (item, self.name))
            self.queue.task_done()

if __name__ == '__main__':
    queue = Queue()
    t1 = Producer(queue)
    t2 = Consumer(queue)
    t3 = Consumer(queue)
    t4 = Consumer(queue)

    t1.start()
    t2.start()
    t3.start()
    t4.start()

    t1.join()
    t2.join()
    t3.join()
    t4.join()

它是如何工作的...

首先,使用producer类,我们不需要传递整数列表,因为我们使用队列来存储生成的整数。

producer类中的线程生成整数,并在for循环中将它们放入队列中。producer类使用Queue.put(item[, block[, timeout]])在队列中插入数据。它具有在队列中插入数据之前获取锁的逻辑。

有两种可能性:

  • 如果可选参数blocktruetimeoutNone(这是我们示例中使用的默认情况),那么我们需要阻塞,直到有空闲槽位可用。如果timeout是一个正数,那么它最多阻塞timeout秒,如果在那个时间内没有空闲槽位,则引发满异常。

  • 如果blockfalse,那么如果立即有空闲槽位,则将项目放入队列中,否则引发满异常(在这种情况下忽略超时)。在这里,put检查队列是否已满,然后内部调用wait,之后生产者开始等待。

接下来是consumer类。线程从队列中获取整数,并通过使用task_done来指示它已完成对该整数的工作。consumer类使用Queue.get([block[, timeout]])并在从队列中移除数据之前获取锁。如果队列是空的,消费者将被置于等待状态。最后,在main函数中,我们创建了四个线程,一个用于producer类,三个用于consumer类。

输出应该像这样:

Producer notify : item N°186 appended to queue by Thread-1
Consumer notify : 186 popped from queue by Thread-2

Producer notify : item N°16 appended to queue by Thread-1
Consumer notify : 16 popped from queue by Thread-3

Producer notify : item N°72 appended to queue by Thread-1
Consumer notify : 72 popped from queue by Thread-4

Producer notify : item N°178 appended to queue by Thread-1
Consumer notify : 178 popped from queue by Thread-2

Producer notify : item N°214 appended to queue by Thread-1
Consumer notify : 214 popped from queue by Thread-3

更多...

producer类和consumer类之间的所有操作都可以使用以下方案轻松恢复:

图片

使用队列模块进行线程同步

  • Producer线程获取锁,然后在队列数据结构中插入数据。

  • Consumer线程从队列中获取整数。这些线程在从队列中移除数据之前获取锁。

如果队列为空,那么consumer线程将进入等待状态。

使用这个配方,关于基于线程的并行主义的章节就此结束。

第三章:基于进程的并行

在上一章中,我们学习了如何使用线程来实现并发应用程序。本章将检查我们在第一章,“Python 并行计算入门”中介绍的过程方法。特别是,本章的重点是 Python 的multiprocessing模块。

Python 的multiprocessing模块,作为语言标准库的一部分,实现了共享内存编程范式,即由一个或多个具有访问共享内存的处理器组成的系统的编程。

在本章中,我们将介绍以下食谱:

  • 理解 Python 的multiprocessing模块

  • 启动一个进程

  • 命名一个进程

  • 在后台运行进程

  • 终止进程

  • 在子类中定义进程

  • 使用队列交换对象

  • 使用管道交换对象

  • 同步进程

  • 在进程之间管理状态

  • 使用进程池

理解 Python 的multiprocessing模块

Python multiprocessing 文档的介绍(docs.python.org/2.7/library/multiprocessing.html#introduction)明确指出,此包内的所有功能都需要main模块可被子进程导入(docs.python.org/3.3/library/multiprocessing.html)。

在 IDLE 中,__main__模块不可被子进程导入,即使你以文件形式使用 IDLE 运行脚本。为了得到正确的结果,我们将从命令提示符运行所有示例:

> python multiprocessing_example.py

这里,multiprocessing_example.py是脚本的名称。

启动进程

启动一个进程是从父进程创建子进程的过程。后者异步执行或等待子进程结束。

准备工作

multiprocessing库允许通过以下步骤启动进程:

  1. 定义进程对象。

  2. 调用进程的start()方法来运行它。

  3. 调用进程的join()方法。它等待进程完成工作后退出。

如何做到...

让我们看看以下步骤:

  1. 要创建一个进程,我们需要使用以下命令导入multiprocessing模块:
import multiprocessing
  1. 每个进程都与myFunc(i)函数相关联。此函数输出从0i的数字,其中i是与进程号关联的 ID:
def myFunc(i):
    print ('calling myFunc from process n°: %s' %i)
    for j in range (0,i):
        print('output from myFunc is :%s' %j)

  1. 然后,我们使用myFunc作为target函数定义process对象:
if __name__ == '__main__':
    for i in range(6):
        process = multiprocessing.Process(target=myFunc, args=(i,))
  1. 最后,我们在创建的进程上调用startjoin方法:
     process.start()
     process.join()

没有使用join方法,子进程不会结束,必须手动终止。

它是如何工作的...

因此,在本节中,我们已经看到如何从父进程开始创建进程。这个特性被称为启动进程

Python 的 multiprocessing 库通过以下三个简单步骤允许轻松地管理进程。第一步是通过 multiprocessing 类方法 Process 进行进程定义:

process = multiprocessing.Process(target=myFunc, args=(i,))

Process 方法将 myFunc 函数作为要创建的函数的参数,以及函数本身的任何参数。

执行和退出进程的以下两个步骤是必要的:

     process.start()
     process.join()

要运行进程并显示结果,让我们打开命令提示符,最好是在包含示例文件(spawning_processes.py)的同一文件夹中,然后输入以下命令:

> python spawning_processes.py

对于创建的每个进程(总共有六个),都会显示目标函数的输出。记住,这是一个从 0 到进程 ID 索引的简单计数器:

calling myFunc from process n°: 0
calling myFunc from process n°: 1
output from myFunc is :0
calling myFunc from process n°: 2
output from myFunc is :0
output from myFunc is :1
calling myFunc from process n°: 3
output from myFunc is :0
output from myFunc is :1
output from myFunc is :2
calling myFunc from process n°: 4
output from myFunc is :0
output from myFunc is :1
output from myFunc is :2
output from myFunc is :3
calling myFunc from process n°: 5
output from myFunc is :0
output from myFunc is :1
output from myFunc is :2
output from myFunc is :3
output from myFunc is :4

还有更多...

这一次又一次提醒我们实例化 Process 对象在主部分的重要性:这是因为创建的子进程会导入包含 target 函数的脚本文件。然后,通过在这个块中实例化 process 对象,我们防止了这种实例化的无限递归调用。

使用有效的替代方案在另一个脚本中定义 target 函数,即 myFunc.py

def myFunc(i):
    print ('calling myFunc from process n°: %s' %i)
    for j in range (0,i):
        print('output from myFunc is :%s' %j)
    return

包含进程实例的 main 程序定义在第二个文件(spawning_processes_namespace.py)中:

import multiprocessing
from myFunc import myFunc

if __name__ == '__main__':
    for i in range(6):
        process = multiprocessing.Process(target=myFunc, args=(i,))
        process.start()
        process.join()

要运行此示例,请输入以下命令:

> python spawning_processes_names.py

输出与上一个示例相同。

参见

multiprocessing 库的官方指南可以在 docs.python.org/3/ 找到。

命名进程

在前面的示例中,我们确定了进程以及如何将变量传递给目标函数。然而,将名称与进程关联非常有用,因为调试应用程序需要进程有良好的标记和可识别性。

准备工作

在你的代码的某个地方,可能需要知道当前正在执行哪个进程。为此,multiprocessing 库提供了 current_process() 方法,它使用 name 属性来识别当前正在运行的进程。在下一节中,我们将学习这个主题。

如何做到...

让我们执行以下步骤:

  1. 两个进程的目标函数都是 myFunc 函数。它通过评估 multiprocessing.current_process().name 方法输出进程名称:
import multiprocessing
import time

def myFunc():
    name = multiprocessing.current_process().name
    print ("Starting process name = %s \n" %name)
    time.sleep(3)
    print ("Exiting process name = %s \n" %name)
  1. 然后,我们通过实例化 name 参数和 process_with_default_name 创建 process_with_name
if __name__ == '__main__':
    process_with_name = multiprocessing.Process\
                        (name='myFunc process',\
                          target=myFunc)

    process_with_default_name = multiprocessing.Process\
                                (target=myFunc)
  1. 最后,启动进程并等待它们完成:
    process_with_name.start()
    process_with_default_name.start()
    process_with_name.join()
    process_with_default_name.join()

它是如何工作的...

main 程序中,使用相同的目标函数 myFunc 创建进程。这个函数简单地打印进程名称。

要运行示例,打开命令提示符并输入以下命令:

> python naming_processes.py

输出看起来像这样:

Starting process name = myFunc process
Starting process name = Process-2

Exiting process name = Process-2
Exiting process name = myFunc process

还有更多...

主 Python 进程是 multiprocessing.process._MainProcess,而子进程是 multiprocessing.process.Process。可以通过简单地输入以下内容进行测试:

>>> import multiprocessing
>>> multiprocessing.current_process().name
'MainProcess'

相关信息

更多关于这个主题的信息可以在doughellmann.com/blog/2012/04/30/determining-the-name-of-a-process-from-python/找到。

在后台运行进程

在后台运行是某些不需要用户存在或干预的程序执行模式,并且可能与其他程序的执行并发(因此,它仅在多任务系统中才可行),导致用户对此一无所知。后台程序通常执行长时间或耗时的任务,如对等文件共享程序或文件系统的碎片整理。许多操作系统进程也在后台运行。

在 Windows 中,此模式下的程序(如扫描杀毒软件或操作系统更新)通常在系统托盘(系统时钟旁边的桌面区域)中放置一个图标,以表示其活动并采用减少资源使用的行为,以便不干扰用户的交互式活动,例如减慢速度或造成中断。在 Unix 和 Unix-like 系统中,在后台运行的进程被称为守护进程。使用任务管理器可以突出显示所有正在运行的程序,包括后台程序。

准备工作

multiprocessing模块通过守护选项允许运行后台进程。在以下示例中,定义了两个进程:

  • background_processdaemon参数设置为True

  • NO_background_processdaemon参数设置为False

如何实现...

在以下示例中,我们实现了一个目标函数,即foo,如果子进程在后台,则显示从04的数字;否则,打印从59的数字:

  1. 让我们导入相关的库:
import multiprocessing
import time
  1. 然后,我们定义foo()函数。如前所述,打印的数字取决于name参数的值:
def foo():
    name = multiprocessing.current_process().name
    print ("Starting %s \n" %name)
    if name == 'background_process':
        for i in range(0,5):
            print('---> %d \n' %i)
        time.sleep(1)
    else:
        for i in range(5,10):
            print('---> %d \n' %i)
        time.sleep(1)
    print ("Exiting %s \n" %name)
  1. 最后,我们定义以下进程:background_processNO_background_process。注意,这两个进程的daemon参数都设置了:
if __name__ == '__main__':
    background_process = multiprocessing.Process\
                         (name='background_process',\
                          target=foo)
    background_process.daemon = True

    NO_background_process = multiprocessing.Process\
                            (name='NO_background_process',\
                             target=foo)

    NO_background_process.daemon = False

    background_process.start()
    NO_background_process.start()

工作原理...

注意,只有进程的daemon参数定义了进程是否应在后台运行。要运行此示例,请输入以下命令:

> python run_background_processes.py

输出清楚地报告了只有NO_background_process的输出:

Starting NO_background_process
---> 5

---> 6

---> 7

---> 8

---> 9
Exiting NO_background_process

输出将background_processdaemon参数设置为False

background_process.daemon = False

运行此示例,请输入以下命令:

C:\>python run_background_processes_no_daemons.py

输出报告了background_processNO_background_process进程的执行:

Starting NO_background_process
Starting background_process
---> 5

---> 0
---> 6

---> 1
---> 7

---> 2
---> 8

---> 3
---> 9

---> 4

Exiting NO_background_process
Exiting background_process

相关信息

在 Linux 中后台运行 Python 脚本的代码片段可以在janakiev.com/til/python-background/找到。

终止进程

没有完美的软件,即使在最好的应用中,你也可能嵌套一个会导致应用阻塞的错误,这就是为什么现代操作系统开发了多种方法来终止应用程序的进程,以便尽快释放系统资源并允许用户将它们用于其他操作。本节将向您展示如何在您的多进程应用程序中终止进程。

准备工作

可以通过使用terminate方法立即终止进程。此外,我们还使用is_alive方法来跟踪进程是否存活。

如何实现...

以下步骤允许我们执行配方:

  1. 让我们导入相关的库:
import multiprocessing
import time
  1. 然后,实现一个简单的target函数。在这个例子中,target函数foo()打印前10位数字:
def foo():
    print ('Starting function')
    for i in range(0,10):
        print('-->%d\n' %i)
        time.sleep(1)
    print ('Finished function')
  1. main程序中,我们通过is_alive方法创建一个进程来监控其生命周期;然后,我们通过调用terminate来结束它:
if __name__ == '__main__':
    p = multiprocessing.Process(target=foo)
    print ('Process before execution:', p, p.is_alive())
    p.start()
    print ('Process running:', p, p.is_alive())
    p.terminate()
    print ('Process terminated:', p, p.is_alive())
    p.join()
    print ('Process joined:', p, p.is_alive())
  1. 然后,我们验证进程完成时的状态代码并读取ExitCode进程的属性:
    print ('Process exit code:', p.exitcode)
  1. ExitCode的可能值如下:
  • == 0:没有产生错误。

  • > 0:进程出现错误并退出该代码。

  • < 0:进程被带有-1 * ExitCode信号的信号终止。

它是如何工作的...

示例代码由一个目标函数foo()组成,其任务是打印屏幕上的前10个整数。在main程序中,进程被执行,然后通过terminate指令终止。进程随后被连接,并确定ExitCode

要运行代码,请输入以下命令:

> python killing_processes.py

然后,我们得到以下输出:

Process before execution: <Process(Process-1, initial)> False
Process running: <Process(Process-1, started)> True
Process terminated: <Process(Process-1, started)> True
Process joined: <Process(Process-1, stopped[SIGTERM])> False
Process exit code: -15

注意到ExitCode代码的输出值等于**-**15-15的负值表示子进程被中断信号终止,该信号由数字15标识。

参见

在 Linux 机器上,可以通过遵循www.cagrimmett.com/til/2016/05/06/killing-rogue-python-processes.html中的教程来识别并终止 Python 进程。

在子类中定义进程

multiprocessing模块提供了对进程管理功能的访问。在本节中,我们将了解如何在multiprocessing.Process类的子类中定义进程。

准备工作

要实现多进程自定义子类,我们需要做以下事情:

  • 定义 multiprocessing.Process类的子类,重新定义run()方法。

  • 覆盖 _init_(self [,args])方法以添加所需的额外参数。

  • 覆盖 run(self [,args])方法以实现进程启动时应执行的操作。

一旦创建了新的Process子类,就可以创建其实例,然后通过调用start方法开始执行,这将反过来调用run方法。

如何实现...

仅考虑一个非常简单的例子,如下所示:

  1. 首先导入相关库:
import multiprocessing
  1. 然后,定义一个子类,MyProcess,只覆盖run方法,该方法返回进程的名称:
class MyProcess(multiprocessing.Process):

    def run(self):
        print ('called run method by %s' %self.name)
        return
  1. main程序中,我们定义了10个进程的子类:
if __name__ == '__main__':
    for i in range(10):
        process = MyProcess()
        process.start()
        process.join()

它是如何工作的...

每个进程子类都由一个扩展Process类并覆盖run()方法的类表示。这是Process的起点:

class MyProcess (multiprocessing.Process):
    def run(self):
        print ('called run method in process: %s' %self.name)
        return

main程序中,我们创建了几个MyProcess()类型的对象。线程的执行开始于调用start()方法时:

p = MyProcess()
p.start()

join()命令仅处理进程的终止。要从命令提示符运行脚本,请输入以下命令:

> python process_in_subclass.py

输出看起来像这样:

called run method by MyProcess-1
called run method by MyProcess-2
called run method by MyProcess-3
called run method by MyProcess-4
called run method by MyProcess-5
called run method by MyProcess-6
called run method by MyProcess-7
called run method by MyProcess-8
called run method by MyProcess-9
called run method by MyProcess-10

还有更多...

在面向对象编程中,子类是从超类继承所有属性(无论是对象还是方法)的类。子类的另一个名称是派生类继承是表示子类或派生类继承父类或超类属性的具体术语。

你可以将子类视为其超类的一个特定流派;实际上,它可以使用方法和/或属性,并通过重写来重新定义它们。

参见

更多关于类定义技术的信息可以在buildingskills.itmaybeahack.com/book/python-2.6/html/p03/p03c02_adv_class.html找到。

使用队列交换数据

队列是一种先进先出FIFO)类型的数据结构(第一个输入的是第一个退出)。一个实际的例子是获取服务的队列,如何在超市付款,或者理发师那里理发。理想情况下,你被服务的顺序与你被呈现的顺序相同。这正是 FIFO 队列的工作方式。

准备工作

在本节中,我们向您展示如何使用队列来解决生产者-消费者问题,这是一个经典的进程同步示例。

生产者-消费者问题描述了两个进程:一个是生产者,另一个是消费者,它们共享一个固定大小公共缓冲区。

生产者的任务是持续生成数据并将其存入缓冲区。同时,消费者将使用生产出的数据,并时不时地从缓冲区中移除它。问题在于确保当缓冲区满时,生产者不处理新数据,而当缓冲区空时,消费者不寻找数据。对于生产者的解决方案是,如果缓冲区满了,就暂停其执行。

一旦消费者从缓冲区中取出一个项目,生产者就会醒来并开始再次填充缓冲区。同样,如果缓冲区为空,消费者将暂停。一旦生产者将数据下载到缓冲区中,消费者就会醒来。

如何做到这一点...

此解决方案可以通过进程之间的通信策略、共享内存或消息传递来实现。一个不正确的解决方案可能导致死锁,其中两个进程都在等待被唤醒:

import multiprocessing
import random
import time

让我们按照以下步骤执行:

  1. producer类负责通过put方法将10个项目放入队列:
class producer(multiprocessing.Process):
    def __init__(self, queue):
        multiprocessing.Process.__init__(self)
        self.queue = queue

    def run(self) :
        for i in range(10):
            item = random.randint(0, 256)
            self.queue.put(item) 
            print ("Process Producer : item %d appended \
                   to queue %s"\
                   % (item,self.name))
            time.sleep(1)
            print ("The size of queue is %s"\
                   % self.queue.qsize())
  1. consumer类的任务是移除队列中的项目(使用get方法)并验证队列不为空。如果发生这种情况,则while循环中的流程将使用break语句结束:
class consumer(multiprocessing.Process):
    def __init__(self, queue):
        multiprocessing.Process.__init__(self)
        self.queue = queue

    def run(self):
        while True:
            if (self.queue.empty()):
                print("the queue is empty")
                break
            else :
                time.sleep(2)
                item = self.queue.get()
                print ('Process Consumer : item %d popped \
                        from by %s \n'\
                       % (item, self.name))
                time.sleep(1)
  1. multiprocessing类在main程序中实例化了其queue对象:
if __name__ == '__main__':
        queue = multiprocessing.Queue()
        process_producer = producer(queue)
        process_consumer = consumer(queue)
        process_producer.start()
        process_consumer.start()
        process_producer.join()
        process_consumer.join()

它是如何工作的...

main程序中,我们使用multiprocessing.Queue对象定义队列。然后,它作为参数传递给producerconsumer进程:

        queue = multiprocessing.Queue()
        process_producer = producer(queue)
        process_consumer = consumer(queue)

producer类中,使用queue.put方法将新项目追加到队列中:

self.queue.put(item) 

consumer类中,使用queue.get方法取出项目:

self.queue.get()

通过输入以下命令执行代码:

> python communicating_with_queue.py

以下输出报告了生产者和消费者之间的交互:

Process Producer : item 79 appended to queue producer-1
The size of queue is 1
Process Producer : item 50 appended to queue producer-1
The size of queue is 2
Process Consumer : item 79 popped from by consumer-2
Process Producer : item 33 appended to queue producer-1
The size of queue is 2
Process Producer : item 57 appended to queue producer-1
The size of queue is 3
Process Producer : item 227 appended to queue producer-1
Process Consumer : item 50 popped from by consumer-2
The size of queue is 3
Process Producer : item 98 appended to queue producer-1
The size of queue is 4
Process Producer : item 64 appended to queue producer-1
The size of queue is 5
Process Producer : item 182 appended to queue producer-1
Process Consumer : item 33 popped from by consumer-2
The size of queue is 5
Process Producer : item 206 appended to queue producer-1
The size of queue is 6
Process Producer : item 214 appended to queue producer-1
The size of queue is 7
Process Consumer : item 57 popped from by consumer-2
Process Consumer : item 227 popped from by consumer-2
Process Consumer : item 98 popped from by consumer-2
Process Consumer : item 64 popped from by consumer-2
Process Consumer : item 182 popped from by consumer-2
Process Consumer : item 206 popped from by consumer-2
Process Consumer : item 214 popped from by consumer-2
the queue is empty

还有更多...

队列有JoinableQueue子类。这提供了以下方法:

  • task_done(): 此方法表示任务已完成,例如,在使用get()方法从队列中获取项目之后。因此,task_done()方法只能由队列消费者使用。

  • join(): 此方法阻塞进程,直到队列中的所有项目都已完成并处理。

参见

一个关于如何使用队列的好教程可在www.pythoncentral.io/use-queue-beginners-guide/找到。

使用管道交换对象

管道执行以下操作:

  • 它返回一个通过管道连接的连接对象对。

  • 每个连接对象都必须有发送/接收方法,以便在进程之间进行通信。

准备工作

multiprocessing库允许您使用multiprocessing.Pipe (duplex)函数实现管道数据结构。这返回一个对象对(conn1, conn2),代表管道的末端。

duplex参数确定最后一个案例的管道是否为双向(即duplex = True),或单向(即duplex = False)。conn1只能用于接收消息,而conn2只能用于发送消息。

现在,让我们看看如何使用管道交换对象。

如何做...

这里有一个简单的管道示例。我们有一个输出从09数字的进程管道,还有一个接收这些数字并将它们平方的第二个进程管道:

  1. 让我们导入multiprocessing库:
import multiprocessing
  1. pipe函数返回一个由双向管道连接的连接对象对。在示例中,out_pipe包含由create_itemstarget函数生成的从09的数字:
def create_items(pipe):
    output_pipe, _ = pipe
    for item in range(10):
        output_pipe.send(item)
    output_pipe.close()
  1. multiply_items函数基于两个管道pipe_1pipe_2
 def multiply_items(pipe_1, pipe_2):
    close, input_pipe = pipe_1
    close.close()
    output_pipe, _ = pipe_2
    try:
        while True:
            item = input_pipe.recv()
  1. 此函数返回每个管道元素的乘积:
           output_pipe.send(item * item)
 except EOFError:
        output_pipe.close()
  1. main程序中定义了pipe_1pipe_2
if __name__== '__main__':
  1. 首先,处理pipe_1,使用从09的数字:
    pipe_1 = multiprocessing.Pipe(True)
    process_pipe_1 = \
                   multiprocessing.Process\
                   (target=create_items, args=(pipe_1,))
    process_pipe_1.start()
  1. 然后,处理pipe_2,它从pipe_1中获取数字并将它们平方:
    pipe_2 = multiprocessing.Pipe(True)
    process_pipe_2 = \
                   multiprocessing.Process\
                   (target=multiply_items, args=(pipe_1, pipe_2,))
    process_pipe_2.start()
  1. 关闭进程:
    pipe_1[0].close()
    pipe_2[0].close()
  1. 打印出结果:
    try:
        while True:
            print (pipe_2[1].recv())
    except EOFError:
        print("End")

它是如何工作的...

实际上,两个管道pipe_1pipe_2是通过multiprocessing.Pipe(True)语句创建的:

pipe_1 = multiprocessing.Pipe(True)
pipe_2 = multiprocessing.Pipe(True)

第一个管道pipe_1简单地创建了一个从09的整数列表,而第二个管道pipe_2则处理由pipe_1创建的列表中的每个元素,计算每个元素的平方值:

process_pipe_2 = \
                   multiprocessing.Process\
                   (target=multiply_items, args=(pipe_1, pipe_2,))

因此,两个进程都被关闭:

pipe_1[0].close()
pipe_2[0].close()

最终结果被打印出来:

print (pipe_2[1].recv())

通过输入以下命令执行代码:

> python communicating_with_pipe.py

以下结果显示了前9个数字的平方:

0
1
4
9
16
25
36
49
64
81

还有更多...

如果你需要超过两个点来通信,那么使用Queue()方法。然而,如果你需要绝对性能,那么Pipe()方法要快得多,因为Queue()是建立在Pipe()之上的

相关内容

更多关于 Python 和管道的信息可以在www.python-course.eu/pipes.php找到。

同步进程

多个进程可以协同工作以执行给定的任务。通常,它们共享数据。确保各种进程对共享数据的访问不会产生不一致的数据非常重要。因此,通过共享数据合作的进程必须以有序的方式行动,以便该数据可访问。同步原语与在库和线程中遇到的类似。

同步原语如下:

  • :此对象可以是锁定或解锁状态。锁定对象有两个方法,acquire()release(),用于管理对共享资源的访问。

  • 事件:此对象实现了进程之间的简单通信;一个进程发出事件,而其他进程等待它。事件对象有两个方法,set()clear(),用于管理其内部标志。

  • 条件:此对象用于同步工作流程的各个部分,在顺序或并行过程中。它有两个基本方法:wait()用于等待条件,而notify_all()用于传达应用的条件。

  • 信号量:这用于共享一个公共资源,例如,支持固定数量的同时连接。

  • 可重入锁(RLock):这定义了递归锁对象。RLock 的方法和功能与threading模块相同。

  • 屏障:它将程序划分为阶段,因为它要求所有进程在继续之前都必须到达屏障。在屏障之后执行的代码不能与屏障之前执行的代码并发。

准备中

Python 中的屏障对象用于在给定线程可以继续执行程序之前等待固定数量的线程执行完成。

以下示例展示了如何使用barrier()对象同步同时任务。

如何做...

让我们考虑四个进程,其中进程p1p2由屏障语句管理,而进程p3p4没有同步指令。

要做到这一点,请执行以下步骤:

  1. 导入相关库:
import multiprocessing
from multiprocessing import Barrier, Lock, Process
from time import time
from datetime import datetime
  1. test_with_barrier函数执行屏障的wait()方法:
def test_with_barrier(synchronizer, serializer):
    name = multiprocessing.current_process().name
    synchronizer.wait()
    now = time()
  1. 当两个进程都调用了wait()方法时,它们将同时释放:
with serializer:
    print("process %s ----> %s" \
        %(name,datetime.fromtimestamp(now)))

def test_without_barrier():
    name = multiprocessing.current_process().name
    now = time()
    print("process %s ----> %s" \
        %(name ,datetime.fromtimestamp(now)))
  1. main程序中,我们创建了四个进程。然而,我们还需要一个屏障和锁原语。Barrier语句中的2参数代表要管理的进程总数:
if __name__ == '__main__':
    synchronizer = Barrier(2)
    serializer = Lock()
    Process(name='p1 - test_with_barrier'\
            ,target=test_with_barrier,\
            args=(synchronizer,serializer)).start()
    Process(name='p2 - test_with_barrier'\
            ,target=test_with_barrier,\
            args=(synchronizer,serializer)).start()
    Process(name='p3 - test_without_barrier'\
            ,target=test_without_barrier).start()
    Process(name='p4 - test_without_barrier'\
            ,target=test_without_barrier).start()

它是如何工作的...

Barrier对象提供了 Python 同步技术之一,它允许单个或多个线程等待直到一组活动中的某个点,并一起进行进度。

main程序中,通过以下语句定义了Barrier对象(即同步器):

synchronizer = Barrier(2)

注意,括号内的数字2表示屏障应等待的进程数。

然后,我们实现了一组四个进程,但只为p1p2进程。请注意,synchronizer作为参数传递:

Process(name='p1 - test_with_barrier'\
            ,target=test_with_barrier,\
            args=(synchronizer,serializer)).start()
Process(name='p2 - test_with_barrier'\
            ,target=test_with_barrier,\
            args=(synchronizer,serializer)).start()

的确,在test_with_barrier函数的主体中,屏障的wait()方法被用来同步进程:

synchronizer.wait()

通过运行脚本,我们可以看到p1p2进程打印出预期的相同时间戳:

> python processes_barrier.py
process p4 - test_without_barrier ----> 2019-03-03 08:58:06.159882
process p3 - test_without_barrier ----> 2019-03-03 08:58:06.144257
process p1 - test_with_barrier ----> 2019-03-03 08:58:06.175505
process p2 - test_with_barrier ----> 2019-03-03 08:58:06.175505

还有更多...

以下图表展示了两个进程如何与屏障一起工作:

图片

使用屏障进行进程管理

参见

请阅读pymotw.com/2/multiprocessing/communication.html以获取更多进程同步的示例。

使用进程池

进程池机制允许跨多个输入值并行执行函数,在进程之间分配输入数据。因此,进程池允许实现所谓的数据并行性,这是基于通过不同进程的数据分布来实现的,这些进程并行地对数据进行操作。

准备工作

multiprocessing库提供了Pool类,用于简单的并行处理任务。

Pool类有以下方法:

如何操作...

此示例展示了如何实现进程池以执行并行应用。我们创建了一个包含四个进程的池,然后我们使用池的 map 方法执行一个简单函数:

  1. 导入 multiprocessing 库:
import multiprocessing
  1. Pool 方法将 function_square 函数应用于输入元素以执行简单计算:
def function_square(data):
    result = data*data
    return result

if __name__ == '__main__':
  1. 参数输入是一个从 0100 的整数列表:
    inputs = list(range(0,100))
  1. 并行进程的总数是 4
    pool = multiprocessing.Pool(processes=4)
  1. pool.map 方法将任务提交给进程池:
    pool_outputs = pool.map(function_square, inputs)
    pool.close() 
    pool.join() 
  1. 计算结果存储在 pool_outputs 中:
    print ('Pool    :', pool_outputs)

重要的是要注意,pool.map() 方法的输出与 Python 内置的 map() 函数的结果等效,只是进程是并行运行的。

工作原理...

在这里,我们使用以下语句创建了一个包含四个进程的池:

  pool = multiprocessing.Pool(processes=4)

每个进程都有一个整数列表作为输入。在这里,pool.mapmap 的工作方式相同,但使用多个进程,其数量,即四个,是在创建池时预先定义的:

   pool_outputs = pool.map(function_square, inputs)

要终止进程池的计算,使用常规的 closejoin 函数:

    pool.close() 
    pool.join() 

要执行此操作,请输入以下命令:

> python process_pool.py

这是完成计算后得到的结果:

Pool : [0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225, 256, 289, 324, 361, 400, 441, 484, 529, 576, 625, 676, 729, 784, 841, 900, 961, 1024, 1089, 1156, 1225, 1296, 1369, 1444, 1521, 1600, 1681, 1764, 1849, 1936, 2025, 2116, 2209, 2304, 2401, 2500, 2601, 2704, 2809, 2916, 3025, 3136, 3249, 3364, 3481, 3600, 3721, 3844, 3969, 4096, 4225, 4356, 4489, 4624, 4761, 4900, 5041, 5184, 5329, 5476, 5625, 5776, 5929, 6084, 6241, 6400, 6561, 6724, 6889, 7056, 7225, 7396, 7569, 7744, 7921, 8100, 8281, 8464, 8649, 8836, 9025, 9216, 9409, 9604, 9801]

还有更多...

在前面的例子中,我们看到 Pool 也提供了 map 方法,这允许我们将函数应用于不同的数据集。特别是,在输入元素上并行执行相同操作的场景被称为数据并行

在以下示例中,我们使用 Poolmap,我们创建了一个包含 5 个工作者的 pool,并通过 map 方法将 f 函数应用于一个包含 10 个元素的列表:

from multiprocessing import Pool

def f(x):
    return x+10

if __name__ == '__main__':
     p=Pool(processes=5)
     print(p.map(f, [1, 2, 3,5,6,7,8,9,10]))

输出如下:

11 12 13 14 15 16 17 18 19 20

参考信息

要了解更多关于进程池的信息,请使用以下链接:www.tutorialspoint.com/concurrency_in_python/concurrency_in_python_pool_of_processes.htm

第四章:消息传递

本章将简要介绍消息传递接口MPI),这是一个用于消息交换的规范。MPI 的主要目标是建立一个高效、灵活且可移植的消息交换通信标准。

主要地,我们将展示库中的函数,包括同步和异步通信原语(如(发送/接收)和(广播/全对全)),组合计算部分结果的运算(收集/归约),以及最后,进程间的同步原语(屏障)。

此外,将通过定义拓扑来展示通信网络的控制函数。

本章我们将介绍以下内容:

  • 使用mpi4pyPython 模块

  • 实现点对点通信

  • 避免死锁问题

  • 使用广播进行集体通信

  • 使用scatter函数进行集体通信

  • 使用gather函数进行集体通信

  • 使用Alltoall进行集体通信

  • 归约操作

  • 优化通信

技术要求

您需要为本章使用mpichmpi4py库。

mpich库是 MPI 的可移植实现。它是免费软件,适用于 Unix 的各种版本(包括 Linux 和 macOS)以及 Microsoft Windows。

要安装mpich,请使用从下载页面下载的安装程序(www.mpich.org/static/downloads/1.4.1p1/)。此外,请确保选择 32 位或 64 位版本,以获取适合您机器的正确版本。

mpi4pyPython 模块为 MPI 标准(www.mpi-forum.org)提供了 Python 绑定。它基于 MPI-1/2/3 规范实现,并公开了一个基于标准 MPI-2 C++绑定的 API。

在 Windows 机器上安装mpi4py的步骤如下:

C:>pip install mpi4py

Anaconda 用户必须输入以下内容:

C:>conda install mpi4py

注意,本章中的所有示例都使用了通过pip安装器安装的mpi4py

这意味着运行mpi4py示例所使用的符号表示如下:

C:>mpiexec -n x python mpi4py_script_name.py 

mpiexec命令是启动并行作业的典型方式:x是要使用的总进程数,而mpi4py_script_name.py是要执行的脚本的名称。

理解 MPI 结构

MPI 标准定义了管理虚拟拓扑、同步和进程间通信的原语。有几个 MPI 实现,它们在支持的标准的版本和功能上有所不同。

我们将通过 Python 的mpi4py库介绍 MPI 标准。

在 20 世纪 90 年代之前,为不同的架构编写并行应用程序比今天更困难。许多库简化了这一过程,但没有一个标准的方法来做这件事。当时,大多数并行应用程序都是为科学研究环境而设计的。

各个库最常采用的模型是消息传递模型,在这种模型中,进程间的通信是通过消息交换来进行的,而不使用共享资源。例如,主进程可以通过发送一个描述要完成的工作的消息来简单地分配一个任务给从属进程。另一个非常简单的例子是一个并行应用程序,它执行归并排序。数据在本地对进程进行排序,然后将结果传递给其他进程,这些进程将处理合并。

由于库在很大程度上使用了相同的模型,尽管彼此之间有细微的差异,但各个库的作者于 1992 年聚会,以定义一个用于消息交换的标准接口,从这里,MPI 诞生了。这个接口必须允许程序员在大多数并行架构上编写可移植的并行应用程序,使用他们已经熟悉的相同特性和模型。

最初,MPI 是为分布式内存架构设计的,这种架构 20 年前开始变得流行:

图片

分布式内存架构方案

随着时间的推移,分布式内存系统开始相互结合,创建了具有分布式/共享内存的混合系统:

图片

混合系统架构方案

今天,MPI 在分布式内存、共享内存和混合系统上运行。然而,编程模型仍然是分布式内存的,尽管实际执行计算的真正架构可能不同。

MPI 的强项可以总结如下:

  • 标准化:它被所有高性能计算HPC)平台所支持。

  • 可移植性:对源代码所做的更改最小,如果你决定在也支持相同标准的不同平台上使用应用程序,这将非常有用。

  • 性能:制造商可以创建针对特定类型硬件优化的实现,并获得更好的性能。

  • 功能性:在 MPI-3 中定义了超过 440 个例程,但许多并行程序可以使用少于 10 个例程来编写。

在接下来的章节中,我们将检查用于消息传递的主要 Python 库:mpi4py库。

使用 mpi4py Python 模块

Python 编程语言提供了几个 MPI 模块来编写并行程序。其中最有趣的是mpi4py库。它建立在 MPI-1/2 规范之上,提供了一个面向对象的接口,该接口紧密遵循 MPI-2 C++绑定。C MPI 用户可以使用这个模块而不需要学习新的接口。因此,它被广泛用作 Python 中 MPI 库的几乎完整的包。

本章将描述的模块的主要应用如下:

  • 点对点通信

  • 集体通信

  • 通信拓扑

如何做到这一点...

让我们通过检查一个程序的经典代码开始我们的 MPI 库之旅,该程序在每个实例化的进程中打印短语Hello, world!

  1. 导入mpi4py库:
from mpi4py import MPI 

在 MPI 中,参与并行程序执行的进程通过称为rank的非负整数序列来标识。

  1. 如果我们有一个运行程序的进程数(p),那么这些进程将有一个从0p-1 的rank。特别是,为了评估每个进程的 rank,我们必须特别使用COMM_WORLD MPI 函数。这个函数被称为通信器,因为它定义了可以一起通信的所有进程的集合:
 comm = MPI.COMM_WORLD 
  1. 最后,以下Get_rank()函数返回调用它的进程的rank
rank = comm.Get_rank() 
  1. 一旦评估,rank将被打印:
print ("hello world from process ", rank)  

它是如何工作的...

根据 MPI 执行模型,我们的应用程序由N(在这个例子中为 5)个自主进程组成,每个进程都有自己的本地内存,能够通过消息交换来交换数据。

通信器定义了一组可以相互通信的进程。这里使用的MPI_COMM_WORLD工作是一个默认的通信器,包括所有进程。

进程的标识基于 rank。每个进程为其所属的每个通信器分配一个 rank。rank 是一个整数,从零开始分配,用于在特定通信器的上下文中标识每个单独的进程。常见的做法是将具有全局 rank 0 的进程定义为主进程。通过 rank,开发者可以指定发送进程是什么以及接收进程是什么。

应该注意的是,为了说明目的,stdout输出将不会总是有序的,因为多个进程可以同时通过在屏幕上写入来应用,操作系统任意选择顺序。因此,我们准备好进行一个基本观察:每个参与 MPI 执行执行的进程都运行相同的编译后的二进制文件,因此每个进程接收相同的指令来执行。

要执行代码,请输入以下命令行:

C:>mpiexec -n 5 python helloworld_MPI.py 

这是我们执行此代码后得到的结果(注意进程执行的顺序不是顺序的):

hello world from process  1 
hello world from process  0 
hello world from process  2 
hello world from process  3
hello world from process  4

应该注意的是,要使用的进程数量严格依赖于程序必须运行的机器的特性。

还有更多...

MPI 属于单程序多数据SPMD)编程技术。

SPMD 是一种编程技术,其中所有进程执行相同的程序,但每个进程在不同的数据上。不同进程之间的执行区别是通过区分程序的流程来实现的,基于进程的本地 rank。

SPMD 是一种编程技术,其中单个程序由多个进程同时执行,但每个进程可以操作不同的数据。同时,进程可以执行相同的指令和不同的指令。显然,程序将包含适当的指令,允许只执行代码的一部分和/或操作数据的一个子集。这可以使用不同的编程模型来实现,并且所有可执行文件都同时启动。

参见

mpi4py库的完整参考可以在mpi4py.readthedocs.io/en/stable/找到。

实现点对点通信

点对点操作包括两个进程之间的消息交换。在一个完美的世界中,每个发送操作都会与相应的接收操作完美同步。显然,这不是这种情况,MPI 实现必须能够保留发送者和接收者进程不同步时发送的数据。通常,这会使用一个缓冲区来实现,该缓冲区对开发者来说是透明的,并且完全由mpi4py库管理。

mpi4py Python 模块通过两个函数启用点对点通信:

  • Comm.Send(data, process_destination):此函数将数据发送到由通信器组中其 rank 标识的目标进程。

  • Comm.Recv(process_source):此函数从源进程接收数据,该源进程也在通信器组中通过其 rank 进行标识。

Comm参数,简称通信器,定义了可能通过消息传递进行通信的进程组,即comm = MPI.COMM_WORLD

如何做到这一点...

在以下示例中,我们将利用comm.sendcomm.recv指令在不同进程之间交换消息:

  1. 导入相关的mpi4py库:
from mpi4py import MPI
  1. 然后,我们通过MPI.COMM_WORLD语句定义通信器参数,即comm
comm=MPI.COMM_WORLD 
  1. rank参数用于标识进程本身:
rank = comm.rank 
  1. 打印出进程的rank是有用的:
print("my rank is : " , rank) 
  1. 然后,我们开始考虑进程的 rank。在这种情况下,对于 rank 等于0的进程,我们将destination_processdata(在这种情况下data = 10000000)设置为要发送的内容:
if rank==0: 
    data= 10000000 
    destination_process = 4 
  1. 然后,通过使用comm.send语句,将之前设置的数据发送到目标进程:
    comm.send(data,dest=destination_process) 
    print ("sending data %s " %data + \  
           "to process %d" %destination_process) 
  1. 对于rank等于1的进程,destination_process的值是8,而要发送的数据是"hello"字符串:
if rank==1: 
    destination_process = 8 
    data= "hello" 
    comm.send(data,dest=destination_process) 
    print ("sending data %s :" %data + \  
           "to process %d" %destination_process) 
  1. rank等于4的进程是一个接收进程。确实,源进程(即rank等于0的进程)被设置为comm.recv语句中的参数:
if rank==4: 
    data=comm.recv(source=0) 
  1. 现在,使用以下代码,必须显示从0进程接收到的数据:
    print ("data received is = %s" %data) 
  1. 最后设置的进程编号是9。在这里,我们将rank等于1的源进程定义为comm.recv语句中的参数:
if rank==8: 
    data1=comm.recv(source=1) 
  1. 然后打印data1值:
 print ("data1 received is = %s" %data1) 

它是如何工作的...

我们使用总共9个进程的数量运行了示例。因此,在comm通信器组中,我们有九个可以相互通信的任务:

comm=MPI.COMM_WORLD 

此外,为了在组内识别任务或进程,我们使用它们的rank值:

rank = comm.rank 

我们有两个发送进程和两个接收进程。rank等于0的进程向rank等于4的接收进程发送数值数据:

if rank==0: 
    data= 10000000 
    destination_process = 4 
    comm.send(data,dest=destination_process) 

类似地,我们必须指定rank等于4的接收进程。我们还注意到,comm.recv语句必须包含发送进程的rank作为参数:

if rank==4: 
    data=comm.recv(source=0) 

对于其他发送者和接收进程(rank等于1的进程和rank等于8的进程),情况相同,唯一的区别是数据类型。

在这种情况下,对于发送进程,我们有一个要发送的字符串:

if rank==1: 
    destination_process = 8 
    data= "hello" 
    comm.send(data,dest=destination_process) 

对于rank等于8的接收进程,指出发送进程的rank

if rank==8: 
    data1=comm.recv(source=1) 

以下图表总结了mpi4py中的点对点通信协议:

发送/接收传输协议

如您所见,它描述了一个两步过程,包括从一项任务(发送者)发送一些数据,另一项任务(接收者)接收这些数据。发送任务必须指定要发送的数据及其目的地(接收者进程),而接收任务必须指定要接收的消息的来源。

要运行脚本,我们将使用9个进程:

C:>mpiexec -n 9 python pointToPointCommunication.py 

这是在运行脚本后你将得到的输出:

my rank is : 7
my rank is : 5
my rank is : 2
my rank is : 6
my rank is : 3
my rank is : 1
sending data hello :to process 8
my rank is : 0
sending data 10000000 to process 4
my rank is : 4
data received is = 10000000
my rank is : 8
data1 received is = hello 

更多内容...

comm.send()comm.recv()函数是阻塞函数,这意味着它们会阻塞调用者,直到涉及的数据缓冲区可以安全使用。此外,在 MPI 中,有两种发送和接收消息的管理方法:

  • 缓冲模式:一旦要发送的数据被复制到缓冲区,流量控制就会返回程序。这并不意味着消息已被发送或接收。

  • 同步模式:函数只有在相应的receive函数开始接收消息时才会终止。

参见

可以在github.com/antolonappan/MPI_tutorial找到关于这个主题的有趣教程。

避免死锁问题

我们面临的一个常见问题是死锁。这是一种情况,其中两个(或更多)进程互相阻塞并等待对方执行某些动作,这些动作服务于对方,反之亦然。mpi4py模块不提供任何特定功能来解决死锁问题,但开发者必须遵循一些措施以避免死锁问题。

如何做到这一点...

让我们先分析以下 Python 代码,它将介绍一个典型的死锁问题。我们有两个进程——rank等于1rank等于5——它们相互通信,并且都具有数据发送和数据接收的功能:

  1. 导入mpi4py库:
from mpi4py import MPI 
  1. 定义通信器为commrank参数:
comm=MPI.COMM_WORLD 
rank = comm.rank 
print("my rank is %i" % (rank)) 
  1. rank等于1的进程从rank等于5的进程发送和接收数据:
if rank==1: 
    data_send= "a" 
    destination_process = 5 
    source_process = 5 
    data_received=comm.recv(source=source_process) 
    comm.send(data_send,dest=destination_process) 
    print ("sending data %s " %data_send + \ 
           "to process %d" %destination_process) 
    print ("data received is = %s" %data_received) 
  1. 同样,这里我们定义rank等于5的进程:
if rank==5: 
    data_send= "b" 
  1. 目标和发送进程等于1
    destination_process = 1 
    source_process = 1  
    comm.send(data_send,dest=destination_process) 
    data_received=comm.recv(source=source_process) 
    print ("sending data %s :" %data_send + \ 
           "to process %d" %destination_process) 
    print ("data received is = %s" %data_received) 

它是如何工作的...

如果我们尝试运行这个程序(只有两个进程执行是有意义的),那么我们会注意到两个进程中的任何一个都无法继续:

C:\>mpiexec -n 9 python deadLockProblems.py

my rank is : 8
my rank is : 6
my rank is : 7
my rank is : 2
my rank is : 4
my rank is : 3
my rank is : 0
my rank is : 1
sending data a to process 5
data received is = b
my rank is : 5
sending data b :to process 1
data received is = a

两个进程都准备从另一个进程接收消息,并卡在那里。这是因为comm.recv() MPI 函数和comm.send() MPI 函数阻止了它们。这意味着调用进程等待它们的完成。至于comm.send() MPI,完成发生在数据已发送并且可能被覆盖而不修改消息时。

实际上,comm.recv() MPI 的完成是在数据被接收并可以使用时发生的。为了解决这个问题,第一个想法是将comm.recv() MPI 与comm.send() MPI 进行反转,如下所示:

if rank==1: 
    data_send= "a" 
    destination_process = 5 
    source_process = 5 
    comm.send(data_send,dest=destination_process) 
    data_received=comm.recv(source=source_process) 

    print ("sending data %s " %data_send + \
           "to process %d" %destination_process)
    print ("data received is = %s" %data_received)

if rank==5: 
    data_send= "b" 
    destination_process = 1 
    source_process = 1 
    data_received=comm.recv(source=source_process) 
    comm.send(data_send,dest=destination_process) 

    print ("sending data %s :" %data_send + \
           "to process %d" %destination_process)
    print ("data received is = %s" %data_received)

这个解决方案,即使正确,也不能保证我们避免死锁。事实上,通信是通过带有comm.send()指令的缓冲区进行的。

MPI 将待发送的数据复制到缓冲区。这种模式在没有问题的情况下工作,但仅当缓冲区能够保存所有数据时。如果这种情况没有发生,那么就会出现死锁:发送者无法完成发送数据,因为缓冲区正忙,接收者无法接收数据,因为它被comm.send() MPI 调用阻塞,而这个调用尚未完成。

在这一点上,允许我们避免死锁的解决方案是用来交换发送和接收函数,使它们不对称:

if rank==1: 
    data_send= "a" 
    destination_process = 5 
    source_process = 5 
    comm.send(data_send,dest=destination_process) 
    data_received=comm.recv(source=source_process) 

if rank==5: 
    data_send= "b" 
    destination_process = 1 
    source_process = 1 
    comm.send(data_send,dest=destination_process) 
    data_received=comm.recv(source=source_process) 

最后,我们得到正确的输出:

C:\>mpiexec -n 9 python deadLockProblems.py 

my rank is : 4
my rank is : 0
my rank is : 3
my rank is : 8
my rank is : 6
my rank is : 7
my rank is : 2
my rank is : 1
sending data a to process 5
data received is = b
my rank is : 5
sending data b :to process 1
data received is = a 

还有更多...

提出的解决死锁的方案并非唯一方案。

例如,有一个函数可以将发送消息到给定进程的单个调用和接收来自另一个进程的消息的另一个调用统一。这个函数被称为Sendrecv

Sendrecv(self, sendbuf, int dest=0, int sendtag=0, recvbuf=None, int source=0, int recvtag=0, Status status=None) 

如您所见,所需的参数与comm.send()comm.recv()MPI 相同(在这种情况下,也是函数阻塞)。然而,Sendrecv提供了优势,即让通信子系统负责检查发送和接收之间的依赖关系,从而避免死锁。

以这种方式,上一个示例的代码变为以下内容:

if rank==1: 
    data_send= "a" 
    destination_process = 5 
    source_process = 5 
    data_received=comm.sendrecv(data_send,dest=\
                                destination_process,\ 
                                source =source_process) 
if rank==5: 
    data_send= "b" 
    destination_process = 1 
    source_process = 1 
    data_received=comm.sendrecv(data_send,dest=\ 
                                destination_process,\ 
                                source=source_process) 

参见

可以在codewithoutrules.com/2017/08/16/concurrency-python/找到关于由于死锁管理而使并行编程变得困难的有趣分析。

使用广播进行集体通信

在并行代码的开发过程中,我们经常发现自己处于必须在不同进程之间共享运行时某个变量的值或每个进程提供的变量上的某些操作(可能具有不同的值)的情况。

为了解决这些类型的情况,使用通信树(例如,进程 0 向进程 1 和 2 发送数据,它们将分别负责将数据发送到进程 3、4、5、6 等等)。

相反,MPI 库提供了理想的信息交换或使用多个进程的函数,这些函数明显针对它们运行的机器进行了优化:

图片

从进程 0 向进程 1、2、3 和 4 广播数据

涉及到属于通信器的所有进程的通信方法称为集体通信。因此,集体通信通常涉及超过两个进程。然而,我们将这种集体通信称为广播,其中单个进程将相同的数据发送到任何其他进程。

准备工作

mpi4py的广播功能由以下方法提供:

buf = comm.bcast(data_to_share, rank_of_root_process) 

此函数将消息进程根中包含的信息发送到属于comm通信器的每个其他进程。

如何操作...

现在我们来看一个例子,我们使用了broadcast函数。我们有一个rank等于0的根进程,它将自己的数据variable_to_share与其他在通信器组中定义的进程共享:

  1. 让我们导入mpi4py库:
from mpi4py import MPI 
  1. 现在,让我们定义通信器和rank参数:
comm = MPI.COMM_WORLD 
rank = comm.Get_rank() 
  1. 对于rank等于0的进程而言,我们定义要与其他进程共享的变量:
if rank == 0: 
    variable_to_share = 100      
else: 
    variable_to_share = None 
  1. 最后,我们定义一个广播,其rank进程等于零作为其root
variable_to_share = comm.bcast(variable_to_share, root=0) 
print("process = %d" %rank + " variable shared  = %d " \   
                               %variable_to_share) 

它是如何工作的...

rank等于0的根进程实例化一个变量variable_to_share,其值为100。这个变量将与其他通信组的进程共享:

if rank == 0: 
   variable_to_share = 100  

要执行此操作,我们还引入了广播通信语句:

variable_to_share = comm.bcast(variable_to_share, root=0) 

在这里,函数中的参数如下:

  • 要共享的数据(variable_to_share)。

  • 根进程,即排名等于 0 的进程(root=0)。

运行代码后,我们有一个包含 10 个进程的通信组,并且 variable_to_share 在组内的其他进程之间共享。最后,print 语句可视化运行进程的排名及其变量的值:

print("process = %d" %rank + " variable shared  = %d " \   
                     %variable_to_share) 

设置 10 个进程后,得到的输出如下:

C:\>mpiexec -n 10 python broadcast.py 
process = 0 
variable shared = 100 
process = 8 
variable shared = 100 
process = 2 variable 
shared = 100 
process = 3 
variable shared = 100 
process = 4 
variable shared = 100 
process = 5 
variable shared = 100 
process = 9 
variable shared = 100 
process = 6 
variable shared = 100 
process = 1 
variable shared = 100 
process = 7 
variable shared = 100 

更多...

集体通信允许组内多个进程之间同时进行数据传输。mpi4py 库提供了集体通信功能,但仅提供阻塞版本(即,它将调用方法阻塞,直到涉及的数据缓冲区可以安全使用)。

最常用的集体通信操作如下:

  • 组内进程的屏障同步

  • 通信函数:

    • 从一个进程向组内所有进程广播数据

    • 从所有进程收集数据到一个进程

    • 从一个进程向所有进程散射数据

  • 聚合操作

参见

请参阅此链接 (nyu-cds.github.io/python-mpi/) 以找到 Python 和 MPI 的完整介绍。

使用 scatter 函数进行集体通信

scatter 功能与散射广播非常相似,但有一个主要区别:虽然 comm.bcast 向所有监听进程发送相同的数据,但 comm.scatter 可以将数组中的数据块发送到不同的进程。

以下图示说明了 scatter 功能:

图片

从进程 0 向进程 1、2、3 和 4 散射数据

comm.scatter 函数将数组的元素分配给进程,根据它们的排名进行分配,其中第一个元素将被发送到进程 0,第二个元素发送到进程 1,依此类推。在 mpi4py 中实现的功能如下:

recvbuf  = comm.scatter(sendbuf, rank_of_root_process) 

如何实现...

在以下示例中,我们将看到如何使用 scatter 功能将数据分配给不同的进程:

  1. 导入 mpi4py 库:
from mpi4py import MPI 
  1. 接下来,我们以通常的方式定义 commrank 参数:
comm = MPI.COMM_WORLD 
rank = comm.Get_rank() 
  1. 对于 rank 等于 0 的进程,以下数组将被散射:
if rank == 0: 
    array_to_share = [1, 2, 3, 4 ,5 ,6 ,7, 8 ,9 ,10]  
else: 
    array_to_share = None 
  1. 然后,设置 recvbufroot 进程是 rank 等于 0 的进程:
recvbuf = comm.scatter(array_to_share, root=0) 
print("process = %d" %rank + " recvbuf = %d " %recvbuf) 

它是如何工作的...

rank 等于 0 的进程将 array_to_share 数据结构分配给其他进程:

array_to_share = [1, 2, 3, 4 ,5 ,6 ,7, 8 ,9 ,10] 

recvbuf 参数表示将通过 comm.scatter 语句发送到进程的 i^(th) 变量的值:

recvbuf = comm.scatter(array_to_share, root=0)

输出如下:

C:\>mpiexec -n 10 python scatter.py 
process = 0 variable shared  = 1 
process = 4 variable shared  = 5 
process = 6 variable shared  = 7 
process = 2 variable shared  = 3 
process = 5 variable shared  = 6 
process = 3 variable shared  = 4 
process = 7 variable shared  = 8 
process = 1 variable shared  = 2 
process = 8 variable shared  = 9 
process = 9 variable shared  = 10 

我们还指出,comm.scatter 的一个限制是您可以将与执行语句中指定的处理器数量(在这个例子中是三个)一样多的元素进行散射。实际上,如果您尝试散射比指定的处理器数量更多的元素(在这个例子中是三个),那么您将得到类似于以下错误的错误:

C:\> mpiexec -n 3 python scatter.py 
Traceback (most recent call last): 
  File "scatter.py", line 13, in <module> 
    recvbuf = comm.scatter(array_to_share, root=0) 
  File "Comm.pyx", line 874, in mpi4py.MPI.Comm.scatter 
  (c:\users\utente\appdata\local\temp\pip-build-h14iaj\mpi4py\
  src\mpi4py.MPI.c:73400) 
  File "pickled.pxi", line 658, in mpi4py.MPI.PyMPI_scatter 
  (c:\users\utente\appdata\local\temp\pip-build-h14iaj\mpi4py\src\
  mpi4py.MPI.c:34035) 
  File "pickled.pxi", line 129, in mpi4py.MPI._p_Pickle.dumpv 
  (c:\users\utente\appdata\local\temp\pip-build-h14iaj\mpi4py
  \src\mpi4py.MPI.c:28325) 
  ValueError: expecting 3 items, got 10 
  mpiexec aborting job... 

job aborted: 
rank: node: exit code[: error message] 
0: Utente-PC: 123: mpiexec aborting job 
1: Utente-PC: 123 
2: Utente-PC: 123 

更多...

mpi4py 库提供了另外两个用于分散数据的函数:

  • comm.scatter(sendbuf, recvbuf, root=0): 此函数将数据从一个进程发送到通信器中的所有其他进程。

  • comm.scatterv(sendbuf, recvbuf, root=0): 此函数将数据从给定组中的一个进程分散到所有其他提供不同数据量和位移的进程。

sendbufrecvbuf 参数必须以列表的形式给出(如 comm.send 点对点函数中所示):

buf = [data, data_size, data_type] 

在这里,data 必须是 data_size 大小和 data_type 类型的缓冲区对象。

参见

pythonprogramming.net/mpi-broadcast-tutorial-mpi4py/ 提供了一个关于 MPI 广播的有趣教程。

使用 gather 函数进行集体通信

gather 函数执行 scatter 函数的逆操作。在这种情况下,所有进程将数据发送到一个收集接收到的数据的根进程。

准备工作

mpi4py 中实现的 gather 函数如下:

recvbuf  = comm.gather(sendbuf, rank_of_root_process) 

在这里,sendbuf 是发送的数据,rank_of_root_process 代表接收所有数据的接收进程:

从进程 1、2、3 和 4 收集数据

如何实现...

在以下示例中,我们将表示前面图中所示的条件,其中每个进程构建自己的数据,这些数据将被发送到以 rank 零标识的根进程:

  1. 输入必要的导入:
from mpi4py import MPI 
  1. 接下来,我们定义以下三个参数。comm 参数是通信器,rank 提供进程的排名,size 是进程的总数:
comm = MPI.COMM_WORLD 
size = comm.Get_size() 
rank = comm.Get_rank() 
  1. 在这里,我们定义从 rank 零的进程收集的数据:
data = (rank+1)**2 
  1. 最后,通过 comm.gather 函数提供收集。注意,根进程(将收集其他进程数据的进程)是零排名进程:
data = comm.gather(data, root=0) 
  1. 对于 rank 等于 0 的进程,收集的数据和发送进程将被打印出来:
if rank == 0: 
    print ("rank = %s " %rank +\ 
          "...receiving data to other process") 
   for i in range(1,size): 
       value = data[i] 
       print(" process %s receiving %s from process %s"\ 
            %(rank , value , i)) 

它是如何工作的...

0 的根进程从其他四个进程接收数据,如前图所示。

我们设置了 n(= 5)个进程发送他们的数据:

    data = (rank+1)**2  

如果进程的 rank0,则数据将收集到一个数组中:

if rank == 0: 
    for i in range(1,size): 
        value = data[i] 

数据收集由以下函数给出:

data = comm.gather(data, root=0) 

最后,我们运行代码,将进程组设置为 5

C:\>mpiexec -n 5 python gather.py
rank = 0 ...receiving data to other process
process 0 receiving 4 from process 1
process 0 receiving 9 from process 2
process 0 receiving 16 from process 3
process 0 receiving 25 from process 4 

还有更多...

要收集数据,mpi4py 提供以下函数:

  • 向一个任务收集:comm.Gathercomm.Gathervcomm.gather

  • 向所有任务收集:comm.Allgathercomm.Allgathervcomm.allgather

参见

更多关于 mpi4py 的信息可以在 www.ceci-hpc.be/assets/training/mpi4py.pdf 找到。

使用 Alltoall 进行集体通信

Alltoall 集体通信结合了 scattergather 功能。

如何做...

在以下示例中,我们将看到mpi4pycomm.Alltoall的实现。我们将通信者视为一组进程,其中每个进程从组中定义的其他进程发送和接收数值数据数组:

  1. 对于这个示例,必须导入相关的 mpi4pynumpy 库:
from mpi4py import MPI 
import numpy 
  1. 与前一个示例一样,我们需要设置相同的参数,commsizerank
comm = MPI.COMM_WORLD 
size = comm.Get_size() 
rank = comm.Get_rank() 
  1. 因此,我们必须定义每个进程将发送的数据(senddata)以及同时从其他进程接收的数据(recvdata):
senddata = (rank+1)*numpy.arange(size,dtype=int) 
recvdata = numpy.empty(size,dtype=int) 
  1. 最后,执行 Alltoall 函数:
comm.Alltoall(senddata,recvdata) 
  1. 每个进程发送和接收的数据显示如下:
print(" process %s sending %s receiving %s"\ 
      %(rank , senddata , recvdata)) 

它是如何工作的...

comm.alltoall 方法将任务 jsendbuf 参数的 i^(th) 对象复制到任务 irecvbuf 参数的 j^(th) 对象中。

如果我们以 5 进程的通信者组运行代码,那么我们的输出如下:

C:\>mpiexec -n 5 python alltoall.py 
process 0 sending [0 1 2 3 4] receiving [0 0 0 0 0] 
process 1 sending [0 2 4 6 8] receiving [1 2 3 4 5] 
process 2 sending [ 0 3 6 9 12] receiving [ 2 4 6 8 10] 
process 3 sending [ 0 4 8 12 16] receiving [ 3 6 9 12 15] 
process 4 sending [ 0 5 10 15 20] receiving [ 4 8 12 16 20] 

我们也可以通过以下模式来找出发生了什么:

Alltoall 集体通信

我们对模式的观察如下:

  • P0 进程包含 [0 1 2 3 4] 数据数组,其中它将 0 分配给自己,1 分配给 P1 进程,2 分配给 P2 进程,3 分配给 P3 进程,并将 4 分配给 P4 进程;

  • P1 进程包含 [0 2 4 6 8] 数据数组,其中它将 0 分配给 P0 进程,2 分配给自己,4 分配给 P2 进程,6 分配给 P3 进程,并将 8 分配给 P4 进程;

  • P2 进程包含 [0 3 6 9 12] 数据数组,其中它将 0 分配给 P0 进程,3 分配给 P1 进程,6 分配给自己,9 分配给 P3 进程,并将 12 分配给 P4 进程;

  • P3 进程包含 [0 4 8 12 16] 数据数组,其中它将 0 分配给 P0 进程,4 分配给 P1 进程,8 分配给 P2 进程,12 分配给自己,并将 16 分配给 P4 进程;

  • P4 进程包含 [0 5 10 15 20] 数据数组,其中它将 0 分配给 P0 进程,5 分配给 P1 进程,10 分配给 P2 进程,15 分配给 P3 进程,并将 20 分配给自己。

还有更多...

Alltoall 个性化通信也称为完全交换。这种操作用于各种并行算法,如快速傅里叶变换、矩阵转置、样本排序以及一些并行数据库连接操作。

mpi4py 中,有三种类型的 Alltoall 集体通信:

  • comm.Alltoall(sendbuf, recvbuf): Alltoall 散射/收集将数据从组中的所有进程发送到所有进程。

  • comm.Alltoallv(sendbuf, recvbuf): Alltoall 散射/收集向量将数据从组中的所有进程发送到所有进程,提供不同数量的数据和位移。

  • comm.Alltoallw(sendbuf, recvbuf): 扩展的 Alltoall 通信允许每个伙伴有不同的计数、位移和数据类型。

参见

可以从 www.duo.uio.no/bitstream/handle/10852/10848/WenjingLinThesis.pdf 下载对 MPI Python 模块的有意思的分析。

归约操作

comm.gather 类似,comm.reduce 在每个进程中接收一个输入元素数组,并将一个输出元素数组返回给根进程。输出元素包含归约结果。

准备工作

mpi4py 中,我们通过以下语句定义归约操作:

comm.Reduce(sendbuf, recvbuf, rank_of_root_process, op = type_of_reduction_operation) 

我们必须注意,与 comm.gather 语句的区别在于 op 参数,这是你希望应用于你的数据的操作,而 mpi4py 模块包含一组可以使用的归约操作。

如何做...

现在,我们将通过使用归约功能来实现使用 MPI.SUM 归约操作对元素数组求和的方法。每个进程将操作大小为 10 的数组。

对于数组操作,我们使用 numpy Python 模块提供的函数:

  1. 在这里,导入了相关的库,mpi4pynumpy
import numpy 
from mpi4py import MPI  
  1. 定义 commsizerank 参数:
comm = MPI.COMM_WORLD  
size = comm.size  
rank = comm.rank 
  1. 然后,设置数组的大小 (array_size):
array_size = 10 
  1. 定义了要发送和接收的数据:
recvdata = numpy.zeros(array_size,dtype=numpy.int) 
senddata = (rank+1)*numpy.arange(array_size,dtype=numpy.int) 
  1. 打印出进程发送者和发送的数据:
print(" process %s sending %s " %(rank , senddata)) 
  1. 最后,执行 Reduce 操作。请注意,root 进程设置为 0op 参数设置为 MPI.SUM
comm.Reduce(senddata,recvdata,root=0,op=MPI.SUM) 
  1. 归约操作的输出如下所示:
print ('on task',rank,'after Reduce:    data = ',recvdata) 

它是如何工作的...

要执行求和操作,我们使用 comm.Reduce 语句。同时,我们以 rank 零的身份识别,这是包含 recvbufroot 进程,它代表计算的最终结果:

comm.Reduce(senddata,recvdata,root=0,op=MPI.SUM) 

使用 10 进程的通信器组运行代码是有意义的,因为这正是操作数组的尺寸。

输出如下所示:

C:\>mpiexec -n 10 python reduction.py 
  process 1 sending [ 0 2 4 6 8 10 12 14 16 18]
on task 1 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 5 sending [ 0 6 12 18 24 30 36 42 48 54]
on task 5 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 7 sending [ 0 8 16 24 32 40 48 56 64 72]
on task 7 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 3 sending [ 0 4 8 12 16 20 24 28 32 36]
on task 3 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 9 sending [ 0 10 20 30 40 50 60 70 80 90]
on task 9 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 6 sending [ 0 7 14 21 28 35 42 49 56 63]
on task 6 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 2 sending [ 0 3 6 9 12 15 18 21 24 27]
on task 2 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 8 sending [ 0 9 18 27 36 45 54 63 72 81]
on task 8 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 4 sending [ 0 5 10 15 20 25 30 35 40 45]
on task 4 after Reduce: data = [0 0 0 0 0 0 0 0 0 0]
 process 0 sending [0 1 2 3 4 5 6 7 8 9]
on task 0 after Reduce: data = [ 0 55 110 165 220 275 330 385 440 495] 

还有更多...

注意,使用 op=MPI.SUM 选项时,我们对列数组的所有元素应用求和操作。为了更好地理解归约操作的工作原理,让我们看一下以下图表:

图片

集体通信中的归约

发送操作如下:

  • P0 进程发送了 [0 1 2] 数据数组。

  • P1 进程发送了 [0 2 4] 数据数组。

  • P2 进程发送了 [0 3 6] 数据数组。

归约操作将每个任务的 i^(th) 元素相加,然后将结果放入 P0 根进程数组的 i^(th) 元素中。对于接收操作,P0 进程接收 [0 6 12] 数据数组。

MPI 定义的一些归约操作如下:

  • MPI.MAX:返回最大元素。

  • MPI.MIN:返回最小元素。

  • MPI.SUM:将元素相加。

  • MPI.PROD:将所有元素相乘。

  • MPI.LAND:对元素执行 AND 逻辑操作。

  • MPI.MAXLOC: 该函数返回最大值及其所属进程的排名。

  • MPI.MINLOC: 该函数返回最小值及其所属进程的排名。

参见

mpitutorial.com/tutorials/mpi-reduce-and-allreduce/,你可以找到关于这个主题以及更多内容的良好教程。

优化通信

MPI 提供的一个有趣特性是虚拟拓扑。如前所述,所有通信函数(点对点或集体)都指的是一组进程。我们一直使用包含所有进程的MPI_COMM_WORLD组。它为每个属于大小为n的通信器的进程分配从0n-1的排名。

然而,MPI 允许我们将虚拟拓扑分配给通信器。它定义了对不同进程的标签分配:通过构建虚拟拓扑,每个节点将只与其虚拟邻居通信,从而提高性能,因为它减少了执行时间。

例如,如果排名是随机分配的,那么消息可能需要在到达目的地之前强制通过许多其他节点。除了性能问题之外,虚拟拓扑确保代码更清晰、更易读。

MPI 提供了两种构建拓扑的方法。第一种结构创建笛卡尔拓扑,而后者创建任何类型的拓扑。具体来说,在第二种情况下,我们必须提供你想要构建的图的邻接矩阵。我们只处理笛卡尔拓扑,通过它可以构建许多广泛使用的结构,如网格、环形和环面。

用于创建笛卡尔拓扑的mpi4py函数如下:

comm.Create_cart((number_of_rows,number_of_columns))

在这里,number_of_rowsnumber_of_columns指定了要制作的网格的行和列。

如何做到这一点...

在以下示例中,我们展示了如何实现大小为M×N的笛卡尔拓扑。我们还定义了一组坐标以了解所有进程是如何排列的:

  1. 导入所有相关库:
from mpi4py import MPI 
import numpy as np 
  1. 定义以下参数以在拓扑中移动:
UP = 0 
DOWN = 1 
LEFT = 2 
RIGHT = 3 
  1. 对于每个进程,以下数组定义了相邻进程:
neighbour_processes = [0,0,0,0] 
  1. main程序中,定义了comm.ranksize参数:
if __name__ == "__main__": 
    comm = MPI.COMM_WORLD 
    rank = comm.rank 
    size = comm.size 
  1. 现在,让我们构建拓扑:
    grid_rows = int(np.floor(np.sqrt(comm.size))) 
    grid_column = comm.size // grid_rows 
  1. 以下条件确保进程始终在拓扑内:
    if grid_rows*grid_column > size: 
        grid_column -= 1 
    if grid_rows*grid_column > size: 
        grid_rows -= 1
  1. rank等于0的进程开始拓扑构建:
    if (rank == 0) : 
        print("Building a %d x %d grid topology:"\ 
              % (grid_rows, grid_column) ) 

    cartesian_communicator = \ 
                           comm.Create_cart( \ 
                               (grid_rows, grid_column), \ 
                               periods=(False, False), \
                               reorder=True) 
    my_mpi_row, my_mpi_col = \ 
                cartesian_communicator.Get_coords\ 
                ( cartesian_communicator.rank )  

    neighbour_processes[UP], neighbour_processes[DOWN]\ 
                             = cartesian_communicator.Shift(0, 1) 
    neighbour_processes[LEFT],  \ 
                               neighbour_processes[RIGHT]  = \ 
                               cartesian_communicator.Shift(1, 1) 
    print ("Process = %s
    \row = %s\n \ 
    column = %s ----> neighbour_processes[UP] = %s \ 
    neighbour_processes[DOWN] = %s \ 
    neighbour_processes[LEFT] =%s neighbour_processes[RIGHT]=%s" \ 
             %(rank, my_mpi_row, \ 
             my_mpi_col,neighbour_processes[UP], \ 
             neighbour_processes[DOWN], \ 
             neighbour_processes[LEFT] , \ 
             neighbour_processes[RIGHT])) 

它是如何工作的...

对于每个进程,输出应如下所示:如果neighbour_processes = -1,则它没有拓扑邻近性,否则,neighbour_processes显示与进程紧密相关的排名。

结果拓扑是一个2×2的网格(参考前面的图以了解网格表示),其大小等于输入中的进程数;即四个:

grid_row = int(np.floor(np.sqrt(comm.size))) 
grid_column = comm.size // grid_row 
if grid_row*grid_column > size: 
    grid_column -= 1 
if grid_row*grid_column > size: 
    grid_rows -= 1

然后,使用comm.Create_cart函数构建笛卡尔拓扑(注意参数,periods = (False,False)):

cartesian_communicator = comm.Create_cart( \  
    (grid_row, grid_column), periods=(False, False), reorder=True) 

要知道进程的位置,我们使用以下形式的Get_coords()方法:

my_mpi_row, my_mpi_col =\ 
                cartesian_communicator.Get_coords(cartesian_communicator.rank ) 

对于进程,除了获取它们的坐标外,我们还必须计算并找出哪些进程在拓扑上更接近。为此,我们使用comm.Shift (rank_source,rank_dest)函数:


neighbour_processes[UP], neighbour_processes[DOWN] =\            
                                  cartesian_communicator.Shift(0, 1) 

neighbour_processes[LEFT],  neighbour_processes[RIGHT] = \                                     
                                    cartesian_communicator.Shift(1, 1) 

获得的拓扑结构如下:

虚拟网格 2x2 拓扑

如上图所示,进程P0被连接到P1 (RIGHT)P2 (DOWN)进程。P1进程被连接到P3 (DOWN)P0 (LEFT)进程,P3进程被连接到P1 (UP)P2 (LEFT)进程,P2进程被连接到P3 (RIGHT)P0 (UP)进程。

最后,通过运行脚本,我们获得了以下结果:

C:\>mpiexec -n 4 python virtualTopology.py
Building a 2 x 2 grid topology:
Process = 0 row = 0 column = 0
 ---->
neighbour_processes[UP] = -1
neighbour_processes[DOWN] = 2
neighbour_processes[LEFT] =-1
neighbour_processes[RIGHT]=1

Process = 2 row = 1 column = 0
 ---->
neighbour_processes[UP] = 0
neighbour_processes[DOWN] = -1
neighbour_processes[LEFT] =-1
neighbour_processes[RIGHT]=3

Process = 1 row = 0 column = 1
 ---->
neighbour_processes[UP] = -1
neighbour_processes[DOWN] = 3
neighbour_processes[LEFT] =0
neighbour_processes[RIGHT]=-1

Process = 3 row = 1 column = 1
 ---->
neighbour_processes[UP] = 1
neighbour_processes[DOWN] = -1
neighbour_processes[LEFT] =2
neighbour_processes[RIGHT]=-1

还有更多...

要获得大小为M×N的环面拓扑,让我们再次使用comm.Create_cart,但这次,让我们将periods参数设置为periods=(True,True)

cartesian_communicator = comm.Create_cart( (grid_row, grid_column),\ 
                                 periods=(True, True), reorder=True) 

获得了以下输出:

C:\>mpiexec -n 4 python virtualTopology.py
Process = 3 row = 1 column = 1
---->
neighbour_processes[UP] = 1
neighbour_processes[DOWN] = 1
neighbour_processes[LEFT] =2
neighbour_processes[RIGHT]=2

Process = 1 row = 0 column = 1
---->
neighbour_processes[UP] = 3
neighbour_processes[DOWN] = 3
neighbour_processes[LEFT] =0
neighbour_processes[RIGHT]=0

Building a 2 x 2 grid topology:
Process = 0 row = 0 column = 0
---->
neighbour_processes[UP] = 2
neighbour_processes[DOWN] = 2
neighbour_processes[LEFT] =1
neighbour_processes[RIGHT]=1

Process = 2 row = 1 column = 0
---->
neighbour_processes[UP] = 0
neighbour_processes[DOWN] = 0
neighbour_processes[LEFT] =3
neighbour_processes[RIGHT]=3 

输出覆盖了此处表示的拓扑:

虚拟的 2x2 环面拓扑

上一张图中表示的拓扑表明,P0进程被连接到P1RIGHTLEFT)和P2UPDOWN)进程,P1进程被连接到P3UPDOWN)和P0RIGHTLEFT)进程,P3进程被连接到P1UPDOWN)和P2RIGHTLEFT)进程,P2进程被连接到P3LEFTRIGHT)和P0UPDOWN)进程。

参考以下内容

更多关于 MPI 的信息可以在pages.tacc.utexas.edu/~eijkhout/pcse/html/mpi-topo.html找到。

第五章:异步编程

除了顺序和并行执行模型之外,还有一个基本重要的第三模型,与事件编程的概念一起:异步模型

异步任务的执行模型可以通过单个主控制流实现,无论是在单处理器系统还是在多处理器系统中。在并发异步执行模型中,各种任务的执行在时间线上相互交叉,所有事情都在单一控制流的作用下发生(单线程)。一旦开始,任务的执行可以被暂停,然后在一段时间后恢复,与当前其他任务的执行交替进行。

异步模型的代码开发与多线程编程的代码开发完全不同。并发多线程并行模型与单线程并发异步模型之间的重要区别在于,在前一种情况下,操作系统决定在暂停一个线程的活动并启动另一个线程的时间线。

这一点超出了程序员的控制范围,与异步模型不同。任务的执行或终止会持续进行,只要它被明确地需要。

这种类型编程最重要的特性是代码不是在多个线程上执行,就像在经典并发编程中那样,而是在单个线程上执行。因此,两个任务同时执行的说法完全不正确,但根据这种方法,它们几乎是同时执行的。

尤其是我们将描述在 Python 3.4 中引入的asyncioPython 模块。这使我们能够使用协程和未来来简化异步代码的编写,并使其更具可读性。

在本章中,我们将介绍以下食谱:

  • 使用concurrent.futures Python 模块

  • 使用asyncio管理事件循环

  • 使用asyncio处理协程

  • 使用asyncio操作任务

  • 处理asyncio和未来

使用concurrent.futures Python 模块

concurrent.futures模块是 Python 标准库的一部分,通过将线程建模为异步函数,提供了对线程的抽象层次。

此模块由两个主要类构建:

  • concurrent.futures.Executor:这是一个抽象类,提供了执行异步调用的方法。

  • concurrent.futures.Future:这封装了可调用函数的异步执行。Future对象通过将任务(具有可选参数的函数)提交给Executors来实例化。

这里是该模块的一些主要方法:

  • submit(function,argument):这安排在参数上调用可调用函数的执行。

  • map(function,argument):这以异步模式执行参数的函数。

  • shutdown(Wait=True):这向执行器发出信号,释放任何资源。

通过其子类访问执行器:ThreadPoolExecutorProcessPoolExecutor。因为线程和进程的实例化是一个资源密集型任务,所以最好将这些资源池化,并将它们用作可重复的启动器或执行器(因此有 Executors 概念),用于并行或并发任务。

我们在这里采取的方法是使用池执行器。我们将提交资产到池(线程和进程)并获取未来,这些是将来将对我们可用的结果。当然,我们可以等待所有未来成为真实的结果。

线程池或进程池(也称为 pooling)表示正在使用的管理软件,用于优化和简化程序中线程和/或进程的使用。通过池化,您可以提交任务(或多个任务)以便将它们执行到池中。

池配备了待处理任务的内部队列和执行它们的几个线程 进程。在池化中的一个常见概念是重用:在生命周期中,线程(或进程)被多次用于不同的任务。这减少了创建新线程或进程的开销,并提高了程序的性能。

重用 不是规则,但它是一个主要的原因,导致编码者在他们的应用程序中使用池化。

准备工作

concurrent.futures 模块提供了 Executor 类的两个子类,它们异步地操作线程池和进程池。这两个子类如下:

  • concurrent.futures.ThreadPoolExecutor(max_workers)

  • concurrent.futures.ProcessPoolExecutor(max_workers)

max_workers 参数标识异步执行调用时的工作者最大数量。

如何操作...

这里是一个线程和进程池使用的示例,我们将比较执行时间与顺序执行所需的时间。

要执行的任务如下:我们有一个包含 10 个元素的列表。列表的每个元素都被设置为计数到 100,000,000(只是为了浪费时间),然后最后一个数字乘以列表的 i-th 元素。特别是,我们正在评估以下情况:

  • 顺序执行

  • 具有五个工作者的线程池

  • 具有五个工作者的进程池

现在,让我们看看如何操作:

  1. 导入相关库:
import concurrent.futures
import time
  1. 定义从 110 的数字列表:
number_list = list(range(1, 11))
  1. count(number) 函数从 1 计数到 100000000,然后返回 number × 100,000,000 的乘积:
def count(number):
    for i in range(0,100000000):
        i += 1
    return i*number
  1. evaluate(item) 函数在 item 参数上评估 count 函数。它打印出 item 的值和 count(item) 的结果:
def evaluate(item):
    result_item = count(item)
    print('Item %s, result %s' % (item, result_item))
  1. __main__ 中,执行顺序执行、线程池和进程池:
if __name__ == '__main__':
  1. 对于顺序执行,evaluate 函数对 number_list 的每个项目执行。然后,打印出执行时间:
    start_time = time.clock()
    for item in number_list:
        evaluate(item)
    print('Sequential Execution in %s seconds' % (time.clock() -\ 
        start_time))
  1. 关于线程和进程池执行,使用相同的工人数量(max_workers=5)。当然,对于两个池,都会显示执行时间:
    start_time = time.clock()
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as\ 
    executor:
        for item in number_list:
            executor.submit(evaluate, item)
    print('Thread Pool Execution in %s seconds' % (time.clock() -\ 
        start_time))
    start_time = time.clock()
    with concurrent.futures.ProcessPoolExecutor(max_workers=5) as\ 
    executor:
        for item in number_list:
            executor.submit(evaluate, item)
    print('Process Pool Execution in %s seconds' % (time.clock() -\ 
        start_time))

它是如何工作的...

我们构建了一个存储在number_list中的数字列表:

number_list = list(range(1, 11))

对于列表中的每个元素,我们执行计数过程,直到达到100000000次迭代,然后乘以100000000的结果值:

def count(number) : 
    for i in range(0, 100000000):
        i=i+1
    return i*number

def evaluate_item(x):
    result_item = count(x)

main程序中,我们以顺序模式执行相同的任务:

if __name__ == "__main__":
   for item in number_list:
       evaluate_item(item)

然后,在并行模式下,使用concurrent.futures的线程池功能:

with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
    for item in number_list:
        executor.submit(evaluate, item)

对于进程池也做同样的操作:

with concurrent.futures.ProcessPoolExecutor(max_workers=5) as executor:
    for item in number_list:
        executor.submit(evaluate, item)

注意,线程池和进程池都设置了max_workers=5;此外,如果max_workers等于None,它将默认为机器上的处理器数量。

要运行此示例,打开命令提示符,并在包含示例的同一文件夹中,输入以下内容:

> python concurrent_futures_pooling.py

通过执行前面的示例,我们可以看到三种执行模型的相对时间:

Item 1, result 10000000
Item 2, result 20000000
Item 3, result 30000000
Item 4, result 40000000
Item 5, result 50000000
Item 6, result 60000000
Item 7, result 70000000
Item 8, result 80000000
Item 9, result 90000000
Item 10, result 100000000
Sequential Execution in 6.8109448 seconds
Item 2, result 20000000
Item 1, result 10000000
Item 4, result 40000000
Item 5, result 50000000
Item 3, result 30000000
Item 8, result 80000000
Item 7, result 70000000
Item 6, result 60000000
Item 10, result 100000000
Item 9, result 90000000
Thread Pool Execution in 6.805766899999999 seconds
Item 1, result 10000000
Item 4, result 40000000
Item 2, result 20000000
Item 3, result 30000000
Item 5, result 50000000
Item 6, result 60000000
Item 7, result 70000000
Item 9, result 90000000
Item 8, result 80000000
Item 10, result 100000000
Process Pool Execution in 4.166398899999999 seconds

应该注意的是,尽管示例在计算上并不昂贵,但顺序和线程池执行在时间上是可以比较的。使用进程池给我们提供了最快的执行时间。

然后,池将进程(在这种情况下,五个进程)在可用的核心之间(对于本例,使用了一个具有四个核心的机器)以FIFO(即先进先出)模式分配。

因此,对于每个核心,分配的进程按顺序运行。只有在执行 I/O 操作之后,池才会安排另一个进程的执行。当然,如果你使用线程池,执行机制也是相同的。

计算时间在进程池的情况下较低,这必须追溯到 I/O 操作并不重要的原因。这使得进程池可以更快,因为,与线程不同,它们不需要任何同步机制(如第一章中所述,使用 Python 进行并行计算入门,在介绍并行编程食谱中)。

更多内容...

池技术被广泛应用于服务器应用程序中,因为需要管理来自任意数量客户端的多个同时请求。

然而,许多其他应用程序要求每个活动都立即执行,或者你需要对运行它的线程有更多的控制:在这种情况下,池不是最佳选择。

参见

可以在这里找到一个关于concurrent.futures的有趣教程:masnun.com/2016/03/29/python-a-quick-introduction-to-the-concurrent-futures-module.html

使用 asyncio 管理事件循环

asyncio Python 模块提供了管理事件、协程、任务以及线程的设施,以及编写并发代码的同步原语。

此模块的主要组件如下:

  • 事件循环asyncio模块允许每个进程有一个事件循环。这是处理管理和分配不同任务执行的实体。特别是,它注册任务并通过在任务之间切换控制流来管理它们。

  • 协程:这是子程序概念的推广。此外,协程可以在执行过程中暂停以等待外部处理(某些 I/O 中的例程)并从外部处理完成后返回到停止的位置。

  • 期货:这定义了Future对象,与concurrent.futures模块中的定义完全相同。它表示一个尚未完成的计算。

  • 任务:这是asyncio的一个子类,用于在并行模式下封装和管理协程。

在这个菜谱中,重点是软件程序中事件和事件管理(即事件循环)的概念。

理解事件循环

在计算机科学中,事件是程序可以管理的程序拦截的动作。例如,一个事件可以是用户与图形界面交互时虚拟按键的压力、物理键盘上的按键压力、外部中断信号,或者更抽象地说,通过网络接收数据。但更普遍地,任何其他形式的事件,只要可以以某种方式检测和管理,都可以被视为事件。

在一个系统中,能够生成事件的实体被称为事件源,而处理发生事件的实体被称为事件处理器。

事件循环编程结构实现了在程序中管理事件的功能。更确切地说,事件循环在整个程序执行期间循环运行,跟踪在数据结构中发生的事件,并将它们排队,然后逐个调用事件处理器来处理,如果主线程空闲的话。

事件循环管理器的伪代码如下所示:

while (1) {
    events = getEvents()
    for (e in events)
        processEvent(e)
}

所有输入到while循环的事件都被捕获,然后由事件处理器处理。处理事件的处理器是系统中唯一的活动。当处理器结束时,控制权传递到下一个计划的事件。

asyncio提供了以下方法来管理事件循环:

  • loop = get_event_loop(): 这获取当前上下文的事件循环。

  • loop.call_later(time_delay,callback,argument): 这安排在给定的time_delay(以秒为单位)后调用回调。

  • loop.call_soon(callback, argument): 这安排在尽可能早的时候调用回调。回调在call_soon()返回后调用,当控制返回到事件循环时。

  • loop.time(): 这返回事件循环内部时钟的当前时间作为float值 (docs.python.org/3/library/functions.html)。

  • asyncio.set_event_loop(): 这将当前上下文的事件循环设置为循环。

  • asyncio.new_event_loop(): 根据此策略的规则创建并返回一个新的事件循环对象。

  • loop.run_forever(): 这将一直运行,直到调用stop() (docs.python.org/3/library/asyncio-eventloop.html)。

如何做到这一点...

在这个例子中,我们看看如何使用asyncio库提供的事件循环语句,以构建一个异步模式工作的应用程序。

在这个例子中,我们定义了三个任务。每个任务的执行时间由一个随机时间参数确定。一旦执行完成,任务 A调用任务 B任务 B调用任务 C,而任务 C调用任务 A

事件循环将继续,直到满足终止条件。正如我们可以想象的那样,这个例子遵循以下异步模式:

异步编程模型

让我们看看以下步骤:

  1. 让我们先导入实现所需的库:
import asyncio
import time
import random
  1. 然后,我们定义task_A,其执行时间随机确定,可以从1秒到5秒不等。在执行结束时,如果未满足终止条件,则计算转到task_B
def task_A(end_time, loop):
    print ("task_A called")
    time.sleep(random.randint(0, 5))
    if (loop.time() + 1.0) < end_time:
        loop.call_later(1, task_B, end_time, loop)
    else:
        loop.stop()
  1. 这里,定义了task_B。其执行时间随机确定,可以从4秒到7秒不等。在执行结束时,如果未满足终止条件,则计算转到task_B
def task_B(end_time, loop):
    print ("task_B called ")
    time.sleep(random.randint(3, 7))
    if (loop.time() + 1.0) < end_time:
        loop.call_later(1, task_C, end_time, loop)
    else:
        loop.stop()
  1. 然后,实现task_C。其执行时间随机确定,可以从6秒到10秒不等。在执行结束时,如果未满足终止条件,则计算回到task_A
def task_C(end_time, loop):
    print ("task_C called")
    time.sleep(random.randint(5, 10))
    if (loop.time() + 1.0) < end_time:
        loop.call_later(1, task_A, end_time, loop)
    else:
        loop.stop()
  1. 下一个语句定义了loop参数,它简单地获取当前事件循环:
loop = asyncio.get_event_loop()
  1. end_loop值定义了终止条件。此示例代码的执行必须持续60秒:
end_loop = loop.time() + 60
  1. 然后,让我们请求执行task_A
loop.call_soon(task_A, end_loop, loop)
  1. 现在,我们设置一个长时间周期,它会持续响应事件,直到被停止:
loop.run_forever()
  1. 现在,关闭事件循环:
loop.close()

它是如何工作的...

为了管理task_Atask_Btask_C三个任务的执行,我们需要捕获事件循环:

loop = asyncio.get_event_loop()

然后,我们使用call_soon构造函数安排对task_A的第一个调用:

end_loop = loop.time() + 60
loop.call_soon(function_1, end_loop, loop)

让我们记录task_A的定义:

def task_A(end_time, loop):
    print ("task_A called")
    time.sleep(random.randint(0, 5))
    if (loop.time() + 1.0) < end_time:
        loop.call_later(1, task_B, end_time, loop)
    else:
        loop.stop()

应用程序的非同步行为由以下参数确定:

  • time.sleep(random.randint(0, 5)): 这定义了任务执行的持续时间。

  • end_time: 这定义了task_A中的上限时间限制,并通过call_later方法调用task_B

  • loop:这是之前使用 get_event_loop() 方法捕获的事件循环。

执行任务后,loop.timeend_time 进行比较。如果执行时间在最大时间(60 秒)内,则通过调用 task_B 继续计算,否则计算结束,关闭事件循环:

 if (loop.time() + 1.0) < end_time:
        loop.call_later(1, task_B, end_time, loop)
    else:
        loop.stop()

对于其他两个任务,操作实际上相同,但只有执行时间和对下一个任务的调用不同。

现在,让我总结一下情况:

  1. task_A 使用随机执行时间在 1 到 5 秒之间调用 task_B

  2. task_B 使用随机执行时间在 4 到 7 秒之间调用 task_C

  3. task_C 使用随机执行时间在 6 到 10 秒之间调用 task_A

当运行时间到期时,事件循环必须结束:

loop.run_forever()
loop.close()

本例的可能输出如下:

task_A called
task_B called 
task_C called
task_A called
task_B called 
task_C called
task_A called
task_B called 
task_C called
task_A called
task_B called 
task_C called
task_A called
task_B called 
task_C called

更多...

异步事件编程取代了一种并发编程类型,其中程序的不同部分由不同的线程同时执行,这些线程可以访问内存中的相同数据,从而引发临界区问题。同时,能够利用现代 CPU 的不同核心已成为必要,因为在某些领域,使用单核处理器已无法达到后者提供的性能。

参见

这里是 asyncio 的一个很好的介绍:hackernoon.com/a-simple-introduction-to-pythons-asyncio-595d9c9ecf8c

使用 asyncio 处理协程

在展示的各种示例中,我们看到当程序变得非常长且复杂时,将其划分为子程序是很方便的,每个子程序实现一个特定的任务。然而,子程序不能独立执行,只能由主程序请求执行,主程序负责协调子程序的使用。

在本节中,我们介绍了子程序概念的推广,称为协程:就像子程序一样,协程计算单个计算步骤,但与子程序不同,没有 main 程序来协调结果。协程将自己链接起来形成一个没有负责以特定顺序调用它们的监督函数的管道。

在协程中,执行点可以被挂起并在稍后恢复,因为协程跟踪执行状态。拥有协程池,可以交错计算:第一个运行直到它 交出控制权,然后第二个运行并继续下去。

交错由事件循环管理,这在 使用 asyncio 管理事件循环 的配方中已有描述。它跟踪所有协程并安排它们的执行时间。

协程的其他重要方面如下:

  • 协程允许有多个入口点,可以多次产生值。

  • 协程可以将执行权传递给任何其他协程。

在这里,术语yield用于描述协程暂停并将控制流传递给另一个协程。

准备工作

我们将使用以下符号来处理协程:

import asyncio 

@asyncio.coroutine
def coroutine_function(function_arguments):
    ............
    DO_SOMETHING
    ............   

协程使用 PEP 380 中引入的yield from语法来停止当前计算的执行并挂起协程的内部状态。

特别是,在yield from future的情况下,协程将在future完成之前挂起,然后future的结果将被传播(或引发异常);在yield from coroutine的情况下,协程将等待另一个协程产生一个将被传播(或引发异常)的结果。

在下一个例子中,我们将看到如何使用协程模拟有限状态机,我们将使用yield from coroutine符号。

更多关于asyncio的协程信息,请参阅docs.python.org/3.5/library/asyncio-task.html

如何做到这一点...

在这个例子中,我们看到了如何使用协程模拟具有五个状态的状态机。

有限状态机有限状态自动机是一个在工程学科中广泛使用的数学模型,但也在数学和计算机科学等科学中得到应用。

我们想要使用协程模拟其行为的自动机如下:

有限状态机

系统的状态是S0S1S2S3S401是状态机可以从一个状态转换到下一个状态(这个操作称为转换)的值。例如,状态S0可以转换到状态S1,但仅对于值1,而S0可以转换到状态S2,但仅对于值0

以下 Python 代码模拟了自动机从状态S0(起始状态)到状态S4(结束状态)的转换:

  1. 第一步显然是导入相关库:
import asyncio
import time
from random import randint
  1. 然后,我们相对于start_state定义协程。input_value参数随机评估;它可以是01。如果是0,则控制流转到协程state2;否则,它变为协程state1
@asyncio.coroutine
def start_state():
    print('Start State called\n')
    input_value = randint(0, 1)
    time.sleep(1)
    if input_value == 0:
        result = yield from state2(input_value)
    else:
        result = yield from state1(input_value)
    print('Resume of the Transition:\nStart State calling'+ result)
  1. 这里是state1的协程。input_value参数随机评估;它可以是01。如果是0,则控制流转到state2;否则,它变为state1
@asyncio.coroutine
def state1(transition_value):
    output_value ='State 1 with transition value = %s\n'% \
                                             transition_value
    input_value = randint(0, 1)
    time.sleep(1)
    print('...evaluating...')
    if input_value == 0:
        result = yield from state3(input_value)
    else:
        result = yield from state2(input_value)
    return output_value + 'State 1 calling %s' % result
  1. state1的协程有一个允许状态通过的transition_value参数。此外,在这种情况下,input_value是随机评估的。如果是0,则状态转换到state3;否则,控制流变为state2
@asyncio.coroutine
def state2(transition_value):
    output_value = 'State 2 with transition value = %s\n' %\
                                             transition_value
    input_value = randint(0, 1)
    time.sleep(1)
    print('...evaluating...')
    if input_value == 0:
        result = yield from state1(input_value)
    else:
        result = yield from state3(input_value)
    return output_value + 'State 2 calling %s' % result
  1. state3 的协程具有允许状态转换的 transition_value 参数。input_value 是随机评估的。如果是 0,则状态转换到 state1;否则,控制权转移到 end_state
@asyncio.coroutine
def state3(transition_value):
    output_value = 'State 3 with transition value = %s\n' %\
                                                 transition_value
    input_value = randint(0, 1)
    time.sleep(1)
    print('...evaluating...')
    if input_value == 0:
        result = yield from state1(input_value)
    else:
        result = yield from end_state(input_value)
    return output_value + 'State 3 calling %s' % result
  1. end_state 打印出允许状态转换的 transition_value 参数,然后停止计算:
@asyncio.coroutine
def end_state(transition_value):
    output_value = 'End State with transition value = %s\n'%\
                                                transition_value
    print('...stop computation...')
    return output_value
  1. __main__ 函数中,获取事件循环,然后我们开始模拟有限状态机的仿真,调用自动机的 start_state
if __name__ == '__main__':
    print('Finite State Machine simulation with Asyncio Coroutine')
    loop = asyncio.get_event_loop()
    loop.run_until_complete(start_state())

它是如何工作的...

自动机的每个状态都是通过使用装饰器定义的:

 @asyncio.coroutine

例如,状态 S0 在这里定义:

@asyncio.coroutine
def StartState():
    print ("Start State called \n")
    input_value = randint(0,1)
    time.sleep(1)
    if (input_value == 0):
        result = yield from State2(input_value)
    else :
        result = yield from State1(input_value)

下一个状态的转换是由 input_value 决定的,它由 Python 的 random 模块的 randint (0,1) 函数定义。此函数随机提供 01 的值。

以这种方式,randint 随机确定有限状态机将要转换到的状态:

input_value = randint(0,1)

确定传递的值之后,协程使用 yield from 命令调用下一个协程:

if (input_value == 0):
        result = yield from State2(input_value)
    else :
        result = yield from State1(input_value)

result 变量是每个协程返回的值。它是一个字符串,在计算结束时,我们可以从自动机的初始状态 start_state 重建到 end_state 的转换。

main 程序在事件循环内部开始评估:

if __name__ == "__main__":
    print("Finite State Machine simulation with Asyncio Coroutine")
    loop = asyncio.get_event_loop()
    loop.run_until_complete(StartState())

运行代码,我们得到如下输出:

Finite State Machine simulation with Asyncio Coroutine
Start State called
...evaluating...
...evaluating...
...evaluating...
...evaluating...
...stop computation...
Resume of the Transition : 
Start State calling State 1 with transition value = 1
State 1 calling State 2 with transition value = 1
State 2 calling State 1 with transition value = 0
State 1 calling State 3 with transition value = 0
State 3 calling End State with transition value = 1

更多内容...

在 Python 3.5 发布之前,asyncio 模块使用生成器来模拟异步调用,因此其语法与 Python 3.5 的当前版本不同。

Python 3.5 引入了 asyncawait 关键字。注意 await func() 调用周围没有括号。

以下是一个使用 Python 3.5+ 引入的新语法的 "Hello, world!" 示例,使用 asyncio

import asyncio

async def main():
    print(await func())

async def func():
    # Do time intensive stuff...
    return "Hello, world!"

if __name__ == "__main__":
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

参见

Python 中的协程在这里有很好的描述:www.geeksforgeeks.org/coroutine-in-python/

使用 asyncio 操作任务

asyncio 模块旨在处理事件循环上的异步过程和并发任务执行。它还提供了 asyncio.Task() 类,用于将协程包装在任务中(docs.python.org/3/library/asyncio-task.html)。它的用途是允许独立运行的任务在相同的事件循环上与其他任务并发运行。

当协程被任务包装时,它将 Task 连接到事件循环,并在循环启动时自动运行,从而提供了一种自动驱动协程的机制。

asyncio 模块提供了 asyncio.Task(coroutine) 方法来处理带有任务的计算;此外,asyncio.Task(coroutine) 调度协程的执行(docs.python.org/3/library/asyncio-task.html)。

任务负责在 事件循环 中执行协程对象。

如果包装的协程使用了如 Handling coroutines with asyncio 部分所述的 yields from future 符号,那么任务将暂停包装协程的执行并等待未来的完成。

当未来完成时,包装协程的执行将使用未来的结果或异常重新启动。此外,我们必须注意,事件循环一次只运行一个任务。如果其他线程中运行着其他事件循环,其他任务可能并行运行。

当一个任务等待未来的完成时,事件循环将执行一个新的任务。

如何做...

在这个例子中,我们展示了如何通过 asyncio.Task() 语句并发执行三个数学函数:

  1. 当然,让我们先导入 asyncio 库:
import asyncio
  1. 在第一个协程中,定义了 factorial 函数:
@asyncio.coroutine
def factorial(number):
    f = 1
    for i in range(2, number + 1):
        print("Asyncio.Task: Compute factorial(%s)" % (i))
        yield from asyncio.sleep(1)
        f *= i
    print("Asyncio.Task - factorial(%s) = %s" % (number, f))
  1. 之后,定义第二个函数——fibonacci 函数:
@asyncio.coroutine
def fibonacci(number):
    a, b = 0, 1
    for i in range(number):
        print("Asyncio.Task: Compute fibonacci (%s)" % (i))
        yield from asyncio.sleep(1)
        a, b = b, a + b
    print("Asyncio.Task - fibonacci(%s) = %s" % (number, a))
  1. 最后要并发执行的功能是二项式系数:
@asyncio.coroutine
def binomial_coefficient(n, k):
    result = 1
    for i in range(1, k + 1):
        result = result * (n - i + 1) / i
        print("Asyncio.Task: Compute binomial_coefficient (%s)" % 
            (i))
        yield from asyncio.sleep(1)
    print("Asyncio.Task - binomial_coefficient(%s , %s) = %s" % 
        (n,k,result))
  1. __main__ 函数中,task_list 包含必须使用 asyncio.Task 函数并行执行的功能:
if __name__ == '__main__':
    task_list = [asyncio.Task(factorial(10)),
                 asyncio.Task(fibonacci(10)),
                 asyncio.Task(binomial_coefficient(20, 10))]
  1. 最后,我们获取事件循环并开始计算:
    loop = asyncio.get_event_loop()
    loop.run_until_complete(asyncio.wait(task_list))
    loop.close()

它是如何工作的...

每个协程由 @asyncio.coroutine 注解定义(称为 装饰器):

@asyncio.coroutine
def function (args):
    do something

为了并行运行,每个函数都是 asyncio.Task 模块的参数,因此它们被包含在 task_list 中:

if __name__ == '__main__':
    task_list = [asyncio.Task(factorial(10)),
                 asyncio.Task(fibonacci(10)),
                 asyncio.Task(binomial_coefficient(20, 10))]

然后,我们获取事件循环:

    loop = asyncio.get_event_loop()

最后,我们将 task_list 的执行添加到事件循环中:

    loop.run_until_complete(asyncio.wait(task_list))
    loop.close()

注意,asyncio.wait(task_list) 语句等待给定的协程完成。

上述代码的输出如下:

Asyncio.Task: Compute factorial(2)
Asyncio.Task: Compute fibonacci(0)
Asyncio.Task: Compute binomial_coefficient(1)
Asyncio.Task: Compute factorial(3)
Asyncio.Task: Compute fibonacci(1)
Asyncio.Task: Compute binomial_coefficient(2)
Asyncio.Task: Compute factorial(4)
Asyncio.Task: Compute fibonacci(2)
Asyncio.Task: Compute binomial_coefficient(3)
Asyncio.Task: Compute factorial(5)
Asyncio.Task: Compute fibonacci(3)
Asyncio.Task: Compute binomial_coefficient(4)
Asyncio.Task: Compute factorial(6)
Asyncio.Task: Compute fibonacci(4)
Asyncio.Task: Compute binomial_coefficient(5)
Asyncio.Task: Compute factorial(7)
Asyncio.Task: Compute fibonacci(5)
Asyncio.Task: Compute binomial_coefficient(6)
Asyncio.Task: Compute factorial(8)
Asyncio.Task: Compute fibonacci(6)
Asyncio.Task: Compute binomial_coefficient(7)
Asyncio.Task: Compute factorial(9)
Asyncio.Task: Compute fibonacci(7)
Asyncio.Task: Compute binomial_coefficient(8)
Asyncio.Task: Compute factorial(10)
Asyncio.Task: Compute fibonacci(8)
Asyncio.Task: Compute binomial_coefficient(9)
Asyncio.Task - factorial(10) = 3628800
Asyncio.Task: Compute fibonacci(9)
Asyncio.Task: Compute binomial_coefficient(10)
Asyncio.Task - fibonacci(10) = 55
Asyncio.Task - binomial_coefficient(20, 10) = 184756.0

更多内容...

asyncio 提供了其他方法来使用 ensure_future()AbstractEventLoop.create_task() 方法调度任务,这两个方法都接受一个协程对象。

参见

更多关于 asyncio 和任务的信息可以在这里找到:tutorialedge.net/python/concurrency/asyncio-tasks-tutorial/

处理 asyncio 和未来

asyncio 模块的另一个关键组件是 asyncio.Future 类。它与 concurrent.Futures 非常相似,但当然,它是针对 asyncio 的主要机制——事件循环——进行了适配。

asyncio.Future 类表示一个尚未可用的结果(但也可以是异常)。

因此,它代表了一个尚未实现的事物的抽象。必须处理任何结果的回调实际上被添加到这个类的实例中。

准备工作

要定义一个 future 对象,必须使用以下语法:

future = asyncio.Future

管理此对象的主要方法如下:

  • cancel():这个方法取消 future 对象并安排回调。

  • result():这个方法返回这个 future 表示的结果。

  • exception():这个方法返回设置在此 future 上的异常。

  • add_done_callback(fn): 这将在future完成时添加一个要运行的回调。

  • remove_done_callback(fn): 这将在完成时从调用中移除所有回调实例。

  • set_result(result): 这将future标记为完成并设置其结果。

  • set_exception(exception): 这将future标记为完成并设置一个异常。

如何做到...

以下示例展示了如何使用asyncio.Future类来管理两个协程:first_coroutinesecond_coroutine,它们执行以下任务。first_coroutine执行前N个整数的求和,而second_coroutine执行 N 的阶乘:

  1. 现在,让我们导入相关的库:
import asyncio
import sys
  1. first_coroutine实现了前N个整数的sum函数:
@asyncio.coroutine
def first_coroutine(future, num):
    count = 0
    for i in range(1, num + 1):
        count += i
    yield from asyncio.sleep(1)
    future.set_result('First coroutine (sum of N integers)\
                      result = %s' % count)
  1. second_coroutine中,我们仍然实现了factorial函数:
@asyncio.coroutine
def second_coroutine(future, num):
    count = 1
    for i in range(2, num + 1):
        count *= i
    yield from asyncio.sleep(2)
    future.set_result('Second coroutine (factorial) result = %s' %\ 
                      count)
  1. 使用got_result函数,我们打印计算的输出:
def got_result(future):
    print(future.result())
  1. main函数中,num1num2参数必须由用户设置。它们将被用作第一个和第二个协程实现的功能的参数:
if __name__ == "__main__":
    num1 = int(sys.argv[1])
    num2 = int(sys.argv[2])
  1. 现在,让我们来看事件循环:
    loop = asyncio.get_event_loop()
  1. 这里,未来对象是通过asyncio.future函数定义的:
    future1 = asyncio.Future()
    future2 = asyncio.Future()
  1. 包含在tasks列表中的两个协程——first_couroutinesecond_couroutine——具有future1future2未来对象、用户定义的参数以及num1num2参数:
tasks = [first_coroutine(future1, num1),
        second_coroutine(future2, num2)]
  1. 未来对象添加了一个回调:
    future1.add_done_callback(got_result)
    future2.add_done_callback(got_result)
  1. 然后,将tasks列表添加到事件循环中,以便开始计算:
    loop.run_until_complete(asyncio.wait(tasks))
    loop.close()

它是如何工作的...

main程序中,我们使用asyncio.Future()指令分别定义了future对象future1future2

if __name__ == "__main__":
        future1 = asyncio.Future()
        future2 = asyncio.Future()

在定义任务时,我们将future对象作为两个协程first_couroutinesecond_couroutine的参数传递:

tasks = [first_coroutine(future1,num1), 
         second_coroutine(future2,num2)]

最后,我们添加一个在future完成时要运行的回调:

future1.add_done_callback(got_result)
future2.add_done_callback(got_result)

这里,got_result是一个打印future结果的函数:

def got_result(future):
    print(future.result())

在协程中,我们将future对象作为参数传递。计算完成后,我们为第一个协程设置 3 秒的睡眠时间,为第二个协程设置 4 秒的睡眠时间:

yield from asyncio.sleep(sleep_time)

执行命令时,根据不同的值可以获得以下输出:

> python asyncio_and_futures.py 1 1
First coroutine (sum of N integers) result = 1
Second coroutine (factorial) result = 1

> python asyncio_and_futures.py 2 2
First coroutine (sum of N integers) result = 2 Second coroutine (factorial) result = 2

> python asyncio_and_futures.py 3 3
First coroutine (sum of N integers) result = 6
Second coroutine (factorial) result = 6

> python asyncio_and_futures.py 5 5
First coroutine (sum of N integers) result = 15
Second coroutine (factorial) result = 120
 > python asyncio_and_futures.py 50 50
First coroutine (sum of N integers) result = 1275
Second coroutine (factorial) result = 30414093201713378043612608166064768844377641568960512000000000000 
First coroutine (sum of N integers) result = 1275 

还有更多...

我们可以反转输出结果,即先输出second_coroutine的结果,只需简单地交换两个协程之间的睡眠时间:在first_coroutine定义中使用yield from asyncio.sleep(2),在second_coroutine定义中使用yield from asyncio.sleep(1)。这可以通过以下示例展示:

> python asyncio_and_future.py 1 10
second coroutine (factorial) result = 3628800
first coroutine (sum of N integers) result = 1

参见

更多关于asyncio和未来的示例可以在www.programcreek.com/python/example/102763/asyncio.futures找到。

第六章:分布式 Python

本章将介绍一些重要的分布式计算 Python 模块。特别是,我们将描述 socket 模块,它允许您通过客户端-服务器模型实现简单分布式应用程序。

然后,我们将介绍 Celery 模块,这是一个强大的 Python 框架,用于管理分布式任务。最后,我们将描述 Pyro4 模块,它允许您调用在不同进程(可能在不同机器上)中使用的函数。

在本章中,我们将介绍以下内容:

  • 引入分布式计算

  • 使用 Python 的 socket 模块

  • 使用 Celery 进行分布式任务管理

  • 使用 Pyro4 进行远程方法调用(RMI)

引入分布式计算

并行计算分布式计算 是类似的技术,旨在增加特定任务可用的处理能力。通常,这些方法用于解决需要强大计算能力的问题。

当问题被划分为许多小块时,可以由许多处理器同时计算问题的各个部分。这允许对问题施加比单个处理器能提供的更多处理能力。

并行处理与分布式处理之间的主要区别在于,并行配置在单个系统中包含许多处理器,而分布式配置同时利用多台计算机的处理能力。

让我们看看其他的不同之处:

并行处理 分布式处理
并行处理具有提供非常低延迟的可靠处理能力的优势。 分布式处理在处理器层面并不是非常高效,因为数据必须通过网络传输,而不是通过单个系统的内部连接。
通过将所有处理能力集中在一个系统中,由于数据传输造成的速度损失最小化。 由于数据传输会形成瓶颈,限制处理能力,每个处理器将贡献的处理能力远低于并行系统中的任何处理器。
真正的限制只是系统中集成的处理器数量。 由于分布式系统中处理器数量的实际上限并不存在,系统几乎可以无限扩展。

然而,在计算机应用的环境中,通常区分本地和分布式架构:

本地架构 分布式架构
所有组件都在同一台机器上。 应用程序和组件可以驻留在通过网络连接的不同节点上。

使用分布式计算的优势主要在于程序可以并发使用、数据集中以及处理负载的分布,这些都以更高的复杂性为代价,尤其是在各个组件之间的通信方面。

分布式应用程序的类型

分布式应用程序可以根据分布程度进行分类:

  • 客户端-服务器应用程序

  • 多层应用程序

客户端-服务器应用程序

只有两个级别,所有操作都在服务器上执行。例如,我们可以提到经典的静态或动态网站。实现这些类型应用程序的工具是网络套接字,其编程可以在包括 C、C++、Java 和当然还有 Python 在内的各种语言中实现。

术语客户端-服务器系统指的是一种网络架构,其中客户端计算机或终端通常连接到服务器以使用某种服务;例如,与其他客户端共享某些硬件/软件资源,或依赖于底层协议架构。

客户端-服务器架构

客户端-服务器架构是一个实现处理和数据分布的系统。架构的核心元素是服务器。服务器可以从逻辑和物理两个角度来考虑。从物理角度来看——硬件,服务器是一台专门运行软件服务器的机器。

从逻辑角度来看,服务器是软件。服务器作为一个逻辑进程,为承担请求者或客户端角色的其他进程提供服务。通常,服务器不会在结果被客户端请求之前将结果发送给请求者。

区分客户端与其服务器的特性在于客户端可以主动与服务器发起事务,而服务器则不能主动与客户端发起事务:

图片

客户端-服务器架构

实际上,客户端的具体任务包括启动事务、请求特定服务、通知服务的完成以及从服务器接收结果,如前图所示。

客户端-服务器通信

客户端与服务器之间的通信可以使用各种机制进行,从地理网络到本地网络,再到通信服务——在操作系统级别的应用程序之间。此外,客户端-服务器架构必须独立于客户端和服务器之间存在的物理连接方法。

还应注意的是,客户端-服务器进程不必位于物理上分开的系统上。实际上,服务器进程和客户端进程可以位于同一计算平台上。

在数据管理方面,客户端-服务器架构的主要目标是允许客户端应用访问由服务器管理的数据。服务器(在逻辑意义上理解为软件)通常运行在远程系统上(例如,在另一个城市或本地网络)。

因此,客户端-服务器应用通常与分布式处理相关联。

TCP/IP 客户端-服务器架构

TCP/IP 连接在两个应用之间建立点对点连接。这个连接的两端由 IP 地址标记,IP 地址通过端口号识别工作站,这使得可以在同一工作站上连接到独立应用的多个连接。

一旦建立连接并且协议可以通过它交换数据,底层的 TCP/IP 协议就会负责将数据分成数据包,从连接的一端发送到另一端。特别是,TCP 协议负责组装和拆解数据包,以及管理握手过程,以确保连接的可靠性,而 IP 协议负责传输单个数据包以及选择数据包在网络中的最佳路由。

这种机制是 TCP/IP 协议的健壮性的基础,反过来,这也代表了该协议在军事领域(ARPANET)自身发展的一个原因。

现有的各种标准应用(如网页浏览、文件传输和电子邮件)使用标准化的应用协议,例如 HTTP、FTP、POP3、IMAP 和 SMTP。

每个特定的客户端-服务器应用必须定义并应用其自己的专有应用协议。这可能涉及在固定大小的数据块中进行数据交换(这是最简单的解决方案)。

多级应用

存在更多层级可以减轻服务器的处理负载。那些实际上被细分的是服务器端的功能,而客户端部分具有托管应用程序界面的任务,其特征基本保持不变。这种类型架构的一个例子是三层模型,其结构分为三层或层级:

  • 前端或表示层或界面

  • 中间层或应用逻辑

  • 后端或数据层或持久数据管理

这种命名法是网络应用的典型特征。更普遍地说,可以提到适用于任何软件应用的三个层级细分,如下所示:

  • 表示层PL):这是数据可视化部分,对于用户界面来说是必要的,例如输入的模块和控制。

  • 业务逻辑层 (BLL):这是应用程序的主要部分,它独立于用户可用的表示方法和存档中保存的方法定义了各种实体及其关系。

  • 数据访问层 (DAL):这包含管理持久数据所需的一切(基本上,数据库管理系统)。

本章将介绍 Python 为实现分布式架构提出的一些解决方案。我们将从描述 socket 模块开始,我们将使用该模块实现一些基本客户端-服务器模型的示例。

使用 Python 的 socket 模块

套接字是一种软件对象,它允许在远程主机(通过网络)或本地进程(如 进程间通信 (IPC))之间发送和接收数据。

套接字是在伯克利作为 BSD Unix 项目的组成部分被发明的。它们基于 Unix 文件输入/输出管理模型的精确管理。实际上,打开、读取、写入和关闭套接字的操作与 Unix 文件的管理方式相同,但需要考虑的有用参数是通信的有用参数,如地址、端口号和协议。

套接字技术的成功和普及与互联网的发展息息相关。实际上,套接字与互联网的结合使得不同类型、散布全球的机器之间的通信变得极其简单(至少与其他系统相比)。

准备工作

Python 的 socket 模块公开了使用 BSD (伯克利软件发行版) 套接字接口进行网络通信的低级 C API。BSD (Berkeley Software Distribution*) 是指 伯克利软件发行版

此模块包括 Socket 类,它包括管理以下任务的主要方法:

  • socket ([family [, type [, protocol]]]): 使用以下参数构建套接字:

    • family 地址,可以是 AF_INET (默认)AF_INET6AF_UNIX

    • type 套接字,可以是 SOCK_STREAM (默认)SOCK_DGRAM 或其他 "SOCK_" 常量之一。

    • protocol 编号(通常是零)

  • gethostname(): 返回机器的当前 IP 地址。

  • accept (): 返回以下一对值(connaddress),其中 conn 是套接字类型对象(用于在连接上发送/接收数据),而 address 是连接到连接另一端的套接字的地址。

  • bind (address): 将套接字与 address 服务器关联。

此方法历史上接受了一组参数用于 AF_INET 地址,而不是单个元组。

  • close (): 提供在客户端通信完成后清理连接的选项。套接字被关闭并由垃圾回收器收集。

  • connect(address): 将远程套接字连接到地址。address 格式取决于地址族。

如何实现...

在以下示例中,服务器正在监听默认端口,并且通过 TCP/IP 连接,客户端将连接建立时的日期和时间发送到服务器。

这里是server.py的服务器实现:

  1. 导入相关的 Python 模块:
import socket
import time
  1. 使用给定的地址、套接字类型和协议号创建一个新的套接字:
serversocket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
  1. 获取本地机器名称(host):
host=socket.gethostname()
  1. 设置端口号
port=9999
  1. 将套接字绑定到hostport
serversocket.bind((host,port))
  1. 监听对套接字的连接请求。5的参数指定了队列中的最大连接数。最大值取决于系统(通常为5),最小值始终为0
serversocket.listen(5)
  1. 建立连接:
while True:
  1. 然后,连接被接受。返回值是一个对(connaddress),其中conn是一个新的socket对象,用于发送和接收数据,而address是与套接字相关联的地址。一旦接受,就会创建一个新的套接字,它将有自己的标识符。这个新的套接字仅用于这个特定的客户端:
clientsocket,addr=serversocket.accept()
  1. 打印出连接的地址和端口号:
print ("Connected with[addr],[port]%s"%str(addr))
  1. currentTime被评估:
currentTime=time.ctime(time.time())+"\r\n"
  1. 以下语句向套接字发送数据,并返回发送的字节数:
clientsocket.send(currentTime.encode('ascii'))
  1. 以下语句表示套接字关闭(即通信通道);套接字上的所有后续操作都将失败。当套接字被拒绝时,它们会自动关闭,但始终建议使用close()操作来关闭它们:
clientsocket.close()

客户端的代码(client.py)如下:

  1. 导入socket库:
import socket
  1. 然后,创建socket对象:
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
  1. 获取本地机器名称(host):
host=socket.gethostname()
  1. 设置端口号
port=9999
  1. 设置到hostport的连接:
s.connect((host,port))

可以接收的最大字节数不超过 1,024 字节:(tm=s.recv(1024))。

  1. 现在,关闭连接,并最终打印出连接到服务器的时间:
s.close()
print ("Time connection server:%s"%tm.decode('ascii'))

它是如何工作的...

客户端和服务端分别创建它们各自的套接字,服务器在端口上监听它们。客户端向服务器发起连接请求。需要注意的是,我们可以有两个不同的端口号,因为其中一个可能仅用于出站流量,而另一个可能仅用于入站。这取决于主机配置。

实质上,客户端的本地端口不一定与服务器的远程端口相匹配。服务器接收请求,如果被接受,就会创建一个新的连接。现在,客户端和服务器通过一个虚拟通道进行通信,这个通道是在数据套接字连接的数据流中专门创建的,位于套接字和服务端之间。

与第一阶段提到的内容一致,服务器创建数据套接字,因为第一个套接字是专门用于管理请求的。因此,可能有多个客户端正在使用服务器为它们创建的数据套接字与服务器通信。TCP 协议是面向连接的,这意味着当不再需要通信时,客户端会通知服务器,然后关闭连接。

要运行示例,请执行服务器:

C:\>python server.py 

然后,在另一个 Windows 终端中执行客户端:

C:\>python client.py

客户端的结果应报告连接的地址(addr)和端口(port):

Connected with[addr],port

然而,在服务器端,结果应该是这样的:

Time connection server:Sun Mar 31 20:59:38 2019

还有更多...

通过对之前代码的微小修改,我们可以创建一个简单的文件传输客户端-服务器应用程序。服务器实例化套接字并等待来自客户端的连接实例。一旦连接到服务器,客户端就开始数据传输。

要传输的数据,位于mytext.txt文件中,通过调用conn.send函数逐字节复制并发送到服务器。然后服务器接收数据并将其写入第二个文件,received.txt

client2.py的源代码如下:

import socket
s =socket.socket()
host=socket.gethostname()
port=60000
s.connect((host,port))
s.send('HelloServer!'.encode())
with open('received.txt','wb') as f:
    print ('file opened')
    while True :
        print ('receiving data...')
        data=s.recv(1024)
        if not data:
            break
        print ('Data=>',data.decode())
        f.write(data)
f.close()
print ('Successfully get the file')
s.close()
print ('connection closed')

这里是client.py的源代码:

import socket
port=60000
s =socket.socket()
host=socket.gethostname()
s.bind((host,port))
s.listen(15)
print('Server listening....')
while True :
    conn,addr=s.accept()
    print ('Got connection from',addr)
    data=conn.recv(1024)
    print ('Server received',repr(data.decode()))
    filename='mytext.txt'
    f =open(filename,'rb')
    l =f.read(1024)
    while True:
        conn.send(l)
        print ('Sent',repr(l.decode()))
        l =f.read(1024)
        f.close()
        print ('Done sending')
        conn.send('->Thank you for connecting'.encode())
        conn.close()

套接字类型

我们可以区分以下三种类型的套接字,它们以连接模式为特征:

  • 流套接字:这些是面向连接的套接字,基于 TCP 或 SCTP 等可靠协议。

  • 数据报套接字:这些不是面向连接的(无连接)套接字,基于快速但不可靠的 UDP 协议。

  • 原始套接字(原始 IP):传输层被绕过,头部在应用层可访问。

流套接字

我们将特别看到这种类型套接字的更多内容。基于 TCP 等传输层协议,它们保证可靠、全双工和面向连接的通信,具有可变长度的字节流。

通过此套接字进行的通信包括以下阶段:

  1. 套接字创建:客户端和服务器创建它们各自的套接字,服务器在端口上监听它们。由于服务器可以与不同的客户端(甚至同一个客户端)创建多个连接,因此它需要一个队列来处理各种请求。

  2. 连接请求:客户端请求与服务器建立连接。请注意,我们可以有不同的端口号,因为一个可能只分配给出站流量,另一个只分配给入站流量。这取决于主机配置。本质上,客户端的本地端口不一定与服务器的远程端口相同。服务器接收请求,如果接受,则创建一个新的连接。在图中,客户端套接字的端口号为8080,而套接字服务器的端口号为80

  3. 通信:现在,客户端和服务器通过一个虚拟通道进行通信,在客户端套接字和为数据流创建的新套接字(服务器端)之间:一个数据套接字。正如在第一阶段提到的,服务器创建数据套接字,因为第一个数据套接字仅用于管理请求。因此,可能有多个客户端与服务器通信,每个客户端都有一个服务器为它们专门创建的数据套接字。

  4. 连接关闭:由于 TCP 是一种面向连接的协议,当不再需要通信时,客户端会通知服务器,服务器将释放数据套接字。

通过流套接字进行通信的阶段在以下图中展示:

图片

流套接字阶段

参见

更多有关 Python 套接字的信息可以在 docs.python.org/3/howto/sockets.html 找到。

使用 Celery 进行分布式任务管理

Celery 是一个 Python 框架,通过面向对象中间件方法管理分布式任务。其主要特点是处理许多小任务并将它们分布到多个计算节点上。最后,每个任务的输出将被重新处理,以组成整体解决方案。

要使用 Celery,需要一个消息代理。这是一个独立的(与 Celery 无关)软件组件,具有中间件功能,用于向分布式任务工作者发送和接收消息。

实际上,消息代理——也称为消息中间件——处理通信网络中的消息交换:此类中间件的寻址方案不再是点对点类型,而是面向消息的寻址。

消息代理管理的消息交换的参考架构基于所谓的发布/订阅范式,如下所示:

图片

消息代理架构

Celery 支持许多类型的代理。然而,更完整的是 RabbitMQ 和 Redis。

准备工作

要安装 Celery,请使用以下 pip 安装程序:

C:\>pip install celery

然后,必须安装一个消息代理。有几种选择可用,但为了我们的示例,建议从以下链接安装 RabbitMQ:www.rabbitmq.com/download.html

RabbitMQ 是一种面向消息的中间件,实现了 高级消息队列协议AMQP)。RabbitMQ 服务器是用 Erlang 编程语言编写的,因此为了安装它,您需要从 www.erlang.org/download.html 下载并安装 Erlang。

涉及的步骤如下:

  1. 要检查 celery 的安装,首先启动消息代理(例如,RabbitMQ)。然后,输入以下命令:
C:\>celery --version
  1. 以下输出,表示celery版本,如下所示:
4.2.2 (Windowlicker)

接下来,让我们学习如何使用celery模块创建和调用任务。

celery提供了以下两种方法来调用任务:

  • apply_async(args[, kwargs[, ...]]):这会发送一个任务消息。

  • delay(*args, **kwargs):这是一个发送任务消息的快捷方式,但它不支持执行选项。

delay方法更容易使用,因为它被调用为一个常规函数task.delay(arg1, arg2, kwarg1='x', kwarg2='y')。然而,对于apply_async,语法是task.apply_async (args=[arg1,arg2] kwargs={'kwarg1':'x','kwarg2': 'y'})

Windows 设置

为了在 Windows 环境中使用 Celery,您必须执行以下程序:

  1. 前往系统属性 | 环境变量 | 用户或系统变量 | 新建。

  2. 设置以下值:

  • 变量名:FORKED_BY_MULTIPROCESSING

  • 变量值:1

这种设置的原因是因为 Celery 依赖于billiard包(github.com/celery/billiard),它使用FORKED_BY_MULTIPROCESSING变量。

关于 Celery 在 Windows 上的设置更多信息,请参阅www.distributedpython.com/2018/08/21/celery-4-windows/

如何做到...

这里的任务是对两个数字的求和。为了执行这个简单的任务,我们必须组合addTask.pyaddTask_main.py脚本文件:

  1. 对于addTask.py,开始导入 Celery 框架如下:
from celery import Celery
  1. 然后,定义任务。在我们的例子中,任务是对两个数字的求和:
app = Celery('tasks', broker='amqp://guest@localhost//')
@app.task
def add(x, y):
    return x + y
  1. 现在,将之前定义的addTask.py文件导入到addtask_main.py中:
import addTask
  1. 然后,调用addTask.py来执行两个数字的求和:
if __name__ == '__main__':
    result = addTask.add.delay(5,5)

它是如何工作的...

为了使用 Celery,首先要做的是运行 RabbitMQ 服务,然后通过键入以下内容来执行 Celery 工作服务器(即addTask.py文件脚本):

C:\>celery -A addTask worker --loglevel=info

输出如下所示:

Microsoft Windows [Versione 10.0.17134.648]
(c) 2018 Microsoft Corporation. Tutti i diritti sono riservati.

C:\Users\Giancarlo>cd C:\Users\Giancarlo\Desktop\Python Parallel Programming CookBook 2nd edition\Python Parallel Programming NEW BOOK\chapter_6 - Distributed Python\esempi

C:\Users\Giancarlo\Desktop\Python Parallel Programming CookBook 2nd edition\Python Parallel Programming NEW BOOK\chapter_6 - Distributed Python\esempi>celery -A addTask worker --loglevel=info

 -------------- celery@pc-giancarlo v4.2.2 (windowlicker)
---- **** -----
--- * *** * -- Windows-10.0.17134 2019-04-01 21:32:37
-- * - **** ---
- ** ---------- [config]
- ** ---------- .> app: tasks:0x1deb8f46940
- ** ---------- .> transport: amqp://guest:**@localhost:5672//
- ** ---------- .> results: disabled://
- *** --- * --- .> concurrency: 4 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** -----
 -------------- [queues]
 .> celery exchange=celery(direct) key=celery
[tasks]
 . addTask.add

[2019-04-01 21:32:37,650: INFO/MainProcess] Connected to amqp://guest:**@127.0.0.1:5672//
[2019-04-01 21:32:37,745: INFO/MainProcess] mingle: searching for neighbors
[2019-04-01 21:32:39,353: INFO/MainProcess] mingle: all alone
[2019-04-01 21:32:39,479: INFO/SpawnPoolWorker-2] child process 10712 calling self.run()
[2019-04-01 21:32:39,512: INFO/SpawnPoolWorker-3] child process 10696 calling self.run()
[2019-04-01 21:32:39,536: INFO/MainProcess] celery@pc-giancarlo ready.
[2019-04-01 21:32:39,551: INFO/SpawnPoolWorker-1] child process 6084 calling self.run()
[2019-04-01 21:32:39,615: INFO/SpawnPoolWorker-4] child process 2080 calling self.run()

然后,使用 Python 启动第二个脚本:

C:\>python addTask_main.py

最后,第一个命令提示符中的结果应该如下所示:

[2019-04-01 21:33:00,451: INFO/MainProcess] Received task: addTask.add[6fc350a9-e925-486c-bc41-c239ebd96041]
[2019-04-01 21:33:00,452: INFO/SpawnPoolWorker-2] Task addTask.add[6fc350a9-e925-486c-bc41-c239ebd96041] succeeded in 0.0s: 10

如您所见,结果是10。让我们专注于第一个脚本,addTask.py:在前两行代码中,我们创建了一个使用 RabbitMQ 服务代理的Celery应用程序实例:

from celery import Celery
app = Celery('addTask', broker='amqp://guest@localhost//')

Celery函数的第一个参数是当前模块的名称(addTask.py),第二个是代理键盘参数;这表示用于连接代理(RabbitMQ)的 URL。

现在,让我们介绍要完成的任务。

每个任务都必须使用@app.task注解(即,装饰器)来添加;装饰器帮助Celery识别哪些函数可以被调度到任务队列中。

在装饰器之后,我们创建了一个工人可以执行的任务:这将是一个简单的执行两个数字求和的函数:

@app.task
def add(x, y):
    return x + y

在第二个脚本addTask_main.py中,我们使用delay()方法调用我们的任务:

if __name__ == '__main__':
    result = addTask.add.delay(5,5)

让我们记住,此方法是 apply_async() 方法的快捷方式,它为我们提供了对任务执行的更多控制。

还有更多...

Celery 的使用非常简单。可以使用以下命令执行:

Usage: celery <command> [options]

这里,选项如下:

positional arguments:
 args

optional arguments:
 -h, --help             show this help message and exit
 --version              show program's version number and exit

Global Options:
 -A APP, --app APP
 -b BROKER, --broker BROKER
 --result-backend RESULT_BACKEND
 --loader LOADER
 --config CONFIG
 --workdir WORKDIR
 --no-color, -C
 --quiet, -q

主要命令如下:

+ Main:
| celery worker
| celery events
| celery beat
| celery shell
| celery multi
| celery amqp

+ Remote Control:
| celery status

| celery inspect --help
| celery inspect active
| celery inspect active_queues
| celery inspect clock
| celery inspect conf [include_defaults=False]
| celery inspect memdump [n_samples=10]
| celery inspect memsample
| celery inspect objgraph [object_type=Request] [num=200 [max_depth=10]]
| celery inspect ping
| celery inspect query_task [id1 [id2 [... [idN]]]]
| celery inspect registered [attr1 [attr2 [... [attrN]]]]
| celery inspect report
| celery inspect reserved
| celery inspect revoked
| celery inspect scheduled
| celery inspect stats

| celery control --help
| celery control add_consumer <queue> [exchange [type [routing_key]]]
| celery control autoscale [max [min]]
| celery control cancel_consumer <queue>
| celery control disable_events
| celery control election
| celery control enable_events
| celery control heartbeat
| celery control pool_grow [N=1]
| celery control pool_restart
| celery control pool_shrink [N=1]
| celery control rate_limit <task_name> <rate_limit (e.g., 5/s | 5/m | 
5/h)>
| celery control revoke [id1 [id2 [... [idN]]]]
| celery control shutdown
| celery control terminate <signal> [id1 [id2 [... [idN]]]]
| celery control time_limit <task_name> <soft_secs> [hard_secs]

+ Utils:
| celery purge
| celery list
| celery call
| celery result
| celery migrate
| celery graph
| celery upgrade

+ Debugging:
| celery report
| celery logtool

+ Extensions:
| celery flower
-------------------------------------------------------------

Celery 协议可以通过使用 Webhooks 在任何语言中实现(developer.github.com/webhooks/)。

相关内容

使用 Pyro4 的 RMI

PyroPython Remote Objects 的缩写。它的工作方式与 Java 的 RMI(远程方法调用)完全相同,允许像本地对象(属于在调用中运行的同一进程)一样调用远程对象的函数(属于不同的进程)。

在面向对象的系统中使用 RMI 机制,涉及项目中的统一性和对称性的显著优势,因为此机制允许使用相同的概念工具对分布式进程之间的交互进行建模。

如以下图所示,Pyro4 允许对象以客户端/服务器风格进行分布式;这意味着 Pyro4 系统的主要部分可以从客户端调用者切换到远程对象,该对象被调用以执行函数:

RMI

需要注意的是,在远程调用过程中,始终有两个不同的部分:一个客户端和一个服务器,它们接受并执行客户端调用。

准备中

在分布式方式中管理此机制的全部方法由 Pyro4 提供。要安装 Pyro4 的最新版本,使用 pip 安装程序(此处使用 Windows 安装)并添加以下命令:

C:\>pip install Pyro4

我们使用 pyro_server.pypyro_client.py 代码来完成此菜谱。

如何做...

在此示例中,我们将看到如何使用 Pyro4 中间件构建和使用简单的客户端-服务器通信。客户端的代码是 pyro_server.py

  1. 导入 Pyro4 库:
import Pyro4
  1. 定义包含将要公开的 welcomeMessage() 方法的 Server 类:
class Server(object):
    @Pyro4.expose
    def welcomeMessage(self, name):
        return ("Hi welcome " + str (name))

注意,装饰器@Pyro4.expose表示前面的方法将可以远程访问。

  1. startServer函数包含了启动服务器所需的所有指令:
def startServer():
  1. 接下来,构建Server类的server实例:
server = Server()
  1. 然后,定义Pyro4守护程序:
daemon = Pyro4.Daemon()
  1. 要执行此脚本,我们必须运行一个Pyro4语句来定位名称服务器:
ns = Pyro4.locateNS()
  1. 将对象服务器注册为Pyro 对象;它只会在 Pyro 守护程序内部被知晓:
uri = daemon.register(server)
  1. 现在,我们可以在名称服务器中以一个名称注册对象服务器:
ns.register("server", uri)
  1. 函数以调用守护程序的requestLoop方法结束。这启动了服务器的事件循环并等待调用:
print("Ready. Object uri =", uri)
daemon.requestLoop()
  1. 最后,通过main程序调用startServer
if __name__ == "__main__":
    startServer()

以下是为客户端(pyro_client.py)编写的代码:

  1. 导入Pyro4库:
import Pyro4
  1. Pyro4 API 允许开发者以透明的方式分布对象。在这个例子中,客户端脚本向服务器程序发送请求以执行welcomeMessage()方法:
uri = input("What is the Pyro uri of the greeting object? ").strip()
name = input("What is your name? ").strip()
  1. 然后,创建远程调用:
server = Pyro4.Proxy("PYRONAME:server")
  1. 最后,客户端调用服务器,打印一条消息:
print(server.welcomeMessage(name))

它是如何工作的...

前面的例子由两个主要函数组成:pyro_server.pypyro_client.py

pyro_server.py中,Server类对象提供了welcomeMessage()方法,返回一个等于客户端会话中插入的名称的字符串:

class Server(object):
    @Pyro4.expose
    def welcomeMessage(self, name):
        return ("Hi welcome " + str (name))

Pyro4使用守护程序对象将传入的调用分配到适当的对象。服务器必须创建一个守护程序来管理其实例的所有内容。每个服务器都有一个守护程序,它了解服务器提供的所有 Pyro 对象:

 daemon = Pyro4.Daemon()

对于pyro_client.py函数,首先执行远程调用并创建一个Proxy对象。特别是,Pyro4客户端使用代理对象将方法调用转发到远程对象,然后将结果返回给调用代码:

server = Pyro4.Proxy("PYRONAME:server")

为了执行客户端-服务器连接,我们需要有一个正在运行的Pyro4名称服务器。在命令提示符中,输入以下内容:

C:\>python -m Pyro4.naming

然后,你会看到以下信息:

Not starting broadcast server for localhost.
NS running on localhost:9090 (127.0.0.1)
Warning: HMAC key not set. Anyone can connect to this server!
URI = PYRO:Pyro.NameServer@localhost:9090

前面的信息表示名称服务器正在你的网络中运行。最后,我们可以在两个单独的 Windows 控制台中启动服务器和客户端脚本:

  1. 要运行pyro_server.py,只需输入以下内容:
C:\>python pyro_server.py
  1. 之后,你会看到类似以下的内容:
Ready. Object uri = PYRO:obj_76046e1c9d734ad5b1b4f6a61ee77425@localhost:63269
  1. 然后,通过输入以下内容运行客户端:
C:\>python pyro_client.py
  1. 将打印出以下信息:
What is your name? 
  1. 输入一个名称(例如,Ruvika):
What is your name? Ruvika
  1. 将显示以下欢迎信息:
Hi welcome Ruvika

还有更多...

Pyro4的特性中,有创建对象拓扑的功能。例如,假设我们想要构建一个遵循链式拓扑的分布式架构,如下所示:

使用 Pyro4 链式对象

客户端向服务器 1发起请求,然后请求被转发到服务器*2,然后服务器 2调用服务器 3。链式调用在服务器 3调用服务器 1时结束。

实现链式拓扑

要使用Pyro4实现链式拓扑,我们需要实现chain对象以及客户端和服务器对象。Chain类允许通过处理输入消息并重建请求应发送到的服务器地址,将调用重定向到下一个服务器。

还要注意,在这种情况下,@Pyro4.expose装饰器允许暴露类(chainTopology.py)的所有方法:

import Pyro4

@Pyro4.expose
class Chain(object):
    def __init__(self, name, next_server):
        self.name = name
        self.next_serverName = next_server
        self.next_server = None

    def process(self, message):
        if self.next_server is None:
            self.next_server = Pyro4.core.Proxy("PYRONAME:example.\
                chainTopology." + self.next_serverName)

如果链已关闭(最后一个调用是从server_chain_3.pyserver_chain_1.py),则打印出关闭消息:

       if self.name in message:
            print("Back at %s;the chain is closed!" % self.name)
            return ["complete at " + self.name]

如果链中存在下一个元素,则打印出转发消息:

        else:
            print("%s forwarding the message to the object %s" %\ 
                (self.name, self.next_serverName))
            message.append(self.name)
            result = self.next_server.process(message)
            result.insert(0, "passed on from " + self.name)
            return result

接下来,我们有客户端的源代码(client_chain.py):

import Pyro4

obj = Pyro4.core.Proxy("PYRONAME:example.chainTopology.1")
print("Result=%s" % obj.process(["hello"]))

接下来是第一个服务器(即server_1)的源代码,它是由客户端(server_chain_1.py)调用的。在这里,导入了相关的库。注意,之前描述的chainTopology.py文件的导入:

import Pyro4
import chainTopology

注意,服务器的源代码仅在定义链中的当前服务器和下一个服务器方面有所不同:

current_server= "1"
next_server = "2"

剩余的代码行定义了与链中下一个元素的通信:

servername = "example.chainTopology." + current_server
daemon = Pyro4.core.Daemon()
obj = chainTopology.Chain(current_server, next_server)
uri = daemon.register(obj)
ns = Pyro4.locateNS()
ns.register(servername, uri)
print("server_%s started " % current_server)
daemon.requestLoop()

要执行此示例,首先运行Pyro4名称服务器:

C:\>python -m Pyro4.naming
Not starting broadcast server for localhost.
NS running on localhost:9090 (127.0.0.1)
Warning: HMAC key not set. Anyone can connect to this server!
URI = PYRO:Pyro.NameServer@localhost:9090

在三个不同的终端中运行三个服务器,分别输入每个服务器的名称(这里使用 Windows 终端):

第一个终端中的第一个服务器(server_chain_1.py):

C:\>python server_chain_1.py

然后在第二个终端中运行第二个服务器(server_chain_2.py):

C:\>python server_chain_2.py

最后,在第三个终端中运行第三个服务器(server_chain_3.py):

C:\>python server_chain_3.py

然后,从另一个终端运行client_chain.py脚本:

C:\>python client_chain.py

这是命令提示符中显示的输出:

Result=['passed on from 1','passed on from 2','passed on from 3','complete at 1']

前面的消息是在三个服务器之间传递转发请求后显示的,因为它返回了任务已完成的消息给server_chain_1

此外,我们还可以关注在请求转发到链中的下一个对象时对象服务器的行为(参考启动消息下的消息):

  1. server_1启动并将以下消息转发给server_2
server_1 started
1 forwarding the message to the object 2
  1. server_2将以下消息转发给server_3
server_2 started
2 forwarding the message to the object 3
  1. server_3将以下消息转发给server_1
server_3 started
3 forwarding the message to the object 1
  1. 最后,消息返回到起点(换句话说,server_1),关闭链:
server_1 started
1 forwarding the message to the object 2
Back at 1; the chain is closed!

参见

Pyro4文档可在buildmedia.readthedocs.org/media/pdf/pyro4/stable/pyro4.pdf找到。

这包含了 4.75 版本的描述和一些应用示例。

第七章:云计算

云计算是通过互联网()分发计算服务(如服务器、存储资源、数据库、网络、软件、分析和智能)的分布。本章的目的是提供与 Python 编程语言相关的主要云计算技术的概述。

首先,我们将描述 PythonAnywhere 平台,我们将使用该平台在云上部署 Python 应用程序。在云计算的背景下,将确定两种新兴技术:容器和无服务器技术。

容器代表了资源虚拟化的新方法,而无服务器技术代表了云服务领域的一大步,因为它们可以加快应用程序的发布。

实际上,你不必担心配置、服务器或基础设施配置。你只需要创建可以独立于应用程序运行的功能(即 Lambda 函数)。

在本章中,我们将涵盖以下食谱:

  • 什么是云计算?

  • 理解云计算架构

  • 使用 PythonAnywhere 开发 Web 应用程序

  • 将 Python 应用程序 Docker 化

  • 介绍无服务器计算

我们还将看到如何利用AWS Lambda框架来开发 Python 应用程序。

什么是云计算?

云计算是基于一组资源(如虚拟处理、大量内存和网络)的服务分发计算模型,这些资源可以作为运行应用程序的平台动态聚合和激活,满足适当的服务水平并优化资源使用的效率。

这可以通过最少的努力或与服务提供商的交互快速获取和释放。这种云模型由五个基本特征、三种服务模型和四种部署模型组成。

特别是,五个基本特征如下:

  • 免费和按需访问:这允许用户通过用户友好的界面访问提供商提供的服务,而无需人工交互。

  • 网络无处不在的访问:资源遍布整个网络,可以通过标准设备(如智能手机平板电脑个人电脑)访问。

  • 快速弹性:这是云能够快速自动增加或减少分配的资源的能力,例如,让用户感觉它们似乎是无限的。这为系统提供了极大的可伸缩性。

  • 测量服务:云系统持续监控提供的资源,并根据估计的使用量自动优化它们。这样,客户只为该特定会话实际使用的资源付费。

  • 资源共享:提供商通过多租户模型提供其资源,以便可以根据客户请求动态分配和重新分配,并由多个消费者使用:

云计算的主要特点

然而,云计算有许多定义,每个定义都有不同的解释和含义。美国国家标准与技术研究院(NIST)试图提供详细和官方的解释(csrc.nist.gov/publications/detail/sp/800-145/final)。

另一个特性(虽然在 NIST 的定义中没有列出,但它是云计算的基础)是虚拟化的概念。这是在相同的物理资源上执行多个操作系统的可能性,保证了众多优势,如可扩展性、成本降低和向客户提供新资源时的速度更快。

以下是最常见的虚拟化方法:

  • 容器

  • 虚拟机

在应用程序隔离方面,这两种解决方案几乎具有相同的优势,但它们在虚拟化的不同级别上工作,因为容器虚拟化了操作系统,而虚拟机虚拟化了硬件。这意味着容器更易于移植和高效。

通过容器进行虚拟化的最常见应用是 Docker。我们将简要介绍这个框架,并了解如何容器化(或 dockerize)一个 Python 应用程序。

理解云计算架构

云计算的架构指的是构成系统结构的组件和子组件的系列。通常,它可以分为两个主要部分:前端后端

云计算架构

每个部分都有非常具体的意义和范围,并通过虚拟网络或互联网网络相互连接。

前端指的是云计算系统中用户可以看到的部分,它通过一系列接口和应用来实现,允许消费者访问云系统。不同的云计算系统有不同的用户界面。

后端是用户看不到的部分。这部分包含所有允许提供商提供云计算服务的资源,如服务器、存储系统和虚拟机。后端创建背后的理念是将整个系统的管理委托给单个中央服务器,因此它将不得不不断监控流量和用户请求,执行访问控制,并实施通信协议。

在这个架构的各个组件中,最重要的是虚拟机管理程序,也称为虚拟机管理器。这是一种固件,它可以动态分配资源,并允许您在多个用户之间共享单个实例。简而言之,这是实现虚拟化的程序,这是云计算的主要属性之一。

在提供云计算的定义并解释其基本特性之后,我们将介绍云计算服务可以提供的服务模型

服务模型

提供商提供的云计算服务可以分为三大类:

  • Software as a Service (SaaS)

  • Platform as a Service (PaaS)

  • Infrastructure as a Service (IaaS)

这种分类导致了名为SPI模型(参见前文列表中的粗体首字母)的方案的定义。有时它被称为云计算堆栈,因为这些类别是相互依赖的。

现在将按自上而下的方法详细描述这些各个层次。

SaaS

SaaS 提供商向用户提供按需的软件应用程序,这些应用程序可以通过任何互联网设备访问,例如网络浏览器。此外,提供商托管软件应用程序和底层基础设施,从而减轻了客户在软件更新和安全补丁应用等方面的管理和维护负担。

使用这种模型对用户和提供商都有许多优点。对于用户来说,管理成本有相当大的降低,而对于提供商来说,他们可以更好地控制流量,从而避免任何过载。SaaS 的一个例子是任何基于网络的电子邮件服务,例如GmailOutlookSalesforceYahoo!

PaaS

与 SaaS 不同,这种服务指的是应用程序的整个开发环境,而不仅仅是它的使用。因此,PaaS 解决方案提供了一个可以通过网络浏览器访问的云平台,用于软件应用的开发、测试、分发和管理。此外,提供商提供基于网络的界面、多租户架构和通信工具,以便开发者能够以更简单的方式创建应用程序。这支持软件的整个生命周期,并有利于合作。

PaaS 的例子包括Microsoft Azure ServicesGoogle App EngineAmazon Web Services

IaaS

IaaS 是一种提供计算基础设施作为按需服务的模型。因此,您可以购买虚拟机,在这些虚拟机上运行自己的软件,存储资源(根据您的需求,可以快速增加或减少存储容量),网络和操作系统,通过支付实际使用的费用来使用它们。这种类型的动态基础设施增加了更大的可伸缩性,同时也显著降低了成本。

这种模型被既没有大量资本投资的小型新兴公司,也由寻求简化其硬件架构的成熟公司使用。IaaS 销售商的范围非常广泛,包括 Amazon Web ServicesIBMOracle

分布模型

云计算架构并非都是相同的。事实上,有四种不同的分布模型:

  • 公有云

  • 私有云

  • 云社区

  • 混合云

公有云

这种分布模型对所有用户开放,包括个人用户和公司。通常,公有云运行在服务提供商拥有的数据中心中,该数据中心处理硬件、软件和其他支持基础设施。这样,用户可以免除任何维护活动/费用。

私有云

也称为内部云,私有云提供与公有云相同的优势,但提供对数据和流程的更大控制。这种模式被呈现为一个仅为公司工作的云基础设施,因此它在该公司的范围内进行管理和托管。显然,使用它的组织可以将其架构扩展到与其有商业关系的任何群体。

通过采用这种类型的解决方案,可以避免涉及敏感数据违规和工业间谍活动的问题,同时也不忽视使用简化、可配置和性能高的工作配置系统。正因为如此,近年来,使用私有云的公司数量显著增加。

云社区

从概念上讲,这种模型描述了一个由多个具有共同利益的公司实施和管理的共享基础设施。这种类型的解决方案很少使用,因为将责任和管理活动在社区的不同成员之间进行分配可能会变得复杂。

混合云

NIST 将其定义为之前提到的三种实施模型(私有云、公有云和社区云)的组合结果,试图取其三者之长,以弥补其他方面的不足。所使用的云仍然是独立的实体,这可能导致操作一致性不足。因此,采用这种模式的公司有责任通过专有技术确保其服务器的互操作性,并针对它们必须扮演的具体角色进行优化。

混合云与所有其他云的区别在于其云爆发或在大规模峰值需求下能够动态地将私有云中的多余流量转移到公有云中的可能性。

这种实施模型被那些打算共享其软件应用但保留其敏感数据在内部云中的公司采用。

云计算平台

云计算平台是一组软件和技术,能够提供云中的资源(按需、可扩展和虚拟化资源)。在最受欢迎的平台中,包括谷歌的以及云计算的里程碑:亚马逊网络服务AWS)。两者都支持 Python 作为开发语言。

然而,在下一个菜谱中,我们将专注于 PythonAnywhere,这是一个专门为在 Python 编程语言中部署 Web 应用而开发的云平台。

使用 PythonAnywhere 开发 Web 应用

PythonAnywhere 是基于 Python 编程语言的在线托管开发和服务环境。一旦在网站上注册,您将被引导到仪表板,其中包括一个完全用 HTML 代码制作的先进外壳和文本编辑器。有了这个,您可以创建、修改和执行自己的脚本。

此外,这个开发环境还允许您选择要使用的 Python 版本。在这里,一个简单的向导帮助我们预先配置应用程序。

准备工作

让我们先看看如何获取网站的登录凭证。

以下截图显示了各种订阅类型,以及获得免费账户的可能性(请访问www.pythonanywhere.com/registration/register/beginner/):

PythonAnywhere: 注册页面

一旦获得网站访问权限(建议创建一个初学者账户),我们便登录。鉴于集成到浏览器中的 Python 外壳非常实用,尤其是对于初学者和入门级编程课程,它们在技术层面上当然不是新事物。

相反,PythonAnywhere 的增值服务在登录后通过访问个人仪表板即可感知:

PythonAnywhere: 仪表板

通过个人仪表板,我们可以在 2.7 到 3.7 之间选择要运行的 Python 版本,是否带有 IPython 界面:

PythonAnywhere: 控制台视图

可用的控制台数量根据您的订阅类型而有所不同。在我们的例子中,由于我们创建了一个初学者账户,我们最多可以使用两个 Python 控制台。一旦选择了一个 Python 外壳,例如版本 3.5,以下视图应该在浏览器中打开:

PythonAnywhere: Python 外壳

在下一节中,我们想向您展示如何使用 PythonAnywhere 编写一个简单的 Web 应用。

如何操作...

让我们看看以下步骤:

  1. 在仪表板上,打开 Web 选项卡:

PythonAnywhere: 网页应用视图

  1. 界面告诉我们我们还没有网络应用。通过选择“添加新网络应用”,将打开以下视图。它告诉我们我们的应用将具有以下网络地址:loginname.pythonanywhere.com(在这个例子中,应用的网址将是 giazax.pythonanywhere.com):

PythonAnywhere: 网络应用向导

  1. 当我们点击“下一步”时,我们可以选择我们想要使用的 Python 网络框架:

PythonAnywhere: 网络框架向导

  1. 我们选择 Flask 作为网络框架,然后点击“下一步”以选择我们想要使用的 Python 版本,如图所示:

PythonAnywhere: 网络框架向导

Flask 是一个易于安装和使用的 Python 微型框架,被 Pinterest 和 LinkedIn 等公司使用。

如果你不知道什么是创建网络应用的框架,那么你可以想象一组旨在简化创建网络服务(如网络服务器和 APIs)的程序。有关 Flask 的更多信息,请参阅 flask.pocoo.org/docs/1.0/

  1. 在前面的屏幕截图中,我们为 Flask 1.0.2 选择 Python 3.5,然后点击“下一步”以输入用于存储 Flask 应用的 Python 文件的路径。在这里,默认文件被选中:

PythonAnywhere: Flask 项目定义

  1. 当我们最后一次点击“下一步”时,将显示以下屏幕,它总结了网络应用的配置参数:

PythonAnywhere: giazax.pythonanywhere.com 的配置页面

现在,让我们看看会发生什么。

它是如何工作的...

在网页浏览器的地址栏中,输入我们网络应用的 URL,在我们的例子中,https://giazax.pythonanywhere.com/。网站显示了一个简单的欢迎语:

giazax.pythonanywhere.com 网站页面

通过选择与“源代码”标签对应的“进入目录”,可以看到此应用的源代码:

PythonAnywhere: 配置页面

在这里,我们可以分析构成网络应用的文件:

PythonAnywhere: 项目站点仓库

还可以上传新文件并可能修改内容。在这里,我们选择我们的第一个网络应用的 flask_app.py 文件。内容看起来像是一个最小的 Flask 应用:

# A very simple Flask Hello World app for you to get started with...

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello from Flask!'

Flask 使用 route() 装饰器来定义应该触发 hello_world 函数的 URL。这个简单的函数返回在网页浏览器中显示的消息。

还有更多...

PythonAnywhere 的 shell 是用 HTML 制作的,这使得它几乎可以在多个平台和浏览器上便携,包括苹果的移动版本。您可以根据选择的账户配置文件保持多个 shell 打开(变量数量的 shell),与其他用户共享,或根据需要终止它们。

PythonAnywhere 有一个相当先进的文本编辑器,具有语法高亮和自动缩进功能,通过它可以创建、修改和执行自己的脚本。文件存储在根据账户配置不同大小的存储区域中,但如果空间不足或您希望与 PC 的文件系统有更流畅的集成,那么 PythonAnywhere 允许您使用 Dropbox 账户,使您的共享文件夹在流行的存储服务上可访问。

每个 shell 可以包含一个与特定 URL 对应的 WSGI 脚本。也可以从 bash shell 启动,用于调用 Git 并与文件系统交互。最后,正如我们所看到的,有一个向导可供我们预先配置Djangoweb2py或 Flask 应用程序。

此外,还有利用MySQL数据库的可能性,这是一个允许我们定期执行某些脚本的 cron 作业系列。因此,我们将获得 PythonAnywhere 的真正精髓:以光速部署 Web 应用程序。

PythonAnywhere完全依赖于Amazon EC2基础设施,因此没有理由不信任该服务。因此,强烈推荐那些考虑个人使用的人。入门账户提供的资源比Heroku(www.heroku.com/)上的相应账户更多,部署比OpenShift(www.openshift.com/)简单,整个系统通常比Google App Engine(cloud.google.com/appengine/)更加灵活。

参见

Flask类似,建议您访问这些网站获取如何使用这些库的信息。

将 Python 应用程序容器化

容器是虚拟化环境。它们包括软件所需的一切,即库、依赖项、文件系统和网络接口。与经典虚拟机不同,上述所有元素与它们运行的机器共享内核。这样,对主机节点资源的使用影响大大降低。

这使得容器在可扩展性、性能和隔离性方面成为一种非常吸引人的技术。容器不是一种新技术;它们在 2013 年 Docker 发布时取得了成功。从那时起,它们彻底改变了用于应用程序开发和管理的标准。

Docker 是一个基于Linux 容器LXC)实现的容器平台,它通过将容器作为自包含的镜像来管理其功能,并添加了额外的工具来协调它们的生命周期和保存它们的状态。

容器化的想法正是允许特定的应用程序在任何类型的系统上执行,因为所有依赖项都已经包含在容器本身中。

以这种方式,应用程序变得高度便携,可以轻松地在任何类型的环境中进行测试和部署,无论是在本地还是在云中。

现在,让我们看看如何使用 Docker 将 Python 应用程序 docker 化。

准备工作

Docker 团队的想法是将容器概念作为核心,并围绕它构建一个生态系统,以简化其使用。这个生态系统包括一系列工具:

安装 Docker for Windows

安装过程相当简单:一旦下载了安装程序(docs.docker.com/docker-for-windows/install/),只需运行它即可完成。安装过程通常是线性的。唯一需要注意的只是安装的最后阶段,可能需要启用 Hyper-V 功能。如果是这样,那么我们接受并重新启动机器。

计算机重启后,Docker 图标应该出现在屏幕右下角的系统托盘中。

打开命令提示符或 PowerShell 控制台,通过执行docker version命令来检查一切是否正常:

C:\>docker version
Client: Docker Engine - Community
 Version: 18.09.2
 API version: 1.39
 Go version: go1.10.8
 Git commit: 6247962
 Built: Sun Feb 10 04:12:31 2019
 OS/Arch: windows/amd64
 Experimental: false

Server: Docker Engine - Community
 Engine:
 Version: 18.09.2
 API version: 1.39 (minimum version 1.12)
 Go version: go1.10.6
 Git commit: 6247962
 Built: Sun Feb 10 04:13:06 2019
 OS/Arch: linux/amd64
 Experimental: false

输出中最有趣的部分是客户端和服务器之间的细分。客户端是我们的本地 Windows 系统,而服务器是 Docker 在幕后实例化的 Linux 虚拟机。这些部分通过介绍中提到的 API 层相互通信。

现在,让我们看看如何将一个简单的 Python 应用程序容器化(或 docker 化)。

如何操作...

让我们假设我们想要部署以下 Python 应用程序,我们称之为dockerize.py

from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
    return "Hello World!"
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=int("5000"), debug=True)

示例应用程序使用了Flask模块。它在一个本地的5000地址上实现了一个简单的 Web 应用程序。

第一步是创建以下文本文件,文件扩展名为.py,我们将称之为Dockerfile.py

FROM python:alpine3.7
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
EXPOSE 5000
CMD python ./dockerize.py

之前代码中列出的指令执行以下任务:

  • FROM python: alpine3.7指令指示 Docker 使用 Python 3.7 版本。

  • COPY将应用程序复制到容器镜像中。

  • WORKDIR设置工作目录(WORKDIR)。

  • RUN指令调用pip安装器,指向requirements.txt文件。它包含应用程序必须执行(在我们的案例中,唯一的依赖是flask)的依赖项列表。

  • EXPOSE指令将端口暴露给 Flask 使用的端口。

因此,总结一下,我们编写了三个文件:

  • 要容器化的应用程序:dockerize.py

  • Dockerfile

  • 依赖项列表文件

因此,我们需要创建dockerize.py应用程序的镜像:

docker build --tag dockerize.py

这将为my-python-app镜像打上标签并构建它。

它是如何工作的...

在构建了my-python-app镜像之后,你可以将其作为容器运行:

docker run -p 5000:5000 dockerize.py

然后将应用程序作为容器启动,之后名称参数将名称发送到容器,-p参数将主机的5000端口映射到容器的5000端口。

接下来,你需要打开你的网络浏览器,然后在地址栏中输入localhost: 5000。如果一切正常,你应该会看到以下网页:

Docker 应用程序

Docker 通过使用run命令运行dockerize.py容器,结果是一个网络应用程序。该镜像包含容器操作所需的指令。

通过将镜像与类关联,将容器与类实例关联,可以通过面向对象编程范式来理解容器和镜像之间的关系。

有用的是回顾一下我们创建容器实例时发生的情况:

  • 容器镜像(如果尚未存在)将在本地卸载。

  • 创建了一个启动容器的环境。

  • 屏幕上打印了一条消息。

  • 然后丢弃之前创建的环境。

所有这些都在几秒钟内完成,使用简单、直观且易于阅读的命令。

还有更多...

显然,容器和虚拟机似乎是非常相似的概念。但尽管这两种解决方案有共同的特征,它们是深刻不同的技术,就像我们必须开始思考我们应用程序的架构是如何不同的。我们可以创建一个包含我们的单体应用程序的容器,但这样,我们将无法充分利用容器(以及 Docker)的优势。

适用于容器基础设施的可能软件架构是经典的微服务架构。其理念是将应用程序分解成许多小的组件——每个组件都有其特定的任务——能够交换消息并相互协作。这些组件的部署将以许多容器的形式单独进行。

一个可以用微服务处理的场景,用虚拟机处理则完全不切实际,因为每个新实例化的虚拟机都会要求主机机器消耗大量的能量。另一方面,容器非常轻量,因为它们执行的是与虚拟机完全不同的虚拟化:

虚拟机中的微服务架构和 Docker 实现

在虚拟机中,一个名为 Hypervisor 的工具负责从主机操作系统预留(静态或动态)一定数量的资源,以专门用于一个或多个称为 guestshosts 的操作系统。客户操作系统将完全隔离于主机操作系统。这种机制在资源方面非常昂贵,因此将微服务与虚拟机结合起来的想法是完全不可能的。

与此相反,容器对问题的贡献完全不同。隔离性更加平淡无奇,所有运行的容器都与底层操作系统共享相同的内核。虚拟机管理程序的开销完全消失,单个主机可以托管数百个容器。

当我们要求 Docker 从其镜像运行一个容器时,它必须存在于本地磁盘上,否则 Docker 会警告我们问题(显示无法在本地找到 'hello-world: latest' 镜像的消息)并自动下载它。

要找出我们计算机上从 Docker 下载了哪些镜像,我们使用 docker images 命令:

C:\>docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
dockerize.py latest bc3d70b05ed4 23 hours ago 91.8MB
<none> <none> ca18efb44b3c 24 hours ago 91.8MB
python alpine3.7 00be2573e9f7 2 months ago 81.3MB

仓库是相关镜像的容器。例如,dockerize 仓库包含 dockerize 镜像的各种版本。在 Docker 世界中,术语 tag 更准确地用来表达镜像版本化的概念。在先前的代码示例中,镜像已被标记为最新版本,并且是 dockerize 仓库中唯一的标记。

最新标记是默认标记:当我们提到仓库而没有指定标记名称时,Docker 会隐式地引用最新标记,如果这个标记不存在,则会显示错误。因此,作为一个最佳实践,仓库标记形式会更可取,因为它允许对镜像内容的预测性更高,避免容器之间可能出现的冲突以及由于缺少最新标记而导致的错误。

参见

容器技术是一个非常广泛的概念,可以通过查阅网络上的众多文章和应用程序示例来探索。然而,在开始这段漫长而艰难的旅程之前,建议从网站 (www.docker.com/) 开始,该网站内容全面,信息丰富。

在下一节中,我们将探讨无服务器计算的主要功能,其主要目标是使软件开发者更容易编写旨在在云平台上运行的代码。

介绍无服务器计算

在近年来,一种名为 函数即服务 (FaaS) 的新服务模型得到了发展,它也被称为 无服务器计算

无服务器计算是一种云计算范式,允许在不担心与底层基础设施相关问题的前提下执行应用程序。术语 无服务器 可能具有误导性;实际上,可能会认为这种模型没有预见处理服务器的使用。实际上,它表明应用程序执行的服务的配置、可伸缩性和管理都是自动进行的,并且对开发者来说是完全透明的。所有这一切都得益于一个名为 无服务器 的新架构模型。

第一个 FaaS 模型可以追溯到 Amazon,当时在 2014 年发布了 AWS Lambda 服务。随着时间的推移,亚马逊解决方案中添加了其他替代方案,这些替代方案由其他主要供应商开发,例如拥有 Azure FunctionsMicrosoft,以及 IBMGoogle,它们分别拥有自己的 Cloud Functions。还有有效的开源解决方案:在常用的解决方案中,我们有 Apache OpenWhisk,它被 IBMBluemix 上用于其无服务器产品,还有 OpenLambdaIronFunctions,后者基于 Docker 的容器技术。

在这个菜谱中,我们看到了如何通过 AWS Lambda 实现无服务器 Python 函数。

准备工作

AWS 是通过一个通用界面提供和管理的整个类别的云服务。console.aws.amazon.com/ 是 AWS 网络控制台中提供服务的通用界面可访问的地址。

此类服务是收费的。然而,在第一年,提供 免费层。这是一个使用最少资源的服务集,可以免费用于评估服务和开发应用程序。

有关如何创建 AWS 免费账户的详细信息,请参阅官方亚马逊文档aws.amazon.com

在这些部分中,我们将概述在 AWS Lambda 中运行代码的基本知识,而无需配置或管理任何服务器。我们将展示如何使用 AWS Lambda 控制台创建 Hello World 函数。我们还将解释如何使用示例事件数据手动调用 Lambda 函数以及如何解释输出参数。本教程中所示的所有操作都可以作为免费计划的一部分在 aws.amazon.com/free 上执行。

如何操作...

让我们看看以下步骤:

  1. 首件事是登录 Lambda 控制台(console.aws.amazon.com/console/home)。然后,您需要找到并选择计算下的 Lambda 以打开 AWS Lambda 控制台(如下面的截图所示,以绿色突出显示):

AWS:选择 Lambda 服务

  1. 然后,在 AWS Lambda 控制台中,选择立即开始,然后创建 Lambda 函数:

AWS:Lambda 启动页面

  1. 在过滤器框中,键入 hello-world-python 并选择 hello-world-python 蓝图。

  2. 现在我们需要配置 Lambda 函数。以下列表显示了配置并提供示例值:

  • 配置函数

  • 名称:在此处输入函数的名称。对于本教程,请输入 hello-world-python

  • 描述:在这里,您可以输入函数的简要描述。此框预先填充了短语“一个入门 AWS Lambda 函数”。

  • 运行时:目前,可以编写 Lambda 函数的代码,包括 Java、Node.js 和 Python 2.7、3.6 和 3.7。对于本教程,设置 Python 2.7 作为运行时。

  • Lambda 函数代码

  • 如您在以下截图中所见,可以查看 Python 示例代码。

  • Lambda 函数处理程序和角色

  • 处理程序:您可以为 AWS Lambda 指定一个启动执行代码的方法。AWS Lambda 将事件数据作为输入提供给处理程序,处理程序将处理这些事件。在此示例中,Lambda 从示例代码中识别事件,因此该字段将编译为 lambda_function.lambda_handler。

  • 角色:单击下拉菜单并选择基本执行角色:

AWS 配置函数页面

  1. 在这一点上,有必要创建一个执行角色(命名为 IAM 角色)并具有 AWS Lambda 作为 Lambda 函数执行者所需的授权。通过点击允许,将返回配置函数页面,并选择 lambda_basic_execution 函数:

AWS:角色摘要页面

  1. 控制台将代码保存为压缩文件,该文件代表分发包。然后,控制台将分发包加载到 AWS Lambda 中以创建 Lambda 函数:

AWS:Lambda 审查页面

现在可以测试函数,检查结果,并显示日志:

  1. 要运行我们的第一个 Lambda 函数,请点击测试:

图片

AWS:Lambda 测试页面

  1. 在弹出编辑器中输入一个事件以测试该函数。

  2. 在输入测试事件页面上从示例事件模板列表中选择 Hello World:

图片

AWS:Lambda 模板

点击保存并测试。然后,AWS Lambda 将代表你执行该函数。

它是如何工作的...

执行完成后,可以在控制台中查看结果:

  • 执行结果部分记录了函数的正确执行。

  • 摘要部分显示了日志输出部分报告的最重要信息。

  • 日志输出部分显示了 Lambda 函数执行生成的日志:

图片

AWS:执行结果

更多内容...

AWS Lambda 监控函数,并通过 Amazon CloudWatch 自动生成参数报告(见以下截图)。为了简化执行期间的代码监控,AWS Lambda 自动跟踪请求数量、每个请求的延迟和错误请求的数量,并发布相关参数:

图片

什么是 Lambda 函数?

Lambda 函数包含开发者在响应某些事件时想要执行的一段代码。开发者负责配置此代码,并在参考提供商的控制台中指定资源要求。其他所有事情,包括资源的大小,都由提供商根据所需的工作量自动管理。

为什么选择无服务器?

无服务器计算的好处如下:

  • 无需基础设施管理: 开发者可以专注于要构建的产品,而不是运行时服务器的操作和管理。

  • 自动可伸缩性: 资源会自动调整以应对任何类型的工作量,无需进行可伸缩性配置,而是对实时事件做出反应。

  • 资源使用优化: 由于处理和存储资源是动态分配的,因此不再需要在事先投资过剩的容量。

  • 成本降低: 在传统云计算中,即使资源未被实际使用,也需要支付运行资源的费用。在无服务器的情况下,应用程序是事件驱动的,这意味着当应用程序代码未运行时,不会产生费用,因此你不需要为未使用的资源付费。

  • 高可用性: 管理基础设施和应用程序的服务保证高可用性和容错性。

  • 上市时间改进: 消除基础设施管理费用使开发者能够专注于产品质量,并更快地将代码投入生产。

可能的问题和限制

在评估采用无服务器计算时,有一些缺点需要考虑:

  • 可能的性能损失:如果代码不是非常频繁地使用,那么在执行过程中可能会出现延迟问题。与在服务器、虚拟机或容器上持续执行的情况相比,这些问题更为突出。这是因为(与使用自动扩展策略时发生的情况相反)在无服务器模型中,如果代码未被使用,云提供商通常会完全释放资源。这意味着如果启动时间需要一些时间,那么在初始启动阶段不可避免地会创建额外的延迟。

  • 无状态模式:无服务器函数以无状态模式运行。这意味着如果您想添加逻辑来保存一些元素,例如将参数作为参数传递给不同函数,那么您需要向应用程序流程中添加一个持久存储组件并将事件相互链接。例如,亚马逊提供了一个名为 AWS Step Functions 的额外工具,它协调和管理无服务器应用程序的所有微服务和分布式组件的状态。

  • 资源限制:无服务器计算不适合某些类型的工作负载或用例,尤其是高性能类型和云提供商强加的资源使用限制(例如,AWS 限制了 Lambda 函数并发运行的数量)。这两者都是由于在有限和固定的时间内难以提供所需服务器数量的困难。

  • 调试和监控:如果您依赖于非开源解决方案,那么开发人员将依赖于供应商进行应用程序的调试和监控,因此,他们无法通过使用额外的分析器或调试器来详细诊断任何问题。因此,他们必须依赖于各自提供商提供的工具。

参见

正如我们所见,与无服务器架构一起工作的参考点是 AWS 框架(aws.amazon.com/)。在先前的网址上,您可以找到大量信息和教程,包括本节中描述的示例。

第八章:异构计算

本章将帮助我们通过 Python 语言探索 Graphics Processing UnitGPU)编程技术。GPU 的持续进化揭示了这些架构如何为执行复杂计算带来巨大好处。

显然,GPU 不能取代 CPU。然而,它们是结构良好且异构的代码,能够利用两种处理器(实际上)的优势,从而带来相当大的优势。

我们将检查异构编程的主要开发环境,即用于 Compute Unified Device ArchitectureCUDA)的 PyCUDANumba 环境,以及用于 Open Computing LanguageOpenCL)框架的 Python 版本的 PyOpenCL 环境。

在本章中,我们将涵盖以下食谱:

  • 理解异构计算

  • 理解 GPU 架构

  • 理解 GPU 编程

  • 处理 PyCUDA

  • 使用 PyCUDA 进行异构编程

  • 使用 PyCUDA 实现内存管理

  • 介绍 PyOpenCL

  • 使用 PyOpenCL 构建应用程序

  • 使用 PyOpenCL 的元素级表达式

  • 评估 PyOpenCL 应用程序

  • 使用 Numba 进行 GPU 编程

让我们从详细了解异构计算开始。

理解异构计算

多年来,为了提高日益复杂的计算性能,人们采用了计算机使用的新技术。其中一种技术被称为 异构计算,其目的是以合作的方式与不同(或异构)的处理器协同工作,从而在时间计算效率方面具有优势(特别是)。

在这种情况下,运行主程序的处理器的(通常为 CPU)被称为 主机,而协处理器(例如 GPU)被称为 设备。后者通常与主机物理分离,并管理自己的内存空间,该空间也独立于主机的内存。

尤其是在显著的市场需求下,GPU 已经发展成为一个高度并行的处理器,将 GPU 从图形渲染设备转变为可并行化和计算密集型通用计算的设备。

实际上,将 GPU 用于屏幕渲染图形以外的任务被称为异构计算。

最后,良好的 GPU 编程任务是要充分利用图形卡提供的并行性和数学能力,同时最小化它所呈现的所有不利因素,例如主机和设备之间物理连接的延迟。

理解 GPU 架构

GPU 是一种专门用于图形数据处理以从多边形原语渲染图像的 CPU/core。一个好的 GPU 程序的任务是充分利用图形卡提供的并行性和数学能力,并最大限度地减少它所呈现的所有缺点,例如主机和设备之间物理连接的延迟。

GPU 以高度并行结构为特征,允许你以高效的方式操作大量数据集。这一特性与硬件性能程序的快速改进相结合,将科学界的注意力吸引到使用 GPU 进行除渲染图像之外的其他用途的可能性。

GPU(参见图表)由几个称为 流多处理器SMs)的处理单元组成,它们代表了并行逻辑的第一级。实际上,每个 SM 都与其他 SM 同时独立工作:

GPU 架构

每个 SM 被划分为一组 流处理器SPs),它们有一个可以顺序运行线程的核心。SP 代表执行逻辑的最小单元和更细粒度的并行级别。

为了最好地编程这种架构,我们需要介绍 GPU 编程,这在下一节中将有描述。

理解 GPU 编程

GPU 的可编程性越来越高。实际上,它们的指令集已经扩展,允许执行更多的任务。

今天,在 GPU 上可以执行经典的 CPU 编程指令,如循环和条件、内存访问和浮点运算。两大独立显卡制造商——NVIDIAAMD——已经开发了他们的 GPU 架构,为开发者提供了相关的开发环境,允许使用不同的编程语言进行编程,包括 Python。

目前,开发者拥有在非纯图形相关环境中编程使用 GPU 的软件的有价值工具。在异构计算的主要开发环境中,我们有 CUDA 和 OpenCL。

让我们详细地看看它们。

CUDA

CUDA 是 NVIDIA 的专有硬件架构,同时也为其相关的开发环境命名。目前,CUDA 拥有数十万活跃的开发者,这表明在并行编程环境中,对这项技术的兴趣正在不断增长。

CUDA 为最常用的编程语言提供了扩展,包括 Python。最著名的 CUDA Python 扩展如下:

我们将在接下来的章节中使用这些扩展。

OpenCL

并行计算的第二大主角是 OpenCL,与它的 NVIDIA 对应版本不同,OpenCL 是一个开放标准,不仅可以用在不同制造商的 GPU 上,还可以用于不同类型的微处理器。

然而,OpenCL 是一个更完整、更通用的解决方案,因为它不具备 CUDA 所具有的成熟度和易用性。

OpenCL 的 Python 扩展是 PyOpenCL([mathema.tician.de/software/pyopencl/](https://mathema.tician.de/software/pyopencl/))。

在以下章节中,将分析 CUDA 和 OpenCL 的编程模型,并伴随一些有趣的应用示例。

处理 PyCUDA

PyCUDA 是一个绑定库,由安德烈亚斯·克洛克纳(Andreas Klöckner)提供,它通过 Python API 访问 CUDA。主要特性包括与对象生命周期相关的自动清理,从而防止泄漏,方便的模块和缓冲区抽象,对驱动程序的完全访问,以及内置的错误处理。它也非常轻量。

该项目在 MIT 许可证下开源,文档非常清晰,许多在线找到的不同来源可以提供帮助和支持。PyCUDA 的主要目的是让开发者以最小的抽象从 Python 调用 CUDA,它还支持 CUDA 元编程和模板化。

准备工作

请按照安德烈亚斯·克洛克纳(Andreas Klöckner)主页上的说明([https://mathema.tician.de/software/pycuda/](https://mathema.tician.de/software/pycuda/))安装 PyCUDA。

下一个编程示例具有双重功能:

  • 第一件事是验证 PyCUDA 是否正确安装。

  • 第二步是读取并打印 GPU 卡的特性。

如何操作...

让我们看看以下步骤:

  1. 通过第一条指令,我们将 Python 驱动程序(即 pycuda.driver)导入到我们 PC 上安装的 CUDA 库中:
import pycuda.driver as drv
  1. 初始化 CUDA。注意,以下指令必须在 pycuda.driver 模块中的任何其他指令之前调用:
drv.init()
  1. 列出 PC 上的 GPU 卡数量:
print ("%d device(s) found." % drv.Device.count())
  1. 对于每块现有的 GPU 卡,打印出型号名称、计算能力和设备上的总内存量(以千字节为单位):
for ordinal i n range(drv.Device.count()): 
       dev = drv.Device(ordinal) 
       print ("Device #%d: %s" % (ordinal, dev.name()) 
       print ("Compute Capability: %d.%d"% dev.compute_capability()) 
       print ("Total Memory: %s KB" % (dev.total_memory()//(1024))) 

它是如何工作的...

执行相当简单。在代码的第一行,导入 pycuda.driver 并初始化:

import pycuda.driver as drv  
drv.init() 

pycuda.driver 模块将驱动程序级别暴露给 CUDA 的编程接口,这比 CUDA C 运行时级别的编程接口更灵活,并且它有一些运行时不具备的特性。

然后,它循环进入 drv.Device.count() 函数,并为每块 GPU 打印卡名及其主要特性(计算能力和总内存):

print ("Device #%d: %s" % (ordinal, dev.name()))  
print ("Compute Capability: %d.%d" % dev.compute_capability()) 
print ("Total Memory: %s KB" % (dev.total_memory()//(1024))) 

执行以下代码:

C:\>python dealingWithPycuda.py

完成这些操作后,安装的 GPU 将显示在屏幕上,如下例所示:

1 device(s) found.
Device #0: GeForce GT 240
Compute Capability: 1.2
Total Memory: 1048576 KB

更多...

CUDA 编程模型(以及随之而来的 PyCUDA,它是一个 Python 包装器)是通过针对 C 语言标准库的特定扩展来实现的。这些扩展就像标准 C 库中的函数调用一样被创建,允许采用一种简单的方法来实现包含主机和设备代码的异构编程模型。这两个逻辑部分的管理是通过nvcc编译器完成的。

这里简要描述了它是如何工作的:

  1. 分离设备代码和主机代码。

  2. 调用默认编译器(例如,GCC)来编译主机代码。

  3. 构建设备代码的二进制形式(.cubin对象)或汇编形式(PTX对象):

图片

PyCUDA 执行模型

所有的前述步骤都是在 PyCUDA 执行过程中完成的,与 CUDA 应用程序相比,应用程序的加载时间有所增加。

参见

使用 PyCUDA 进行异构编程

CUDA 编程模型(以及 PyCUDA 的模型)旨在在 CPU 和 GPU 上联合执行软件应用程序,以便在 CPU 上执行应用程序的串行部分,在 GPU 上执行可并行化的部分。不幸的是,计算机还不够智能,无法自主地理解如何分配代码,因此需要开发者指出哪些部分应由 CPU 和 GPU 执行。

实际上,CUDA 应用程序由串行组件组成,这些组件由系统 CPU 或主机执行,或者由并行组件,即内核执行,这些内核由 GPU 或设备执行。

内核被定义为网格,可以进一步分解成块,这些块依次分配给各个多处理器,从而实现粗粒度并行性。在块内部,有基本的计算单元,即线程,具有非常细粒度的并行性。一个线程只能属于一个块,并且在整个内核中通过一个唯一的索引来识别。为了方便,可以使用二维索引来表示块,使用三维索引来表示线程。内核在它们之间按顺序执行。另一方面,块和线程是并行执行的。运行的线程数(并行运行)取决于它们在块中的组织以及它们对资源的请求,相对于设备中可用的资源。

为了可视化之前表达的概念,请参考sites.google.com/site/computationvisualization/programming/cuda/article1中的(图 5)。

块被设计成保证可扩展性。事实上,如果你有一个具有两个多处理器的架构,另一个具有四个多处理器的架构,那么,GPU 应用程序可以在这两种架构上执行,显然,时间和并行化水平会有所不同。

根据 PyCUDA 编程模型执行异构程序的结构如下:

  1. 在主机上 分配内存。

  2. 数据从主机内存传输到设备内存。

  3. 运行 设备,通过调用内核函数。

  4. 结果从设备内存传输到主机内存。

  5. 释放 设备上分配的内存。

下面的图显示了根据 PyCUDA 编程模型执行的程序执行流程:

PyCUDA 编程模型

在下一个例子中,我们将通过一个具体的编程方法示例来展示如何构建 PyCUDA 应用程序。

如何做到这一点...

为了展示 PyCUDA 编程模型,我们考虑了这样一个任务:需要将一个 5 × 5 矩阵的所有元素都加倍:

  1. 我们导入执行任务所需的库:
import PyCUDA.driver as CUDA 
import PyCUDA.autoinit 
from PyCUDA.compiler import SourceModule 
import numpy 
  1. 我们导入的 numpy 库允许我们构建问题的输入,即一个 5 × 5 矩阵,其值是随机选择的:
a = numpy.random.randn(5,5) 
a = a.astype(numpy.float32) 
  1. 因此构建的矩阵必须从主机内存复制到设备内存。为此,我们在设备上分配了一个内存空间 (a*_*gpu),这是包含矩阵 a 所必需的。为此,我们使用 mem_alloc 函数,该函数的主题是分配的内存空间。特别是,矩阵 a 的字节数,由 a.nbytes 参数表示,如下所示:
a_gpu = cuda.mem_alloc(a.nbytes) 
  1. 之后,我们可以使用 memcpy_htod 函数将矩阵从主机传输到设备上专门创建的内存区域:
cuda.memcpy_htod(a_gpu, a) 
  1. 在设备内部,doubleMatrix 内核函数将运行。其目的是将输入矩阵的每个元素乘以 2。正如你所见,doubleMatrix 函数的语法类似于 C 语言,而 SourceModule 语句是 NVIDIA 编译器(nvcc 编译器)的一个真实指令,它创建了一个模块,在这个例子中,该模块只包含 doubleMatrix 函数:
mod = SourceModule(""" 
  __global__ void doubles_matrix(float *a){ 
    int idx = threadIdx.x + threadIdx.y*4; 
    a[idx] *= 2;} 
  """)
  1. 通过 func 参数,我们识别出包含在 mod 模块中的 doubleMatrix 函数:
func = mod.get_function("doubles_matrix") 
  1. 最后,我们运行内核函数。为了在设备上成功执行内核函数,CUDA 用户必须指定内核的输入和执行线程块的大小。在以下情况下,输入是之前复制到设备的 a_gpu 矩阵,而线程块的大小是 (5,5,1)
func(a_gpu, block=(5,5,1)) 
  1. 因此,我们分配了一个与输入矩阵 a 大小相等的内存区域:
a_doubled = numpy.empty_like(a) 
  1. 然后,我们将分配给设备的内存区域的内容(即 a_gpu 矩阵)复制到之前定义的内存区域 a_doubled
cuda.memcpy_dtoh(a_doubled, a_gpu) 
  1. 最后,我们打印输入矩阵 a 和输出矩阵的内容,以验证实现的品质:
print ("ORIGINAL MATRIX") 
print (a) 
print ("DOUBLED MATRIX AFTER PyCUDA EXECUTION") 
print (a_doubled) 

它是如何工作的...

让我们从查看此示例中导入的哪些库开始:

import PyCUDA.driver as CUDA 
import PyCUDA.autoinit 
from PyCUDA.compiler import SourceModule 

特别是,autoinit 导入自动识别我们系统上哪个 GPU 可用于执行,而 SourceModule 是 NVIDIA (nvcc) 编译器的指令,允许我们识别必须编译并上传到设备的对象。

然后,我们使用 numpy 库构建 5 × 5 输入矩阵:

import numpy 
a = numpy.random.randn(5,5) 

在这种情况下,矩阵中的元素被转换为单精度模式(因为执行此示例的图形卡只支持单精度):

a = a.astype(numpy.float32) 

然后,我们使用以下两个操作将数组从主机复制到设备:

a_gpu = CUDA.mem_alloc(a.nbytes) 
CUDA.memcpy_htod(a_gpu, a) 

注意,在内核函数执行过程中,设备和主机内存可能永远不会通信。因此,为了在设备上并行执行内核函数,所有与内核函数相关的输入数据也必须存在于设备的内存中。

还应注意的是,a_gpu 矩阵是线性化的,也就是说,它是一维的,因此我们必须这样管理它。

此外,所有这些操作都不需要内核调用。这意味着它们是由主机直接执行的。

SourceModule 实体允许定义 doubleMatrix 内核函数。__global__ 是一个 nvcc 指令,表示 doubleMatrix 函数将由设备处理:

mod = SourceModule(""" 
  __global__ void doubleMatrix(float *a) 

让我们考虑内核的主体。idx 参数是矩阵索引,由 threadIdx.xthreadIdx.y 线程坐标标识:

    int idx = threadIdx.x + threadIdx.y*4; 
    a[idx] *= 2; 

然后,mod.get_function("doubleMatrix") 返回一个指向 func 参数的标识符:

func = mod.get_function("doubleMatrix ") 

为了执行内核,我们需要配置执行上下文。这意味着通过在 func 调用内部使用块参数设置属于块网格的线程的三维结构:

func(a_gpu, block = (5, 5, 1)) 

block = (5, 5, 1) 告诉我们正在调用一个内核函数,该函数使用 a_gpu 线性化输入矩阵,并且有一个大小为 5 的单个线程块(即 5 个线程)在 x 方向上,5 个线程在 y 方向上,以及 1 个线程在 z 方向上,总共 16 个线程。请注意,每个线程执行相同的内核代码(总共 25 个线程)。

在 GPU 设备上的计算完成后,我们使用一个数组来存储结果:

a_doubled = numpy.empty_like(a) 
CUDA.memcpy_dtoh(a_doubled, a_gpu) 

要运行此示例,请在命令提示符中键入以下内容:

C:\>python heterogenousPycuda.py

输出应该是这样的:

ORIGINAL MATRIX
[[-0.59975582 1.93627465 0.65337795 0.13205571 -0.46468592]
[ 0.01441949 1.40946579 0.5343408 -0.46614054 -0.31727529]
[-0.06868593 1.21149373 -0.6035406 -1.29117763 0.47762445]
[ 0.36176383 -1.443097 1.21592784 -1.04906416 -1.18935871]
[-0.06960868 -1.44647694 -1.22041082 1.17092752 0.3686313 ]] 
DOUBLED MATRIX AFTER PyCUDA EXECUTION
[[-1.19951165 3.8725493 1.3067559 0.26411143 -0.92937183]
[ 0.02883899 2.81893158 1.0686816 -0.93228108 -0.63455057]
[-0.13737187 2.42298746 -1.2070812 -2.58235526 0.95524889]
[ 0.72352767 -2.886194 2.43185568 -2.09812832 -2.37871742]
[-0.13921736 -2.89295388 -2.44082164 2.34185504 0.73726263 ]]

还有更多...

CUDA 的关键特性使得这种编程模型与其他并行模型(通常用于 CPU)有显著不同,它要求为了高效,需要成千上万的线程处于活动状态。这是由 GPU 的典型结构实现的,它使用轻量级线程,并且允许非常快速和高效地创建和修改执行上下文。

注意,线程的调度直接与 GPU 架构及其固有的并行性相关联。实际上,一个线程块被分配给单个 SM。在这里,线程被进一步分为组,称为 warp。属于同一 warp 的线程由线程调度器管理。为了充分利用 SM 的固有并行性,同一 warp 中的线程必须执行相同的指令。如果这个条件不成立,那么我们称之为线程发散

参见

使用 PyCUDA 实现内存管理

PyCUDA 程序应遵守由 SM 的结构和内部组织规定的规则,这些规则对线程性能施加了限制。实际上,了解并正确使用 GPU 提供的各种类型的内存对于实现最大效率是基本的。在这些为 CUDA 使用而启用的 GPU 卡上,有四种类型的内存,如下所示:

  • 寄存器:每个线程都被分配了一个内存寄存器,只有分配的线程可以访问,即使线程属于同一块。

  • 共享内存:每个块都有其自己的共享内存,属于该块中的线程。即使这种内存也极其快速。

  • 常量内存:网格中的所有线程都可以常量访问内存,但只能读取。其中存在的数据在整个应用程序的整个持续期间保持不变。

  • 全局内存:网格中的所有线程,因此所有内核,都可以访问全局内存。此外,数据持久性正好像常量内存:

GPU 内存模型

准备中

为了获得最佳性能,PyCUDA 程序必须充分利用每种类型的内存。特别是,它必须充分利用共享内存,最小化对全局级别内存的访问。

为了做到这一点,通常将问题域细分,以便单个线程块能够在数据的一个封闭子集中执行其处理。这样,在单个块上操作的线程都将一起在相同的共享内存区域工作,优化访问。

每个线程的基本步骤如下:

  1. 全局内存中加载数据到共享内存。

  2. 同步块中的所有线程,以便每个人都可以读取由其他线程填充的安全位置和共享内存。

  3. 处理共享内存中的数据。创建新的同步是必要的,以确保共享内存已更新为结果。

  4. 写入结果到全局内存中。

为了阐明这种方法,在下一节中,我们将基于两个矩阵乘积的计算提供一个示例。

如何操作...

以下代码片段显示了使用标准方法计算两个矩阵 M×N 的乘积,该方法基于顺序方法。输出矩阵 P 的每个元素是通过从矩阵 M 中取一行元素和从矩阵 N 中取一列元素得到的:

void SequentialMatrixMultiplication(float*M,float *N,float *P, int width){ 
  for (int i=0; i< width; ++i) 
      for(int j=0;j < width; ++j) { 
          float sum = 0; 
          for (int k = 0 ; k < width; ++k) { 
              float a = M[I * width + k]; 
              float b = N[k * width + j]; 
              sum += a * b; 
                     } 
         P[I * width + j] = sum; 
    } 
} 
P[I * width + j] = sum; 

在这种情况下,如果每个线程都被分配计算矩阵每个元素的任务,那么对内存的访问将主导算法的执行时间。

我们可以依赖一个线程块一次计算一个输出子矩阵。这样,访问相同内存块的线程可以合作优化访问,从而最小化总计算时间:

  1. 第一步是加载实现算法所需的所有模块:
import numpy as np 
from pycuda import driver, compiler, gpuarray, tools 
  1. 然后,初始化 GPU 设备:
import pycuda.autoinit 
  1. 我们实现 kernel_code_template,它实现了分别用 ab 指示的两个矩阵的乘积,而结果矩阵用参数 c 指示。请注意,MATRIX_SIZE 参数将在下一步定义:
kernel_code_template = """ 
__global__ void MatrixMulKernel(float *a, float *b, float *c) 
{ 
    int tx = threadIdx.x; 
    int ty = threadIdx.y; 
    float Pvalue = 0; 
    for (int k = 0; k < %(MATRIX_SIZE)s; ++k) { 
        float Aelement = a[ty * %(MATRIX_SIZE)s + k]; 
        float Belement = b[k * %(MATRIX_SIZE)s + tx]; 
        Pvalue += Aelement * Belement; 
    } 
    c[ty * %(MATRIX_SIZE)s + tx] = Pvalue; 
}""" 
  1. 以下参数将用于设置矩阵的维度。在这种情况下,大小是 5 × 5:
MATRIX_SIZE = 5
  1. 我们定义两个输入矩阵 a_cpub_cpu,它们将包含随机浮点值:
a_cpu = np.random.randn(MATRIX_SIZE, MATRIX_SIZE).astype(np.float32) 
b_cpu = np.random.randn(MATRIX_SIZE, MATRIX_SIZE).astype(np.float32)
  1. 然后,我们在主机设备上计算两个矩阵 ab 的乘积:
c_cpu = np.dot(a_cpu, b_cpu) 
  1. 我们在设备(GPU)上分配与输入矩阵大小相等的内存区域:
a_gpu = gpuarray.to_gpu(a_cpu)  
b_gpu = gpuarray.to_gpu(b_cpu) 
  1. 我们在 GPU 上分配一个内存区域,其大小与两个矩阵乘积得到的输出矩阵大小相同。在这种情况下,得到的矩阵 c_gpu 的大小将是 5 × 5:
c_gpu = gpuarray.empty((MATRIX_SIZE, MATRIX_SIZE), np.float32) 
  1. 以下 kernel_code 重新定义了 kernel_code_template,但设置了 matrix_size 参数:
kernel_code = kernel_code_template % { 
    'MATRIX_SIZE': MATRIX_SIZE} 
  1. SourceModule 指令告诉 nvccNVIDIA CUDA 编译器)它需要创建一个模块——即包含之前定义的 kernel_code 的函数集合:
mod = compiler.SourceModule(kernel_code) 
  1. 最后,我们从模块 mod 中取出 MatrixMulKernel 函数,并将其命名为 matrixmul
matrixmul = mod.get_function("MatrixMulKernel")
  1. 我们执行两个矩阵 a_gpub_gpu 的乘积,得到 c_gpu 矩阵。线程块的大小定义为 MATRIX_SIZE, MATRIX_SIZE, 1
matrixmul( 
    a_gpu, b_gpu,  
    c_gpu,  
    block = (MATRIX_SIZE, MATRIX_SIZE, 1))
  1. 打印输入矩阵:
print ("-" * 80) 
print ("Matrix A (GPU):") 
print (a_gpu.get()) 
print ("-" * 80) 
print ("Matrix B (GPU):") 
print (b_gpu.get()) 
print ("-" * 80) 
print ("Matrix C (GPU):") 
print (c_gpu.get()) 
  1. 为了检查在 GPU 上执行的计算的有效性,我们比较两种实现的计算结果,即主机设备(CPU)上执行的和在设备(GPU)上执行的计算。为此,我们使用 numpy allclose 指令,该指令验证两个逐元素数组在等于 1e-05 的容差内是否相等:
np.allclose(c_cpu, c_gpu.get()) 

它是如何工作的...

让我们考虑 PyCUDA 编程工作流程。让我们准备输入矩阵、输出矩阵以及存储结果的位置:

MATRIX_SIZE = 5 
a_cpu = np.random.randn(MATRIX_SIZE, MATRIX_SIZE).astype(np.float32) 
b_cpu = np.random.randn(MATRIX_SIZE, MATRIX_SIZE).astype(np.float32) 
c_cpu = np.dot(a_cpu, b_cpu) 

然后,我们使用 gpuarray.to_gpu() PyCUDA 函数将这些矩阵传输到 GPU 设备:

a_gpu = gpuarray.to_gpu(a_cpu)  
b_gpu = gpuarray.to_gpu(b_cpu) 
c_gpu = gpuarray.empty((MATRIX_SIZE, MATRIX_SIZE), np.float32) 

算法的核心是以下内核函数。请注意,__global__ 关键字指定此函数是一个内核函数,这意味着它将在设备(GPU)上执行,随后是主机代码(CPU)的调用:

__global__ void MatrixMulKernel(float *a, float *b, float *c){
    int tx = threadIdx.x;
    int ty = threadIdx.y;
    float Pvalue = 0;
    for (int k = 0; k < %(MATRIX_SIZE)s; ++k) {
        float Aelement = a[ty * %(MATRIX_SIZE)s + k];
        float Belement = b[k * %(MATRIX_SIZE)s + tx];
        Pvalue += Aelement * Belement;}
    c[ty * %(MATRIX_SIZE)s + tx] = Pvalue;
}

threadIdx.xthreadIdy.y 是坐标,允许识别二维块网格中的线程。请注意,网格块内的线程执行相同的内核代码,但处理不同的数据。如果我们比较并行版本和顺序版本,那么我们立即会注意到循环索引 ij 已经被 threadIdx.xthreadIdx.y 索引所取代。

这意味着在并行版本中,我们将只有一个循环迭代。实际上,MatrixMulKernel 内核将在一个 5 × 5 并行线程的网格上执行。

这种条件在以下图中表示:

示例的线程网格和块组织

然后,我们通过比较两个结果矩阵来验证乘积计算:

np.allclose(c_cpu, c_gpu.get())

输出如下:

C:\>python memManagementPycuda.py

---------------------------------------------------------------------
Matrix A (GPU):
[[ 0.90780383 -0.4782407 0.23222363 -0.63184392 1.05509627]
 [-1.27266967 -1.02834761 -0.15528528 -0.09468858 1.037099 ]
 [-0.18135822 -0.69884419 0.29881889 -1.15969539 1.21021318]
 [ 0.20939326 -0.27155793 -0.57454145 0.1466181 1.84723163]
 [ 1.33780348 -0.42343542 -0.50257754 -0.73388749 -1.883829 ]]
---------------------------------------------------------------------
Matrix B (GPU):
[[ 0.04523897 0.99969769 -1.04473436 1.28909719 1.10332143]
 [-0.08900332 -1.3893919 0.06948703 -0.25977209 -0.49602833]
 [-0.6463753 -1.4424541 -0.81715286 0.67685211 -0.94934392]
 [ 0.4485206 -0.77086055 -0.16582981 0.08478995 1.26223004]
 [-0.79841441 -0.16199949 -0.35969591 -0.46809086 0.20455229]]
---------------------------------------------------------------------
Matrix C (GPU):
[[-1.19226956 1.55315971 -1.44614291 0.90420711 0.43665022]
 [-0.73617989 0.28546685 1.02769876 -1.97204924 -0.65403283]
 [-1.62555301 1.05654192 -0.34626681 -0.51481217 -1.35338223]
 [-1.0040834 1.00310731 -0.4568972 -0.90064859 1.47408712]
 [ 1.59797418 3.52156591 -0.21708387 2.31396151 0.85150564]]
---------------------------------------------------------------------

TRUE

还有更多...

在共享内存中分配的数据在单线程块中的可见性有限。很容易看出 PyCUDA 编程模型适应特定的应用程序类别。

特别是,这些应用程序必须展示的特性包括许多数学运算,具有高度的数据并行性(即,相同的操作序列在大量的数据上重复进行)。

拥有这些特性的应用程序领域都属于以下科学:密码学、计算化学、图像和信号分析。

参见

介绍 PyOpenCL

PyOpenCL 是 PyCUDA 的一个姐妹项目。它是一个绑定库,提供从 Python 对 OpenCL API 的完全访问,也是由 Andreas Klöckner 开发的。它具有与 PyCUDA 相似的多项概念,包括对超出作用域的对象的清理、对数据结构的部分抽象以及错误处理,所有这些都具有最小的开销。该项目在 MIT 许可证下可用;其文档非常好,可以在网上找到大量的指南和教程。

PyOpenCL 的主要重点是提供一个轻量级的连接,将 Python 和 OpenCL 连接起来,但它还包括对模板和元程序的支援。PyOpenCL 程序的流程几乎与 OpenCL 的 C 或 C++ 程序完全相同。主机程序准备调用设备程序,启动它,然后等待结果。

准备工作

PyOpenCL 安装的官方参考是 Andreas Klöckner 的主页:mathema.tician.de/software/pyopencl/

如果你使用 Anaconda,那么建议执行以下步骤:

  1. 从以下链接安装最新的 Anaconda 发行版,Python 3.7:www.anaconda.com/distribution/#download-section。对于本节,已安装 Windows 安装程序的 Anaconda 2019.07。

  2. 从以下链接获取 Christoph Gohlke 的 PyOpenCL 预构建二进制文件:www.lfd.uci.edu/~gohlke/pythonlibs/。选择正确的操作系统和 CPython 版本组合。在这里,我们使用pyopencl-2019.1+cl12-cp37-cp37m-win_amd64.whl

  3. 使用pip安装上一个包。只需在 Anaconda 提示符中输入以下内容:

(base) C:\> pip install <directory>\pyopencl-2019.1+cl12-cp37-cp37m-win_amd64.whl

<directory>是 PyOpenCL 包所在的文件夹。

此外,以下符号表示我们正在操作 Anaconda 提示符:

(base) C:\>

如何操作...

在以下示例中,我们将使用 PyOpenCL 的一个功能,该功能允许我们枚举它将运行的 GPU 的特性。

我们实现的代码非常简单且逻辑清晰:

  1. 在第一步中,我们导入pyopencl库:
import pyopencl as cl
  1. 我们构建一个函数,其输出将为我们提供正在使用的 GPU 硬件的特性:
def print_device_info() :
    print('\n' + '=' * 60 + '\nOpenCL Platforms and Devices')
    for platform in cl.get_platforms():
        print('=' * 60)
        print('Platform - Name: ' + platform.name)
        print('Platform - Vendor: ' + platform.vendor)
        print('Platform - Version: ' + platform.version)
        print('Platform - Profile: ' + platform.profile)

        for device in platform.get_devices():
            print(' ' + '-' * 56)
            print(' Device - Name: ' \
                  + device.name)
            print(' Device - Type: ' \
                  + cl.device_type.to_string(device.type))
            print(' Device - Max Clock Speed: {0} Mhz'\
                  .format(device.max_clock_frequency))
            print(' Device - Compute Units: {0}'\
                  .format(device.max_compute_units))
            print(' Device - Local Memory: {0:.0f} KB'\
                  .format(device.local_mem_size/1024.0))
            print(' Device - Constant Memory: {0:.0f} KB'\
                  .format(device.max_constant_buffer_size/1024.0))
            print(' Device - Global Memory: {0:.0f} GB'\
                  .format(device.global_mem_size/1073741824.0))
            print(' Device - Max Buffer/Image Size: {0:.0f} MB'\
                  .format(device.max_mem_alloc_size/1048576.0))
            print(' Device - Max Work Group Size: {0:.0f}'\
                  .format(device.max_work_group_size))
    print('\n')
  1. 因此,我们实现main函数,该函数调用之前实现的print_device_info函数:
if __name__ == "__main__":
    print_device_info()

它是如何工作的...

以下命令用于导入pyopencl库:

import pyopencl as cl

这使我们能够使用get_platforms方法,该方法返回一个平台实例列表,即系统中的设备列表:

for platform in cl.get_platforms():

然后,对于每个找到的设备,以下主要特性将被显示:

  • 名称和设备类型

  • 最大时钟速度

  • 计算单元

  • 局部/常量/全局内存

此示例的输出如下:

(base) C:\>python deviceInfoPyopencl.py

=============================================================
OpenCL Platforms and Devices
============================================================
Platform - Name: NVIDIA CUDA
Platform - Vendor: NVIDIA Corporation
Platform - Version: OpenCL 1.2 CUDA 10.1.152
Platform - Profile: FULL_PROFILE
    --------------------------------------------------------
    Device - Name: GeForce 840M
    Device - Type: GPU
    Device - Max Clock Speed: 1124 Mhz
    Device - Compute Units: 3
    Device - Local Memory: 48 KB
    Device - Constant Memory: 64 KB
    Device - Global Memory: 2 GB
    Device - Max Buffer/Image Size: 512 MB
    Device - Max Work Group Size: 1024
============================================================
Platform - Name: Intel(R) OpenCL
Platform - Vendor: Intel(R) Corporation
Platform - Version: OpenCL 2.0
Platform - Profile: FULL_PROFILE
    --------------------------------------------------------
    Device - Name: Intel(R) HD Graphics 5500
    Device - Type: GPU
    Device - Max Clock Speed: 950 Mhz
    Device - Compute Units: 24
    Device - Local Memory: 64 KB
    Device - Constant Memory: 64 KB
    Device - Global Memory: 3 GB
    Device - Max Buffer/Image Size: 808 MB
    Device - Max Work Group Size: 256
    --------------------------------------------------------
    Device - Name: Intel(R) Core(TM) i7-5500U CPU @ 2.40GHz
    Device - Type: CPU
    Device - Max Clock Speed: 2400 Mhz
    Device - Compute Units: 4
    Device - Local Memory: 32 KB
    Device - Constant Memory: 128 KB
    Device - Global Memory: 8 GB
    Device - Max Buffer/Image Size: 2026 MB
    Device - Max Work Group Size: 8192

更多...

OpenCL 目前由 Khronos Group 管理,这是一个非营利性公司联盟,它们合作定义了本(以及许多其他)标准的规范和合规参数,以创建针对每种平台类型的 OpenCL 特定驱动程序。

这些驱动程序还提供了编译用内核语言编写的程序的功能:这些程序被转换为某种形式的中间语言,通常是供应商特定的,然后在这些参考架构上执行。

在以下链接中可以找到有关 OpenCL 的更多信息:www.khronos.org/registry/OpenCL/

参见

使用 PyOpenCL 构建应用程序

构建 PyOpenCL 程序的第一步是编写主机应用程序的代码。这是在 CPU 上执行的,其任务是管理在 GPU 卡(即设备)上可能执行的内核。

内核是可执行代码的基本单元,类似于 C 函数。它可以进行数据并行或任务并行。然而,PyOpenCL 的基石是并行性的利用。

一个基本概念是程序,它是一组内核和其他函数的集合,类似于动态库。因此,我们可以将内核中的指令分组,并将不同的内核分组到程序中。

程序可以从应用程序中调用。我们有执行队列,指示内核执行的顺序。然而,在某些情况下,这些可以不按原始顺序启动。

我们可以最终列出使用 PyOpenCL 开发应用程序的基本元素:

  • 设备: 这标识了内核代码将要执行的硬件。请注意,PyOpenCL 应用程序可以在 CPU 和 GPU 板上(以及 PyCUDA)以及嵌入式设备上运行,例如现场可编程门阵列(FPGAs)。

  • 程序: 这是一个具有选择必须在设备上运行的哪个内核的任务的内核组。

  • 内核: 这是将在设备上执行的代码。内核是一个类似于 C 的函数,这意味着它可以在支持 PyOpenCL 驱动程序的任何设备上编译。

  • 命令队列: 这在设备上对内核的执行进行排序。

  • 上下文: 这是一个允许设备接收内核和传输数据的设备组。

下面的图示显示了这种数据结构如何在主机应用程序中工作:

图片

PyOpenCL 编程模型

再次,我们观察到程序可以包含更多要在设备上运行的函数,并且每个内核只封装程序中的一个函数。

如何做到这一点...

在以下示例中,我们展示了使用 PyOpenCL 构建应用程序的基本步骤:要执行的任务是两个向量的和。为了有一个可读的输出,我们将考虑两个各有 100 个元素的向量:结果向量的每个i元素将等于vector_ai元素与vector_bi元素的和:

  1. 让我们先导入所有必要的库:
import numpy as np 
import pyopencl as cl 
import numpy.linalg as la 
  1. 我们定义要添加的向量的大小,如下所示:
vector_dimension = 100 
  1. 在这里,定义了输入向量vector_avector_b
vector_a = np.random.randint(vector_dimension,size=vector_dimension) 
vector_b = np.random.randint(vector_dimension,size=vector_dimension) 
  1. 按顺序,我们定义platformdevicecontextqueue
platform = cl.get_platforms()[1] 
device = platform.get_devices()[0] 
context = cl.Context([device]) 
queue = cl.CommandQueue(context) 
  1. 现在,是时候组织包含输入向量的内存区域了:
mf = cl.mem_flags 
a_g = cl.Buffer(context, mf.READ_ONLY | mf.COPY_HOST_PTR,\ hostbuf=vector_a) 
b_g = cl.Buffer(context, mf.READ_ONLY | mf.COPY_HOST_PTR,\ hostbuf=vector_b) 
  1. 最后,我们使用Program方法构建应用程序内核:
program = cl.Program(context, """ 
__kernel void vectorSum(__global const int *a_g, __global const int *b_g, __global int *res_g) { 
  int gid = get_global_id(0); 
  res_g[gid] = a_g[gid] + b_g[gid]; 
} 
""").build()
  1. 然后,我们分配结果矩阵的内存:
res_g = cl.Buffer(context, mf.WRITE_ONLY, vector_a.nbytes) 
  1. 然后,我们调用内核函数:
program.vectorSum(queue, vector_a.shape, None, a_g, b_g, res_g) 
  1. 用于存储结果的内存空间在主机内存区域(res_np)中分配:
res_np = np.empty_like(vector_a) 
  1. 将计算结果复制到创建的内存区域:
cl._enqueue_copy(queue, res_np, res_g) 
  1. 最后,我们打印结果:
print ("PyOPENCL SUM OF TWO VECTORS") 
print ("Platform Selected = %s" %platform.name ) 
print ("Device Selected = %s" %device.name) 
print ("VECTOR LENGTH = %s" %vector_dimension) 
print ("INPUT VECTOR A") 
print (vector_a) 
print ("INPUT VECTOR B") 
print (vector_b) 
print ("OUTPUT VECTOR RESULT A + B ") 
print (res_np) 
  1. 然后,我们执行一个简单的检查,以验证求和操作是否正确:
assert(la.norm(res_np - (vector_a + vector_b))) < 1e-5 

它是如何工作的...

在以下行中,在相关导入之后,我们定义输入向量*:

vector_dimension = 100 
vector_a = np.random.randint(vector_dimension, size= vector_dimension) 
vector_b = np.random.randint(vector_dimension, size= vector_dimension) 

每个向量包含 100 个整数项,这些项通过numpy函数随机选择:

np.random.randint(max integer , size of the vector) 

然后,我们使用get_platform()方法选择平台以使用该平台进行计算:

platform = cl.get_platforms()[1] 

然后,选择相应的设备。在这里,platform.get_devices()[0]对应于 Intel(R) HD Graphics 5500 显卡:

device = platform.get_devices()[0]

在以下步骤中,定义了上下文和队列;PyOpenCL 提供了 context(选定的设备)和 queue(选定的上下文)方法:

context = cl.Context([device]) 
queue = cl.CommandQueue(context) 

为了在选定的设备上执行计算,输入向量被复制到设备的内存:

mf = cl.mem_flags 
a_g = cl.Buffer(context, mf.READ_ONLY | mf.COPY_HOST_PTR,\
hostbuf=vector_a) 
b_g = cl.Buffer(context, mf.READ_ONLY | mf.COPY_HOST_PTR,\
 hostbuf=vector_b) 

然后,我们为结果向量准备缓冲区:

res_g = cl.Buffer(context, mf.WRITE_ONLY, vector_a.nbytes) 

这里,内核代码被定义:

program = cl.Program(context, """ 
__kernel void vectorSum(__global const int *a_g, __global const int *b_g, __global int *res_g) { 
  int gid = get_global_id(0); 
  res_g[gid] = a_g[gid] + b_g[gid];} 
""").build()

vectorSum是内核的名称,参数列表定义了输入参数的数据类型和输出数据类型(两者都是整数向量)。在内核体内部,两个向量的和按照以下步骤定义:

  1. 初始化向量的索引:int gid = get_global_id(0)

  2. 求和向量的分量:res_g[gid] = a_g[gid] + b_g[gid]

在 OpenCL(因此,在 PyOpenCL 中),缓冲区被附加到一个上下文(documen.tician.de/pyopencl/runtime.html#pyopencl.Context),一旦缓冲区在该设备上使用,上下文就会移动到设备上。

最后,我们在设备上执行vectorSum

program.vectorSum(queue, vector_a.shape, None, a_g, b_g, res_g)

为了检查结果,我们使用assert语句。这测试结果,如果条件为假,则触发错误:

assert(la.norm(res_np - (vector_a + vector_b))) < 1e-5

输出应该是以下内容:

(base) C:\>python vectorSumPyopencl.py 

PyOPENCL SUM OF TWO VECTORS
Platform Selected = Intel(R) OpenCL
Device Selected = Intel(R) HD Graphics 5500
VECTOR LENGTH = 100
INPUT VECTOR A
 [45 46 0 97 96 98 83 7 51 21 72 70 59 65 79 92 98 24 56 6 70 64 59 0
 96 78 15 21 4 89 14 66 53 20 34 64 48 20 8 53 82 66 19 53 11 17 39 11
 89 97 51 53 7 4 92 82 90 78 31 18 72 52 44 17 98 3 36 69 25 87 86 68
 85 16 58 4 57 64 97 11 81 36 37 21 51 22 17 6 66 12 80 50 77 94 6 70
 21 86 80 69]
 INPUT VECTOR B
[25 8 76 57 86 96 58 89 26 31 28 92 67 47 72 64 13 93 96 91 91 36 1 75
 2 40 60 49 24 40 23 35 80 60 61 27 82 38 66 81 95 79 96 23 73 19 5 43
 2 47 17 88 46 76 64 82 31 73 43 17 35 28 48 89 8 61 23 17 56 7 84 36
 95 60 34 9 4 5 74 59 6 89 84 98 25 50 38 2 3 43 64 96 47 79 12 82
 72 0 78 5]
 OUTPUT VECTOR RESULT A + B
[70 54 76 154 182 194 141 96 77 52 100 162 126 112 151 156 111 117 152 
 97 161 100 60 75 98 118 75 70 28 129 37 101 133 80 95 91 130 58 74 134 
 177 145 115 76 84 36 44 54 91 144 68 141 53 80 156 164 121 151 74 35 
 107 80 92 106 106 64 59 86 81 94 170 104 80 76 92 13 61 69 171 70 87 
 125 121 119 76 72 55 8 69 55 144 146 124 173 18 152 93 86 158 74] 

还有更多...

在本节中,我们看到了 PyOpenCL 执行模型,类似于 PyCUDA,涉及一个主机处理器,该处理器管理一个或多个异构设备。特别是,每个 PyOpenCL 命令以通过内核函数定义的源代码形式从主机发送到设备。

然后,将源代码加载到参考架构的程序对象中,程序被编译到参考架构中,并创建了与程序相关的内核对象。

内核对象可以在可变数量的工作组中执行,创建一个n-维计算矩阵,允许它在每个工作组中有效地将n-维(1、2 或 3)的问题工作负载细分。反过来,它们由多个并行工作的工作项组成。

根据设备并行计算能力平衡每个工作组的负载是实现良好应用性能的关键参数之一。

工作负载的不当分配,以及每个设备的特定特性(例如传输延迟、吞吐量和带宽),可能导致性能显著下降,或者在未考虑任何基于设备计算能力的动态信息获取系统的情况下执行代码时,会损害代码的可移植性。

然而,这些技术的准确使用使我们能够通过结合不同计算单元的计算结果,达到高性能水平。

参见

更多关于 PyOpenCL 编程的信息可以在 pydanny-event-notes.readthedocs.io/en/latest/PyConPL2012/async_via_pyopencl.html 找到。

使用 PyOpenCL 的逐元素表达式

逐元素功能允许我们将复杂表达式(由更多操作数组成)评估为单个计算过程。

入门

ElementwiseKernel (context, argument, operation, name, optional_parameters) 方法是在 PyOpenCL 中实现的,用于处理逐元素表达式。

主要参数如下:

  • context 是执行逐元素操作的设备或设备组。

  • argument 是所有涉及计算的参数的类似于 C 语言的参数列表。

  • operation 是一个表示对参数列表执行的操作的字符串。

  • name 是与 Elementwisekernel 关联的内核的名称。

  • optional_parameters 在此配方中不是很重要。

如何做到这一点...

在这里,我们再次考虑添加两个整数向量的任务:

  1. 开始导入相关库:
import pyopencl as cl
import pyopencl.array as cl_array
import numpy as np
  1. 定义上下文元素(context)和命令队列(queue):
context = cl.create_some_context()
queue = cl.CommandQueue(context)
  1. 在这里,我们设置了向量的维度以及输入和输出向量的空间分配:
vector_dim = 100 
vector_a=cl_array.to_device(queue,np.random.randint(100,\
size=vector_dim)) 
vector_b = cl_array.to_device(queue,np.random.randint(100,\ 
size=vector_dim)) 
result_vector = cl_array.empty_like(vector_a) 
  1. 我们将 elementwiseSum 设置为 ElementwiseKernel 的应用,然后将其设置为定义对输入向量应用的操作的参数集:
elementwiseSum = cl.elementwise.ElementwiseKernel(context, "int *a,\
int *b, int *c", "c[i] = a[i] + b[i]", "sum")
elementwiseSum(vector_a, vector_b, result_vector)
  1. 最后,我们打印结果:
print ("PyOpenCL ELEMENTWISE SUM OF TWO VECTORS")
print ("VECTOR LENGTH = %s" %vector_dimension)
print ("INPUT VECTOR A")
print (vector_a)
print ("INPUT VECTOR B")
print (vector_b)
print ("OUTPUT VECTOR RESULT A + B ")
print (result_vector)

它是如何工作的...

在脚本的最初几行中,我们导入所有请求的模块。

为了初始化上下文,我们使用 cl.create_some_context() 方法。这要求用户选择用于执行计算的上下文:

Choose platform:
[0] <pyopencl.Platform 'NVIDIA CUDA' at 0x1c0a25aecf0>
[1] <pyopencl.Platform 'Intel(R) OpenCL' at 0x1c0a2608400>

然后,我们需要实例化接收 ElementwiseKernel 的队列:

queue = cl.CommandQueue(context)

实例化输入和输出向量。输入向量 vector_avector_b 是使用 random.randint NumPy 函数获得的随机整数向量。然后,使用 PyOpenCL 语句将这些向量复制到设备上:

cl.array_to_device(queue,array)

ElementwiseKernel 中,创建了一个对象:

elementwiseSum = cl.elementwise.ElementwiseKernel(context,\
               "int *a, int *b, int *c", "c[i] = a[i] + b[i]", "sum")

注意,所有参数都是以 C 语言参数列表的字符串格式表示的(它们都是整数)。

操作是一个类似于 C 语言的代码片段,执行操作,即输入向量元素的求和。

将要编译的内核的函数名是 sum

最后,我们使用之前定义的参数调用elementwiseSum函数:

elementwiseSum(vector_a, vector_b, result_vector)

示例最后通过打印输入向量和获得的结果结束。输出看起来像这样:

(base) C:\>python elementwisePyopencl.py

Choose platform:
[0] <pyopencl.Platform 'NVIDIA CUDA' at 0x1c0a25aecf0>
[1] <pyopencl.Platform 'Intel(R) OpenCL' at 0x1c0a2608400>
Choice [0]:1 
Choose device(s):
[0] <pyopencl.Device 'Intel(R) HD Graphics 5500' on 'Intel(R) OpenCL' at 0x1c0a1640db0>
[1] <pyopencl.Device 'Intel(R) Core(TM) i7-5500U CPU @ 2.40GHz' on 'Intel(R) OpenCL' at 0x1c0a15e53f0>
Choice, comma-separated [0]:0 PyOpenCL ELEMENTWISE SUM OF TWO VECTORS
VECTOR LENGTH = 100
INPUT VECTOR A
[24 64 73 37 40 4 41 85 19 90 32 51 6 89 98 56 97 53 34 91 82 89 97 2
 54 65 90 90 91 75 30 8 62 94 63 69 31 99 8 18 28 7 81 72 14 53 91 80
 76 39 8 47 25 45 26 56 23 47 41 18 89 17 82 84 10 75 56 89 71 56 66 61
 58 54 27 88 16 20 9 61 68 63 74 84 18 82 67 30 15 25 25 3 93 36 24 27
 70 5 78 15] 
INPUT VECTOR B
[49 18 69 43 51 72 37 50 79 34 97 49 51 29 89 81 33 7 47 93 70 52 63 90
 99 95 58 33 41 70 84 87 20 83 74 43 78 34 94 47 89 4 30 36 34 56 32 31
 56 22 50 52 68 98 52 80 14 98 43 60 20 49 15 38 74 89 99 29 96 65 89 41
 72 53 89 31 34 64 0 47 87 70 98 86 41 25 34 10 44 36 54 52 54 86 33 38
 25 49 75 53] 
OUTPUT VECTOR RESULT A + B
[73 82 142 80 91 76 78 135 98 124 129 100 57 118 187 137 130 60 81 184 
 152 141 160 92 153 160 148 123 132 145 114 95 82 177 137 112 109 133 
 102 65 117 11 111 108 48 109 123 111 132 61 58 99 93 143 78 136 37 145 
 84 78 109 66 97 122 84 164 155 118 167 121 155 102 130 107 116 119 50 
 84 9 108 155 133 172 170 59 107 101 40 59 61 79 55 147 122 57 65 
 95 54 153 68] 

更多内容...

PyCUDA 也有逐元素的功能:

ElementwiseKernel(arguments,operation,name,optional_parameters)

这个功能几乎与为 PyOpenCL 构建的函数有相同的参数,除了上下文参数。本节中实现的相同示例,通过 PyCUDA 实现,如下所示:

import pycuda.autoinit 
import numpy 
from pycuda.elementwise import ElementwiseKernel 
import numpy.linalg as la 

vector_dimension=100 
input_vector_a = np.random.randint(100,size= vector_dimension) 
input_vector_b = np.random.randint(100,size= vector_dimension) 
output_vector_c = gpuarray.empty_like(input_vector_a) 

elementwiseSum = ElementwiseKernel(" int *a, int * b, int *c",\ 
                             "c[i] = a[i] + b[i]"," elementwiseSum ") 
elementwiseSum(input_vector_a, input_vector_b,output_vector_c) 

print ("PyCUDA ELEMENTWISE SUM OF TWO VECTORS") 
print ("VECTOR LENGTH = %s" %vector_dimension) 
print ("INPUT VECTOR A") 
print (vector_a) 
print ("INPUT VECTOR B") 
print (vector_b) 
print ("OUTPUT VECTOR RESULT A + B ") 
print (result_vector) 

参见

在以下链接中,你可以找到 PyOpenCL 应用的有趣示例:github.com/romanarranz/PyOpenCL

评估 PyOpenCL 应用

在本节中,我们使用 PyOpenCL 库在 CPU 和 GPU 之间进行性能比较测试。

实际上,在研究要实现的算法的性能之前,了解你拥有的计算平台提供的计算优势也很重要。

入门

计算系统的具体特性会干扰计算时间,因此它们代表了首要重要性的一个方面。

在以下示例中,我们将进行测试以监控此类系统的性能:

  • GPU:GeForce 840 M

  • CPU:Intel Core i7 – 2.40 GHz

  • RAM:8 GB

如何做...

在以下测试中,将评估数学运算的计算时间,例如具有浮点元素的向量之和,并将其进行比较。为了进行比较,相同的操作将在两个不同的函数上执行。

第一个函数仅由 CPU 计算,而第二个函数则是使用 PyOpenCL 库来利用 GPU 卡编写的。测试是在大小为 10,000 个元素的向量上进行的。

这里是代码:

  1. 导入相关库。注意导入time库来计算计算时间,以及linalg库,它是numpy库的线性代数工具:
from time import time 
import pyopencl as cl   
import numpy as np    
import deviceInfoPyopencl as device_info 
import numpy.linalg as la 
  1. 然后,我们定义输入向量。它们都包含10000个浮点数的随机元素:
a = np.random.rand(10000).astype(np.float32) 
b = np.random.rand(10000).astype(np.float32) 
  1. 以下函数是在 CPU(主机)上计算两个向量的和:
def test_cpu_vector_sum(a, b): 
    c_cpu = np.empty_like(a) 
    cpu_start_time = time() 
    for i in range(10000): 
            for j in range(10000): 
                    c_cpu[i] = a[i] + b[i] 
    cpu_end_time = time() 
    print("CPU Time: {0} s".format(cpu_end_time - cpu_start_time)) 
    return c_cpu 
  1. 以下函数是在 GPU(设备)上计算两个向量的和:
def test_gpu_vector_sum(a, b): 
    platform = cl.get_platforms()[0] 
    device = platform.get_devices()[0] 
    context = cl.Context([device]) 
    queue = cl.CommandQueue(context,properties=\
                           cl.command_queue_properties.PROFILING_ENABLE)
  1. test_gpu_vector_sum函数中,我们准备内存缓冲区以包含输入向量和输出向量:
    a_buffer = cl.Buffer(context,cl.mem_flags.READ_ONLY \ 
                | cl.mem_flags.COPY_HOST_PTR, hostbuf=a) 
    b_buffer = cl.Buffer(context,cl.mem_flags.READ_ONLY \ 
                | cl.mem_flags.COPY_HOST_PTR, hostbuf=b) 
    c_buffer = cl.Buffer(context,cl.mem_flags.WRITE_ONLY, b.nbytes) 
  1. 尽管如此,在test_gpu_vector_sum函数中,我们定义了将在设备上计算两个向量之和的内核:
    program = cl.Program(context, """ 
    __kernel void sum(__global const float *a,\ 
                      __global const float *b,\ 
                      __global float *c){ 
        int i = get_global_id(0); 
        int j; 
        for(j = 0; j < 10000; j++){ 
            c[i] = a[i] + b[i];} 
    }""").build() 
  1. 然后,我们在开始计算之前重置gpu_start_time变量。之后,我们计算两个向量的和,然后评估计算时间:
    gpu_start_time = time() 
    event = program.sum(queue, a.shape, None,a_buffer, b_buffer,\ 
            c_buffer) 
    event.wait() 
    elapsed = 1e-9*(event.profile.end - event.profile.start) 
    print("GPU Kernel evaluation Time: {0} s".format(elapsed)) 
    c_gpu = np.empty_like(a) 
    cl._enqueue_read_buffer(queue, c_buffer, c_gpu).wait() 
    gpu_end_time = time() 
    print("GPU Time: {0} s".format(gpu_end_time - gpu_start_time)) 
    return c_gpu 
  1. 最后,我们执行测试,回忆之前定义的两个函数:
if __name__ == "__main__": 
    device_info.print_device_info() 
    cpu_result = test_cpu_vector_sum(a, b) 
    gpu_result = test_gpu_vector_sum(a, b) 
    assert (la.norm(cpu_result - gpu_result)) < 1e-5 

它是如何工作的...

如前所述,测试包括通过test_cpu_vector_sum函数在 CPU 上执行计算任务,然后通过test_gpu_vector_sum函数在 GPU 上执行。

两个函数都报告执行时间。

关于 CPU 上的测试函数test_cpu_vector_sum,它由对10000个向量元素的两次计算循环组成:

            cpu_start_time = time() 
               for i in range(10000): 
                         for j in range(10000): 
                             c_cpu[i] = a[i] + b[i] 
               cpu_end_time = time() 

总 CPU 时间是以下两者的差值:

    CPU Time = cpu_end_time - cpu_start_time 

关于test_gpu_vector_sum函数,您可以通过查看执行内核看到以下内容:

    __kernel void sum(__global const float *a, 
                      __global const float *b, 
                      __global float *c){ 
        int i=get_global_id(0); 
        int j; 
        for(j=0;j< 10000;j++){ 
            c[i]=a[i]+b[i];} 

两个向量的和通过单个计算循环执行。

结果,正如可以想象的那样,test_gpu_vector_sum函数的执行时间显著减少:

(base) C:\>python testApplicationPyopencl.py 

============================================================
OpenCL Platforms and Devices
============================================================
Platform - Name: NVIDIA CUDA
Platform - Vendor: NVIDIA Corporation
Platform - Version: OpenCL 1.2 CUDA 10.1.152
Platform - Profile: FULL_PROFILE
 --------------------------------------------------------
 Device - Name: GeForce 840M
 Device - Type: GPU
 Device - Max Clock Speed: 1124 Mhz
 Device - Compute Units: 3
 Device - Local Memory: 48 KB
 Device - Constant Memory: 64 KB
 Device - Global Memory: 2 GB
 Device - Max Buffer/Image Size: 512 MB
 Device - Max Work Group Size: 1024
============================================================
Platform - Name: Intel(R) OpenCL
Platform - Vendor: Intel(R) Corporation
Platform - Version: OpenCL 2.0
Platform - Profile: FULL_PROFILE
 --------------------------------------------------------
 Device - Name: Intel(R) HD Graphics 5500
 Device - Type: GPU
 Device - Max Clock Speed: 950 Mhz
 Device - Compute Units: 24
 Device - Local Memory: 64 KB
 Device - Constant Memory: 64 KB
 Device - Global Memory: 3 GB
 Device - Max Buffer/Image Size: 808 MB
 Device - Max Work Group Size: 256
 --------------------------------------------------------
 Device - Name: Intel(R) Core(TM) i7-5500U CPU @ 2.40GHz
 Device - Type: CPU
 Device - Max Clock Speed: 2400 Mhz
 Device - Compute Units: 4
 Device - Local Memory: 32 KB
 Device - Constant Memory: 128 KB
 Device - Global Memory: 8 GB
 Device - Max Buffer/Image Size: 2026 MB
 Device - Max Work Group Size: 8192

CPU Time: 39.505873918533325 s
GPU Kernel evaluation Time: 0.013606592 s
GPU Time: 0.019981861114501953 s 

即使测试的计算量不大,它也提供了关于 GPU 卡潜力的有用指示。

还有更多...

OpenCL 是一个标准化的跨平台 API,用于开发利用异构系统并行计算的应用程序。它与 CUDA 的相似之处非常显著,包括从内存层次结构到线程与工作项之间的直接对应关系。

即使在编程层面,也有许多具有相同功能相似方面和扩展。

然而,由于 OpenCL 能够支持广泛的硬件,它具有更复杂的设备管理模型。另一方面,OpenCL 旨在实现不同制造商产品之间的代码可移植性。

由于 CUDA 的成熟度和专用硬件,它提供了简化的设备管理和高级 API,使其更受欢迎,但仅限于处理特定架构(即 NVIDIA 显卡)。

在以下章节中解释了 CUDA 和 OpenCL 库以及 PyCUDA 和 PyOpenCL 库的优缺点。

OpenCL 和 PyOpenCL 的优点

优点如下:

  • 它们允许使用不同类型微处理器的异构系统。

  • 同一段代码可以在不同的系统上运行。

OpenCL 和 PyOpenCL 的缺点

缺点如下:

  • 复杂的设备管理

  • API 尚未完全稳定

CUDA 和 PyCUDA 的优点

优点如下:

  • 高抽象层的 API

  • 支持多种编程语言的扩展

  • 丰富的文档和庞大的社区

CUDA 和 PyCUDA 的缺点

缺点如下:

  • 仅支持最新的 NVIDIA GPU 作为设备

  • 将异构性降低到 CPU 和 GPU

参见

安德烈亚斯·克洛克纳(Andreas Klöckner)在www.bu.edu/pasi/courses/gpu-programming-with-pyopencl-and-pycuda/www.youtube.com/results?search_query=pyopenCL+and+pycuda上提供了一系列关于使用 PyCuda 和 PyOpenCL 进行 GPU 编程的讲座。

使用 Numba 进行 GPU 编程

Numba 是一个提供基于 CUDA 的 API 的 Python 编译器。它主要设计用于数值计算任务,就像 NumPy 库一样。特别是,numba库管理和处理 NumPy 提供的数组数据类型。

实际上,利用数据并行性,这是涉及数组的数值计算中固有的,对于 GPU 加速器来说是一个自然的选择。

Numba 编译器通过指定 Python 函数的签名类型(或装饰器)并在运行时启用编译(这种编译也称为 即时 编译)来工作。

最重要的装饰器如下:

  • jit:这允许开发者编写类似 CUDA 的函数。当遇到时,编译器将装饰器下的代码转换为伪汇编 PTX 语言,以便它可以由 GPU 执行。

  • autojit:这为 延迟编译 过程注解了一个函数,这意味着具有此签名的函数只编译一次。

  • vectorize:这创建了一个所谓的 NumPy 通用函数ufunc),它接受一个函数并在具有向量参数的情况下并行执行它。

  • guvectorize:这构建了一个所谓的 NumPy 广义通用函数gufunc)。一个 gufunc 对象可以操作整个子数组。

准备工作

Numba(版本 0.45)与 Python 2.7 和 3.5 或更高版本兼容,以及 NumPy 版本 1.7 到 1.16。

根据 pyopencl 的建议,为了安装 numba,建议使用 Anaconda 框架,因此,从 Anaconda Prompt 中,只需输入以下命令:

(base) C:\> conda install numba

此外,为了充分利用 numba 的全部潜力,必须安装 cudatoolkit 库:

(base) C:\> conda install cudatoolkit

之后,可以验证 CUDA 库和 GPU 是否正确检测到。

从 Anaconda Prompt 打开 Python 解释器:

(base) C:\> python
Python 3.7.3 (default, Apr 24 2019, 15:29:51) [MSC v.1915 64 bit (AMD64)] :: Anaconda, Inc. on win32
Type "help", "copyright", "credits" or "license" for more information.
>>

第一次测试涉及检查 CUDA 库 (cudatoolkit) 是否正确安装:

>>> import numba.cuda.api
>>> import numba.cuda.cudadrv.libs
>>> numba.cuda.cudadrv.libs.test()

以下输出显示了安装的质量,其中所有检查都返回了正值:

Finding cublas from Conda environment
 located at C:\Users\Giancarlo\Anaconda3\Library\bin\cublas64_10.dll
 trying to open library... ok
Finding cusparse from Conda environment
 located at C:\Users\Giancarlo\Anaconda3\Library\bin\cusparse64_10.dll
 trying to open library... ok
Finding cufft from Conda environment
 located at C:\Users\Giancarlo\Anaconda3\Library\bin\cufft64_10.dll
 trying to open library... ok
Finding curand from Conda environment
 located at C:\Users\Giancarlo\Anaconda3\Library\bin\curand64_10.dll
 trying to open library... ok
Finding nvvm from Conda environment
 located at C:\Users\Giancarlo\Anaconda3\Library\bin\nvvm64_33_0.dll
 trying to open library... ok
Finding libdevice from Conda environment
 searching for compute_20... ok
 searching for compute_30... ok
 searching for compute_35... ok
 searching for compute_50... ok
True

在第二次测试中,我们验证了图形卡的存在:

>>> numba.cuda.api.detect()

输出显示了找到的图形卡以及它是否受支持:

Found 1 CUDA devices
id 0 b'GeForce 840M' [SUPPORTED]
                      compute capability: 5.0
                           pci device id: 0
                              pci bus id: 8
Summary:
        1/1 devices are supported
True

如何操作...

在这个例子中,我们提供了一个使用 @guvectorize 注解的 Numba 编译器的演示。

要执行的任务是矩阵乘法:

  1. numba 库和 numpy 模块导入 guvectorize
from numba import guvectorize 
import numpy as np 
  1. 使用 @guvectorize 装饰器,我们定义了 matmul 函数,该函数将执行矩阵乘法任务:
@guvectorize(['void(int64[:,:], int64[:,:], int64[:,:])'], 
             '(m,n),(n,p)->(m,p)') 
def matmul(A, B, C): 
    m, n = A.shape 
    n, p = B.shape 
    for i in range(m): 
        for j in range(p): 
            C[i, j] = 0 
            for k in range(n): 
                C[i, j] += A[i, k] * B[k, j] 
  1. 输入矩阵的大小为 10 × 10,而元素为整数:
dim = 10 
A = np.random.randint(dim,size=(dim, dim)) 
B = np.random.randint(dim,size=(dim, dim)) 
  1. 最后,我们在先前定义的输入矩阵上调用 matmul 函数:
C = matmul(A, B) 
  1. 我们打印输入矩阵和结果矩阵:
print("INPUT MATRIX A") 
print(":\n%s" % A) 
print("INPUT MATRIX B") 
print(":\n%s" % B) 
print("RESULT MATRIX C = A*B") 
print(":\n%s" % C) 

它是如何工作的...

@guvectorize 装饰器作用于数组参数,按照顺序接受四个参数以指定 gufunc 签名:

  • 前三个参数指定了要管理的数据类型和整数数组:void(int64[:,:], int64[:,:], int64[:,:])

  • @guvectorize 的最后一个参数指定了如何操作矩阵维度:(m,n),(n,p)->(m,p)

然后,定义了矩阵乘法操作,其中 AB 是输入矩阵,C 是输出矩阵:A(m,n) * B(n,p) = C(m,p),其中 mnp 是矩阵的维度。

矩阵乘法是通过三个 for 循环以及矩阵索引来执行的:

      for i in range(m): 
            for j in range(p): 
                C[i, j] = 0 
                for k in range(n): 
                      C[i, j] += A[i, k] * B[k, j] 

这里使用 randint NumPy 函数构建了 10 × 10 维度的输入矩阵:

dim = 10
A = np.random.randint(dim,size=(dim, dim))
B = np.random.randint(dim,size=(dim, dim))

最后,使用这些矩阵作为参数调用 matmul 函数,并打印出结果矩阵 C

C = matmul(A, B)
print("RESULT MATRIX C = A*B")
print(":\n%s" % C)

要执行此示例,请输入以下:

(base) C:\>python matMulNumba.py

结果显示了作为输入的两个矩阵以及它们乘积得到的矩阵:

INPUT MATRIX A
:
[[8 7 1 3 1 0 4 9 2 2]
 [3 6 2 7 7 9 8 4 4 9]
 [8 9 9 9 1 1 1 1 8 0]
 [0 5 0 7 1 3 2 0 7 3]
 [4 2 6 4 1 2 9 1 0 5]
 [3 0 6 5 1 0 4 3 7 4]
 [0 9 7 2 1 4 3 3 7 3]
 [1 7 2 7 1 8 0 3 4 1]
 [5 1 5 0 7 7 2 3 0 9]
 [4 6 3 6 0 3 3 4 1 2]]
INPUT MATRIX B
:
[[2 1 4 6 6 4 9 9 5 2]
 [8 6 7 6 5 9 2 1 0 9]
 [4 1 2 4 8 2 9 5 1 4]
 [9 9 1 5 0 5 1 1 7 1]
 [8 7 8 3 9 1 4 3 1 5]
 [7 2 5 8 3 5 8 5 6 2]
 [5 3 1 4 3 7 2 9 9 5]
 [8 7 9 3 4 1 7 8 0 4]
 [3 0 4 2 3 8 8 8 6 2]
 [8 6 7 1 8 3 0 8 8 9]]
RESULT MATRIX C = A*B
:
[[225 172 201 161 170 172 189 230 127 169]
 [400 277 289 251 278 276 240 324 295 273]
 [257 171 177 217 208 254 265 224 176 174]
 [187 130 116 117 94 175 105 128 152 114]
 [199 133 117 143 168 156 143 214 188 157]
 [180 118 124 113 152 149 175 213 167 122]
 [238 142 186 165 188 215 202 200 139 192]
 [237 158 162 176 122 185 169 140 137 130]
 [249 160 220 159 249 125 201 241 169 191]
 [209 152 142 154 131 160 147 161 132 137]]

更多内容...

使用 PyCUDA 编写一个用于降维操作的算法可能相当复杂。为此,Numba 提供了 @reduce 装饰器,用于将简单的二进制操作转换为 降维内核

降维操作将一组值减少到一个值。一个典型的降维操作示例是计算一个数组所有元素的总和。例如,考虑以下元素数组:1, 2, 3, 4, 5, 6, 7, 8。

顺序算法按照图中所示的方式运行,即逐个添加数组的元素:

顺序求和

并行算法按照以下方案运行:

并行求和

很明显,后者具有更短的执行时间优势。

通过使用 Numba 和 @reduce 装饰器,我们可以用几行代码编写一个算法,用于对从 1 到 10,000 的整数数组进行并行求和:

import numpy 
from numba import cuda 

@cuda.reduce 
def sum_reduce(a, b): 
    return a + b 

A = (numpy.arange(10000, dtype=numpy.int64)) + 1
print(A) 
got = sum_reduce(A)
print(got) 

可以通过输入以下命令执行前面的示例:

(base) C:\>python reduceNumba.py

以下结果提供:

vector to reduce = [ 1 2 3 ... 9998 9999 10000]
result = 50005000

参见

在以下仓库中,您可以找到许多 Numba 的示例:github.com/numba/numba-examples。在 nyu-cds.github.io/python-numba/05-cuda/ 可以找到对 Numba 和 CUDA 编程的有趣介绍。

第九章:Python 调试与测试

本章的最后部分将介绍两个重要的软件工程主题——调试和测试,它们是软件开发过程中的重要步骤。

本章的第一部分专注于代码调试。错误是程序中的错误,可能会引起不同的问题,这些问题可能严重程度不同,具体取决于情况。为了鼓励程序员寻找错误,使用了特殊的软件工具,称为 调试器;使用这些软件工具,我们可以利用特定的调试功能,通过查找程序中的错误或故障来识别受错误影响的软件部分。

在第二部分中,主要话题是 软件测试:它是一个用于识别正在开发的软件产品中 正确性完整性可靠性 缺陷的过程。

在这个背景下,我们将检查三个最重要的用于调试代码的 Python 工具的实际应用。这些是 winpdb-reborn,它涉及使用可视化工具进行调试;pdb,Python 标准库中的调试器;以及 rpdb,其中 r 代表远程,意味着它是来自远程机器的代码调试*。

关于软件测试,我们将检查以下工具:unittestnose

这些是用于开发单元测试的框架,其中单元是程序在独立操作中的最小组成部分。

在本章中,我们将涵盖以下主题:

  • 什么是调试?

  • 什么是软件测试?

  • 使用 Winpdb Reborn 进行调试

  • pdb 交互

  • 实现 rpdb 进行调试

  • 处理 unittest

  • 使用 nose 进行应用程序测试

什么是调试?

术语 调试 指的是在软件使用后识别代码中一个或多个错误(错误)的活动。

错误可以在程序的测试阶段定位;也就是说,它仍然处于开发阶段,尚未准备好供最终用户使用,或者在使用程序时。在找到错误后,调试阶段随之而来,并识别出错误所在的软件部分,这有时可能非常复杂。

现在,这项活动得到了特定应用程序和调试器的支持,它们通过逐步的软件指令向程序员展示执行过程,同时允许查看和分析程序本身的输入和输出。

在这些工具可用于识别和纠正错误(甚至在它们不可用的情况下,现在也是如此)之前,代码检查的最简单(但也是最无效)的技术是打印文件或打印程序正在执行的屏幕指令。

调试是程序开发中最重要的操作之一。由于正在开发的软件的复杂性,它通常非常困难。由于引入新的错误或不符合预期行为的风险,它甚至更加微妙,这些错误或行为是在尝试纠正那些导致活动进行的活动时出现的。

虽然使用调试完善软件的任务每次都是独特的,并构成一个故事本身,但一些基本原则始终适用。特别是,在软件应用程序的背景下,可以识别以下四个调试阶段,如下面的图表所示:

图片

调试阶段

当然,Python 为开发者提供了许多调试工具(有关 Python 调试器的列表,请参阅wiki.python.org/moin/PythonDebuggingTools)。在本章中,我们将考虑 Winpdb Reborn、rpdbpdb

什么是软件测试?

如本章引言中所述,软件测试是用于识别正在开发的软件产品中正确性、完整性和可靠性缺陷的过程。

因此,通过搜索缺陷,或一系列指令和程序,当在特定的输入数据和特定的操作环境中执行时,会产生故障,我们想要确保产品的质量。故障是用户未预期的软件行为;因此,它与规格不同,也与为这些应用程序定义的隐含或显式要求不同。

因此,测试的目的是通过故障来检测缺陷,以最大限度地减少在软件产品的正常使用中发生此类故障的概率。测试不能确定产品在所有可能的执行条件下都能正确运行,但它可以在特定条件下突出显示缺陷。

事实上,鉴于测试所有输入组合以及应用程序可能运行的软件和硬件环境的不可行性,无法将故障的概率降低到零,但必须将其降低到最小,以便用户可以接受。

一种特殊的软件测试是单元测试(我们将在本章中学习),其目的是隔离程序的一部分,并展示其实施中的正确性和完整性。它还可以及时揭示任何缺陷,以便在集成之前轻松纠正。

此外,单元测试与对整个应用程序进行测试以实现相同结果相比,降低了识别和纠正缺陷的成本——在时间和资源方面。

使用 Winpdb Reborn 进行调试

Winpdb Reborn是 Python 中最重要的和最知名的调试器之一。这个调试器的最大优势是管理基于线程的代码的调试。

Winpdb Reborn 基于 RPDB2 调试器,而 Winpdb 是 RPDB2 的 GUI 前端(见:github.com/bluebird75/winpdb/blob/master/rpdb2.py)。

准备工作

安装 Winpdb Reborn(版本 2.0.0 dev5)最常用的方法是使用pip,因此你需要在控制台中输入以下内容:

C:\>pip install winpdb-reborn

此外,如果你还没有在你的 Python 发行版中安装 wxPython,那么你需要这样做。wxPython 是 Python 语言的跨平台 GUI 工具包。

对于 Python 2.x 版本,请参阅sourceforge.net/projects/wxpython/files/wxPython/。对于 Python 3.x 版本,wxPython 会自动通过pip作为依赖项安装。

在下一节中,我们将通过 Winpdb Reborn 的一个简单使用示例来检查其主要特性和图形界面。

如何操作...

假设我们想要分析以下使用线程库的 Python 应用程序,以下示例与第二章基于线程的并行性如何定义线程子类部分描述的示例非常相似。在以下示例中,我们使用MyThreadClass类创建和管理三个线程的执行。以下是调试整个代码的完整代码:

import time
import os
from random import randint
from threading import Thread

class MyThreadClass (Thread):
   def __init__(self, name, duration):
      Thread.__init__(self)
      self.name = name
      self.duration = duration
   def run(self):
      print ("---> " + self.name + \
             " running, belonging to process ID "\
             + str(os.getpid()) + "\n")
      time.sleep(self.duration)
      print ("---> " + self.name + " over\n")
def main():
    start_time = time.time()

    # Thread Creation
    thread1 = MyThreadClass("Thread#1 ", randint(1,10))
    thread2 = MyThreadClass("Thread#2 ", randint(1,10))
    thread3 = MyThreadClass("Thread#3 ", randint(1,10))

    # Thread Running
    thread1.start()
    thread2.start()
    thread3.start()

    # Thread joining
    thread1.join()
    thread2.join()
    thread3.join()

    # End 
    print("End")

    #Execution Time
    print("--- %s seconds ---" % (time.time() - start_time))

if __name__ == "__main__":
    main()

让我们看看以下步骤:

  1. 打开你的控制台,输入包含示例文件文件夹的名称,winpdb_reborn_code_example.py
 python -m winpdb .\winpdb_reborn_code_example.py

这在 macOS 上同样适用,但你必须使用 Python 的框架构建。如果你使用 Anaconda 与 Winpdb Reborn 一起使用,只需用pythonw代替python来启动 Winpdb Reborn 会话。

  1. 如果安装成功,Winpdb Reborn 的 GUI 应该会打开:

Winpdb Reborn GUI

  1. 如以下截图所示,我们在第 12 行和第 23 行(用红色突出显示)插入了两个断点(使用断点菜单):

代码断点

要了解断点是什么,请继续阅读本食谱的更多内容...部分。

  1. 在源代码窗口中,我们将鼠标放在第 23 行,我们在这里插入了第二个断点,然后按F8键,然后按F5键。断点允许代码执行到所选行。如你所见,命名空间表示我们正在考虑MyThreadClass类的实例,其中thread#1作为参数:

命名空间

  1. 调试器的另一个基本功能是进入函数的能力,这不仅可以检查正在调试的代码,还可以检查执行过程中调用的库函数和子程序。

  2. 在开始删除之前的断点(菜单 | 断点 | 清除所有)之前,在第 28 行插入新的断点:

图片

第 28 行断点

  1. 最后,按F5键,应用程序将执行到第28行的断点。

  2. 然后,按F7键。在这里,源窗口不再显示我们的示例代码,而是显示我们正在使用的threading库(见下一张截图)。

  3. 因此,断点功能以及进入函数功能不仅允许调试相关代码,还允许检查所有库函数和任何其他使用的子程序:

图片

执行“进入函数”后的第 28 行源窗口

它是如何工作的...

在这个第一个例子中,我们已经熟悉了 Winpdb Reborn 工具。这个调试环境(就像一般环境一样)允许您在精确的点停止程序执行,检查执行堆栈、变量的内容、创建的对象的状态等等。

要使用 Winpdb Reborn,只需记住以下基本步骤:

  1. 在源代码(源窗口)中设置一些断点。

  2. 通过“进入函数”来检查函数。

  3. 查看变量状态(命名空间窗口)和执行堆栈(堆栈窗口)。

通过简单地用左鼠标按钮双击所需的行来设置断点。作为一般警告,不建议在同一行上有多个命令;否则,将无法将这些断点与其中一些关联。

当您使用鼠标右键时,可以选择性地禁用断点而不删除它们(红色高亮将消失)。要删除所有断点,请使用之前提到的“清除所有”命令,该命令位于断点菜单中。

当达到第一个断点时,最好关注正在分析程序中的以下视图:

  • 堆栈视图显示了执行堆栈的内容,其中显示了当前挂起的各种方法的实例。通常,堆栈底部的那个是主方法,而堆栈顶部的那个是包含已达到断点的那个方法。

  • 命名空间视图显示了方法中的局部变量,并允许您检查其值。如果变量引用对象,则可以找出对象的唯一标识符并检查其状态。

通常,可以使用与 Winpdb Reborn 命令栏上的图标(或Fx键)关联的不同模式来管理程序的执行。

最后,我们将指出以下重要的执行方法:

  • 单步进入(F7 键):这将逐行恢复程序的执行,包括库方法或子例程的调用。

  • 返回(F12 键):这允许你在单步进入函数激活的确切点恢复执行。

  • 下一步(F6 键):这将逐行恢复程序的执行,而不会在任何调用的方法中停止。

  • 运行到行(F8 键)这将运行程序,直到它停止(等待新命令)在指定的行。

还有更多...

正如你在 Winpdb Reborn GUI 截图中所看到的,GUI 被分为五个主要窗口:

  • 命名空间:在这个窗口中,显示实体名称,这些实体是程序定义并用于源文件中的各种变量和标识符。

  • 线程:显示执行中的当前线程,它由TIDThread IDentification 的缩写)字段、线程名称和线程状态来表征。

  • 栈:这是要分析程序的执行栈显示的地方。栈也被称为后进先出LIFO)数据结构,因为最后插入的元素是第一个被移除的。当程序调用一个函数时,被调用的函数必须知道如何返回调用控制,因此调用函数的返回地址被输入到程序执行栈中。程序执行栈还包含在函数每次调用时使用的局部变量的内存。

  • 控制台:这是一个命令行界面,因此允许用户与 Winpdb Reborn 进行文本交互。

  • 源代码:此窗口显示要调试的源代码。通过滚动代码行,也可以通过按F9 键在感兴趣的代码行上设置断点。

断点是调试的一个非常基本的工具。实际上,它允许你运行程序,但具有在期望的点或当某些条件发生时中断程序的可能性,以便获取正在运行的程序的信息

存在多种调试策略。在此,我们列出其中一些:

  • 重现错误:确定导致错误的输入数据。

  • 简化错误:确定导致错误的最简单可能的数据。

  • 分而治之:在单步执行模式下执行主要过程,直到出现异常。导致异常的方法是在找到问题之前最后执行的方法,因此我们可以通过单步进入该特定调用并按照方法说明再次执行来重新调试。

  • 有意识地执行:在调试过程中,你不断地将变量的当前值与预期的值进行比较。

  • 检查所有细节:在调试时不要忽略细节。如果你在源代码中注意到任何差异,最好做笔记。

  • 纠正错误:只有在你确信你已经很好地理解了问题的情况下,才纠正错误。

参见

一个好的 Winpdb Reborn 教程可以在 heather.cs.ucdavis.edu/~matloff/winpdb.html#usewin 找到。

与 pdb 交互

pdb 是一个用于执行交互式调试的 Python 模块。

pdb 的主要功能如下:

  • 断点的使用

  • 逐行交互处理源代码

  • 堆栈帧分析

调试器是通过 pdb 类实现的。因此,它可以很容易地通过新功能进行扩展。

准备工作

不需要安装 pdb,因为它已经是 Python 标准库的一部分。它可以通过以下主要使用模式启动:

  • 与命令行交互

  • 使用 Python 解释器

  • 在要调试的代码中插入指令(即 pdb 语句)

与命令行交互

最简单的方法是直接将程序的名称作为输入。例如,对于 pdb_test.py 程序,如下所示:

class Pdb_test(object):
    def __init__(self, parameter):
    self.counter = parameter

    def go(self):
        for j in range(self.counter):
             print ("--->",j)
        return

if __name__ == '__main__':
    Pdb_test(10).go()

通过命令行执行,pdb 加载要分析的源文件,并在找到的第一个语句处停止执行。在这种情况下,调试在行 1(即 Pdb_test 类的定义处)停止:

python -m pdb pdb_test.py
> .../pdb_test.py(1)<module>()
-> class Pdb_test(object):
(Pdb)

使用 Python 解释器

可以通过使用 run() 命令以交互模式使用 pdb 模块:

>>> import pdb_test
>>> import pdb
>>> pdb.run('pdb_test.Pdb_test(10).go()')
> <string>(1)<module>()
(Pdb)

在这种情况下,run() 语句来自调试器,将在评估第一个表达式之前停止执行。

在代码中插入调试指令

对于长时间运行的过程,如果在程序执行中问题出现得较晚,那么在程序内部使用 pdb set_trace() 指令启动调试器会更加方便:

import pdb

class Pdb_test(object):
    def __init__(self, parameter):
        self.counter = parameter
    def go(self):
        for j in range(self.counter):
            pdb.set_trace()
            print ("--->",j)
        return

if __name__ == '__main__':
    Pdb_test(10).go()

set_trace() 可以在任何程序点调用以进行调试。例如,可以根据条件、异常处理程序或特定的控制指令分支调用它。

在这种情况下,输出如下:

-> print ("--->",j)
(Pdb) 

代码运行在 pdb.set_trace() 语句完成后停止。

如何做到这一点...

要与 pdb 交互,你需要使用它的语言,这允许你在代码中移动,检查和修改变量的值,插入断点或遍历堆栈调用:

  1. 使用 where 命令(或其紧凑形式 w)查看正在运行的代码行和调用栈。在这种情况下,这是在 pdb_test.py 模块的 go() 方法中的第 17 行:
> python -m pdb pdb_test.py
-> class Pdb_test(object):
(Pdb) where
 c:\python35\lib\bdb.py(431)run()
-> exec(cmd, globals, locals)
 <string>(1)<module>()
(Pdb)
  1. 使用 list 检查当前位置(由箭头指示)附近的代码行。在默认模式下,11 行被列出在当前行周围(五行之前和五行之后):
 (Pdb) list
 1 -> class Pdb_test(object):
 2 def __init__(self, parameter):
 3 self.counter = parameter
 4
 5 def go(self):
 6 for j in range(self.counter):
 7 print ("--->",j)
 8 return
 9
 10 if __name__ == '__main__':
 11 Pdb_test(10).go()
  1. 如果 list 接收两个参数,则它们被解释为要显示的第一行和最后一行:
 (Pdb) list 3,9
 3 self.counter = parameter
 4
 5 def go(self):
 6 for j in range(self.counter):
 7 print ("--->",j)
 8 return
 9
  1. 使用 up(或 u)移动到堆栈上的旧帧,并使用 down(或 d)移动到更近的堆栈帧:
(Pdb) up
> <string>(1)<module>()
(Pdb) up
> c:\python35\lib\bdb.py(431)run()
-> exec(cmd, globals, locals)
(Pdb) down
> <string>(1)<module>()
(Pdb) down
>....\pdb_test.py(1)<module>()
-> class Pdb_test(object):
(Pdb)

它是如何工作的...

调试活动是按照运行程序的流程(跟踪)进行的。在每一行代码中,编码者会实时显示指令执行的操作和记录在变量中的值。这样,开发者可以检查一切是否正常工作或确定故障的原因。

每种编程语言都有自己的调试器。然而,没有适用于所有编程语言的调试器,因为每种语言都有自己的语法和语法。调试器执行逐步源代码。因此,调试器必须了解语言的规则,就像编译器一样。

更多...

在使用 Python 调试器时,需要记住以下最有用的pdb命令及其简称:

命令 操作
args 打印当前函数的参数列表
break 创建一个断点(需要参数)
continue 继续程序执行
help 列出命令(或帮助)的命令(作为参数)
jump 设置下一个要执行的行
list 打印当前行周围的源代码
next 继续执行直到当前函数的下一行或返回
step 执行当前行,在第一个可能的机会停止
pp 美化打印表达式的值
quitexit pdb中退出
return 继续执行直到当前函数返回

相关内容

你可以通过观看这个有趣的视频教程了解更多关于pdb的信息:www.youtube.com/watch?v=bZZTeKPRSLQ

实现 rpdb 进行调试

在某些情况下,调试远程位置的代码是合适的;也就是说,该位置不在运行调试器的同一台机器上。为此,开发了rpdb。这是一个在pdb上包装的组件,它使用 TCP 套接字与外部世界通信。

准备中

rpdb的安装首先需要使用pip的主要步骤。对于 Windows 操作系统,只需输入以下内容:

C:\>pip install rpdb

然后,你需要确保你的机器上有一个启用的telnet客户端。在 Windows 10 中,如果你打开命令提示符并输入telnet,那么操作系统会响应一个错误,因为它默认没有安装。

让我们看看如何通过几个简单的步骤来安装它:

  1. 以管理员模式打开命令提示符。

  2. 点击 Cortana 按钮并输入cmd

  3. 在出现的列表中,右键单击“命令提示符”项,然后选择“以管理员身份运行”。

  4. 然后,在以管理员身份运行命令提示符时,输入以下命令:

dism /online /Enable-Feature /FeatureName:TelnetClient
  1. 等待几分钟,直到安装完成。如果过程成功,那么你会看到以下内容:

图片

  1. 现在,您可以直接从提示符使用 telnet。通过输入telnet,应该会出现以下窗口:

图片

在以下示例中,让我们看看如何使用rpdb进行远程调试。

如何做到这一点...

让我们执行以下步骤:

  1. 考虑以下示例代码:
import threading

def my_func(thread_number):
    return print('my_func called by thread N°
        {}'.format(thread_number))

def main():
    threads = []
    for i in range(10):
        t = threading.Thread(target=my_func, args=(i,))
        threads.append(t)
        t.start()
        t.join()

if __name__ == "__main__":
    main()
  1. 要使用rpdb,你需要插入以下代码行(在import threading语句之后)。实际上,这三行代码通过远程客户端在端口4444和 IP 地址127.0.0.1上启用rpdb的使用:
import rpdb
debugger = rpdb.Rpdb(port=4444)
rpdb.Rpdb().set_trace()
  1. 如果你在这三行代码之后运行示例代码,这些代码启用了rpdb的使用,那么你应该在 Python 命令提示符上看到以下消息:
pdb is running on 127.0.0.1:4444
  1. 然后,你可以通过以下 telnet 连接远程切换以调试示例代码:
telnet localhost 4444
  1. 应该打开以下窗口:

图片

  1. 在示例代码中,注意第7行的箭头。代码没有运行,它只是在等待执行指令:

图片

  1. 例如,在这里,我们执行代码并反复输入next语句:
 (Pdb) next
> c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py(10)<module>()
-> def main():
(Pdb) next
> c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py(18)<module>()
-> if __name__ == "__main__":
(Pdb) next
> c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py(20)<module>()
-> main()
(Pdb) next
my_func called by thread N 0
my_func called by thread N 1
my_func called by thread N 2
my_func called by thread N 3
my_func called by thread N 4
my_func called by thread N 5
my_func called by thread N 6
my_func called by thread N 7
my_func called by thread N 8
my_func called by thread N 9
--Return--
> c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py(20)<module>()->None
-> main()
(Pdb)

一旦程序完成,你仍然可以运行新的调试部分。现在,让我们看看下一节中rpdp是如何工作的。

它是如何工作的...

在本节中,我们将看到如何通过使用next语句简单地移动到代码中,该语句将继续执行直到当前函数的下一行或返回。

要使用rpdb,请按照以下步骤操作:

  1. 导入相关的rpdb库:
import rpdb
  1. 设置debugger参数,该参数指定连接以运行调试器的 telnet 端口:
debugger = rpdb.Rpdb(port=4444)
  1. 调用set_trace()指令,这使得可以进入调试模式:
rpdb.Rpdb().set_trace()

在我们的例子中,我们在debugger实例之后立即放置了set_trace()指令。实际上,我们可以在代码的任何位置放置它;例如,如果条件满足,或者在由异常管理的部分内。

第二步,相反,是在命令提示符下打开,通过设置与示例代码中debugger参数定义中指定的相同端口号来启动telnet

telnet localhost 4444

可以通过使用一个小型命令语言与rpdb调试器进行交互,该语言允许在堆栈调用之间移动,检查并修改变量的值,并控制调试器执行其程序的方式。

还有更多...

你可以通过在Pdb提示符下输入help命令来显示你可以在rpdb中与之交互的命令列表:

> c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py(7)<module>()
-> def my_func(thread_number):
(Pdb) help

Documented commands (type help <topic>):
========================================
EOF   c   d   h list q rv undisplay
a cl debug help ll quit s unt
alias clear disable ignore longlist r source until
args commands display interact n restart step up
b condition down j next return tbreak w
break cont enable jump p retval u whatis
bt continue exit l pp run unalias where

Miscellaneous help topics:
==========================
pdb exec

(Pdb)

在最有用的命令中,这就是我们如何在代码中插入断点的方式:

  1. 输入b和行号来设置断点。在这里,断点设置在第5行和第10行:
 (Pdb) b 5
Breakpoint 1 at c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py:5
(Pdb) b 10
Breakpoint 2 at c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py:10
  1. 只需输入b命令即可显示已实现的断点列表:
 (Pdb) b
Num Type Disp Enb Where
1 breakpoint keep yes at c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py:5
2 breakpoint keep yes at c:\users\giancarlo\desktop\python parallel programming cookbook 2nd edition\python parallel programming new book\chapter_x- code debugging\rpdb_code_example.py:10
(Pdb)

每添加一个新的断点,都会分配一个数字标识符。这些标识符用于启用、禁用和交互式地删除断点。要禁用断点,请使用disable命令,该命令告诉调试器在到达该行时不要停止。断点不会被遗忘,但会被忽略。

参见

你可以在本网站上找到关于pdbrpdb的大量信息:github.com/spiside/pdb-tutorial

在接下来的两个部分中,我们将探讨一些用于实现单元测试的 Python 工具:

  • unittest

  • nose

处理 unittest

unittest模块由标准 Python 库提供。它提供了一套广泛的工具和过程,用于执行单元测试。在本节中,我们将简要介绍unittest模块的工作原理。

单元测试由两部分组成:

  • 管理所谓的测试系统的代码

  • 测试本身

准备工作

最简单的unittest模块可以通过TestCase子类获得,必须重写或添加适当的方法。

一个简单的unittest模块可以组成如下:

import unittest

class SimpleUnitTest(unittest.TestCase):

    def test(self):
        self.assertTrue(True)

if __name__ == '__main__':
    unittest.main()

要运行unittest模块,你需要包含unittest.main(),而我们有单个方法test(),如果TrueFalse,则测试失败。

通过执行前面的示例,你得到以下结果:

-----------------------------------------------------------
Ran 1 test in 0.005s

OK

测试成功,因此结果是OK

在下一节中,我们将更详细地介绍unittest模块的工作原理。特别是,我们想研究单元测试可能的结果。

如何做...

让我们通过这个例子看看我们如何用这个例子来描述测试结果:

  1. 导入相关模块:
import unittest
  1. 定义outcomesTest类,其参数为TestCase子类:
class OutcomesTest(unittest.TestCase):
  1. 我们定义的第一个方法是testPass
    def testPass(self):
        return
  1. 这是TestFail方法:
    def testFail(self):
        self.failIf(True)
  1. 接下来,我们有TestError方法:
    def testError(self):
        raise RuntimeError('test error!')
  1. 最后,我们有main函数,通过它我们回忆我们的程序:
if __name__ == '__main__':
    unittest.main()

它是如何工作的...

在这个例子中,展示了unittest单元测试的可能结果。

可能的结果如下:

  • ERROR: 测试抛出除AssertionError之外的异常。没有明确的方法可以通过测试,因此测试状态取决于异常的存在(或不存在)。

  • FAILED: 测试未通过,并抛出AssertionError异常。

  • OK: 测试通过。

输出如下:

===========================================================
ERROR: testError (__main__.OutcomesTest)
-----------------------------------------------------------
Traceback (most recent call last):
 File "unittest_outcomes.py", line 15, in testError
 raise RuntimeError('Errore nel test!')
RuntimeError: Errore nel test!

===========================================================
FAIL: testFail (__main__.OutcomesTest)
-----------------------------------------------------------
Traceback (most recent call last):
 File "unittest_outcomes.py", line 12, in testFail
 self.failIf(True)
AssertionError

-----------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (failures=1, errors=1)

大多数测试确认条件的真实性。根据测试作者的视角以及是否验证了代码期望的结果,验证真实性的测试有不同的编写方式。如果代码产生一个可以评估为真的值,那么应该使用failUnless()assertTrue()方法。如果代码产生一个假值,那么使用failIf()assertFalse()方法则更有意义:

import unittest

class TruthTest(unittest.TestCase):

    def testFailUnless(self):
        self.failUnless(True)

    def testAssertTrue(self):
        self.assertTrue(True)

    def testFailIf(self):
        self.assertFalse(False)

    def testAssertFalse(self):
        self.assertFalse(False)

if __name__ == '__main__':
    unittest.main()

结果如下:

> python unittest_failwithmessage.py -v
testFail (__main__.FailureMessageTest) ... FAIL

===========================================================
FAIL: testFail (__main__.FailureMessageTest)
-----------------------------------------------------------
Traceback (most recent call last):
 File "unittest_failwithmessage.py", line 9, in testFail
 self.failIf(True, 'Il messaggio di fallimento va qui')
AssertionError: Il messaggio di fallimento va qui

-----------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
robby@robby-desktop:~/pydev/pymotw-it/dumpscripts$ python unittest_truth.py -v
testAssertFalse (__main__.TruthTest) ... ok
testAssertTrue (__main__.TruthTest) ... ok
testFailIf (__main__.TruthTest) ... ok
testFailUnless (__main__.TruthTest) ... ok

-----------------------------------------------------------
Ran 4 tests in 0.000s

OK

更多内容...

如前所述,如果测试抛出了除AssertionError之外的异常,则被视为错误。这对于发现你在编辑已经存在匹配测试的代码时发生的错误非常有用。

然而,在某些情况下,你可能想要运行一个测试来验证某些代码实际上是否产生了异常。例如,当将无效值作为对象的属性传递时。在这种情况下,failUnlessRaises()使代码比在代码中捕获异常更清晰:

import unittest

def raises_error(*args, **kwds):
    print (args, kwds)
    raise ValueError\
        ('Valore non valido:'+ str(args)+ str(kwds))

class ExceptionTest(unittest.TestCase):
    def testTrapLocally(self):
        try:
            raises_error('a', b='c')
        except ValueError:
            pass
        else:
            self.fail('Non si vede ValueError')

    def testFailUnlessRaises(self):
       self.assertRaises\
               (ValueError, raises_error, 'a', b='c')

if __name__ == '__main__':
    unittest.main()

两个测试的结果都是相同的。然而,第二个测试的结果,使用failUnlessRaises(),更短:

> python unittest_exception.py -v
testFailUnlessRaises (__main__.ExceptionTest) ... ('a',) {'b': 'c'}
ok
testTrapLocally (__main__.ExceptionTest) ...('a',) {'b': 'c'}
ok

-----------------------------------------------------------
Ran 2 tests in 0.000s

OK

参见

更多关于 Python 测试的信息可以在realpython.com/python-testing/找到。

使用 nose 进行应用程序测试

nose是一个重要的 Python 模块,用于定义单元测试。它允许我们使用unittest.TestCase的子类编写简单的测试函数,也可以编写不是unittest.TestCase子类的测试类。

准备工作

使用pip安装nose

C:\>pip install nose

可以通过以下步骤在pypi.org/project/nose/下载并安装源包:

  1. 解压源包。

  2. 切换到新目录。

然后,输入以下命令:

C:\>python setup.py install

nose的一个优点是自动从以下内容收集测试:

  • Python 源文件

  • 工作目录中找到的目录和包

要指定要运行的测试,请在命令行上传递相关的测试名称:

C:\>nosetests only_test_this.py

指定的测试名称可以是文件或模块名称,并且可以通过用冒号分隔模块或文件名和测试用例名称来可选地指示要运行的测试用例。文件名可以是相对的或绝对的。

以下是一些示例:

C:\>nosetests test.module
C:\>nosetests another.test:TestCase.test_method
C:\>nosetests a.test:TestCase
C:\>nosetests /path/to/test/file.py:test_function

你也可以使用-w开关更改工作目录,其中nose查找测试:

C:\>nosetests -w /path/to/tests

注意,然而,现在对多个-w参数的支持已被弃用,并将在未来版本中删除。但是,可以通过指定不带-w开关的目标目录来获得相同的行为:

C:\>nosetests /path/to/tests /another/path/to/tests

可以通过使用插件进一步自定义测试选择和加载。

测试结果输出与unittest相同,除了额外的功能,如错误类,以及插件提供的功能,如输出捕获和断言内省。

在下一节中,我们将探讨使用nose.进行类测试。

如何做...

让我们执行以下步骤:

  1. 导入相关的nose.tools*:
from nose.tools import eq_ 
  1. 然后,设置TestSuite类。在这里,类的方程序通过eq_函数进行测试:
class TestSuite:
    def test_mult(self):
        eq_(2*2,4)

    def ignored(self):
        eq_(2*2,3)

它是如何工作的...

单元测试可以由开发者独立开发,但遵循一个标准产品,如unittest,并遵守常见的测试实践是一个好的做法。

如您从以下示例中可以看到,测试方法是通过使用eq_函数设置的。这与unittestassertEquals类似,它验证两个参数是否相等:

    def test_mult(self):
        eq_(2*2,4)

    def ignored(self):
        eq_(2*2,3)

这种测试实践,尽管初衷良好,但存在明显的局限性,例如不能随着时间的推移重复进行(例如,当软件模块发生变化时)所谓的回归测试

这里是输出:

C:\>nosetests -v testset.py
testset.TestSuite.test_mult ... ok

-----------------------------------------------------------
Ran 1 tests in 0.001s

OK

通常,测试无法识别程序中的所有错误,单元测试也是如此,因为根据定义,单元测试通过分析单个单元无法识别集成错误、性能问题和其他系统相关的问题。通常,当与其他软件测试技术结合使用时,单元测试更有效。

与任何测试形式一样,即使是单元测试也不能证明错误的不存在,但只能突出错误的存在。

还有更多...

软件测试是一个组合数学问题。例如,每个布尔测试至少需要两个测试,一个用于真条件,一个用于假条件。可以证明,对于每行功能代码,测试至少需要三到五行代码。因此,在没有专门的测试用例生成工具的情况下,测试任何非平凡代码的所有可能的输入组合是不切实际的。

要从单元测试中获得预期的收益,在整个开发过程中都需要有严格的纪律感。不仅要跟踪已开发和执行的测试,还要跟踪对所讨论单元的功能代码以及所有其他单元所做的所有更改。使用版本控制系统是必不可少的。如果一个单元的后续版本在之前通过测试时失败了,那么版本控制系统可以帮助你突出显示在此期间发生的代码更改。

参见

nose.readthedocs.io/en/latest/index.html有一个有效的nose教程。

posted @ 2025-09-19 10:36  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报