Python-并行编程秘籍-全-

Python 并行编程秘籍(全)

原文:zh.annas-archive.org/md5/e472b7edae31215ac8e4e5f1e5748012

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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

然而,这种扩展不仅在半导体行业中,也在可以通过并行计算执行的应用程序的开发中带来了挑战。

事实上,并行计算代表着同时利用多个计算资源来解决处理问题,以便可以在多个 CPU 上执行,将问题分解为可以同时处理的离散部分,每个部分进一步分解为可以在不同 CPU 上串行执行的一系列指令。

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

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

最后但同样重要的是,并行计算代表了最大化时间这一无限但同时越来越宝贵和稀缺的资源的尝试。这就是为什么并行计算正在从为少数人保留的非常昂贵的超级计算机的世界转向基于多处理器、图形处理单元GPU)或几台相互连接的计算机的更经济和解决方案,这些解决方案可以克服串行计算的约束和单个 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 成为任何应用程序的有价值工具,特别是当然是并行计算。

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

第三章,基于进程的并行性,引导读者通过基于进程的方法来并行化程序。一整套示例将向读者展示如何使用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. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

该书的代码包也托管在 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

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

警告或重要说明会出现在这样的地方。提示和技巧会出现在这样的地方。

章节

在本书中,您会经常看到几个标题(准备工作如何做…它是如何工作的…还有更多…另请参阅)。

为了清晰地说明如何完成食谱,请使用以下各节:

准备工作

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

如何做…

这一部分包含了遵循食谱所需的步骤。

它是如何工作的…

这一部分通常包括对前一部分发生的事情的详细解释。

还有更多…

这一部分包括有关食谱的额外信息,以使您对食谱更加了解。

另请参阅

这一部分为食谱提供了其他有用信息的链接。

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

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

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

接下来的部分将概述并行编程体系结构和编程模型。这些概念对于初学者来说是有用的,他们第一次接触并行编程技术。此外,它也可以成为有经验的程序员的基本参考。还介绍了并行系统的双重特征。第一种特征基于系统架构,而第二种特征基于并行编程范式。

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

在本章中,我们将涵盖以下内容:

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

  • 弗林的分类

  • 内存组织

  • 并行编程模型

  • 性能评估

  • 介绍 Python

  • Python 和并行编程

  • 介绍进程和线程

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

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

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

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

弗林的分类

弗林的分类是一种用于分类计算机体系结构的系统。它基于两个主要概念:

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

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

由于指令和数据流是独立的,存在四类并行机器:单指令单数据SISD)、单指令多数据SIMD)、多指令单数据MISD)和多指令多数据MIMD):

弗林分类法

单指令单数据(SISD)

SISD 计算系统类似于冯·诺伊曼机,即单处理器机器。如弗林分类法图所示,它执行单个指令,作用于单个数据流。在 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 计算机解决。

根据这一范式开发的超级计算机,我们必须提到Connection Machine(Thinking Machine,1985)和MPP(NASA, 1983)。

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

多指令多数据(MIMD)

根据弗林的分类,这类并行计算机是最一般和最强大的类。这包括n个处理器,n个指令流和n个数据流。每个处理器都有自己的控制单元和本地内存,这使得 MIMD 架构比 SIMD 架构更具计算能力。

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

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

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

通过考虑 SIMD 机器可以分为两个子组:

  • 数值超级计算机

  • 矢量机器

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

事实上,下一节着重讨论 MIMD 机器内存组织的最后一个方面。

内存组织

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

我们需要克服的主要问题是使内存的响应时间与处理器的速度兼容,这是内存周期时间,即两次连续操作之间经过的时间。处理器的周期时间通常比内存的周期时间短得多。

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

MIMD 架构中的内存组织

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

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

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

共享内存

共享内存多处理器系统的架构如下图所示。这里的物理连接非常简单。

共享内存架构图

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

每个处理器都与缓存内存相关联,因为假定处理器需要在本地内存中具有数据或指令的概率非常高。

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

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

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

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

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

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

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

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

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

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

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

分布式内存

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

分布式内存架构模式

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

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

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

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

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

基本消息传递

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

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

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

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

  • 使用消息传递协议,以便 CPU 可以通过交换数据包进行通信。消息是信息的离散单元,从这个意义上说,它们具有明确定义的身份,因此总是可以将它们与其他消息区分开来。

大规模并行处理(MPP)

MPP 机器由数百个处理器组成(在某些机器中可以达到数十万个处理器),它们通过通信网络连接。世界上最快的计算机基于这些架构;这些架构系统的一些例子是 Earth Simulator、Blue Gene、ASCI White、ASCI Red、ASCI Purple 和 Red Storm。

工作站集群

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

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

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

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

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

  • 高性能计算集群:在这种情况下,每个节点都配置为提供极高的性能。该过程也被分成多个作业,并行化并分布到不同的机器上。

异构架构

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

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

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

因此,尽管 CPU 和 GPU 似乎不能有效地共同工作,但通过使用这种新架构,我们可以优化它们与并行应用程序的交互和性能:

异构架构模式

在接下来的部分中,我们将介绍主要的并行编程模型。

并行编程模型

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

绝对来说,没有一个模型比其他模型更好。因此,应用的最佳解决方案将在很大程度上取决于程序员需要解决和解决的问题。最广泛使用的并行编程模型如下:

  • 共享内存模型

  • 多线程模型

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

  • 数据并行模型

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

共享内存模型

在这个模型中,任务共享一个内存区域,我们可以异步读写。有一些机制允许编码人员控制对共享内存的访问;例如,锁或信号量。这个模型的优点是编码人员不必澄清任务之间的通信。在性能方面的一个重要缺点是,更难理解和管理数据局部性。这指的是保持数据局部于处理器上,以保留内存访问、缓存刷新和总线流量,当多个处理器使用相同数据时发生。

多线程模型

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

当前一代的 CPU 在软件和硬件上都是多线程的。POSIX(代表可移植操作系统接口)线程是软件上多线程实现的经典例子。英特尔的超线程技术通过在一个线程停滞或等待 I/O 时在两个线程之间切换来在硬件上实现多线程。即使数据对齐是非线性的,也可以从这个模型中实现并行性。

消息传递模型

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

一些例子自 20 世纪 80 年代以来就存在,但直到 20 世纪 90 年代中期才创建了一个标准化的模型,导致了一种事实上的标准,称为消息传递接口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时,这是一个效率低下的可并行化问题。

扩展

扩展被定义为在并行机器上高效的能力。它确定了计算能力(执行速度)与处理器数量成比例。通过增加问题的规模和同时增加处理器的数量,性能不会有损失。

可扩展的系统,根据不同因素的增量,可以保持相同的效率或改善效率。

Amdahl 定律

Amdahl 定律是一条广泛使用的定律,用于设计处理器和并行算法。它规定了可以实现的最大加速比受程序的串行部分限制:

1 - P表示程序的串行部分(不并行化)。

这意味着,例如,如果一个程序中有 90%的代码可以并行执行,但 10%必须保持串行,则最大可实现的加速比为 9,即使有无限数量的处理器也是如此。

Gustafson 定律

Gustafson 定律陈述如下:

在这里,正如我们在方程中指出的那样:

  • P处理器数量

  • S加速因子。

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

Gustafson 定律与 Amdahl 定律形成对比,后者假设程序的整体工作量不随处理器数量的变化而改变。

事实上,Gustafson 定律建议程序员首先设置解决问题的并行时间,然后基于(即时间)调整问题的大小。因此,并行系统,在相同时间内可以解决的问题越大

Gustafson 定律的影响是将计算机研究的目标引向以某种方式选择或重新制定问题,以便在相同的时间内仍然可以解决更大的问题。此外,该定律重新定义了效率的概念,即需要至少减少程序的顺序部分,尽管工作量增加

介绍 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)

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

>>> 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 版本以来,集合已经集成到 Python 中(之前的版本可在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 

请注意导入时间模块,该模块将用于评估执行时间,在本例中,以及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 RAM,如下所示:

List processing complete.
serial time= 25.428767204284668

multithreading实现的情况下,我们有以下情况:

> 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模块提供了几种非常简单实现的同步机制。

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程序中,使用target函数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 个线程的i索引作为参数传递给第 i 个线程:

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方法中定义的操作为特征,在这个简单的例子中,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函数计算的,该函数输出 1 到 10 之间的随机整数。从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模块还包括了一个简单的锁机制,允许我们在线程之间实现同步。

准备工作

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

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

acquire()方法接受一个可选参数,如果未指定或设置为True,则强制线程暂停执行,直到锁被释放并可以获取。另一方面,如果使用参数等于False执行acquire()方法,则立即返回一个布尔结果,如果锁已被获取,则为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()机制管理线程。

准备工作

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

一个RLock块可以被同一个线程多次获取。其他线程在拥有它的线程对每次之前的acquire()调用进行release()调用之前将无法获取RLock块。确实,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类和要添加或移除的项目的总数作为参数:
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之间的区别如下:

  • lock只能在释放之前被获取一次。但是,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上执行等待时,就会创建死锁情况。

使用条件进行线程同步

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

一旦条件发生,线程就会获取锁,以便对共享资源进行独占访问

准备工作

一个很好的说明这种机制的方法是再次看一个生产者/消费者问题。生产者类在缓冲区不满时向缓冲区写入数据,而消费者类在缓冲区满时从缓冲区中取出数据(从后者中消除)。生产者类将通知消费者缓冲区不为空,而消费者将向生产者报告缓冲区不满。

如何做...

涉及的步骤如下:

  1. 消费者类获取通过items[]列表建模的共享资源:
condition.acquire()
  1. 如果列表的长度等于0,则消费者被置于等待状态:
if len(items) == 0:
 condition.wait()
  1. 然后它从 items 列表中进行一次pop操作:
items.pop()
  1. 因此,消费者的状态被通知给生产者,共享资源被释放:
condition.notify()
  1. 生产者类获取共享资源,然后验证列表是否完全满(在我们的示例中,我们放置了最大数量的项目10,可以包含在 items 列表中)。如果列表已满,则生产者被置于等待状态,直到列表被消耗:
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,后者将开始再次填充缓冲区。

同样,如果缓冲区为空,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()方法。

准备工作

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

如何做...

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

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

现在,让我们看看如何使用event语句实现消费者/生产者问题的线程同步:

  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获取锁,向队列添加项目,并通知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 的线程模块通过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赢得了比赛。

使用队列进行线程通信

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

然而,使用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(这是我们在示例中使用的默认情况),则我们需要阻塞直到有一个空闲槽可用。如果超时是一个正数,则最多阻塞超时秒,并在该时间内没有可用的空闲槽时引发 full 异常。

  • 如果blockfalse,则如果立即有空闲槽,则将项目放入队列,否则引发 full 异常(在这种情况下忽略超时)。在这里,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线程获取锁定,然后将数据插入QUEUE数据结构中。

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

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

通过这个示例,本章关于基于线程的并行性就结束了。

第三章:基于进程的并行处理

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

Python 的multiprocessing模块是语言标准库的一部分,实现了共享内存编程范式,即一个或多个处理器可以访问共享内存的系统的编程。

在本章中,我们将涵盖以下内容:

  • 理解 Python 的multiprocessing模块

  • 生成一个进程

  • 给进程命名

  • 在后台运行进程

  • 杀死进程

  • 在子类中定义一个进程

  • 使用队列交换对象

  • 使用管道交换对象

  • 同步进程

  • 管理进程之间的状态

  • 使用进程池

理解 Python 的多进程模块

Python 的multiprocessing文档(docs.python.org/2.7/library/multiprocessing.html#introduction)清楚地提到,这个包中的所有功能都需要main模块对子模块可导入(docs.python.org/3.3/library/multiprocessing.html)。

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

> python multiprocessing_example.py

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

生成一个进程

生成进程是从父进程创建子进程。后者会异步地继续执行或等待子进程结束。

准备就绪

multiprocessing库允许通过以下步骤生成进程:

  1. 定义process对象。

  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. 两个进程的target函数都是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 系统中,运行在后台的进程称为守护进程。使用任务管理器可以突出显示所有运行的程序,包括后台程序。

准备就绪

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

  • background_process,其daemon参数设置为True

  • NO_background_process,其daemon参数设置为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

另请参阅

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

终止进程

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

准备工作

可以使用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 机器上,可以通过以下教程简单地识别并终止 Python 进程:www.cagrimmett.com/til/2016/05/06/killing-rogue-python-processes.html

在子类中定义进程

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

准备工作

要实现一个多进程的自定义子类,我们需要做以下几件事:

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

  • 覆盖_init__(self [,args])方法以添加额外的参数,如果需要的话。

  • 覆盖run(self [,args])方法来实现Process在启动时应该做什么。

一旦你创建了新的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. 首先,处理从09的数字的pipe_1
 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模块相同。

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

准备就绪

Python 中的Barrier对象用于等待固定数量的线程执行完毕,然后给定线程才能继续执行程序。

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

如何做...

让我们考虑四个进程,其中进程p1和进程p2由一个屏障语句管理,而进程p3和进程p4没有同步指令。

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

  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):

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类有以下方法:

  • apply(): 这会阻塞,直到结果准备就绪。

  • apply_async(): 这是apply()方法的变体,返回一个结果对象。这是一个异步操作,直到所有子类都执行完毕才会锁定主线程。

  • map(): 这是内置map()的并行等价物(docs.python.org/2/library/functions.html#map)。这会阻塞,直到结果准备好,并且它会将可迭代数据分成多个块,作为单独的任务提交给进程池。

  • map_async(): 这是map()的一个变体(docs.python.org/2/library/multiprocessing.html?highlight=pool%20class#multiprocessing.pool.multiprocessing.Pool.map),它返回一个result对象。如果指定了回调函数,则应该是可调用的,接受一个参数。当结果准备好时,将应用回调函数(除非调用失败)。回调函数应立即完成;否则,处理结果的线程将被阻塞。

如何做…

这个例子向你展示了如何实现一个进程池来执行并行应用。我们创建了一个包含四个进程的进程池,然后使用进程池的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.map的工作方式与 map 相同,但使用了多个进程,其数量为四,在创建 pool 时事先定义好了:

 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 的主要目标是建立一种高效、灵活和可移植的消息交换通信标准。

主要是展示库的函数,包括同步和异步通信原语,如(发送/接收)和(广播/全对全),计算的部分结果的组合操作(gather/reduce),最后是进程之间的同步原语(屏障)。

此外,通过定义拓扑来介绍通信网络的控制函数。

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

  • 使用mpi4py Python 模块

  • 实现点对点通信

  • 避免死锁问题

  • 使用广播进行集体通信

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

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

  • 使用Alltoall进行集体通信

  • 减少操作

  • 优化通信

技术要求

本章需要mpichmpi4py库。

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

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

mpi4py Python 模块为 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 中,执行并行程序的进程由一系列非负整数称为排名来标识。

  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的。每个进程为其所属的每个通信器分配一个rankrank是一个从零开始分配的整数,用于在特定通信器的上下文中标识每个单独的进程。通常做法是将全局rank0的进程定义为主进程。通过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的进程是接收进程。实际上,在comm.recv语句的参数中设置了源进程(即rank等于0的进程):
if rank==4: 
    data=comm.recv(source=0) 
  1. 现在,使用以下代码,必须显示来自进程0的数据接收:
 print ("data received is = %s" %data) 
  1. 最后要设置的进程是编号为9的进程。在这里,我们在comm.recv语句中将rank等于1的源进程定义为参数:
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)。

  • 根进程,即rank等于 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 功能与 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个变量的值:

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的限制之一是,您可以在执行语句中指定的处理器数量中分散多少元素。实际上,如果您尝试分散比指定的处理器(在本例中为 3)更多的元素,那么您将收到类似以下的错误:

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类型。

另请参阅

有关 MPI 广播的有趣教程,请访问pythonprogramming.net/mpi-broadcast-tutorial-mpi4py/

使用 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的功能。

如何做到...

在以下示例中,我们将看到comm.Alltoallmpi4py实现。我们将考虑一个通信器,其中每个进程从组中定义的其他进程发送和接收数值数据的数组:

  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)*个对象,并将其复制到任务`i`的`recvbuf`参数的第*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 减少操作。

对于数组操作,我们使用 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个元素求和,然后将结果放入P0根进程数组的第i个元素中。对于接收操作,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进程与P1RIGHT)和P2DOWN)进程链接。P1进程与P3DOWN)和P0LEFT)进程链接,P3进程与P1UP)和P2LEFT)进程链接,P2进程与P3RIGHT)和P0UP)进程链接。

最后,通过运行脚本,我们获得以下结果:

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 中引入的asyncio模块。这使我们能够使用协程和未来来更轻松地编写异步代码,并使其更易读。

在本章中,我们将涵盖以下内容:

  • 使用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概念)以用于并行或并发任务。

我们在这里采取的方法涉及使用池执行器。我们将资产提交到池(线程和进程)并获得未来,这些未来将来会对我们可用。当然,我们可以等待所有未来变成真正的结果。

线程或进程池(也称为池化)表示正在用于优化和简化程序中线程和/或进程的使用的管理软件。通过池化,您可以将任务(或任务)提交给池执行。

池配备有一个待处理任务的内部队列和多个线程执行它们的进程。池中的一个经常出现的概念是重用:一个线程(或进程)在其生命周期内多次用于不同的任务。这减少了创建新线程或进程的开销,并提高了程序的性能。

重用不是一个规则,但它是导致编码人员在他们的应用程序中使用池的主要原因之一。

准备就绪

concurrent.futures模块提供了Executor类的两个子类,它们可以异步地操作一个线程池和一个进程池。这两个子类如下:

  • concurrent.futures.ThreadPoolExecutor(max_workers)

  • concurrent.futures.ProcessPoolExecutor(max_workers)

max_workers参数标识着异步执行调用的最大工作线程数。

如何做...

这是线程和进程池使用的一个例子,我们将比较执行时间与顺序执行所需的时间。

要执行的任务如下:我们有一个包含 10 个元素的列表。列表的每个元素都被计数到 100,000,000(只是为了浪费时间),然后最后一个数字乘以列表的第i个元素。特别是,我们正在评估以下情况:

  • 顺序执行

  • 具有五个工作线程的线程池

  • 使用五个工作线程的进程池

现在,让我们看看如何做:

  1. 导入相关的库:
import concurrent.futures
import time
  1. 定义从110的数字列表:
number_list = list(range(1, 11))
  1. count(number)函数计算从1100000000的数字,然后返回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. 对于顺序执行,对number_list的每个项目执行evaluate函数。然后,打印出执行时间:
 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 中的某个例程)并在外部处理完成时从停止的点返回。

  • Futures:这与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()docs.python.org/3/library/asyncio-eventloop.html)返回后调用回调。

  • loop.time(): 这将根据事件循环的内部时钟返回当前时间作为float值(docs.python.org/3/library/functions.html)。

  • asyncio.set_event_loop(): 这将当前上下文的事件循环设置为loop

  • asyncio.new_event_loop(): 这根据此策略的规则创建并返回一个新的事件循环对象。

  • loop.run_forever(): 这将一直运行,直到调用stop()docs.python.org/3/library/asyncio-eventloop.html)。

如何做到这一点...

在这个例子中,我们看一下如何使用asyncio库提供的事件循环语句,以便构建一个以异步模式工作的应用程序。

在这个例子中,我们定义了三个任务。每个任务的执行时间由一个随机时间参数确定。一旦执行完成,Task A调用Task BTask B调用Task CTask C调用Task A

事件循环将持续进行,直到满足终止条件。正如我们可以想象的那样,这个例子遵循这个异步模式:

异步编程模型

让我们看看以下步骤:

  1. 让我们从导入我们实现所需的库开始:
import asyncio
import time
import random
  1. 然后,我们定义了task_A,其执行时间是随机确定的,可以从15秒不等。在执行结束时,如果终止条件没有满足,那么计算就会转到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。它的执行时间是随机确定的,可以从47秒不等。在执行结束时,如果终止条件没有满足,那么计算就会转到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。它的执行时间是随机确定的,可以从610秒不等。在执行结束时,如果终止条件没有满足,那么计算就会回到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 处理协程

在我们所呈现的各种示例中,我们已经看到,当程序变得非常长和复杂时,将其分成子程序是方便的,每个子程序实现一个特定的任务。但是,子程序不能独立执行,而只能在主程序的请求下执行,主程序负责协调子程序的使用。

在这一部分,我们介绍了子程序概念的一个泛化,称为协程:就像子程序一样,协程计算单个计算步骤,但与子程序不同的是,没有“主”程序来协调结果。协程将自己链接在一起,形成一个管道,没有任何监督功能负责按特定顺序调用它们。

在协程中,执行点可以被暂停并稍后恢复,因为协程跟踪执行状态。拥有一组协程后,可以交错计算:第一个运行直到将控制权让出,然后第二个运行并继续下去。

交错由事件循环管理,该事件循环在使用 asyncio 管理事件循环配方中进行了描述。它跟踪所有协程,并安排它们何时执行。

协程的其他重要方面如下:

  • 协程允许多个入口点,可以多次产生。

  • 协程可以将执行转移到任何其他协程。

在这里,术语yield用于描述协程暂停并将控制流传递给另一个协程。

准备就绪

我们将使用以下表示法来处理协程:

import asyncio 

@asyncio.coroutine
def coroutine_function(function_arguments):
 ............
 DO_SOMETHING
 ............ 

协程使用 PEP 380 中引入的yield from语法(在www.python.org/dev/peps/pep-0380/中阅读更多)来停止当前计算的执行并挂起协程的内部状态。

特别是在yield from future的情况下,协程被挂起,直到future完成,然后将传播future的结果(或引发异常);在yield from coroutine的情况下,协程等待另一个协程产生结果,该结果将被传播(或引发异常)。

正如我们将在下一个示例中看到的,其中协程将用于模拟有限状态机,我们将使用yield from coroutine表示法。

有关使用asyncio的协程的更多信息,请访问docs.python.org/3.5/library/asyncio-task.html

如何做...

在这个示例中,我们看到如何使用协程来模拟具有五个状态的有限状态机。

有限状态机有限状态自动机是一种在工程学科中广泛使用的数学模型,也在数学和计算机科学等科学中使用。

我们想要使用协程模拟行为的自动机如下:

有限状态机

系统的状态为S0S1S2S3S4,其中01是自动机可以从一个状态过渡到下一个状态的值(这个操作称为过渡)。例如,状态S0可以过渡到状态S1,但只能为值1S0可以过渡到状态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_stateend_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+引入的新语法和asyncio"Hello, world!"的示例:

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)。

一个任务负责在事件循环中执行一个协程对象。

如果包装的协程使用yields from future表示法,如使用 asyncio 处理协程部分中已经描述的那样,那么任务将暂停包装的协程的执行并等待未来的完成。

当未来完成时,包装的协程的执行将重新开始,使用未来的结果或异常。此外,必须注意,事件循环一次只运行一个任务。如果其他事件循环在不同的线程中运行,则其他任务可以并行运行。

当任务等待未来的完成时,事件循环执行一个新任务。

如何做到这一点...

在这个例子中,我们展示了如何通过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 和 futures

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 协议稳健性的基础,而 TCP/IP 协议的发展又是军事领域(ARPANET)发展的原因之一。

各种现有的标准应用程序(如 Web 浏览、文件传输和电子邮件)使用标准化的应用程序协议,如 HTTP、FTP、POP3、IMAP 和 SMTP。

每个特定的客户端-服务器应用程序必须定义和应用自己的专有应用程序协议。这可能涉及以固定大小的数据块交换数据(这是最简单的解决方案)。

多级应用程序

有更多级别可以减轻服务器的处理负载。实际上,被细分的是服务器端的功能,而客户端部分的特性基本保持不变,其任务是托管应用程序界面。这种架构的一个例子是三层模型,其结构分为三层或级别:

  • 前端或演示层或界面

  • 中间层或应用逻辑

  • 后端或数据层或持久数据管理

这种命名方式通常用于 Web 应用程序。更一般地,可以将任何软件应用程序分为三个级别,如下所示:

  • 表示层PL):这是数据的可视化部分(例如用户界面所需的模块和输入控件)。

  • 业务逻辑层BLL):这是应用程序的主要部分,独立于用户可用的演示方法并保存在档案中,定义了各种实体及其关系。

  • 数据访问层DAL):其中包含管理持久数据所需的一切(基本上是数据库管理系统)。

本章将介绍 Python 提出的一些分布式架构的解决方案。我们将首先描述socket模块,然后使用它来实现一些基本的客户端-服务器模型的示例。

使用 Python 套接字模块

套接字是一种软件对象,允许在远程主机(通过网络)或本地进程之间发送和接收数据,例如进程间通信IPC)。

套接字是在伯克利作为BSD Unix项目的一部分发明的。它们基于 Unix 文件的输入和输出管理模型。事实上,打开、读取、写入和关闭套接字的操作与 Unix 文件的管理方式相同,但需要考虑的区别是用于通信的有用参数,如地址、端口号和协议。

套接字技术的成功和传播与互联网的发展息息相关。事实上,套接字与互联网的结合使得任何类型的机器之间的通信以及分散在世界各地的机器之间的通信变得非常容易(至少与其他系统相比是如此)。

准备就绪

Python 套接字模块公开了用于使用BSDBerkeley Software Distribution的缩写)套接字接口进行网络通信的低级 C API。

该模块包括Socket类,其中包括管理以下任务的主要方法:

  • socket([family [, type [, protocol]]]): 使用以下参数构建套接字:

  • family地址,可以是AF_INET(默认)AF_INET6,或AF_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号码:
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号码:
port=9999
  1. 建立到hostport的连接:
s.connect((host,port))

可以接收的最大字节数不超过 1024 字节:(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,然后调用服务器 3。当服务器 3调用服务器 1时,链式调用结束。

实现链式拓扑

使用Pyro4实现链式拓扑,我们需要实现一个chain对象和clientserver对象。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']

在返回任务完成的三个服务器之间传递转发请求后,将显示前面的消息。

此外,我们可以关注对象服务器在请求转发到链中的下一个对象时的行为(参见开始消息下方的消息):

  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 应用程序容器化

  • 介绍无服务器计算

我们还将看到如何利用AWS Lambda框架开发 Python 应用程序。

什么是云计算?

云计算是一种基于一组资源的计算模型,例如虚拟处理、大容量存储和网络,可以动态聚合和激活为运行应用程序的平台,满足适当的服务水平并优化资源使用效率。

这可以通过最少的管理工作或与服务提供商的交互快速获取和释放。这种云模型由五个基本特征、三种服务模型和四种部署模型组成。

特别是,五个基本特征如下:

  • 免费和按需访问:这使用户可以通过用户友好的界面访问提供商提供的服务,无需人工干预。

  • 网络的无处不在的访问:资源可以通过网络随时访问,并且可以通过标准设备(如智能手机平板电脑个人电脑)访问。

  • 快速弹性:这是云快速和自动增加或减少分配的资源的能力,使其对用户来说似乎是无限的。这为系统提供了很大的可伸缩性。

  • 可测量的服务:云系统不断监视提供的资源,并根据估计的使用自动优化它们。这样,客户只支付在特定会话中实际使用的资源。

  • 资源共享:提供商通过多租户模型提供其资源,以便可以根据客户的请求动态分配和重新分配,并由多个消费者使用:

云计算的主要特点

然而,云计算有许多定义,每个定义都有不同的解释和含义。国家标准与技术研究所(NIST)试图提供详细和官方的解释(csrc.nist.gov/publications/detail/sp/800-145/final)。

另一个特性(未列在 NIST 定义中,但是云计算的基础)是虚拟化的概念。这是在相同的物理资源上执行多个操作系统的可能性,保证了许多优势,如可伸缩性、成本降低和向客户提供新资源的速度更快。

虚拟化的最常见方法如下:

  • 容器

  • 虚拟机

这两种解决方案在隔离应用程序方面几乎具有相同的优势,但它们在不同的虚拟化级别上工作,因为容器虚拟化操作系统,而虚拟机虚拟化硬件。这意味着容器更具可移植性和效率。

通过容器进行虚拟化的最常见应用是 Docker。我们将简要介绍这个框架,并看看如何将 Python 应用程序容器化(或 dockerize)。

了解云计算架构

云计算架构指的是构成系统结构的一系列组件和子组件。通常,它可以分为前端后端两个主要部分:

云计算架构

每个部分都有非常具体的含义和范围,并通过虚拟网络或互联网网络与其他部分相连。

前端指的是用户可见的云计算系统部分,通过一系列界面和应用程序实现,允许消费者访问云系统。不同的云计算系统有不同的用户界面。

后端是客户看不到的部分。该部分包含所有资源,允许提供商提供云计算服务,如服务器、存储系统和虚拟机。创建后端的想法是将整个系统的管理委托给单个中央服务器,因此必须不断监视流量和用户请求,执行访问控制,并实施通信协议。

在这种架构的各个组件中,最重要的是 Hypervisor,也称为虚拟机管理器。这是一种固件,可以动态分配资源,并允许您在多个用户之间共享单个实例。简而言之,这是实现虚拟化的程序,这是云计算的主要属性之一。

在提供云计算的定义并解释基本特性之后,我们将介绍云计算服务可以提供的服务模型

服务模型

提供商提供的云计算服务可分为三大类:

  • S软件即服务(SaaS)

  • P平台即服务(PaaS)

  • I基础设施即服务(IaaS)

这种分类导致了一个名为SPI模型的方案的定义(请参阅前面列表中的粗体首字母)。有时它被称为云计算堆栈,因为这些类别是基于彼此的。

现在将详细描述每个级别,采用自上而下的方法。

SaaS

SaaS 提供商为用户提供按需的软件应用程序,可以通过任何互联网设备(如 Web 浏览器)访问。此外,提供商托管软件应用程序和基础架构,减轻了客户管理和维护活动的负担,如软件更新和安全补丁的应用。

使用这种模型对用户和提供商都有许多优势。对于用户来说,管理成本大大降低,对于提供商来说,他们对流量有更多的控制,从而避免任何过载。SaaS 的一个例子是任何基于 Web 的电子邮件服务,如GmailOutlookSalesforceYahoo!

PaaS

与 SaaS 不同,这项服务指的是应用程序的整个开发环境,而不仅仅是其使用。因此,PaaS 解决方案提供了一个通过 Web 浏览器访问的云平台,用于开发、测试、分发和管理软件应用程序。此外,提供商提供基于 Web 的界面、多租户架构和通信工具,以便让开发人员更简单地创建应用程序。这支持软件的整个生命周期,也有利于合作。

PaaS 的例子有微软 Azure 服务谷歌应用引擎亚马逊网络服务

IaaS

IaaS 是一种以按需服务提供计算基础设施的模型。因此,您可以购买虚拟机,在其上运行自己的软件,存储资源(根据实际需求迅速增加或减少存储容量),网络和操作系统,并根据实际使用情况付费。这种动态基础设施增加了更大的可扩展性,同时也大大降低了成本。

这种模型既被小型新兴公司使用,因为它们没有大量资金进行投资,也被寻求简化其硬件架构的成熟公司使用。IaaS 卖家的范围非常广泛,包括亚马逊网络服务IBM甲骨文

分发模型

事实上,云计算架构并非都是一样的。实际上,有四种不同的分发模型:

  • 公共云

  • 私有云

  • 云社区

  • 混合云

公共云

这种分发模型对所有人开放,包括个人用户和公司。通常,公共云在由服务提供商拥有的数据中心中运行,处理硬件、软件和其他支持基础设施。这样,用户就不必进行任何维护活动/费用。

私有云

也被称为内部云,私有云提供与公共云相同的优势,但对数据和流程提供更大的控制。这种模型被呈现为一种专门为公司工作的云基础设施,因此在给定公司的边界内进行管理和托管。显然,使用它的组织可以将其架构扩展到与其有业务关系的任何群体。

通过采用这种解决方案,可以避免涉及敏感数据违规和工业间谍活动的可能问题,同时也不忽视使用简化、可配置和高性能的工作配置系统的可能性。正因为如此,近年来使用私有云的公司数量显著增加。

云社区

从概念上讲,这种模型描述了由几家具有共同利益的公司实施和管理的共享基础设施。这种类型的解决方案很少被使用,因为在各个社区成员之间分享责任和管理活动可能变得复杂。

混合云

NIST 将其定义为前面提到的三种实施模型(私有云、公共云和社区云)的组合结果,试图利用每种云的优势来弥补其他云的不足之处。使用的云保持独立实体,这可能导致操作一致性的缺失。因此,采用这种模型的公司有责任通过专有技术来保证其服务器的互操作性,使其能够优化其必须扮演的特定角色。

混合云与其他所有云的一个特点是云爆发,或者在出现大量峰值需求时,能够动态地将私有云中的过多流量转移到公共云中的可能性。

这种实施模型是由那些打算在保留内部云中的敏感数据的同时共享其软件应用程序的公司采用的。

云计算平台

云计算平台是一组软件和技术,可以在云中交付资源(按需,可扩展和虚拟化资源)。最受欢迎的平台包括谷歌的平台,当然还有云计算的里程碑:亚马逊网络服务AWS)。两者都支持 Python 作为开发语言。

然而,在下一个教程中,我们将专注于 PythonAnywhere,这是专门用于部署 Python 编程语言的 Web 应用程序的云平台。

使用 PythonAnywhere 开发 Web 应用程序

PythonAnywhere 是基于 Python 编程语言的在线托管开发和服务环境。一旦在网站上注册,您将被引导到包含完全由 HTML 代码制作的高级 shell 和文本编辑器的仪表板。通过这样,您可以创建,修改和执行自己的脚本。

此外,这个开发环境还允许您选择要使用的 Python 版本。在这方面,一个简单的向导帮助我们预配置应用程序。

准备就绪

首先让我们看看如何获取网站的登录凭据。

以下屏幕截图显示了各种订阅类型,以及获得免费帐户的可能性(请转到www.pythonanywhere.com/registration/register/beginner/):

PythonAnywhere:注册页面

一旦获得了对网站的访问权(建议您创建一个初学者帐户),我们登录。鉴于集成到浏览器中的 Python shell 对于初学者和入门编程课程来说非常有用,它们在技术上当然不是新鲜事物。

相反,PythonAnywhere 的附加值在您登录并访问个人仪表板时立即被感知:

PythonAnywhere:仪表板

通过个人仪表板,我们可以选择在 2.7 和 3.7 之间运行的 Python 版本,还可以选择是否使用 IPython 界面:

PythonAnywhere:控制台视图

可以使用的控制台数量根据您拥有的订阅类型而变化。在我们的情况下,我们使用了初学者帐户,最多可以使用两个 Python 控制台。选择 Python shell,例如版本 3.5,应该在 Web 浏览器上打开以下视图:

PythonAnywhere:Python shell

在接下来的部分,我们想向您展示如何使用 PythonAnywhere 编写一个简单的 Web 应用程序。

如何做到...

让我们看看以下步骤:

  1. 在仪表板上,打开 Web 选项卡:

PythonAnywhere:Web 应用程序视图

  1. 界面告诉我们我们还没有 Web 应用程序。通过选择添加新的 Web 应用程序,将打开以下视图。它告诉我们我们的应用程序将具有以下 Web 地址:loginname.pythonanywhere.com(例如,应用程序的 Web 地址将是giazax.pythonanywhere.com):

PythonAnywhere:Web 应用程序向导

  1. 当我们单击“下一步”时,我们可以选择我们想要使用的 Python Web 框架:

PythonAnywhere:Web 框架向导

  1. 我们选择 Flask 作为 Web 框架,然后单击“下一步”来选择我们想要使用的 Python 版本,如下所示:

PythonAnywhere:Web 框架向导 Flask 是一个易于安装和使用的 Python 微框架,被 Pinterest 和 LinkedIn 等公司使用。

如果您不知道用于创建 Web 应用程序的框架是什么,那么您可以想象一组旨在简化 Web 服务(如 Web 服务器和 API)创建的程序。有关 Flask 的更多信息,请访问flask.pocoo.org/docs/1.0/

  1. 在上一个屏幕截图中,我们选择了 Flask 1.0.2 的 Python 3.5,然后点击“下一步”以输入用于保存 Flask 应用程序的 Python 文件的路径。在这里,选择了默认文件:

PythonAnywhere:Flask 项目定义

  1. 当我们最后一次点击“下一步”时,将显示以下屏幕,其中总结了 Web 应用程序的配置参数:

PythonAnywhere:giazax.pythonanywhere.com 的配置页面

现在,让我们看看这会发生什么。

它是如何工作的...

在 Web 浏览器的地址栏中,键入我们的 Web 应用程序的 URL,例如https://giazax.pythonanywhere.com/。该站点显示一个简单的欢迎短语:

giazax.pythonanywhere.com 站点页面

通过选择“转到目录”可以查看此应用程序的源代码,与“源代码”标签对应。

PythonAnywhere:配置页面

在这里,可以分析构成 Web 应用程序的文件:

PythonAnywhere:项目站点存储库

还可以上传新文件并可能修改内容。在这里,我们选择了我们第一个 Web 应用程序的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!'

route()装饰器由 Flask 用于定义应触发hello_world函数的 URL。这个简单的函数返回在 Web 浏览器中显示的消息。

还有更多...

PythonAnywhere shell 是用 HTML 制作的,几乎可以在多个平台和浏览器上使用,包括苹果的移动版本。可以保持多个 shell 打开(根据所选的帐户配置文件选择不同数量),与其他用户共享它们,或根据需要终止它们。

PythonAnywhere 具有一个相当先进的文本编辑器,具有语法着色和自动缩进功能,通过它可以创建,修改和执行自己的脚本。文件存储在存储区域中,其大小取决于帐户的配置文件,但如果空间不足或者希望更流畅地与 PC 的文件系统集成,那么 PythonAnywhere 允许您使用 Dropbox 帐户,在流行的存储服务上访问您的共享文件夹。

每个 shell 可以包含与特定 URL 对应的 WSGI 脚本。还可以启动一个 bash shell,从中调用 Git 并与文件系统交互。最后,正如我们所看到的,有一个可用的向导,允许我们预配置Djangoweb2py或 Flask 应用程序。

此外,还有利用MySQL数据库的可能性,这是一系列允许我们定期执行某些脚本的 cron 作业。因此,我们将获得 PythonAnywhere 的真正本质:以光速部署 Web 应用程序。

PythonAnywhere 完全依赖于Amazon EC2基础设施,因此没有理由不信任该服务。因此,强烈建议那些考虑个人使用的人使用。初学者账户提供的资源比Heroku上的对应资源更多(www.heroku.com/),部署比OpenShiftwww.openshift.com/)更简单,整个系统通常比Google App Enginecloud.google.com/appengine/)更灵活。

另请参阅

Flask一样,建议您访问这些网站以获取有关如何使用这些库的信息。

将 Python 应用程序容器化

容器是虚拟化环境。它们包括软件所需的一切,即库、依赖项、文件系统和网络接口。与经典的虚拟机不同,所有上述元素与它们运行的机器共享内核。这样,对主机节点资源的使用影响大大减少。

这使得容器在可扩展性、性能和隔离方面成为一种非常有吸引力的技术。容器并不是一种新技术;它们在 2013 年 Docker 推出时就取得了成功。从那时起,它们彻底改变了应用开发和管理所使用的标准。

Docker 是一个基于Linux 容器LXC)实现的容器平台,它通过管理容器作为自包含映像,并添加额外的工具来协调其生命周期和保存其状态,扩展了这项技术的功能。

容器化的想法恰恰是允许给定的应用程序在任何类型的系统上执行,因为所有其依赖项已经包含在容器本身中。

这样,应用程序变得高度可移植,并且可以在任何类型的环境上轻松测试和部署,无论是本地还是云端。

现在,让我们看看如何使用 Docker 将 Python 应用程序容器化。

准备工作

Docker 团队的直觉是采用容器的概念并构建一个围绕它的生态系统,简化其使用。这个生态系统包括一系列工具:

安装 Windows 版 Docker

安装非常简单:一旦下载了安装程序(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 层进行通信,正如本教程开头提到的那样。

现在,让我们看看如何容器化(或 dockerize)一个简单的 Python 应用程序。

如何做...

让我们想象我们想要部署以下 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

接下来,您需要打开您的 Web 浏览器,然后在地址栏中输入localhost:5000。如果一切顺利,您应该看到以下网页:

Docker 应用程序

Docker 使用run命令运行dockerize.py容器,结果是一个 Web 应用程序。镜像包含了容器运行所需的指令。

容器和镜像之间的关联可以通过将镜像与类关联,将容器与类实例关联来理解面向对象编程范式。

当我们创建容器实例时,有必要总结发生了什么:

  • 容器的镜像(如果尚未存在)将在本地卸载。

  • 创建一个启动容器的环境。

  • 屏幕上打印出一条消息。

  • 然后放弃先前创建的环境。

所有这些都在几秒钟内以简单、直观和可读的命令完成。

还有更多...

显然,容器和虚拟机似乎是非常相似的概念。但尽管这两种解决方案具有共同的特点,它们是根本不同的技术,就像我们必须开始思考我们的应用程序架构有何不同一样。我们可以在容器中创建我们的单片应用程序,但这样做将无法充分利用容器的优势,因此也无法充分利用 Docker 的优势。

适用于容器基础架构的可能软件架构是经典的微服务架构。其思想是将应用程序分解为许多小组件,每个组件都有自己特定的任务,能够交换消息并相互合作。这些组件的部署将以许多容器的形式单独进行。

使用微服务可以处理的场景在虚拟机中是绝对不切实际的,因为每个新实例化的虚拟机都需要主机机器大量的能源开支。另一方面,容器非常轻便,因为它们执行与虚拟机完全不同的虚拟化:

虚拟机和 Docker 实现中的微服务架构

在虚拟机中,一个称为Hypervisor的工具负责从主机操作系统中静态或动态地保留一定数量的资源,以便专门用于一个或多个称为guestshosts的操作系统。客用操作系统将完全与主机操作系统隔离。这种机制在资源方面非常昂贵,因此将微服务与虚拟机结合的想法是完全不可能的。

另一方面,容器对这个问题提供了完全不同的解决方案。隔离性要弱得多,所有运行的容器共享与底层操作系统相同的内核。Hypervisor 的开销完全消失,一个主机可以承载数百个容器。

当我们要求 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 世界中,术语标签更正确地用于表示图像版本的概念。在前面的代码示例中,图像已被标记为最新版本,并且是 dockerize 存储库唯一可用的标签。

最新标签是默认标签:每当我们引用一个存储库而没有指定标签名称时,Docker 将隐式地引用最新标签,如果不存在,则会显示错误。因此,作为最佳实践,存储库标签形式更可取,因为它允许更大的可预测性,避免容器之间的可能冲突和由于缺少最新标签而导致的错误。

另请参见

容器技术是一个非常广泛的概念,可以通过查阅网上的许多文章和应用示例来探索。然而,在开始这段漫长而艰难的旅程之前,建议从完整且充分信息的网站(www.docker.com/)开始。

在下一节中,我们将研究无服务器计算的主要特点,其主要目标是使软件开发人员更容易地编写设计用于在云平台上运行的代码。

介绍无服务器计算

近年来,出现了一种名为函数即服务FaaS)的新服务模型,也被称为无服务器计算

无服务器计算是一种云计算范式,允许执行应用程序而不必担心与底层基础设施相关的问题。术语无服务器可能会产生误导;事实上,可以认为这种模型不预见使用处理服务器。实际上,它表明应用程序的提供、可伸缩性和管理是自动进行的,对于开发人员来说是完全透明的。这一切都得益于一种称为无服务器的新架构模型。

第一个 FaaS 模型可以追溯到 2014 年发布的 AWS Lambda 服务。随着时间的推移,其他替代方案被添加到亚马逊解决方案中,这些替代方案由其他主要供应商开发,例如微软的 Azure Functions,IBM 和 Google 的 Cloud Functions。还有有效的开源解决方案:其中最常用的是 IBM 在其无服务器提供的 Bluemix 上使用的 Apache OpenWhisk,但也有 OpenLambda 和 IronFunctions,后者基于 Docker 的容器技术。

在这个教程中,我们将看到如何通过 AWS Lambda 实现无服务器 Python 函数。

准备就绪

AWS 是一整套通过共同接口提供和管理的云服务。提供 AWS 网络控制台中的服务的共同接口可在console.aws.amazon.com/上访问。

这种类型的服务是收费的。但是,在第一年,提供了免费套餐。这是一组使用最少资源并且可以免费用于评估服务和应用程序开发的服务。

有关如何在 AWS 创建免费账户的详细信息,请参阅官方亚马逊文档aws.amazon.com

在这些部分,我们将概述在 AWS Lambda 中运行代码的基础知识,而无需预配或管理任何服务器。我们将展示如何使用 AWS Lambda 控制台在 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

  • 描述:在这里,您可以输入函数的简要描述。此框中预填有短语 A starter AWS Lambda Function。

  • 运行时:目前,可以使用 Java,Node.js 和 Python 2.7,3.6 和 3.7 编写 Lambda 函数的代码。对于本教程,请设置 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 语言探索图形处理单元GPU)编程技术。GPU 的不断演进揭示了这些架构如何为执行复杂计算带来巨大好处。

GPU 当然不能取代 CPU。然而,它们是一个结构良好的异构代码,能够利用两种类型处理器的优势,事实上,可以带来相当大的优势。

我们将研究异构编程的主要开发环境,即PyCUDANumba环境,用于CUDAPyOpenCL环境,它们是 Python 版本的OpenCL框架。

在本章中,我们将涵盖以下内容:

  • 理解异构计算

  • 理解 GPU 架构

  • 理解 GPU 编程

  • 处理 PyCUDA

  • 使用 PyCUDA 进行异构编程

  • 使用 PyCUDA 实现内存管理

  • 介绍 PyOpenCL

  • 使用 PyOpenCL 构建应用程序

  • 使用 PyOpenCL 进行逐元素表达

  • 评估 PyOpenCL 应用程序

  • 使用 Numba 进行 GPU 编程

让我们从详细了解异构计算开始。

理解异构计算

多年来,对越来越复杂计算的更好性能的追求导致了在计算机使用方面采用新技术。其中之一称为异构计算,旨在以一种有利于时间计算效率的方式与不同(或异构)处理器合作。

在这种情况下,主程序运行的处理器(通常是 CPU)被称为“主机”,而协处理器(例如 GPU)被称为“设备”。后者通常与主机物理上分离,并管理自己的内存空间,这也与主机的内存分开。

特别是,受到市场需求的影响,GPU 已经演变成高度并行的处理器,将 GPU 从图形渲染设备转变为可并行化和计算密集型的通用计算设备。

事实上,除了在屏幕上渲染图形之外,使用 GPU 进行其他任务被称为异构计算。

最后,良好的 GPU 编程任务是充分利用图形卡提供的高级并行性和数学能力,并尽量减少它所带来的所有缺点,例如主机和设备之间的物理连接延迟。

理解 GPU 架构

GPU 是用于矢量处理图形数据以从多边形基元渲染图像的专用 CPU/核心。良好的 GPU 程序的任务是充分利用图形卡提供的高级并行性和数学能力,并尽量减少它所带来的所有缺点,例如主机和设备之间的物理连接延迟。

GPU 具有高度并行的结构,可以以高效的方式处理大型数据集。这一特性与硬件性能程序的快速改进相结合,引起了科学界对使用 GPU 进行除了渲染图像之外的其他用途的关注。

GPU(参见下图)由称为流多处理器SMs)的多个处理单元组成,代表了并行逻辑的第一级别。事实上,每个 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 对应物不同,它是开放标准,不仅可以与不同制造商的 GPU 一起使用,还可以与不同类型的微处理器一起使用。

然而,OpenCL 是一个更完整和多功能的解决方案,因为它没有 CUDA 的成熟和简单易用。

OpenCL Python 扩展是 PyOpenCL (mathema.tician.de/software/pyopencl/)。

在接下来的章节中,将分析 CUDA 和 OpenCL 编程模型及其 Python 扩展,并附带一些有趣的应用示例。

处理 PyCUDA

PyCUDA 是 Andreas Klöckner 提供的一个绑定库,通过它可以访问 CUDA 的 Python API。其主要特点包括自动清理,与对象的生命周期相关联,从而防止泄漏,对模块和缓冲区的方便抽象,对驱动程序的完全访问以及内置的错误处理。它也非常轻巧。

该项目是根据 MIT 许可证开源的,文档非常清晰,而且在线可以找到许多不同的来源来提供帮助和支持。PyCUDA 的主要目的是让开发人员以最小的抽象从 Python 调用 CUDA,并支持 CUDA 元编程和模板化。

准备就绪

请按照 Andreas Klöckner 主页上的说明(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 编程模型(因此也包括 Python 包装器 PyCUDA)是通过对 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.nbytes参数表示的矩阵a的字节数如下:
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的单个线程块的内核函数(即在x方向上*5*个线程,在y方向上*5*个线程,在z方向上 1 个线程,总共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 的线程由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 Compiler)它将需要创建一个模块,其中包含先前定义的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 Installer 的 Anaconda 2019.07。

  2. 从此链接获取 PyOpenCL 预构建二进制文件,链接为:www.lfd.uci.edu/~gohlke/pythonlibs/。选择正确的 OS 和 CPython 版本组合。在这里,我们使用pyopencl-2019.1+cl12-cp37-cp37m-win_amd64.whl

  3. 使用pip来安装之前的软件包。只需在 Anaconda Prompt 中输入以下内容:

**(base) C:\> pip install <directory>\pyopencl-2019.1+cl12-cp37-cp37m-win_amd64.whl** 

<directory>是 PyOpenCL 软件包所在的文件夹。

此外,以下符号表示我们正在使用 Anaconda Prompt:

**(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_a的第 i 个元素加上vector_b的第 i 个元素的和:

  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 = 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 

工作原理...

如前所述,测试包括在 CPU 上通过test_cpu_vector_sum函数执行计算任务,然后通过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 的缺点

缺点如下:

  • 复杂的设备管理

  • APIs 不够稳定

CUDA 和 PyCUDA 的优点

优点如下:

  • 具有非常高抽象级别的 APIs

  • 许多编程语言的扩展

  • 庞大的文档和非常庞大的社区

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。

要安装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 调试代码工具。这些是winpdb-reborn,涉及使用可视化工具进行调试;pdb,Python 标准库中的调试器;和rpdb,其中r代表远程,意味着它是从远程机器进行代码调试。

使用nose进行应用程序测试

如今,这一活动得到了特定应用程序和调试器的支持,这些调试器通过逐步软件指令向程序员展示执行过程,允许同时查看和分析程序本身的输入和输出。

在本章中,我们将涵盖以下主题:

  • 什么是调试?

  • 什么是软件测试?

  • 使用 Winpdb Reborn 进行调试

  • 实现rpdb进行调试

  • 什么是调试?

  • 处理unittest

  • 这些是用于开发单元测试的框架,其中单元是程序中独立操作的最小组件。

术语调试表示在软件使用后,识别代码部分中发现一个或多个错误(bug)的活动。

关于软件测试,我们将研究以下工具:unittestnose

什么是软件测试?

错误可以在程序的测试阶段定位;即在开发阶段尚未准备好供最终用户使用时,或者在最终用户使用程序时。找到错误后,将进行调试阶段,并确定错误所在的软件部分,有时非常复杂。

在这些工具可用于识别和纠正错误的活动之前(甚至现在,在没有它们的情况下),代码检查的最简单(但也是最不有效的)技术是打印文件或打印程序正在执行的指令。

调试是程序开发中最重要的操作之一。由于正在开发的软件的复杂性,通常非常困难。由于存在引入新错误或行为的风险,这甚至是微妙的,这些错误或行为与尝试纠正的错误不符。

尽管使用调试来完善软件的任务每次都是独一无二的,构成了一个独立的故事,但一些通用原则始终适用。特别是在软件应用程序的上下文中,可以识别以下四个调试阶段,总结如下图所示:

pdb交互

当然,Python 为开发人员提供了许多调试工具(请参阅wiki.python.org/moin/PythonDebuggingTools以获取 Python 调试器列表)。在本章中,我们将考虑 Winpdb Reborn、rpdbpdb

Python 调试和测试

正如本章介绍中所述,软件测试是用于识别正在开发的软件产品中的正确性、完整性和可靠性缺陷的过程。

因此,通过这项活动,我们希望通过搜索缺陷或一系列指令和程序来确保产品的质量,当以特定输入数据和特定操作环境执行时,会产生故障。故障是用户不期望的软件行为;因此,它与规范以及为此类应用程序定义的隐式或显式要求不同。

因此,测试的目的是通过故障检测缺陷,以便在软件产品的正常使用中最小化此类故障发生的概率。测试无法确定产品在所有可能的执行条件下都能正确运行,但可以在特定条件下突出显示缺陷。

事实上,鉴于无法测试所有输入组合以及应用程序可能运行的可能软件和硬件环境,故障的概率无法降低到零,但必须降低到最低以使用户可以接受。

软件测试的一种特定类型是单元测试(我们将在本章中学习),其目的是隔离程序的每个部分,并展示其在实现上的正确性和完整性。它还可以及时发现任何缺陷,以便在集成之前轻松进行纠正。

此外,与在整个应用程序上执行测试相比,单元测试降低了识别和纠正缺陷的成本(时间和资源)。

使用 Winpdb Reborn 进行调试

Winpdb Reborn是最重要和最知名的 Python 调试器之一。该调试器的主要优势在于管理基于线程的代码的调试。

Winpdb Reborn 基于 RPDB2 调试器,而 Winpdb 是 RPDB2 的 GUI 前端(参见:github.com/bluebird75/winpdb/blob/master/rpdb2.py)。

准备工作

安装 Winpdb Reborn(release 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 的简单示例来检查 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 应该会打开:

Windpdb 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键):这允许您在 Step Into 功能被激活的确切点恢复执行。

  • 下一个(F6键):这会一次一行地恢复程序的执行,而不会在调用的任何方法中停止。

  • 运行到行(F8键):这会运行程序直到在指定行停止(等待新命令)。

还有更多...

正如您在 Winpdb Reborn GUI 截图中看到的,GUI 分为五个主要窗口:

  • 命名空间:在这个窗口中,显示了程序定义并在源文件中使用的各种变量和标识符的名称。

  • 线程:显示当前执行线程,并以TID(线程 ID)字段、线程名称和线程状态为特征。

  • 堆栈:这里显示了要分析的程序的执行堆栈。堆栈也被称为后进先出LIFO)数据结构,因为最后插入的元素是第一个被移除的。当程序调用一个函数时,被调用的函数必须知道如何返回调用控制,因此调用函数的返回地址被输入到程序执行堆栈中。程序执行堆栈还包含每次调用函数时使用的本地变量的内存。

  • 控制台:这是一个命令行界面,因此允许用户和 Winpdb Reborn 之间进行文本交互。

  • 源代码:这个窗口显示要调试的源代码。通过沿着代码行滚动,也可以在感兴趣的代码行上按下F9来插入断点。

断点是一个非常基本的调试工具。实际上,它允许您运行程序,但可以在所需的点或在发生某些条件时中断它,以获取有关正在运行的程序的信息

有多种调试策略。在这里,我们列出其中一些:

  • 重现错误:识别导致错误的输入数据。

  • 简化错误:识别导致错误的可能最简单的数据。

  • 分而治之:以步进模式执行主要过程,直到出现异常。导致异常的方法是在能够找到问题之前执行的最后一个方法,因此我们可以通过对该特定调用进行步进调试,然后再次按照方法的指示进行步进。

  • 谨慎进行:在调试过程中,不断比较变量的当前值与您期望的值。

  • 检查所有细节:在调试时不要忽视细节。如果您注意到源代码中有任何差异,最好做个记录。

  • 纠正错误:只有在确信已经理解了问题时才纠正错误。

另请参阅

可以在heather.cs.ucdavis.edu/~matloff/winpdb.html#usewin找到一个很好的 Winpdb Reborn 教程。

与 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 解释器

pdb模块可以通过使用run()命令以交互模式使用:

>>> 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)
(P**db)** 

代码运行在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 行(之前 5 行和之后 5 行):
 (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()

在我们的情况下,我们将set_trace()指令放在debugger实例之后。实际上,我们可以将它放在代码的任何地方;例如,如果条件满足,或者在由异常管理的部分内。

第二步,是打开命令提示符并启动telnet,并设置与示例代码中debugger参数定义中指定的相同端口值:

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模块是标准 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(),如果True曾经是False,则失败。

通过执行上面的例子,您将得到以下结果:

-----------------------------------------------------------
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. cd到新目录。

然后,输入以下命令:

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的有效教程可在nose.readthedocs.io/en/latest/index.html找到。

posted @ 2025-09-19 10:36  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报