精通-Python-并发(全)

精通 Python 并发(全)

原文:zh.annas-archive.org/md5/9D7D3F09D4C6183257545C104A0CAC2A

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

并发性可能非常难以正确实现,但幸运的是,Python 编程语言使得处理并发性变得可行且容易。本书展示了如何使用 Python 编写高性能、健壮、并发的程序,以及其独特的编程形式。

本书适合任何对构建快速、非阻塞和资源节约型系统应用感兴趣的好奇开发人员,本书将涵盖最佳实践和模式,帮助您将并发性整合到您的系统中。此外,本书还将讨论 Python 并发编程中的新兴主题,包括新的 AsyncIO 语法,广泛接受的“锁不锁任何东西”的观点,原子消息队列的使用,并发应用架构和最佳实践。

我们将通过实际和引人入胜的代码示例来解决复杂的并发概念和模型。阅读本书后,您将深入了解 Python 并发生态系统中的主要组件,以及对现实并发问题的不同方法的实际欣赏。

这本书适合谁

如果您是熟悉 Python 并且想学习如何利用单核、多核或分布式并发来构建可扩展的高性能应用程序的开发人员,那么本书适合您。

本书涵盖内容

第一章,并发和并行编程的高级介绍,向您介绍了并发的概念,并演示了并发编程如何显著提高 Python 程序的速度。

第二章,阿姆达尔定律,采用理论方法讨论了并发性在提高应用程序速度方面的局限性。我们将看看并发性真正提供了什么,以及如何最好地整合它。

第三章,在 Python 中使用线程,介绍了线程的正式定义,并涵盖了在 Python 程序中实现线程的不同方法。在本章中,我们还将讨论并发编程中的一个重要元素——同步的概念。

第四章,在线程中使用 with 语句,将上下文管理的概念与 Python 中的线程结合在一起,放在并发编程的整体背景中。我们将介绍上下文管理背后的主要思想,以及它在各种编程实践中的应用,包括线程。

第五章,并发网络请求,涵盖了并发编程的主要应用之一:网络爬虫。它还涵盖了网络爬虫的概念,以及其他相关元素,然后讨论了如何将线程应用于网络爬虫程序以实现显著的加速。

第六章,在 Python 中使用进程,展示了多进程的正式定义以及 Python 如何支持它。我们还将更多地了解线程和多进程之间的关键区别,这两者经常被混淆。

第七章,进程中的约简运算符,将约简运算符的概念与多进程结合起来作为并发编程实践。本章将介绍约简运算符的理论基础,以及它与多进程以及一般编程的相关性。

第八章,《并发图像处理》,涉及并发的一个特定应用:图像处理。除了一些最常见的处理技术之外,还讨论了图像处理背后的基本思想。当然,我们将看到并发,特别是多进程,如何加速图像处理任务。

第九章,《异步编程简介》,将异步编程的正式概念作为三种主要并发编程模型之一,除了线程和多进程。我们将学习异步编程如何从这两种模型根本上不同,但仍然可以加速并发应用程序。

第十章,《在 Python 中实现异步编程》,深入探讨了 Python 提供的 API,以便促进异步编程。具体来说,我们将学习asyncio模块,这是在 Python 中实现异步编程的主要工具,以及异步应用程序的一般结构。

第十一章,《使用 asyncio 构建通信通道》,结合了前几章涵盖的异步编程知识和网络通信主题。具体来说,我们将研究使用aiohttp模块作为工具向 Web 服务器发出异步 HTTP 请求,以及实现异步文件读取/写入的aiofile模块。

第十二章,《死锁》,介绍了并发编程中常见的问题之一。我们将学习关于古典的餐桌哲学家问题,作为死锁如何导致并发程序停止运行的示例。本章还将涵盖一些潜在的死锁方法以及相关概念,如活锁和分布式死锁。

第十三章,《饥饿》,考虑了并发应用中另一个常见问题。本章使用经典的读者-写者问题叙述来解释饥饿的概念及其原因。当然,我们还将通过 Python 的实际示例讨论这些问题的潜在解决方案。

第十四章,《竞争条件》,讨论了可能是最知名的并发问题:竞争条件。我们还将讨论临界区的概念,这是竞争条件特别重要的元素,也是并发编程的一般情况。本章还将涵盖互斥作为这个问题的潜在解决方案。

第十五章,《全局解释器锁》,介绍了臭名昭著的 GIL,被认为是 Python 并发编程中最大的挑战。我们将了解 GIL 实施背后的原因以及它引发的问题。本章最后会对 Python 程序员和开发人员应该如何思考和与 GIL 交互提出一些想法。

第十六章,《设计基于锁和无互斥的并发数据结构》,分析了设计两种常见的涉及锁作为同步机制的并发数据结构的过程:基于锁和无互斥。本章还包括对数据结构实施的高级分析,以及性能分析,以便读者在设计并发应用程序时能够形成批判性的思维。

第十七章,内存模型和原子类型的操作,包括涉及 Python 语言底层结构以及程序员如何在并发应用中利用它的理论主题。本章还向读者介绍了原子操作的概念。

第十八章,从头开始构建服务器,指导读者通过构建低级别的非阻塞服务器的过程。我们将了解 Python 中套接字模块提供的网络编程功能,以及如何使用它们来实现一个功能齐全的服务器。我们还将应用本书早期讨论的异步程序的一般结构,将阻塞服务器转换为非阻塞服务器。

第十九章,测试、调试和调度并发应用,涵盖了并发程序的更高级别用法。本章首先介绍了如何通过 APScheduler 模块将并发应用于 Python 应用程序调度的任务。然后我们将讨论并发在测试和调试 Python 程序的复杂性。

充分利用本书

本书的读者应该知道如何在开发环境中执行 Python 程序,或者直接从命令提示符中执行。他们还应该熟悉 Python 编程中的一般语法和实践(变量、函数、导入包等)。本书在各个部分假定读者具有一些基本的计算机科学知识,如像素、执行堆栈和字节码指令。

第一章的最后一部分,并发和并行编程的高级介绍,涵盖了设置 Python 环境的过程。本书的各章可能会讨论使用外部库或工具,这些库或工具必须通过 pip 和 Anaconda 等软件包管理器安装,其安装说明将包含在相应的章节中。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packt.com/support注册,直接将文件发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册,请访问www.packt.com

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

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,并按照屏幕上的说明操作。

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

  • Windows 系统可使用 WinRAR/7-Zip。

  • Mac 系统可使用 Zipeg/iZip/UnRarX。

  • Linux 系统可使用 7-Zip/PeaZip。

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Concurrency-in-Python。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的书籍和视频目录,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图片

我们还提供了一份 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/9781789343052_ColorImages.pdf

代码实例

访问以下链接查看代码运行的视频:bit.ly/2BsvQj6

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“asyncio模块提供了许多不同的传输类。”

代码块设置如下:

async def main(url):
    async with aiohttp.ClientSession() as session:
        await download_html(session, url)

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

urls = [
    'http://packtpub.com',
    'http://python.org',
    'http://docs.python.org/3/library/asyncio',
    'http://aiohttp.readthedocs.io',
    'http://google.com'
]

任何命令行输入或输出都以以下方式编写:

> python3 example5.py
Took 0.72 seconds.

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中以这种方式出现。例如:“要下载存储库,只需单击窗口右上角的克隆或下载按钮。”

警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。

第一章:并发和并行编程的高级介绍

Python 并发编程大师的第一章将概述并发编程是什么(与顺序编程相对)。我们将简要讨论可以并发进行的程序与不能并发进行的程序之间的区别。我们将回顾并发工程和编程的历史,并提供许多并发编程在当今如何使用的例子。最后,我们将简要介绍本书的方法,包括章节结构的概述和如何下载代码并创建工作的 Python 环境的详细说明。

本章将涵盖以下主题:

  • 并发的概念

  • 为什么有些程序不能并发进行,以及如何区分它们与可以并发进行的程序

  • 计算机科学中的并发历史:它如何在当今的工业中使用,以及未来可以期待什么

  • 书中每个部分/章节将涵盖的具体主题

  • 如何设置 Python 环境,以及如何从 GitHub 检出/下载代码

技术要求

查看以下视频以查看代码的实际操作:bit.ly/2TAMAeR

什么是并发?

据估计,计算机程序需要处理的数据量每两年翻一番。例如,国际数据公司IDC)估计,到 2020 年,地球上每个人将有 5200GB 的数据。随着这一庞大的数据量,对计算能力的需求是无止境的,虽然每天都在开发和利用大量的计算技术,但并发编程仍然是处理数据的一种最显著的有效和准确的方式之一。

当一些人看到并发这个词时可能会感到害怕,但它背后的概念是非常直观的,甚至在非编程的情境中也是非常常见的。然而,这并不是说并发程序像顺序程序一样简单;它们确实更难编写和理解。然而,一旦实现了正确和有效的并发结构,执行时间将显著改善,这一点稍后你会看到。

并发与顺序

也许理解并发编程最明显的方法是将其与顺序编程进行比较。在顺序程序中,一次只能在一个地方,而在并发程序中,不同的组件处于独立或半独立的状态。这意味着处于不同状态的组件可以独立执行,因此可以同时执行(因为一个组件的执行不依赖于另一个的结果)。以下图表说明了这两种类型之间的基本区别:

并发和顺序程序之间的区别

并发的一个直接优势是执行时间的改善。同样,由于一些任务是独立的,因此可以同时完成,计算机执行整个程序所需的时间更少。

示例 1 - 检查非负数是否为质数

让我们考虑一个快速的例子。假设我们有一个简单的函数,检查非负数是否为质数,如下所示:

# Chapter01/example1.py

from math import sqrt

def is_prime(x):
    if x < 2:
    return False

if x == 2:
    return True

if x % 2 == 0:
    return False

limit = int(sqrt(x)) + 1
    for i in range(3, limit, 2):
        if x % i == 0:
            return False

return True

另外,假设我们有一个显著大的整数列表(10¹³10¹³+500),我们想要使用前面的函数检查它们是否是质数:

input = [i for i in range(10 ** 13, 10 ** 13 + 500)]

一个顺序的方法是简单地将一个接一个的数字传递给is_prime()函数,如下所示:

# Chapter01/example1.py

from timeit import default_timer as timer

# sequential
start = timer()
result = []
for i in input:
    if is_prime(i):
        result.append(i)
print('Result 1:', result)
print('Took: %.2f seconds.' % (timer() - start))

复制代码或从 GitHub 存储库下载并运行它(使用python example1.py命令)。你的输出的第一部分将类似于以下内容:

> python example1.py
Result 1: [10000000000037, 10000000000051, 10000000000099, 10000000000129, 10000000000183, 10000000000259, 10000000000267, 10000000000273, 10000000000279, 10000000000283, 10000000000313, 10000000000343, 10000000000391, 10000000000411, 10000000000433, 10000000000453]
Took: 3.41 seconds.

您可以看到程序处理所有数字大约需要3.41秒;我们很快会回到这个数字。现在,对于我们来说,检查计算机在运行程序时的工作情况也是有益的。在操作系统中打开一个 Activity Monitor 应用程序,然后再次运行 Python 脚本;以下截图显示了我的结果:

Activity Monitor 显示计算机性能

显然,计算机并没有工作太辛苦,因为它几乎闲置了 83%。

现在,让我们看看并发是否真的可以帮助我们改进程序。is_prime()函数包含大量的重型计算,因此它是并发编程的一个很好的候选对象。由于将一个数字传递给is_prime()函数的过程与传递另一个数字是独立的,我们可以潜在地将并发应用到我们的程序中,如下所示:

# Chapter01/example1.py

# concurrent
start = timer()
result = []
with concurrent.futures.ProcessPoolExecutor(max_workers=20) as executor:
    futures = [executor.submit(is_prime, i) for i in input]

    for i, future in enumerate(concurrent.futures.as_completed(futures)):
        if future.result():
            result.append(input[i])

print('Result 2:', result)
print('Took: %.2f seconds.' % (timer() - start))

粗略地说,我们将任务分割成不同的、更小的块,并同时运行它们。现在不要担心代码的具体细节,因为我们稍后将更详细地讨论使用进程池的情况。

当我执行该函数时,执行时间明显更好,计算机也更多地利用了它的资源,只有 37%的空闲时间:

> python example1.py
Result 2: [10000000000183, 10000000000037, 10000000000129, 10000000000273, 10000000000259, 10000000000343, 10000000000051, 10000000000267, 10000000000279, 10000000000099, 10000000000283, 10000000000313, 10000000000391, 10000000000433, 10000000000411, 10000000000453]
Took: 2.33 seconds

Activity Monitor 应用程序的输出将类似于以下内容:

Activity Monitor 显示计算机性能

并发与并行

此时,如果您有一些并行编程的经验,您可能会想知道并发是否与并行有所不同。并发和并行编程之间的关键区别在于,虽然在并行程序中有许多处理流(主要是 CPU 和核心)可以独立工作,但在并发程序中,可能有不同的处理流(主要是线程)同时访问和使用共享资源

由于这个共享资源可以被不同的处理流程读取和覆盖,有时需要一定形式的协调,当需要执行的任务并不完全独立时。换句话说,有些任务重要的是在其他任务之后执行,以确保程序会产生正确的结果。

并发与并行的区别

上图说明了并发和并行的区别:在上部分,不相互交互的并行活动(在本例中是汽车)可以同时运行,而在下部分,一些任务必须等待其他任务完成后才能执行。

我们稍后将看更多这些区别的例子。

一个快速的比喻

并发是一个很难立即完全理解的概念,所以让我们考虑一个快速的比喻,以便更容易理解并发及其与并行的区别。

尽管一些神经科学家可能会有不同看法,让我们简要假设人脑的不同部分负责执行独立的身体部位动作和活动。例如,大脑的左半球控制身体的右侧,因此控制右手(反之亦然);或者,大脑的一部分可能负责写作,而另一部分则专门处理说话。

现在,让我们具体考虑第一个例子。如果您想移动您的左手,大脑的右侧(只有右侧)必须处理移动的命令,这意味着左侧的大脑是空闲的,可以处理其他信息。因此,可以同时移动和使用左手和右手,以执行不同的事情。同样,可以同时写作说话。

这就是并行性:不同的进程不相互交互,彼此独立。请记住,并发并不完全像并行。尽管有一些情况下进程是一起执行的,但并发也涉及共享相同的资源。如果并行类似于同时使用左手和右手进行独立任务,那么并发可以与杂耍相关联,两只手同时执行不同的任务,但它们也与同一个对象(在这种情况下是杂耍球)进行交互,并且因此需要两只手之间的某种协调。

不是所有的事情都应该并发进行

并非所有的程序都是平等的:有些可以相对容易地并行或并发执行,而其他一些则是固有的顺序,因此不能并发执行或并行执行。前者的一个极端例子是令人尴尬的并行程序,可以将其分成不同的并行任务,这些任务之间几乎没有依赖性或需要通信。

令人尴尬的并行

一个常见的令人尴尬的并行程序的例子是由图形处理单元处理的 3D 视频渲染,其中每个帧或像素都可以在没有相互依赖的情况下进行处理。密码破解是另一个可以轻松分布在 CPU 核心上的令人尴尬的并行任务。在后面的章节中,我们将解决许多类似的问题,包括图像处理和网络抓取,这些问题可以直观地进行并发/并行处理,从而显著提高执行时间。

固有的顺序

与令人尴尬的并行任务相反,一些任务的执行严重依赖于其他任务的结果。换句话说,这些任务不是独立的,因此不能并行或并发执行。此外,如果我们试图将并发性引入这些程序,可能会花费更多的执行时间来产生相同的结果。让我们回到之前的素数检查示例;以下是我们看到的输出:

> python example1.py
Result 1: [10000000000037, 10000000000051, 10000000000099, 10000000000129, 10000000000183, 10000000000259, 10000000000267, 10000000000273, 10000000000279, 10000000000283, 10000000000313, 10000000000343, 10000000000391, 10000000000411, 10000000000433, 10000000000453]
Took: 3.41 seconds.
Result 2: [10000000000183, 10000000000037, 10000000000129, 10000000000273, 10000000000259, 10000000000343, 10000000000051, 10000000000267, 10000000000279, 10000000000099, 10000000000283, 10000000000313, 10000000000391, 10000000000433, 10000000000411, 10000000000453]
Took: 2.33 seconds.

仔细观察,你会发现两种方法得到的结果并不相同;第二个结果列表中的素数是无序的。(回想一下,在第二种方法中,为了应用并发,我们指定将任务分成不同的组同时执行,我们获得的结果的顺序是每个任务完成执行的顺序。)这是我们第二种方法中使用并发的直接结果:我们将要执行的任务分成不同的组,并且我们的程序同时处理了这些组中的任务。

由于不同组的任务同时执行,存在一些任务在输入列表中落后于其他任务,但在输出列表中却先于其他任务执行。例如,数字 10000000000183 在我们的输入列表中落后于数字 10000000000129,但在输出列表中却在数字 10000000000129 之前被处理。实际上,如果你一遍又一遍地执行程序,第二个结果几乎每次都会有所不同。

显然,如果我们希望获得的结果需要按照我们最初的输入顺序,那么这种情况是不可取的。当然,在这个例子中,我们可以通过使用某种形式的排序来简单修改结果,但最终会花费我们额外的执行时间,这可能使其比原始的顺序方法更昂贵。

用来说明某些任务的固有顺序性的常用概念是怀孕:女性的数量永远不会减少怀孕的时间。与并行或并发任务相反,在固有顺序任务中增加处理实体的数量不会改善执行时间。固有顺序性的著名例子包括迭代算法:牛顿法、三体问题的迭代解、或迭代数值逼近方法。

例 2 - 固有顺序任务

让我们考虑一个快速的例子:

计算f¹⁰⁰⁰(3),其中f(x) = x² - x + 1f^(n + 1)(x) = f(f^n(x))

对于像f这样复杂的函数(其中找到f^n(x)的一般形式相对困难),计算f¹⁰⁰⁰**(3)或类似值的唯一合理的方法是迭代计算f²(3) = f( f(3)), f³(3) = f( f²(3)), ... ,f⁹⁹⁹(3) = f( f⁹⁹⁸(3)), 最后,f¹⁰⁰⁰(3) = f( f⁹⁹⁹(3))

即使使用计算机,实际计算f¹⁰⁰⁰**(3)也需要很长时间,因此我们的代码中只考虑f²⁰(3)(我的笔记本电脑在计算f²⁵(3)后实际上开始发热):

# Chapter01/example2.py

def f(x):
    return x * x - x + 1

# sequential
def f(x):
    return x * x - x + 1

start = timer()
result = 3
for i in range(20):
    result = f(result)

print('Result is very large. Only printing the last 5 digits:', result % 100000)
print('Sequential took: %.2f seconds.' % (timer() - start))

运行它(或使用python example2.py);以下代码显示了我收到的输出:

> python example2.py
Result is very large. Only printing the last 5 digits: 35443
Sequential took: 0.10 seconds.

现在,如果我们尝试将并发应用于此脚本,唯一可能的方法是通过for循环。一个解决方案可能如下:

# Chapter01/example2.py

# concurrent
def concurrent_f(x):
    global result
    result = f(result)

result = 3

with concurrent.futures.ThreadPoolExecutor(max_workers=20) as exector:
    futures = [exector.submit(concurrent_f, i) for i in range(20)]

    _ = concurrent.futures.as_completed(futures)

print('Result is very large. Only printing the last 5 digits:', result % 100000)
print('Concurrent took: %.2f seconds.' % (timer() - start))

我收到的输出如下所示:

> python example2.py
Result is very large. Only printing the last 5 digits: 35443
Concurrent took: 0.19 seconds.

尽管两种方法都产生了相同的结果,但并发方法所花费的时间几乎是顺序方法的两倍。这是因为每次生成新线程(来自ThreadPoolExecutor)时,该线程内的函数conconcurrent_f()都需要等待变量result被前一个线程完全处理,因此整个程序仍然以顺序方式执行。

因此,虽然第二种方法中实际上没有涉及并发,但生成新线程的开销导致了明显更差的执行时间。这是固有的顺序任务的一个例子,其中不应该尝试应用并发或并行来改善执行时间。

I/O 绑定

另一种思考顺序性的方式是计算机科学中称为 I/O 绑定的条件:计算完成所花费的时间主要由等待输入/输出(I/O)操作完成的时间决定。当请求数据的速率慢于消耗数据的速率时,或者简而言之,花费在请求数据上的时间比处理数据的时间更多时,就会出现这种情况。

在 I/O 绑定状态下,CPU 必须暂停其操作,等待数据被处理。这意味着,即使 CPU 在处理数据方面变得更快,由于它们更多地受到 I/O 绑定的影响,进程的速度不会与 CPU 速度的增加成比例地提高。随着更快的计算速度成为新计算机和处理器设计的主要目标,I/O 绑定状态变得不受欢迎,但在程序中变得越来越常见。

正如您所见,有许多情况下,并发编程的应用会导致处理速度下降,因此应该避免。因此,对我们来说,重要的是不将并发视为可以产生无条件更好执行时间的黄金票据,并理解受益于并发和不受益于并发的程序结构之间的差异。

并发的历史、现在和未来

在接下来的子主题中,我们将讨论并发的过去、现在和未来。

自计算机科学的早期以来,并发编程领域就一直备受关注。在本节中,我们将讨论并发编程的起源和发展历程,以及它在工业中的当前使用情况,以及一些关于并发性将来如何使用的预测。

并发性的历史

并发性的概念已经存在了相当长的时间。这个想法起源于 19 世纪和 20 世纪初对铁路和电报的早期工作,并且一些术语甚至一直延续至今(比如信号量,它表示并发程序中控制对共享资源访问的变量)。并发性首先被应用于解决如何处理同一铁路系统上的多列火车,以避免碰撞并最大化效率,以及如何处理早期电报中给定一组电线上的多次传输。

并发编程的理论基础在 20 世纪 60 年代实际上已经奠定了。早期的算法语言 ALGOL 68 于 1959 年首次开发,包括支持并发编程的特性。并发性的学术研究正式始于 1965 年的一篇开创性论文,作者是计算机科学先驱 Edsger Dijkstra,他以其命名的路径查找算法而闻名。

那篇开创性的论文被认为是并发编程领域的第一篇论文,Dijkstra 在其中确定并解决了互斥问题。互斥是并发控制的一个属性,它可以防止竞争条件(我们稍后会讨论),后来成为并发中最受讨论的话题之一。

然而,在那之后并没有太多的兴趣。从 1970 年左右到 2000 年初,处理器据说每 18 个月执行速度翻倍。在这段时间内,程序员不需要关注并发编程,因为他们只需要等待程序运行得更快。然而,在 2000 年初,处理器业务发生了一场范式转变;制造商开始专注于更小、更慢的处理器,这些处理器被组合在一起。这是计算机开始拥有多核处理器的时候。

如今,一台普通的计算机拥有多个核心。因此,如果程序员以任何方式编写所有的程序都不是并发的话,他们会发现他们的程序只利用一个核心或一个线程来处理数据,而 CPU 的其余部分则闲置不做任何事情。这也是最近推动并发编程的一个原因。

并发性日益增长的另一个原因是图形、多媒体和基于网络的应用程序开发领域的不断扩大,其中并发性的应用被广泛用于解决复杂和有意义的问题。例如,并发性在 Web 开发中扮演着重要角色:用户发出的每个新请求通常都作为自己的进程(这称为多进程;参见第六章,在 Python 中处理进程)或与其他请求异步协调(这称为异步编程;参见第九章,异步编程简介);如果其中任何请求需要访问共享资源(例如数据库),并发性应该被考虑进去。

现在

考虑到现在,互联网和数据共享的爆炸性增长每秒都在发生,因此并发性比以往任何时候都更加重要。当前并发编程的使用强调正确性、性能和稳健性。

一些并发系统,如操作系统或数据库管理系统,通常被设计为无限运行,包括从故障中自动恢复,并且不会意外终止。如前所述,并发系统使用共享资源,因此它们在实现中需要某种形式的信号量来控制和协调对这些资源的访问。

并发编程在软件开发领域非常普遍。以下是一些并发存在的示例:

  • 并发在大多数常见的编程语言中都扮演着重要角色:C++、C#、Erlang、Go、Java、Julia、JavaScript、Perl、Python、Ruby、Scala 等等。

  • 再次,由于几乎每台计算机今天都在其 CPU 中有多个核心,桌面应用程序需要能够利用这种计算能力,以提供真正设计良好的软件。

MacBook Pro 电脑使用的多核处理器

  • 2011 年发布的 iPhone 4S 具有双核 CPU,因此移动开发也必须与并发应用程序保持连接。

  • 至于视频游戏,目前市场上最大的两个参与者是多 CPU 系统的 Xbox 360 和本质上是多核系统的索尼 PS3。

  • 即使是当前的 35 美元的树莓派也是基于四核系统构建的。

  • 据估计,谷歌平均每秒处理超过 40,000 个搜索查询,相当于每天超过 35 亿次搜索,全球每年处理 1.2 万亿次搜索。除了拥有处理能力惊人的大型机器外,并发性是处理如此大量数据请求的最佳方式。

如今,大部分数据和应用程序存储在云中。由于云上的计算实例相对较小,几乎每个网络应用都被迫采用并发处理,同时处理不同的小任务。随着获得更多客户并需要处理更多请求,设计良好的网络应用可以简单地利用更多服务器,同时保持相同的逻辑;这对应了我们之前提到的鲁棒性属性。

即使在人工智能和数据科学这些日益流行的领域,也取得了重大进展,部分原因是高端图形卡(GPU)的可用性,它们被用作并行计算引擎。在最大的数据科学网站(www.kaggle.com/)的每一次显著竞赛中,几乎所有获奖解决方案在训练过程中都使用了某种形式的 GPU。由于大数据模型需要处理大量数据,因此并发提供了一种有效的解决方案。一些人工智能算法甚至被设计成将输入数据分解成较小的部分并独立处理,这是应用并发以实现更好的模型训练时间的绝佳机会。

未来

在当今这个时代,无论用户使用什么应用程序,计算机/互联网用户都期望即时输出,开发人员经常发现自己在努力解决为其应用程序提供更快速度的问题。在使用方面,并发性将继续成为编程领域的主要参与者之一,为这些问题提供独特和创新的解决方案。如前所述,无论是视频游戏设计、移动应用、桌面软件还是 Web 开发,未来并发性都将无处不在。

鉴于应用程序对并发支持的需求,有人可能会认为并发编程在学术界也将变得更加标准。尽管计算机科学课程中涵盖了并发和并行主题,但深入的、复杂的并发编程课题(理论和应用课题)将被纳入本科和研究生课程中,以更好地为学生们未来在行业中的工作做准备,因为并发在日常中被广泛使用。计算机科学课程将涉及构建并发系统、研究数据流以及分析并发和并行结构,这只是一个开始。

其他人可能对并发编程的未来持更为怀疑的观点。有人说,并发实际上是关于依赖分析的:这是编译器理论的一个子领域,分析语句/指令之间的执行顺序约束,并确定程序是否安全地重新排序或并行化其语句。此外,由于真正理解并发及其复杂性的程序员数量很少,将会有一种推动力,即编译器以及操作系统的支持,来承担实际将并发实现到它们自己编译的程序中的责任。

具体来说,未来程序员将不必关心并发编程的概念和问题,也不应该。在编译器级别实现的算法应该查看正在编译的程序,分析语句和指令,生成依赖图以确定这些语句和指令的最佳执行顺序,并在适当和有效的地方应用并发/并行。简而言之,程序员对并发系统的理解和有效工作的数量较少,以及自动化设计并发的可能性,将导致对并发编程的兴趣减少。

最终,只有时间才能告诉我们并发编程的未来会是什么样子。我们程序员只能看看并发目前在现实世界中是如何被使用的,并确定是否值得学习:正如我们在这个案例中所看到的那样。此外,尽管设计并发程序与依赖分析之间存在着紧密的联系,但我个人认为并发编程是一个更为复杂和深入的过程,可能很难通过自动化实现。

并发编程确实非常复杂,很难做到完美,但这也意味着通过这个过程获得的知识将对任何程序员都是有益的,我认为这已经足够好的理由来学习并发。分析程序加速的问题、将程序重构为不同的独立任务,并协调这些任务使用相同的资源,是程序员在处理并发时所建立的主要技能,对这些主题的了解也将帮助他们解决其他编程问题。

Python 并发编程的简要概述

Python 是最受欢迎的编程语言之一,而且理由充分。该语言配备了许多库和框架,可以促进高性能计算,无论是软件开发、网站开发、数据分析还是机器学习。然而,开发人员之间一直在讨论 Python 的问题,其中经常涉及全局解释器锁(GIL)以及实现并发和并行程序所带来的困难。

尽管在 Python 中,并发和并行的行为与其他常见的编程语言有所不同,但程序员仍然可以实现并发或并行运行的 Python 程序,并为其程序实现显著的加速。

《Python 并发编程大师》将作为 Python 中并发工程和编程中各种高级概念的全面介绍。本书还将详细介绍并发和并行在现实应用中的使用情况。它是理论分析和实际示例的完美结合,将使您充分了解 Python 中并发编程的理论和技术。

本书将分为六个主要部分。它将从并发和并发编程背后的理念开始——历史,它如何在当今的工业中使用,最后,对并发可能提供的加速的数学分析。此外,本章的最后一节(也是我们的下一节)将介绍如何按照本书中的编码示例,包括在自己的计算机上设置 Python 环境,从 GitHub 下载/克隆本书中包含的代码,并在计算机上运行每个示例的说明。

接下来的三节将分别涵盖并发编程中的三种主要实现方法:线程、进程和异步 I/O。这些部分将包括每种方法的理论概念和原则,Python 语言提供的语法和各种功能来支持它们,以及它们高级用法的最佳实践讨论,并且直接应用这些概念来解决现实问题的实践项目。

第五节将向读者介绍工程师和程序员在并发编程中面临的一些常见问题:死锁、饥饿和竞争条件。读者将了解每个问题的理论基础和原因,在 Python 中分析和复制每个问题,并最终实现潜在的解决方案。本节的最后一章将讨论前面提到的 GIL,这是 Python 语言特有的。它将涵盖 GIL 在 Python 生态系统中的重要作用,GIL 对并发编程提出的一些挑战,以及如何实现有效的解决方法。

在书的最后一节中,我们将致力于并发 Python 编程的各种高级应用。这些应用将包括无锁和有锁并发数据结构的设计,内存模型和原子类型的操作,以及如何从头开始构建支持并发请求处理的服务器。本节还将涵盖在测试、调试和调度并发 Python 应用程序时的最佳实践。

在整本书中,您将通过讨论、示例代码和实践项目来建立处理并发程序的基本技能。您将了解并发编程中最重要的概念的基础知识,如何在 Python 程序中实现它们,以及如何将这些知识应用于高级应用。通过《Python 并发编程大师》,您将具备关于并发的广泛理论知识和在 Python 语言中并发应用的实际知识的独特组合。

为什么选择 Python?

正如之前提到的,开发者在使用 Python 编程语言(特别是 CPython——用 C 编写的 Python 的参考实现)进行并发编程时面临的困难之一是其 GIL。GIL 是一个互斥锁,用于保护对 Python 对象的访问,防止多个线程同时执行 Python 字节码。这个锁主要是因为 CPython 的内存管理不是线程安全的。CPython 使用引用计数来实现其内存管理。这导致多个线程可以同时访问和执行 Python 代码;这种情况是不希望发生的,因为它可能导致数据处理不正确,我们称这种内存管理方式不是线程安全的。为了解决这个问题,GIL 是一个锁,如其名,只允许一个线程访问 Python 代码和对象。然而,这也意味着,要在 CPython 中实现多线程程序,开发者需要意识到 GIL 并绕过它。这就是为什么许多人在 Python 中实现并发系统时会遇到问题。

那么,为什么要在 Python 中使用并发?尽管 GIL 在某些情况下阻止多线程的 CPython 程序充分利用多处理器系统,但大多数阻塞或长时间运行的操作,如 I/O、图像处理和 NumPy 数值计算,都发生在 GIL 之外。因此,GIL 只对在 GIL 内花费大量时间的多线程程序造成潜在瓶颈。正如您将在未来的章节中看到的,多线程只是一种并发编程形式,而且,虽然 GIL 对允许多个线程访问共享资源的多线程 CPython 程序提出了一些挑战,但其他形式的并发编程并没有这个问题。例如,不共享任何公共资源的多进程应用程序,如 I/O、图像处理或 NumPy 数值计算,可以与 GIL 无缝配合。我们将在第十五章中更深入地讨论 GIL 及其在 Python 生态系统中的位置,全局解释锁

除此之外,Python 在编程社区中的受欢迎程度不断增加。由于其用户友好的语法和整体可读性,越来越多的人发现在开发中使用 Python 相对来说相对简单,无论是初学者学习新的编程语言,中级用户寻找 Python 的高级功能,还是经验丰富的程序员使用 Python 解决复杂问题。据估计,Python 代码的开发速度可能比 C/C++代码快 10 倍。

使用 Python 的开发者数量的增加导致了一个强大且不断增长的支持社区。Python 中的库和包每天都在不同的问题和技术上进行开发和发布。目前,Python 语言支持非常广泛的编程范围,包括软件开发、桌面 GUI、视频游戏设计、Web 和互联网开发,以及科学和数值计算。近年来,Python 还作为数据科学、大数据和机器学习领域的顶尖工具之一不断增长,与该领域的长期参与者 R 竞争。

Python 开发工具的数量之多鼓励了更多的开发者开始使用 Python 进行编程,使 Python 变得更加流行和易于使用;我称之为Python 的恶性循环。DataCamp 的首席数据科学家大卫·罗宾逊在博客中写道(stackoverflow.blog/2017/09/06/incredible-growth-python/),Python 的增长令人难以置信,并称其为最受欢迎的编程语言。

然而,Python 很慢,或者至少比其他流行的编程语言慢。这是因为 Python 是一种动态类型的解释语言,其中值不是存储在密集的缓冲区中,而是存储在分散的对象中。这直接是 Python 易读性和用户友好性的结果。幸运的是,有各种选项可以让您的 Python 程序运行得更快,而并发是其中最复杂的之一;这就是我们将在本书中掌握的内容。

设置您的 Python 环境

在我们进一步进行之前,让我们了解一些关于如何设置本书中将要使用的必要工具的规范。特别是,我们将讨论如何为您的系统获取 Python 发行版以及适当的开发环境的过程,以及如何下载本书各章中包含的示例中使用的代码。

一般设置

让我们看看如何为您的系统获取 Python 发行版以及适当的开发环境的过程:

  • 任何开发人员都可以从www.python.org/downloads/获取他们自己的 Python 发行版。

  • 尽管 Python 2 和 Python 3 都得到支持和维护,但在本书中,我们将使用 Python 3。

  • 对于本书来说,选择一个集成开发环境IDE)是灵活的。虽然从技术上讲,可以使用最小的文本编辑器(如记事本或 TextEdit)开发 Python 应用程序,但使用专门为 Python 设计的 IDE 通常更容易阅读和编写代码。这些包括 IDLE(docs.python.org/3/library/idle.html)、PyCharm(www.jetbrains.com/pycharm/)、Sublime Text(www.sublimetext.com/)和 Atom(atom.io/)。

下载示例代码

要获取本书中使用的代码,您可以从 GitHub 下载存储库,其中包括本书中涵盖的所有示例和项目代码:

单击“下载 ZIP”以下载存储库

  • 解压下载的文件以创建我们正在寻找的文件夹。文件夹的名称应为Mastering-Concurrency-in-Python

文件夹内有各自命名为ChapterXX的文件夹,表示该文件夹中代码所涵盖的章节。例如,Chapter03文件夹包含了第三章中涵盖的示例和项目代码,在 Python 中使用线程。在每个子文件夹中,有各种 Python 脚本;当您在本书中阅读每个代码示例时,您将知道在每个章节的特定时点运行哪个脚本。

总结

您现在已经了解了并发和并行编程的概念。它涉及设计和构造编程命令和指令,以便程序的不同部分可以以有效的顺序执行,同时共享相同的资源。由于当一些命令和指令同时执行时可以节省时间,因此与传统的顺序编程相比,并发编程在程序执行时间上提供了显着的改进。

然而,在设计并发程序时需要考虑各种因素。虽然有些特定任务可以很容易地分解成可以并行执行的独立部分(尴尬并行任务),但其他任务需要不同形式的协调,以便正确高效地使用共享资源。还有固有的顺序任务,无法应用并发和并行来实现程序加速。您应该了解这些任务之间的基本区别,以便适当地设计并发程序。

最近,出现了一种范式转变,促进了并发在编程世界的大多数方面的实现。现在,几乎可以在任何地方找到并发:桌面和移动应用程序,视频游戏,Web 和互联网开发,人工智能等等。并发仍在增长,并且预计将来会继续增长。因此,任何有经验的程序员都必须了解并发及其相关概念,并知道如何将这些概念集成到他们的应用程序中,这是至关重要的。

另一方面,Python 是最受欢迎的编程语言之一(如果不是最受欢迎的)。它在大多数编程子领域提供了强大的选项。因此,并发和 Python 的结合是编程中最值得学习和掌握的主题之一。

在下一章中,我们将讨论 Amdahl 定律,以了解并发为我们的程序提供的加速改进有多重要。我们将分析 Amdahl 定律的公式,讨论其含义,并考虑 Python 示例。

问题

  • 并发的概念是什么,为什么它有用?

  • 并发编程和顺序编程之间有什么区别?

  • 并发编程和并行编程之间有什么区别?

  • 每个程序都可以做成并发或并行吗?

  • 什么是尴尬并行任务?

  • 什么是固有的顺序任务?

  • I/O 绑定是什么意思?

  • 当前在现实世界中如何使用并发处理?

进一步阅读

更多信息请参考以下链接:

  • Python 并行编程食谱,作者:Giancarlo Zaccone,Packt Publishing Ltd,2015

  • 学习 Python 并发:构建高效、健壮和并发的应用(2017),作者:Forbes, Elliot

  • 《并发工程基础的历史根源》IEEE 工程管理交易 44.1(1997):67-78,作者:Robert P. Smith

  • 编程语言实用性,Morgan Kaufmann,2000,作者:Michael Lee Scott

第二章:阿姆达尔定律

阿姆达尔定律经常用于围绕并发程序的讨论,它解释了在使用并发时可以预期的程序执行的理论加速。在本章中,我们将讨论阿姆达尔定律的概念,并分析其估计程序潜在加速的公式,并在 Python 代码中复制它。本章还将简要介绍阿姆达尔定律与边际收益递减定律之间的关系。

本章将涵盖以下主题:

  • 阿姆达尔定律

  • 阿姆达尔定律:其公式和解释

  • 阿姆达尔定律与边际收益递减定律之间的关系

  • Python 中的模拟和阿姆达尔定律的实际应用

技术要求

以下是本章的先决条件列表:

阿姆达尔定律

如何在并行化顺序程序(通过增加处理器数量)和优化顺序程序本身的执行速度之间找到平衡?例如,哪个选项更好:有四个处理器运行给定程序的 40%的执行时间,还是只使用两个处理器执行相同的程序,但时间加倍?这种权衡在并发编程中经常出现,可以通过应用阿姆达尔定律进行战略分析和回答。

此外,虽然并发和并行可以是提供程序执行时间显著改进的强大工具,但它们并不是可以无限制地加速任何非顺序架构的银弹。因此,开发人员和程序员了解并理解并发和并行提供给他们的程序速度改进的限制是非常重要的,而阿姆达尔定律正是解决了这些问题。

术语

阿姆达尔定律提供了一个数学公式,计算通过增加资源(特别是可用处理器的数量)来提高并发程序速度的潜在改进。在我们深入阿姆达尔定律的理论之前,首先我们必须澄清一些术语,如下所示:

  • 阿姆达尔定律仅讨论由并行执行任务产生的潜在延迟加速。虽然这里并没有直接讨论并发性,但阿姆达尔定律关于并行性的结果将为我们提供有关并发程序的估计。

  • 程序的速度表示程序执行所需的时间。这可以用任何时间单位来衡量。

  • 加速是衡量并行执行计算的好处的时间。它定义为程序在串行执行(使用一个处理器)所需的时间除以并行执行(使用多个处理器)所需的时间。加速的公式如下:

在上述公式中,T(j)是使用j个处理器执行程序所需的时间。

公式和解释

在我们深入探讨阿姆达尔定律及其含义的公式之前,让我们通过一些简要分析来探讨加速的概念。假设有N个工人在完成一个完全可并行化的工作,也就是说,这项工作可以完全分成N个相等的部分。这意味着N个工人一起完成工作所需的时间只有一个工人完成相同工作所需时间的1/N

然而,大多数计算机程序并非 100%可并行化:程序的某些部分可能是固有的顺序,而其他部分则被分解为并行任务。

阿姆达尔定律的公式

现在,让 B 表示严格串行的程序部分的分数,并考虑以下内容:

  • B * T(1) 是执行程序中固有顺序部分所需的时间。

  • T(1) - B * T(1) = (1 - B) * T(1) 是使用一个处理器执行程序的可并行化部分所需的时间:

  • 然后,(1 - B) * T(1) / N 是使用 N 个处理器执行这些部分所需的时间

  • 因此,B * T(1) + (1 - B) * T(1) / N 是使用 N 个处理器执行整个程序所需的总时间。

回到加速度数量的公式,我们有以下内容:

这个公式实际上是阿姆达尔定律的一种形式,用于估计并行程序的加速。

一个快速的例子

假设我们有一个计算机程序,并且以下内容适用于它:

  • 其中 40%可以并行处理,所以 B = 1 - 40% = 0.6

  • 它的可并行化部分将由四个处理器处理,所以 j = 4

阿姆达尔定律规定应用改进的整体加速度将如下所示:

影响

以下是 1967 年 Gene Amdahl 的一句引用:

“十多年来,预言家们一直声称单台计算机的组织已经达到了极限,真正显著的进步只能通过多台计算机的互连来实现,以便允许合作解决方案... 这种开销(在并行性中)似乎是顺序的,因此不太可能适用于并行处理技术。即使在一个单独的处理器中进行了管理,开销本身也会将吞吐量的上限放置在顺序处理速率的五到七倍,即使在任何时间点上,也很难预见如何有效地克服顺序计算机中的以前瓶颈。”

通过这句引用,阿姆达尔指出,无论在程序中实现了什么并发和并行技术,程序中所需的顺序性开销总是设定了程序将获得多少加速度的上限。这是阿姆达尔定律进一步暗示的其中一个影响。考虑以下例子:

表示从 n 个处理器中获得的加速。

这表明,随着资源数量(特别是可用处理器的数量)的增加,整个任务执行的加速度也会增加。然而,这并不意味着我们应该总是使用尽可能多的系统处理器来实现并发和并行,以实现最高的性能。实际上,从公式中,我们还可以得出增加处理器数量所实现的加速度会减少。换句话说,随着我们为并发程序添加更多处理器,我们将获得越来越少的执行时间改进。

此外,正如之前提到的,阿姆达尔定律暗示的另一个影响涉及执行时间改进的上限:

是并发和并行可以为您的程序提供的改进的上限。这就是说,无论您的系统有多少可用资源,通过并发都不可能获得大于  的加速度,这个上限由程序的顺序开销部分所决定(B 是严格串行的程序部分的分数)。

阿姆达尔定律与边际收益递减定律的关系

阿姆达尔定律经常与边际收益递减定律混淆,后者是经济学中一个相当流行的概念。然而,边际收益递减定律只是应用阿姆达尔定律的特例,取决于改进的顺序。如果选择以最佳方式改进程序中的单独任务顺序,将观察到执行时间的单调递减改进,表明边际收益递减。最佳方法指的是首先应用那些将导致最大加速的改进,然后将那些产生较小加速的改进留到后面。

现在,如果我们选择资源的顺序进行反转,即在改进更不理想的程序组件之前改进更理想的组件,通过改进获得的加速将在整个过程中增加。此外,实际上,对我们来说更有利的是按照这种反向最佳顺序实施系统改进,因为更理想的组件通常更复杂,需要更多时间来改进。

阿姆达尔定律和边际收益递减定律之间的另一个相似之处涉及通过向系统添加更多处理器获得的加速改进。具体来说,当向系统添加新处理器以处理固定大小的任务时,它提供的可用计算能力将少于上一个处理器。正如我们在上一节中讨论的,这种情况下的改进严格减少,随着处理器数量的增加,总吞吐量接近1/B的上限。

重要的是要注意,此分析未考虑其他潜在的瓶颈,如内存带宽和 I/O 带宽。实际上,如果这些资源不随处理器数量增加而增加,那么简单地添加处理器将导致更低的回报。

如何在 Python 中模拟

在本节中,我们将通过一个 Python 程序来查看阿姆达尔定律的结果。仍然考虑到确定整数是否为素数的任务,如第一章中所讨论的,并发和并行编程的高级介绍,我们将看到通过并发实际实现了什么样的加速。如果您已经从 GitHub 页面下载了书籍的代码,我们将查看Chapter02/example1.py文件。

作为复习,检查素数的函数如下:

# Chapter02/example1.py

from math import sqrt

def is_prime(x):
    if x < 2:
        return False

    if x == 2:
        return x

    if x % 2 == 0:
        return False

    limit = int(sqrt(x)) + 1
    for i in range(3, limit, 2):
        if x % i == 0:
            return False

    return x

代码的下一部分是一个函数,它接受一个整数,表示我们将利用多少个处理器(工作者)并发解决问题(在本例中,用于确定列表中的哪些数字是素数):

# Chapter02/example1.py

import concurrent.futures

from timeit import default_timer as timer

def concurrent_solve(n_workers):
    print('Number of workers: %i.' % n_workers)

    start = timer()
    result = []

    with concurrent.futures.ProcessPoolExecutor(
      max_workers=n_workers) as executor:

        futures = [executor.submit(is_prime, i) for i in input]
        completed_futures = concurrent.futures.as_completed(futures)

        sub_start = timer()

        for i, future in enumerate(completed_futures):
            if future.result():
                result.append(future.result())

        sub_duration = timer() - sub_start

    duration = timer() - start
    print('Sub took: %.4f seconds.' % sub_duration)
    print('Took: %.4f seconds.' % duration)

请注意,变量sub_startsub_duration测量正在同时解决的任务部分,在我们之前的分析中,它表示为1 - B。至于输入,我们将查看介于10¹³10¹³ + 1000之间的数字:

input = [i for i in range(10 ** 13, 10 ** 13 + 1000)]

最后,我们将循环从 1 到系统中可用的最大处理器数量,并将该数字传递给前面的concurrent_solve()函数。作为一个快速提示,要从计算机中获取可用处理器的数量,请调用multiprocessing.cpu_count(),如下所示:

for n_workers in range(1, multiprocessing.cpu_count() + 1):
    concurrent_solve(n_workers)
    print('_' * 20)

您可以通过输入命令python example1.py来运行整个程序。由于我的笔记本电脑有四个核心,运行程序后的输出如下:

Number of workers: 1.
Sub took: 7.5721 seconds.
Took: 7.6659 seconds.
____________________
Number of workers: 2.
Sub took: 4.0410 seconds.
Took: 4.1153 seconds.
____________________
Number of workers: 3.
Sub took: 3.8949 seconds.
Took: 4.0063 seconds.
____________________
Number of workers: 4.
Sub took: 3.9285 seconds.
Took: 4.0545 seconds.
____________________

以下是需要注意的几点:

  • 首先,在每次迭代中,任务的子部分几乎和整个程序一样长。换句话说,在每次迭代期间,并发计算形成了程序的大部分。这是可以理解的,因为除了素数检查之外,程序中几乎没有其他繁重的计算。

  • 其次,更有趣的是,我们可以看到,尽管在从12个处理器增加数量后获得了显著的改进(从7.6659 秒4.1153 秒),但在第三次迭代期间几乎没有实现加速。第四次迭代期间花费的时间比第三次还要长,但这很可能是开销处理。这与我们早期讨论有关,即在考虑处理器数量时,阿姆达尔定律和收益递减法则之间的相似性是一致的。

  • 我们还可以参考加速曲线来可视化这一现象。加速曲线只是一个图表,其中x轴显示处理器数量,y轴显示实现的加速度。在一个完美的场景中,其中S = j(即,实现的加速度等于使用的处理器数量),加速曲线将是一条直线,45 度线。阿姆达尔定律表明,任何程序产生的加速曲线将保持在该线下,并且随着效率的降低而开始变平。在前面的程序中,这是在从两个处理器到三个处理器的过渡期间:

不同并行部分的加速曲线

阿姆达尔定律的实际应用

正如我们所讨论的,通过分析给定程序或系统的顺序和可并行部分,我们可以使用阿姆达尔定律来确定或至少估计并行计算带来的潜在速度改进的上限。在获得这一估计后,我们可以做出明智的决定,判断提高执行时间是否值得增加处理能力。

从我们的例子中,我们可以看到,当你有一个既顺序执行又并行执行指令的并发程序时,阿姆达尔定律是适用的。通过使用阿姆达尔定律进行分析,我们可以确定每次增加可用核心来执行程序时的加速度,以及这种增加对帮助程序实现并行化的最佳加速度有多接近。

现在,让我们回到本章开头提出的初始问题:增加处理器数量与增加并行性能的时间之间的权衡。假设你负责开发一个并发程序,目前有 40%的指令可以并行执行。这意味着多个处理器可以同时运行 40%的程序执行。现在,你的任务是通过实施以下两种选择之一来提高该程序的速度:

  • 实施四个处理器来执行程序指令

  • 实施两个处理器,另外增加程序的可并行部分到 80%

我们如何分析比较这两种选择,以确定哪一种对我们的程序来说会产生最佳速度?幸运的是,阿姆达尔定律可以在这个过程中帮助我们:

  • 对于第一种选择,可以获得的加速比如下:

  • 对于第二种选择,速度提升如下:

正如你所看到的,第二个选择(比第一个选择的处理器少)实际上是加速我们特定程序的更好选择。这是阿姆达尔定律的另一个例子,说明有时简单地增加可用处理器的数量实际上是不可取的,从而改善程序的速度。类似的权衡,可能具有不同的规格,也可以通过这种方式进行分析。

最后需要注意的是,尽管阿姆达尔定律以一种明确的方式提供了潜在加速的估计,但定律本身做出了许多潜在的假设,并没有考虑一些可能重要的因素,比如并行性的开销或内存速度。因此,阿姆达尔定律的公式简化了在实践中可能常见的各种考虑因素。

那么,并发程序的程序员应该如何思考和使用阿姆达尔定律?我们应该记住,阿姆达尔定律的结果只是提供给我们一个关于在哪里以及以多大程度上,我们可以通过增加可用处理器的数量来进一步优化并发系统的想法。最终,只有实际的测量才能准确回答我们关于我们的并发程序在实践中能够实现多少加速的问题。话虽如此,阿姆达尔定律仍然可以帮助我们有效地确定使用并发和并行性来改进计算速度的良好理论策略。

摘要

阿姆达尔定律为我们提供了一种估计任务执行时间潜在加速的方法,当系统资源得到改善时,我们可以期待系统的速度提升。它说明,随着系统资源的改善,执行时间也会相应提高。然而,增加资源时的差异加速严格减少,吞吐量加速受程序的顺序开销限制。

您还看到,在特定情况下(即,只增加处理器数量时),阿姆达尔定律类似于边际收益递减定律。具体来说,随着处理器数量的增加,通过改进获得的效率减少,速度提升曲线变得平缓。

最后,本章表明通过并发和并行性的改进并不总是可取的,需要详细的规格说明才能实现有效和高效的并发程序。

有了对并发可以帮助我们加速程序的了解,我们现在将开始讨论 Python 提供的实现并发的具体工具。具体来说,我们将在下一章中考虑并发编程的主要参与者之一,即线程,以及它们在 Python 编程中的应用。

问题

  • 阿姆达尔定律是什么?阿姆达尔定律试图解决什么问题?

  • 解释阿姆达尔定律的公式及其组成部分。

  • 根据阿姆达尔定律,随着系统资源的改善,速度提升会无限增加吗?

  • 阿姆达尔定律与边际收益递减定律之间的关系是什么?

进一步阅读

欲了解更多信息,请参考以下链接:

  • 《阿姆达尔定律》(home.wlu.edu/~whaleyt/classes/parallel/topics/amdahl.html),作者:Aaron Michalove

  • 《阿姆达尔定律的用途和滥用》,《计算机科学学院杂志》17.2(2001):288-293,作者:S. Krishnaprasad

  • 《学习 Python 并发:构建高效、健壮和并发的应用》(2017),作者:Elliot Forbes

第三章:在 Python 中处理线程

在第一章中,并发和并行编程的高级介绍,您看到了线程在并发和并行编程中的使用示例。在本章中,您将了解线程的正式定义,以及 Python 中的threading模块。我们将涵盖在 Python 程序中处理线程的多种方法,包括创建新线程、同步线程以及通过具体示例处理多线程优先队列等活动。我们还将讨论线程同步中锁的概念,并实现基于锁的多线程应用程序,以更好地理解线程同步的好处。

本章将涵盖以下主题:

  • 计算机科学中并发编程上下文中的线程概念

  • Python 中threading模块的基本 API

  • 如何通过threading模块创建新线程

  • 锁的概念以及如何使用不同的锁定机制来同步线程

  • 并发编程上下文中队列的概念,以及如何使用Queue模块在 Python 中处理队列对象

技术要求

以下是本章的先决条件列表:

线程的概念

在计算机科学领域,执行线程是调度程序(通常作为操作系统的一部分)可以处理和管理的编程命令(代码)的最小单位。根据操作系统的不同,线程和进程的实现(我们将在以后的章节中介绍)有所不同,但线程通常是进程的一个元素(组件)。

线程与进程的区别

在同一进程中可以实现多个线程,通常并发执行并访问/共享相同的资源,如内存;而单独的进程不会这样做。同一进程中的线程共享后者的指令(其代码)和上下文(其变量在任何给定时刻引用的值)。

这两个概念之间的关键区别在于,线程通常是进程的组成部分。因此,一个进程可以包括多个线程,这些线程可以同时执行。线程通常也允许共享资源,如内存和数据,而进程很少这样做。简而言之,线程是计算的独立组件,类似于进程,但进程中的线程可以共享该进程的地址空间,因此也可以共享该进程的数据:

在一个处理器上运行的两个执行线程的进程

据报道,线程最早用于 OS/360 多道程序设计中的可变数量任务,这是 IBM 于 1967 年开发的一种已停用的批处理系统。当时,开发人员将线程称为任务,而后来线程这个术语变得流行,并且被归因于数学家和计算机科学家维克托·A·维索茨基,他是 Digital 的剑桥研究实验室的创始主任。

多线程

在计算机科学中,单线程类似于传统的顺序处理,一次执行一个命令。另一方面,多线程实现了多个线程同时存在和执行单个进程。通过允许多个线程访问共享资源/上下文并独立执行,这种编程技术可以帮助应用程序在执行独立任务时提高速度。

多线程主要可以通过两种方式实现。在单处理器系统中,多线程通常是通过时间片分配实现的,这是一种允许 CPU 在不同线程上切换的技术。在时间片分配中,CPU 执行得非常快速和频繁,以至于用户通常会感知到软件在并行运行(例如,在单处理器计算机上同时打开两个不同的软件)。

时间片分配技术的一个例子称为轮转调度

与单处理器系统相反,具有多个处理器或核心的系统可以通过在单独的进程或核心中执行每个线程来轻松实现多线程,同时进行。此外,时间片分配也是一种选择,因为这些多处理器或多核系统可以只有一个处理器/核心在任务之间切换,尽管这通常不是一个好的做法。

与传统的顺序应用程序相比,多线程应用程序具有许多优点;以下是其中一些:

  • 更快的执行时间:通过多线程并发的主要优势之一是实现的加速。同一程序中的单独线程可以并发或并行执行,如果它们彼此足够独立。

  • 响应性:单线程程序一次只能处理一个输入;因此,如果主执行线程在长时间运行的任务上阻塞(即需要大量计算和处理的输入),整个程序将无法继续处理其他输入,因此看起来会被冻结。通过使用单独的线程来执行计算并保持运行以同时接收不同的用户输入,多线程程序可以提供更好的响应性。

  • 资源消耗效率:正如我们之前提到的,同一进程中的多个线程可以共享和访问相同的资源。因此,多线程程序可以使用比使用单线程或多进程程序时少得多的资源,同时为数据处理和服务许多客户请求。这也导致了线程之间更快的通信。

话虽如此,多线程程序也有其缺点,如下所示:

  • 崩溃:即使一个进程可以包含多个线程,一个线程中的单个非法操作也可能对该进程中的所有其他线程的处理产生负面影响,并导致整个程序崩溃。

  • 同步:尽管共享相同的资源可以优于传统的顺序编程或多处理程序,但对共享资源也需要仔细考虑。通常,线程必须以一种深思熟虑和系统化的方式协调,以便正确计算和操作共享数据。由于不慎的线程协调可能导致的难以理解的问题包括死锁、活锁和竞争条件,这些问题将在未来的章节中讨论。

Python 中的一个例子

为了说明在同一进程中运行多个线程的概念,让我们来看一个在 Python 中的快速示例。如果您已经从 GitHub 页面下载了本书的代码,请转到Chapter03文件夹。让我们看一下Chapter03/my_thread.py文件,如下所示:

# Chapter03/my_thread.py

import threading
import time

class MyThread(threading.Thread):
    def __init__(self, name, delay):
        threading.Thread.__init__(self)
        self.name = name
        self.delay = delay

    def run(self):
        print('Starting thread %s.' % self.name)
        thread_count_down(self.name, self.delay)
        print('Finished thread %s.' % self.name)

def thread_count_down(name, delay):
    counter = 5

    while counter:
        time.sleep(delay)
        print('Thread %s counting down: %i...' % (name, counter))
        counter -= 1

在这个文件中,我们使用 Python 的threading模块作为MyThread类的基础。这个类的每个对象都有一个namedelay参数。run()函数在初始化和启动新线程时被调用,打印出一个开始消息,然后调用thread_count_down()函数。这个函数从数字5倒数到数字0,在每次迭代之间休眠指定秒数,由延迟参数指定。

这个例子的重点是展示在同一个程序(或进程)中运行多个线程的并发性质,通过同时启动MyThread类的多个对象。我们知道,一旦启动每个线程,该线程的基于时间的倒计时也将开始。在传统的顺序程序中,单独的倒计时将按顺序分别执行(即,新的倒计时不会在当前倒计时完成之前开始)。正如您将看到的那样,单独的线程倒计时是同时执行的。

让我们看一下Chapter3/example1.py文件,如下所示:

# Chapter03/example1.py

from my_thread import MyThread

thread1 = MyThread('A', 0.5)
thread2 = MyThread('B', 0.5)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print('Finished.')

在这里,我们同时初始化和启动了两个线程,每个线程的delay参数都是0.5秒。使用您的 Python 解释器运行脚本。您应该会得到以下输出:

> python example1.py
Starting thread A.
Starting thread B.
Thread A counting down: 5...
Thread B counting down: 5...
Thread B counting down: 4...
Thread A counting down: 4...
Thread B counting down: 3...
Thread A counting down: 3...
Thread B counting down: 2...
Thread A counting down: 2...
Thread B counting down: 1...
Thread A counting down: 1...
Finished thread B.
Finished thread A.
Finished.

正如我们所预期的那样,输出告诉我们,线程的两个倒计时是同时执行的;程序不是先完成第一个线程的倒计时,然后再开始第二个线程的倒计时,而是几乎同时运行了两个倒计时。在不包括一些额外开销和其他声明的情况下,这种线程技术使得前面的程序速度几乎提高了一倍。

在前面的输出中还有一件事情需要注意。在数字5的第一个倒计时之后,我们可以看到线程 B 的倒计时实际上在执行中超过了线程 A,尽管我们知道线程 A 在线程 B 之前初始化和启动。这种变化实际上允许线程 B 在线程 A 之前完成。这种现象是通过多线程并发产生的直接结果;由于两个线程几乎同时初始化和启动,很可能一个线程在执行中超过另一个线程。

如果您多次执行此脚本,很可能会得到不同的输出,无论是执行顺序还是倒计时的完成。以下是我多次执行脚本后获得的两个输出。第一个输出显示了一致且不变的执行顺序和完成顺序,两个倒计时一起执行。第二个输出显示了一种情况,线程 A 的执行速度明显快于线程 B;甚至在线程 B 计数到数字1之前就已经完成了。这种输出的变化进一步说明了这些线程是由 Python 平等对待和执行的事实。

以下代码显示了程序的一个可能输出:

> python example1.py
Starting thread A.
Starting thread B.
Thread A counting down: 5...
Thread B counting down: 5...
Thread A counting down: 4...
Thread B counting down: 4...
Thread A counting down: 3...
Thread B counting down: 3...
Thread A counting down: 2...
Thread B counting down: 2...
Thread A counting down: 1...
Thread B counting down: 1...
Finished thread A.
Finished thread B.
Finished.

以下是另一个可能的输出:

> python example1.py
Starting thread A.
Starting thread B.
Thread A counting down: 5...
Thread B counting down: 5...
Thread A counting down: 4...
Thread B counting down: 4...
Thread A counting down: 3...
Thread B counting down: 3...
Thread A counting down: 2...
Thread B counting down: 2...
Thread A counting down: 1...
Finished thread A.
Thread B counting down: 1...
Finished thread B.
Finished.

线程模块概述

在 Python 中实现多线程程序时有很多选择。在 Python 中处理线程的最常见方式之一是通过threading模块。在深入探讨模块的用法和语法之前,让我们先探索一下thread模型,这在 Python 中曾经是主要的基于线程的开发模块。

Python 2 中的线程模块

threading模块变得流行之前,主要基于线程的开发模块是thread。如果您使用的是较旧版本的 Python 2,可以直接使用该模块。然而,根据模块文档页面,thread模块实际上在 Python 3 中被重命名为_thread

对于那些一直在使用thread模块构建多线程应用程序并希望将其代码从 Python 2 迁移到 Python 3 的读者来说,2to3 工具可能是一个解决方案。2to3 工具处理了大部分 Python 不同版本之间可检测到的不兼容性,同时解析源代码并遍历源树将 Python 2.x 代码转换为 Python 3.x 代码。另一个实现转换的技巧是在 Python 程序中将导入代码从import thread改为import _thread as thread

thread模块的主要特点是快速有效地创建新线程以执行函数:thread.start_new_thread()函数。除此之外,该模块还支持一些低级的处理多线程原语和共享全局数据空间的方式。此外,还提供了简单的锁对象(例如互斥锁和信号量)用于同步目的。

Python 3 中的线程模块

很长一段时间以来,旧的thread模块一直被 Python 开发人员认为是过时的,主要是因为它的功能相对较低级,使用范围有限。另一方面,threading模块是建立在thread模块之上的,通过强大的高级 API 提供了更容易处理线程的方式。Python 用户实际上被鼓励在他们的程序中使用新的threading模块而不是thread模块。

此外,thread模块将每个线程视为一个函数;当调用thread.start_new_thread()时,它实际上接受一个单独的函数作为其主要参数,以产生一个新的线程。然而,threading模块被设计为对面向对象软件开发范式的用户友好,将创建的每个线程视为一个对象。

除了thread模块提供的所有处理线程功能之外,threading模块还支持一些额外的方法,如下所示:

  • threading.activeCount(): 此函数返回程序中当前活动线程对象的数量

  • threading.currentThread(): 此函数从调用者返回当前线程控制中的线程对象数

  • threading.enumerate(): 此函数返回程序中当前活动线程对象的列表

遵循面向对象的软件开发范式,threading模块还提供了一个支持线程面向对象实现的Thread类。该类支持以下方法:

  • run(): 当初始化并启动新线程时执行此方法

  • start(): 这个方法通过调用run()方法来启动初始化的调用线程对象

  • join(): 这个方法在继续执行程序的其余部分之前等待调用线程对象终止

  • isAlive(): 这个方法返回一个布尔值,指示调用线程对象当前是否正在执行

  • getName(): 这个方法返回调用线程对象的名称

  • setName(): 这个方法设置调用线程对象的名称

在 Python 中创建一个新线程

在本节中,我们已经提供了threading模块及其与旧的thread模块的区别的概述,现在我们将通过在 Python 中使用这些工具来创建新线程的一些示例来探讨。正如之前提到的,threading模块很可能是在 Python 中处理线程的最常见方式。特定情况下需要使用thread模块,也许还需要其他工具,因此我们有必要能够区分这些情况。

使用线程模块启动线程

thread模块中,新线程被创建以并发执行函数。正如我们所提到的,通过使用thread.start_new_thread()函数来实现这一点:

thread.start_new_thread(function, args[, kwargs])

当调用此函数时,将生成一个新线程来执行参数指定的函数,并且当函数完成执行时,线程的标识符将被返回。function参数是要执行的函数的名称,args参数列表(必须是列表或元组)包括要传递给指定函数的参数。另一方面,可选的kwargs参数包括一个额外的关键字参数的字典。当thread.start_new_thread()函数返回时,线程也会悄悄地终止。

让我们看一个在 Python 程序中使用thread模块的例子。如果您已经从 GitHub 页面下载了本书的代码,请转到Chapter03文件夹和Chapter03/example2.py文件。在这个例子中,我们将看一下is_prime()函数,这个函数我们在之前的章节中也使用过:

# Chapter03/example2.py

from math import sqrt

def is_prime(x):
    if x < 2:
        print('%i is not a prime number.' % x)

    elif x == 2:
        print('%i is a prime number.' % x)

    elif x % 2 == 0:
        print('%i is not a prime number.' % x)

    else:
        limit = int(sqrt(x)) + 1
        for i in range(3, limit, 2):
            if x % i == 0:
                print('%i is not a prime number.' % x)

        print('%i is a prime number.' % x)

您可能已经注意到,is_prime(x)函数返回其计算结果的方式有很大的不同;它不是返回truefalse来指示x参数是否是一个质数,而是直接打印出该结果。正如您之前看到的,thread.start_new_thread()函数通过生成一个新线程来执行参数函数,但它实际上返回线程的标识符。在is_prime()函数内部打印结果是通过thread模块访问该函数的结果的一种解决方法。

在我们程序的主要部分,我们将循环遍历潜在的质数候选列表,并对该列表中的每个数字调用thread.start_new_thread()函数和is_prime()函数,如下所示:

# Chapter03/example2.py

import _thread as thread

my_input = [2, 193, 323, 1327, 433785907]

for x in my_input:
    thread.start_new_thread(is_prime, (x, ))

您会注意到,在Chapter03/example2.py文件中,有一行代码在最后接受用户的输入:

a = input('Type something to quit: \n')

现在,让我们注释掉最后一行。然后,当我们执行整个 Python 程序时,可以观察到程序在没有打印任何输出的情况下终止;换句话说,程序在线程执行完毕之前终止。这是因为,当通过thread.start_new_thread()函数生成一个新线程来处理我们输入列表中的一个数字时,程序会继续循环遍历下一个输入数字,而新创建的线程在执行。

因此,当 Python 解释器到达程序末尾时,如果有任何线程尚未执行完毕(在我们的情况下,是所有线程),那么该线程将被忽略和终止,并且不会打印任何输出。然而,偶尔会有一个输出是2 是一个质数。,它将在程序终止之前被打印出来,因为处理数字2的线程能够在那一点之前执行完毕。

代码的最后一行是thread模块的另一个解决方法,这次是为了解决前面的问题。这行代码阻止程序退出,直到用户在键盘上按下任意键,此时程序将退出。策略是等待程序执行完所有线程(也就是处理我们输入列表中的所有数字)。取消最后一行的注释并执行文件,您的输出应该类似于以下内容:

> python example2.py
Type something to quit: 
2 is a prime number.
193 is a prime number.
1327 is a prime number.
323 is not a prime number.
433785907 is a prime number.

正如你所看到的,“键入一些内容以退出:”这一行对应于我们程序中的最后一行代码,在is_prime()函数的输出之前被打印出来;这与该行在其他线程完成执行之前被执行的事实一致,大多数情况下是这样。我之所以说大多数情况是因为,当处理第一个输入(数字2)的线程在 Python 解释器到达最后一行之前执行完毕时,程序的输出将类似于以下内容:

> python example2.py
2 is a prime number.
Type something to quit: 
193 is a prime number.
323 is not a prime number.
1327 is a prime number.
433785907 is a prime number.

使用线程模块启动线程

您现在知道如何使用thread模块启动线程,以及它在线程使用方面的有限和低级的使用,以及在处理它时需要相当不直观的解决方法。在本小节中,我们将探讨首选的threading模块及其相对于thread模块在 Python 中实现多线程程序方面的优势。

使用threading模块创建和自定义一个新的线程,需要遵循特定的步骤:

  1. 在程序中定义threading.Thread类的子类

  2. 在子类中覆盖默认的__init__(self [,args])方法,以添加类的自定义参数

  3. 在子类中覆盖默认的run(self [,args])方法,以自定义线程类在初始化和启动新线程时的行为

实际上,在本章的第一个示例中,您已经看到了这个例子。作为一个复习,以下是我们必须使用的内容来自定义threading.Thread子类,以执行一个五步倒计时,每一步之间都有一个可定制的延迟:

# Chapter03/my_thread.py

import threading
import time

class MyThread(threading.Thread):
    def __init__(self, name, delay):
        threading.Thread.__init__(self)
        self.name = name
        self.delay = delay

    def run(self):
        print('Starting thread %s.' % self.name)
        thread_count_down(self.name, self.delay)
        print('Finished thread %s.' % self.name)

def thread_count_down(name, delay):
    counter = 5

    while counter:
        time.sleep(delay)
        print('Thread %s counting down: %i...' % (name, counter))
        counter -= 1

在我们的下一个示例中,我们将看看如何确定一个特定的数字是否是素数。这一次,我们将通过threading模块实现一个多线程的 Python 程序。转到Chapter03文件夹和example3.py文件。让我们首先关注MyThread类,如下所示:

# Chapter03/example3.py

import threading

class MyThread(threading.Thread):
    def __init__(self, x):
        threading.Thread.__init__(self)
        self.x = x

    def run(self):
        print('Starting processing %i...' % x)
        is_prime(self.x)

MyThread类的每个实例都将有一个名为x的参数,指定要处理的素数候选数。正如您所看到的,当类的一个实例被初始化并启动(也就是在run(self)函数中),is_prime()函数,这是我们在前面的示例中使用的相同的素数检查函数,对x参数进行检查,然后run()函数也打印出一条消息来指定处理的开始。

在我们的主程序中,我们仍然有相同的素数检查输入列表。我们将遍历该列表中的每个数字,生成并运行一个新的MyThread类的实例,并将该MyThread实例附加到一个单独的列表中。这个创建的线程列表是必要的,因为在那之后,我们将不得不对所有这些线程调用join()方法,以确保所有线程都已成功执行:

my_input = [2, 193, 323, 1327, 433785907]

threads = []

for x in my_input:
    temp_thread = MyThread(x)
    temp_thread.start()

    threads.append(temp_thread)

for thread in threads:
    thread.join()

print('Finished.')

请注意,与我们使用thread模块时不同的是,这一次,我们不必发明一种解决方法来确保所有线程都已成功执行。同样,这是由threading模块提供的join()方法完成的。这只是使用threading模块更强大、更高级 API 的许多优势之一,而不是使用thread模块。

同步线程

正如您在前面的示例中看到的,threading模块在功能和高级 API 调用方面比其前身thread模块有许多优势。尽管一些人建议有经验的 Python 开发人员应该知道如何使用这两个模块来实现多线程应用程序,但您在 Python 中处理线程时很可能会使用threading模块。在本节中,我们将看看如何在线程同步中使用threading模块。

线程同步的概念

在我们跳入实际的 Python 示例之前,让我们探讨计算机科学中的同步概念。正如您在前几章中看到的,有时,让程序的所有部分并行执行是不可取的。事实上,在大多数当代并发程序中,代码有顺序部分和并发部分;此外,即使在并发部分内部,也需要一些形式的协调来处理不同的线程/进程。

线程/进程同步是计算机科学中的一个概念,它指定了各种机制,以确保不超过一个并发线程/进程可以同时处理和执行特定程序部分;这部分被称为临界区,当我们考虑并发编程中的常见问题时,我们将在第十二章 饥饿和第十三章 竞争条件中进一步讨论它。

在给定的程序中,当一个线程正在访问/执行程序的临界部分时,其他线程必须等待,直到该线程执行完毕。线程同步的典型目标是避免多个线程访问其共享资源时可能出现的数据不一致;只允许一个线程一次执行程序的临界部分,可以确保多线程应用中不会发生数据冲突。

线程锁类

应用线程同步最常见的方法之一是通过实现锁定机制。在我们的threading模块中,threading.Lock类提供了一种简单直观的方法来创建和使用锁。它的主要用法包括以下方法:

  • threading.Lock(): 此方法初始化并返回一个新的锁对象。

  • acquire(blocking): 调用此方法时,所有线程将同步运行(即,一次只有一个线程可以执行临界部分):

  • 可选参数blocking允许我们指定当前线程是否应等待获取锁

  • blocking = 0时,当前线程不会等待锁,如果线程无法获取锁,则返回0,否则返回1

  • blocking = 1时,当前线程将阻塞并等待锁被释放,然后获取它

  • release(): 调用此方法时,锁将被释放。

Python 中的一个例子

让我们考虑一个具体的例子。在这个例子中,我们将查看Chapter03/example4.py文件。我们将回到从五数到一的线程示例,这是我们在本章开头看到的;如果您不记得问题,请回顾一下。在这个例子中,我们将调整MyThread类,如下所示:

# Chapter03/example4.py

import threading
import time

class MyThread(threading.Thread):
    def __init__(self, name, delay):
        threading.Thread.__init__(self)
        self.name = name
        self.delay = delay

    def run(self):
        print('Starting thread %s.' % self.name)
        thread_lock.acquire()
        thread_count_down(self.name, self.delay)
        thread_lock.release()
        print('Finished thread %s.' % self.name)

def thread_count_down(name, delay):
    counter = 5

    while counter:
        time.sleep(delay)
        print('Thread %s counting down: %i...' % (name, counter))
        counter -= 1

与本章的第一个例子相反,在这个例子中,MyThread类在其run()函数内部使用了一个锁对象(变量名为thread_lock)。具体来说,在调用thread_count_down()函数之前(即倒计时开始时)获取锁对象,并在结束后释放锁对象。理论上,这个规定将改变我们在第一个例子中看到的线程行为;程序现在将分别执行线程,倒计时将依次进行。

最后,我们将初始化thread_lock变量,并运行两个MyThread类的单独实例:

thread_lock = threading.Lock()

thread1 = MyThread('A', 0.5)
thread2 = MyThread('B', 0.5)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print('Finished.')

输出将如下:

> python example4.py
Starting thread A.
Starting thread B.
Thread A counting down: 5...
Thread A counting down: 4...
Thread A counting down: 3...
Thread A counting down: 2...
Thread A counting down: 1...
Finished thread A.
Thread B counting down: 5...
Thread B counting down: 4...
Thread B counting down: 3...
Thread B counting down: 2...
Thread B counting down: 1...
Finished thread B.
Finished.

多线程优先级队列

在非并发和并发编程中广泛使用的计算机科学概念是排队。队列是一种抽象数据结构,它是按特定顺序维护的不同元素的集合;这些元素可以是程序中的其他对象。

现实生活和程序排队之间的联系

队列是一个直观的概念,可以很容易地与我们的日常生活联系起来,比如当您在机场排队登机时。在实际的人群中,您会看到以下情况:

  • 人们通常从一端进入队列,从另一端离开

  • 如果 A 在 B 之前进入队列,A 也将在 B 之前离开队列(除非 B 具有更高的优先级)

  • 一旦每个人都登上飞机,排队就没有人了。换句话说,队列将为空

在计算机科学中,队列的工作方式非常相似。

  • 元素可以被添加到队列的末尾;这个任务被称为入队

  • 元素也可以从队列的开头移除;这个任务被称为出队

  • 先进先出FIFO)队列中,首先添加的元素将首先被移除(因此称为 FIFO)。这与计算机科学中的另一个常见数据结构相反,后添加的元素将首先被移除。这被称为后进先出LIFO)。

  • 如果队列中的所有元素都被移除,队列将为空,将无法再从队列中移除更多元素。同样,如果队列达到了它可以容纳的元素的最大容量,就无法再向队列中添加任何其他元素:

队列数据结构的可视化

队列模块

Python 中的queue模块提供了队列数据结构的简单实现。queue.Queue类中的每个队列可以容纳特定数量的元素,并且可以具有以下方法作为其高级 API:

  • get(): 这个方法返回调用queue对象的下一个元素并将其从queue对象中移除

  • put(): 这个方法向调用queue对象添加一个新元素

  • qsize(): 这个方法返回调用queue对象中当前元素的数量(即其大小)

  • empty(): 这个方法返回一个布尔值,指示调用queue对象是否为空

  • full(): 这个方法返回一个布尔值,指示调用queue对象是否已满

并发编程中的排队

队列的概念在并发编程的子领域中更加普遍,特别是当我们需要在程序中实现固定数量的线程来与不同数量的共享资源交互时。

在前面的例子中,我们已经学会了将特定任务分配给一个新线程。这意味着需要处理的任务数量将决定我们的程序应该产生的线程数量。(例如,在我们的Chapter03/example3.py文件中,我们有五个数字作为输入,因此我们创建了五个线程,每个线程都处理一个输入数字。)

有时候我们不希望有和任务数量一样多的线程。比如我们有大量任务需要处理,那么产生同样数量的线程并且每个线程只执行一个任务将会非常低效。有一个固定数量的线程(通常称为线程池)以合作的方式处理任务可能更有益。

这就是队列概念的应用。我们可以设计一个结构,线程池不会保存任何关于它们应该执行的任务的信息,而是任务存储在队列中(也就是任务队列),队列中的项目将被提供给线程池的各个成员。当线程池的成员完成了给定的任务,如果任务队列仍然包含要处理的元素,那么队列中的下一个元素将被发送给刚刚变得可用的线程。

这个图表进一步说明了这个设置:

线程排队

让我们以 Python 中的一个快速示例来说明这一点。转到Chapter03/example5.py文件。在这个例子中,我们将考虑打印给定正整数列表中元素的所有正因子的问题。我们仍然在看之前的MyThread类,但做了一些调整:

# Chapter03/example5.py
import queue
import threading
import time

class MyThread(threading.Thread):
    def __init__(self, name):
        threading.Thread.__init__(self)
        self.name = name

    def run(self):
        print('Starting thread %s.' % self.name)
        process_queue()
        print('Exiting thread %s.' % self.name)

def process_queue():
    while True:
        try:
            x = my_queue.get(block=False)
        except queue.Empty:
            return
        else:
            print_factors(x)

        time.sleep(1)

def print_factors(x):
    result_string = 'Positive factors of %i are: ' % x
    for i in range(1, x + 1):
        if x % i == 0:
            result_string += str(i) + ' '
    result_string += '\n' + '_' * 20

    print(result_string)

# setting up variables
input_ = [1, 10, 4, 3]

# filling the queue
my_queue = queue.Queue()
for x in input_:
    my_queue.put(x)

# initializing and starting 3 threads
thread1 = MyThread('A')
thread2 = MyThread('B')
thread3 = MyThread('C')

thread1.start()
thread2.start()
thread3.start()

# joining all 3 threads
thread1.join()
thread2.join()
thread3.join()

print('Done.')

有很多事情要做,所以让我们把程序分解成更小的部分。首先,让我们看看我们的关键函数,如下所示:

# Chapter03/example5.py

def print_factors(x):
    result_string = 'Positive factors of %i are: ' % x
    for i in range(1, x + 1):
        if x % i == 0:
            result_string += str(i) + ' '
    result_string += '\n' + '_' * 20

    print(result_string)

此函数接受一个参数x,然后迭代所有介于1x之间的正数,以检查一个数字是否是x的因子。最后,它打印出一个格式化的消息,其中包含它通过循环累积的所有信息。

在我们的新MyThread类中,当初始化并启动一个新实例时,process_queue()函数将被调用。此函数首先尝试以非阻塞方式通过调用get(block=False)方法获取my_queue变量中持有的队列对象的下一个元素。如果发生queue.Empty异常(表示队列当前没有值),则我们将结束执行该函数。否则,我们只需将刚刚获取的元素传递给print_factors()函数。

# Chapter03/example5.py

def process_queue():
    while True:
        try:
            x = my_queue.get(block=False)
        except queue.Empty:
            return
        else:
            print_factors(x)

        time.sleep(1)

my_queue变量在我们的主函数中被定义为queue模块中的Queue对象,其中包含input_列表中的元素:

# setting up variables
input_ = [1, 10, 4, 3]

# filling the queue
my_queue = queue.Queue(4)
for x in input_:
    my_queue.put(x)

对于主程序的其余部分,我们只需启动并运行三个单独的线程,直到它们都完成各自的执行。在这里,我们选择创建三个线程来模拟我们之前讨论的设计——一个固定数量的线程处理一个输入队列,其元素数量可以独立变化。

# initializing and starting 3 threads
thread1 = MyThread('A')
thread2 = MyThread('B')
thread3 = MyThread('C')

thread1.start()
thread2.start()
thread3.start()

# joining all 3 threads
thread1.join()
thread2.join()
thread3.join()

print('Done.')

运行程序,你会看到以下输出:

> python example5.py
Starting thread A.
Starting thread B.
Starting thread C.
Positive factors of 1 are: 1 
____________________
Positive factors of 10 are: 1 2 5 10 
____________________
Positive factors of 4 are: 1 2 4 
____________________
Positive factors of 3 are: 1 3 
____________________
Exiting thread C.
Exiting thread A.
Exiting thread B.
Done.

在这个例子中,我们实现了之前讨论过的结构:一个任务队列,其中包含所有要执行的任务,以及一个线程池(线程 A、B 和 C),它们与队列交互以逐个处理其元素。

多线程优先队列

队列中的元素按照它们被添加到队列的顺序进行处理;换句话说,第一个被添加的元素最先离开队列(先进先出)。尽管这种抽象数据结构在许多情况下模拟现实生活,但根据应用程序及其目的,有时我们需要动态地重新定义/更改元素的顺序。这就是优先队列的概念派上用场的地方。

优先队列抽象数据结构类似于队列(甚至前面提到的栈)数据结构,但是优先队列中的每个元素都有与之关联的优先级;换句话说,当一个元素被添加到优先队列时,需要指定其优先级。与常规队列不同,优先队列的出队原则依赖于元素的优先级:具有较高优先级的元素在具有较低优先级的元素之前被处理。

优先队列的概念在各种不同的应用中被使用,包括带宽管理、Dijkstra 算法、最佳优先搜索算法等。每个应用通常使用一个明确定义的评分系统/函数来确定其元素的优先级。例如,在带宽管理中,优先处理实时流等优先流量,以保证最小的延迟和最小的被拒绝的可能性。在用于在图中找到两个给定节点之间的最短路径的最佳搜索算法中,实现了一个优先队列来跟踪未探索的路径;估计路径长度较短的路径在队列中具有更高的优先级。

摘要

执行线程是编程命令的最小单位。在计算机科学中,多线程应用程序允许多个线程同时存在于同一进程中,以实现并发性和并行性。多线程提供了各种优势,包括执行时间、响应性和资源消耗的效率。

Python 3 中的threading模块通常被认为优于旧的thread模块,它提供了一个高效、强大和高级的 API,用于在 Python 中实现多线程应用程序,包括动态生成新线程和通过不同的锁定机制同步线程的选项。

排队和优先排队是计算机科学领域中重要的数据结构,在并发和并行编程中是必不可少的概念。它们允许多线程应用程序以有效的方式执行和完成其线程,确保共享资源以特定和动态的顺序进行处理。

在下一章中,我们将讨论 Python 的更高级功能with语句,以及它如何在 Python 中的多线程编程中起到补充作用。

问题

  • 什么是线程?线程和进程之间的核心区别是什么?

  • Python 中的thread模块提供了哪些 API 选项?

  • Python 中的threading模块提供了哪些 API 选项?

  • 通过threadthreading模块创建新线程的过程是什么?

  • 使用锁进行线程同步背后的思想是什么?

  • 在 Python 中使用锁实现线程同步的过程是什么?

  • 队列数据结构背后的思想是什么?

  • 在并发编程中排队的主要应用是什么?

  • 常规队列和优先队列之间的核心区别是什么?

进一步阅读

有关更多信息,您可以参考以下链接:

  • Python 并行编程食谱,Giancarlo Zaccone,Packt Publishing Ltd,2015

  • "学习 Python 并发:构建高效、稳健和并发的应用程序",Elliot Forbes(2017)

  • 嵌入式系统的实时概念,Qing Li 和 Caroline Yao,CRC 出版社,2003

第四章:在线程中使用 with 语句

with语句在 Python 中有时会让新手和有经验的 Python 程序员感到困惑。本章深入解释了with语句作为上下文管理器的概念,以及它在并发和并行编程中的使用,特别是在同步线程时使用锁。本章还提供了with语句最常见用法的具体示例。

本章将涵盖以下主题:

  • 上下文管理的概念以及with语句作为上下文管理器在并发和并行编程中提供的选项

  • with语句的语法以及如何有效和高效地使用它

  • 在并发编程中使用with语句的不同方式

技术要求

以下是本章的先决条件清单:

上下文管理

新的with语句首次在 Python 2.5 中引入,并且已经使用了相当长的时间。然而,即使对于有经验的 Python 程序员,对其使用仍然存在困惑。with语句最常用作上下文管理器,以正确管理资源,在并发和并行编程中是至关重要的,其中资源在并发或并行应用程序中跨不同实体共享。

从管理文件开始

作为一个有经验的 Python 用户,你可能已经看到with语句被用来在 Python 程序中打开和读取外部文件。从更低的层次来看,Python 中打开外部文件的操作会消耗资源——在这种情况下是文件描述符——你的操作系统会对这种资源设置一个限制。这意味着在你的系统上运行的单个进程同时打开的文件数量是有上限的。

让我们考虑一个快速的例子来进一步说明这一点。让我们看一下Chapter04/example1.py文件,如下所示的代码:

# Chapter04/example1.py

n_files = 10
files = []

for i in range(n_files):
    files.append(open('output1/sample%i.txt' % i, 'w'))

这个快速程序简单地在output1文件夹中创建了 10 个文本文件:sample0.txtsample1.txt,...,sample9.txt。对我们来说可能更感兴趣的是这些文件是在for循环中打开的,但没有关闭——这是编程中的一个不良实践,我们稍后会讨论。现在,假设我们想将n_files变量重新分配给一个大数——比如 10000,如下所示的代码:

# Chapter4/example1.py

n_files = 10000
files = []

# method 1
for i in range(n_files):
    files.append(open('output1/sample%i.txt' % i, 'w'))

我们会得到类似以下的错误:

> python example1.py
Traceback (most recent call last):
 File "example1.py", line 7, in <module>
OSError: [Errno 24] Too many open files: 'output1/sample253.txt'

仔细看错误消息,我们可以看到我的笔记本电脑只能同时处理 253 个打开的文件(顺便说一句,如果你在类 UNIX 系统上工作,运行ulimit -n会给你系统可以处理的文件数量)。更一般地说,这种情况是由所谓的文件描述符泄漏引起的。当 Python 在程序中打开一个文件时,该打开的文件实质上由一个整数表示。这个整数充当程序可以使用的参考点,以便访问该文件,同时不完全控制底层文件本身。

通过同时打开太多文件,我们的程序分配了太多文件描述符来管理打开的文件,因此出现了错误消息。文件描述符泄漏可能导致许多困难的问题——特别是在并发和并行编程中——即未经授权的对打开文件的 I/O 操作。解决这个问题的方法就是以协调的方式关闭打开的文件。让我们看看第二种方法中的Chapter04/example1.py文件。在for循环中,我们会这样做:

# Chapter04/example1.py

n_files = 1000
files = []

# method 2
for i in range(n_files):
    f = open('output1/sample%i.txt' % i, 'w')
    files.append(f)
    f.close()

作为上下文管理器的 with 语句

在实际应用中,很容易通过忘记关闭它们来管理程序中打开的文件;有时也可能无法确定程序是否已经完成处理文件,因此我们程序员将无法决定何时适当地关闭文件。这种情况在并发和并行编程中更为常见,其中不同元素之间的执行顺序经常发生变化。

这个问题的一个可能解决方案,在其他编程语言中也很常见,就是每次想要与外部文件交互时都使用try...except...finally块。这种解决方案仍然需要相同级别的管理和显著的开销,并且在程序的易用性和可读性方面也没有得到很好的改进。这就是 Python 的with语句派上用场的时候。

with语句为我们提供了一种简单的方法,确保所有打开的文件在程序使用完毕时得到适当的管理和清理。使用with语句最显著的优势在于,即使代码成功执行或返回错误,with语句始终通过上下文处理和管理打开的文件。例如,让我们更详细地看看我们的Chapter04/example1.py文件:

# Chapter04/example1.py

n_files = 254
files = []

# method 3
for i in range(n_files):
    with open('output1/sample%i.txt' % i, 'w') as f:
        files.append(f)

虽然这种方法完成了我们之前看到的第二种方法相同的工作,但它另外提供了一种更清晰和更易读的方式来管理我们的程序与之交互的打开文件。更具体地说,with语句帮助我们指示特定变量的范围——在这种情况下,指向打开文件的变量——因此也指明了它们的上下文。

例如,在前面代码的第三种方法中,f变量在with块的每次迭代中指示当前打开的文件,并且一旦我们的程序退出了with块(超出了f变量的范围),就再也无法访问它。这种架构保证了与文件描述符相关的所有清理都会适当地进行。因此with语句被称为上下文管理器。

with 语句的语法

with语句的语法可以直观和简单。为了使用上下文管理器定义的方法包装一个块的执行,它由以下简单形式组成:

with [expression] (as [target]):
    [code]

请注意,with语句中的as [target]部分实际上是不需要的,我们稍后会看到。此外,with语句也可以处理同一行上的多个项目。具体来说,创建的上下文管理器被视为多个with语句嵌套在一起。例如,看看以下代码:

with [expression1] as [target1], [expression2] as [target2]:
    [code]

这被解释为:

with [expression1] as [target1]:
    with [expression2] as [target2]:
        [code]

并发编程中的 with 语句

显然,打开和关闭外部文件并不太像并发。然而,我们之前提到with语句作为上下文管理器,不仅用于管理文件描述符,而且通常用于管理大多数资源。如果你在阅读第二章时发现管理threading.Lock()类的锁对象与管理外部文件类似,那么这里的比较就派上用场了。

作为一个提醒,锁是并发和并行编程中通常用于同步多线程的机制(即防止多个线程同时访问关键会话)。然而,正如我们将在第十二章中再次讨论的那样,饥饿,锁也是死锁的常见来源,其中一个线程获取了一个锁,但由于未处理的发生而从未释放它,从而停止整个程序。

死锁处理示例

让我们来看一个 Python 的快速示例。让我们看一下Chapter04/example2.py文件,如下所示:

# Chapter04/example2.py

from threading import Lock

my_lock = Lock()

def get_data_from_file_v1(filename):
    my_lock.acquire()

    with open(filename, 'r') as f:
        data.append(f.read())

    my_lock.release()

data = []

try:
    get_data_from_file('output2/sample0.txt')
except FileNotFoundError:
    print('Encountered an exception...')

my_lock.acquire()
print('Lock can still be acquired.')

在这个例子中,我们有一个get_data_from_file_v1()函数,它接受外部文件的路径,从中读取数据,并将该数据附加到一个预先声明的名为data的列表中。在这个函数内部,一个名为my_lock的锁对象,在调用函数之前也是预先声明的,分别在读取文件之前和之后被获取和释放。

在主程序中,我们将尝试在一个不存在的文件上调用get_data_from_file_v1(),这是编程中最常见的错误之一。在程序的末尾,我们还会再次获取锁对象。重点是看看我们的编程是否能够处理读取不存在文件的错误,只使用我们已经有的try...except块。

运行脚本后,您会注意到我们的程序将打印出try...except块中指定的错误消息遇到异常...,这是预期的,因为找不到文件。但是,程序还将无法执行其余的代码;它永远无法到达代码的最后一行print('Lock acquired.'),并且将永远挂起(或者直到您按下Ctrl + C强制退出程序)。

这是一个死锁的情况,再次发生在get_data_from_file_v1()函数内部获取my_lock,但由于我们的程序在执行my_lock.release()之前遇到错误,锁从未被释放。这反过来导致程序末尾的my_lock.acquire()行挂起,因为无论如何都无法获取锁。因此,我们的程序无法达到最后一行代码print('Lock acquired.')

然而,这个问题可以很容易地通过with语句轻松处理。在example2.py文件中,只需注释掉调用get_data_from_file_v1()的行,并取消注释调用get_data_from_file_v2()的行,您将得到以下结果:

# Chapter04/example2.py

from threading import Lock

my_lock = Lock()

def get_data_from_file_v2(filename):
    with my_lock, open(filename, 'r') as f:
        data.append(f.read())

data = []

try:
    get_data_from_file_v2('output2/sample0.txt')
except:
    print('Encountered an exception...')

my_lock.acquire()
print('Lock acquired.')

get_data_from_file_v2()函数中,我们有一对嵌套的with语句,如下所示:

with my_lock:
    with open(filename, 'r') as f:
        data.append(f.read())

由于Lock对象是上下文管理器,简单地使用with my_lock:将确保锁对象被适当地获取和释放,即使在块内遇到异常。运行脚本后,您将得到以下输出:

> python example2.py
Encountered an exception...
Lock acquired.

我们可以看到,这次我们的程序能够获取锁,并且在没有错误的情况下优雅地执行脚本的末尾。

总结

Python 中的with语句提供了一种直观和方便的方式来管理资源,同时确保错误和异常被正确处理。在并发和并行编程中,管理资源的能力更加重要,不同实体之间共享和利用各种资源,特别是通过在多线程应用程序中使用with语句与threading.Lock对象同步不同线程。

除了更好的错误处理和保证清理任务外,with语句还可以提供程序的额外可读性,这是 Python 为开发人员提供的最强大的功能之一。

在下一章中,我们将讨论 Python 目前最流行的用途之一:网络爬虫应用。我们将看看网络爬虫背后的概念和基本思想,Python 提供的支持网络爬虫的工具,以及并发如何显著帮助您的网络爬虫应用程序。

问题

  • 文件描述符是什么,Python 中如何处理它?

  • 当文件描述符没有得到谨慎处理时会出现什么问题?

  • 锁是什么,Python 中如何处理它?

  • 当锁没有得到谨慎处理时会出现什么问题?

  • 上下文管理器背后的思想是什么?

  • Python 的with语句在上下文管理方面提供了哪些选项?

进一步阅读

有关更多信息,您可以参考以下链接:

第五章:并发网络请求

本章将重点介绍并发性在进行网络请求时的应用。直观地,向网页发出请求以收集有关其的信息与将相同任务应用于另一个网页是独立的。因此,在这种情况下,特别是线程,可以成为一个强大的工具,可以在这个过程中提供显著的加速。在本章中,我们将学习网络请求的基础知识以及如何使用 Python 与网站进行交互。我们还将看到并发性如何帮助我们以高效的方式进行多个请求。最后,我们将看一些网络请求的良好实践。

在本章中,我们将涵盖以下概念:

  • 网络请求的基础知识

  • 请求模块

  • 并发网络请求

  • 超时问题

  • 进行网络请求的良好实践

技术要求

以下是本章的先决条件列表:

网络请求的基础知识

据估计,全球生成数据的能力每两年就会增加一倍。尽管有一个名为数据科学的跨学科领域专门致力于数据的研究,但几乎软件开发中的每个编程任务都与收集和分析数据有关。其中一个重要部分当然是数据收集。然而,我们应用程序所需的数据有时并没有以清晰和干净的方式存储在数据库中,有时我们需要从网页中收集我们需要的数据。

例如,网络爬虫是一种自动向网页发出请求并下载特定信息的数据提取方法。网络爬虫允许我们遍历许多网站,并以系统和一致的方式收集我们需要的任何数据,这些收集的数据可以由我们的应用程序稍后进行分析,或者简单地以各种格式保存在我们的计算机上。一个例子是谷歌,它编写并运行了许多自己的网络爬虫来查找和索引搜索引擎的网页。

Python 语言本身提供了许多适用于这种类型应用的好选择。在本章中,我们将主要使用requests模块从我们的 Python 程序中进行客户端网络请求。然而,在我们更详细地了解这个模块之前,我们需要了解一些网络术语,以便能够有效地设计我们的应用程序。

HTML

超文本标记语言HTML)是开发网页和 Web 应用程序的标准和最常见的标记语言。HTML 文件只是一个扩展名为.html的纯文本文件。在 HTML 文档中,文本被标签包围和分隔,标签用尖括号括起来:<p><img><i>等。这些标签通常由一对组成,即开放标签和闭合标签,指示样式或数据的

数据的性质。

在 HTML 代码中还可以包括其他形式的媒体,如图像或视频。常见的 HTML 文档中还有许多其他标签。有些标签指定了一组具有共同特征的元素,例如<id></id><class></class>

以下是 HTML 代码的示例:

示例 HTML 代码

幸运的是,我们不需要详细了解每个 HTML 标签的功能,就能够有效地进行网络请求。正如我们将在本章后面看到的那样,进行网络请求的更重要的部分是能够有效地与网页进行交互。

HTTP 请求

在 Web 上的典型通信过程中,HTML 文本是要保存和/或进一步处理的数据。这些数据首先需要从网页中收集,但我们该如何做呢?大多数通信是通过互联网进行的——更具体地说,是通过万维网——这利用了超文本传输协议HTTP)。在 HTTP 中,请求方法用于传达所请求的数据以及应该从服务器发送回来的信息。

例如,当您在浏览器中输入packtpub.com时,浏览器通过 HTTP 向 Packt 网站的主服务器发送请求方法,请求网站的数据。现在,如果您的互联网连接和 Packt 的服务器都正常工作,那么您的浏览器将从服务器接收到响应,如下图所示。此响应将以 HTML 文档的形式呈现,浏览器将解释相应的 HTML 输出并在屏幕上显示。

HTTP 通信图

通常,请求方法被定义为表示所需执行的操作的动词,而 HTTP 客户端(Web 浏览器)和服务器相互通信:GETHEADPOSTPUTDELETE等。在这些方法中,GETPOST是 Web 抓取应用程序中最常用的两种请求方法;它们的功能如下所述:

  • GET 方法从服务器请求特定数据。此方法仅检索数据,对服务器及其数据库没有其他影响。

  • POST 方法以服务器接受的特定形式发送数据。例如,这些数据可能是发往公告板、邮件列表或新闻组的消息;要提交到 Web 表单的信息;或要添加到数据库的项目。

我们在互联网上常见的所有通用 HTTP 服务器实际上都必须至少实现 GET(和 HEAD)方法,而 POST 方法被视为可选。

HTTP 状态代码

并非总是当发出 Web 请求并发送到 Web 服务器时,服务器会处理请求并无误地返回所请求的数据。有时,服务器可能完全关闭或已忙于与其他客户端交互,因此无法对新请求做出响应;有时,客户端本身向服务器发出错误请求(例如,格式不正确或恶意请求)。

为了将这些问题归类并在 Web 请求引起的通信中提供尽可能多的信息,HTTP 要求服务器对其客户端的每个请求做出HTTP 响应 状态代码的响应。状态代码通常是一个三位数,指示服务器发送回客户端的响应的具体特征。

HTTP 响应状态代码共有五个大类,由代码的第一位数字表示。它们如下所示:

  • 1xx(信息状态代码):请求已收到,服务器正在处理。例如,100 表示已接收请求头,并且服务器正在等待请求正文;102 表示请求当前正在处理中(用于大型请求和防止客户端超时)。

  • 2xx(成功状态代码):请求已被服务器成功接收、理解和处理。例如,200 表示请求已成功完成;202 表示请求已被接受进行处理,但处理本身尚未完成。

  • 3xx(重定向状态码):需要采取其他操作才能成功处理请求。例如,300 表示关于如何处理来自服务器的响应有多个选项(例如,在下载视频文件时,为客户端提供多个视频格式选项);301 表示服务器已永久移动,所有请求应重定向到另一个地址(在服务器响应中提供)。

  • 4xx(客户端的错误状态码):客户端错误地格式化了请求,无法处理。例如,400 表示客户端发送了错误的请求(例如,语法错误或请求的大小太大);404(可能是最知名的状态码)表示服务器不支持请求方法。

  • 5xx(服务器的错误状态码):请求虽然有效,但服务器无法处理。例如,500 表示出现内部服务器错误,遇到了意外情况;504(网关超时)表示充当网关或代理的服务器未能及时从最终服务器接收响应。

关于这些状态码还可以说很多,但对于我们来说,只需记住之前提到的五大类别就足够了。如果您想找到有关上述或其他状态码的更多具体信息,互联网编号分配机构IANA)维护着 HTTP 状态码的官方注册表。

请求模块

requests模块允许用户发出和发送 HTTP 请求方法。在我们考虑的应用程序中,它主要用于与我们想要提取数据的网页的服务器联系,并获取服务器的响应。

根据该模块的官方文档,强烈建议requests中使用 Python 3 而不是 Python 2。

要在计算机上安装该模块,请运行以下命令:

pip install requests

如果您使用pip作为软件包管理器,请使用此代码。但如果您使用的是 Anaconda,只需使用以下代码:

conda install requests

如果您的系统尚未安装这些依赖项(idnacertifiurllib3等),这些命令应该会为您安装requests和其他所需的依赖项。之后,在 Python 解释器中运行import requests以确认模块已成功安装。

在 Python 中发出请求

让我们看一下该模块的一个示例用法。如果您已经从 GitHub 页面下载了本书的代码,请转到Chapter05文件夹。让我们看一下以下代码中显示的example1.py文件:

# Chapter05/example1.py

import requests

url = 'http://www.google.com'

res = requests.get(url)

print(res.status_code)
print(res.headers)

with open('google.html', 'w') as f:
    f.write(res.text)

print('Done.')

在此示例中,我们使用requests模块下载网页www.google.com的 HTML 代码。requests.get()方法向url发送GET请求方法,并将响应存储在res变量中。在打印出响应的状态和标头后,我们创建一个名为google.html的文件,并将存储在响应文本中的 HTML 代码写入文件。

运行程序(假设您的互联网正常工作,Google 服务器没有宕机),您应该会得到以下输出:

200
{'Date': 'Sat, 17 Nov 2018 23:08:58 GMT', 'Expires': '-1', 'Cache-Control': 'private, max-age=0', 'Content-Type': 'text/html; charset=ISO-8859-1', 'P3P': 'CP="This is not a P3P policy! See g.co/p3phelp for more info."', 'X-XSS-Protection': '1; mode=block', 'X-Frame-Options': 'SAMEORIGIN', 'Content-Encoding': 'gzip', 'Server': 'gws', 'Content-Length': '4958', 'Set-Cookie': '1P_JAR=2018-11-17-23; expires=Mon, 17-Dec-2018 23:08:58 GMT; path=/; domain=.google.com, NID=146=NHT7fic3mjBO_vdiFB3-gqnFPyGN1EGxyMkkNPnFMEVsqjGJ8S0EwrivDBWBgUS7hCPZGHbosLE4uxz31shnr3X4adRpe7uICEiK8qh3Asu6LH_bIKSLWStAp8gMK1f9_GnQ0_JKQoMvG-OLrT_fwV0hwTR5r2UVYsUJ6xHtX2s; expires=Sun, 19-May-2019 23:08:58 GMT; path=/; domain=.google.com; HttpOnly'}
Done.

响应的状态码为200,这意味着请求已成功完成。响应的标头存储在res.headers中,还包含有关响应的进一步具体信息。例如,我们可以看到请求的日期和时间,或者响应的内容是文本和 HTML,内容的总长度为4958

服务器发送的完整数据也被写入了google.html文件。当您在文本编辑器中打开文件时,您将能够看到我们使用请求下载的网页的 HTML 代码。另一方面,如果您使用 Web 浏览器打开文件,您将看到原始网页的大部分信息现在通过下载的离线文件显示出来。

例如,以下是我的系统上 Google Chrome 如何解释 HTML 文件:

离线打开的下载 HTML

服务器上还存储着网页引用的其他信息。这意味着并非所有在线网页提供的信息都可以通过GET请求下载,这就是为什么离线 HTML 代码有时无法包含从中下载的在线网页上所有可用的信息的原因。(例如,前面截图中下载的 HTML 代码无法正确显示 Google 图标。)

运行 ping 测试

在掌握了 HTTP 请求和 Python 中的requests模块的基本知识后,我们将在本章的其余部分中解决一个核心问题:运行 ping 测试。Ping 测试是一个过程,通过该过程您可以通过向每个相关服务器发出请求来测试系统与特定 Web 服务器之间的通信。通过考虑服务器(可能)返回的 HTTP 响应状态代码,该测试用于评估您自己系统的互联网连接或服务器的可用性。

Ping 测试在 Web 管理员中非常常见,他们通常需要同时管理大量网站。Ping 测试是一个快速识别意外无响应或宕机页面的好工具。有许多工具可以为您提供强大的 ping 测试选项,在本章中,我们将设计一个可以同时发送多个 Web 请求的 ping 测试应用程序。

为了模拟不同的 HTTP 响应状态代码发送回我们的程序,我们将使用httpstat.us,这是一个可以生成各种状态代码并常用于测试应用程序如何处理不同响应的网站。具体来说,要在程序中使用返回 200 状态代码的请求,我们可以简单地向httpstat.us/200发出请求,其他状态代码也是如此。在我们的 ping 测试程序中,我们将有一个包含不同状态代码的httpstat.us URL 列表。

现在让我们来看一下Chapter05/example2.py文件,如下面的代码所示:

# Chapter05/example2.py

import requests

def ping(url):
    res = requests.get(url)
    print(f'{url}: {res.text}')

urls = [
    'http://httpstat.us/200',
    'http://httpstat.us/400',
    'http://httpstat.us/404',
    'http://httpstat.us/408',
    'http://httpstat.us/500',
    'http://httpstat.us/524'
]

for url in urls:
    ping(url)

print('Done.')

在这个程序中,ping()函数接收一个 URL,并尝试向站点发出GET请求。然后它将打印出服务器返回的响应内容。在我们的主程序中,我们有一个不同状态代码的列表,我们将逐个调用ping()函数。

运行上述示例后的最终输出应该如下:

http://httpstat.us/200: 200 OK
http://httpstat.us/400: 400 Bad Request
http://httpstat.us/404: 404 Not Found
http://httpstat.us/408: 408 Request Timeout
http://httpstat.us/500: 500 Internal Server Error
http://httpstat.us/524: 524 A timeout occurred
Done.

我们看到我们的 ping 测试程序能够从服务器获得相应的响应。

并发网络请求

在并发编程的背景下,我们可以看到向 Web 服务器发出请求并获取返回的响应的过程与为不同的 Web 服务器执行相同的过程是独立的。这意味着我们可以将并发性和并行性应用于我们的 ping 测试应用程序,以加快执行速度。

在我们设计的并发 ping 测试应用程序中,将同时向服务器发出多个 HTTP 请求,并将相应的响应发送回我们的程序,如下图所示。正如之前讨论的那样,并发性和并行性在 Web 开发中有重要的应用,大多数服务器现在都有能力同时处理大量的请求:

并行 HTTP 请求

生成多个线程

要应用并发,我们只需使用我们一直在讨论的threading模块来创建单独的线程来处理不同的网络请求。让我们看一下Chapter05/example3.py文件,如下面的代码所示:

# Chapter05/example3.py

import threading
import requests
import time

def ping(url):
    res = requests.get(url)
    print(f'{url}: {res.text}')

urls = [
    'http://httpstat.us/200',
    'http://httpstat.us/400',
    'http://httpstat.us/404',
    'http://httpstat.us/408',
    'http://httpstat.us/500',
    'http://httpstat.us/524'
]

start = time.time()
for url in urls:
    ping(url)
print(f'Sequential: {time.time() - start : .2f} seconds')

print()

start = time.time()
threads = []
for url in urls:
    thread = threading.Thread(target=ping, args=(url,))
    threads.append(thread)
    thread.start()
for thread in threads:
    thread.join()

print(f'Threading: {time.time() - start : .2f} seconds')

在这个例子中,我们包括了前一个例子中的顺序逻辑来处理我们的 URL 列表,以便我们可以比较当我们将线程应用到我们的 ping 测试程序时速度的提高。我们还使用threading模块为我们的 URL 列表中的每个 URL 创建一个线程来 ping;这些线程将独立执行。使用time模块的方法还跟踪了顺序和并发处理 URL 所花费的时间。

运行程序,您的输出应该类似于以下内容:

http://httpstat.us/200: 200 OK
http://httpstat.us/400: 400 Bad Request
http://httpstat.us/404: 404 Not Found
http://httpstat.us/408: 408 Request Timeout
http://httpstat.us/500: 500 Internal Server Error
http://httpstat.us/524: 524 A timeout occurred
Sequential: 0.82 seconds

http://httpstat.us/404: 404 Not Found
http://httpstat.us/200: 200 OK
http://httpstat.us/400: 400 Bad Request
http://httpstat.us/500: 500 Internal Server Error
http://httpstat.us/524: 524 A timeout occurred
http://httpstat.us/408: 408 Request Timeout
Threading: 0.14 seconds

尽管顺序逻辑和线程逻辑处理所有 URL 所花费的具体时间可能因系统而异,但两者之间仍应有明显的区别。具体来说,我们可以看到线程逻辑几乎比顺序逻辑快了六倍(这对应于我们有六个线程并行处理六个 URL 的事实)。毫无疑问,并发可以为我们的 ping 测试应用程序以及一般的 Web 请求处理过程提供显著的加速。

重构请求逻辑

我们 ping 测试应用程序的当前版本按预期工作,但我们可以通过重构我们的请求逻辑将 Web 请求的逻辑放入一个线程类中来提高其可读性。考虑Chapter05/example4.py文件,特别是MyThread类:

# Chapter05/example4.py

import threading
import requests

class MyThread(threading.Thread):
    def __init__(self, url):
        threading.Thread.__init__(self)
        self.url = url
        self.result = None

    def run(self):
        res = requests.get(self.url)
        self.result = f'{self.url}: {res.text}'

在这个例子中,MyThread继承自threading.Thread类,并包含两个额外的属性:urlresulturl属性保存了线程实例应该处理的 URL,来自 Web 服务器对该线程的响应将被写入result属性(在run()函数中)。

在这个类之外,我们现在可以简单地循环遍历 URL 列表,并相应地创建和管理线程,而不必担心主程序中的请求逻辑:

urls = [
    'http://httpstat.us/200',
    'http://httpstat.us/400',
    'http://httpstat.us/404',
    'http://httpstat.us/408',
    'http://httpstat.us/500',
    'http://httpstat.us/524'
]

start = time.time()

threads = [MyThread(url) for url in urls]
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()
for thread in threads:
    print(thread.result)

print(f'Took {time.time() - start : .2f} seconds')

print('Done.')

请注意,我们现在将响应存储在MyThread类的result属性中,而不是像以前的示例中的旧ping()函数中直接打印出来。这意味着,在确保所有线程都已完成后,我们需要再次循环遍历这些线程并打印出这些响应。

重构请求逻辑不应该对我们当前的程序性能产生很大影响;我们正在跟踪执行速度,以查看是否实际情况如此。执行程序,您将获得类似以下的输出:

http://httpstat.us/200: 200 OK
http://httpstat.us/400: 400 Bad Request
http://httpstat.us/404: 404 Not Found
http://httpstat.us/408: 408 Request Timeout
http://httpstat.us/500: 500 Internal Server Error
http://httpstat.us/524: 524 A timeout occurred
Took 0.14 seconds
Done.

正如我们预期的那样,通过重构的请求逻辑,我们仍然从程序的顺序版本中获得了显著的加速。同样,我们的主程序现在更易读,而对请求逻辑的进一步调整(正如我们将在下一节中看到的)可以简单地指向MyThread类,而不会影响程序的其余部分。

超时问题

在本节中,我们将探讨对我们的 ping 测试应用程序可以进行的一个潜在改进:超时处理。超时通常发生在服务器在处理特定请求时花费异常长的时间,并且服务器与其客户端之间的连接被终止。

在 ping 测试应用程序的上下文中,我们将实现一个定制的超时阈值。回想一下,ping 测试用于确定特定服务器是否仍然响应,因此我们可以在程序中指定,如果请求花费的时间超过了服务器响应的超时阈值,我们将将该特定服务器归类为超时。

来自 httpstat.us 和 Python 模拟的支持

除了不同状态码的选项之外,httpstat.us网站还提供了一种在发送请求时模拟响应延迟的方法。具体来说,我们可以使用GET请求中的查询参数来自定义延迟时间(以毫秒为单位)。例如,httpstat.us/200?sleep=5000将在延迟五秒后返回响应。

现在,让我们看看这样的延迟会如何影响我们程序的执行。考虑一下Chapter05/example5.py文件,其中包含我们 ping 测试应用程序的当前请求逻辑,但具有不同的 URL 列表:

# Chapter05/example5.py

import threading
import requests

class MyThread(threading.Thread):
    def __init__(self, url):
        threading.Thread.__init__(self)
        self.url = url
        self.result = None

    def run(self):
        res = requests.get(self.url)
        self.result = f'{self.url}: {res.text}'

urls = [
    'http://httpstat.us/200',
    'http://httpstat.us/200?sleep=20000',
    'http://httpstat.us/400'
]

threads = [MyThread(url) for url in urls]
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()
for thread in threads:
    print(thread.result)

print('Done.')

这里有一个 URL,将花费大约 20 秒才能返回响应。考虑到我们将阻塞主程序直到所有线程完成执行(使用join()方法),我们的程序在打印出任何响应之前很可能会出现 20 秒的挂起状态。

运行程序来亲身体验一下。将会发生 20 秒的延迟(这将使执行时间显著延长),我们将获得以下输出:

http://httpstat.us/200: 200 OK
http://httpstat.us/200?sleep=20000: 200 OK
http://httpstat.us/400: 400 Bad Request
Took 22.60 seconds
Done.

超时规范

一个高效的 ping 测试应用程序不应该长时间等待来自网站的响应;它应该有一个超时的设定阈值,如果服务器在该阈值下未返回响应,应用程序将认为该服务器不响应。因此,我们需要实现一种方法来跟踪自从发送请求到服务器以来经过了多少时间。我们将通过从超时阈值倒计时来实现这一点,一旦超过该阈值,所有响应(无论是否已返回)都将被打印出来。

此外,我们还将跟踪还有多少请求仍在等待并且还没有返回响应。我们将使用threading.Thread类中的isAlive()方法来间接确定特定请求是否已经返回响应:如果在某一时刻,处理特定请求的线程仍然存活,我们可以得出结论,该特定请求仍在等待。

导航到Chapter05/example6.py文件,并首先考虑process_requests()函数:

# Chapter05/example6.py

import time

UPDATE_INTERVAL = 0.01

def process_requests(threads, timeout=5):
    def alive_count():
        alive = [1 if thread.isAlive() else 0 for thread in threads]
        return sum(alive)

    while alive_count() > 0 and timeout > 0:
        timeout -= UPDATE_INTERVAL
        time.sleep(UPDATE_INTERVAL)
    for thread in threads:
        print(thread.result)

该函数接受一个线程列表,我们在之前的示例中一直在使用这些线程来进行网络请求,还有一个可选参数指定超时阈值。在这个函数内部,我们有一个内部函数alive_count(),它返回在函数调用时仍然存活的线程数。

process_requests()函数中,只要有线程仍然存活并处理请求,我们将允许线程继续执行(这是在while循环中完成的,具有双重条件)。正如你所看到的,UPDATE_INTERVAL变量指定了我们检查这个条件的频率。如果任一条件失败(如果没有存活的线程或者超时阈值已过),那么我们将继续打印出响应(即使有些可能尚未返回)。

让我们把注意力转向新的MyThread类:

# Chapter05/example6.py

import threading
import requests

class MyThread(threading.Thread):
    def __init__(self, url):
        threading.Thread.__init__(self)
        self.url = url
        self.result = f'{self.url}: Custom timeout'

    def run(self):
        res = requests.get(self.url)
        self.result = f'{self.url}: {res.text}'

这个类几乎与我们在之前的示例中考虑的类相同,只是result属性的初始值是指示超时的消息。在我们之前讨论的情况中,超时阈值在process_requests()函数中指定,当打印出响应时,将使用这个初始值。

最后,让我们考虑一下我们的主程序:

# Chapter05/example6.py

urls = [
    'http://httpstat.us/200',
    'http://httpstat.us/200?sleep=4000',
    'http://httpstat.us/200?sleep=20000',
    'http://httpstat.us/400'
]

start = time.time()

threads = [MyThread(url) for url in urls]
for thread in threads:
    thread.setDaemon(True)
    thread.start()
process_requests(threads)

print(f'Took {time.time() - start : .2f} seconds')

print('Done.')

在我们的 URL 列表中,我们有一个请求需要 4 秒,另一个需要 20 秒,除了那些会立即响应的请求。由于我们使用的超时阈值是 5 秒,理论上我们应该能够看到 4 秒延迟的请求成功获得响应,而 20 秒延迟的请求则不会。

关于这个程序还有另一个要点:守护线程。在process_requests()函数中,如果超时阈值在至少有一个线程在处理时被触发,那么函数将继续打印出每个线程的result属性。

 while alive_count() > 0 and timeout > 0:
    timeout -= UPDATE_INTERVAL
    time.sleep(UPDATE_INTERVAL)
for thread in threads:
    print(thread.result)

这意味着我们不会通过使用join()函数阻止程序直到所有线程都执行完毕,因此如果达到超时阈值,程序可以简单地继续前进。然而,这意味着线程本身在这一点上并不终止。特别是 20 秒延迟的请求,在我们的程序退出process_requests()函数后仍然很可能在运行。

如果处理此请求的线程不是守护线程(如我们所知,守护线程在后台执行并且永远不会终止),它将阻止主程序完成,直到线程本身完成。通过将此线程和任何其他线程设置为守护线程,我们允许主程序在执行其指令的最后一行后立即完成,即使仍有线程在运行。

让我们看看这个程序的运行情况。执行代码,您的输出应该类似于以下内容:

http://httpstat.us/200: 200 OK
http://httpstat.us/200?sleep=4000: 200 OK
http://httpstat.us/200?sleep=20000: Custom timeout
http://httpstat.us/400: 400 Bad Request
Took 5.70 seconds
Done.

正如您所看到的,这次我们的程序花了大约 5 秒才完成。这是因为它花了 5 秒等待仍在运行的线程,一旦超过 5 秒的阈值,程序就会打印出结果。在这里,我们看到 20 秒延迟请求的结果只是MyThread类的result属性的默认值,而其他请求都能够从服务器获得正确的响应(包括 4 秒延迟的请求,因为它有足够的时间来获取响应)。

如果您想看到我们之前讨论的非守护线程的影响,只需注释掉主程序中相应的代码行,如下所示:

threads = [MyThread(url) for url in urls]
for thread in threads:
    #thread.setDaemon(True)
    thread.start()
process_requests(threads)

您将看到主程序将挂起大约 20 秒,因为处理 20 秒延迟请求的非守护线程仍在运行,然后才能完成执行(即使产生的输出将是相同的)。

制作网络请求的良好实践

在进行并发网络请求时,有一些方面需要仔细考虑和实施。在本节中,我们将讨论这些方面以及在开发应用程序时应该使用的一些最佳实践。

考虑服务条款和数据收集政策

未经授权的数据收集已经成为技术世界的讨论话题,过去几年,它将继续存在很长一段时间,这也是有充分理由的。因此,对于在其应用程序中进行自动化网络请求的开发人员来说,查找网站的数据收集政策非常重要。您可以在其服务条款或类似文件中找到这些政策。如果有疑问,直接联系网站询问更多细节通常是一个很好的经验法则。

错误处理

编程领域中,错误是无法轻易避免的事情,特别是在进行网络请求时。这些程序中的错误可能包括发出错误的请求(无效请求或者是网络连接不佳),处理下载的 HTML 代码不当,或者解析 HTML 代码失败。因此,在 Python 中使用try...except块和其他错误处理工具以避免应用程序崩溃非常重要。如果您的代码/应用程序用于生产和大型应用程序中,避免崩溃尤为重要。

特别是在并发网络爬虫中,一些线程可能成功收集数据,而其他线程可能失败。通过在程序的多线程部分实现错误处理功能,您可以确保失败的线程不会导致整个程序崩溃,并确保成功的线程仍然可以返回其结果。

然而,需要注意的是,盲目捕获错误仍然是不可取的。这个术语表示我们在程序中有一个大的try...expect块,它将捕获程序执行中发生的任何错误,而且无法获得有关错误的进一步信息;这种做法也可能被称为错误吞噬。强烈建议在程序中具有特定的错误处理代码,这样不仅可以针对特定错误采取适当的行动,而且还可以发现未考虑的其他错误。

定期更新您的程序

网站定期更改其请求处理逻辑以及显示的数据是非常常见的。如果一个向网站发出请求的程序具有相当不灵活的逻辑来与网站的服务器交互(例如,以特定格式构造其请求,仅处理一种响应),那么当网站改变其处理客户端请求的方式时,该程序很可能会停止正常运行。这种情况经常发生在寻找特定 HTML 标签中的数据的网络爬虫程序中;当 HTML 标签发生变化时,这些程序将无法找到它们的数据。

这种做法是为了防止自动数据收集程序的运行。要继续使用最近更改了请求处理逻辑的网站,唯一的方法是分析更新的协议并相应地修改我们的程序。

避免发出大量请求

我们讨论的每个程序运行时,都会向管理您想要提取数据的网站的服务器发出 HTTP 请求。在并发程序中,向该服务器提交多个请求的频率更高,时间更短。

如前所述,现在的服务器具有轻松处理多个请求的能力。然而,为了避免过度工作和过度消耗资源,服务器也设计为停止回应过于频繁的请求。大型科技公司的网站,如亚马逊或 Twitter,会寻找来自同一 IP 地址的大量自动请求,并实施不同的响应协议;一些请求可能会延迟,一些可能会拒绝响应,甚至可能会禁止该 IP 地址在特定时间内继续发出请求。

有趣的是,向服务器重复发送大量请求实际上是一种对网站进行黑客攻击的形式。在拒绝服务DoS)和分布式拒绝服务DDoS)攻击中,大量请求同时发送到服务器,使目标服务器的带宽被流量淹没,因此,其他客户端的正常、非恶意请求被拒绝,因为服务器正忙于处理并发请求,如下图所示:

DDoS 攻击的一种

因此,重要的是要分隔应用程序对服务器发出的并发请求,以便应用程序不被视为攻击者,并且可能被禁止或视为恶意客户端。这可以简单地限制程序中可以同时实施的最大线程/请求数量,或者在向服务器发出请求之前暂停线程一段特定时间(例如,使用time.sleep()函数)。

总结

在本章中,我们已经了解了 HTML 和网络请求的基础知识。最常见的网络请求是GETPOST请求。HTTP 响应状态码有五个主要类别,每个类别表示关于服务器和其客户端之间通信的不同概念。通过考虑从不同网站接收的状态代码,我们可以编写一个 ping 测试应用程序,有效地检查这些网站的响应能力。

并发可以应用于同时进行多个网络请求的问题,通过线程提供了应用程序速度的显着改进。但是,在进行并发网络请求时,需要牢记一些考虑因素。

在下一章中,我们将开始讨论并发编程中的另一个重要角色:进程。我们将考虑进程的概念和基本思想,以及 Python 为我们提供的处理进程的选项。

问题

  • 什么是 HTML?

  • HTTP 请求是什么?

  • 什么是 HTTP 响应状态码?

  • requests模块如何帮助进行网络请求?

  • 什么是 ping 测试,通常如何设计?

  • 为什么并发适用于进行网络请求?

  • 在开发进行并发网络请求的应用程序时需要考虑哪些因素?

进一步阅读

有关更多信息,您可以参考以下链接:

  • 用 Python 自动化乏味的事情:面向完全初学者的实用编程,Al. Sweigart,No Starch Press,2015

  • 使用 Python 进行网络抓取,Richard Lawson,Packt Publishing Ltd,2015

  • 使用 Java 进行即时网络抓取,Ryan Mitchell,Packt Publishing Ltd,2013

第六章:在 Python 中处理进程

本章是关于在 Python 中使用多进程编程进行并发的三章中的第一章。我们已经看到了在并发和并行编程中使用进程的各种示例。在本章中,您将了解进程的正式定义,以及 Python 中的multiprocessing模块。本章将介绍使用multiprocessing模块的 API 与进程一起工作的一些最常见的方法,例如Process类,Pool类和诸如Queue类之类的进程间通信工具。本章还将研究并发编程中多线程和多进程之间的主要区别。

本章将涵盖以下主题:

  • 在计算机科学中并发编程的上下文中的进程概念

  • Python 中multiprocessing模块的基本 API

  • 如何与进程交互以及multiprocessing模块提供的高级功能

  • multiprocessing模块如何支持进程间通信

  • 并发编程中多进程和多线程之间的主要区别

技术要求

以下是本章的先决条件列表:

进程的概念

在计算机科学领域,执行过程是操作系统正在执行的特定计算机程序或软件的实例。进程包含程序代码及其当前的活动和与其他实体的交互。根据操作系统的不同,进程的实现可以由多个执行线程组成,这些线程可以并发或并行执行指令。

重要的是要注意,进程不等同于计算机程序。虽然程序只是一组静态指令(程序代码),但进程实际上是这些指令的实际执行。这也意味着相同的程序可以通过生成多个进程并发地运行。这些进程执行来自父程序的相同代码。

例如,互联网浏览器 Google Chrome 通常会管理一个名为Google Chrome Helper的进程,以便为其主程序提供网页浏览和其他进程的便利,以协助各种目的。查看系统正在运行和管理的不同进程的简单方法包括使用 Windows 的任务管理器,iOS 的活动监视器和 Linux 操作系统的系统监视器。

以下是我的活动监视器的屏幕截图。在列表中可以看到多个名为 Google Chrome Helper 的进程。PID列(代表进程 ID)报告了每个进程的唯一 ID:

进程的示例列表

进程与线程

在开发并发和并行应用程序时,程序员经常犯的一个常见错误是混淆进程和线程的结构和功能。正如我们从第三章中所看到的,在 Python 中使用线程,线程是编程代码的最小单位,通常是进程的组成部分。此外,可以在同一进程中实现多个线程以访问和共享内存或其他资源,而不同的进程不以这种方式进行交互。这种关系如下图所示:

两个线程在一个进程中执行的图表

由于进程是比线程更大的编程单元,因此它也更复杂,包含更多的编程组件。因此,进程也需要更多的资源,而线程则不需要,有时被称为轻量级进程。在典型的计算机系统进程中,有许多主要资源,如下列表所示:

  • 从父程序执行的代码的图像(或副本)。

  • 与程序实例相关联的内存。这可能包括特定进程的可执行代码、输入和输出、用于管理程序特定事件的调用堆栈,或者包含生成的计算数据并在运行时由进程使用的堆。

  • 由操作系统分配给特定进程的资源的描述符。我们已经在第四章中看到了这些资源的示例——文件描述符——在线程中使用 with 语句

  • 特定进程的安全组件,即进程的所有者及其权限和允许的操作。

  • 处理器状态,也称为进程上下文。进程的上下文数据通常位于处理器寄存器、进程使用的内存或操作系统用于管理进程的控制寄存器中。

由于每个进程都有专门的状态,进程比线程保存更多的状态信息;进程内的多个线程又共享进程状态、内存和其他各种资源。出于类似的原因,进程只能通过系统提供的进程间通信方法与其他进程进行交互,而线程可以通过共享资源轻松地相互通信。

此外,上下文切换——保存进程或线程的状态数据以中断任务的执行并在以后恢复它的行为——在不同进程之间所需的时间比在同一进程内的不同线程之间所需的时间更长。然而,尽管我们已经看到线程之间的通信需要仔细的内存同步以确保正确的数据处理,由于不同进程之间的通信较少,进程几乎不需要或不需要内存同步。

多处理

计算机科学中的一个常见概念是多任务处理。在多任务处理时,操作系统会以高速在不同进程之间切换,从而使这些进程看起来像是同时执行,尽管通常情况下只有一个进程在任何给定时间内在一个单独的中央处理单元(CPU)上执行。相比之下,多处理是使用多个 CPU 来执行任务的方法。

虽然术语多处理有许多不同的用法,但在并发性和并行性的上下文中,多处理指的是在操作系统中执行多个并发进程,其中每个进程在单独的 CPU 上执行,而不是在任何给定时间内执行单个进程。由于进程的性质,操作系统需要有两个或更多个 CPU 才能实现多处理任务,因为它需要同时支持多个处理器并适当地分配任务。

此关系显示在以下图表中:

多处理使用两个 CPU 核心的示例图

我们在第三章中看到,多线程与多处理有相似的定义。多线程意味着只有一个处理器被利用,并且系统在该处理器内的任务之间进行切换(也称为时间片切割),而多处理通常表示使用多个处理器实际并发/并行执行多个进程。

多进程应用在并发和并行编程领域享有显著的流行度。一些原因如下所列:

  • 更快的执行时间:我们知道,正确的并发总是能够为程序提供额外的加速,前提是它们的某些部分可以独立执行。

  • 无需同步:由于在多进程应用中,独立的进程不会共享资源,开发人员很少需要花时间协调这些资源的共享和同步,不像多线程应用程序,需要努力确保数据被正确操作。

  • 免于崩溃:由于进程在计算过程和输入/输出方面是相互独立的,多进程程序中一个进程的失败不会影响另一个进程的执行,如果处理正确的话。这意味着程序员可以承担产生更多进程(系统仍然可以处理的)的风险,而整个应用程序崩溃的机会不会增加。

话虽如此,使用多进程也有一些值得注意的缺点,如下列表所示:

  • 需要多个处理器:再次强调,多进程需要操作系统拥有多个 CPU。尽管多处理器如今对计算机系统来说相当普遍,但如果你的系统没有多个处理器,那么多进程的实现将是不可能的。

  • 处理时间和空间:如前所述,实现一个进程及其资源涉及许多复杂的组件。因此,与使用线程相比,生成和管理进程需要大量的计算时间和计算能力。

Python 中的入门示例

为了说明在一个操作系统上运行多个进程的概念,让我们看一个 Python 的快速示例。让我们看一下Chapter06/example1.py文件,如下面的代码所示:

# Chapter06/example1.py

from multiprocessing import Process
import time

def count_down(name, delay):
    print('Process %s starting...' % name)

    counter = 5

    while counter:
        time.sleep(delay)
        print('Process %s counting down: %i...' % (name, counter))
        counter -= 1

    print('Process %s exiting...' % name)

if __name__ == '__main__':
    process1 = Process(target=count_down, args=('A', 0.5))
    process2 = Process(target=count_down, args=('B', 0.5))

    process1.start()
    process2.start()

    process1.join()
    process2.join()

    print('Done.')

在这个文件中,我们回到了在第三章中看到的倒计时示例,在 Python 中使用线程,同时我们也看一下线程的概念。我们的count_down()函数接受一个字符串作为进程标识符和一个延迟时间范围。然后它将从 5 倒数到 1,同时在每次迭代之间睡眠,睡眠时间由delay参数指定。该函数还在每次迭代时打印出带有进程标识符的消息。

正如我们在第三章中所看到的,在 Python 中使用线程,这个倒计时的例子的目的是展示同时运行不同进程的并发性质,这次是通过使用multiprocessing模块中的Process类来实现的。在我们的主程序中,我们同时初始化两个进程来同时实现两个独立的基于时间的倒计时。与两个独立的线程一样,我们的两个进程将同时进行它们自己的倒计时。

运行 Python 脚本后,你的输出应该类似于以下内容:

> python example1.py
Process A starting...
Process B starting...
Process B counting down: 5...
Process A counting down: 5...
Process B counting down: 4...
Process A counting down: 4...
Process B counting down: 3...
Process A counting down: 3...
Process B counting down: 2...
Process A counting down: 2...
Process A counting down: 1...
Process B counting down: 1...
Process A exiting...
Process B exiting...
Done.

正如我们所预期的,输出告诉我们,两个独立进程的倒计时是同时执行的;程序并不是先完成第一个进程的倒计时,然后再开始第二个进程的,而是几乎同时运行了两个倒计时。尽管进程比线程更昂贵,包含更多的开销,但多进程也允许程序的速度提高一倍,就像前面的例子一样。

请记住,在多线程中,我们看到一个现象,即程序的不同运行之间打印输出的顺序发生了变化。具体来说,有时进程 B 在倒计时期间超过进程 A 并在进程 A 之前完成,尽管它是后初始化的。这又一次是由于几乎同时执行相同函数的两个进程的实现和启动的直接结果。通过多次执行脚本,您会发现在计数和倒计时完成的顺序方面,您很可能会获得不断变化的输出。

多进程模块概述

multiprocessing模块是 Python 中最常用的多进程编程实现之一。它提供了一种类似于threading模块的 API,用于生成和与进程交互(就像我们在前面的示例中看到的start()join()方法)。根据其文档网站,该模块允许本地和远程并发,并通过使用子进程而不是线程有效地避免了 Python 中的全局解释器锁(GIL)(我们将在第十五章中更详细地讨论这一点,全局解释器锁)。

进程类

multiprocessing模块中,进程通常通过Process类生成和管理。每个Process对象代表在单独进程中执行的活动。方便的是,Process类具有与threading.Thread类中的等效方法和 API。

具体来说,利用面向对象的编程方法,multiprocessing中的Process类提供以下资源:

  • run():当初始化并启动新进程时执行此方法

  • start():此方法通过调用run()方法启动初始化的调用Process对象

  • join():此方法在继续执行程序的其余部分之前等待调用Process对象终止

  • isAlive():此方法返回一个布尔值,指示调用的Process对象当前是否正在执行

  • name:此属性包含调用Process对象的名称

  • pid:此属性包含调用Process对象的进程 ID

  • terminate():此方法终止调用的Process对象

正如您可以从我们之前的示例中看到的,初始化Process对象时,我们可以通过指定target(目标函数)和args(目标函数参数)参数向函数传递参数,并在单独的进程中执行它。请注意,也可以重写默认的Process()构造函数并实现自己的run()函数。

由于它是multiprocessing模块和 Python 中并发的主要组成部分,我们将在下一节再次查看Process类。

池类

multiprocessing模块中,Pool类主要用于实现一组进程,每个进程将执行提交给Pool对象的任务。通常,Pool类比Process类更方便,特别是如果并发应用程序返回的结果应该是有序的。

具体来说,我们已经看到,当通过函数并发地运行程序时,列表中不同项目的完成顺序很可能会发生变化。这导致在重新排序程序的输出时,难以根据产生它们的输入的顺序进行排序。其中一个可能的解决方案是创建进程和它们的输出的元组,并按进程 ID 对它们进行排序。

Pool类解决了这个问题:Pool.map()Pool.apply()方法遵循 Python 传统map()apply()方法的约定,确保返回的值按照输入的顺序排序。然而,这些方法会阻塞主程序,直到进程完成处理。因此,Pool类还具有map_async()apply_async()函数,以更好地支持并发和并行。

确定当前进程、等待和终止进程

Process类提供了一些在并发程序中轻松与进程交互的方法。在本节中,我们将探讨通过确定当前进程、等待和终止进程来管理不同进程的选项。

确定当前进程

处理进程有时会相当困难,因此需要进行重大调试。调试多进程程序的一种方法是识别遇到错误的进程。作为复习,在前面的倒计时示例中,我们向count_down()函数传递了一个name参数,以确定倒计时期间每个进程的位置。

然而,这是不必要的,因为每个Process对象都有一个name参数(带有默认值),可以进行更改。给进程命名是跟踪运行进程的更好方法,而不是将标识符传递给目标函数本身(就像我们之前做的那样),特别是在同时运行不同类型进程的应用程序中。multiprocessing模块提供的一个强大功能是current_process()方法,它将返回当前正在运行的Process对象。这是另一种有效而轻松地跟踪运行进程的方法。

让我们通过一个例子更详细地看一下。转到Chapter06/example2.py文件,如下所示的代码:

# Chapter06/example2.py

from multiprocessing import Process, current_process
import time

def f1():
    pname = current_process().name
    print('Starting process %s...' % pname)
    time.sleep(2)
    print('Exiting process %s...' % pname)

def f2():
    pname = current_process().name
    print('Starting process %s...' % pname)
    time.sleep(4)
    print('Exiting process %s...' % pname)

if __name__ == '__main__':
    p1 = Process(name='Worker 1', target=f1)
    p2 = Process(name='Worker 2', target=f2)
    p3 = Process(target=f1)

    p1.start()
    p2.start()
    p3.start()

    p1.join()
    p2.join()
    p3.join()

在这个例子中,我们有两个虚拟函数f1()f2(),每个函数在睡眠一段指定的时间后打印执行该函数的进程的名称。在我们的主程序中,我们初始化了三个单独的进程。前两个我们分别命名为Worker 1Worker 2,最后一个我们故意留空,以给它的名称默认值(即'Process-3')。运行脚本后,您应该会得到类似以下的输出:

> python example2.py
Starting process Worker 1...
Starting process Worker 2...
Starting process Process-3...
Exiting process Worker 1...
Exiting process Process-3...
Exiting process Worker 2...

我们可以看到current_process()成功帮助我们访问运行每个函数的正确进程,并且第三个进程默认分配了名称Process-3。在程序中跟踪运行进程的另一种方法是使用os模块查看各个进程的 ID。让我们看一个修改后的例子,在Chapter06/example3.py文件中,如下所示的代码:

# Chapter06/example3.py

from multiprocessing import Process, current_process
import time
import os

def print_info(title):
    print(title)

    if hasattr(os, 'getppid'):
        print('Parent process ID: %s.' % str(os.getppid()))

    print('Current Process ID: %s.\n' % str(os.getpid()))

def f():
    print_info('Function f')

    pname = current_process().name
    print('Starting process %s...' % pname)
    time.sleep(1)
    print('Exiting process %s...' % pname)

if __name__ == '__main__':
    print_info('Main program')

    p = Process(target=f)
    p.start()
    p.join()

    print('Done.')

我们这个例子的主要焦点是print_info()函数,它使用os.getpid()os.getppid()函数来使用进程 ID 标识当前进程。具体来说,os.getpid()返回当前进程的进程 ID,而os.getppid()(仅在 Unix 系统上可用)返回父进程的 ID。在运行脚本后,以下是我的输入:

> python example3.py
Main program
Parent process ID: 14806.
Current Process ID: 29010.

Function f
Parent process ID: 29010.
Current Process ID: 29012.

Starting process Process-1...
Exiting process Process-1...
Done.

进程 ID 可能因系统而异,但它们的相对关系应该是相同的。特别是对于我的输出,我们可以看到,主 Python 程序的 ID 是29010,其父进程的 ID 是14806。使用Activity Monitor,我交叉检查了这个 ID,并将其连接到我的 Terminal 和 Bash 配置文件,这是有道理的,因为我是从我的 Terminal 运行这个 Python 脚本的。您可以在以下截图中看到 Activity Monitor 中显示的结果:

使用 Activity Monitor 交叉检查 PID 的截图

除了主 Python 程序外,我们还在f()函数内调用了print_info(),其进程 ID 为29012。我们还可以看到运行f()函数的进程的父进程实际上是我们的主进程,其 ID 为29010

等待进程

通常,我们希望在移动到程序的新部分之前等待所有并发进程完成执行。如前所述,multiprocessing模块中的Process类提供了join()方法,以实现等待进程完成任务并退出的方法。

然而,有时开发人员希望实现在后台运行并且不阻止主程序退出的进程。当主程序没有简单的方法告诉它是否适合在任何给定时间中断进程,或者在退出主程序而不完成工作进程不会影响最终结果时,通常会使用这个规范。

这些进程被称为守护进程Process类还提供了一个简单的选项来通过daemon属性指定进程是否是守护进程,该属性接受一个布尔值。daemon属性的默认值是False,因此将其设置为True将使给定进程成为守护进程。让我们通过Chapter06/example4.py文件中的示例更详细地了解一下,如下所示:

# Chapter06/example4.py

from multiprocessing import Process, current_process
import time

def f1():
    p = current_process()
    print('Starting process %s, ID %s...' % (p.name, p.pid))
    time.sleep(4)
    print('Exiting process %s, ID %s...' % (p.name, p.pid))

def f2():
    p = current_process()
    print('Starting process %s, ID %s...' % (p.name, p.pid))
    time.sleep(2)
    print('Exiting process %s, ID %s...' % (p.name, p.pid))

if __name__ == '__main__':
    p1 = Process(name='Worker 1', target=f1)
    p1.daemon = True
    p2 = Process(name='Worker 2', target=f2)

    p1.start()
    time.sleep(1)
    p2.start()

在这个例子中,我们有一个长时间运行的函数(由f1()表示,其中有 4 秒的休眠时间)和一个更快的函数(由f2()表示,其中只有 2 秒的休眠时间)。我们还有两个单独的进程,如下列表所示:

  • p1进程是一个守护进程,负责运行f1()

  • p2进程是一个常规进程,负责运行f2()

在我们的主程序中,我们启动了这两个进程,但在程序结束时没有调用join()方法。由于p1是一个长时间运行的进程,它很可能在p2(两者中更快的进程)完成之前不会执行完。我们也知道p1是一个守护进程,所以我们的程序应该在它执行完之前退出。运行 Python 脚本后,你的输出应该类似于以下代码:

> python example4.py
Starting process Worker 1, ID 33784...
Starting process Worker 2, ID 33788...
Exiting process Worker 2, ID 33788...

再次强调,即使当您自己运行脚本时,进程 ID 可能会有所不同,但输出的一般格式应该是相同的。正如我们所看到的,输出与我们讨论的内容一致:我们的主程序初始化并启动了p1p2进程,并且在非守护进程退出后立即终止了程序,而不等待守护进程完成。

能够在不等待守护进程处理特定任务的情况下终止主程序的能力确实非常有用。然而,有时我们可能希望在退出之前等待守护进程一段指定的时间;这样,如果程序的规格允许等待进程执行一段时间,我们可以完成一些潜在的守护进程,而不是过早地终止它们。

守护进程和multiprocessing模块中的join()方法的结合可以帮助我们实现这种架构,特别是考虑到,虽然join()方法会无限期地阻塞程序执行(或者至少直到任务完成),但也可以传递一个超时参数来指定在退出之前等待进程的秒数。让我们考虑Chapter06/example5.py中前一个例子的修改版本。使用相同的f1()f2()函数,在下面的脚本中,我们改变了主程序中处理守护进程的方式:

# Chapter06/example5.py

if __name__ == '__main__':
    p1 = Process(name='Worker 1', target=f1)
    p1.daemon = True
    p2 = Process(name='Worker 2', target=f2)

    p1.start()
    time.sleep(1)
    p2.start()

    p1.join(1)
    print('Whether Worker 1 is still alive:', p1.is_alive())
    p2.join()

在这个例子中,我们不是在等待守护进程而是调用了join()方法来等待两个进程:我们允许p1在一秒内完成,同时阻塞主程序直到p2完成。如果p1在一秒后仍未执行完,主程序将继续执行其余部分并退出,这时我们会看到p1—或Worker 1—仍然活着。运行 Python 脚本后,你的输出应该类似于以下内容:

> python example5.py
Starting process Worker 1, ID 36027...
Starting process Worker 2, ID 36030...
Whether Worker 1 is still alive: True
Exiting process Worker 2, ID 36030...

我们看到p1在等待一秒后确实还活着。

终止进程

multiprocessing.Process类中的terminate()方法提供了一种快速终止进程的方式。当调用该方法时,Process类或重写类中指定的退出处理程序、最终原因或类似资源将不会被执行。然而,终止进程的后代进程不会被终止。这些进程被称为孤立进程

虽然有时终止进程会受到指责,但有时是必要的,因为某些进程与进程间通信资源(如锁、信号量、管道或队列)交互,强行停止这些进程可能导致这些资源变得损坏或对其他进程不可用。然而,如果程序中的进程从未与上述资源交互,terminate()方法是非常有用的,特别是如果一个进程看起来无响应或死锁。

使用terminate()方法时需要注意的一点是,即使在调用该方法后Process对象被有效地终止,也很重要的是你也要在对象上调用join()。由于Process对象的alive状态有时在terminate()方法后不会立即更新,这种做法给了后台系统一个机会来实现更新以反映进程的终止。

进程间通信

虽然锁是用于线程间通信的最常见的同步原语之一,但管道和队列是不同进程之间通信的主要方式。具体来说,它们提供了消息传递选项,以促进进程之间的通信——管道用于连接两个进程,队列用于多个生产者和消费者。

在本节中,我们将探讨队列的使用,特别是multiprocessing模块中的Queue类。Queue类的实现实际上既是线程安全的,也是进程安全的,我们已经在第三章中看到了队列的使用,在 Python 中使用线程。Python 中的所有可 pickle 对象都可以通过Queue对象传递;在本节中,我们将使用队列在进程之间来回传递消息。

使用消息队列进行进程间通信比使用共享资源更可取,因为如果某些进程在共享资源时处理不当并损坏了共享内存和资源,那么将会产生许多不良和不可预测的后果。然而,如果一个进程未能正确处理其消息,队列中的其他项目将保持完好。以下图表示了使用消息队列和共享资源(特别是内存)进行进程间通信的架构之间的差异:

使用消息队列和共享资源进行进程间通信的架构

单个工作进程的消息传递

在我们深入讨论 Python 中的示例代码之前,首先我们需要具体讨论如何在我们的多进程应用程序中使用Queue对象。假设我们有一个执行大量计算且不需要大量资源共享和通信的worker类。然而,这些工作者实例仍然需要能够在执行过程中不时接收信息。

这就是队列的使用方式:当我们将所有工作者放入队列时。同时,我们还将有一些初始化的进程,每个进程都将遍历该队列并处理一个工作者。如果一个进程已经执行完一个工作者,并且队列中仍有其他工作者,它将继续执行另一个工作者。回顾之前的图表,我们可以看到有两个单独的进程不断地从队列中取出并执行消息。

Queue对象中,我们将使用以下列表中显示的两种主要方法:

  • get(): 这个方法返回调用的Queue对象中的下一个项目

  • put(): 这个方法将传递给它的参数作为额外项目添加到调用的Queue对象中

让我们看一个示例脚本,展示了在 Python 中使用队列。转到并打开Chapter06/example6.py文件,如下面的代码所示:

# Chapter06/example6.py

import multiprocessing

class MyWorker():
    def __init__(self, x):
        self.x = x

    def process(self):
        pname = multiprocessing.current_process().name
        print('Starting process %s for number %i...' % (pname, self.x))

def work(q):
    worker = q.get()
    worker.process()

if __name__ == '__main__':
    my_queue = multiprocessing.Queue()

    p = multiprocessing.Process(target=work, args=(my_queue,))
    p.start()

    my_queue.put(MyWorker(10))

    my_queue.close()
    my_queue.join_thread()
    p.join()

    print('Done.')

在此脚本中,我们有一个MyWorker类,它接受一个x参数并对其进行计算(目前只会打印出数字)。在我们的主函数中,我们从multiprocessing模块初始化了一个Queue对象,并添加了一个带有数字10MyWorker对象。我们还有work()函数,当被调用时,将从队列中获取第一个项目并处理它。最后,我们有一个任务是调用work()函数的进程。

该结构旨在将消息传递给一个单一进程,即一个MyWorker对象。然后主程序等待进程完成执行。运行脚本后,您的输出应类似于以下内容:

> python example6.py
Starting process Process-1 for number 10...
Done.

多个工作者之间的消息传递

如前所述,我们的目标是有一个结构,其中有几个进程不断地执行队列中的工作者,并且如果一个进程完成执行一个工作者,那么它将继续执行另一个。为此,我们将利用Queue的一个子类JoinableQueue,它将提供额外的task_done()join()方法,如下列表所述:

  • task_done(): 这个方法告诉程序调用的JoinableQueue对象已经完成

  • join(): 这个方法阻塞,直到调用的JoinableQueue对象中的所有项目都已被处理

现在,这里的目标是有一个JoinableQueue对象,其中包含所有要执行的任务,我们将其称为任务队列,并且有一些进程。只要任务队列中有项目(消息),进程就会轮流执行这些项目。我们还将有一个Queue对象来存储从进程返回的所有结果,我们将其称为结果队列。

转到Chapter06/example7.py文件,并查看Consumer类和Task类,如下面的代码所示:

# Chapter06/example7.py

from math import sqrt
import multiprocessing

class Consumer(multiprocessing.Process):

    def __init__(self, task_queue, result_queue):
        multiprocessing.Process.__init__(self)
        self.task_queue = task_queue
        self.result_queue = result_queue

    def run(self):
        pname = self.name

        while not self.task_queue.empty():

            temp_task = self.task_queue.get()

            print('%s processing task: %s' % (pname, temp_task))

            answer = temp_task.process()
            self.task_queue.task_done()
            self.result_queue.put(answer)

class Task():
    def __init__(self, x):
        self.x = x

    def process(self):
        if self.x < 2:
            return '%i is not a prime number.' % self.x

        if self.x == 2:
            return '%i is a prime number.' % self.x

        if self.x % 2 == 0:
            return '%i is not a prime number.' % self.x

        limit = int(sqrt(self.x)) + 1
        for i in range(3, limit, 2):
            if self.x % i == 0:
                return '%i is not a prime number.' % self.x

        return '%i is a prime number.' % self.x

    def __str__(self):
        return 'Checking if %i is a prime or not.' % self.x

Consumer类是multiprocessing.Process类的一个重写子类,是我们的处理逻辑,它接受一个任务队列和一个结果队列。每个Consumer对象启动时,将获取其任务队列中的下一个项目,执行它,最后调用task_done()并将返回的结果放入其结果队列。任务队列中的每个项目依次由Task类表示,其主要功能是对其x参数进行素数检查。当Consumer类的一个实例与Task类的一个实例交互时,它还会打印出一个帮助消息,以便我们轻松跟踪哪个消费者正在执行哪个任务。

让我们继续考虑我们的主程序,如下面的代码所示:

# Chapter06/example7.py

if __name__ == '__main__':
    tasks = multiprocessing.JoinableQueue()
    results = multiprocessing.Queue()

    # spawning consumers with respect to the
    # number cores available in the system
    n_consumers = multiprocessing.cpu_count()
    print('Spawning %i consumers...' % n_consumers)
    consumers = [Consumer(tasks, results) for i in range(n_consumers)]
    for consumer in consumers:
        consumer.start()

    # enqueueing jobs
    my_input = [2, 36, 101, 193, 323, 513, 1327, 100000, 9999999, 433785907]
    for item in my_input:
        tasks.put(Task(item))

    tasks.join()

    for i in range(len(my_input)):
        temp_result = results.get()
        print('Result:', temp_result)

    print('Done.')

正如我们之前所说,我们在主程序中创建了一个任务队列和一个结果队列。我们还创建了一个Consumer对象的列表,并启动了它们所有;创建的进程数量与系统中可用的 CPU 数量相对应。接下来,从一个需要从Task类中进行大量计算的输入列表中,我们用每个输入初始化一个Task对象,并将它们全部放入任务队列。此时,我们的进程——我们的Consumer对象——将开始执行这些任务。

最后,在我们的主程序的末尾,我们调用join()在我们的任务队列上,以确保所有项目都已执行,并通过循环遍历我们的结果队列打印出结果。运行脚本后,你的输出应该类似于以下内容:

> python example7.py
Spawning 4 consumers...
Consumer-3 processing task: Checking if 2 is a prime or not.
Consumer-2 processing task: Checking if 36 is a prime or not.
Consumer-3 processing task: Checking if 101 is a prime or not.
Consumer-2 processing task: Checking if 193 is a prime or not.
Consumer-3 processing task: Checking if 323 is a prime or not.
Consumer-2 processing task: Checking if 1327 is a prime or not.
Consumer-3 processing task: Checking if 100000 is a prime or not.
Consumer-4 processing task: Checking if 513 is a prime or not.
Consumer-3 processing task: Checking if 9999999 is a prime or not.
Consumer-2 processing task: Checking if 433785907 is a prime or not.
Result: 2 is a prime number.
Result: 36 is not a prime number.
Result: 193 is a prime number.
Result: 101 is a prime number.
Result: 323 is not a prime number.
Result: 1327 is a prime number.
Result: 100000 is not a prime number.
Result: 9999999 is not a prime number.
Result: 513 is not a prime number.
Result: 433785907 is a prime number.
Done.

一切似乎都在运行,但是如果我们仔细看一下我们的进程打印出来的消息,我们会注意到大多数任务是由Consumer-2Consumer-3执行的,而Consumer-4只执行了一个任务,而Consumer-1则未执行任何任务。这里发生了什么?

基本上,当我们的一个消费者——比如Consumer-3——完成执行一个任务后,它会立即尝试寻找另一个任务来执行。大多数情况下,它会优先于其他消费者,因为它已经被主程序运行。因此,虽然Consumer-2Consumer-3不断完成它们的任务执行并拾取其他任务来执行,Consumer-4只能“挤”自己进来一次,而Consumer-1则根本无法做到这一点。

当一遍又一遍地运行脚本时,你会注意到一个类似的趋势:大多数任务只由一个或两个消费者执行,而其他消费者未能做到这一点。对我们来说,这种情况是不可取的,因为程序没有利用在程序开始时创建的所有可用进程。

为了解决这个问题,已经开发了一种技术,用于阻止消费者立即从任务队列中取下下一个项目,称为毒丸。其想法是,在设置任务队列中的真实任务之后,我们还添加包含“停止”值的虚拟任务,并且当前消费者将保持并允许其他消费者先获取任务队列中的下一个项目;因此得名“毒丸”。

为了实现这一技术,我们需要在主程序的特殊对象中添加我们的tasks值,每个消费者一个。此外,在我们的Consumer类中,还需要实现处理这些特殊对象的逻辑。让我们看一下example8.py文件(前一个示例的修改版本,包含毒丸技术的实现),特别是Consumer类和主程序,如下面的代码所示:

# Chapter06/example8.py

class Consumer(multiprocessing.Process):

    def __init__(self, task_queue, result_queue):
        multiprocessing.Process.__init__(self)
        self.task_queue = task_queue
        self.result_queue = result_queue

    def run(self):
        pname = self.name

        while True:
            temp_task = self.task_queue.get()

            if temp_task is None:
                print('Exiting %s...' % pname)
                self.task_queue.task_done()
                break

            print('%s processing task: %s' % (pname, temp_task))

            answer = temp_task.process()
            self.task_queue.task_done()
            self.result_queue.put(answer)

class Task():
    def __init__(self, x):
        self.x = x

    def process(self):
        if self.x < 2:
            return '%i is not a prime number.' % self.x

        if self.x == 2:
            return '%i is a prime number.' % self.x

        if self.x % 2 == 0:
            return '%i is not a prime number.' % self.x

        limit = int(sqrt(self.x)) + 1
        for i in range(3, limit, 2):
            if self.x % i == 0:
                return '%i is not a prime number.' % self.x

        return '%i is a prime number.' % self.x

    def __str__(self):
        return 'Checking if %i is a prime or not.' % self.x

if __name__ == '__main__':

    tasks = multiprocessing.JoinableQueue()
    results = multiprocessing.Queue()

    # spawning consumers with respect to the
    # number cores available in the system
    n_consumers = multiprocessing.cpu_count()
    print('Spawning %i consumers...' % n_consumers)
    consumers = [Consumer(tasks, results) for i in range(n_consumers)]
    for consumer in consumers:
        consumer.start()

    # enqueueing jobs
    my_input = [2, 36, 101, 193, 323, 513, 1327, 100000, 9999999, 433785907]
    for item in my_input:
        tasks.put(Task(item))

    for i in range(n_consumers):
        tasks.put(None)

    tasks.join()

    for i in range(len(my_input)):
        temp_result = results.get()
        print('Result:', temp_result)

    print('Done.')

Task类与我们之前的示例相同。我们可以看到我们的毒丸是None值:在主程序中,我们向任务队列中添加了与我们生成的消费者数量相等的None值;在Consumer类中,如果要执行的当前任务包含值None,那么该类对象将打印出指示毒丸的消息,调用task_done()并退出。

运行脚本;你的输出应该类似于以下内容:

> python example8.py
Spawning 4 consumers...
Consumer-1 processing task: Checking if 2 is a prime or not.
Consumer-2 processing task: Checking if 36 is a prime or not.
Consumer-3 processing task: Checking if 101 is a prime or not.
Consumer-4 processing task: Checking if 193 is a prime or not.
Consumer-1 processing task: Checking if 323 is a prime or not.
Consumer-2 processing task: Checking if 513 is a prime or not.
Consumer-3 processing task: Checking if 1327 is a prime or not.
Consumer-1 processing task: Checking if 100000 is a prime or not.
Consumer-2 processing task: Checking if 9999999 is a prime or not.
Consumer-3 processing task: Checking if 433785907 is a prime or not.
Exiting Consumer-1...
Exiting Consumer-2...
Exiting Consumer-4...
Exiting Consumer-3...
Result: 2 is a prime number.
Result: 36 is not a prime number.
Result: 323 is not a prime number.
Result: 101 is a prime number.
Result: 513 is not a prime number.
Result: 1327 is a prime number.
Result: 100000 is not a prime number.
Result: 9999999 is not a prime number.
Result: 193 is a prime number.
Result: 433785907 is a prime number.
Done.

这一次,除了看到毒丸消息被打印出来之外,输出还显示了在哪个消费者执行了哪个任务方面的显着改善分布。

摘要

在计算机科学领域,进程是操作系统正在执行的特定计算机程序或软件的实例。进程包含程序代码及其当前活动和与其他实体的交互。在同一个进程中可以实现多个线程来访问和共享内存或其他资源,而不同的进程不以这种方式进行交互。

在并发和并行的背景下,多进程指的是从操作系统中执行多个并发进程,其中每个进程在单独的 CPU 上执行,而不是在任何给定时间执行单个进程。Python 中的multiprocessing模块提供了一个强大而灵活的 API,用于生成和管理多进程应用程序。它还允许通过Queue类进行复杂的进程间通信技术。

在下一章中,我们将讨论 Python 的更高级功能——归约操作——以及它在多进程编程中的支持。

问题

  • 什么是进程?进程和线程之间的核心区别是什么?

  • 什么是多进程?多进程和多线程之间的核心区别是什么?

  • multiprocessing模块提供了哪些 API 选项?

  • Process类和Pool类在multiprocessing模块中的核心区别是什么?

  • 在 Python 程序中确定当前进程的选项有哪些?

  • 在多进程程序中,守护进程是什么?它们在等待进程方面有什么目的?

  • 如何终止一个进程?为什么有时终止进程是可以接受的?

  • 在 Python 中促进进程间通信的一种方式是什么?

进一步阅读

有关更多信息,您可以参考以下链接:

  • Python 并行编程食谱,作者 Giancarlo Zaccone,Packt Publishing Ltd(2015 年)。

  • “学习 Python 并发:构建高效、健壮和并发的应用程序”,Elliot Forbes(2017 年)。

  • Python 本周模块。“进程间通信”(pymotw.com/2/multiprocessing/communication.html)。这包含了您可以用来识别当前进程的函数。

第七章:进程中的减少运算符

减少运算符的概念——其中数组的许多或所有元素被减少为一个单一结果——与并发和并行编程密切相关。具体来说,由于运算符的结合和交换性质,可以应用并发和并行性来大大提高它们的执行时间。

本章讨论了从程序员和开发人员的角度设计和编写减少运算符的理论并发方法。从这里开始,本章还将建立与可以以类似方式使用并发性解决的类似问题的联系。

本章将涵盖以下主题:

  • 计算机科学中的减少运算符的概念

  • 减少运算符的交换和结合属性,以及并发可以应用的原因

  • 如何识别与减少运算符等价的问题,以及如何在这种情况下应用并发编程

技术要求

以下是本章的先决条件列表:

减少运算符的概念

作为经验丰富的程序员,您无疑遇到过需要计算数组中所有数字的和或乘积,或者计算将AND运算符应用于数组的所有布尔元素以查看该数组中是否存在任何假值的情况。这些被称为减少运算符,它们接受一组或一个元素数组,并执行某种形式的计算,以返回一个单一的结果。

减少运算符的属性

并非每个数学或计算机科学运算符都是减少运算符。事实上,即使一个运算符能够将一个元素数组减少为一个单一值,也不能保证它是一个减少运算符。如果运算符满足以下条件,则运算符是减少运算符:

  • 操作员可以将一个元素数组减少为一个标量值

  • 最终结果(标量值)必须通过创建和计算部分任务来获得

第一个条件表明了“减少运算符”这个短语,因为输入数组的所有元素都必须被组合并减少为一个单一的值。然而,第二个条件本质上是关于并发和并行性。它要求任何减少运算符的计算都能够被分解为较小的部分计算。

首先,让我们考虑最常见的减少运算符之一:加法。例如,考虑输入数组[1, 4, 8, 3, 2, 5]的元素之和如下:

1 + 4 + 8 + 3 + 2 + 5
= ((((1 + 4) + 8) + 3) + 2) + 5
= (((5 + 8) + 3) + 2) + 5
= ((13 + 3) + 2) + 5
= (16 + 2) + 5
= 18 + 5
= 23

在前面的计算中,我们按顺序将数组中的数字减少到它们的总和23。换句话说,我们从数组的开头到结尾遍历了每个元素,并添加了当前的总和。现在,我们知道加法是一个可交换和可结合的运算符,这意味着:a + b = b + a(a + b) + c = a + (b + c)

因此,我们可以通过将前面的计算分解为更高效的方式来进行更高效的计算:

1 + 4 + 8 + 3 + 2 + 5
= ((1 + 4) + (8 + 3)) + (2 + 5)
= (5 + 11) + 7
= 16 + 7
= 23

这种技术是应用并发和并行(特别是多进程)到减少运算符的核心。通过将整个任务分解为较小的子任务,多个进程可以同时执行这些小计算,整个系统可以更快地得出结果。

出于同样的原因,交换性和结合性属性被认为等同于我们之前讨论的减法运算符的要求。换句话说,运算符  是一个具有交换性和结合性的减法运算符。具体如下:

  • 交换性: b = b  a

  • 结合性:(a  b)  c = a  (b  c)

这里的 abc 是输入数组的元素。

因此,如果一个运算符是减法运算符,它必须是交换性和结合性的,因此具有将大任务分解为更小、更易管理的子任务的能力,可以使用多进程以更有效的方式进行计算。

示例和非示例

到目前为止,我们已经看到加法是减法运算符的一个例子。要将加法作为减法运算符执行,我们首先将输入数组的元素分成两组,每组都是我们的子任务之一。然后我们对每组进行加法运算,取得每组的加法结果,再将它们分成两组。

这个过程一直持续到得到一个单一的数字。这个过程遵循一个叫做二叉树减法的模型,它利用两个元素组成子任务:

二叉树减法加法图

在前面的例子中,对数组 [1, 4, 8, 3, 2, 5] 进行分组(1 和 4,8 和 3,2 和 5),我们使用三个独立的过程将数字对相加。然后我们得到数组 [5, 11, 7],我们用一个过程得到 [16, 7],再用另一个过程最终得到 23。因此,使用三个或更多个 CPU,六个元素的加法运算可以在 log[2]6 = 3 步内完成,而不是顺序加法的五步。

其他常见的减法运算符示例包括乘法和逻辑 AND。例如,使用乘法作为减法运算符对相同的数字数组 [1, 4, 8, 3, 2, 5] 进行减法运算如下:

1 x 4 x 8 x 3 x 2 x 5
= ((1 x 4) x (8 x 3)) x (2 x 5)
= (4 x 24) x 10
= 96 x 10
= 960

例如,对布尔值数组进行减法(TrueFalseFalseTrue),使用逻辑 AND 运算符,我们可以这样做:

True AND False AND False AND True
= (True AND False) AND (False AND True)
= False AND False
= False

减法运算符的非示例是幂函数,因为改变计算顺序会改变最终结果(即,该函数不是交换性的)。例如,顺序减法数组 [2, 1, 2] 将给出以下结果:

2 ^ 1 ^ 2 = 2 ^ (1 ^ 2) = 2 ^ 1 = 2

如果我们改变操作顺序如下:

(2 ^ 1) ^ 2 = 2 ^ 2 = 4

我们将得到一个不同的值。因此,幂函数不是一个减法运算。

Python 中的示例实现

正如我们之前提到的,由于它们的交换性和结合性属性,减法运算符可以独立创建和处理它们的部分任务,这就是并发可以应用的地方。要真正理解减法运算符如何利用并发,让我们尝试从头开始实现一个并发的多进程减法运算符,具体来说是加法运算符。

与前一章中看到的类似,在这个例子中,我们将使用任务队列和结果队列来促进进程间通信。具体来说,程序将把输入数组中的所有数字存储在任务队列中作为单独的任务。每当我们的消费者(单独的进程)执行时,它将在任务队列上调用 get() 两次 来获取两个任务数字(除了一些边缘情况,任务队列中没有或只剩下一个数字),将它们相加,并将结果放入结果队列。

与在上一节中所做的一样,通过迭代任务队列一次并将添加的任务数字对放入结果队列后,输入数组中的元素数量将减少一半。例如,输入数组[1, 4, 8, 3, 2, 5]将变为[5, 11, 7]

现在,我们的程序将把新的任务队列分配为结果队列(因此,在这个例子中,[5, 11, 7]现在是新的任务队列),我们的进程将继续遍历它并将数字对相加以生成新的结果队列,这将成为下一个任务队列。这个过程重复进行,直到结果队列只包含一个元素,因为我们知道这个单个数字是原始输入数组中数字的总和。

下面的图表显示了处理输入数组[1, 4, 8, 3, 2, 5]的每次迭代中任务队列和结果队列的变化;当结果队列只包含一个数字(23)时,进程停止:

多进程加法运算符的示例图表

让我们来看一下Chapter07/example1.py文件中的ReductionConsumer类:

# Chapter07/example1.py

class ReductionConsumer(multiprocessing.Process):

    def __init__(self, task_queue, result_queue):
        multiprocessing.Process.__init__(self)
        self.task_queue = task_queue
        self.result_queue = result_queue

    def run(self):
        pname = self.name
        print('Using process %s...' % pname)

        while True:
            num1 = self.task_queue.get()
            if num1 is None:
                print('Exiting process %s.' % pname)
                self.task_queue.task_done()
                break

            self.task_queue.task_done()
            num2 = self.task_queue.get()
            if num2 is None:
                print('Reaching the end with process %s and number 
                      %i.' % (pname, num1))
                self.task_queue.task_done()
                self.result_queue.put(num1)
                break

            print('Running process %s on numbers %i and %i.' % (
                    pname, num1, num2))
            self.task_queue.task_done()
            self.result_queue.put(num1 + num2)

我们通过重写multiprocessing.Process类来实现ReductionConsumer类。这个消费者类在初始化时接受一个任务队列和一个结果队列,并处理程序的消费者进程逻辑,调用任务队列上的get()两次来从队列中获取两个数字,并将它们的和添加到结果队列中。

在执行这个过程的同时,ReductionConsumer类还处理了任务队列中没有或只剩下一个数字的情况(也就是说,当num1num2变量为None时,这是我们在上一章中知道的用来表示毒丸的方式)。

另外,回想一下,multiprocessing模块的JoinableQueue类用于实现我们的任务队列,并且在每次调用get()函数后需要调用task_done()函数,否则我们稍后将在任务队列上调用的join()函数将无限期地阻塞。因此,在消费者进程调用get()两次的情况下,重要的是在当前任务队列上调用两次task_done(),而当我们只调用一次get()(当第一个数字是毒丸时),那么我们应该只调用一次task_done()。这是在处理多进程通信的程序时需要考虑的更复杂的问题之一。

为了处理和协调不同的消费者进程,以及在每次迭代后操作任务队列和结果队列,我们有一个名为reduce_sum()的单独函数:

def reduce_sum(array):
    tasks = multiprocessing.JoinableQueue()
    results = multiprocessing.JoinableQueue()
    result_size = len(array)

    n_consumers = multiprocessing.cpu_count()

    for item in array:
        results.put(item)

    while result_size > 1:
        tasks = results
        results = multiprocessing.JoinableQueue()

        consumers = [ReductionConsumer(tasks, results) 
                     for i in range(n_consumers)]
        for consumer in consumers:
            consumer.start()

        for i in range(n_consumers):
            tasks.put(None)

        tasks.join()
        result_size = result_size // 2 + (result_size % 2)
        #print('-' * 40)

    return results.get()

这个函数接受一个 Python 数字列表来计算其元素的总和。除了任务队列和结果队列之外,该函数还跟踪另一个名为result_size的变量,该变量表示当前结果队列中的元素数量。

在初始化其基本变量之后,该函数在一个 while 循环中生成其消费者进程以减少当前任务队列。正如我们之前讨论的,在 while 循环的每次迭代中,任务队列中的元素会成对相加,然后将添加的结果存储在结果队列中。之后,任务队列将接管该结果队列的元素,并向队列中添加额外的None值以实现毒丸技术。

在每次迭代中,还会初始化一个新的空结果队列作为JoinableQueue对象——这与我们在上一章中用于结果队列的multiprocessing.Queue类不同,因为我们将在下一次迭代开始时分配tasks = results,任务队列需要是一个JoinableQueue对象。

我们还在每次迭代结束时更新result_size的值,通过result_size = result_size // 2 + (result_size % 2)。这里需要注意的是,虽然JoinableQueue类的qsize()方法是跟踪其对象的长度(即JoinableQueue对象中的元素数量)的一种潜在方法,但由于各种原因,这种方法通常被认为是不可靠的,甚至在 Unix 操作系统中也没有实现。

由于我们可以轻松预测输入数组中剩余数字的数量在每次迭代后的变化(如果是偶数,则减半,否则通过整数除法减半,然后加1到结果),我们可以使用一个名为result_size的单独变量来跟踪该数字。

至于我们这个例子的主程序,我们只需将 Python 列表传递给reduce_sum()函数。在这里,我们正在将 0 到 19 的数字相加:

my_array = [i for i in range(20)]

result = reduce_sum(my_array)
print('Final result: %i.' % result)

运行脚本后,您的输出应该类似于以下内容:

> python example1.py
Using process ReductionConsumer-1...
Running process ReductionConsumer-1 on numbers 0 and 1.
Using process ReductionConsumer-2...
Running process ReductionConsumer-2 on numbers 2 and 3.
Using process ReductionConsumer-3...

[...Truncated for readability..]

Exiting process ReductionConsumer-17.
Exiting process ReductionConsumer-18.
Exiting process ReductionConsumer-19.
Using process ReductionConsumer-20...
Exiting process ReductionConsumer-20.
Final result: 190.

并发缩减运算符的现实应用

缩减运算符处理其数据的交际和结合性质使得运算符的子任务能够独立处理,并且与并发和并行性高度相关。因此,并发编程中的各种主题可以与缩减运算符相关,并且通过应用缩减运算符的相同原则,可以使涉及这些主题的问题更加直观和高效。

正如我们所见,加法和乘法运算符都是缩减运算符。更一般地说,通常涉及交际和结合运算符的数值计算问题是应用并发和并行性的主要候选对象。这实际上是 Python 中最著名的、可能是最常用的模块之一—NumPy 的真实情况,其代码被实现为尽可能可并行化。

此外,将逻辑运算符 AND、OR 或 XOR 应用于布尔值数组的方式与缩减运算符的工作方式相同。一些并发位缩减运算符的真实应用包括以下内容:

  • 有限状态机通常在处理逻辑门时利用逻辑运算符。有限状态机可以在硬件结构和软件设计中找到。

  • 跨套接字/端口的通信通常涉及奇偶校验位和停止位来检查数据错误,或者流控制算法。这些技术利用单个字节的逻辑值通过逻辑运算符处理信息。

  • 压缩和加密技术严重依赖于位算法。

总结

在 Python 中实现多进程缩减运算符时需要仔细考虑,特别是如果程序利用任务队列和结果队列来促进消费者进程之间的通信。

各种现实世界问题的操作类似于缩减运算符,并且对于这些问题使用并发和并行性可以极大地提高程序处理它们的效率和生产力。因此,重要的是能够识别这些问题,并与缩减运算符的概念联系起来来实现它们的解决方案。

在下一章中,我们将讨论 Python 中多进程程序的一个特定的现实应用:图像处理。我们将介绍图像处理背后的基本思想,以及并发(特别是多进程)如何应用于图像处理应用程序。

问题

  • 什么是缩减运算符?必须满足什么条件才能使运算符成为缩减运算符?

  • 缩减运算符具有与所需条件等价的什么属性?

  • 缩减运算符与并发编程之间的联系是什么?

  • 在使用 Python 进行进程间通信的多处理程序中,必须考虑哪些因素?

  • 并发减少运算符的一些真实应用是什么?

进一步阅读

更多信息,请参考以下链接:

  • Python 并行编程食谱,Giancarlo Zaccone,Packt Publishing Ltd,2015

  • 学习 Python 并发:构建高效、健壮和并发的应用程序,Elliot Forbes (2017)

  • OpenMP 中的并行编程,Morgan Kaufmann,Chandra, Rohit (2001)

  • 并行多核架构基础,Yan Solihin (2016),CRC Press

第八章:并发图像处理

本章分析了通过并发编程,特别是多进程处理图像的处理和操作过程。由于图像是相互独立处理的,因此并发编程可以显著加快图像处理的速度。本章讨论了图像处理技术背后的基础知识,说明了并发编程提供的改进,并最终总结了图像处理应用中使用的一些最佳实践。

本章将涵盖以下主题:

  • 图像处理背后的理念和一些基本的图像处理技术

  • 如何将并发应用于图像处理,以及如何分析它提供的改进

  • 并发图像处理的最佳实践

技术要求

以下是本章的先决条件列表:

图像处理基础知识

数字/计算图像处理(我们将在此后简称为图像处理)在现代时代变得如此受欢迎,以至于它存在于我们日常生活的许多方面。当您使用不同的滤镜使用相机或手机拍照时,涉及图像处理和操作,或者使用 Adobe Photoshop 等高级图像编辑软件时,甚至只是使用 Microsoft Paint 编辑图像时。

图像处理中使用的许多技术和算法是在 1960 年代初为各种目的开发的,如医学成像、卫星图像分析、字符识别等。然而,这些图像处理技术需要大量的计算能力,当时可用的计算机设备无法满足快速计算的需求,这减缓了图像处理的使用。

快进到未来,在那里拥有快速、多核处理器的强大计算机被开发出来,图像处理技术因此变得更加易于访问,并且图像处理的研究显著增加。如今,正在积极开发和研究许多图像处理应用,包括模式识别、分类、特征提取等。利用并发和并行编程的特定图像处理技术,否则将极其耗时的包括隐马尔可夫模型、独立成分分析,甚至新兴的神经网络模型:

图像处理的一个示例用途:灰度处理

Python 作为图像处理工具

正如我们在本书中多次提到的,Python 编程语言正在成为最受欢迎的编程语言。这在计算图像处理领域尤其如此,大多数时候需要快速原型设计和设计,以及显著的自动化能力。

正如我们将在下一节中发现的那样,数字图像以二维和三维矩阵表示,以便计算机可以轻松处理它们。因此,大多数时候,数字图像处理涉及矩阵计算。多个 Python 库和模块不仅提供了高效的矩阵计算选项,而且与处理图像读取/写入的其他库无缝交互。

正如我们已经知道的,自动化任务并使其并发都是 Python 的强项。这使得 Python 成为实现图像处理应用程序的首选候选。在本章中,我们将使用两个主要的 Python 库:OpenCV(代表开源计算机视觉),这是一个提供 C++、Java 和 Python 图像处理和计算机视觉选项的库,以及 NumPy,正如我们所知,它是最受欢迎的 Python 模块之一,可以执行高效和可并行化的数值计算。

安装 OpenCV 和 NumPy

要使用pip软件包管理器为您的 Python 发行版安装 NumPy,请运行以下命令:

pip install numpy

然而,如果您使用 Anaconda/Miniconda 来管理您的软件包,请运行以下命令:

conda install numpy

安装 OpenCV 可能更复杂,这取决于您的操作系统。最简单的选择是使用 Anaconda 处理安装过程,按照此指南进行操作(anaconda.org/conda-forge/opencv),在安装 Anaconda(www.anaconda.com/download/)后作为您的主要 Python 包管理器。然而,如果您没有使用 Anaconda,安装 OpenCV 的主要选项是按照其官方文档指南进行操作,该指南可以在docs.opencv.org/master/df/d65/tutorial_table_of_content_introduction.html找到。成功安装 OpenCV 后,打开 Python 解释器并尝试导入库,如下所示:

>>> import cv2
>>> print(cv2.__version__)
3.1.0

我们使用名称cv2导入 OpenCV,这是 Python 中 OpenCV 的库别名。成功消息表示已下载的 OpenCV 库版本(3.1.0)。

计算机图像基础

在我们开始处理和操作数字图像文件之前,我们首先需要讨论这些文件的基础知识,以及计算机如何解释其中的数据。具体来说,我们需要了解图像文件中单个像素的颜色和坐标数据是如何表示的,以及如何使用 Python 提取它。

RGB 值

RGB 值是数字表示颜色的基础。绿代表RGB值,这是因为所有颜色都可以通过红、绿和蓝的特定组合生成。因此,RGB 值是由三个整数构成的元组,每个整数的取值范围从 0(表示没有颜色)到 255(表示该特定颜色的最深色调)。

例如,红色对应元组(255, 0, 0);在元组中,只有红色的最高值,其他颜色没有值,因此整个元组代表纯红色。类似地,蓝色由(0, 0, 255)表示,绿色由(0, 255, 0)表示。黄色是将红色和绿色混合相等量得到的结果,因此由(255, 255, 0)表示(最大量的红色和绿色,没有蓝色)。白色是三种颜色的组合,为(255, 255, 255),而黑色是白色的相反,因此缺乏所有颜色,表示为(0, 0, 0)。

RGB 值基础

像素和图像文件

因此,RGB 值表示特定颜色,但我们如何将其与计算机图像连接起来呢?如果我们在计算机上查看图像并尝试尽可能放大,我们会观察到随着放大的深入,图像将开始分解为越来越可辨认的彩色方块——这些方块称为像素,在计算机显示器或数字图像中是最小的颜色单位:

数字图像中的像素示例

以表格格式排列的一组不同像素(像素的行和列)组成了一幅计算机图像。每个像素,反过来,是一个 RGB 值;换句话说,一个像素是一个由三个整数组成的元组。这意味着计算机图像只是一个由元组组成的二维数组,其大小对应于图像的尺寸。例如,一个 128 x 128 的图像有 128 行和 128 列的 RGB 元组作为其数据。

图像内的坐标

与二维数组的索引类似,数字图像像素的坐标是一对整数,表示该像素的xy坐标;x坐标表示像素沿水平轴从左侧开始的位置,y坐标表示像素沿垂直轴从顶部开始的位置。

在这里,我们可以看到在图像处理时通常涉及到大量的计算数值过程,因为每个图像都是一个整数元组的矩阵。这也表明,借助 NumPy 库和并发编程,我们可以在 Python 图像处理应用程序的执行时间上实现显著的改进。

遵循 NumPy 中对二维数组进行索引的惯例,像素的位置仍然是一对整数,但第一个数字表示包含像素的行的索引,对应于y坐标,同样,第二个数字表示像素的x坐标。

OpenCV API

在 Python 中有许多方法来读取、处理图像和显示数字图像文件。然而,OpenCV 提供了一些最简单和最直观的 API 来实现这一点。关于 OpenCV 的一个重要事项是,当解释其图像时,它实际上将 RGB 值反转为 BGR 值,因此在图像矩阵中,元组将表示蓝色、绿色和红色,而不是红色、绿色和蓝色。

让我们看一个在 Python 中与 OpenCV 交互的例子。让我们来看一下Chapter08/example1.py文件:

# Chapter08/example1.py

import cv2

im = cv2.imread('input/ship.jpg')
cv2.imshow('Test', im)
cv2.waitKey(0) # press any key to move forward here

print(im)
print('Type:', type(im))
print('Shape:', im.shape)
print('Top-left pixel:', im[0, 0])

print('Done.')

在这个脚本中使用了一些 OpenCV 的方法,我们需要讨论一下:

  • cv2.imread(): 这个方法接受一个图像文件的路径(常见的文件扩展名包括.jpeg.jpg.png等),并返回一个图像对象,正如我们后面将看到的,它由一个 NumPy 数组表示。

  • cv2.imshow(): 这个方法接受一个字符串和一个图像对象,并在一个单独的窗口中显示它。窗口的标题由传入的字符串指定。该方法应始终跟随cv2.waitKey()方法。

  • cv2.waitKey(): 这个方法接受一个数字,并阻塞程序相应的毫秒数,除非传入数字0,在这种情况下,它将无限期地阻塞,直到用户在键盘上按下一个键。该方法应始终跟随cv2.imshow()方法。

input子文件夹中调用cv2.imshow()来显示ship.jpg文件,程序将停止,直到按下一个键,此时它将执行程序的其余部分。如果成功运行,脚本将显示以下图像:

在关闭显示的图片后,按下任意键后,您还应该获得主程序的其余部分的以下输出:

> python example1.py
[[[199 136 86]
 [199 136 86]
 [199 136 86]
 ..., 
 [198 140 81]
 [197 139 80]
 [201 143 84]]

[...Truncated for readability...]

 [[ 56 23 4]
 [ 59 26 7]
 [ 60 27 7]
 ..., 
 [ 79 43 7]
 [ 80 44 8]
 [ 75 39 3]]]
Type: <class 'numpy.ndarray'>
Shape: (1118, 1577, 3)
Top-left pixel: [199 136 86]
Done.

输出确认了我们之前讨论的一些事项:

  • 首先,当打印出从cv2.imread()函数返回的图像对象时,我们得到了一个数字矩阵。

  • 使用 Python 的type()方法,我们发现这个矩阵的类确实是一个 NumPy 数组:numpy.ndarray

  • 调用数组的shape属性,我们可以看到图像是一个形状为(111815773)的三维矩阵,对应于一个具有1118行和1577列的表,其中每个元素都是一个像素(三个数字的元组)。行和列的数字也对应于图像的大小。

  • 聚焦矩阵中的左上角像素(第一行的第一个像素,即im[0, 0]),我们得到了(19913686)的 BGR 值——199蓝色,136绿色,86红色。通过任何在线转换器查找这个 BGR 值,我们可以看到这是一种浅蓝色,对应于图像的上部分天空。

图像处理技术

我们已经看到了一些由 OpenCV 提供的 Python API,用于从图像文件中读取数据。在我们可以使用 OpenCV 执行各种图像处理任务之前,让我们讨论一些常用的图像处理技术的理论基础。

灰度处理

我们在本章前面看到了一个灰度处理的例子。可以说,灰度处理是最常用的图像处理技术之一,它是通过仅考虑每个像素的强度信息(由光的数量表示)来减少图像像素矩阵的维度。

因此,灰度图像的像素不再包含三维信息(红色、绿色和蓝色),而只包含一维的黑白数据。这些图像完全由灰度色调组成,黑色表示最弱的光强度,白色表示最强的光强度。

灰度处理在图像处理中有许多重要用途。首先,正如前面提到的,它通过将传统的三维颜色数据映射到一维灰度数据,减少了图像像素矩阵的维度。因此,图像处理程序只需要处理灰度图像的三分之一的工作,而不是分析和处理三层颜色数据。此外,通过仅使用一个光谱表示颜色,图像中的重要模式更有可能在黑白数据中被识别出来。

有多种算法可以将彩色转换为灰度:色度转换、亮度编码、单通道等。幸运的是,我们不必自己实现一个,因为 OpenCV 库提供了一个一行代码的方法,将普通图像转换为灰度图像。仍然使用上一个例子中的船的图像,让我们看一下Chapter08/example2.py文件:

# Chapter08/example2.py

import cv2

im = cv2.imread('input/ship.jpg')
gray_im = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)

cv2.imshow('Grayscale', gray_im)
cv2.waitKey(0) # press any key to move forward here

print(gray_im)
print('Type:', type(gray_im))
print('Shape:', gray_im.shape)
cv2.imwrite('output/gray_ship.jpg', gray_im)

print('Done.')

在这个例子中,我们使用 OpenCV 的cvtColor()方法将原始图像转换为灰度图像。运行此脚本后,您的计算机上应该显示以下图像:

灰度处理的输出

按任意键解除程序阻塞,您应该获得以下输出:

> python example2.py
[[128 128 128 ..., 129 128 132]
 [125 125 125 ..., 129 128 130]
 [124 125 125 ..., 129 129 130]
 ..., 
 [ 20 21 20 ..., 38 39 37]
 [ 19 22 21 ..., 41 42 37]
 [ 21 24 25 ..., 36 37 32]]
Type: <class 'numpy.ndarray'>
Shape: (1118, 1577)
Done.

我们可以看到,灰度图像对象的结构与我们原始图像对象所见的不同。尽管它仍然由 NumPy 数组表示,但现在它是一个二维整数数组,每个整数的范围从 0(黑色)到 255(白色)。然而,像素表仍然由1118行和1577列组成。

在这个例子中,我们还使用了cv2.imwrite()方法,它将图像对象保存到您的本地计算机上。因此,灰度图像可以在本章文件夹的输出子文件夹中找到,如我们的代码中指定的那样。

阈值处理

图像处理中的另一个重要技术是阈值处理。目标是将数字图像中的每个像素分类到不同的组中(也称为图像分割),阈值处理提供了一种快速直观的方法来创建二值图像(只有黑色和白色像素)。

阈值化的思想是,如果像素的强度大于先前指定的阈值,则用白色像素替换图像中的每个像素,如果像素的强度小于该阈值,则用黑色像素替换。与灰度化的目标类似,阈值化放大了高强度和低强度像素之间的差异,从而可以识别和提取图像中的重要特征和模式。

回想一下,灰度化将完全彩色的图像转换为只有不同灰度的版本;在这种情况下,每个像素的值是从 0 到 255 的整数。从灰度图像,阈值化可以将其转换为完全的黑白图像,其中每个像素现在只是 0(黑色)或 255(白色)。因此,在图像上执行阈值化后,该图像的每个像素只能保持两个可能的值,也显著减少了图像数据的复杂性。

因此,有效阈值处理的关键是找到一个适当的阈值,使图像中的像素以一种方式分割,使图像中的不同区域变得更加明显。最简单的阈值处理形式是使用一个常数阈值来处理整个图像中的所有像素。让我们在Chapter08/example3.py文件中考虑这种方法的一个例子。

# Chapter08/example3.py

import cv2

im = cv2.imread('input/ship.jpg')
gray_im = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)

ret, custom_thresh_im = cv2.threshold(gray_im, 127, 255, cv2.THRESH_BINARY)
cv2.imwrite('output/custom_thresh_ship.jpg', custom_thresh_im)

print('Done.')

在这个例子中,将我们一直在使用的船的图像转换为灰度图像后,我们从 OpenCV 调用threshold(src, thresh, maxval, type)函数,该函数接受以下参数:

  • src:此参数接受输入/源图像。

  • thresh:要在整个图像中使用的常数阈值。在这里,我们使用127,因为它只是 0 和 255 之间的中间点。

  • maxval:原始值大于常数阈值的像素在阈值处理后将采用此值。我们传入 255 来指定这些像素应该完全是白色的。

  • type:此值指示 OpenCV 使用的阈值类型。我们执行简单的二进制阈值处理,因此我们传入cv2.THRESH_BINARY

运行脚本后,您应该能够在输出中找到以下图像,名称为custom_thresh_ship.jpg

简单阈值输出

我们可以看到,通过简单的阈值(127),我们得到了一个突出显示图像的不同区域的图像:天空、船和海洋。然而,这种简单阈值方法存在一些问题,其中最常见的问题是找到适当的常数阈值。由于不同的图像具有不同的色调、光照条件等,使用静态值作为它们的阈值跨不同图像是不可取的。

这个问题通过自适应阈值方法来解决,这些方法计算图像的小区域的动态阈值。这个过程允许阈值根据输入图像调整,而不仅仅依赖于静态值。让我们考虑这些自适应阈值方法的两个例子,即自适应均值阈值和自适应高斯阈值。导航到Chapter08/example4.py文件:

# Chapter08/example4.py

import cv2

im = cv2.imread('input/ship.jpg')
im = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)

mean_thresh_im = cv2.adaptiveThreshold(im, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 11, 2)
cv2.imwrite('output/mean_thresh_ship.jpg', mean_thresh_im)

gauss_thresh_im = cv2.adaptiveThreshold(im, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2)
cv2.imwrite('output/gauss_thresh_ship.jpg', gauss_thresh_im)

print('Done.')

类似于我们之前使用cv2.threshold()方法所做的,这里我们再次将原始图像转换为其灰度版本,然后将其传递给 OpenCV 的adaptiveThreshold()方法。该方法接受与cv2.threshold()方法类似的参数,只是它不是接受一个常数作为阈值,而是接受一个自适应方法的参数。我们分别使用了cv2.ADAPTIVE_THRESH_MEAN_Ccv2.ADAPTIVE_THRESH_GAUSSIAN_C

倒数第二个参数指定了执行阈值处理的窗口大小;这个数字必须是奇数正整数。具体来说,在我们的例子中,我们使用了 11,因此对于图像中的每个像素,算法将考虑相邻像素(在原始像素周围的 11 x 11 方形中)。最后一个参数指定了要对最终输出中的每个像素进行的调整。这两个参数再次帮助定位图像不同区域的阈值,从而使阈值处理过程更加动态,并且正如名称所示,是自适应的。

运行脚本后,您应该能够找到以下图像作为输出,名称为mean_thresh_ship.jpggauss_thresh_ship.jpgmean_thresh_ship.jpg的输出如下:

均值阈值处理的输出

gauss_thresh_ship.jpg的输出如下:

高斯阈值处理的输出

我们可以看到,使用自适应阈值处理,特定区域的细节将在最终输出图像中进行阈值处理并突出显示。当我们需要识别图像中的小细节时,这些技术非常有用,而简单的阈值处理在我们只想提取图像的大区域时非常有用。

将并发应用于图像处理

我们已经讨论了图像处理的基础知识和一些常见的图像处理技术。我们也知道为什么图像处理是一个繁重的数值计算任务,以及并发和并行编程可以应用于加速独立处理任务。在本节中,我们将看一个具体的例子,介绍如何实现一个并发图像处理应用程序,可以处理大量的输入图像。

首先,转到本章代码的当前文件夹。在input文件夹内,有一个名为large_input的子文件夹,其中包含我们将在此示例中使用的 400 张图像。这些图片是原始船舶图像中的不同区域,使用 NumPy 提供的数组索引和切片选项从中裁剪出来的。如果您想知道这些图像是如何生成的,请查看Chapter08/generate_input.py文件。

本节的目标是实现一个程序,可以同时处理这些图像并进行阈值处理。为此,让我们看一下example5.py文件:

from multiprocessing import Pool
import cv2

import sys
from timeit import default_timer as timer

THRESH_METHOD = cv2.ADAPTIVE_THRESH_GAUSSIAN_C
INPUT_PATH = 'input/large_input/'
OUTPUT_PATH = 'output/large_output/'

n = 20
names = ['ship_%i_%i.jpg' % (i, j) for i in range(n) for j in range(n)]

def process_threshold(im, output_name, thresh_method):
    gray_im = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
    thresh_im = cv2.adaptiveThreshold(gray_im, 255, thresh_method, 
                cv2.THRESH_BINARY, 11, 2)

    cv2.imwrite(OUTPUT_PATH + output_name, thresh_im)

if __name__ == '__main__':

    for n_processes in range(1, 7):
        start = timer()

        with Pool(n_processes) as p:
            p.starmap(process_threshold, [(
                cv2.imread(INPUT_PATH + name),
                name,
                THRESH_METHOD
            ) for name in names])

        print('Took %.4f seconds with %i process(es).
              ' % (timer() - start, n_processes))

    print('Done.')

在这个例子中,我们使用multiprocessing模块中的Pool类来管理我们的进程。作为复习,Pool对象提供了方便的选项,可以使用Pool.map()方法将一系列输入映射到单独的进程。然而,在我们的例子中,我们使用了Pool.starmap()方法,以便将多个参数传递给目标函数。

在程序的开头,我们进行了一些基本的赋值:在处理图像时执行自适应阈值处理的方法,输入和输出文件夹的路径,以及要处理的图像的名称。process_threshold()函数是我们用来实际处理图像的函数;它接受一个图像对象,图像的处理版本的名称,以及要使用的阈值处理方法。这也是为什么我们需要使用Pool.starmap()方法而不是传统的Pool.map()方法。

在主程序中,为了演示顺序和多进程图像处理之间的差异,我们希望以不同数量的进程运行我们的程序,具体来说,从一个单一进程到六个不同进程。在for循环的每次迭代中,我们初始化一个Pool对象,并将每个图像的必要参数映射到process_threshold()函数,同时跟踪处理和保存所有图像所需的时间。

运行脚本后,处理后的图像可以在当前章节文件夹中的output/large_output/子文件夹中找到。您应该获得类似以下的输出:

> python example5.py
Took 0.6590 seconds with 1 process(es).
Took 0.3190 seconds with 2 process(es).
Took 0.3227 seconds with 3 process(es).
Took 0.3360 seconds with 4 process(es).
Took 0.3338 seconds with 5 process(es).
Took 0.3319 seconds with 6 process(es).
Done.

当我们从一个单一进程转到两个独立的进程时,执行时间有很大的差异。然而,当从两个进程转到更多的进程时,速度几乎没有或甚至是负的加速。一般来说,这是因为实现大量独立进程的重大开销,与相对较低数量的输入相比。尽管出于简单起见,我们没有实施这种比较,但随着输入数量的增加,我们会看到来自大量工作进程的更好的改进。

到目前为止,我们已经看到并发编程可以为图像处理应用程序提供显著的加速。然而,如果我们看一下我们之前的程序,我们会发现有其他调整可以进一步提高执行时间。具体来说,在我们之前的程序中,我们通过使用列表推导式顺序读取图像:

with Pool(n_processes) as p:
    p.starmap(process_threshold, [(
        cv2.imread(INPUT_PATH + name),
        name,
        THRESH_METHOD
    ) for name in names])

从理论上讲,如果我们将不同图像文件的读取过程并发进行,我们也可以通过我们的程序获得额外的加速。这在处理大型输入文件的图像处理应用程序中尤其如此,在那里大量时间花在等待输入读取上。考虑到这一点,让我们考虑以下示例,在其中我们将实现并发输入/输出处理。导航到example6.py文件:

from multiprocessing import Pool
import cv2

import sys
from functools import partial
from timeit import default_timer as timer

THRESH_METHOD = cv2.ADAPTIVE_THRESH_GAUSSIAN_C
INPUT_PATH = 'input/large_input/'
OUTPUT_PATH = 'output/large_output/'

n = 20
names = ['ship_%i_%i.jpg' % (i, j) for i in range(n) for j in range(n)]

def process_threshold(name, thresh_method):
    im = cv2.imread(INPUT_PATH + name)
    gray_im = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
    thresh_im = cv2.adaptiveThreshold(gray_im, 255, thresh_method, cv2.THRESH_BINARY, 11, 2)

    cv2.imwrite(OUTPUT_PATH + name, thresh_im)

if __name__ == '__main__':

    for n_processes in range(1, 7):
        start = timer()

        with Pool(n_processes) as p:
            p.map(partial(process_threshold, thresh_method=THRESH_METHOD), names)

        print('Took %.4f seconds with %i process(es).' % (timer() - start, n_processes))

    print('Done.')

这个程序的结构与上一个程序类似。然而,我们不是准备要处理的必要图像和其他相关的输入信息,而是将它们实现在process_threshold()函数中,现在只需要输入图像的名称并处理读取图像本身。

作为一个旁注,我们在主程序中使用 Python 的内置functools.partial()方法传递一个部分参数(因此得名),具体是thresh_method,传递给process_threshold()函数,因为这个参数在所有图像和进程中都是固定的。有关此工具的更多信息可以在docs.python.org/3/library/functools.html找到。

运行脚本后,您应该获得类似以下的输出:

> python example6.py
Took 0.5300 seconds with 1 process(es).
Took 0.4133 seconds with 2 process(es).
Took 0.2154 seconds with 3 process(es).
Took 0.2147 seconds with 4 process(es).
Took 0.2213 seconds with 5 process(es).
Took 0.2329 seconds with 6 process(es).
Done.

与我们上次的输出相比,这个应用程序的实现确实给我们带来了显著更好的执行时间。

良好的并发图像处理实践

到目前为止,您很可能已经意识到图像处理是一个相当复杂的过程,在图像处理应用程序中实现并发和并行编程可能会给我们的工作增加更多的复杂性。然而,有一些良好的实践将指导我们朝着正确的方向发展我们的图像处理应用程序。接下来的部分讨论了我们应该牢记的一些最常见的实践。

选择正确的方式(其中有很多)

当我们学习阈值处理时,我们已经简要提到了这种实践。图像处理应用程序如何处理和处理其图像数据在很大程度上取决于它应该解决的问题,以及将要提供给它的数据的类型。因此,在处理图像时选择特定参数时存在显著的变异性。

例如,正如我们之前所看到的,有各种方法可以对图像进行阈值处理,每种方法都会产生非常不同的输出:如果您只想关注图像的大的、明显的区域,简单的常数阈值处理将比自适应阈值处理更有益;然而,如果您想突出图像细节中的小变化,自适应阈值处理将更好。

让我们考虑另一个例子,我们将看到调整图像处理函数的特定参数如何产生更好的输出。在这个例子中,我们使用一个简单的 Haar 级联模型来检测图像中的面部。我们不会深入讨论模型如何处理和处理其数据,因为它已经内置在 OpenCV 中;同样,我们只是在高层次上使用这个模型,改变它的参数以获得不同的结果。

在本章的文件夹中导航到example7.py文件。该脚本旨在检测我们输入文件夹中的obama1.jpegobama2.jpg图像中的面部:

import cv2

face_cascade = cv2.CascadeClassifier('input/haarcascade_frontalface_default.xml')

for filename in ['obama1.jpeg', 'obama2.jpg']:
    im = cv2.imread('input/' + filename)
    gray_im = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
    faces = face_cascade.detectMultiScale(im)

    for (x, y, w, h) in faces:
        cv2.rectangle(im, (x, y), (x + w, y + h), (0, 255, 0), 2)

    cv2.imshow('%i face(s) found' % len(faces), im)
    cv2.waitKey(0)

print('Done.')

首先,程序使用cv2.CascadeClassifier类从input文件夹中加载预训练的 Haar 级联模型。对于每个输入图像,脚本将其转换为灰度并将其输入到预训练模型中。然后脚本在图像中找到的每张脸周围画一个绿色的矩形,最后在一个单独的窗口中显示它。

运行程序,你会看到以下带有标题5 个面部被发现的图像:

正确的面部检测

看起来我们的程序到目前为止工作得很好。按任意键继续,你应该会看到以下带有标题7 个面部被发现的图像:

错误的面部检测

现在,我们的程序将一些其他物体误认为是真正的面部,导致了两个误报。这背后的原因涉及到预训练模型的创建方式。具体来说,Haar 级联模型使用了一个训练数据集,其中包含特定(像素)大小的图像,当输入图像包含不同大小的面部时——这在一张集体照片中很常见,有些人离相机很近,而其他人离得很远——将输入到该模型中,会导致输出中出现误报。

cv2.CascadeClassifier类的detectMultiScale方法中的scaleFactor参数解决了这个问题。该参数将在尝试预测这些区域是否包含面部之前,将输入图像的不同区域缩小。这样做可以消除面部大小的潜在差异。为了实现这一点,将我们传递输入图像到模型的那一行更改为以下内容,以指定scaleFactor参数为1.2

faces = face_cascade.detectMultiScale(im, scaleFactor=1.2)

运行程序,你会看到这次我们的应用能够正确检测输入图像中的所有面部,而不会产生任何误报。

从这个例子中,我们可以看到了解输入图像对你的图像处理应用程序在执行中可能带来的潜在挑战是很重要的,并尝试在处理的一个方法中使用不同的方法或参数来获得最佳结果。

生成适当数量的进程

我们在并发图像处理的例子中注意到的一个问题是生成进程的任务需要相当长的时间。由于这个原因,如果可用于分析数据的进程数量与输入量相比太高,那么从增加工作进程数量中获得的执行时间改善将会减少,有时甚至会变得负面。

然而,除非我们也考虑到其输入图像,否则没有一个具体的方法可以确定一个程序是否需要适当数量的独立进程。例如,如果输入图像是相对较大的文件,并且程序从存储中加载它们需要相当长的时间,那么拥有更多的进程可能是有益的;当一些进程在等待它们的图像加载时,其他进程可以继续对它们的图像进行处理。换句话说,拥有更多的进程将允许加载和处理时间之间的一些重叠,这将导致更好的加速。

简而言之,重要的是测试图像处理应用程序中可用的不同进程,以查看可扩展性的最佳数字是多少。

同时处理输入/输出

我们发现,以顺序方式加载输入图像可能会对图像处理应用程序的执行时间产生负面影响,而不是允许单独的进程加载它们自己的输入。如果图像文件非常大,那么在单独的进程中加载时间可能会与其他进程中的加载/处理时间重叠,这一点尤为真实。对于将输出图像写入文件也是如此。

总结

图像处理是分析和操作数字图像文件以创建图像的新版本或从中提取重要数据的任务。这些数字图像由像素表表示,这些像素表是 RGB 值,或者本质上是数字元组。因此,数字图像只是数字的多维矩阵,这导致图像处理任务通常归结为大量的数字计算。

由于图像可以在图像处理应用程序中独立地进行分析和处理,因此并发和并行编程 – 特别是多进程 – 提供了一种实现应用程序执行时间显着改进的方法。此外,在实现自己的并发图像处理程序时,有许多良好的实践方法可遵循。

到目前为止,在本书中,我们已经涵盖了并发编程的两种主要形式:多线程和多进程。在下一章中,我们将转向异步 I/O 的主题,这也是并发和并行的关键要素之一。

问题

  • 什么是图像处理任务?

  • 数字成像的最小单位是什么?它在计算机中是如何表示的?

  • 什么是灰度处理?这种技术有什么作用?

  • 什么是阈值处理?这种技术有什么作用?

  • 为什么图像处理应该并发进行?

  • 并发图像处理的一些良好实践是什么?

进一步阅读

有关更多信息,您可以参考以下链接:

  • 用 Python 自动化无聊的事情:初学者的实用编程,Al Sweigart,No Starch Press,2015

  • 使用 OpenCV 学习图像处理,Garcia,Gloria Bueno 等人,Packt Publishing Ltd,2015

  • 数字图像处理的计算介绍,Alasdair McAndrew,Chapman and Hall/CRC,2015

  • 豪斯,J.,P. Joshi 和 M. Beyeler。OpenCV:Python 计算机视觉项目。Packt Publishing Ltd,2016

第九章:异步编程介绍

在本章中,我们将向读者介绍异步编程的正式定义。我们将讨论异步处理背后的基本思想,异步编程与我们所见过的其他编程模型之间的区别,以及为什么异步编程在并发中如此重要。

本章将涵盖以下主题:

  • 异步编程的概念

  • 异步编程与其他编程模型之间的关键区别

技术要求

以下是本章的先决条件列表:

一个快速的类比

异步编程是一种专注于协调应用程序中不同任务的编程模型。它的目标是确保应用程序在最短的时间内完成执行这些任务。从这个角度来看,异步编程是关于在适当时刻从一个任务切换到另一个任务,以创建等待和处理时间之间的重叠,并从而缩短完成整个程序所需的总时间。

为了理解异步编程的基本思想,让我们考虑一个快速的现实生活类比。想象一下,你正在烹饪一顿包括以下菜肴的三道菜:

  • 需要 2 分钟准备和 3 分钟烹饪/等待的开胃菜

  • 需要 5 分钟准备和 10 分钟烹饪/等待的主菜

  • 需要 3 分钟准备和 5 分钟烹饪/等待的甜点

现在,考虑菜肴完成烹饪的顺序,你的目标是确定生产三道菜所需的最短时间。例如,如果我们按顺序烹饪菜肴,我们将首先完成开胃菜,需要 5 分钟,然后我们将转向主菜,需要 15 分钟,最后是甜点,需要 8 分钟。总共,整顿饭需要 28 分钟完成。

找到更快的方法的关键是重叠一个菜的烹饪/等待时间与另一个菜的准备时间。由于在等待已经准备好烹饪的食物时你不会被占用,这段时间可以通过准备另一道菜的食物来节省。例如,可以通过以下步骤实现改进:

  • 准备开胃菜:2 分钟。

  • 在等待开胃菜烹饪时准备主菜:5 分钟。在这一步中,开胃菜将完成。

  • 在等待主菜烹饪时准备和烹饪甜点:8 分钟。在这一步骤中,甜点将完成,主菜还有 2 分钟的烹饪时间。

  • 等待主菜烹饪完成:2 分钟。在这一步中,主菜将烹饪完成。

通过重叠时间,我们节省了大量烹饪三餐的时间,现在总共只需要 17 分钟,而如果按顺序进行的话,需要 28 分钟。然而,显然有多种方式来决定我们应该先开始哪道菜,哪道菜应该第二个和最后一个烹饪。烹饪顺序的另一个变化可能如下:

  • 准备主菜:5 分钟。

  • 在等待主菜烹饪时准备开胃菜:2 分钟。主菜还有 8 分钟的烹饪时间。

  • 在等待开胃菜和主菜烹饪的时候准备甜点:3 分钟。在这一步骤中,开胃菜将已经完成,主菜还有 5 分钟的烹饪时间。

  • 等待主菜和甜点烹饪完成:5 分钟。在这一步骤中,主菜和甜点都已经完成。

这次,制作整顿饭只需要 15 分钟。我们可以看到,不同的烹饪顺序可能导致不同的总烹饪时间。找到在程序中执行和切换任务的最佳顺序是异步编程的主要思想:而不是以顺序方式执行该程序的所有指令,我们协调这些指令,以便我们可以创建重叠的等待和处理时间,并最终实现更好的执行时间。

异步与其他编程模型

异步编程是并发特别是编程的一个重要概念,但它是一个相当复杂的概念,有时我们很难将其与其他编程模型区分开来。在本节中,我们将比较异步编程与同步编程以及我们已经看到的其他并发编程模型(即线程和多进程)。

异步与同步编程

再次,异步编程与同步编程在本质上是不同的,因为它具有任务切换的特性。在同步编程中,程序的指令是按顺序执行的:一个任务必须在下一个任务开始处理之前执行完毕。而在异步编程中,如果当前任务需要花费很长时间才能完成,您可以选择在任务执行期间指定一个时间,将执行切换到另一个任务。正如我们所观察到的,这样做可能会导致整个程序的执行时间有所改善。

异步编程的一个常见示例是服务器和客户端在 HTTP 请求期间的交互。如果 HTTP 请求是同步的,客户端将不得不在发出请求后等待,直到从服务器接收到响应。想象一下,每次您转到新链接或开始播放视频时,浏览器都会挂起,直到实际数据从服务器返回。这对 HTTP 通信来说将是极其不便和低效的。

更好的方法是异步通信,客户端可以自由继续工作,当来自服务器的请求数据返回时,客户端将收到通知并继续处理数据。异步编程在 Web 开发中非常常见,一个名为AJAX异步 JavaScript 和 XML的缩写)的整个编程模型现在几乎在每个网站上都在使用。此外,如果您使用过 JavaScript 中的常见库,如 jQuery 或 Node.js,那么您可能已经使用过或至少听说过回调这个术语,它简单地意味着可以传递给另一个函数以便将来执行的函数。在函数执行之间来回切换是异步编程的主要思想,我们将在第十八章中实际分析回调使用的高级示例,从头开始构建服务器

以下图表进一步说明了同步和异步客户端-服务器通信之间的区别。

同步和异步 HTTP 请求之间的区别

当然,异步编程不仅限于 HTTP 请求。涉及一般网络通信、软件数据处理、与数据库交互等任务都可以利用异步编程。与同步编程相反,异步编程通过防止程序在等待数据时挂起来为用户提供响应性。因此,它是在处理大量数据的程序中实现的一个很好的工具。

异步与线程和多进程

虽然异步编程在某种程度上提供了与线程和多进程相似的好处,但它在 Python 编程语言中与这两种编程模型有根本的不同。

众所周知,在多进程中,我们的主程序的多个副本连同其指令和变量被创建并独立地在不同的核心上执行。线程,也被称为轻量级进程,也是基于同样的原理:虽然代码不是在单独的核心中执行,但在单独的线程中执行的独立部分也不会相互交互。

另一方面,异步编程将程序的所有指令都保留在同一个线程和进程中。异步编程的主要思想是,如果从一个任务切换到另一个任务更有效(从执行时间的角度来看),那么就简单地等待第一个任务的同时处理第二个任务。这意味着异步编程不会利用系统可能具有的多个核心。

Python 中的一个例子

虽然我们将更深入地讨论如何在 Python 中实现异步编程以及我们将使用的主要工具,包括asyncio模块,让我们考虑一下异步编程如何改善我们的 Python 程序的执行时间。

让我们看一下Chapter09/example1.py文件:

# Chapter09/example1.py

from math import sqrt

def is_prime(x):
    print('Processing %i...' % x)

    if x < 2:
        print('%i is not a prime number.' % x)

    elif x == 2:
        print('%i is a prime number.' % x)

    elif x % 2 == 0:
        print('%i is not a prime number.' % x)

    else:
        limit = int(sqrt(x)) + 1
        for i in range(3, limit, 2):
            if x % i == 0:
                print('%i is not a prime number.' % x)
                return

        print('%i is a prime number.' % x)

if __name__ == '__main__':

    is_prime(9637529763296797)
    is_prime(427920331)
    is_prime(157)

在这里,我们有我们熟悉的质数检查“is_prime()”函数,它接受一个整数并打印出一个消息,指示该输入是否是质数。在我们的主程序中,我们对三个不同的数字调用“is_prime()”。我们还跟踪了程序处理所有三个数字所花费的时间。

一旦您执行脚本,您的输出应该类似于以下内容:

> python example1.py
Processing 9637529763296797...
9637529763296797 is a prime number.
Processing 427920331...
427920331 is a prime number.
Processing 157...
157 is a prime number.

您可能已经注意到,程序花了相当长的时间来处理第一个输入。由于“is_prime()”函数的实现方式,如果输入的质数很大,那么“is_prime()”处理它的时间就会更长。因此,由于我们的第一个输入是一个很大的质数,我们的 Python 程序在打印输出之前将会 hang 一段时间。这通常会给我们的程序带来一种不响应的感觉,这在软件工程和 Web 开发中都是不可取的。

为了改善程序的响应性,我们将利用asyncio模块,该模块已经在Chapter09/example2.py文件中实现:

# Chapter09/example2.py

from math import sqrt

import asyncio

async def is_prime(x):
    print('Processing %i...' % x)

    if x < 2:
        print('%i is not a prime number.' % x)

    elif x == 2:
        print('%i is a prime number.' % x)

    elif x % 2 == 0:
        print('%i is not a prime number.' % x)

    else:
        limit = int(sqrt(x)) + 1
        for i in range(3, limit, 2):
            if x % i == 0:
                print('%i is not a prime number.' % x)
                return
            elif i % 100000 == 1:
                #print('Here!')
                await asyncio.sleep(0)

        print('%i is a prime number.' % x)

async def main():

    task1 = loop.create_task(is_prime(9637529763296797))
    task2 = loop.create_task(is_prime(427920331))
    task3 = loop.create_task(is_prime(157))

    await asyncio.wait([task1, task2, task3])

if __name__ == '__main__':
    try:
        loop = asyncio.get_event_loop()
        loop.run_until_complete(main())
    except Exception as e:
        print('There was a problem:')
        print(str(e))
    finally:
        loop.close()

我们将在下一章详细介绍这段代码。现在,只需运行脚本,您将看到打印输出的响应性有所改善:

> python example2.py
Processing 9637529763296797...
Processing 427920331...
427920331 is a prime number.
Processing 157...
157 is a prime number.
9637529763296797 is a prime number.

具体来说,当处理9637529763296797(我们的最大输入)时,程序决定切换到下一个输入。因此,在它之前返回了427920331157的结果,从而提高了程序的响应性。

总结

异步编程是一种基于任务协调和任务切换的编程模型。它与传统的顺序(或同步)编程不同,因为它在处理和等待时间之间创建了重叠,从而提高了速度。异步编程也不同于线程和多进程,因为它只发生在一个单一线程和一个单一进程中。

异步编程主要用于改善程序的响应性。当一个大输入需要花费大量时间来处理时,顺序版本的程序会出现挂起的情况,而异步程序会转移到其他较轻的任务。这允许小输入先完成执行,并帮助程序更具响应性。

在下一章中,我们将学习异步程序的主要结构,并更详细地了解asyncio模块及其功能。

问题

  • 异步编程背后的理念是什么?

  • 异步编程与同步编程有何不同?

  • 异步编程与线程和多进程有何不同?

进一步阅读

欲了解更多信息,您可以参考以下链接:

  • 《使用 Python 进行并行编程》,作者 Jan Palach,Packt Publishing Ltd,2014

  • 《Python 并行编程食谱》,作者 Giancarlo Zaccone,Packt Publishing Ltd,2015

  • 《RabbitMQ Cookbook》,作者 Sigismondo Boschi 和 Gabriele Santomaggio,Packt Publishing Ltd,2013

第十章:在 Python 中实现异步编程

本章将向您介绍 Python 中的asyncio模块。它将涵盖这个新并发模块背后的理念,该模块利用事件循环和协程,并提供了一个与同步代码一样可读的 API。在本章中,我们还将讨论异步编程的实现,以及通过concurrent.futures模块进行线程和多进程处理。在此过程中,我们将涵盖通过asyncio的最常见用法来应用异步编程,包括异步输入/输出和避免阻塞任务。

本章将涵盖以下主题:

  • 使用asyncio实现异步编程的基本要素

  • asyncio提供的异步编程框架

  • concurrent.futures模块及其在asyncio中的使用

技术要求

以下是本章的先决条件列表:

asyncio模块

正如您在上一章中看到的,asyncio模块提供了一种将顺序程序转换为异步程序的简单方法。在本节中,我们将讨论异步程序的一般结构,以及如何在 Python 中实现从顺序到异步程序的转换。

协程、事件循环和 futures

大多数异步程序都具有一些常见的元素,协程、事件循环和 futures 就是其中的三个元素。它们的定义如下:

  • 事件循环是异步程序中任务的主要协调者。事件循环跟踪所有要异步运行的任务,并决定在特定时刻执行哪些任务。换句话说,事件循环处理异步编程的任务切换方面(或执行流程)。

  • 协程是一种特殊类型的函数,它包装特定任务,以便可以异步执行。为了指定函数中应该发生任务切换的位置,需要协程;换句话说,它们指定函数应该何时将执行流程交还给事件循环。协程的任务通常存储在任务队列中或在事件循环中创建。

  • Futures是从协程返回的结果的占位符。这些 future 对象在协程在事件循环中启动时创建,因此 futures 可以表示实际结果、待定结果(如果协程尚未执行完毕)或异常(如果协程将返回异常)。

事件循环、协程及其对应的 futures 是异步编程过程的核心元素。首先启动事件循环并与其任务队列交互,以获取第一个任务。然后创建该任务的协程及其对应的 future。当需要在该协程内进行任务切换时,协程将暂停,并调用下一个协程;同时也保存了第一个协程的所有数据和上下文。

现在,如果该协程是阻塞的(例如,输入/输出处理或休眠),执行流程将被释放回事件循环,事件循环将继续执行任务队列中的下一个项目。事件循环将在切换回第一个协程之前启动任务队列中的最后一个项目,并将从上次暂停的地方继续执行。

当每个任务执行完成时,它将从任务队列中出列,其协程将被终止,并且相应的 future 将注册来自协程的返回结果。这个过程将一直持续,直到任务队列中的所有任务都被完全执行。下面的图表进一步说明了前面描述的异步过程的一般结构:

异步编程过程

异步 IO API

在考虑异步程序的一般结构时,让我们考虑一下asyncio模块和 Python 为实现异步程序提供的特定 API。这个 API 的第一个基础是添加到 Python 3.5 中的asyncawait关键字。这些关键字用于向 Python 指定异步程序的主要元素。

具体来说,当声明一个函数时,async通常放在def关键字的前面。在带有async关键字的函数前面声明的函数将被 Python 解释为协程。正如我们讨论过的,每个协程内部都必须有关于何时进行任务切换事件的规定。然后,await关键字用于指定何时何地将执行流返回给事件循环;这通常是通过等待另一个协程产生结果(await coroutine)或通过asyncio模块的辅助函数,如asyncio.sleep()asyncio.wait()函数来实现的。

重要的是要注意,asyncawait关键字实际上是由 Python 提供的,而不是由asyncio模块管理的。这意味着异步编程实际上可以在没有asyncio的情况下实现,但是,正如你将看到的,asyncio提供了一个框架和基础设施来简化这个过程,因此是 Python 中实现异步编程的主要工具。

具体来说,asyncio模块中最常用的 API 是事件循环管理功能。使用asyncio,你可以通过直观和简单的函数调用开始操纵你的任务和事件循环,而不需要大量的样板代码。其中包括以下内容:

  • asyncio.get_event_loop(): 这个方法返回当前上下文的事件循环,它是一个AbstractEventLoop对象。大多数情况下,我们不需要担心这个类,因为asyncio模块已经提供了一个高级 API 来管理我们的事件循环。

  • AbstractEventLoop.create_task(): 这个方法由事件循环调用。它将其输入添加到调用事件循环的当前任务队列中;输入通常是一个协程(即带有async关键字的函数)。

  • AbstractEventLoop.run_until_complete(): 这个方法也是由事件循环调用的。它接受异步程序的主协程,并执行它,直到协程的相应 future 被返回。虽然这个方法启动了事件循环的执行,但它也会阻塞其后的所有代码,直到所有的 future 都完成。

  • AbstractEventLoop.run_forever(): 这个方法与AbstractEventLoop.run_until_complete()有些相似,不同之处在于,正如方法名所示,调用事件循环将永远运行,除非调用AbstractEventLoop.stop()方法。因此,循环不会退出,即使获得了返回的 future。

  • AbstractEventLoop.stop(): 这个方法会导致调用事件循环停止执行,并在最近的适当机会退出,而不会导致整个程序崩溃。

除了这些方法之外,我们使用了许多非阻塞函数来促进任务切换事件。其中包括以下内容:

  • asyncio.sleep(): 虽然本身是一个协程,但这个函数创建一个在给定时间后(由输入的秒数指定)完成的额外协程。通常用作asyncio.sleep(0),以引起立即的任务切换事件。

  • asyncio.wait(): 这个函数也是一个协程,因此可以用来切换任务。它接受一个序列(通常是一个列表)的 futures,并等待它们完成执行。

异步框架的实际应用

正如您所见,asyncio提供了一种简单直观的方法来使用 Python 的异步编程关键字实现异步程序的框架。有了这个,让我们考虑将提供的框架应用于 Python 中的同步应用程序,并将其转换为异步应用程序。

异步倒计时

让我们看一下Chapter10/example1.py文件,如下所示:

# Chapter10/example1.py

import time

def count_down(name, delay):
    indents = (ord(name) - ord('A')) * '\t'

    n = 3
    while n:
        time.sleep(delay)

        duration = time.perf_counter() - start
        print('-' * 40)
        print('%.4f \t%s%s = %i' % (duration, indents, name, n))

        n -= 1

start = time.perf_counter()

count_down('A', 1)
count_down('B', 0.8)
count_down('C', 0.5)

print('-' * 40)
print('Done.')

这个例子的目标是说明重叠处理和独立任务等待时间的异步特性。为了做到这一点,我们将分析一个倒计时函数(count_down()),它接受一个字符串和一个延迟时间。然后它将从三倒数到一,以秒为单位,同时打印出从函数执行开始到输入字符串(带有当前倒计时数字)的经过的时间。

在我们的主程序中,我们将在字母ABC上调用count_down()函数,延迟时间不同。运行脚本后,您的输出应该类似于以下内容:

> python example1.py
----------------------------------------
1.0006 A = 3
----------------------------------------
2.0041 A = 2
----------------------------------------
3.0055 A = 1
----------------------------------------
3.8065         B = 3
----------------------------------------
4.6070         B = 2
----------------------------------------
5.4075         B = 1
----------------------------------------
5.9081                 C = 3
----------------------------------------
6.4105                 C = 2
----------------------------------------
6.9107                 C = 1
----------------------------------------
Done.

行首的数字表示从程序开始经过的总秒数。您可以看到程序首先为字母A倒数,间隔一秒,然后转移到字母B,间隔 0.8 秒,最后转移到字母C,间隔 0.5 秒。这是一个纯粹的顺序同步程序,因为处理和等待时间之间没有重叠。此外,运行程序大约需要 6.9 秒,这是所有三个字母倒计时时间的总和:

1 second x 3 (for A) + 0.8 seconds x 3 (for B) + 0.5 seconds x 3 (for C) = 6.9 seconds

牢记异步编程背后的思想,我们可以看到实际上我们可以将这个程序转换为异步程序。具体来说,假设在程序的第一秒钟,当我们等待倒数字母A时,我们可以切换任务以移动到其他字母。事实上,我们将为count_down()函数中的所有字母实现这个设置(换句话说,我们将count_down()变成一个协程)。

从理论上讲,现在所有倒计时任务都是异步程序中的协程,我们应该能够获得更好的执行时间和响应性。由于所有三个任务都是独立处理的,倒计时消息应该是无序打印出来的(在不同的字母之间跳跃),而异步程序应该只需要与最大任务所需的时间大致相同(即字母A需要三秒)。

但首先,让我们将程序变成异步的。为了做到这一点,我们首先需要将count_down()变成一个协程,并指定函数内的某一点为任务切换事件。换句话说,我们将在函数前面添加关键字async,而不是使用time.sleep()函数,我们将使用asyncio.sleep()函数以及await关键字;函数的其余部分应保持不变。我们的count_down()协程现在应该如下所示:

# Chapter10/example2.py

async def count_down(name, delay):
    indents = (ord(name) - ord('A')) * '\t'

    n = 3
    while n:
        await asyncio.sleep(delay)

        duration = time.perf_counter() - start
        print('-' * 40)
        print('%.4f \t%s%s = %i' % (duration, indents, name, n))

        n -= 1

至于我们的主程序,我们需要初始化和管理一个事件循环。具体来说,我们将使用asyncio.get_event_loop()方法创建一个空的事件循环,使用AbstractEventLoop.create_task()将所有三个倒计时任务添加到任务队列中,并最后使用AbstractEventLoop.run_until_complete()开始运行事件循环。我们的主程序应该如下所示:

# Chapter10/example2.py

loop = asyncio.get_event_loop()
tasks = [
    loop.create_task(count_down('A', 1)),
    loop.create_task(count_down('B', 0.8)),
    loop.create_task(count_down('C', 0.5))
]

start = time.perf_counter()
loop.run_until_complete(asyncio.wait(tasks))

print('-' * 40)
print('Done.')

完整的脚本也可以在书的代码存储库中找到,在Chapter10子文件夹中,名为example2.py。运行脚本后,您的输出应该类似于以下内容:

> python example2.py
----------------------------------------
0.5029                 C = 3
----------------------------------------
0.8008         B = 3
----------------------------------------
1.0049 A = 3
----------------------------------------
1.0050                 C = 2
----------------------------------------
1.5070                 C = 1
----------------------------------------
1.6011         B = 2
----------------------------------------
2.0090 A = 2
----------------------------------------
2.4068         B = 1
----------------------------------------
3.0147 A = 1
----------------------------------------
Done.

现在,您可以看到异步程序如何可以提高程序的执行时间和响应性。我们的程序不再按顺序执行单个任务,而是在不同的倒计时之间切换,并重叠它们的处理/等待时间。正如我们讨论过的,这导致不同的字母在彼此之间或同时被打印出来。

在程序开始时,程序不再等待整整一秒才打印出第一条消息A = 3,而是切换到任务队列中的下一个任务(在这种情况下,它等待 0.8 秒来打印字母B)。这个过程一直持续,直到过去了 0.5 秒,打印出C = 3,再过 0.3 秒(在 0.8 秒时),打印出B = 3。这都发生在打印出A = 3之前。

我们的异步程序的这种任务切换属性使其更具响应性。在打印第一条消息之前不再等待一秒,程序现在只需要 0.5 秒(最短的等待时间)就可以打印出第一条消息。至于执行时间,您可以看到这一次,整个程序只需要三秒的时间来执行(而不是 6.9 秒)。这符合我们的推测:执行时间将会接近执行最大任务所需的时间。

关于阻塞函数的说明

正如您所见,我们必须用asyncio模块中的等效函数替换我们原始的time.sleep()函数。这是因为time.sleep()本质上是一个阻塞函数,这意味着它不能用于实现任务切换事件。为了测试这一点,在我们的Chapter10/example2.py文件(我们的异步程序)中,我们将替换以下代码行:

await asyncio.sleep(delay)

先前的代码将被替换为以下代码:

time.sleep(delay)

运行这个新脚本后,您的输出将与我们原始的顺序同步程序的输出相同。因此,用time.sleep()替换await asyncio.sleep()实际上将我们的程序重新转换为同步,忽略了我们实现的事件循环。发生的情况是,当我们的程序继续执行count_down()函数中的那行时,time.sleep()实际上阻塞并阻止了执行流的释放,从根本上使整个程序再次变成同步。将time.sleep()恢复为await asyncio.sleep()以解决这个问题。

以下图表说明了阻塞和非阻塞文件处理之间执行时间差异的示例:

阻塞与非阻塞

这种现象引发了一个有趣的问题:如果一个耗时长的任务是阻塞的,那么使用该任务作为协程实现异步编程就是不可能的。因此,如果我们真的想要在异步应用程序中实现阻塞函数返回的内容,我们需要实现该阻塞函数的另一个版本,该版本可以成为协程,并允许在函数内至少有一个点进行任务切换。

幸运的是,在将asyncio作为 Python 的官方功能之一后,Python 核心开发人员一直在努力制作最常用的 Python 阻塞函数的协程版本。这意味着,如果您发现阻塞函数阻止您的程序真正实现异步,您很可能能够找到这些函数的协程版本来在您的程序中实现。

然而,Python 中传统阻塞函数的异步版本具有潜在不同的 API,这意味着您需要熟悉来自单独函数的这些 API。处理阻塞函数的另一种方法,而无需实现它们的协程版本,是使用执行器在单独的线程或单独的进程中运行函数,以避免阻塞主事件循环的线程。

异步素数检查

从我们开始的倒计时例子中继续,让我们重新考虑上一章的例子。作为一个复习,以下是程序同步版本的代码:

# Chapter09/example1.py

from math import sqrt

def is_prime(x):
    print('Processing %i...' % x)

    if x < 2:
        print('%i is not a prime number.' % x)

    elif x == 2:
        print('%i is a prime number.' % x)

    elif x % 2 == 0:
        print('%i is not a prime number.' % x)

    else:
        limit = int(sqrt(x)) + 1
        for i in range(3, limit, 2):
            if x % i == 0:
                print('%i is not a prime number.' % x)
                return

        print('%i is a prime number.' % x)

if __name__ == '__main__':

    is_prime(9637529763296797)
    is_prime(427920331)
    is_prime(157)

正如我们在上一章讨论的那样,这里我们有一个简单的素数检查函数is_prime(x),它打印出消息,指示它接收的输入整数x是否是素数。在我们的主程序中,我们按照递减的顺序依次对三个素数调用is_prime()。这种设置再次在处理大输入时创建了一个显著的时间段,导致程序在处理大输入时出现停顿,从而降低了程序的响应性。

程序产生的输出将类似于以下内容:

Processing 9637529763296797...
9637529763296797 is a prime number.
Processing 427920331...
427920331 is a prime number.
Processing 157...
157 is a prime number.

要为此脚本实现异步编程,首先,我们将不得不创建我们的第一个主要组件:事件循环。为此,我们将其转换为一个单独的函数,而不是使用'__main__'范围。这个函数和我们的is_prime()素数检查函数将成为我们最终异步程序中的协程。

现在,我们需要将is_prime()main()函数都转换为协程;同样,这意味着在def关键字前面加上async关键字,并在每个函数内部使用await关键字来指定任务切换事件。对于main(),我们只需在等待任务队列时实现该事件,使用aysncio.wait(),如下所示:

# Chapter09/example2.py

async def main():

    task1 = loop.create_task(is_prime(9637529763296797))
    task2 = loop.create_task(is_prime(427920331))
    task3 = loop.create_task(is_prime(157))

    await asyncio.wait([task1, task2, task3])

is_prime()函数中的情况更加复杂,因为在执行流程应该释放回事件循环的时间点不明确,就像我们之前倒计时的例子一样。回想一下,异步编程的目标是实现更好的执行时间和响应性,为了实现这一点,任务切换事件应该发生在一个繁重且长时间运行的任务中。然而,这一要求取决于您的程序的具体情况,特别是协程、程序的任务队列和队列中的各个任务。

例如,我们程序的任务队列包括三个数字:9637529763296797427920331157;按顺序,我们可以将它们视为一个大任务、一个中等任务和一个小任务。为了提高响应性,我们希望在大任务期间切换任务,而不是在小任务期间。这种设置将允许在执行大任务时启动、处理和可能完成中等和小任务,即使大任务在程序的任务队列中处于前列。

然后,我们将考虑我们的is_prime()协程。在检查一些特定边界情况后,它通过for循环遍历输入整数平方根下的每个奇数,并测试输入与当前奇数的可除性。在这个长时间运行的for循环中,是切换任务的完美位置——即释放执行流程回事件循环。

然而,我们仍然需要决定在for循环中的哪些具体点实现任务切换事件。再次考虑任务队列中的各个任务,我们正在寻找一个在大任务中相当常见,在中等任务中不太常见,并且在小任务中不存在的点。我决定这一点是每 1,00,000 个数字周期,这满足我们的要求,我使用了await asyncio.sleep(0)命令来促进任务切换事件,如下所示:

# Chapter09/example2.py

from math import sqrt
import asyncio

async def is_prime(x):
    print('Processing %i...' % x)

    if x < 2:
        print('%i is not a prime number.' % x)

    elif x == 2:
        print('%i is a prime number.' % x)

    elif x % 2 == 0:
        print('%i is not a prime number.' % x)

    else:
        limit = int(sqrt(x)) + 1
        for i in range(3, limit, 2):
            if x % i == 0:
                print('%i is not a prime number.' % x)
                return
            elif i % 100000 == 1:
                await asyncio.sleep(0)

        print('%i is a prime number.' % x)

最后,在我们的主程序(不要与main()协程混淆),我们创建事件循环并使用它来运行我们的main()协程,直到它完成执行:

try:
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
except Exception as e:
    print('There was a problem:')
    print(str(e))
finally:
    loop.close()

正如您在上一章中看到的,通过脚本的这种异步版本实现了更好的响应性。具体来说,我们的程序现在在处理第一个大任务时不会像挂起一样,而是在完成执行大任务之前,会打印出其他较小任务的输出消息。我们的最终结果将类似于以下内容:

Processing 9637529763296797...
Processing 427920331...
427920331 is a prime number.
Processing 157...
157 is a prime number.
9637529763296797 is a prime number.

Python 3.7 的改进

截至 2018 年,Python 3.7 刚刚发布,带来了几个重大的新功能,例如数据类、有序字典、更好的时间精度等。异步编程和asyncio模块也得到了一些重要的改进。

首先,asyncawait现在在 Python 中是正式保留的关键字。虽然我们一直称它们为关键字,但事实上,Python 直到现在都没有将这些词视为保留关键字。这意味着在 Python 程序中既不能使用async也不能使用await来命名变量或函数。如果您正在使用 Python 3.7,请启动 Python 解释器并尝试使用这些关键字作为变量或函数名称,您应该会收到以下错误消息:

>>> def async():
 File "<stdin>", line 1
 def async():
 ^
SyntaxError: invalid syntax
>>> await = 0
 File "<stdin>", line 1
 await = 0
 ^
SyntaxError: invalid syntax

Python 3.7 的一个重大改进是asyncio模块。具体来说,您可能已经注意到从我们之前的例子中,主程序通常包含大量样板代码来初始化和运行事件循环,这在所有异步程序中可能都是相同的:

loop = asyncio.get_event_loop()
asyncio.run_until_complete(main())

在我们的程序中,main()是一个协程,asyncio允许我们使用asyncio.run()方法在事件循环中简单地运行它。这消除了 Python 异步编程中的重要样板代码。

因此,我们可以将前面的代码转换为 Python 3.7 中更简化的版本,如下所示:

asyncio.run(main())

关于异步编程,Python 3.7 还实现了性能和使用便利方面的其他改进;但是,在本书中我们将不会讨论它们。

固有阻塞任务

在本章的第一个例子中,您看到异步编程可以为我们的 Python 程序提供更好的执行时间,但并非总是如此。仅有异步编程本身只能在所有处理任务都是非阻塞的情况下提供速度上的改进。然而,类似于并发和编程任务中固有的顺序性之间的比较,Python 中的一些计算任务是固有阻塞的,因此无法利用异步编程。

这意味着如果您的异步编程在某些协程中具有固有的阻塞任务,程序将无法从异步架构中获得额外的速度改进。虽然这些程序仍然会发生任务切换事件,从而提高程序的响应性,但指令不会重叠,因此也不会获得额外的速度。事实上,由于 Python 中异步编程的实现存在相当大的开销,我们的程序甚至可能需要更长的时间来完成执行,而不是原始的同步程序。

例如,让我们来比较一下我们的素数检查程序的两个版本在速度上的差异。由于程序的主要处理部分是is_prime()协程,它完全由数字计算组成,我们知道这个协程包含阻塞任务。因此,预期异步版本的运行速度会比同步版本慢。

转到代码存储库的Chapter10子文件夹,查看example3.pyexample4.py文件。这些文件包含我们一直在看的同步和异步素数检查程序的相同代码,但额外添加了跟踪运行各自程序所需时间的功能。以下是我运行synchronous程序example3.py后的输出:

> python example3.py
Processing 9637529763296797...
9637529763296797 is a prime number.
Processing 427920331...
427920331 is a prime number.
Processing 157...
157 is a prime number.
Took 5.60 seconds.

以下代码显示了我运行asynchronous程序example4.py后的输出:

> python example4.py
Processing 9637529763296797...
Processing 427920331...
427920331 is a prime number.
Processing 157...
157 is a prime number.
9637529763296797 is a prime number.
Took 7.89 seconds.

虽然您收到的输出在运行每个程序所需的具体时间上可能有所不同,但应该是异步程序实际上比同步(顺序)程序运行时间更长。再次强调,这是因为我们的is_prime()协程中的数字计算任务是阻塞的,而我们的异步程序在执行时只是在这些任务之间切换,而不是重叠这些任务以获得额外的速度。在这种情况下,异步编程只能实现响应性。

然而,这并不意味着如果您的程序包含阻塞函数,异步编程就不可能。如前所述,如果未另行指定,异步程序中的所有执行都完全在同一线程和进程中进行,阻塞的 CPU 绑定任务可以阻止程序指令重叠。但是,如果任务分布到单独的线程/进程中,情况就不同了。换句话说,线程和多进程可以帮助具有阻塞指令的异步程序实现更好的执行时间。

concurrent.futures作为解决阻塞任务的解决方案。

在本节中,我们将考虑另一种实现线程/多进程的方法:concurrent.futures模块,它被设计为实现异步任务的高级接口。具体来说,concurrent.futures模块与asyncio模块无缝配合,此外,它还提供了一个名为Executor的抽象类,其中包含实现异步线程和多进程的两个主要类的骨架(根据它们的名称建议):ThreadPoolExecutorProcessPoolExecutor

框架的变化

在我们深入讨论concurrent.futures的 API 之前,让我们先讨论一下异步线程/多进程的理论基础,以及它如何融入asyncio提供的异步编程框架。

提醒一下,我们的异步编程生态系统中有三个主要元素:事件循环、协程和它们对应的 future。在利用线程/多进程时,我们仍然需要事件循环来协调任务并处理它们返回的结果(future),因此这些元素通常与单线程异步编程保持一致。

至于协程,由于将异步编程与线程和多进程相结合的想法涉及通过在单独的线程和进程中执行它们来避免协程中的阻塞任务,因此协程不再必须被 Python 解释为实际的协程。相反,它们可以简单地成为传统的 Python 函数。

我们将需要实现的一个新元素是执行器,它可以促进线程或多进程;这可以是ThreadPoolExecutor类或ProcessPoolExecutor类的实例。现在,每当我们在事件循环中向任务队列添加任务时,我们还需要引用这个执行器,这样分离的任务将在不同的线程/进程中执行。这是通过AbstractEventLoop.run_in_executor()方法完成的,该方法接受一个执行器、一个协程(尽管它不必是一个真正的协程),以及要在单独的线程/进程中执行的协程的参数。我们将在下一节中看到这个 API 的示例。

Python 示例

让我们看一下concurrent.futures模块的具体实现。回想一下,在本章的第一个示例(倒计时示例)中,阻塞的time.sleep()函数阻止了我们的异步程序真正成为异步,必须用其非阻塞版本asyncio.sleep()替换。现在,我们在单独的线程或进程中执行各自的倒计时,这意味着阻塞的time.sleep()函数不会在执行我们的程序异步方面造成任何问题。

导航到Chapter10/example5.py文件,如下所示:

# Chapter10/example5.py

from concurrent.futures import ThreadPoolExecutor
import asyncio
import time

def count_down(name, delay):
    indents = (ord(name) - ord('A')) * '\t'

    n = 3
    while n:
        time.sleep(delay)

        duration = time.perf_counter() - start
        print('-' * 40)
        print('%.4f \t%s%s = %i' % (duration, indents, name, n))

        n -= 1

async def main():
    futures = [loop.run_in_executor(
        executor,
        count_down,
        *args
    ) for args in [('A', 1), ('B', 0.8), ('C', 0.5)]]

    await asyncio.gather(*futures)

    print('-' * 40)
    print('Done.')

start = time.perf_counter()
executor = ThreadPoolExecutor(max_workers=3)
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

注意count_down()被声明为一个典型的非协程 Python 函数。在main()中,仍然是一个协程,我们为事件循环声明了我们的任务队列。同样,在这个过程中,我们使用run_in_executor()方法,而不是在单线程异步编程中使用的create_task()方法。在我们的主程序中,我们还需要初始化一个执行器,这种情况下,它是来自concurrent.futures模块的ThreadPoolExecutor类的实例。

使用线程和多进程的决定,正如我们在之前的章节中讨论的那样,取决于程序的性质。在这里,我们需要在单独的协程之间共享start变量(保存程序开始执行的时间),以便它们可以执行倒计时的动作;因此,选择了多线程而不是多进程。

运行脚本后,您的输出应该类似于以下内容:

> python example5.py
----------------------------------------
0.5033                 C = 3
----------------------------------------
0.8052         B = 3
----------------------------------------
1.0052 A = 3
----------------------------------------
1.0079                 C = 2
----------------------------------------
1.5103                 C = 1
----------------------------------------
1.6064         B = 2
----------------------------------------
2.0093 A = 2
----------------------------------------
2.4072         B = 1
----------------------------------------
3.0143 A = 1
----------------------------------------
Done.

这个输出与我们从纯asyncio支持的异步程序中获得的输出是相同的。因此,即使有一个阻塞处理函数,我们也能够使我们的程序的执行异步化,通过concurrent.futures模块实现了线程。

现在让我们将相同的概念应用到我们的素数检查问题上。我们首先将我们的is_prime()协程转换为其原始的非协程形式,并再次在单独的进程中执行它(这比线程更可取,因为is_prime()函数是一个密集的数值计算任务)。使用原始版本的is_prime()的另一个好处是,我们不必执行我们在单线程异步程序中的任务切换条件的检查。

elif i % 100000 == 1:
    await asyncio.sleep(0)

这也将为我们提供显著的加速。让我们看一下Chapter10/example6.py文件,如下所示:

# Chapter10/example6.py

from math import sqrt
import asyncio
from concurrent.futures import ProcessPoolExecutor
from timeit import default_timer as timer

#async def is_prime(x):
def is_prime(x):
    print('Processing %i...' % x)

    if x < 2:
        print('%i is not a prime number.' % x)

    elif x == 2:
        print('%i is a prime number.' % x)

    elif x % 2 == 0:
        print('%i is not a prime number.' % x)

    else:
        limit = int(sqrt(x)) + 1
        for i in range(3, limit, 2):
            if x % i == 0:
                print('%i is not a prime number.' % x)
                return

        print('%i is a prime number.' % x)

async def main():

    task1 = loop.run_in_executor(executor, is_prime, 9637529763296797)
    task2 = loop.run_in_executor(executor, is_prime, 427920331)
    task3 = loop.run_in_executor(executor, is_prime, 157)

    await asyncio.gather(*[task1, task2, task3])

if __name__ == '__main__':
    try:
        start = timer()

        executor = ProcessPoolExecutor(max_workers=3)
        loop = asyncio.get_event_loop()
        loop.run_until_complete(main())

        print('Took %.2f seconds.' % (timer() - start))

    except Exception as e:
        print('There was a problem:')
        print(str(e))

    finally:
        loop.close()

运行脚本后,我得到了以下输出:

> python example6.py
Processing 9637529763296797...
Processing 427920331...
Processing 157...
157 is a prime number.
427920331 is a prime number.
9637529763296797 is a prime number.
Took 5.26 seconds.

再次强调,您的执行时间很可能与我的不同,尽管我们的原始、同步版本所花费的时间应该始终与单线程异步版本和多进程异步版本的比较一致:原始的同步版本所花费的时间少于单线程异步版本,但多于多进程异步版本。换句话说,通过将多进程与异步编程结合起来,我们既得到了异步编程的一致响应性,又得到了多进程的速度提升。

总结

在本章中,您了解了异步编程,这是一种利用协调计算任务以重叠等待和处理时间的编程模型。异步程序有三个主要组件:事件循环、协程和期货。事件循环负责使用其任务队列调度和管理协程。协程是要异步执行的计算任务;每个协程都必须在其函数内部指定它将在何处将执行流返回给事件循环(即任务切换事件)。期货是包含从协程获得的结果的占位符对象。

asyncio模块与 Python 关键字asyncawait一起,提供了易于使用的 API 和直观的框架来实现异步程序;此外,该框架使异步代码与同步代码一样易读,这在异步编程中通常是相当罕见的。然而,我们不能仅使用asyncio模块在阻塞计算任务上应用单线程异步编程。解决此问题的方法是concurrent.futures模块,它提供了一个高级 API 来实现异步线程和多进程,并且可以与asyncio模块一起使用。

在下一章中,我们将讨论异步编程的最常见应用之一,即传输控制协议TCP),作为服务器-客户端通信的手段。您将了解概念的基础,它如何利用异步编程,并如何在 Python 中实现它。

问题

  • 什么是异步编程?它提供了哪些优势?

  • 异步程序中的主要元素是什么?它们如何相互交互?

  • asyncawait关键字是什么?它们有什么作用?

  • asyncio模块在实现异步编程方面提供了哪些选项?

  • Python 3.7 中关于异步编程的改进是什么?

  • 什么是阻塞函数?它们为传统的异步编程带来了什么问题?

  • concurrent.futures如何为异步编程中的阻塞函数提供解决方案?它提供了哪些选项?

进一步阅读

有关更多信息,您可以参考以下链接:

第十一章:使用 asyncio 构建通信渠道

通信渠道是计算机科学领域中应用并发性的重要组成部分。在本章中,我们将介绍传输的基本理论,这些理论是由asyncio模块提供的类,以便抽象各种形式的通信渠道。我们还将介绍 Python 中简单回显服务器-客户端逻辑的实现,以进一步说明asyncio和通信系统中并发性的使用。这个例子的代码将成为本书后面出现的一个高级例子的基础。

本章将涵盖以下主题:

  • 通信渠道的基础知识以及将异步编程应用于它们

  • 如何使用asyncioaiohttp在 Python 中构建异步服务器

  • 如何异步地向多个服务器发出请求,并处理异步文件的读取和写入

技术要求

以下是本章的先决条件列表:

  • 确保您的计算机上已安装 Python 3

  • 确保您的计算机上已安装 Telnet

  • 确保您已经在您的 Python 3 发行版中安装了 Python 模块aiohttp

  • github.com/PacktPublishing/Mastering-Concurrency-in-Python下载 GitHub 存储库

  • 在本章中,我们将使用名为Chapter11的子文件夹

  • 查看以下视频以查看代码的实际操作:bit.ly/2FMwKL8

通信渠道的生态系统

通信渠道用于表示不同系统之间的物理接线连接和促进计算机网络的逻辑数据通信。在本章中,我们只关注后者,因为这是与计算相关的问题,更与异步编程的概念相关。在本节中,我们将讨论通信渠道的一般结构,以及该结构中与异步编程特别相关的两个特定元素。

通信协议层

大多数通过通信渠道进行的数据传输过程都是通过开放系统互连OSI)模型协议层来实现的。OSI 模型规定了系统间通信过程中的主要层和主题。

以下图表显示了 OSI 模型的一般结构:

OSI 模型结构

如前图所示,数据传输过程中有七个主要的通信层,具有不同程度的计算级别。我们不会详细介绍每个层的目的和具体功能,但重要的是您要理解媒体和主机层背后的一般思想。

底层的三个层包含与通信渠道的底层操作相当相关的操作。物理和数据链路层的操作包括编码方案、访问方案、低级错误检测和纠正、位同步等。这些操作用于在传输数据之前实现和指定数据的处理和准备逻辑。另一方面,网络层处理从一个系统(例如服务器)到另一个系统(例如客户端)的数据包转发,通过确定接收者的地址和数据传输路径。

另一方面,顶层处理高级数据通信和操作。在这些层中,我们将专注于传输层,因为它直接被asyncio模块用于实现通信渠道。这一层通常被视为媒体层和主机层(例如客户端和服务器)之间的概念性过渡,负责在不同系统之间的端到端连接中发送数据。此外,由于数据包(由网络层准备)可能在传输过程中由于网络错误而丢失或损坏,传输层还负责通过错误检测代码中的方法检测这些错误。

其他主机层实现处理、解释和提供来自另一个系统发送的数据的机制。在从传输层接收数据后,会话层处理身份验证、授权和会话恢复过程。表示层然后将相同的数据进行翻译并重新组织成可解释的表示形式。最后,应用层以用户友好的格式显示数据。

通信渠道的异步编程

鉴于异步编程的性质,编程模型可以提供与有效促进通信渠道的过程相辅相成的功能,这并不奇怪。以 HTTP 通信为例,服务器可以异步处理多个客户端;当它在等待特定客户端发出 HTTP 请求时,它可以切换到另一个客户端并处理该客户端的请求。同样,如果客户端需要向多个服务器发出 HTTP 请求,并且必须等待某些服务器的大型响应,它可以处理更轻量级的响应,这些响应已经被处理并首先发送回客户端。以下图表显示了服务器和客户端在 HTTP 请求中如何异步地相互交互:

异步交错的 HTTP 请求

在 asyncio 中的传输和协议

asyncio模块提供了许多不同的传输类。实质上,这些类是在前一节讨论的传输层功能的实现。您已经知道传输层在通信渠道中发挥着重要作用;因此,传输类给asyncio(因此也给开发人员)更多控制权,以实现我们自己的通信渠道的过程。

asyncio模块将传输的抽象与异步程序的实现结合在一起。特别是,尽管传输是通信渠道的核心元素,但为了利用传输类和其他相关的通信渠道工具,我们需要初始化和调用事件循环,这是asyncio.AbstractEventLoop类的一个实例。事件循环本身将创建传输并管理低级通信过程。

重要的是要注意,在asyncio中建立的通信渠道中,transport对象始终与asyncio.Protocol类的实例相关联。正如其名称所示,Protocol类指定了通信渠道使用的基础协议;对于与另一个系统建立的每个连接,将创建此类的新协议对象。在与transport对象密切合作时,协议对象可以从transport对象调用各种方法;这是我们可以实现通信渠道的具体内部工作的地方。

因此,通常在构建连接通道时,我们需要专注于实现asyncio.Protocol子类及其方法。换句话说,我们使用asyncio.Protocol作为父类来派生一个满足通信通道需求的子类。为此,我们在自定义协议子类中覆盖asyncio.Protocol基类中的以下方法:

  • Protocol.connection_made(transport): 每当来自另一个系统的连接建立时,将自动调用此方法。transport参数保存与连接相关联的transport对象。同样,每个transport都需要与协议配对;我们通常将此transport对象作为特定协议对象的属性存储在connection_made()方法中。

  • Protocol.data_received(data): 每当我们连接的系统发送其数据时,将自动调用此方法。请注意,data参数中保存的发送信息通常以字节表示,因此在进一步处理data之前应使用 Python 的encode()函数。

接下来,让我们考虑来自asyncio传输类的重要方法。所有传输类都继承自一个名为asyncio.BaseTransport的父传输类,对于该类,我们有以下常用方法:

  • BaseTransport.get_extra_info(): 此方法返回调用的transport对象的额外通道特定信息,正如其名称所示。结果可以包括有关与该传输相关联的套接字、管道和子进程的信息。在本章后面,我们将调用BaseTransport.get_extra_info('peername'),以获取传输的远程地址。

  • BaseTransport.close(): 此方法用于关闭调用的transport对象,之后不同系统之间的连接将被停止。传输的相应协议将自动调用其connection_lost()方法。

在许多传输类的实现中,我们将专注于asyncio.WriteTransport类,它再次继承自BaseTransport类的方法,并且还实现了其他用于实现仅写传输功能的方法。在这里,我们将使用WriteTransport.write()方法,该方法将写入我们希望通过transport对象与另一个系统通信的数据。作为asyncio模块的一部分,此方法不是阻塞函数;相反,它以异步方式缓冲并发送已写入的数据。

asyncio服务器客户端的大局观

您已经了解到异步编程,特别是asyncio,可以显著改善通信通道的执行。您还看到了在实现异步通信通道时需要使用的特定方法。在我们深入研究 Python 中的一个工作示例之前,让我们简要讨论一下我们试图实现的大局观,或者换句话说,我们程序的一般结构。

正如前面提到的,我们需要实现asyncio.Protocol的子类来指定通信通道的基本组织。同样,在每个异步程序的核心都有一个事件循环,因此我们还需要在协议类的上下文之外创建一个服务器,并在程序的事件循环中启动该服务器。这个过程将设置整个服务器的异步架构,并且可以通过asyncio.create_server()方法来完成,我们将在接下来的示例中进行讨论。

最后,我们将使用AbstractEventLoop.run_forever()方法永久运行我们异步程序的事件循环。与实际的服务器类似,我们希望保持服务器运行,直到遇到问题,然后我们将优雅地关闭服务器。以下图表说明了整个过程:

通信通道中的异步程序结构

Python 示例

现在,让我们看一个具体的 Python 示例,实现了一个促进异步通信的服务器。从 GitHub 页面(github.com/PacktPublishing/Mastering-Concurrency-in-Python)下载本书的代码,并转到Chapter11文件夹。

启动服务器

Chapter11/example1.py文件中,让我们来看一下EchoServerClientProtocol类,如下所示:

# Chapter11/example1.py

import asyncio

class EchoServerClientProtocol(asyncio.Protocol):
    def connection_made(self, transport):
        peername = transport.get_extra_info('peername')
        print('Connection from {}'.format(peername))
        self.transport = transport

    def data_received(self, data):
        message = data.decode()
        print('Data received: {!r}'.format(message))

在这里,我们的EchoServerClientProtocol类是asyncio.Protocol的子类。正如我们之前讨论的那样,在这个类的内部,我们需要实现connection_made(transport)data_received(data)方法。在connection_made()方法中,我们简单地通过get_extra_info()方法(使用'peername'参数)获取连接系统的地址,打印出带有该信息的消息,并最终将transport对象存储在类的属性中。为了在data_received()方法中打印出类似的消息,我们再次使用decode()方法从字节数据中获取一个字符串对象。

让我们继续看一下我们脚本的主程序,如下所示:

# Chapter11/example1.py

loop = asyncio.get_event_loop()
coro = loop.create_server(EchoServerClientProtocol, '127.0.0.1', 8888)
server = loop.run_until_complete(coro)

# Serve requests until Ctrl+C is pressed
print('Serving on {}'.format(server.sockets[0].getsockname()))
try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

# Close the server
server.close()
loop.run_until_complete(server.wait_closed())
loop.close()

我们使用熟悉的asyncio.get_event_loop()函数为我们的异步程序创建一个事件循环。然后,我们通过让该事件循环调用create_server()方法来为我们的通信创建一个服务器;这个方法接受asyncio.Protocol类的子类、服务器的地址(在本例中是本地主机:127.0.0.1)以及该地址的端口(通常为8888)。

请注意,这个方法并不会创建服务器本身;它只会异步地启动创建服务器的过程,并返回一个完成该过程的协程。因此,我们需要将该方法返回的协程存储在一个变量中(在我们的例子中是coro),并让我们的事件循环运行该协程。在使用服务器对象的sockets属性打印出一条消息之后,我们将事件循环永远运行,以保持服务器运行,除非出现KeyboardInterrupt异常。

最后,在我们的程序结束时,我们将处理脚本的清理部分,即优雅地关闭服务器。这通常是通过让服务器对象调用close()方法(启动服务器关闭过程)并使用事件循环在服务器对象上运行wait_closed()方法来完成的,以确保服务器正确关闭。最后,我们关闭事件循环。

安装 Telnet

在运行我们的示例 Python 程序之前,我们必须安装 Telnet 程序,以便正确模拟客户端和服务器之间的连接通道。Telnet 是一个提供终端命令的程序,用于促进双向交互式的文本通信协议。如果您的计算机上已经安装了 Telnet,只需跳过下一节;否则,请在本节中找到适合您系统的信息。

在 Windows 系统中,Telnet 已经安装,但可能未启用。要启用它,您可以使用“打开或关闭 Windows 功能”窗口,并确保 Telnet 客户端框被选中,或者运行以下命令:

dism /online /Enable-Feature /FeatureName:TelnetClient

Linux 系统通常预装了 Telnet,因此如果您拥有 Linux 系统,只需继续下一节。

在 macOS 系统中,Telnet 可能已经安装在您的计算机上。如果没有,您需要通过软件包管理软件 Homebrew 进行安装,如下所示:

brew install telnet

请注意,macOS 系统确实有一个预安装的 Telnet 替代品,称为 Netcat。如果您不希望在 macOS 计算机上安装 Telnet,只需在以下示例中使用nc命令而不是telnet,即可实现相同的效果。

模拟连接通道

运行以下服务器示例有多个步骤。首先,我们需要运行脚本以启动服务器,从中您将获得以下输出:

> python example1.py
Serving on ('127.0.0.1', 8888)

请注意,程序将一直运行,直到您调用Ctrl + C键组合。在一个终端(这是我们的服务器终端)中仍在运行程序的情况下,打开另一个终端并连接到指定端口(8888)的服务器(127.0.0.1);这将作为我们的客户端终端:

telnet 127.0.0.1 8888

现在,您将在服务器和客户端终端中看到一些变化。很可能,您的客户端终端将有以下输出:

> telnet 127.0.0.1 8888
Trying 127.0.0.1...
Connected to localhost.

这是 Telnet 程序的界面,它表示我们已成功连接到本地服务器。更有趣的输出在我们的服务器终端上,它将类似于以下内容:

> python example1.py
Serving on ('127.0.0.1', 8888)
Connection from ('127.0.0.1', 60332)

请记住,这是我们在EchoServerClientProtocol类中实现的信息消息,具体在connection_made()方法中。同样,当服务器与新客户端之间建立连接时,将自动调用此方法,以启动通信。从输出消息中,我们知道客户端正在从服务器127.0.0.1的端口60332发出请求(与运行服务器相同,因为它们都是本地的)。

我们在EchoServerClientProtocol类中实现的另一个功能是在data_received()方法中。具体来说,我们打印从客户端发送的解码数据。要模拟这种类型的通信,只需在客户端终端中输入一条消息,然后按Return(对于 Windows,按Enter)键。您将不会在客户端终端输出中看到任何更改,但服务器终端应该打印出一条消息,如我们协议类的data_received()方法中指定的那样。

例如,当我从客户端终端发送消息Hello, World!时,以下是我的服务器终端输出:

> python example1.py
Serving on ('127.0.0.1', 8888)
Connection from ('127.0.0.1', 60332)
Data received: 'Hello, World!\r\n'

\r\n字符只是消息字符串中包含的返回字符。使用我们当前的协议,您可以向服务器发送多条消息,甚至可以让多个客户端向服务器发送消息。要实现这一点,只需打开另一个终端并再次连接到本地服务器。您将从服务器终端看到,不同的客户端(来自不同的端口)已连接到服务器,而服务器与旧客户端的原始通信仍在维持。这是异步编程实现的另一个结果,允许多个客户端与同一服务器无缝通信,而无需使用线程或多进程。

将消息发送回客户端

因此,在我们当前的示例中,我们能够使我们的异步服务器接收、读取和处理来自客户端的消息。但是,为了使我们的通信渠道有用,我们还希望从服务器向客户端发送消息。在本节中,我们将更新我们的服务器到一个回显服务器,根据定义,它将发送从特定客户端接收到的任何和所有数据回到客户端。

为此,我们将使用asyncio.WriteTransport类的write()方法。请查看EchoServerClientProtocol类的data_received()方法中的Chapter11/example2.py文件,如下所示:

# Chapter11/example2.py

import asyncio

class EchoServerClientProtocol(asyncio.Protocol):
    def connection_made(self, transport):
        peername = transport.get_extra_info('peername')
        print('Connection from {}'.format(peername))
        self.transport = transport

    def data_received(self, data):
        message = data.decode()
        print('Data received: {!r}'.format(message))

        self.transport.write(('Echoed back: {}'.format(message)).encode())

loop = asyncio.get_event_loop()
coro = loop.create_server(EchoServerClientProtocol, '127.0.0.1', 8888)
server = loop.run_until_complete(coro)

# Serve requests until Ctrl+C is pressed
print('Serving on {}'.format(server.sockets[0].getsockname()))
try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

# Close the server
server.close()
loop.run_until_complete(server.wait_closed())
loop.close()

在从transport对象接收数据并将其打印出来后,我们向transport对象写入相应的消息,该消息将返回给原始客户端。通过运行Chapter11/example2.py脚本,并模拟上一个例子中使用 Telnet 或 Netcat 实现的相同通信,您会发现在客户端终端输入消息后,客户端会收到服务器的回显消息。在启动通信通道并输入Hello, World!消息后,以下是我的输出:

> telnet 127.0.0.1 8888
Trying 127.0.0.1...
Connected to localhost.
Hello, World!
Echoed back: Hello, World!

本质上,这个例子说明了通过自定义的asyncio.Protocol类,我们可以实现双向通信通道的能力。在运行服务器时,我们可以获取从连接到服务器的各个客户端发送的数据,处理数据,最终将所需的结果发送回适当的客户端。

关闭传输

有时,我们会希望强制关闭通信通道中的传输。例如,即使使用异步编程和其他形式的并发,您的服务器可能会因来自多个客户端的不断通信而不堪重负。另一方面,当服务器达到最大容量时,完全处理一些发送的请求并明确拒绝其余请求是不可取的。

因此,我们可以在服务器上为每个连接指定在成功通信后关闭连接,而不是为每个连接保持通信开放。我们将通过使用BaseTransport.close()方法来强制关闭调用的transport对象,从而停止服务器和特定客户端之间的连接。同样,我们将修改Chapter11/example3.pyEchoServerClientProtocol类的data_received()方法如下:

# Chapter11/example3.py

import asyncio

class EchoServerClientProtocol(asyncio.Protocol):
    def connection_made(self, transport):
        peername = transport.get_extra_info('peername')
        print('Connection from {}'.format(peername))
        self.transport = transport

    def data_received(self, data):
        message = data.decode()
        print('Data received: {!r}'.format(message))

        self.transport.write(('Echoed back: {}'.format(message)).encode())

        print('Close the client socket')
        self.transport.close()

loop = asyncio.get_event_loop()
coro = loop.create_server(EchoServerClientProtocol, '127.0.0.1', 8888)
server = loop.run_until_complete(coro)

# Serve requests until Ctrl+C is pressed
print('Serving on {}'.format(server.sockets[0].getsockname()))
try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

# Close the server
server.close()
loop.run_until_complete(server.wait_closed())
loop.close()

运行脚本,尝试连接到指定的服务器,并输入一些消息,以查看我们实现的更改。使用我们当前的设置,客户端连接并向服务器发送消息后,将收到回显消息,并且其与服务器的连接将被关闭。以下是我在使用当前协议模拟此过程后获得的输出(同样来自 Telnet 程序的界面):

> telnet 127.0.0.1 8888
Trying 127.0.0.1...
Connected to localhost.
Hello, World!
Echoed back: Hello, World!
Connection closed by foreign host.

使用 aiohttp 进行客户端通信

在之前的章节中,我们涵盖了使用asyncio模块实现异步通信通道的示例,主要是从通信过程的服务器端的角度。换句话说,我们一直在考虑处理和处理来自外部系统的请求。然而,这只是方程式的一面,我们还有客户端通信的另一面要探索。在本节中,我们将讨论应用异步编程来向服务器发出请求。

正如您可能已经猜到的那样,这个过程的最终目标是通过异步向这些系统发出请求,有效地从外部系统中收集数据。我们将重新讨论网络爬虫的概念,即自动化对各种网站进行 HTTP 请求并从其 HTML 源代码中提取特定信息的过程。如果您尚未阅读第五章,并发网络请求,我强烈建议在继续本节之前阅读该章,因为该章涵盖了网络爬虫的基本思想和其他相关重要概念。

在本节中,您还将了解另一个支持异步编程选项的模块:aiohttp(代表异步 I/O HTTP)。该模块提供了简化 HTTP 通信过程的高级功能,并且与asyncio模块无缝配合,以便进行异步编程。

安装 aiohttp 和 aiofiles

aiohttp模块不会预装在您的 Python 发行版中;然而,类似于其他包,您可以通过使用pipconda命令轻松安装该模块。我们还将安装另一个模块aiofiles,它可以促进异步文件写入。如果您使用pip作为您的包管理器,只需运行以下命令:

pip install aiohttp
pip install aiofiles

如果您想使用 Anaconda,请运行以下命令:

conda install aiohttp
conda install aiofiles

始终要确认您已成功安装了一个包,打开您的 Python 解释器并尝试导入模块。在这种情况下,运行以下代码:

>>> import aiohttp
>>> import aiofiles

如果包已成功安装,将不会出现错误消息。

获取网站的 HTML 代码

首先,让我们看一下如何使用aiohttp从单个网站发出请求并获取 HTML 源代码。请注意,即使只有一个任务(一个网站),我们的应用程序仍然是异步的,并且异步程序的结构仍然需要实现。现在,导航到Chapter11/example4.py文件,如下所示:

# Chapter11/example4.py

import aiohttp
import asyncio

async def get_html(session, url):
    async with session.get(url, ssl=False) as res:
        return await res.text()

async def main():
    async with aiohttp.ClientSession() as session:
        html = await get_html(session, 'http://packtpub.com')
        print(html)

loop = asyncio.get_event_loop()
loop.run_until_complete(main())

首先考虑main()协程。我们在上下文管理器中初始化了一个aiohttp.ClientSession类的实例;请注意,我们还在这个声明前面加上了async关键字,因为整个上下文块本身也将被视为一个协程。在这个块内部,我们调用并等待get_html()协程进行处理和返回。

将注意力转向get_html()协程,我们可以看到它接受一个会话对象和一个要从中提取 HTML 源代码的网站的 URL。在这个函数内部,我们另外使用了一个异步上下文管理器,用于发出GET请求并将来自服务器的响应存储到res变量中。最后,我们返回存储在响应中的 HTML 源代码;由于响应是从aiohttp.ClientSession类返回的对象,其方法是异步函数,因此在调用text()函数时需要指定await关键字。

当您运行程序时,将打印出 Packt 网站的整个 HTML 源代码。例如,以下是我的输出的一部分:

来自 aiohttp 的 HTML 源代码

异步写文件

大多数情况下,我们希望通过向多个网站发出请求来收集数据,并且简单地打印出响应的 HTML 代码是不合适的(出于许多原因);相反,我们希望将返回的 HTML 代码写入输出文件。实质上,这个过程是异步下载,也是流行的下载管理器的底层架构中实现的。为此,我们将使用aiofiles模块,结合aiohttpasyncio

导航到Chapter11/example5.py文件。首先,我们将看一下download_html()协程,如下所示:

# Chapter11/example5.py

async def download_html(session, url):
    async with session.get(url, ssl=False) as res:
        filename = f'output/{os.path.basename(url)}.html'

        async with aiofiles.open(filename, 'wb') as f:
            while True:
                chunk = await res.content.read(1024)
                if not chunk:
                    break
                await f.write(chunk)

        return await res.release()

这是上一个示例中get_html()协程的更新版本。现在,我们不再使用aiohttp.ClientSession实例来发出GET请求并打印返回的 HTML 代码,而是使用aiofiles模块将 HTML 代码写入文件。例如,为了便于异步文件写入,我们使用aiofiles的异步open()函数来在上下文管理器中读取文件。此外,我们使用read()函数以异步方式按块读取返回的 HTML,使用响应对象的content属性;这意味着在读取当前响应的1024字节后,执行流将被释放回事件循环,并且将发生任务切换事件。

这个示例的main()协程和主程序与我们上一个示例中的相对相同:

async def main(url):
    async with aiohttp.ClientSession() as session:
        await download_html(session, url)

urls = [
    'http://packtpub.com',
    'http://python.org',
    'http://docs.python.org/3/library/asyncio',
    'http://aiohttp.readthedocs.io',
    'http://google.com'
]

loop = asyncio.get_event_loop()
loop.run_until_complete(
    asyncio.gather(*(main(url) for url in urls))
)

main()协程接收一个 URL,并将其传递给download_html()协程,同时传入一个aiohttp.ClientSession实例。最后,在我们的主程序中,我们创建一个事件循环,并将指定的 URL 列表中的每个项目传递给main()协程。运行程序后,输出应该类似于以下内容,尽管运行程序所需的时间可能会有所不同:

> python3 example5.py
Took 0.72 seconds.

此外,在Chapter11文件夹内会有一个名为output的子文件夹,其中将填充我们 URL 列表中每个网站的下载 HTML 代码。同样,这些文件是通过aiofiles模块的功能异步创建和写入的,这是我们之前讨论过的。如您所见,为了比较这个程序及其对应的同步版本的速度,我们还在跟踪整个程序运行所需的时间。

现在,转到Chapter11/example6.py文件。这个脚本包含了我们当前程序的同步版本的代码。具体来说,它按顺序对各个网站进行 HTTP GET请求,并且文件写入的过程也是按顺序实现的。这个脚本产生了以下输出:

> python3 example6.py
Took 1.47 seconds.

尽管它达到了相同的结果(下载 HTML 代码并将其写入文件),但我们的顺序程序花费的时间明显比其异步对应版本多得多。

总结

数据传输过程中有七个主要的通信层,具有不同程度的计算级别。媒体层包含与通信通道的底层过程交互的相当低级别的操作,而主机层处理高级数据通信和操作。在这七个层中,传输层通常被视为媒体层和主机层之间的概念性过渡,负责在不同系统之间的端到端连接中发送数据。异步编程可以提供补充有效促进通信通道的过程的功能。

在服务器方面,asyncio模块将传输的抽象与异步程序的实现结合在一起。具体来说,通过其BaseTransportBaseProtocol类,asyncio提供了不同的方式来定制通信通道的底层架构。与aiohttp模块一起,asyncio在客户端通信过程中提供了效率和灵活性。aiofiles模块可以与其他两个异步编程模块一起使用,还可以帮助促进异步文件读取和写入。

我们现在已经探讨了并发编程中最重要的三个主题:线程、多进程和异步编程。我们已经展示了它们如何应用于各种编程问题,并在速度上提供了显著的改进。在本书的下一章中,我们将开始讨论并发编程对开发人员和程序员常见的问题,从死锁开始。

问题

  • 什么是通信通道?它与异步编程有什么联系?

  • OSI 模型协议层有哪两个主要部分?它们各自的目的是什么?

  • 传输层是什么?它对通信通道为什么至关重要?

  • asyncio如何促进服务器端通信通道的实现?

  • asyncio如何促进客户端通信通道的实现?

  • aiofiles是什么?

进一步阅读

有关更多信息,您可以参考以下链接:

第十二章:死锁

死锁是并发问题中最常见的问题之一。在本章中,我们将讨论并发编程中死锁的理论原因。我们将涵盖并发中的一个经典同步问题,称为哲学家就餐问题,作为死锁的现实例子。我们还将在 Python 中演示死锁的实际实现。我们将讨论解决该问题的几种方法。本章还将涵盖与死锁相关的活锁概念,这是并发编程中相对常见的问题。

本章将涵盖以下主题:

  • 死锁的概念,以及如何在 Python 中模拟它

  • 死锁的常见解决方案,以及如何在 Python 中实现它们

  • 活锁的概念,以及它与死锁的关系

技术要求

以下是本章的先决条件列表:

死锁的概念

在计算机科学领域,死锁指的是并发编程中的一种特定情况,即程序无法取得进展并且陷入当前状态。在大多数情况下,这种现象是由于不同锁对象之间的协调不足或处理不当(用于线程同步目的)。在本节中,我们将讨论一个被称为哲学家就餐问题的思想实验,以阐明死锁及其原因的概念;从那里,您将学习如何在 Python 并发程序中模拟该问题。

哲学家就餐问题

哲学家就餐问题最初是由 Edgar Dijkstra(正如您在第一章中学到的那样,并发和并行编程的高级介绍是并发编程的领先先驱)在 1965 年首次提出的。该问题最初使用不同的技术术语(计算机系统中的资源争用)进行演示,并且后来由 Tony Hoare 重新表述,他是一位英国计算机科学家,也是快速排序算法的发明者。问题陈述如下。

五位哲学家围坐在一张桌子旁,每个人面前都有一碗食物。在这五碗食物之间放着五把叉子,所以每个哲学家左边和右边都有一把叉子。这个设置由以下图表演示:

哲学家就餐问题的插图

每位沉默的哲学家都要在思考和进餐之间交替。每位哲学家需要周围的两把叉子才能够拿起自己碗里的食物,而且一把叉子不能被两个或更多不同的哲学家共享。当一个哲学家吃完一定量的食物后,他们需要把两把叉子放回原来的位置。在这一点上,那位哲学家周围的哲学家将能够使用那些叉子。

由于哲学家们是沉默的,无法相互交流,因此他们没有方法让彼此知道他们需要叉子来吃饭。换句话说,哲学家吃饭的唯一方法是已经有两把叉子可供他们使用。这个问题的问题是设计一组指令,使哲学家能够有效地在进餐和思考之间切换,以便每个哲学家都能得到足够的食物。

现在,解决这个问题的一个潜在方法可能是以下一组指令:

  1. 哲学家必须思考,直到他们左边的叉子可用。当这种情况发生时,哲学家就要拿起它。

  2. 哲学家必须思考,直到他们右边的叉子可用。当这种情况发生时,哲学家就要拿起它。

  3. 如果一个哲学家手里拿着两个叉子,他们会从面前的碗里吃一定量的食物,然后以下情况将适用:

  • 之后,哲学家必须把右边的叉子放回原来的位置

  • 之后,哲学家必须把左边的叉子放回原来的位置。

  1. 过程从第一个项目重复。

很明显,这一系列指令如何导致无法取得进展的情况;也就是说,如果一开始所有哲学家都同时开始执行他们的指令。由于一开始所有叉子都在桌子上,因此附近的哲学家可以拿起叉子执行第一个指令(拿起左边的叉子)。

现在,经过这一步,每个哲学家都会用左手拿着一个叉子,桌子上不会剩下叉子。由于没有哲学家手里同时拿着两个叉子,他们无法开始吃饭。此外,他们得到的指令集规定,只有在哲学家吃了一定量的食物后,才能把叉子放在桌子上。这意味着只要哲学家没有吃饭,他们就不会放下手里的叉子。

因此,每个哲学家只用左手拿着一个叉子,无法开始吃饭或放下手里的叉子。哲学家能吃饭的唯一时机是邻座的哲学家放下叉子,而这只有在他们自己能吃饭的情况下才可能发生;这造成了一个永无止境的条件循环,无法满足。这种情况本质上就是死锁的特性,系统中的所有元素都被困在原地,无法取得进展。

并发系统中的死锁

考虑到餐桌哲学家问题的例子,让我们考虑死锁的正式概念以及相关的理论。给定一个具有多个线程或进程的并发程序,如果一个进程(或线程)正在等待另一个进程持有并使用的资源,而另一个进程又在等待另一个进程持有的资源,那么执行流程就会陷入死锁。换句话说,进程在等待只有在执行完成后才能释放的资源时,无法继续执行其指令;因此,这些进程无法改变其执行状态。

死锁还由并发程序需要同时具备的条件来定义。这些条件最初由计算机科学家 Edward G. Coffman, Jr.提出,因此被称为 Coffman 条件。这些条件如下:

  • 至少有一个资源必须处于不可共享的状态。这意味着资源被一个单独的进程(或线程)持有,其他人无法访问;资源只能被单个进程(或线程)在任何给定时间内访问和持有。这种情况也被称为互斥。

  • 存在一个同时访问资源并等待其他进程(或线程)持有的进程(或线程)。换句话说,这个进程(或线程)需要访问两个资源才能执行其指令,其中一个已经持有,另一个则需要等待其他进程(或线程)释放。这种情况称为持有和等待。

  • 资源只能由持有它们的进程(或线程)释放,如果有特定的指令要求进程(或线程)这样做。这就是说,除非进程(或线程)自愿主动释放资源,否则该资源将保持在不可共享的状态。这就是无抢占条件。

  • 最终的条件称为循环等待。正如名称所示,该条件指定存在一组进程(或线程),使得该组中的第一个进程(或线程)处于等待状态,等待第二个进程(或线程)释放资源,而第二个进程(或线程)又需要等待第三个进程(或线程);最后,该组中的最后一个进程(或线程)等待第一个进程。

让我们快速看一个死锁的基本例子。考虑一个并发程序,其中有两个不同的进程(进程A和进程B),以及两个不同的资源(资源R1和资源R2),如下所示:

样本死锁图

这两个资源都不能在不同的进程之间共享,并且每个进程都需要访问这两个资源来执行其指令。以进程A为例。它已经持有资源R1,但它还需要R2来继续执行。然而,R2无法被进程A获取,因为它被进程B持有。因此,进程A无法继续。进程B也是一样,它持有R2,并且需要R1来继续。而R1又被进程A持有。

Python 模拟

在本节中,我们将在一个实际的 Python 程序中实现前面的情况。具体来说,我们将有两个锁(我们将它们称为锁 A 和锁 B),以及两个分开的线程与锁交互(线程 A 和线程 B)。在我们的程序中,我们将设置这样一种情况:线程 A 已经获取了锁 A,并且正在等待获取锁 B,而锁 B 已经被线程 B 获取,并且正在等待锁 A 被释放。

如果您已经从 GitHub 页面下载了本书的代码,请转到Chapter12文件夹。让我们考虑Chapter12/example1.py文件,如下所示:

# Chapter12/example1.py

import threading
import time

def thread_a():
    print('Thread A is starting...')

    print('Thread A waiting to acquire lock A.')
    lock_a.acquire()
    print('Thread A has acquired lock A, performing some calculation...')
    time.sleep(2)

    print('Thread A waiting to acquire lock B.')
    lock_b.acquire()
    print('Thread A has acquired lock B, performing some calculation...')
    time.sleep(2)

    print('Thread A releasing both locks.')
    lock_a.release()
    lock_b.release()

def thread_b():
    print('Thread B is starting...')

    print('Thread B waiting to acquire lock B.')
    lock_b.acquire()
    print('Thread B has acquired lock B, performing some calculation...')
    time.sleep(5)

    print('Thread B waiting to acquire lock A.')
    lock_a.acquire()
    print('Thread B has acquired lock A, performing some calculation...')
    time.sleep(5)

    print('Thread B releasing both locks.')
    lock_b.release()
    lock_a.release()

lock_a = threading.Lock()
lock_b = threading.Lock()

thread1 = threading.Thread(target=thread_a)
thread2 = threading.Thread(target=thread_b)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print('Finished.')

在这个脚本中,thread_a()thread_b()函数分别指定了我们的线程 A 和线程 B。在我们的主程序中,我们还有两个threading.Lock对象:锁 A 和锁 B。线程指令的一般结构如下:

  1. 启动线程

  2. 尝试获取与线程名称相同的锁(线程 A 将尝试获取锁 A,线程 B 将尝试获取锁 B)

  3. 执行一些计算

  4. 尝试获取另一个锁(线程 A 将尝试获取锁 B,线程 B 将尝试获取锁 A)

  5. 执行一些其他计算

  6. 释放两个锁

  7. 结束线程

请注意,我们使用time.sleep()函数来模拟一些计算正在进行的动作。

首先,我们几乎同时启动线程 A 和线程 B,在主程序中。考虑到线程指令集的结构,我们可以看到此时两个线程将被启动;线程 A 将尝试获取锁 A,并且会成功,因为此时锁 A 仍然可用。线程 B 和锁 B 也是一样。然后两个线程将继续进行一些计算。

让我们考虑一下我们程序的当前状态:锁 A 已被线程 A 获取,锁 B 已被线程 B 获取。在它们各自的计算过程完成后,线程 A 将尝试获取锁 B,线程 B 将尝试获取锁 A。我们很容易看出这是我们死锁情况的开始:由于锁 B 已经被线程 B 持有,并且无法被线程 A 获取,出于同样的原因,线程 B 也无法获取锁 A。

现在,两个线程将无限等待,以获取它们各自的第二个锁。然而,锁能够被释放的唯一方式是线程继续执行指令并在最后释放它所持有的所有锁。因此,我们的程序将在这一点上被卡住,不会再有进展。

以下图表进一步说明了死锁是如何按顺序展开的。

死锁序列图

现在,让我们看看我们创建的死锁是如何发生的。运行脚本,你应该会得到以下输出:

> python example1.py
Thread A is starting...
Thread A waiting to acquire lock A.
Thread B is starting...
Thread A has acquired lock A, performing some calculation...
Thread B waiting to acquire lock B.
Thread B has acquired lock B, performing some calculation...
Thread A waiting to acquire lock B.
Thread B waiting to acquire lock A.

正如我们讨论过的,由于每个线程都试图获取另一个线程当前持有的锁,而锁能够被释放的唯一方式是线程继续执行。这就是死锁,你的程序将无限挂起,永远无法到达程序最后一行的最终打印语句。

死锁情况的方法

正如我们所见,死锁会导致我们的并发程序陷入无限挂起,这在任何情况下都是不可取的。在本节中,我们将讨论预防死锁发生的潜在方法。直觉上,每种方法都旨在消除程序中的四个 Coffman 条件之一,以防止死锁发生。

实现资源之间的排名

从哲学家就餐问题和我们的 Python 示例中,我们可以看到四个 Coffman 条件中的最后一个条件,循环等待,是死锁问题的核心。它指定了并发程序中不同进程(或线程)等待其他进程(或线程)持有的资源的循环方式。仔细观察后,我们可以看到这种条件的根本原因是进程(或线程)访问资源的顺序(或缺乏顺序)。

在哲学家就餐问题中,每个哲学家都被指示首先拿起左边的叉子,而在我们的 Python 示例中,线程总是在执行任何计算之前尝试获取同名的锁。正如你所见,当哲学家们想同时开始就餐时,他们会拿起各自左边的叉子,并陷入无限等待;同样,当两个线程同时开始执行时,它们将获取各自的锁,然后再次无限等待另一个锁。

我们可以从中得出的结论是,如果进程(或线程)不是任意地访问资源,而是按照预定的静态顺序访问它们,那么它们获取和等待资源的循环性质将被消除。因此,对于我们的两把锁 Python 示例,我们将要求两个线程以相同的顺序尝试获取锁。例如,现在两个线程将首先尝试获取锁 A,进行一些计算,然后尝试获取锁 B,进行进一步的计算,最后释放两个线程。

这个改变是在Chapter12/example2.py文件中实现的,如下所示:

# Chapter12/example2.py

import threading
import time

def thread_a():
    print('Thread A is starting...')

    print('Thread A waiting to acquire lock A.')
    lock_a.acquire()
    print('Thread A has acquired lock A, performing some calculation...')
    time.sleep(2)

    print('Thread A waiting to acquire lock B.')
    lock_b.acquire()
    print('Thread A has acquired lock B, performing some calculation...')
    time.sleep(2)

    print('Thread A releasing both locks.')
    lock_a.release()
    lock_b.release()

def thread_b():
    print('Thread B is starting...')

    print('Thread B waiting to acquire lock A.')
    lock_a.acquire()
    print('Thread B has acquired lock A, performing some calculation...')
    time.sleep(5)

    print('Thread B waiting to acquire lock B.')
    lock_b.acquire()
    print('Thread B has acquired lock B, performing some calculation...')
    time.sleep(5)

    print('Thread B releasing both locks.')
    lock_b.release()
    lock_a.release()

lock_a = threading.Lock()
lock_b = threading.Lock()

thread1 = threading.Thread(target=thread_a)
thread2 = threading.Thread(target=thread_b)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print('Finished.')

这个版本的脚本现在能够完成执行,并应该产生以下输出:

> python3 example2.py
Thread A is starting...
Thread A waiting to acquire lock A.
Thread A has acquired lock A, performing some calculation...
Thread B is starting...
Thread B waiting to acquire lock A.
Thread A waiting to acquire lock B.
Thread A has acquired lock B, performing some calculation...
Thread A releasing both locks.
Thread B has acquired lock A, performing some calculation...
Thread B waiting to acquire lock B.
Thread B has acquired lock B, performing some calculation...
Thread B releasing both locks.
Finished.

这种方法有效地消除了我们两把锁示例中的死锁问题,但它对哲学家就餐问题的解决方案有多大的影响呢?为了回答这个问题,让我们尝试自己在 Python 中模拟问题和解决方案。Chapter12/example3.py文件包含了 Python 中哲学家就餐问题的实现,如下所示:

# Chapter12/example3.py

import threading

# The philosopher thread
def philosopher(left, right):
    while True:
        with left:
             with right:
                 print(f'Philosopher at {threading.currentThread()} 
                       is eating.')

# The chopsticks
N_FORKS = 5
forks = [threading.Lock() for n in range(N_FORKS)]

# Create all of the philosophers
phils = [threading.Thread(
    target=philosopher,
    args=(forks[n], forks[(n + 1) % N_FORKS])
) for n in range(N_FORKS)]

# Run all of the philosophers
for p in phils:
    p.start()

在这里,我们有philospher()函数作为我们单独线程的基本逻辑。它接受两个Threading.Lock对象,并模拟先前讨论的吃饭过程,使用两个上下文管理器。在我们的主程序中,我们创建了一个名为forks的五个锁对象的列表,以及一个名为phils的五个线程的列表,规定第一个线程将获取第一个和第二个锁,第二个线程将获取第二个和第三个锁,依此类推;第五个线程将按顺序获取第五个和第一个锁。最后,我们同时启动所有五个线程。

运行脚本,可以很容易地观察到死锁几乎立即发生。以下是我的输出,直到程序无限挂起:

> python3 example3.py
Philosopher at <Thread(Thread-1, started 123145445048320)> is eating.
Philosopher at <Thread(Thread-1, started 123145445048320)> is eating.
Philosopher at <Thread(Thread-1, started 123145445048320)> is eating.
Philosopher at <Thread(Thread-1, started 123145445048320)> is eating.
Philosopher at <Thread(Thread-1, started 123145445048320)> is eating.
Philosopher at <Thread(Thread-1, started 123145445048320)> is eating.
Philosopher at <Thread(Thread-3, started 123145455558656)> is eating.
Philosopher at <Thread(Thread-1, started 123145445048320)> is eating.
Philosopher at <Thread(Thread-3, started 123145455558656)> is eating.
Philosopher at <Thread(Thread-3, started 123145455558656)> is eating.
Philosopher at <Thread(Thread-3, started 123145455558656)> is eating.
Philosopher at <Thread(Thread-3, started 123145455558656)> is eating.
Philosopher at <Thread(Thread-5, started 123145466068992)> is eating.
Philosopher at <Thread(Thread-3, started 123145455558656)> is eating.
Philosopher at <Thread(Thread-3, started 123145455558656)> is eating.

接下来自然而然的问题是:我们如何在philosopher()函数中实现获取锁的顺序?我们将使用 Python 中内置的id()函数,该函数返回参数的唯一常量标识作为排序锁对象的键。我们还将实现一个自定义上下文管理器,以便将这个排序逻辑分离到一个单独的类中。请转到Chapter12/example4.py查看具体实现。

# Chapter12/example4.py

class acquire(object):
    def __init__(self, *locks):
        self.locks = sorted(locks, key=lambda x: id(x))

    def __enter__(self):
        for lock in self.locks:
            lock.acquire()

    def __exit__(self, ty, val, tb):
        for lock in reversed(self.locks):
            lock.release()
        return False

# The philosopher thread
def philosopher(left, right):
    while True:
        with acquire(left,right):
             print(f'Philosopher at {threading.currentThread()} 
                   is eating.')

在主程序保持不变的情况下,这个脚本将产生一个输出,显示排序的解决方案可以有效解决哲学家就餐问题。

然而,当这种方法应用于某些特定情况时,会出现问题。牢记并发的高级思想,我们知道在将并发应用于程序时的主要目标之一是提高速度。让我们回到我们的两锁示例,检查实现资源排序后程序的执行时间。看一下Chapter12/example5.py文件;它只是实现了排序(或有序)锁定的两锁程序,结合了一个计时器,用于跟踪两个线程完成执行所需的时间。

运行脚本后,你的输出应该类似于以下内容:

> python3 example5.py
Thread A is starting...
Thread A waiting to acquire lock A.
Thread B is starting...
Thread A has acquired lock A, performing some calculation...
Thread B waiting to acquire lock A.
Thread A waiting to acquire lock B.
Thread A has acquired lock B, performing some calculation...
Thread A releasing both locks.
Thread B has acquired lock A, performing some calculation...
Thread B waiting to acquire lock B.
Thread B has acquired lock B, performing some calculation...
Thread B releasing both locks.
Took 14.01 seconds.
Finished.

你可以看到两个线程的组合执行大约需要 14 秒。然而,如果我们仔细看两个线程的具体指令,除了与锁交互外,线程 A 需要大约 4 秒来进行计算(通过两个time.sleep(2)命令模拟),而线程 B 需要大约 10 秒(两个time.sleep(5)命令)。

这是否意味着我们的程序花费的时间与我们按顺序执行两个线程时一样长?我们将用Chapter12/example6.py文件测试这个理论,在这个文件中,我们规定每个线程应该在主程序中依次执行它的指令:

# Chapter12/example6.py

lock_a = threading.Lock()
lock_b = threading.Lock()

thread1 = threading.Thread(target=thread_a)
thread2 = threading.Thread(target=thread_b)

start = timer()

thread1.start()
thread1.join()

thread2.start()
thread2.join()

print('Took %.2f seconds.' % (timer() - start))
print('Finished.')

运行这个脚本,你会发现我们的两锁程序的顺序版本将花费与并发版本相同的时间。

> python3 example6.py
Thread A is starting...
Thread A waiting to acquire lock A.
Thread A has acquired lock A, performing some calculation...
Thread A waiting to acquire lock B.
Thread A has acquired lock B, performing some calculation...
Thread A releasing both locks.
Thread B is starting...
Thread B waiting to acquire lock A.
Thread B has acquired lock A, performing some calculation...
Thread B waiting to acquire lock B.
Thread B has acquired lock B, performing some calculation...
Thread B releasing both locks.
Took 14.01 seconds.
Finished.

这个有趣的现象是我们在程序中对锁的严格要求的直接结果。换句话说,由于每个线程都必须获取两个锁才能完成执行,每个锁在任何给定时间内都不能被多个线程获取,最后,需要按特定顺序获取锁,并且单个线程的执行不能同时发生。如果我们回过头来检查Chapter12/example5.py文件产生的输出,很明显可以看到线程 B 在线程 A 在执行结束时释放两个锁后无法开始计算。

因此,很直观地得出结论,如果在并发程序的资源上放置了足够多的锁,它将在执行上变得完全顺序化,并且结合并发编程功能的开销,它的速度甚至会比程序的纯顺序版本更糟糕。然而,在餐桌哲学家问题中(在 Python 中模拟),我们没有看到锁所创建的这种顺序性。这是因为在两线程问题中,两个锁足以使程序执行顺序化,而五个锁不足以使餐桌哲学家问题执行顺序化。

我们将在《第十四章》竞争条件中探讨这种现象的另一个实例。

忽略锁并共享资源

锁无疑是同步任务中的重要工具,在并发编程中也是如此。然而,如果锁的使用导致不良情况,比如死锁,那么我们很自然地会探索在并发程序中简单地不使用锁的选项。通过忽略锁,我们程序的资源有效地在并发程序中的不同进程/线程之间可以共享,从而消除了 Coffman 条件中的第一个条件:互斥。

这种解决死锁问题的方法可能很容易实现;让我们尝试前面的两个例子。在两锁示例中,我们简单地删除了指定与线程函数和主程序中的锁对象的任何交互的代码。换句话说,我们不再使用锁定机制。Chapter12/example7.py 文件包含了这种方法的实现,如下所示:

# Chapter12/example7.py

import threading
import time
from timeit import default_timer as timer

def thread_a():
    print('Thread A is starting...')

    print('Thread A is performing some calculation...')
    time.sleep(2)

    print('Thread A is performing some calculation...')
    time.sleep(2)

def thread_b():
    print('Thread B is starting...')

    print('Thread B is performing some calculation...')
    time.sleep(5)

    print('Thread B is performing some calculation...')
    time.sleep(5)

thread1 = threading.Thread(target=thread_a)
thread2 = threading.Thread(target=thread_b)

start = timer()

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print('Took %.2f seconds.' % (timer() - start))

print('Finished.')

运行脚本,你的输出应该类似于以下内容:

> python3 example7.py
Thread A is starting...
Thread A is performing some calculation...
Thread B is starting...
Thread B is performing some calculation...
Thread A is performing some calculation...
Thread B is performing some calculation...
Took 10.00 seconds.
Finished.

很明显,由于我们不使用锁来限制对任何计算过程的访问,两个线程的执行现在已经完全独立于彼此,因此线程完全并行运行。因此,我们也获得了更好的速度:由于线程并行运行,整个程序所花费的总时间与两个线程中较长任务所花费的时间相同(换句话说,线程 B,10 秒)。

那么餐桌哲学家问题呢?似乎我们也可以得出结论,没有锁(叉子)的情况下,问题可以很容易地解决。由于资源(食物)对于每个哲学家都是独特的(换句话说,没有哲学家应该吃另一个哲学家的食物),因此每个哲学家都可以在不担心其他人的情况下继续执行。通过忽略锁,每个哲学家可以并行执行,类似于我们在两锁示例中看到的情况。

然而,这样做意味着我们完全误解了问题。我们知道锁被利用来让进程和线程可以以系统化、协调的方式访问程序中的共享资源,以避免对数据的错误处理。因此,在并发程序中移除任何锁定机制意味着共享资源的可能性,这些资源现在不受访问限制,被以不协调的方式操纵(因此,变得损坏)的可能性显著增加。

因此,通过忽略锁,我们很可能需要完全重新设计和重构我们的并发程序。如果共享资源仍然需要以有组织的方式访问和操作,就需要实现其他同步方法。我们的进程和线程的逻辑可能需要改变以适当地与这种新的同步方法进行交互,执行时间可能会受到程序结构变化的负面影响,还可能会出现其他潜在的同步问题。

关于锁的额外说明

虽然在我们的程序中取消锁定机制以消除死锁的方法可能会引发一些问题和关注,但它确实为我们揭示了 Python 中锁对象的一个新点:在访问给定资源时,一个并发程序的元素完全可以绕过锁。换句话说,锁对象只有在进程/线程实际获取锁对象时,才能阻止不同的进程/线程访问和操作共享资源。

因此,锁实际上并没有锁定任何东西。它们只是标志,帮助指示在给定时间是否应该访问资源;如果一个指令不清晰甚至恶意的进程/线程试图在没有检查锁对象存在的情况下访问该资源,它很可能可以轻松地做到这一点。换句话说,锁根本不与它们应该锁定的资源相关联,它们绝对不会阻止进程/线程访问这些资源。

因此,简单地使用锁来设计和实现安全的、动态的并发数据结构是低效的。为了实现这一点,我们需要在锁和它们对应的资源之间添加更多具体的链接,或者完全利用不同的同步工具(例如原子消息队列)。

关于死锁解决方案的结论

您已经看到了解决死锁问题的两种最常见方法。每种方法都解决了四个 Coffman 条件中的一个,虽然两种方法在我们的示例中都(在某种程度上)成功地防止了死锁的发生,但每种方法都引发了不同的额外问题和关注。因此,真正理解您的并发程序的性质非常重要,以便知道这两种方法中的哪一种是适用的,如果有的话。

也有可能,一些程序通过死锁向我们展示出不适合并发的特性;有些程序最好是按顺序执行,如果强制并发可能会变得更糟。正如我们所讨论的,虽然并发在我们应用程序的许多领域中提供了显著的改进,但有些领域本质上不适合并发编程的应用。在死锁的情况下,开发人员应该准备考虑设计并发程序的不同方法,并且在一个并发方法不起作用时不要犹豫地实现另一种方法。

活锁的概念

活锁的概念与死锁有关;有些人甚至认为它是死锁的另一种版本。在活锁的情况下,并发程序中的进程(或线程)能够切换它们的状态;事实上,它们不断地切换状态。然而,它们只是无限地来回切换,没有任何进展。现在我们将考虑一个实际的活锁场景。

假设一对夫妇在一起吃晚餐。他们只有一个叉子可以共用,所以在任何给定的时间只有一个人可以吃。此外,夫妇之间非常彬彬有礼,所以即使其中一位饥饿想吃饭,如果另一位也饥饿,他们会把叉子放在桌子上。这个规定是创建这个问题的活锁的核心:当夫妇两个都饥饿时,每个人都会等待另一个先吃饭,从而创建一个无限循环,每个人都在想要吃饭和等待另一位先吃饭之间切换。

让我们在 Python 中模拟这个问题。转到Chapter12/example8.py,看一下Spouse类:

# Chapter12/example8.py

class Spouse(threading.Thread):

    def __init__(self, name, partner):
        threading.Thread.__init__(self)
        self.name = name
        self.partner = partner
        self.hungry = True

    def run(self):
        while self.hungry:
            print('%s is hungry and wants to eat.' % self.name)

            if self.partner.hungry:
                print('%s is waiting for their partner to eat first...' 
                      % self.name)
            else:
                with fork:
                    print('%s has stared eating.' % self.name)
                    time.sleep(5)

                    print('%s is now full.' % self.name)
                    self.hungry = False

这个类继承自threading.Thread类,并实现了我们之前讨论的逻辑。它接受一个Spouse实例的名称和另一个Spouse对象作为其伴侣;初始化时,Spouse对象也总是饥饿的(hungry属性始终设置为True)。类中的run()函数指定了线程启动时的逻辑:只要Spouse对象的hungry属性设置为True,对象将尝试使用叉子(一个锁对象)进食。但是,它总是检查其伴侣的hungry属性是否也设置为True,在这种情况下,它将不会继续获取锁,而是等待其伴侣这样做。

在我们的主程序中,首先将叉子创建为一个锁对象;然后,我们创建两个Spouse线程对象,它们分别是彼此的partner属性。最后,我们启动两个线程,并运行程序直到两个线程都执行完毕:

# Chapter12/example8.py

fork = threading.Lock()

partner1 = Spouse('Wife', None)
partner2 = Spouse('Husband', partner1)
partner1.partner = partner2

partner1.start()
partner2.start()

partner1.join()
partner2.join()

print('Finished.')

运行脚本,您会看到,正如我们讨论的那样,每个线程都会进入一个无限循环,不断地在想要吃饭和等待伴侣吃饭之间切换;程序将永远运行,直到 Python 被中断。以下代码显示了我得到的输出的前几行:

> python3 example8.py
Wife is hungry and wants to eat.
Wife is waiting for their partner to eat first...
Husband is hungry and wants to eat.
Wife is hungry and wants to eat.
Husband is waiting for their partner to eat first...
Wife is waiting for their partner to eat first...
Husband is hungry and wants to eat.
Wife is hungry and wants to eat.
Husband is waiting for their partner to eat first...
Wife is waiting for their partner to eat first...
Husband is hungry and wants to eat.
Wife is hungry and wants to eat.
Husband is waiting for their partner to eat first...
...

总结

在计算机科学领域,死锁是指并发编程中的一种特定情况,即没有任何进展并且程序被锁定在当前状态。在大多数情况下,这种现象是由于不同锁对象之间缺乏或处理不当的协调引起的,可以用餐厅哲学家问题来说明。

预防死锁发生的潜在方法包括对锁对象施加顺序和通过忽略锁对象共享不可共享的资源。每种解决方案都解决了四个 Coffman 条件中的一个,虽然这两种解决方案都可以成功地防止死锁,但每种解决方案都会引发不同的额外问题和关注点。

与死锁概念相关的是活锁。在活锁情况下,并发程序中的进程(或线程)能够切换它们的状态,但它们只是无休止地来回切换,没有任何进展。在下一章中,我们将讨论并发编程中的另一个常见问题:饥饿。

问题

  • 什么会导致死锁情况,为什么这是不可取的?

  • 餐厅哲学家问题与死锁问题有什么关系?

  • 什么是四个 Coffman 条件?

  • 资源排序如何解决死锁问题?在实施这一方法时可能会出现哪些其他问题?

  • 忽略锁如何解决死锁问题?在实施这一方法时可能会出现哪些其他问题?

  • 活锁与死锁有什么关系?

进一步阅读

有关更多信息,您可以参考以下链接:

第十三章:饥饿

在本章中,我们将讨论并发编程中饥饿的概念及其潜在原因。我们将涵盖一些读者-写者问题,这些问题是饥饿的主要例子,并且我们将在示例 Python 代码中模拟它们。本章还将涵盖死锁和饥饿之间的关系,以及饥饿的一些潜在解决方案。

本章将涵盖以下主题:

  • 饥饿背后的基本思想、其根本原因和一些更相关的概念

  • 读者-写者问题的详细分析,用于说明并发系统中饥饿的复杂性

技术要求

本章的先决条件如下:

饥饿的概念

饥饿是并发系统中的一个问题,其中一个进程(或线程)无法获得必要的资源以继续执行,因此无法取得任何进展。在本节中,我们将探讨饥饿情况的特征,分析饥饿的最常见原因,并最后考虑一个示例程序,说明饥饿的情况。

什么是饥饿?

并发程序通常会在其执行过程中实现不同进程之间的某种排序。例如,考虑一个具有三个独立进程的程序,如下所示:

  • 一个负责处理非常紧急的指令,一旦必要的资源可用就需要立即运行

  • 另一个进程负责其他重要的执行,这些执行不像第一个进程中的任务那样重要

  • 最后一个处理杂项、非常不频繁的任务

此外,这三个进程需要利用相同的资源来执行各自的指令。

直观地,我们有充分理由实施一个规范,允许第一个进程具有最高的执行优先级和资源访问权限,然后是第二个进程,最后是优先级最低的最后一个进程。然而,想象一下,前两个进程(优先级较高)运行得如此频繁,以至于第三个进程无法执行其指令;每当第三个进程需要运行时,它都会检查资源是否可用,并发现其他优先级更高的进程正在使用它们。

这是一个饥饿的情况:第三个进程没有机会执行,因此,该进程无法取得任何进展。在典型的并发程序中,很常见有多于三个不同优先级的进程,然而情况基本相似:一些进程获得更多运行的机会,因此它们不断执行。其他进程优先级较低,无法访问必要的资源来执行。

调度

在接下来的几个小节中,我们将讨论导致饥饿情况的潜在原因。大多数情况下,一组调度指令的协调不佳是饥饿的主要原因。例如,处理三个独立任务的相当天真的算法可能会在前两个任务之间实现不断的通信和交互。

这种设置导致算法的执行流程仅在第一和第二个任务之间切换,而第三个任务发现自己处于空闲状态,无法在执行中取得任何进展;在这种情况下,因为它被剥夺了 CPU 的执行流程。直观地,我们可以确定问题的根源在于算法允许前两个任务始终主导 CPU,因此有效地阻止了任何其他任务也利用 CPU。一个良好调度算法的特征是能够平均和适当地分配执行流程和资源。

如前所述,许多并发系统和程序实现了特定的优先级顺序,以进程和线程的执行为基础。这种有序调度的实现很可能会导致低优先级的进程和线程饥饿,并且可能导致一种称为优先级倒置的情况。

假设在您的并发程序中,您有最高优先级的进程 A,中等优先级的进程 B,最后是最低优先级的进程 C;进程 C 很可能会陷入饥饿的情况。此外,如果优先级进程 A 的执行取决于已经处于饥饿状态的进程 C 的完成,那么即使在并发程序中给予了最高优先级,进程 A 也可能永远无法完成其执行。

以下图表进一步说明了优先级倒置的概念:一个从时间t2t3运行的高优先级任务需要访问一些资源,而这些资源正在被低优先级任务利用:

优先级倒置的图表

再次强调,结合饥饿和优先级倒置可能导致即使高优先级任务也无法执行它们的指令的情况。

饥饿的原因

考虑到设计调度算法的复杂性,让我们讨论饥饿的具体原因。我们在前面的部分描述的情况表明了饥饿情况的一些潜在原因。然而,饥饿可能来自多种来源,如下所示:

  • 高优先级的进程(或线程)主导着 CPU 的执行流程,因此,低优先级的进程(或线程)没有机会执行它们自己的指令。

  • 高优先级的进程(或线程)主导着不可共享资源的使用,因此,低优先级的进程(或线程)没有机会执行它们自己的指令。这种情况类似于第一种情况,但是涉及访问资源的优先级,而不是执行本身的优先级。

  • 低优先级的进程(或线程)正在等待资源来执行它们的指令,但是一旦资源变得可用,具有更高优先级的其他进程(或线程)立即获得访问权限,因此低优先级的进程(或线程)将无限等待。

还有其他导致饥饿的原因,但前述是最常见的根本原因。

饥饿与死锁的关系

有趣的是,死锁情况也可能导致饥饿,因为饥饿的定义表明,如果有一个进程(或线程)由于无法获得必要的进程而无法取得任何进展,那么该进程(或线程)正在经历饥饿。

回想一下我们的死锁示例,餐桌哲学家问题,如下所示:

餐桌哲学家问题的插图

当死锁发生时,没有哲学家可以获得执行他们指令所需的资源(每个哲学家需要两把叉子才能开始吃饭)。处于死锁状态的每个哲学家也处于饥饿状态。

读者-写者问题

读者-写者问题是计算机科学领域中经典和最复杂的例子之一,它展示了并发程序中可能出现的问题。通过分析读者-写者问题的不同变体,我们将更多地了解饥饿问题及其常见原因。我们还将在 Python 中模拟这个问题,以便更深入地理解这个问题。

问题陈述

在读者-写者问题中,首先,我们有一个共享资源,大多数情况下是一个文本文件。不同的线程与该文本文件交互;每个线程都是读者或写者。读者是一个简单地访问共享资源(文本文件)并读取其中包含的数据的线程,而写者是一个访问并可能改变文本文件内容的线程。

我们知道写者和读者不能同时访问共享资源,因为如果一个线程正在向文件写入数据,其他线程就不应该访问文件以从中读取任何数据。因此,读者-写者问题的目标是找到一种正确和高效的方式来设计和协调这些读者和写者线程的调度。成功实现这个目标不仅意味着整个程序以最优化的方式执行,而且所有线程都有足够的机会执行它们的指令,不会发生饥饿。此外,需要适当地处理共享资源(文本文件),以便不会损坏数据。

以下图表进一步说明了读者-写者问题的设置:

读者-写者问题的图表

第一个读者-写者问题

正如我们所提到的,这个问题要求我们提出一个调度算法,以便读者和写者可以适当和高效地访问文本文件,而不会错误处理/损坏其中包含的数据。对这个问题的一个天真的解决方案是对文本文件施加锁定,使其成为一个不可共享的资源;这意味着在任何给定时间只有一个线程(无论是读者还是写者)可以访问(并可能操纵)文本文件。

然而,这种方法只是等同于一个顺序程序:如果共享资源一次只能被一个线程使用,不同线程之间的处理时间就不能重叠,实际上,执行变成了顺序的。因此,这不是一个最佳解决方案,因为它没有充分利用并发编程。

关于读者线程的一个见解可以导致对这个问题更优化的解决方案:由于读者只是读取文本文件中的数据而不改变它,可以允许多个读者同时访问文本文件。实际上,即使有多个读者同时从文本文件中获取数据,数据也不会以任何方式改变,因此数据的一致性和准确性得到了维护。

按照这种方法,我们将实现一个规范,其中如果共享资源正在被另一个读者打开进行读取,那么不会让任何读者等待。具体来说,除了对共享资源的锁定,我们还将有一个计数器,用于记录当前正在访问资源的读者数量。如果在程序的任何时刻,该计数器从零增加到一(换句话说,至少有一个读者开始访问资源),我们将锁定资源,使写者无法访问;同样,每当计数器减少到零(换句话说,没有读者请求访问资源),我们将释放对资源的锁定,以便写者可以访问它。

这个规范对读者来说是高效的,因为一旦第一个读者访问了资源并对其进行了锁定,就没有写者可以访问它,而后续的读者在最后一个读者完成对资源的阅读之前不必重新对其进行锁定。

让我们尝试在 Python 中实现这个解决方案。如果你已经从 GitHub 页面下载了本书的代码,请前往Chapter13文件夹。让我们看一下Chapter13/example1.py文件;具体来说,是writer()reader()函数,如下所示:

# Chapter13/example1.py

def writer():
    global text

    while True:
        with resource:
            print(f'Writing being done by 
                   {threading.current_thread().name}.')
            text += f'Writing was done by 
                    {threading.current_thread().name}. '

def reader():
    global rcount

    while True:
        with rcounter:
            rcount += 1
            if rcount == 1:
                resource.acquire()

        print(f'Reading being done by 
               {threading.current_thread().name}:')
        print(text)

        with rcounter:
            rcount -= 1
            if rcount == 0:
                resource.release()

在前面的脚本中,writer()函数由threading.Thread实例(换句话说,一个单独的线程)调用,指定了我们之前讨论的写者线程的逻辑:访问共享资源(在本例中是全局变量text,它只是一个 Python 字符串)并向资源写入一些数据。请注意,我们将所有指令放在一个while循环中,以模拟应用程序的不断性质(写者和读者不断尝试访问共享资源)。

我们还可以在reader()函数中看到读者逻辑。在请求访问共享资源之前,每个读者都会增加一个当前活动并试图访问资源的读者数量的计数器。类似地,在从文件中读取数据后,每个读者都需要减少读者的数量。在这个过程中,如果一个读者是第一个访问文件的读者(换句话说,当计数器为 1 时),它将对文件进行锁定,以便没有写者可以访问它;相反,当一个读者是最后一个读者读取文件时,它必须释放该锁。

关于读者计数器的处理,你可能已经注意到我们在增加/减少计数器变量(rcount)时使用了一个名为rcounter的锁对象。这是一种方法,用来避免计数器变量的竞争条件,这是另一个常见的并发相关问题;具体来说,没有锁定,多个线程可以同时访问和修改计数器变量,但确保数据的完整性的唯一方法是按顺序处理这个计数器变量。我们将在下一章更详细地讨论竞争条件(以及用于避免它们的实践)。

回到我们当前的脚本,在主程序中,我们将设置text变量,读者计数器和两个锁对象(分别用于读者计数器和共享资源)。我们还初始化并启动了三个读者线程和两个写者线程,如下所示:

# Chapter13/example1.py

text = 'This is some text. '
rcount = 0

rcounter = threading.Lock()
resource = threading.Lock()

threads = [threading.Thread(target=reader) for i in range(3)] + [threading.Thread(target=writer) for i in range(2)]

for thread in threads:
    thread.start()

重要的是要注意,由于读者和写者线程的指令都包裹在while循环中,因此当启动脚本时,它将无限运行。在产生足够的输出以观察程序的一般行为后,应在大约 3-4 秒后取消 Python 执行。

在运行脚本后,以下代码显示了我获得的输出的前几行:

> python3 example1.py
Reading being done by Thread-1:
This is some text. 
Reading being done by Thread-2:
Reading being done by Thread-1:
This is some text. 
This is some text. 
Reading being done by Thread-2:
Reading being done by Thread-1:
This is some text. 
This is some text. 
Reading being done by Thread-3:
Reading being done by Thread-1:
This is some text. 
This is some text. 
...

正如你所看到的,在前面的输出中有一个特定的模式:所有访问共享资源的线程都是读者。实际上,在我整个输出中,没有写者能够访问文件,因此text变量只包含初始字符串This is some text.,并且没有以任何方式进行修改。你获得的输出也应该具有相同的模式(共享资源未被修改)。

在这种情况下,写者们正在经历饥饿,因为他们都无法访问和使用资源。这是我们调度算法的直接结果;由于允许多个读者同时访问文本文件,如果有多个读者频繁访问文本文件,将会创建一个连续的读者流通过文本文件,不给写者尝试访问文件留下空间。

这种调度算法无意中给了读者优先于写者,因此被称为读者优先。因此,这种设计是不可取的。

第二个读者-写者问题

第一个方法的问题在于,当一个读者正在访问文本文件并且一个写者正在等待文件被解锁时,如果另一个读者开始执行并且想要访问文件,它将优先于已经等待的写者。此外,如果越来越多的读者继续请求访问文件,写者将无限等待,这就是我们在第一个代码示例中观察到的情况。

为了解决这个问题,我们将实现规范,即一旦写者请求访问文件,就不应该有读者能够插队并在该写者之前访问文件。为此,我们将在程序中添加一个额外的锁对象,以指定是否有写者正在等待文件,因此是否读者线程可以尝试读取文件;我们将称这个锁为read_try

与第一个读者总是锁定文本文件不同,我们现在将等待访问文件的多个写者中的第一个写者锁定read_try,以便没有读者可以再次在它之前请求访问的那些写者之前插队。正如我们在读者方面讨论的那样,由于我们正在跟踪等待文本文件的写者数量,我们需要在程序中实现写者数量及其相应的锁的计数器。

Chapter13/example2.py文件包含了此实现的代码,如下所示:

# Chapter13/example2.py

import threading

def writer():
    global text
    global wcount

    while True:
        with wcounter:
            wcount += 1
            if wcount == 1:
                read_try.acquire()

        with resource:
            print(f'Writing being done by 
                  {threading.current_thread().name}.')
            text += f'Writing was done by 
                  {threading.current_thread().name}. '

        with wcounter:
            wcount -= 1
            if wcount == 0:
                read_try.release()

def reader():
    global rcount

    while True:
        with read_try:
            with rcounter:
                rcount += 1
                if rcount == 1:
                    resource.acquire()

            print(f'Reading being done by 
                  {threading.current_thread().name}:')
            print(text)

            with rcounter:
                rcount -= 1
                if rcount == 0:
                    resource.release()

text = 'This is some text. '
wcount = 0
rcount = 0

wcounter = threading.Lock()
rcounter = threading.Lock()
resource = threading.Lock()
read_try = threading.Lock()

threads = [threading.Thread(target=reader) for i in range(3)] + 
           [threading.Thread(target=writer) for i in range(2)]

for thread in threads:
    thread.start()

与我们对问题的第一个解决方案相比,主程序保持相对不变(除了初始化read_try锁、wcount计数器及其锁wcounter之外),但在我们的writer()函数中,一旦有至少一个写者等待访问文件,我们就会锁定read_try;当最后一个写者完成执行时,它将释放锁,以便任何等待文件的读者现在可以访问它。

再次,为了查看程序产生的输出,我们将让它运行 3-4 秒,然后取消执行,因为程序否则将永远运行。以下是我通过此脚本获得的输出:

> python3 example2.py
Reading being done by Thread-1:
This is some text. 
Reading being done by Thread-1:
This is some text. 
Writing being done by Thread-4.
Writing being done by Thread-5.
Writing being done by Thread-4.
Writing being done by Thread-4.
Writing being done by Thread-4.
Writing being done by Thread-5.
Writing being done by Thread-4.
...

可以观察到,虽然一些读者能够访问文本文件(由我的输出的前四行表示),但一旦写者获得对共享资源的访问权,就再也没有读者能够访问它了。我的输出的其余部分包括有关写入指令的消息:Writing being done by等等。与我们在读者-写者问题的第一个解决方案中看到的情况相反,这个解决方案给了写者优先权,因此读者被饿死。因此,这被称为写者优先

写者优先于读者的优先级是由于只有第一个和最后一个写者必须分别获取和释放read_try锁,而每个想要访问文本文件的读者都必须单独与该锁对象交互。一旦read_try被写者锁定,没有读者甚至可以尝试执行其指令,更不用说尝试访问文本文件了。

有些情况下,如果读者在写者之前初始化并执行(例如,在我们的程序中,读者是前三个元素,写者是线程列表中的最后两个),则一些读者可以访问文本文件。然而,一旦写者能够在执行期间访问文件并获取read_try锁,读者很可能会饿死。

这种解决方案也不理想,因为它在我们的程序中给了写者线程更高的优先级。

第三个读者-写者问题

你已经看到我们尝试实现的两种解决方案都可能导致饥饿,因为没有给予不同线程相等的优先级;一种可能会使写入者饿死,另一种可能会使读者饿死。这两种方法之间的平衡可能会给我们一个实现,使读者和写者之间具有相等的优先级,从而解决饥饿问题。

回想一下:在我们的第二种方法中,我们在读者尝试访问文本文件时放置了一个锁,要求一旦写者开始等待文件,就不会使其饿死。在这个解决方案中,我们将实现一个锁,该锁也利用这种逻辑,但然后应用于读者和写者。然后,所有线程将受到锁的约束,因此在不同线程之间将实现相等的优先级。

具体来说,这是一个锁,指定在特定时刻是否允许线程访问文本文件;我们将其称为服务锁。每个写者或读者在执行任何指令之前都必须尝试获取此服务锁。写者在获得此服务锁后,还将尝试获取资源锁,并立即释放服务锁。然后,写者将执行其写入逻辑,并最终在执行结束时释放资源锁。

让我们看一下Chapter13/example3.py文件中我们在 Python 中的实现的writer()函数,如下所示:

# Chapter13/example3.py

def writer():
    global text

    while True:
        with service:
            resource.acquire()

        print(f'Writing being done by 
              {threading.current_thread().name}.')
        text += f'Writing was done by 
              {threading.current_thread().name}. '

        resource.release()

另一方面,读者也需要首先获取服务锁。由于我们仍然允许多个读者同时访问资源,我们正在实现读者计数器及其相应的锁。

读者将获取服务锁和计数器锁,增加读者计数器(可能锁定资源),然后依次释放服务锁和计数器锁。现在,它将实际从文本文件中读取数据,最后,它将减少读者计数器,并在那时是最后一个读者访问文件时,可能释放资源锁。

reader()函数包含以下规范:

# Chapter13/example3.py

def reader():
    global rcount

    while True:
        with service:
            rcounter.acquire()
            rcount += 1
            if rcount == 1:
                resource.acquire()
        rcounter.release()

        print(f'Reading being done by 
              {threading.current_thread().name}:')
        #print(text)

        with rcounter:
            rcount -= 1
            if rcount == 0:
                resource.release()

最后,在我们的主程序中,我们初始化文本字符串、读者计数器、所有必要的锁以及读者和写者线程,如下所示:

# Chapter13/example3.py

text = 'This is some text. '
rcount = 0

rcounter = threading.Lock()
resource = threading.Lock()
service = threading.Lock()

threads = [threading.Thread(target=reader) for i in range(3)] + [threading.Thread(target=writer) for i in range(2)]

for thread in threads:
    thread.start()

请注意,我们正在对reader()函数中打印文本文件当前内容的代码进行注释,以便后续输出更易读。运行程序 3-4 秒,然后取消。以下输出是我在我的个人电脑上获得的:

> python3 example3.py
Reading being done by Thread-3:
Writing being done by Thread-4.
Reading being done by Thread-1:
Writing being done by Thread-5.
Reading being done by Thread-2:
Reading being done by Thread-3:
Writing being done by Thread-4.
...

我们当前输出的模式是,读者和写者能够合作和高效地访问共享资源;所有读者和写者都在执行其指令,没有线程被这个调度算法饿死。

请注意,当您在并发程序中处理读者-写者问题时,您不必重新发明我们刚刚讨论的方法。PyPI 实际上有一个名为readerwriterlock的外部库,其中包含了 Python 中三种方法的实现,以及对超时的支持。访问pypi.org/project/readerwriterlock/了解更多关于该库及其文档的信息。

饥饿的解决方案

通过分析不同的读者-写者问题的方法,您已经看到解决饥饿的关键:由于如果某些线程在访问共享资源时没有得到高优先级,它们将会被饿死,因此在所有线程的执行中实施公平性将防止饥饿的发生。在这种情况下,公平性并不要求程序放弃对不同线程施加的任何顺序或优先级;但为了实施公平性,程序需要确保所有线程有足够的机会执行它们的指令。

牢记这个想法,我们可以通过实施以下方法之一(或组合)来解决饥饿问题:

  • 增加低优先级线程的优先级:就像我们在读者-写者问题的第二种方法中对写者线程和第三种方法中对读者线程所做的那样,优先考虑那些本来没有机会访问共享资源的线程,可以成功地消除饥饿。

  • 先进先出线程队列:为了确保一个线程在另一个线程之前开始等待共享资源,可以跟踪请求访问的线程,并将其保存在先进先出队列中。

  • 其他方法:还可以实施几种方法来平衡不同线程的选择频率。例如,一个优先级队列也会逐渐增加等待时间较长的线程的优先级,或者如果一个线程能够多次访问共享资源,它将被给予较低的优先级,依此类推。

解决并发程序中的饥饿问题可能是一个相当复杂和涉及深入理解调度算法的过程,结合对进程和线程如何与共享资源交互的理解在这个过程中是必要的。正如您在读者-写者问题的示例中所看到的,解决饥饿问题可能需要多种实现和不同方法的修订,才能得到一个好的解决方案。

总结

饥饿是并发系统中的一个问题,其中一个进程(或线程)无法获得必要的资源来继续执行,因此无法取得任何进展。大多数情况下,调度指令的不良协调是饥饿的主要原因;死锁情况也会导致饥饿。

读者-写者问题是计算机科学领域中经典和最复杂的例子之一,它说明了并发程序中可能出现的问题。通过分析不同的读者-写者问题的方法,您已经了解到如何使用不同的调度算法解决饥饿问题。公平性是一个良好调度算法的重要元素,通过确保优先级在不同进程和线程之间适当分配,可以消除饥饿。

在下一章中,我们将讨论并发编程的三个常见问题中的最后一个:竞争条件。我们将涵盖竞争条件的基本基础和原因,相关概念,以及竞争条件与其他并发相关问题的联系。

问题

  • 什么是饥饿,为什么在并发程序中是不可取的?

  • 饥饿的根本原因是什么?可以从根本原因中产生的饥饿的常见高级原因是什么?

  • 死锁和饥饿之间有什么联系?

  • 什么是读者-写者问题?

  • 读者-写者问题的第一种方法是什么?为什么在那种情况下会出现饥饿?

  • 读者-写者问题的第二种方法是什么?为什么在那种情况下会出现饥饿?

  • 读者-写者问题的第三种方法是什么?为什么它成功地解决了饥饿问题?

  • 饥饿的一些常见解决方案是什么?

进一步阅读

  • 《使用 Python 进行并行编程》,作者 Jan Palach,Packt Publishing Ltd,2014

  • 《Python 并行编程食谱》,作者 Giancarlo Zaccone,Packt Publishing Ltd,2015

  • 《饥饿和公平》(tutorials.jenkov.com/java-concurrency/starvation-and-fairness),作者 Jakob Jenkov

  • 《读者-写者问题的更快公平解决方案》,V.Popov 和 O.Mazonka

第十四章:竞争条件

在本章中,我们将讨论竞争条件的概念及其在并发环境中的潜在原因。还将介绍关键部分的定义,这是与竞争条件和并发编程密切相关的概念。我们将使用 Python 中的一些示例代码来模拟竞争条件以及常用的解决方法。最后,将讨论通常处理竞争条件的现实应用程序。

本章将涵盖以下主题:

  • 竞争条件的基本概念,以及它在并发应用程序中的发生方式,以及关键部分的定义

  • Python 中竞争条件的模拟以及如何实现竞争条件解决方案

  • 通常与竞争条件交互和处理的现实计算机科学概念

技术要求

以下是本章所需的先决条件列表:

竞争条件的概念

竞争条件通常被定义为系统输出不确定并且取决于调度算法和任务调度和执行顺序的现象。当数据在此过程中被错误处理和损坏时,竞争条件就成为系统中的一个错误。鉴于这个问题的性质,竞争条件在强调调度和协调独立任务的并发系统中很常见。

竞争条件可能发生在电子硬件系统和软件应用程序中;在本章中,我们将只讨论软件开发环境中的竞争条件,具体来说是并发软件应用程序。本节将涵盖竞争条件的理论基础及其根本原因以及关键部分的概念。

关键部分

关键部分指示并发应用程序中由多个进程或线程访问的共享资源,这可能导致意外甚至错误的行为。我们已经看到有多种方法来保护这些资源中包含的数据的完整性,我们称这些受保护的部分为关键部分

可以想象,当这些关键部分中的数据在并发或并行交互和更改时,可能会被错误处理或损坏。当与之交互的线程和进程协调不当并且调度不当时,这一点尤其明显。因此,逻辑结论是不允许多个代理同时进入关键部分。我们称这个概念为互斥

我们将在下一小节中讨论关键部分与竞争条件的原因之间的关系。

竞争条件是如何发生的

让我们考虑一个简单的并发程序,以便了解什么会导致竞争条件。假设程序有一个共享资源和两个单独的线程(线程 1 和线程 2),它们将访问并与该资源交互。具体而言,共享资源是一个数字,并且根据它们各自的执行指令,每个线程都要读取该数字,将其增加 1,最后更新共享资源的值为增加后的数字。

假设共享数字最初为 2,然后线程 1 访问和交互该数字;共享资源随后变为 3。在线程 1 成功更改并退出资源后,线程 2 开始执行其指令,并且共享资源即数字被更新为 4。在整个过程中,数字最初为 2,递增了两次(每次由一个单独的线程),并在结束时保持了一个值为 4。在这种情况下,共享数字没有被错误处理和损坏。

现在想象一种情况,即在开始时共享数字仍为 2,但两个线程同时访问该数字。现在,每个线程都从共享资源中读取数字 2,分别将数字 2 递增为 3,然后将数字 3 写回共享资源。尽管共享资源被线程访问和交互了两次,但在进程结束时它只保持了一个值为 3。

这是并发程序中发生竞争条件的一个例子:因为第二个访问共享资源的线程在第一个线程完成执行之前(换句话说,在将新值写入共享资源之前)就已经这样做,第二个线程未能获取更新的资源值。这导致在第二个线程写入资源时,第一个线程处理和更新的值被覆盖。在两个线程执行结束时,共享资源实际上只被第二个线程更新了。

下面的图表进一步说明了正确数据处理过程和竞争条件情况之间的对比:

处理共享数据不当

直觉上,我们可以看到竞争条件可能导致数据的处理和损坏。在前面的例子中,我们可以看到只有两个单独的线程访问一个共同的资源就可能发生竞争条件,导致共享资源被错误地更新,并在程序结束时保持了一个错误的值。我们知道大多数现实生活中的并发应用程序包含了更多的线程和进程以及更多的共享资源,而与共享资源交互的线程/进程越多,竞争条件发生的可能性就越大。

在 Python 中模拟竞争条件

在讨论我们可以实施的解决竞争条件问题的解决方案之前,让我们尝试在 Python 中模拟这个问题。如果您已经从 GitHub 页面下载了本书的代码,请继续导航到Chapter14文件夹。让我们看一下Chapter14/example1.py文件,特别是update()函数,如下所示:

# Chapter14/example1.py

import random
import time

def update():
    global counter

    current_counter = counter # reading in shared resource
    time.sleep(random.randint(0, 1)) # simulating heavy calculations
    counter = current_counter + 1 # updating shared resource

前面的update()函数的目标是递增一个名为counter的全局变量,并且它将被我们脚本中的一个单独的线程调用。在函数内部,我们正在与一个共享资源交互——在这种情况下是counter。然后我们将counter的值赋给另一个本地变量,称为current_counter(这是为了模拟从更复杂的数据结构中读取共享资源的过程)。

接下来,我们将使用time.sleep()方法暂停函数的执行。程序暂停的时间长度是通过函数调用random.randint(0, 1)伪随机选择的,因此程序要么暂停一秒,要么根本不暂停。最后,我们将新计算出的current_counter值(即它的一次递增)赋给原始共享资源(counter变量)。

现在,我们可以继续我们的主程序:

# Chapter14/example1.py

import threading

counter = 0

threads = [threading.Thread(target=update) for i in range(20)]

for thread in threads:
    thread.start()
for thread in threads:
    thread.join()

print(f'Final counter: {counter}.')
print('Finished.')

在这里,我们正在使用一组threading.Thread对象初始化counter全局变量,以便并发执行update()函数;我们初始化了二十个线程对象,以便共享计数器增加二十次。在启动和加入所有线程后,我们最终可以打印出我们共享的counter变量的最终值。

理论上,一个设计良好的并发程序将成功地总共增加共享计数器二十次,而且,由于其原始值为0,计数器的最终值应该在程序结束时为20。然而,当您运行此脚本时,您得到的counter变量很可能不会保持最终值为20。以下是我自己运行脚本后得到的输出:

> python3 example1.py
Final counter: 9.
Finished.

这个输出表明计数器只成功增加了九次。这是我们并发程序存在的竞争条件的直接结果。当一个特定的线程花时间从共享资源中读取和处理数据(具体来说,使用time.sleep()方法一秒钟),另一个线程读取counter变量的当前值,此时该值尚未被第一个线程更新,因为它尚未完成执行。

有趣的是,如果一个线程不花时间处理数据(换句话说,当random.randint()方法选择0时),共享资源的值可能会及时更新,以便下一个线程读取和处理它。这种现象可以通过程序的不同运行中计数器的最终值的变化来说明。例如,以下是我在运行脚本三次后得到的输出。第一次运行的输出如下:

> python3 example1.py
Final counter: 9.
Finished.

第二次运行的输出如下:

> python3 example1.py
Final counter: 12.
Finished.

第三次运行的输出如下:

> python3 example1.py
Final counter: 5.
Finished.

再次,计数器的最终值取决于花一秒暂停的线程数和根本不暂停的线程数。由于这两个数字又取决于random.randint()方法,计数器的最终值在程序的不同运行之间会发生变化。我们的程序仍然存在竞争条件,除非我们可以确保计数器的最终值始终为20(计数器总共成功增加二十次)。

锁作为解决竞争条件的解决方案

在这一部分,我们将讨论竞争条件最常见的解决方案:锁。直觉上,由于我们观察到的竞争条件是在多个线程或进程同时访问和写入共享资源时出现的,解决竞争条件的关键思想是隔离不同线程/进程的执行,特别是在与共享资源交互时。具体来说,我们需要确保一个线程/进程只能在任何其他与资源交互的线程/进程完成其与该资源的交互后才能访问共享资源。

锁的有效性

使用锁,我们可以将并发程序中的共享资源转换为临界区,保证其数据的完整性得到保护。临界区保证了共享资源的互斥访问,并且不能被多个进程或线程同时访问;这将防止受保护的数据由于竞争条件而被更新或改变。

在下图中,线程 B被互斥锁(mutex)阻止访问共享资源——名为var的临界区,因为线程 A已经在访问资源:

锁防止对临界区的同时访问

现在,我们将指定,在并发程序中,为了访问临界区,线程或进程需要获取与临界区相关联的锁对象;同样,该线程或进程在离开临界区时也需要释放该锁。这样的设置将有效地防止对临界区的多次访问,因此也将防止竞争条件。以下图表说明了多个线程与多个临界区交互的执行流程,并且实现了锁的设置:

多线程中的锁和临界区

如图表所示,线程T1T2都与其各自的执行指令中的三个临界区CS1CS2CS3进行交互。在这里,T1T2几乎同时尝试访问CS1,由于CS1受到锁L1的保护,因此只有T1能够获取锁L1,因此可以访问/与临界区交互,而T2必须等待T1退出临界区并释放锁后才能访问该区域。同样,对于临界区CS2CS3,尽管两个线程同时需要访问临界区,但只有一个可以处理,而另一个必须等待获取与临界区相关联的锁。

Python 中的实现

现在,让我们实现前面示例中的规范,以解决竞争条件的问题。转到Chapter14/example2.py文件,并考虑我们已更正的update()函数,如下所示:

# Chapter14/example2.py

import random
import time

def update():
    global counter

    with count_lock:
        current_counter = counter # reading in shared resource
        time.sleep(random.randint(0, 1)) # simulating heavy calculations
        counter = current_counter + 1

您可以看到,线程在update()函数中指定的所有执行指令都在名为count_lock的锁对象的上下文管理器下。因此,每次调用线程运行该函数时,都必须首先获取锁对象,然后才能执行任何指令。在我们的主程序中,除了我们已经拥有的内容,我们只需创建锁对象,如下所示:

# Chapter14/example2.py

import threading

counter = 0
count_lock = threading.Lock()

threads = [threading.Thread(target=update) for i in range(20)]

for thread in threads:
    thread.start()
for thread in threads:
    thread.join()

print(f'Final counter: {counter}.')
print('Finished.')

运行程序,您的输出应该类似于以下内容:

> python3 example2.py
Final counter: 20.
Finished.

您可以看到,计数器成功增加了二十次,并且在程序结束时保持了正确的值。此外,无论脚本执行多少次,计数器的最终值始终为20。这是在并发程序中使用锁来实现临界区的优势。

锁的缺点

在第十二章中,死锁,我们介绍了一个有趣的现象,即使用锁可能会导致不良结果。具体来说,我们发现,在并发程序中实现了足够多的锁后,整个程序可能会变成顺序执行。让我们用当前的程序来分析这个概念。考虑Chapter14/example3.py文件,如下所示:

# ch14/example3.py

import threading
import random; random.seed(0)
import time

def update(pause_period):
    global counter

    with count_lock:
        current_counter = counter # reading in shared resource
        time.sleep(pause_period) # simulating heavy calculations
        counter = current_counter + 1 # updating shared resource

pause_periods = [random.randint(0, 1) for i in range(20)]

###########################################################################

counter = 0
count_lock = threading.Lock()

start = time.perf_counter()
for i in range(20):
    update(pause_periods[i])

print('--Sequential version--')
print(f'Final counter: {counter}.')
print(f'Took {time.perf_counter() - start : .2f} seconds.')

###########################################################################

counter = 0

threads = [threading.Thread(target=update, args=(pause_periods[i],)) for i in range(20)]

start = time.perf_counter()
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()

print('--Concurrent version--')
print(f'Final counter: {counter}.')
print(f'Took {time.perf_counter() - start : .2f} seconds.')

###########################################################################

print('Finished.')

将并发程序变为顺序执行

该脚本的目标是比较当前并发程序与其顺序版本的速度。在这里,我们仍然使用相同的带有锁的update()函数,并且我们将它连续运行二十次,既顺序执行又并发执行,就像我们之前做的那样。我们还创建了一个确定的暂停时间列表,以便这些时间段在模拟顺序版本和模拟并发版本时保持一致(因此,update()函数现在需要接受一个参数,指定每次调用时的暂停时间):

pause_periods = [random.randint(0, 1) for i in range(20)]

在程序的下一步中,我们只需在for循环中调用update()函数,进行二十次迭代,并跟踪循环完成所需的时间。请注意,即使这是为了模拟程序的顺序版本,update()函数仍然需要在此之前创建锁对象,因此我们在这里进行初始化:

counter = 0
count_lock = threading.Lock()

start = time.perf_counter()
for i in range(20):
    update(pause_periods[i])

print('--Sequential version--')
print(f'Final counter: {counter}.')
print(f'Took {time.perf_counter() - start : .2f} seconds.')

最后一步是重置计数器并运行我们已经实现的程序的并发版本。同样,我们需要在初始化运行update()函数的每个线程时传入相应的暂停时间。我们还要跟踪并发程序运行所需的时间:

counter = 0

threads = [threading.Thread(target=update, args=(pause_periods[i],)) for i in range(20)]

start = time.perf_counter()
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()

print('--Concurrent version--')
print(f'Final counter: {counter}.')
print(f'Took {time.perf_counter() - start : .2f} seconds.')

现在,在您运行脚本之后,您会观察到我们的程序的顺序版本和并发版本都花费了相同的时间来运行。具体来说,我得到的输出是:在这种情况下,它们都花费了大约 12 秒。您的程序实际花费的时间可能不同,但两个版本的速度应该是相等的。

> python3 example3.py
--Sequential version--
Final counter: 20.
Took 12.03 seconds.
--Concurrent version--
Final counter: 20.
Took 12.03 seconds.
Finished.

因此,我们的并发程序所花费的时间与其顺序版本一样多,这否定了在程序中实现并发的最大目的之一:提高速度。但为什么具有相同指令和元素集的并发和传统顺序应用程序也具有相同的速度?并发程序是否总是比顺序程序产生更快的速度?

回想一下,在我们的程序中,临界区由一个锁对象保护,没有多个线程可以同时访问它。由于程序的所有执行(对计数器进行 20 次递增)都取决于一个线程访问临界区,因此在临界区放置锁对象意味着在给定时间内只有一个线程可以执行。根据这个规范,任何两个线程的执行都不会重叠,这种并发实现无法获得额外的速度。

这是我们在分析死锁问题时遇到的现象:如果在并发程序中放置了足够多的锁,那么该程序将变得完全顺序化。这就是为什么锁有时不是并发编程问题的理想解决方案的原因。然而,只有当并发程序的所有执行都依赖于与临界区交互时,才会出现这种情况。大多数情况下,读取和操作共享资源的数据只是整个程序的一部分,因此并发仍然可以为我们的程序提供预期的额外速度。

锁不会锁任何东西

锁的另一个方面是它们实际上并没有锁住任何东西。锁对象与特定共享资源的交互线程和进程也需要与锁进行交互。换句话说,如果这些线程和进程选择在访问和更改共享资源之前不检查锁,那么锁对象本身就无法阻止它们这样做。

在我们的示例中,您已经看到,为了实现锁对象的获取/释放过程,线程或进程的指令将被锁上下文管理器包裹;这个规范取决于线程/进程执行逻辑的实现,而不是资源。这是因为我们看到的锁对象与它们所应保护的资源没有任何连接。因此,如果线程/进程执行逻辑不需要与共享资源相关联的锁对象进行任何交互,那么该线程或进程可以简单地访问资源而无需困难,可能导致数据的错误操作和损坏。

这不仅适用于在单个并发程序中拥有多个线程和进程的范围。假设我们有一个由多个组件组成的并发系统,所有这些组件都相互作用并操作跨系统共享的资源的数据,并且这个资源与一个锁对象相关联;由此可见,如果其中任何一个组件未能与该锁进行交互,它可以简单地绕过锁实施的保护并访问共享资源。更重要的是,锁的这种特性也对并发程序的安全性有着重要的影响。如果一个外部的恶意代理连接到系统(比如,一个恶意客户端与服务器进行交互)并且意图破坏跨系统共享的数据,那么该代理可以被指示简单地忽略锁对象并以侵入的方式访问数据。

锁不锁任何东西的观点是由雷蒙德·赫廷格(Raymond Hettinger)提出的,他是 Python 核心开发人员,负责实现 Python 并发编程中的各种元素。有人认为仅使用锁对象并不能保证并发数据结构和系统的安全实现。锁需要与它们要保护的资源具体关联起来,没有任何东西应该能够在未先获取与之相关联的锁的情况下访问资源。或者,其他并发同步工具,比如原子消息队列,可以提供解决这个问题的方案。

现实生活中的竞争条件

现在你已经了解了竞争条件的概念,它们在并发系统中是如何引起的,以及如何有效地防止它们。在本节中,我们将提供一个关于竞争条件如何在计算机科学的各个子领域中发生的总体观点。具体来说,我们将讨论安全、文件管理和网络的主题。

安全

并发编程对系统安全性可能会产生重大影响。回想一下,读取和更改资源数据的过程之间会出现竞争条件;在认证系统中出现竞争条件可能会导致在检查代理的凭据和代理可以利用资源之间数据的损坏。这个问题也被称为检查时间到使用时间(TOCTTOU)漏洞,这无疑对安全系统有害。

在处理竞争条件时对共享资源的粗心保护可以为外部代理提供访问那些被认为受到保护的资源的机会。然后这些代理可以改变资源的数据以创建权限提升(简单来说,给自己更多非法访问更多共享资源的权限),或者他们可以简单地破坏数据,导致整个系统发生故障。

有趣的是,竞争条件也可以用于实现计算机安全。由于竞争条件是由多个线程/进程对共享资源的不协调访问导致的,竞争条件发生的规范是相当随机的。例如,在我们自己的 Python 示例中,你看到在模拟竞争条件时,计数器的最终值在程序的不同执行之间变化;这部分是因为情况的不可预测性,其中多个线程正在运行并访问共享资源。(我说部分是因为随机性也是由我们在每次执行程序时生成的随机暂停期间导致的。)因此,有时会故意引发竞争条件,并且在竞争条件发生时获得的信息可以用于生成安全流程的数字指纹——这些信息同样是相当随机的,因此对安全目的而言具有价值。

操作系统

在操作系统中的文件和内存管理的背景下,竞争条件可能会发生,当两个单独的程序尝试访问相同的资源,如内存空间。想象一种情况,两个来自不同程序的进程已经运行了相当长的时间,尽管它们最初在内存空间方面是分开初始化的,但足够的数据已经积累,一个进程的执行堆栈现在与另一个进程的执行堆栈发生了冲突。这可能导致两个进程共享相同的内存空间部分,并最终导致不可预测的后果。

竞争条件复杂性的另一个方面是由 Unix 版本 7 操作系统中的mkdir命令所说明的。通常,mkdir命令用于在 Unix 操作系统中创建新目录;这是通过调用mknod命令创建实际目录和chown命令指定该目录的所有者来完成的。因为有两个单独的命令需要运行,并且第一个命令完成和第二个命令调用之间存在明确的间隙,这可能导致竞争条件。

在两个命令之间的间隙期间,如果有人可以删除mknod命令创建的新目录,并将引用链接到另一个文件,当运行chown命令时,该文件的所有权将被更改。通过利用这个漏洞,某人理论上可以更改操作系统中任何文件的所有权,以便某人可以创建一个新目录。以下图表进一步说明了这种利用:

mkdir竞争条件的图表

网络

在网络中,竞争条件可以以在网络中为多个用户提供独特特权的形式出现。具体来说,假设给定服务器应该只有一个用户具有管理员特权。如果两个用户,都有资格成为服务器管理员,同时请求访问这些特权,那么两者都有可能获得该访问权限。这是因为在服务器接收到两个用户请求时,两个用户都还没有被授予管理员特权,服务器认为管理员特权仍然可以分配。

这种形式的竞争条件在网络高度优化以进行并行处理时(例如,非阻塞套接字),而没有仔细考虑网络共享资源时是非常常见的。

总结

竞争条件被定义为系统输出不确定的现象,取决于调度算法和任务调度和执行的顺序。临界区指示并发应用程序中由多个进程或线程访问的共享资源,这可能导致意外甚至错误的行为。当两个或多个线程/进程同时访问和更改共享资源时,就会发生竞争条件,导致数据处理不当和损坏。竞争条件在现实生活应用中也有重要影响,如安全性、操作系统和网络。

由于我们观察到的竞争条件是在多个线程或进程同时访问和写入共享资源时出现的,解决竞争条件的关键思想是隔离不同线程/进程的执行,特别是在与共享资源交互时。使用锁,我们可以将并发程序中的共享资源转换为临界区,其数据的完整性得到保护。然而,使用锁也有许多缺点:在并发程序中实现了足够多的锁,整个程序可能变成顺序执行;锁并不真正锁定任何东西。

在下一章中,我们将考虑 Python 并发编程中最大的问题之一:臭名昭著的全局解释器锁(GIL)。您将了解 GIL 背后的基本思想,它的目的,以及如何在并发 Python 应用程序中有效地使用它。

问题

  • 什么是临界区?

  • 什么是竞争条件,为什么在并发程序中是不可取的?

  • 竞争条件的根本原因是什么?

  • 锁如何解决竞争条件的问题?

  • 为什么锁有时在并发程序中是不可取的?

  • 在现实生活系统和应用中,竞争条件的重要性是什么?

进一步阅读

欲了解更多信息,您可以参考以下链接:

  • 使用 Python 进行并行编程,作者 Jan Palach,Packt Publishing Ltd,2014

  • Python 并行编程食谱,作者 Giancarlo Zaccone,Packt Publishing Ltd,2015

  • 竞争条件和临界区tutorials.jenkov.com/java-concurrency/race-conditions-and-critical-sections),作者 Jakob Jenkov

  • 竞争条件、文件和安全漏洞;或乌龟和野兔的重现,作者 Matt Bishop,技术报告 CSE-95-98(1995)

  • 计算机和信息安全,第十一章,软件缺陷和恶意软件 1 插图slideplayer.com/slide/10319860/

第十五章:全局解释器锁

Python 并发编程中的主要参与者之一是全局解释器锁(GIL)。在本章中,我们将介绍 GIL 的定义和目的,以及它对并发 Python 应用程序的影响。还将讨论 GIL 对 Python 并发系统造成的问题以及其实施引起的争议。最后,我们将提到一些关于 Python 程序员和开发人员应该如何思考和与 GIL 交互的想法。

本章将涵盖以下主题:

  • 对 GIL 的简要介绍:它是如何产生的,以及它引起的问题

  • 在 Python 中消除/修复 GIL 的努力

  • 如何有效地处理 Python 并发程序中的 GIL

技术要求

以下是本章的先决条件列表:

全局解释器锁简介

GIL 在 Python 并发编程社区中非常受欢迎。设计为一种锁,它只允许一个线程在任何给定时间访问和控制 Python 解释器,Python 中的 GIL 通常被称为臭名昭著的 GIL,它阻止多线程程序达到其完全优化的速度。在本节中,我们将讨论 GIL 背后的概念及其目标:为什么它被设计和实施,以及它如何影响 Python 中的多线程编程。

Python 中内存管理的分析

在我们深入讨论 GIL 及其影响之前,让我们考虑 Python 核心开发人员在 Python 早期遇到的问题,以及这些问题引发了对 GIL 的需求。具体来说,在内存空间中管理对象方面,Python 编程与其他流行语言的编程存在显着差异。

例如,在编程语言 C++中,变量实际上是内存空间中将写入值的位置。这种设置导致了一个事实,即当非指针变量被赋予特定值时,编程语言将有效地将该特定值复制到内存位置(即变量)。此外,当一个变量被赋予另一个变量(不是指针)时,后者的内存位置将被复制到前者的内存位置;在赋值后,这两个变量之间将不再保持任何连接。

另一方面,Python 将变量视为简单的名称,而变量的实际值则隔离在内存空间的另一个区域。当一个值被赋给一个变量时,变量实际上被赋予了对该值在内存空间中位置的引用(即使引用这个术语并不像 C++中的引用那样使用)。因此,Python 中的内存管理与我们在 C++中看到的将值放入内存空间的模型根本不同。

这意味着当执行赋值指令时,Python 只是与引用交互并将它们切换,而不是实际的值本身。此外,出于这个原因,多个变量可以被同一个值引用,并且一个变量所做的更改将在所有其他相关变量中反映出来。

让我们分析 Python 中的这个特性。如果您已经从 GitHub 页面下载了本书的代码,请转到Chapter15文件夹。让我们看一下Chapter15/example1.py文件,如下所示:

# Chapter15/example1.py

import sys

print(f'Reference count when direct-referencing: {sys.getrefcount([7])}.')

a = [7]
print(f'Reference count when referenced once: {sys.getrefcount(a)}.')

b = a
print(f'Reference count when referenced twice: {sys.getrefcount(a)}.')

###########################################################################

a[0] = 8
print(f'Variable a after a is changed: {a}.')
print(f'Variable b after a is changed: {b}.')

print('Finished.')

在这个例子中,我们正在管理值[7](一个元素的列表:整数7)。我们提到 Python 中的值是独立于变量存储的,Python 中的值管理只是将变量引用到适当的值。Python 中的sys.getrefcount()方法接受一个对象并返回与该对象关联的值的所有引用的计数。在这里,我们调用sys.getrefcount()三次:在实际值[7]上;分配给值的变量a;最后,分配给变量a的变量b

此外,我们正在探讨通过使用与之引用的变量来改变值的过程,以及与该值相关联的所有变量的结果值。具体来说,我们通过变量a来改变列表的第一个元素,并打印出ab的值。运行脚本,你的输出应该类似于以下内容:

> python3 example1.py
Reference count when direct-referencing: 1.
Reference count when referenced once: 2.
Reference count when referenced twice: 3.
Variable a after a is changed: [8].
Variable b after a is changed: [8].
Finished.

正如你所看到的,这个输出与我们讨论的一致:对于第一个sys.getrefcount()函数调用,值[7]只有一个引用计数,当我们直接引用它时创建;当我们将列表分配给变量a时,该值有两个引用,因为a现在与该值相关联;最后,当a分配给b时,[7]还被b引用,引用计数现在是三。

在程序的第二部分输出中,我们可以看到,当我们改变变量a引用的值时,[7]被改变了,而不是变量a。结果,引用与a相同的变量b的值也被改变了。

下图说明了这个过程。在 Python 程序中,变量(ab)只是简单地引用实际值(对象),两个变量之间的赋值语句(例如,a = b)指示 Python 让这两个变量引用相同的对象(而不是将实际值复制到另一个内存位置,就像在 C++中一样):

Python 引用方案的图示

GIL 解决的问题

牢记 Python 对内存和变量管理的实现,我们可以看到 Python 中对给定值的引用在程序中不断变化,因此跟踪值的引用计数非常重要。

现在,应用你在第十四章中学到的竞争条件,你应该知道在 Python 并发程序中,这个引用计数是一个需要保护免受竞争条件影响的共享资源。换句话说,这个引用计数是一个关键部分,如果处理不慎,将导致对特定值引用的变量数量的错误解释。这将导致内存泄漏,使 Python 程序显着低效,并且甚至可能释放实际上被一些变量引用的内存,永久丢失该值。

正如你在上一章中学到的,确保不会发生关于特定共享资源的竞争条件的解决方案是在该资源上放置一个锁,从而在并发程序中最多允许一个线程访问该资源。我们还讨论了,如果在并发程序中放置了足够多的锁,那么该程序将变得完全顺序化,并且通过实现并发性将不会获得额外的速度。

GIL 是对前面两个问题的解决方案,是 Python 整个执行过程中的一个单一锁。任何想要执行的 Python 指令(CPU 密集型任务)必须首先获取 GIL,以防止任何引用计数的竞争条件发生。

在 Python 语言开发的早期,也提出了其他解决这个问题的方案,但 GIL 是迄今为止最有效和最简单实现的。由于 GIL 是 Python 整个执行过程的轻量级全局锁,因此不需要实现其他锁来保证其他关键部分的完整性,从而将 Python 程序的性能开销降到最低。

GIL 引发的问题

直观地说,由于锁保护了 Python 中的所有 CPU 绑定任务,因此并发程序将无法完全实现多线程。GIL 有效地阻止了 CPU 绑定任务在多个线程之间并行执行。为了理解 GIL 这一特性的影响,让我们来看一个 Python 中的例子;转到Chapter15/example2.py

# Chapter15/example2.py

import time
import threading

COUNT = 50000000

def countdown(n):
    while n > 0:
        n -= 1

###########################################################################

start = time.time()
countdown(COUNT)

print('Sequential program finished.')
print(f'Took {time.time() - start : .2f} seconds.')

###########################################################################

thread1 = threading.Thread(target=countdown, args=(COUNT // 2,))
thread2 = threading.Thread(target=countdown, args=(COUNT // 2,))

start = time.time()
thread1.start()
thread2.start()
thread1.join()
thread2.join()

print('Concurrent program finished.')
print(f'Took {time.time() - start : .2f} seconds.')

在这个例子中,我们比较了在 Python 中顺序执行和并发执行(通过多线程)一个特定程序的速度。具体来说,我们有一个名为countdown()的函数,模拟了一个重型 CPU 绑定任务,它接受一个数字n,并将其递减直到变为零或负数。然后,我们将countdown()在 5000 万上顺序执行一次。最后,我们将该函数分别在两个线程中调用,每个线程上执行 2500 万次,这正好是 5000 万的一半;这是程序的多线程版本。我们还记录了 Python 运行顺序程序和多线程程序所需的时间。

理论上,程序的多线程版本应该比顺序版本快一半,因为任务实际上被分成两半并且通过我们创建的两个线程并行运行。然而,程序产生的输出表明了相反的情况。通过运行脚本,我得到了以下输出:

> python3 example2.py
Sequential program finished.
Took 2.80 seconds.
Concurrent program finished.
Took 2.74 seconds.

与我们预测的相反,倒计时的并发版本几乎与顺序版本一样长;多线程对我们的程序并没有提供任何显著的加速。这是由于 GIL 保护 CPU 绑定任务的直接影响,多个线程不被允许同时运行。有时,多线程程序甚至可能比其顺序对应物更长时间才能完成执行,因为还有获取和释放 GIL 的开销。

这无疑是多线程和 Python 中的并发编程的一个重大问题,因为只要程序包含 CPU 绑定指令,这些指令实际上会在程序的执行中是顺序的。然而,不是 CPU 绑定的指令发生在 GIL 之外,因此不受 GIL 的影响(例如 I/O 绑定指令)。

从 Python 中潜在删除 GIL

您已经了解到,GIL 对我们在 Python 中编写的多线程程序产生了重大限制,特别是对于那些包含 CPU 绑定任务的程序。因此,许多 Python 开发人员开始对 GIL 持负面看法,术语“臭名昭著的 GIL”开始变得流行;毫不奇怪,一些人甚至主张从 Python 语言中完全删除 GIL。

事实上,一些知名的 Python 用户曾多次尝试去除 GIL。然而,GIL 在语言的实现中根深蒂固,而大多数不支持多线程的库和包的执行都严重依赖于 GIL,因此去除 GIL 实际上会引发 Python 程序的错误以及向后不兼容性问题。一些 Python 开发人员和研究人员曾试图完全省略 Python 执行中的 GIL,结果大多数现有的 C 扩展都无法正常工作,因为它们严重依赖于 GIL 的功能。

现在有其他可行的解决方案来解决我们讨论过的问题;换句话说,GIL 在任何情况下都是可以替代的。然而,大多数这些解决方案包含如此复杂的指令,以至于它们实际上会降低顺序和 I/O 受限程序的性能,而这些程序并不受 GIL 的影响。因此,这些解决方案将减慢单线程或多线程 I/O 程序的速度,而这些程序实际上占现有 Python 应用程序的很大比例。有趣的是,Python 的创始人 Guido van Rossum 在他的文章《移除 GIL 并不容易》中也对这个话题发表了评论:

我只会欢迎一组补丁进入 Py3k,只要单线程程序的性能(以及多线程但 I/O 受限的程序)不会下降。

不幸的是,没有任何提出的 GIL 替代方案实现了这一要求。GIL 仍然是 Python 语言的一个重要部分。

如何处理 GIL

有几种方法可以处理你的 Python 应用程序中的 GIL,将在下文中讨论。

实施多进程,而不是多线程

这可能是规避 GIL 并在并发程序中实现最佳速度的最流行和最简单的方法。由于 GIL 只阻止多个线程同时执行 CPU 受限任务,因此在系统的多个核心上执行的进程,每个进程都有自己的内存空间,完全不受 GIL 的影响。

具体来说,考虑前面的倒计时示例,让我们比较一下当它是顺序的、多线程的和多进程的时候,那个 CPU 受限程序的性能。导航到Chapter15/example3.py文件;程序的第一部分与我们之前看到的是相同的,但在最后,我们添加了一个从 5000 万开始倒计时的多进程解决方案的实现,使用了两个独立的进程:

# Chapter15/example3.py

import time
import threading
from multiprocessing import Pool

COUNT = 50000000

def countdown(n):
    while n > 0:
        n -= 1

if __name__ == '__main__':

    #######################################################################
    # Sequential

    start = time.time()
    countdown(COUNT)

    print('Sequential program finished.')
    print(f'Took {time.time() - start : .2f} seconds.')
    print()

    #######################################################################
    # Multithreading

    thread1 = threading.Thread(target=countdown, args=(COUNT // 2,))
    thread2 = threading.Thread(target=countdown, args=(COUNT // 2,))

    start = time.time()
    thread1.start()
    thread2.start()
    thread1.join()
    thread2.join()

    print('Multithreading program finished.')
    print(f'Took {time.time() - start : .2f} seconds.')
    print()

    #######################################################################
    # Multiprocessing

    pool = Pool(processes=2)
    start = time.time()
    pool.apply_async(countdown, args=(COUNT//2,))
    pool.apply_async(countdown, args=(COUNT//2,))
    pool.close()
    pool.join()

    print('Multiprocessing program finished.')
    print(f'Took {time.time() - start : .2f} seconds.')

运行程序后,我的输出如下:

> python3 example3.py
Sequential program finished.
Took 2.95 seconds.

Multithreading program finished.
Took 2.69 seconds.

Multiprocessing program finished.
Took 1.54 seconds.

顺序和多线程版本的程序之间仍然存在微小的速度差异。然而,多进程版本能够将执行速度减少了近一半;正如前几章讨论的那样;由于进程相当沉重,多进程指令包含了显著的开销,这就是为什么多进程程序的速度并不完全是顺序程序的一半的原因。

利用本地扩展规避 GIL

有一些用 C/C++编写的 Python 本地扩展,因此能够避免 GIL 设置的限制;一个例子是最流行的 Python 科学计算包 NumPy。在这些扩展中,可以进行 GIL 的手动释放,以便执行可以简单地绕过锁。然而,这些释放需要谨慎实施,并在执行返回到主 Python 执行之前伴随着 GIL 的重新断言。

利用不同的 Python 解释器

GIL 只存在于 CPython 中,这是迄今为止最常用的语言解释器,它是用 C 构建的。然而,Python 还有其他解释器,比如 Jython(用 Java 编写)和 IronPython(用 C++编写),可以用来避免 GIL 及其对多线程程序的影响。请记住,这些解释器并不像 CPython 那样广泛使用,一些包和库可能与其中一个或两个不兼容。

总结

虽然 Python 中的 GIL 为语言中的一个更难的问题提供了一个简单而直观的解决方案,但它也提出了一些自己的问题,涉及在 Python 程序中运行多个线程以处理 CPU 受限任务的能力。已经有多次尝试从 Python 的主要实现中删除 GIL,但没有一次能够在保持处理非 CPU 受限任务的有效性的同时实现它。

在 Python 中,有多种方法可供选择,以提供处理 GIL 的选项。总的来说,虽然它在 Python 编程社区中声名显赫,但 GIL 只影响 Python 生态系统的一部分,并且可以被视为一种必要的恶,因为它对于从语言中移除来说太重要了。Python 开发人员应该学会与 GIL 共存,并在并发程序中绕过它。

在最后四章中,我们讨论了 Python 中并发编程中最著名和常见的一些问题。在本书的最后一节中,我们将研究 Python 提供的一些更高级的并发功能。在下一章中,您将了解无锁和基于锁的并发数据结构的设计。

问题

  • Python 和 C++之间的内存管理有哪些区别?

  • GIL 为 Python 解决了什么问题?

  • GIL 为 Python 带来了什么问题?

  • 在 Python 程序中规避 GIL 的一些方法是什么?

进一步阅读

欲了解更多信息,您可以参考以下链接:

第十六章:设计基于锁和无互斥锁的并发数据结构

在本章中,我们将分析设计和实现并发编程中两种常见类型的数据结构的详细过程:基于锁和无互斥锁。将讨论这两种数据结构之间的主要区别,以及它们在并发编程中的使用。在整个章节中,还提供了并发程序准确性和速度之间的权衡分析。通过这种分析,读者将能够为自己的并发应用程序应用相同的权衡分析。

本章将涵盖以下主题:

  • 基于锁数据结构的常见问题,以及如何解决这些问题

  • 如何实现基于锁的数据结构的详细分析

  • 无互斥锁数据结构的理念,以及与基于锁数据结构相比的优缺点

  • 如何实现无互斥锁数据结构的详细分析

技术要求

以下是本章的先决条件列表:

Python 中基于锁的并发数据结构

在前几章中,我们讨论了锁的使用,您了解到锁并不会锁住任何东西;在数据结构上实现的不牢固的锁定机制实际上并不能阻止外部程序同时访问数据结构,因为它们可以简单地绕过所施加的锁。解决这个问题的一个方法是将锁嵌入到数据结构中,这样外部实体就无法忽略锁。

在本章的第一部分中,我们将考虑锁和基于锁的数据结构的特定使用背后的理论。具体来说,我们将分析设计一个可以由不同线程安全执行的并发计数器的过程,使用锁(或互斥锁)作为同步机制。

LocklessCounter 和竞争条件

首先,让我们模拟在并发程序中使用一个天真的、无锁实现的计数器类遇到的问题。如果您已经从 GitHub 页面下载了本书的代码,请转到Chapter16文件夹。

让我们来看一下Chapter16/example1.py文件,特别是LocklessCounter类的实现:

# Chapter16/example1.py

import time

class LocklessCounter:
    def __init__(self):
        self.value = 0

    def increment(self, x):
        new_value = self.value + x
        time.sleep(0.001) # creating a delay
        self.value = new_value

    def get_value(self):
        return self.value

这是一个简单的计数器,具有名为value的属性,其中包含计数器的当前值,在计数器实例首次初始化时赋值为0。该类的increment()方法接受一个参数x,并将调用LocklessCounter对象的当前值增加x。请注意,在increment()函数内部我们创建了一个小延迟,用于计算计数器的新值和将该新值分配给计数器对象的过程之间。该类还有一个名为get_value()的方法,返回调用计数器的当前值。

很明显,这种LocklessCounter类的实现在并发程序中可能会导致竞争条件:当一个线程正在增加共享计数器时,另一个线程也可能访问计数器来执行increment()方法,并且第一个线程对计数器值的更改可能会被第二个线程所覆盖。

作为复习,以下图表显示了在多个进程或线程同时访问和改变共享资源的情况下竞争条件如何发生:

竞争条件的图示

为了模拟这种竞争条件,在我们的主程序中,我们包括了共计三个线程,将共享计数器增加 300 次:

# Chapter16/example1.py

from concurrent.futures import ThreadPoolExecutor

counter = LocklessCounter()
with ThreadPoolExecutor(max_workers=3) as executor:
    executor.map(counter.increment, [1 for i in range(300)])

print(f'Final counter: {counter.get_value()}.')
print('Finished.')

concurrent.futures模块为我们提供了一种简单且高级的方式,通过线程池调度任务。具体来说,在初始化共享计数器对象后,我们将变量executor声明为一个包含三个线程的线程池(使用上下文管理器),并且该执行器调用共享计数器的increment()方法 300 次,每次将计数器的值增加1

这些任务将在线程池中的三个线程之间执行,使用ThreadPoolExecutor类的map()方法。在程序结束时,我们只需打印出计数器对象的最终值。运行脚本后,以下代码显示了我的输出:

> python3 example1.py
Final counter: 101.
Finished.

虽然在您自己的系统上执行脚本可能会获得计数器的不同值,但计数器的最终值实际上是 300,这是正确的值,这种情况极不可能发生。此外,如果您一遍又一遍地运行脚本,可能会获得计数器的不同值,说明程序的非确定性。同样,由于一些线程覆盖了其他线程所做的更改,一些增量在执行过程中丢失了,导致计数器在这种情况下只成功增加了101次。

在计数器的数据结构中嵌入锁

良好的基于锁的并发数据结构的目标是在其类属性和方法内部实现其锁,以便外部函数和程序无法绕过这些锁并同时访问共享的并发对象。对于我们的计数器数据结构,我们将为该类添加一个额外的属性,该属性将保存与计数器的值对应的lock对象。考虑在Chapter16/example2.py文件中的数据结构的以下新实现:

# Chapter16/example2.py

import threading
import time

class LockedCounter:
    def __init__(self):
        self.value = 0
        self.lock = threading.Lock()

    def increment(self, x):
        with self.lock:
            new_value = self.value + x
            time.sleep(0.001) # creating a delay
            self.value = new_value

    def get_value(self):
        with self.lock:
            value = self.value

        return value

在我们的计数器数据结构实现中,还初始化了一个lock对象作为LockedCounter实例的属性,当初始化该实例时。此外,每当线程访问计数器的值时,无论是读取(get_value()方法)还是更新(increment()方法),都必须获取该lock属性,以确保没有其他线程也在访问它。这是通过使用lock属性的上下文管理器来实现的。

理论上,这种实现应该为我们解决竞争条件的问题。在我们的主程序中,我们正在实现与上一个示例中使用的相同的线程池。将创建一个共享计数器,并且它将在三个不同的线程中被增加 300 次(每次增加一个单位):

# Chapter16/example2.py

from concurrent.futures import ThreadPoolExecutor

counter = LockedCounter()
with ThreadPoolExecutor(max_workers=3) as executor:
    executor.map(counter.increment, [1 for i in range(300)])

print(f'Final counter: {counter.get_value()}.')
print('Finished.')

运行脚本,程序产生的输出应与以下类似:

> python3 example2.py
Final counter: 300.
Finished.

如您所见,竞争条件的问题已经成功解决:计数器的最终值为300,完全对应于执行的增量数量。此外,无论程序运行多少次,计数器的值始终保持为300。我们目前拥有的是一个可并发计数器的工作正确的数据结构。

可扩展性的概念

编程中一个重要的方面是可扩展性。可扩展性指的是当程序要处理的任务数量增加时,性能的变化。Software Performance and Scalability Consulting, LLC 的创始人兼总裁 Andre B. Bondi 将可扩展性定义为“系统、网络或进程处理不断增长的工作量的能力,或者其扩大以适应这种增长的潜力。”

在并发编程中,可伸缩性是一个重要的概念,总是需要考虑;在并发编程中增长的工作量通常是要执行的任务数量,以及执行这些任务的活动进程和线程的数量。例如,并发应用程序的设计、实现和测试阶段通常涉及相当少量的工作,以促进高效和快速的开发。这意味着典型的并发应用程序在实际情况下将处理比在开发阶段更多的工作。这就是为什么可伸缩性分析在设计良好的并发应用程序中至关重要。

由于进程或线程的执行是独立于另一个进程的执行的,只要单个进程/线程负责的工作量保持不变,我们希望进程/线程数量的变化不会影响程序的性能。这种特性称为完美的可伸缩性,是并发程序的理想特性;如果给定的完全可伸缩的并发程序的工作量增加,程序可以简单地创建更多的活动进程或线程,以吸收增加的工作量。其性能可以保持稳定。

然而,由于创建线程和进程的开销,完美的可伸缩性在大多数情况下几乎是不可能实现的。也就是说,如果并发程序的性能随着活动进程或线程数量的增加而没有明显恶化,那么我们可以接受可伸缩性。明显恶化这个术语在很大程度上取决于并发程序负责执行的任务类型,以及允许程序性能下降的程度有多大。

在这种分析中,我们将考虑一个二维图表,表示给定并发程序的可伸缩性。x轴表示活动线程或进程的数量(每个线程或进程负责在整个程序中执行固定数量的工作);y轴表示程序的速度,具有不同数量的活动线程或进程。所考虑的图表将具有一般上升的趋势;程序拥有的进程/线程越多,程序执行所需的时间(很可能)就越长。另一方面,完美的可伸缩性将转化为水平线,因为增加线程/进程数量时不需要额外的时间。

以下图表是可伸缩性分析的示例:

可伸缩性分析示例(来源:stackoverflow.com/questions/10660990/c-sharp-server-scalability-issue-on-linux)

在前面的图表中,x轴表示执行线程/进程的数量,y轴表示运行时间(在这种情况下为秒)。不同的图表表示特定设置的可伸缩性(操作系统与多个核心的组合)。

图表的斜率越陡,相应的并发模型随着线程/进程数量的增加而扩展得越差。例如,水平线(在这种情况下为深蓝色和最低的图表)表示完美的可伸缩性,而黄色(最上面的)图表表示不良的可伸缩性。

对计数器数据结构的可伸缩性分析

现在,让我们考虑我们当前计数器数据结构的可扩展性——具体来说,是随着活动线程数量的变化。我们有三个线程为共享计数器增加了总共 300 次;因此,在我们的可扩展性分析中,我们将使每个活动线程为共享计数器增加 100 次,同时改变程序中的活动线程数量。根据前述的可扩展性规范,我们将看看在线程数量增加时使用计数器数据结构的程序的性能(速度)如何变化。

考虑Chapter16/example3.py文件,如下所示:

# Chapter16/example3.py

import threading
from concurrent.futures import ThreadPoolExecutor
import time
import matplotlib.pyplot as plt

class LockedCounter:
    def __init__(self):
        self.value = 0
        self.lock = threading.Lock()

    def increment(self, x):
        with self.lock:
            new_value = self.value + x
            time.sleep(0.001) # creating a delay
            self.value = new_value

    def get_value(self):
        with self.lock:
            value = self.value

        return value

n_threads = []
times = []
for n_workers in range(1, 11):
    n_threads.append(n_workers)

    counter = LockedCounter()

    start = time.time()

    with ThreadPoolExecutor(max_workers=n_workers) as executor:
        executor.map(counter.increment, 
                     [1 for i in range(100 * n_workers)])

    times.append(time.time() - start)

    print(f'Number of threads: {n_workers}')
    print(f'Final counter: {counter.get_value()}.')
    print(f'Time taken: {times[-1] : .2f} seconds.')
    print('-' * 40)

plt.plot(n_threads, times)
plt.xlabel('Number of threads'); plt.ylabel('Time in seconds')
plt.show()

在前面的脚本中,我们仍然使用了在上一个示例中使用的LockedCounter类的相同实现。在我们的主程序中,我们正在测试这个类针对各种数量的活动线程;具体来说,我们正在迭代一个for循环,使活动线程的数量从 1 增加到 10。在每次迭代中,我们初始化一个共享计数器,并创建一个线程池来处理适当数量的任务——在这种情况下,为每个线程增加共享计数器 100 次。

我们还跟踪活动线程的数量,以及线程池完成任务所花费的时间。这是我们进行可扩展性分析的数据。我们将打印出这些数据,并绘制一个类似于前面示例图中的可扩展性图表。

以下代码显示了我运行脚本的输出:

> python3 example3.py
Number of threads: 1
Final counter: 100.
Time taken: 0.15 seconds.
----------------------------------------
Number of threads: 2
Final counter: 200.
Time taken: 0.28 seconds.
----------------------------------------
Number of threads: 3
Final counter: 300.
Time taken: 0.45 seconds.
----------------------------------------
Number of threads: 4
Final counter: 400.
Time taken: 0.59 seconds.
----------------------------------------
Number of threads: 5
Final counter: 500.
Time taken: 0.75 seconds.
----------------------------------------
Number of threads: 6
Final counter: 600.
Time taken: 0.87 seconds.
----------------------------------------
Number of threads: 7
Final counter: 700.
Time taken: 1.01 seconds.
----------------------------------------
Number of threads: 8
Final counter: 800.
Time taken: 1.18 seconds.
----------------------------------------
Number of threads: 9
Final counter: 900.
Time taken: 1.29 seconds.
----------------------------------------
Number of threads: 10
Final counter: 1000.
Time taken: 1.49 seconds.
----------------------------------------

此外,我得到的可扩展性图如下所示:

基于锁的计数器数据结构的可扩展性

即使您自己的输出在每次迭代的具体持续时间上有所不同,可扩展性趋势应该是相对相同的;换句话说,您的可扩展性图应该与前面的图表具有相同的斜率。从我们所拥有的输出类型中可以看出,尽管每次迭代中计数器的值都是正确的,但我们当前的计数器数据结构的可扩展性非常不理想:随着程序添加更多线程来执行更多任务,程序的性能几乎是线性下降的。请记住,理想的完美可扩展性要求性能在不同数量的线程/进程之间保持稳定。我们的计数器数据结构通过与活动线程数量的增加成比例地增加程序的执行时间。

直观地,这种可扩展性的限制是由我们的锁定机制造成的:由于在任何给定时间只有一个线程可以访问和增加共享计数器,程序需要执行的增量越多,完成所有增量任务所需的时间就越长。使用锁作为同步机制的最大缺点之一是:锁可以执行并发程序(再次强调,第一个缺点是锁实际上并没有锁定任何东西)。

近似计数器作为可扩展性的解决方案

考虑到设计和实现正确但快速的基于锁的并发数据结构的复杂性,开发高效可扩展的锁定机制是计算机科学研究中的热门话题,提出了许多解决我们面临问题的方法。在本节中,我们将讨论其中之一:近似计数器

近似计数器背后的思想

让我们回顾一下我们当前的程序以及锁阻止我们在速度方面获得良好性能的原因:我们程序中的所有活动线程都与相同的共享计数器交互,这只能一次与一个线程交互。解决这个问题的方法是隔离与单独线程计数器的交互。具体来说,我们跟踪的计数器的值将不再仅由单个共享计数器对象表示;相反,我们将使用许多本地计数器,每个线程/进程一个,以及我们最初拥有的共享全局计数器

这种方法背后的基本思想是将工作(递增共享全局计数器)分布到其他低级计数器中。当一个活动线程执行并想要递增全局计数器时,首先它必须递增其对应的本地计数器。与单个共享计数器进行交互不同,与各个本地计数器进行交互具有高度可扩展性,因为只有一个线程访问和更新每个本地计数器;换句话说,不同线程之间在与各个本地计数器交互时不会发生争用。

每个线程与其对应的本地计数器交互时,本地计数器必须与全局计数器交互。具体来说,每个本地计数器将定期获取全局计数器的锁,并根据其当前值递增它;例如,如果一个值为六的本地计数器想要递增全局计数器,它将以六个单位递增,并将自己的值设为零。这是因为从本地计数器报告的所有递增都是相对于全局计数器的值的,这意味着如果一个本地计数器持有值x,全局计数器应该将其值递增x

您可以将这种设计看作是一个简单的网络,全局计数器位于中心节点,每个本地计数器都是一个后端节点。每个后端节点通过将其值发送到中心节点与中心节点交互,随后将其值重置为零。以下图示进一步说明了这种设计:

四线程近似计数器的图示

如前所述,如果所有活动线程都与相同的基于锁的计数器交互,那么无法从使程序并发化中获得额外的速度,因为不同线程之间的执行无法重叠。现在,对于每个线程有一个单独的计数器对象,线程可以独立和同时更新其对应的本地计数器,从而创建重叠,这将导致程序的速度性能更好,使程序更具可扩展性。

近似计数器这个技术的名称来源于全局计数器的值仅仅是正确值的近似。具体来说,全局计数器的值仅通过本地计数器的值计算,每次全局计数器被本地计数器之一递增时,它就变得更加准确。

然而,这种设计中有一个值得深思的规范。本地计数器应该多久与全局计数器交互并更新其值?当然不能是每次递增的速率(每次递增本地计数器时递增全局计数器),因为那将等同于使用一个共享锁,甚至有更多的开销(来自本地计数器)。

阈值 S 是用来表示所讨论的频率的数量;具体来说,阈值 S 被定义为本地计数器值的上限。因此,如果本地计数器被递增,使其值大于阈值 S,它应该更新全局计数器并将其值重置为零。阈值 S 越小,本地计数器更新全局计数器的频率就越高,我们的程序的可伸缩性就越低,但全局计数器的值将更加及时。相反,阈值 S 越大,全局计数器的值更新频率就越低,但程序的性能就会更好。

因此,近似计数对象的准确性和使用该数据结构的并发程序的可伸缩性之间存在权衡。与计算机科学和编程中的其他常见权衡类似,只有通过个人实验和测试,才能确定适合自己的近似计数数据结构的最佳阈值 S。在下一节中,当我们为近似计数数据结构实现我们自己的设计时,我们将任意将阈值 S 的值设置为 10。

在 Python 中实现近似计数器

在考虑近似计数器的概念时,让我们尝试在 Python 中实现这个数据结构,建立在我们之前基于锁的计数器的设计之上。考虑以下Chapter16/example4.py文件,特别是LockedCounter类和ApproximateCounter类:

# Chapter16/example4.py

import threading
import time

class LockedCounter:
    def __init__(self):
        self.value = 0
        self.lock = threading.Lock()

    def increment(self, x):
        with self.lock:
            new_value = self.value + x
            time.sleep(0.001) # creating a delay
            self.value = new_value

    def get_value(self):
        with self.lock:
            value = self.value

        return value

class ApproximateCounter:
    def __init__(self, global_counter):
        self.value = 0
        self.lock = threading.Lock()
        self.global_counter = global_counter
        self.threshold = 10

    def increment(self, x):
        with self.lock:
            new_value = self.value + x
            time.sleep(0.001) # creating a delay
            self.value = new_value

            if self.value >= self.threshold:
                self.global_counter.increment(self.value)
                self.value = 0

    def get_value(self):
        with self.lock:
            value = self.value

        return value

虽然LockedCounter类与之前的示例中保持不变(该类将用于实现我们的全局计数器对象),但ApproximateCounter类却很有意思,它包含了我们之前讨论的近似计数逻辑的实现。一个新初始化的ApproximateCounter对象将被赋予一个起始值为0,它也将有一个锁,因为它也是一个基于锁的数据结构。ApproximateCounter对象的重要属性是它需要报告给的全局计数器和指定它报告给相应全局计数器的速率的阈值。如前所述,这里我们只是随意选择10作为阈值的值。

ApproximateCounter类的increment()方法中,我们还可以看到相同的递增逻辑:该方法接受一个名为x的参数,并在保持调用近似计数器对象的锁的情况下递增计数器的值。此外,该方法还必须检查计数器的新递增值是否超过了它的阈值;如果是,它将增加其全局计数器的值,增加的数量等于本地计数器的当前值,并将本地计数器的值设置回0。在这个类中用于返回计数器当前值的get_value()方法与我们之前看到的是一样的。

现在,让我们在主程序中测试和比较新数据结构的可伸缩性。首先,我们将重新生成旧的单锁计数器数据结构的可伸缩性数据:

# Chapter16/example4.py

from concurrent.futures import ThreadPoolExecutor

# Previous single-lock counter

single_counter_n_threads = []
single_counter_times = []
for n_workers in range(1, 11):
    single_counter_n_threads.append(n_workers)

    counter = LockedCounter()

    start = time.time()

    with ThreadPoolExecutor(max_workers=n_workers) as executor:
        executor.map(counter.increment, 
                     [1 for i in range(100 * n_workers)])

    single_counter_times.append(time.time() - start)

就像在我们之前的示例中一样,我们使用ThreadPoolExecutor对象来并发处理任务,在单独的线程中跟踪每次迭代完成所花费的时间;这里没有什么令人惊讶的。接下来,我们将使用for循环的迭代中相应数量的活动线程生成相同的数据,如下所示:

# New approximate counters

def thread_increment(counter):
    counter.increment(1)

approx_counter_n_threads = []
approx_counter_times = []
for n_workers in range(1, 11):
    approx_counter_n_threads.append(n_workers)

    global_counter = LockedCounter()

    start = time.time()

    local_counters = [ApproximateCounter(global_counter) for i in range(n_workers)]
    with ThreadPoolExecutor(max_workers=n_workers) as executor:
        for i in range(100):
            executor.map(thread_increment, local_counters)

    approx_counter_times.append(time.time() - start)

    print(f'Number of threads: {n_workers}')
    print(f'Final counter: {global_counter.get_value()}.')
    print('-' * 40)

让我们花一些时间来分析上述代码。首先,我们有一个外部的thread_increment()函数,它接受一个计数器并将其递增 1;稍后,这个函数将被用作重构后的代码,以单独递增我们的本地计数器。

同样,我们将通过for循环来迭代分析这种新数据结构在不同数量的活动线程下的性能。在每次迭代中,我们首先初始化一个LockedCounter对象作为我们的全局计数器,以及一个本地计数器列表,这些本地计数器是ApproximateCounter类的实例。它们都与同一个全局计数器相关联(在初始化方法中传递),因为它们需要报告给同一个计数器。

接下来,类似于我们一直在为多个线程安排任务所做的,我们使用上下文管理器创建一个线程池,在其中通过嵌套的for循环分发任务(增加本地计数器)。我们循环另一个for循环是为了模拟与我们在上一个示例中实现的任务数量一致,并将这些任务同时分配到所有本地计数器上。我们还在每次迭代中打印出全局计数器的最终值,以确保我们的新数据结构正常工作。

最后,在我们的主程序中,我们将绘制从两个for循环生成的数据点,以比较两种数据结构的可伸缩性及其各自的性能:

# Chapter16/example4.py
import matplotlib.pyplot as plt

# Plotting

single_counter_line, = plt.plot(
    single_counter_n_threads,
    single_counter_times,
    c = 'blue',
    label = 'Single counter'
)
approx_counter_line, = plt.plot(
    approx_counter_n_threads,
    approx_counter_times,
    c = 'red',
    label = 'Approximate counter'
)
plt.legend(handles=[single_counter_line, approx_counter_line], loc=2)
plt.xlabel('Number of threads'); plt.ylabel('Time in seconds')
plt.show()

运行脚本,您将收到的第一个输出将包括我们第二个for循环中全局计数器的最终值,如下所示:

> python3 example4.py
Number of threads: 1
Final counter: 100.
----------------------------------------
Number of threads: 2
Final counter: 200.
----------------------------------------
Number of threads: 3
Final counter: 300.
----------------------------------------
Number of threads: 4
Final counter: 400.
----------------------------------------
Number of threads: 5
Final counter: 500.
----------------------------------------
Number of threads: 6
Final counter: 600.
----------------------------------------
Number of threads: 7
Final counter: 700.
----------------------------------------
Number of threads: 8
Final counter: 800.
----------------------------------------
Number of threads: 9
Final counter: 900.
----------------------------------------
Number of threads: 10
Final counter: 1000.
----------------------------------------

正如您所看到的,我们从全局计数器获得的最终值都是正确的,证明我们的数据结构按预期工作。此外,您将获得类似以下的图表:

单锁计数器和近似计数器的可伸缩性

蓝线表示单锁计数器数据结构速度的变化,而红线表示近似计数器数据结构的变化。正如您所看到的,即使随着线程数量的增加,近似计数器的性能略有下降(由于创建单独的本地计数器和分发增加的任务数量等开销),我们的新数据结构仍然具有很高的可伸缩性,特别是与以前的单锁计数器数据结构相比。

关于近似计数器设计的一些考虑

您可能已经注意到的一件事是,即使只有一个线程与一个本地计数器交互,数据结构在初始化时仍然具有lock属性。这是因为实际上多个线程可以共享相同的本地计数器。有时创建每个活动线程的本地计数器是低效的,因此开发人员可以让两个或更多线程共享相同的本地计数器,而个别计数器仍然可以报告给相同的全局计数器。

例如,假设有 20 个线程在并发计数器程序中执行;我们只能让 10 个本地计数器报告给一个全局计数器。从我们所见,这种设置的可伸缩性将低于为每个线程使用单独的本地计数器的设置,但这种方法的优势在于它使用更少的内存空间,并避免了创建更多本地计数器的开销。

程序中使用近似计数器的方式还有另一种可能的变化。除了只有一层本地计数器之外,我们还可以实现半全局计数器,本地计数器报告给它,然后它再报告给比自己高一级的全局计数器。在使用近似计数器数据结构时,开发人员不仅需要像之前讨论的那样找到适当的报告阈值,还需要优化与一个单个本地计数器相关联的线程数量,以及我们设计中的层数。

Python 中无互斥锁的并发数据结构

前一小节总结了我们在 Python 中设计基于锁的并发数据结构以及其中涉及的复杂性的讨论。我们现在将转向一种理论上设计无互斥锁并发数据结构的方法。

并发数据结构中的“无互斥锁”一词表示缺乏保护数据结构完整性的锁定机制。这并不意味着数据结构简单地忽视了其数据的保护;相反,数据结构必须使用其他同步机制。在本节中,我们将分析一种这样的机制,称为“读-复制-更新”,并讨论如何将其应用到 Python 数据结构中。

在 Python 中无法实现无锁

基于锁的数据结构的对立面是无锁数据结构。在这里,我们将讨论其定义以及为什么在 Python 中实际上无法实现无锁的特性,以及我们能够接近的最近的是无互斥锁。

与基于锁的数据结构不同,无锁数据结构不仅不使用任何锁定机制(如无互斥锁数据结构),而且要求任何给定的线程或进程不能无限期地等待执行。这意味着,如果成功实现了无锁数据结构,使用该数据结构的应用程序将永远不会遇到死锁和饥饿问题。因此,无锁数据结构被广泛认为是并发编程中更高级的技术,因此它们要难得多地实现。

然而,无锁的特性实际上是无法在 Python(或者更具体地说,在 CPython 解释器中)中实现的。您可能已经猜到,这是由于 GIL 的存在,它阻止多个线程在任何给定时间在 CPU 中执行。要了解有关 GIL 的更多信息,请转到第十五章,“全局解释器锁”,并阅读有关 GIL 的深入分析,如果您还没有阅读的话。总的来说,在 CPython 中实现纯粹的无锁数据结构是一个逻辑上的不可能。

然而,这并不意味着 Python 中的并发程序不能从设计无锁数据结构中受益。如前所述,无互斥锁的 Python 数据结构(可以被视为无锁数据结构的子集)是完全可以实现的。事实上,无互斥锁的数据结构仍然可以成功避免死锁和饥饿问题。然而,它们无法充分利用纯粹的无锁执行,这将导致更快的速度。

在接下来的小节中,我们将研究 Python 中的自定义数据结构,分析如果同时使用会引发的问题,并尝试将无互斥锁的逻辑应用到底层数据结构中。

网络数据结构介绍

我们正在实现的数据结构类似于一个节点网络,其中一个节点是主节点。此外,每个节点都包含一个键和一个节点的值。您可以将这个数据结构看作是一个 Python 字典(换句话说,一组键和值分别配对在一起),但其中一个键和值对被称为网络的主节点。

一个很好的方式来可视化这种数据结构是分析使用该数据结构的情况。假设您被要求实现一个流行网站的请求处理逻辑,这个网站也不幸地是拒绝服务(DoS)攻击的常见目标。由于网站很可能会经常被关闭,尽管网络安全团队的努力,您可以采取的一种方法是在服务器上保留除主网站之外的多个工作副本,以确保网站的客户仍然能够访问它。

这些副本在每个方面等同于主网站,因此主网站可以随时完全被任何副本替换。现在,如果主网站被 DoS 攻击关闭,作为服务器管理员,您可以简单地允许主网站关闭并将新主网站的地址切换到您准备好的任何一个副本。因此,网站的客户在访问网站数据时不会遇到任何困难或不一致,因为副本与被关闭的主网站相同。另一方面,不实现此机制的服务器很可能需要花费一些时间来从 DoS 攻击中恢复(隔离攻击,重建中断或损坏的数据等)。

此时,可以建立这种网站管理方法与上述网络数据结构之间的联系。实际上,网络数据结构本质上是该方法的高级抽象;数据结构是一组节点或值对(在前面的情况下是网站地址和数据),同时跟踪一个主节点,也可以被任何其他节点替换(当主网站受到攻击时,访问网站的客户被引导到新网站)。我们将称这个处理为我们数据结构中的刷新主要,如下图所示:

网络主要刷新的图表

在上图中,我们的网络数据结构中有三个独立的数据节点(可视化为字典,用一对大括号表示):键A,指向某些数据;键B,指向其自己的数据;最后,键C,也指向其自己的数据。此外,我们有一个指针指示我们字典网络的主键,指向键A。随着主要刷新过程的进行,我们将停止跟踪键A(即主键)及其自身,然后将主指针指向网络中的另一个节点(在本例中为键B)。

在 Python 中实现一个简单的网络数据结构和竞争条件

让我们考虑 Python 中这种数据结构的起始实现。按照以下方式导航到Chapter16/network.py文件:

# Chapter16/network.py

import time
from random import choice

class Network:
    def __init__(self, primary_key, primary_value):
        self.primary_key = primary_key
        self.data = {primary_key: primary_value}

    def __str__(self):
        result = '{\n'
        for key in self.data:
            result += f'\t{key}: {self.data[key]};\n'

        return result + '}'

    def add_node(self, key, value):
        if key not in self.data:
            self.data[key] = value
            return True

        return False

    # precondition: the object has more than one node left
    def refresh_primary(self):
        del self.data[self.primary_key]
        self.primary_key = choice(list(self.data))

    def get_primary_value(self):
        primary_key = self.primary_key
        time.sleep(1) # creating a delay
        return self.data[primary_key]

这个文件包含了Network类,它实现了我们之前讨论过的逻辑。在初始化时,这个类的每个实例在其网络中至少有一个节点(存储在data属性中),这是它的主节点;我们还使用 Python 的字典数据结构来实现这个网络设计。每个对象还必须跟踪其主要数据的键,存储在其primary_key属性中。

在这个类中,我们还有一个add_node()方法,用于向网络对象添加新的数据节点;请注意,每个节点都必须有一个键和一个值。回想一下我们的网络管理示例——这对应于一个互联网地址和网站所拥有的数据。该类还有一个refresh_primary()方法,用于模拟刷新主要过程(删除对先前主要数据的引用,并从剩余节点中伪随机选择一个新的主节点)。请记住,这个方法的前提是调用网络对象必须至少还有两个节点。

最后,我们有一个叫做get_primary_value()的访问方法,它返回调用网络对象的主键指向的值。在这里,我们在方法的执行中添加了轻微的延迟,以模拟使用这种天真的数据结构会发生的竞争条件。(另外,我们正在重写默认的__str__()方法,以便进行简单的调试。)

现在,让我们把注意力转向Chapter16/example5.py文件,在这里我们导入这个数据结构并在一个并发程序中使用它:

# Chapter16/example5.py

from network import Network
import threading

def print_network_primary_value():
    global my_network

    print(f'Current primary value: {my_network.get_primary_value()}.')

my_network = Network('A', 1)
print(f'Initial network: {my_network}')
print()

my_network.add_node('B', 1)
my_network.add_node('C', 1)
print(f'Full network: {my_network}')
print()

thread1 = threading.Thread(target=print_network_primary_value)
thread2 = threading.Thread(target=my_network.refresh_primary)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print(f'Final network: {my_network}')
print()

print('Finished.')

首先,我们实现了一个名为print_network_primary_value()的函数,它使用前面提到的get_primary_value()方法访问和获取网络对象的主要数据,这也是一个全局变量。在我们的主程序中,我们使用起始节点初始化了一个网络对象,A作为节点键,1作为节点数据(这个节点也自动成为主节点)。然后我们向这个网络添加了另外两个节点:B指向1C也指向1

现在,初始化并启动了两个线程,第一个调用print_network_primary_value()函数打印出网络的当前主要数据。第二个调用网络对象的refresh_primary()方法。我们还在程序的各个点打印出网络对象的当前状态。

很容易发现这里可能会发生竞争条件:因为第一个线程正在尝试访问主要数据,而第二个线程正在尝试刷新网络的数据(实质上,在那个时候删除当前的主要数据),第一个线程很可能会在执行过程中引发错误。具体来说,运行脚本后,以下是我的输出:

> python3 example5.py
Initial network: {
 A: 1;
}

Full network: {
 A: 1;
 B: 1;
 C: 1;
}

Exception in thread Thread-1:
Traceback (most recent call last):
 File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/threading.py", line 917, in _bootstrap_inner
 self.run()
 File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/threading.py", line 865, in run
 self._target(*self._args, **self._kwargs)
 File "example5.py", line 7, in print_network_primary_value
 print(f'Current primary value: {my_network.get_primary_value()}.')
 File "/Users/quannguyen/Documents/python/mastering_concurrency/ch16/network.py", line 30, in get_primary_value
 return self.data[primary_key]
KeyError: 'A'

Final network: {
 B: 1;
 C: 1;
}

Finished.

就像我们讨论过的那样,我们遇到了一个KeyError,这是因为第一个线程获取主键的时候,该键和主要数据已经被第二个线程的执行从数据结构中删除了。下面的图表进一步说明了这一点:

网络数据结构的竞争条件

正如你在之前的章节中看到的,我们在数据结构的源代码中使用了time.sleep()函数,以确保竞争条件会发生。大多数情况下,执行速度会足够快,不会出现错误,但竞争条件仍然存在,这是我们当前数据结构中需要解决的问题。

RCU 作为解决方案

我们遇到的竞争条件的根源是,我们知道,我们正在使用的网络对象在不同的线程之间共享,这些线程同时对数据结构进行变异和读取数据。具体来说,我们程序中的第二个线程正在变异数据(通过调用refresh_primary()方法),而第一个线程正在从相同的数据中读取。

显然,我们可以简单地将锁定应用为该数据结构的同步机制。然而,我们知道获取和释放锁的任务涉及一些成本,随着数据结构在系统中被广泛使用,这些成本将变得相当可观。由于流行的网站和系统(即 MongoDB)使用此抽象来设计和构造其服务器,因此高水平的流量将使使用锁的成本显而易见,并导致性能下降。实现近似数据结构的变体可能有助于解决此问题,但实现的复杂性可能会被证明难以跟进。

因此,我们的目标是使用无互斥量的方法作为我们的同步机制——在这种情况下是读-复制-更新RCU)。为了保护数据结构的完整性,RCU 本质上是一种同步机制,当线程或进程请求读取或写入访问时,它会创建并维护数据结构的另一个版本。通过在单独的副本中隔离数据结构和线程/进程之间的交互,RCU 确保不会发生冲突的数据。当线程或进程改变了其分配的数据结构副本中的信息时,该更新可以报告给原始数据结构。

简而言之,当共享数据结构有线程或进程请求访问它(读取过程)时,它需要返回自身的副本,而不是让线程/进程访问其自己的数据(复制过程);最后,如果副本中的数据结构发生任何更改,它们将需要更新回共享数据结构(更新过程)。

RCU 对于需要同时处理单个更新程序和多个读取程序的数据结构特别有用,这是我们之前讨论的服务器网络的典型情况(多个客户端不断访问和请求数据,但只有偶尔的定期攻击)。但是这如何应用到我们当前的网络数据结构呢?理论上,我们的数据结构的访问器方法(get_primary_value()方法)需要在从线程读取数据之前创建数据结构的副本。这个规范在访问器方法中实现,在Chapter16/concurrent_network.py文件中,如下:

# Chapter16/concurrent_network.py

from copy import deepcopy
import time

class Network:
    [...]

    def get_primary_value(self):
        copy_network = deepcopy(self)

        primary_key = copy_network.primary_key
        time.sleep(1) # creating a delay
        return copy_network.data[primary_key]

在这里,我们使用了 copy 模块中的内置deepcopy方法,它返回网络的不同内存位置的副本。然后,我们只从这个网络对象的副本中读取数据,而不是原始对象本身。这个过程在下面的图表中说明:

RCU 解决竞争条件

在前面的图表中,我们可以看到在数据方面不会发生冲突,因为两个线程现在处理的是数据结构的不同副本。让我们在Chapter16/example6.py文件中看到这个实现的实际操作,该文件包含与之前的example5.py文件相同的指令(初始化网络对象,同时调用两个线程——一个用于访问网络的主要数据,另一个用于刷新相同的主要数据),只是现在程序正在使用我们从concurrent_network.py文件中获取的新数据结构。

在运行脚本之后,您的输出应该与以下内容相同:

> python3 example6.py
Initial network: {
 A: 1;
}

Full network: {
 A: 1;
 B: 1;
 C: 1;
}

Current primary value: 1.
Final network: {
 B: 1;
 C: 1;
}

Finished.

正如您所看到的,程序不仅在第一个线程中获取了主要数据的正确值而没有引发任何错误,而且在程序结束时也保持了正确的网络(没有之前删除的节点,带有键A)。 RCU 方法确实解决了竞争条件的问题,而没有使用任何锁定机制。

您可能还注意到的一件事是,在前一节中,RCU 也可以应用于我们的计数器示例。事实上,RCU 和近似计数器都是解决计数器问题的合理方法,哪种方法对于特定的并发问题更好的问题只能通过可扩展性分析等经验性的实践分析来回答。

基于简单数据结构

在本章中,我们使用了许多简单的并发数据结构,如计数器和网络。因此,我们真正深入地了解了在使用这些数据结构的并发程序中遇到的问题,并能够深入分析如何改进它们的结构和设计。

当您在工作和项目中处理更复杂的并发数据结构时,您会发现它们的设计和结构以及伴随它们的问题实际上与我们分析的数据结构中看到的问题基本相似。通过真正理解数据结构的基本架构以及使用它们的程序可能出现的问题的根源,您可以在此基础上构建更复杂但逻辑上等效的数据结构。

总结

在本章中,我们研究了基于锁和无互斥锁数据结构之间的理论差异:基于锁的数据结构使用锁定机制来保护其数据的完整性,而无互斥锁的数据结构则不使用。我们分析了在设计不良的数据结构中可能出现的竞争条件问题,并探讨了如何在这两种情况下解决这个问题。

在我们的并发基于锁的计数器数据结构示例中,我们考虑了近似计数器的设计,以及设计可以提供的改进可扩展性。在我们对并发网络数据结构的分析中,我们研究了 RCU 技术,该技术将读取指令与更新指令隔离开来,目的是保持并发数据结构的完整性。

在下一章中,我们将研究 Python 并发编程中的另一组高级概念:内存模型和对原子类型的操作。您将更多地了解 Python 内存管理,以及原子类型的定义和用途。

问题

  • 解决锁不锁任何东西的主要方法是什么?

  • 在并发编程的背景下描述可扩展性的概念

  • 天真的锁定机制如何影响并发程序的可扩展性?

  • 近似计数器是什么,它如何帮助解决并发编程中的可扩展性问题?

  • Python 中是否可能存在无锁数据结构?为什么?

  • 什么是无互斥锁并发数据结构,它与并发基于锁的数据结构有何不同?

  • RCU 技术是什么,以及它如何解决无互斥锁并发数据结构的问题?

进一步阅读

有关更多信息,您可以参考以下链接:

  • 操作系统:三个简单部分。第 151 卷。威斯康星州:Arpaci-Dusseau Books,2014 年,作者:Arpaci-Dusseau,Remzi H.和 Andrea C. Arpaci-Dusseau

  • 并发数据结构的秘密生活(addthis.com/blog/2013/04/25/the-secret-life-of-concurrent-data-structures/),作者:Michael Spiegel

  • RCU 在本质上是什么?Linux 周刊新闻(LWN.net)(2007),作者:McKenney,Paul E.和 Jonathan Walpole

  • 黄蜂窝:Python 中的读-复制-更新模式(emptysqua.re/blog/wasps-nest-read-copy-update-python/),作者:Davis,A. Jesse Jiryu

  • 可扩展性的特征及其对性能的影响,第二届国际软件和性能研讨会(WOSP)'00。第 195 页,André B

第十七章:内存模型和原子类型的操作

并发编程过程中需要考虑的问题以及随之而来的问题,都与 Python 管理其内存的方式有关。因此,对 Python 中变量和值的存储和引用方式有深入的了解,不仅有助于找出导致并发程序故障的低级错误,还有助于优化并发代码。在本章中,我们将深入研究 Python 内存模型以及其原子类型,特别是它们在 Python 并发生态系统中的位置。

本章将涵盖以下主题:

  • Python 内存模型,支持不同层次上的内存分配的组件,以及在 Python 中管理内存的一般理念

  • 原子操作的定义,它们在并发编程中的作用,以及如何在 Python 中使用它们

技术要求

本章的技术要求如下:

Python 内存模型

你可能还记得在《全局解释器锁》第十五章中对 Python 内存管理方法的简要讨论。在本节中,我们将通过将其内存管理机制与 Java 和 C++的内存管理机制进行比较,并讨论它与 Python 并发编程实践的关系,更深入地了解 Python 内存模型。

Python 内存管理器的组件

Python 中的数据以特定方式存储在内存中。为了深入了解并发程序中数据的处理方式,我们首先需要深入了解 Python 内存分配的理论结构。在本节中,我们将讨论数据如何在私有堆中分配,以及通过Python 内存管理器处理这些数据——这是一个确保数据完整性的总体实体。

Python 内存管理器由许多组件组成,这些组件与不同的实体进行交互并支持不同的功能。例如,一个组件通过与 Python 运行的操作系统的内存管理器进行交互,处理低级内存的分配;它被称为原始内存分配器

在更高的层次上,还有许多其他内存分配器与前述的对象和值的私有堆进行交互。Python 内存管理器的这些组件处理特定于对象的分配,执行特定于给定数据和对象类型的内存操作:整数必须由不同的分配器处理和管理,以便处理字符串的分配器或处理字典或元组的分配器。由于这些数据类型之间的存储和读取指令不同,因此实现了这些不同的特定于对象的内存分配器,以获得额外的速度,同时牺牲一些处理空间。

在前述原始内存分配器的下一步是来自标准 C 库的系统分配器(假设考虑的 Python 解释器是 CPython)。有时被称为通用分配器,这些用 C 语言编写的实体负责帮助原始内存分配器与操作系统的内存管理器进行交互。

前面描述的 Python 内存管理器的整个模型可以用以下图示表示:

Python 内存管理器组件

内存模型作为一个带标签的有向图

我们已经了解了 Python 中的内存分配的一般过程,因此在本节中,让我们思考 Python 中数据是如何存储和引用的。许多程序员经常将 Python 中的内存模型想象为一个带有每个节点标签的对象图,边是有向的——简而言之,它是一个带标签的有向对象图。这种内存模型最初是在第二古老的计算机编程语言Lisp(以前称为 LISP)中使用的。

它通常被认为是一个有向图,因为它的内存模型通过指针来跟踪其数据和变量:每个变量的值都是一个指针,这个指针可以指向一个符号、一个数字或一个子程序。因此,这些指针是对象图中的有向边,而实际值(符号、数字、子程序)是图中的节点。以下图表是 Lisp 内存模型早期阶段的简化:

Lisp 内存模型作为对象图

这种对象图内存模型带来了许多有利的内存管理特性。首先,该模型在可重用性方面提供了相当大的灵活性;可以编写一个数据结构或一组指令,用于一种数据类型或对象,然后在其他类型上重用它是可能的,而且实际上相当容易。相比之下,C 是一种利用不同内存模型的编程语言,不提供这种灵活性,其程序员通常需要花费大量时间为不同类型的数据结构和算法重写相同的代码。

这种内存模型提供的另一种灵活性是,每个对象可以被任意数量的指针(或最终变量)引用,因此可以被任何一个变量改变。我们已经在第十五章中的一个 Python 程序示例中看到了这种特性的影响,《全局解释器锁》,如果两个变量引用相同的(可变)对象(通过将一个变量赋值给另一个变量实现),并且一个成功通过其引用改变了对象,那么这种改变也将通过第二个变量的引用反映出来。

正如在第十五章中讨论的那样,《全局解释器锁》,这与 C++中的内存管理不同。例如,当一个变量(不是指针或引用)被赋予特定值时,编程语言将该特定值复制到包含原始变量的内存位置。此外,当一个变量被赋予另一个变量时,后者的内存位置将被复制到前者的内存位置;在赋值后,这两个变量之间不再保持任何连接。

然而,有人认为这实际上可能是编程中的一个缺点,特别是在并发编程中,因为未经协调的尝试去改变共享对象可能导致不良结果。作为经验丰富的 Python 程序员,你可能也注意到在 Python 编程中类型错误(当一个变量期望是一个特定类型,但引用了一个不同的、不兼容类型的对象)是相当常见的。这也是这种内存模型的直接结果,因为引用指针可以指向任何东西。

在并发的背景下

在考虑 Python 内存模型的理论基础时,我们可以期待它如何影响 Python 并发编程的生态系统?幸运的是,Python 内存模型在某种程度上有利于并发编程,因为它允许更容易和更直观地思考和推理并发。具体来说,Python 实现了其内存模型,并以我们通常期望的方式执行其程序指令。

为了理解 Python 具有的这一优势,让我们首先考虑 Java 编程语言中的并发。为了在并发程序(特别是多线程程序)中获得更好的性能,Java 允许 CPU 重新排列 Java 代码中包含的给定操作的执行顺序。然而,重新排列是以任意的方式进行的,因此我们不能仅通过代码的顺序来推断多个线程执行时的执行顺序。这导致了如果 Java 中的并发程序以意外的方式执行,开发人员需要花费大量时间确定程序的执行顺序,以找出程序中的错误。

与 Java 不同,Python 的内存模型结构化,保持了其指令的顺序一致性。这意味着 Python 代码中指令的排列顺序指定了它们的执行顺序——没有代码的任意重新排列,因此并发程序不会出现意外行为。然而,由于 Java 并发中的重新排列是为了提高程序的速度,这意味着 Python 为了保持其执行更简单和更直观而牺牲了性能。

Python 中的原子操作

关于内存管理的另一个重要主题是原子操作。在本小节中,我们将探讨编程中原子性的定义,原子操作在并发编程上下文中的作用,以及如何在 Python 程序中使用原子操作。

什么是原子性?

首先让我们来检查原子性的实际特征。如果在并发程序中,一个操作是原子的,那么在其执行过程中不能被程序中的其他实体中断;原子操作也可以被称为可线性化、不可分割或不可中断的。鉴于竞争条件的性质以及它们在并发程序中的普遍存在,很容易得出原子性是程序的一个理想特征,因为它保证了共享数据的完整性,并保护它免受不协调的变化。

"原子"一词指的是原子操作对于其所在的程序来说是瞬时的。这意味着操作必须以连续、不间断的方式执行。实现原子性的最常见方法,你可能已经猜到了,是通过互斥或锁。正如我们所见,锁需要一个线程或进程一次与共享资源进行交互,从而保护这些线程/进程的交互不会被其他竞争的线程或进程中断和潜在地破坏。

如果程序员允许其并发程序中的一些操作是非原子的,他们还需要允许这些操作足够小心和灵活(在与数据交互和变异的意义上),以便它们不会因为被其他操作中断而产生错误。然而,如果这些操作在执行过程中出现不规则和错误的行为,程序员将很难重现和调试这些行为。

GIL 重新考虑

在 Python 原子操作的上下文中,一个主要元素当然是 GIL;此外还存在一些关于 GIL 在原子操作中扮演的角色的常见误解和复杂性。

例如,关于原子操作的定义,有些人倾向于认为 Python 中的所有操作实际上都是原子的,因为 GIL 实际上要求线程以协调的方式执行,每次只能有一个线程能够运行。事实上,这是一个错误的说法。GIL 要求只有一个线程可以在任何给定时间执行 Python 代码,并不意味着所有 Python 操作都是原子的;一个操作仍然可以被另一个操作中断,并且错误仍然可能由于对共享数据的错误处理和破坏而导致。

在更低的层面上,Python 解释器处理 Python 并发程序中的线程切换。这个过程是根据字节码指令进行的,这些字节码指令是可解释和可执行的 Python 代码。具体来说,Python 维护一个固定的频率,指定解释器应该多久切换一次活动线程到另一个线程,这个频率可以使用内置的sys.setswitchinterval()方法进行设置。任何非原子操作都可以在执行过程中被线程切换事件中断。

在 Python 2 中,这个频率的默认值是 1,000 个字节码指令,这意味着在一个线程成功执行了 1,000 个字节码指令后,Python 解释器将寻找其他等待执行的活动线程。如果至少有一个其他等待的线程,解释器将要求当前运行的线程释放 GIL,并让等待的线程获取它,从而开始执行后者的线程。

在 Python 3 中,频率基本上是不同的。现在,频率的单位是基于时间的,具体来说是以秒为单位。默认值为 15 毫秒,这个频率指定如果一个线程至少执行了等于阈值的时间量,那么线程切换事件(以及 GIL 的释放和获取)将在线程完成当前字节码指令的执行后立即发生。

Python 中的固有原子性

如前所述,如果执行操作的线程已经超过了执行限制(例如,在 Python 3 中默认为 15 毫秒),则操作在执行过程中可以被中断,此时操作必须完成当前的字节码指令,并将 GIL 交还给另一个等待的线程。这意味着线程切换事件只会发生在字节码指令之间。

Python 中有一些操作可以在一个单一的字节码指令中执行,因此在没有外部机制的帮助下是原子性的,比如互斥。具体来说,如果线程中的操作在一个单一的字节码中完成执行,它就不能被线程切换事件中断,因为事件只会在当前字节码指令完成后才会发生。这种固有原子性的特征非常有用,因为它允许具有这种特性的操作自由地执行其指令,即使没有使用同步方法,同时仍然保证它们不会被中断并且数据不会被破坏。

原子与非原子

重要的是要注意,对程序员来说,了解 Python 中哪些操作是原子的,哪些不是,可能会令人惊讶。有些人可能会认为,由于简单操作所需的字节码比复杂操作少,因此操作越简单,就越有可能是固有原子的。然而,事实并非如此,确定哪些操作在本质上是原子的唯一方法是进行进一步的分析。

根据 Python 3 的文档(可以通过此链接找到:docs.python.org/3/faq/library.html#what-kinds-of-global-value-mutation-are-thread-safe),一些天生的原子操作的例子包括以下内容:

  • 将预定义对象附加到列表

  • 用另一个列表扩展列表

  • 从列表中获取元素

  • 从列表中“弹出”

  • 对列表进行排序

  • 将变量分配给另一个变量

  • 将变量分配给对象的属性

  • 为字典创建一个新条目

  • 用另一个字典更新字典

一些不是天生原子的操作包括以下内容:

  • 递增整数,包括使用+=

  • 通过引用列表中的另一个元素更新列表中的元素

  • 通过引用字典中的另一个条目更新字典中的条目

Python 中的模拟

让我们分析实际 Python 并发程序中原子操作和非原子操作之间的区别。如果您已经从 GitHub 页面下载了本书的代码,请转到Chapter17文件夹。对于本例,我们考虑Chapter17/example1.py文件:

# Chapter17/example1.py

import sys; sys.setswitchinterval(.000001)
import threading

def foo():
    global n
    n += 1

n = 0

threads = []
for i in range(1000):
    thread = threading.Thread(target=foo)
    threads.append(thread)

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

print(f'Final value: {n}.')

print('Finished.')

首先,我们将 Python 解释器的线程切换频率重置为 0.000001 秒——这是为了使线程切换事件比平常更频繁,从而放大我们程序中可能存在的任何竞争条件。

程序的要点是使用 1,000 个单独的线程递增一个简单的全局计数器(n),每个线程通过foo()函数递增一次计数器。由于计数器最初被初始化为0,如果程序正确执行,我们将在程序结束时得到计数器的值为 1,000。然而,我们知道我们在foo()函数中使用的递增运算符(+=)不是原子操作,这意味着当应用于全局变量时,它可能会被线程切换事件中断。

在多次运行脚本后,我们可以观察到实际上存在我们代码中的竞争条件。这可以通过计数器的不正确值小于 1,000 来说明。例如,以下是我得到的一个输出:

> python3 example1.py
Final value: 998.
Finished.

这与我们之前讨论的一致,即,由于+=运算符不是原子的,它需要其他同步机制来确保它与多个线程同时交互的数据的完整性。现在让我们用我们知道是原子的操作来模拟相同的实验,具体来说是将预定义对象附加到列表

Chapter17/example2.py文件中,我们有以下代码:

# Chapter17/example2.py

import sys; sys.setswitchinterval(.000001)
import threading

def foo():
    global my_list
    my_list.append(1)

my_list = []

threads = []
for i in range(1000):
    thread = threading.Thread(target=foo)
    threads.append(thread)

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

print(f'Final list length: {len(my_list)}.')

print('Finished.')

现在我们不再有一个全局计数器,而是一个最初为空的全局列表。新的foo()函数现在获取这个全局列表并将整数1附加到它上。在程序的其余部分,我们仍然创建和运行 1,000 个单独的线程,每个线程调用foo()函数一次。在程序结束时,我们将打印出全局列表的长度,以查看列表是否成功地变异了 1,000 次。具体来说,如果列表的长度小于 1,000,我们将知道我们的代码中存在竞争条件,类似于我们在上一个例子中看到的情况。

由于list.append()方法是一个原子操作,因此,当线程调用foo()函数并与全局列表交互时,可以保证没有竞争条件。这可以通过程序结束时列表的长度来说明。无论我们运行程序多少次,列表的长度始终为 1,000:

> python3 example2.py
Final list length: 1000.
Finished.

尽管 Python 中有一些本质上是原子的操作,但很难判断一个给定的操作是否本身是原子的。由于在共享数据上应用非原子操作可能导致竞争条件和错误的结果,因此建议程序员始终利用同步机制来确保并发程序中共享数据的完整性。

总结

在这一章中,我们已经研究了 Python 内存模型的基本结构,以及语言在并发编程环境中如何管理其值和变量。鉴于 Python 中内存管理的结构和实现方式,与其他编程语言相比,理解并发程序的行为可能会更容易得多。然而,在 Python 中理解和调试并发程序的便利性也伴随着性能的降低。

原子操作是在执行过程中不能被中断的指令。原子性是并发操作的一个理想特征,因为它保证了在不同线程之间共享的数据的安全性。虽然 Python 中有一些本质上是原子的操作,但始终建议使用锁定等同步机制来保证给定操作的原子性。

在下一章中,我们将学习如何从头开始构建一个并发服务器。通过这个过程,我们将更多地了解如何实现通信协议以及将并发应用到现有的 Python 应用程序中。

问题

  • Python 内存管理器的主要组成部分是什么?

  • Python 内存模型如何类似于带标签的有向图?

  • 就 Python 内存模型在开发 Python 并发应用程序方面的优缺点是什么?

  • 什么是原子操作,为什么在并发编程中是可取的?

  • 给出 Python 中本质上是原子操作的三个例子。

进一步阅读

有关更多信息,您可以参考以下链接:

第十八章:从头开始构建服务器

在本章中,我们将分析并发编程的更高级应用:从头开始构建一个工作的非阻塞服务器。我们将涵盖socket模块的复杂用法,例如将用户业务逻辑与回调隔离,并使用内联生成器编写回调逻辑,这两个实例都设计为并发运行。我们还将讨论使用awaityield关键字,使用一个示例。

本章将涵盖以下主题:

  • 使用socket模块的全面 API 从头开始构建服务器

  • 关于 Python 生成器和异步生成器的基本信息

  • 如何使用awaityield关键字与内联生成器将阻塞服务器转换为非阻塞服务器

技术要求

以下是本章的先决条件列表:

通过 socket 模块进行低级网络编程

在本章中,我们将使用 Python 中的内置库socket模块来构建我们的工作服务器。socket模块是最常用于实现低级通信协议的模块之一,同时提供直观的选项来控制这些协议。在本节中,我们将介绍实现服务器的底层架构的过程,以及模块中将在后面的示例中使用的关键方法和功能。

请注意,为了成功地跟随本章中的示例,您需要在系统上安装 telnet 程序。Telnet 是一个提供终端命令以促进双向交互式基于文本的通信协议的程序。我们在第十一章中介绍了 telnet 的安装,使用 asyncio 构建通信通道;如果您的系统上尚未安装 Telnet,请简单地转到(并按照)该章节中的说明。

请注意,macOS 系统有一个名为 Netcat 的预安装替代 Telnet 的程序。如果您不想在 macOS 计算机上安装 Telnet,请在以下示例中使用命令nc而不是telnet,您将获得相同的效果。

服务器端通信理论

在第十一章中,使用 asyncio 构建通信通道,您遇到了使用aiohttp模块在更高级别实现异步通信通道的简要示例。在本节中,我们将深入探讨服务器端通信通道的编程结构,以及它如何以高效的方式与其客户端进行交互。

在网络编程领域,套接字被定义为特定计算机网络节点内的理论端点。套接字负责从其所在的节点接收或发送数据。套接字仅对拥有它的节点可用的事实意味着同一计算机网络中的其他节点在理论上无法与套接字交互。换句话说,套接字仅对其对应的节点可用。

要从服务器端打开通信通道,网络程序员必须首先创建一个套接字并将其绑定到特定地址。该地址通常是一对值,包含有关主机和服务器端口的信息。然后,通过套接字,服务器开始监听网络中由其客户端创建的任何潜在通信请求。因此,客户端对服务器的任何连接请求都需要通过创建的套接字。

在收到潜在客户端的连接请求后,服务器可以决定是否接受该请求。然后两个系统之间将建立连接,这意味着它们可以开始通信并共享数据。当客户端通过通信通道向服务器发送消息时,服务器会处理消息,最终通过相同的通道向客户端发送响应;这个过程会持续,直到它们之间的连接结束,要么是其中一个退出连接通道,要么是通过一些外部因素。

上述是创建服务器并与潜在客户端建立连接的基本过程。在整个过程的每个阶段都实施了多种安全措施,尽管它们不是我们关心的内容,也不会在这里讨论。下面的图表也描述了刚刚描述的过程:

使用套接字进行网络编程

请注意,为了创建连接到服务器的请求,潜在客户端还必须初始化自己的通信通道套接字(如前面的图表所示)。再次强调,我们只关注这个过程的服务器端理论,因此在这里不讨论客户端方面的元素。

套接字模块的 API

在本节中,我们将探讨socket模块提供的关键 API,以实现先前描述过程中的相同功能。正如我们已经提到的,socket模块内置在任何 Python 3 发行版中,因此我们可以简单地将模块导入到我们的程序中,而无需运行安装命令。

要创建套接字,我们将使用socket.socket()方法,该方法返回一个套接字对象。这个对象是我们在实现各种通信协议的过程中将要使用的。此外,套接字方法还具有以下方法,帮助我们控制通信协议:

  • socket.bind(): 此方法将调用套接字绑定到传递给方法的地址。在我们的示例中,我们将传递一个包含主机地址和通信通道端口的元组。

  • socket.listen(): 此方法允许我们创建的服务器接受潜在客户端的连接。还可以传递另一个可选的正整数参数给方法,以指定服务器拒绝新连接之前允许的未接受连接的数量。在我们后面的示例中,我们将使用5作为此方法的任意数量。

  • socket.accept(): 此方法如其名称所示,接受调用套接字对象的特定连接。首先,调用对象必须绑定到地址并监听连接,才能调用此方法。换句话说,这个方法要在前两个方法之后调用。该方法还返回一对值(conn, address),其中conn是已接受连接的新套接字对象,能够发送和接收数据,address是连接另一端的地址(客户端地址)。

  • socket.makefile(): 此方法返回与调用socket对象关联的file对象。我们将使用此方法创建一个包含来自服务器接受的客户端数据的文件。这个file对象也需要适当地关闭,使用close()方法。

  • socket.sendall(): 这个方法将传递给调用socket对象的数据发送出去。我们将使用这个方法将数据发送回连接到我们服务器的客户端。请注意,这个方法接收字节数据,所以在我们的示例中将向这个方法传递字节字符串。

  • socket.close(): 这个方法将调用socket对象标记为关闭。在此之后,对socket对象的所有操作都将失败。这在我们终止服务器时使用。

构建一个简单的回显服务器

真正理解先前描述的方法和函数的使用方式的最佳方法是在示例程序中看到它们的运行。在本节中,我们将构建一个回显服务器作为我们的起始示例。这个服务器,正如术语所示,会将从每个客户端接收到的内容发送回客户端。通过这个示例,您将学习如何设置一个功能齐全的服务器,以及如何处理来自客户端的连接和数据,并且我们将在后面的部分构建更复杂的服务器。

然而,在我们进入代码之前,让我们讨论一下将为该服务器实现通信逻辑的程序结构。首先,我们将有所谓的反应器,它设置服务器本身并在潜在客户端请求新连接时提供逻辑。具体来说,一旦服务器设置好,这个反应器将进入一个无限循环,并处理服务器接收到的所有连接请求。

如果您已经阅读了关于异步编程的前几章,也可以将这个反应器看作是一个事件循环。这个事件循环会处理所有要处理的事件(在这种情况下,它们是请求),并使用事件处理程序逐个处理它们。以下图表进一步说明了这个过程:

网络编程中的事件循环

然后,我们程序的第二部分是事件循环类比中的事件处理程序,其中包含用户业务逻辑:如何处理从客户端接收的数据,以及向每个客户端发送什么。对于我们当前的示例,由于它是一个回显服务器,我们只会将每个客户端发送到服务器的任何内容发送回去(如果数据有效)。

有了这个结构,让我们继续实现这个服务器。从 GitHub 页面下载本章的代码,然后转到Chapter18文件夹。我们感兴趣的脚本在Chapter18/example1.py文件中,如下所示:

# Chapter18/example1.py

import socket

# Main event loop
def reactor(host, port):
    sock = socket.socket()
    sock.bind((host, port))
    sock.listen(5)
    print(f'Server up, running, and waiting for call on {host} {port}')

    try:
        while True:
            conn, cli_address = sock.accept()
            process_request(conn, cli_address)

    finally:
        sock.close()

def process_request(conn, cli_address):
    file = conn.makefile()

    print(f'Received connection from {cli_address}')

    try:
        while True:
            line = file.readline()
            if line:
                line = line.rstrip()
                if line == 'quit':
                    conn.sendall(b'connection closed\r\n')
                    return

                print(f'{cli_address} --> {line}')
                conn.sendall(b'Echoed: %a\r\n' % line)
    finally:
        print(f'{cli_address} quit')
        file.close()
        conn.close()

if __name__ == '__main__':
    reactor('localhost', 8080)

程序的结构与我们之前讨论的方式相同:一个反应器和一个用户业务逻辑处理程序(process_request()函数)。首先,反应器设置服务器(通过创建套接字,将其绑定到参数主机和端口地址,并调用listen()方法)。然后进入一个无限循环,并促进与客户端的任何潜在连接,首先通过在socket对象上调用accept()方法接受连接,然后调用process_request()函数。如果在前面的过程中发生错误,反应器还负责关闭socket对象。

另一方面,process_request()函数将首先创建一个与传递给它的套接字相关联的file对象。同样,这个file对象被我们的服务器用来从通过该特定套接字连接的客户端读取数据。具体来说,在制作了file对象之后,该函数将进入另一个无限循环,不断从file对象中读取数据,使用readline()函数。如果从文件中读取的数据是有效的,我们将使用sendall()方法将相同的数据发送回去。

我们还打印出服务器从每个客户端接收到的内容作为服务器输出,包括print(f'{cli_address} --> {line}')这一行。另一个规定是,如果从文件中读取的数据等于字符串quit,那么我们将关闭与该特定客户端的连接。连接关闭后,我们需要仔细处理socket对象本身以及与其关联的file对象,使用close()方法关闭两者。

最后,在我们的程序末尾,我们只需调用reactor()函数并向其传递有关我们服务器的信息。在这种情况下,我们只是使用服务器的回环接口,端口为8080。现在,我们将执行脚本以初始化我们的本地服务器。您的输出应该类似于以下内容:

> python3 example1.py
Server up, running, and waiting for call on localhost 8080

此时,我们的服务器已经启动并运行(如输出所示)。现在,我们想为这个服务器创建一些客户端。为此,打开另一个终端窗口,并使用 Telnet 程序连接到运行中的服务器,运行telnet localhost 8080。您的输出应该类似于以下内容:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.

这个输出意味着 Telnet 客户端已成功连接到我们创建的服务器。现在,我们可以测试服务器是否可以按照我们的意图处理其请求。具体来说,输入一些数据并按returnEnter发送到服务器,您将看到客户端将从服务器接收到一个回显消息,就像我们在前面的process_request()函数中实现的那样。同样,客户端可以通过向服务器发送字符串quit来停止与该服务器的连接。

在输入几个不同的短语时,以下代码显示了我的输出:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
Echoed: 'hello'
nice
Echoed: 'nice'
fdkgsnas
Echoed: 'fdkgsnas'
quit
connection closed
Connection closed by foreign host.

查看我们服务器的输出,您还可以看到在此连接期间发生了什么:

> python3 example1.py
Server up, running, and waiting for call on localhost 8080
Received connection from ('127.0.0.1', 59778)
('127.0.0.1', 59778) --> hello
('127.0.0.1', 59778) --> nice
('127.0.0.1', 59778) --> fdkgsnas
('127.0.0.1', 59778) quit

如上所述,服务器被设计为在反应器中作为事件循环永远运行,可以通过KeyboardInterrupt异常停止。

我们已经成功实现了我们的第一个回显服务器,使用了socket模块提供的低级方法。在下一节中,我们将为我们的服务器实现更高级的功能,并分析将其转换为可以同时处理多个客户端的非阻塞服务器的过程。

使用 socket 模块构建一个计算器服务器

我们试图实现的功能是具有一个简单的请求处理程序,用于计算整数列表的和或乘积,并将其包含在从客户端发送的数据中。具体来说,如果客户端向我们的服务器发送字符串124,那么服务器应该返回7(如果要计算总和)或8(如果要计算乘积)。

每个服务器都实现了某种形式的数据处理,除了处理来自客户端的请求并将数据处理任务的结果发送给这些客户端。因此,这个原型将作为更复杂功能的更广泛服务器的第一个构建块。

底层计算逻辑

我们将使用 Python 字符串的split()方法来提取由字符串中的特定字符分隔的元素。因此,我们要求来自客户端的所有数据都以这种方式格式化(用逗号分隔的整数),如果客户端发送的内容不符合这种格式,我们将简单地发送回一个错误消息,并要求他们发送一个新的消息。

基本的计算逻辑包含在Chapter18/example2.py文件中,如下所示:

# Chapter18/example2.py

from operator import mul
from functools import reduce

try:
    while True:
        line = input('Please enter a list of integer, separated by commas: ')
        try:
            nums = list(map(int, line.split(',')))
        except ValueError:
            print('ERROR. Enter only integers separated by commas')
            continue

        print('Sum of input integers', sum(nums))
        print('Product of input integers', reduce(mul, nums, 1))

except KeyboardInterrupt:
    print('\nFinished.')

同样,我们使用split()方法,带有,参数,来提取特定字符串中的各个数字。sum()函数用于计算参数列表中数字的和。要计算聚合乘积,我们需要从operator模块导入mul()方法(用于乘法),以及从functools模块导入reduce()方法,以在考虑的数字列表中的每个元素上应用乘法。

顺便说一句,传递给reduce()方法的第三个参数(数字1)是减少过程的起始值。如果您还没有这样做,可以阅读第七章,进程中的减少运算符,以了解更多关于减少操作的信息。

至于我们的实际服务器,我们还将跟踪计算模式。计算模式的默认值是执行求和,它决定服务器是否应对输入数字列表执行求和和乘法。该模式也是每个客户端连接的唯一模式,并且可以由该客户端切换。具体来说,如果特定客户端发送的数据是字符串sum,那么我们将切换计算模式为求和,对于字符串product也是一样。

实现计算器服务器

现在,让我们来看一下Chapter18/example3.py文件中这个服务器的完整实现:

# Chapter18/example3.py

import socket
from operator import mul
from functools import reduce

# Main event loop
def reactor(host, port):
    sock = socket.socket()
    sock.bind((host, port))
    sock.listen(5)
    print(f'Server up, running, and waiting for call on {host} {port}')

    try:
        while True:
            conn, cli_address = sock.accept()
            process_request(conn, cli_address)

    finally:
        sock.close()

def process_request(conn, cli_address):
    file = conn.makefile()

    print(f'Received connection from {cli_address}')
    mode = 'sum'

    try:
        conn.sendall(b'<welcome: starting in sum mode>\n')
        while True:
            line = file.readline()
            if line:
                line = line.rstrip()
                if line == 'quit':
                    conn.sendall(b'connection closed\r\n')
                    return

                if line == 'sum':
                    conn.sendall(b'<switching to sum mode>\r\n')
                    mode = 'sum'
                    continue
                if line == 'product':
                    conn.sendall(b'<switching to product mode>\r\n')
                    mode = 'product'
                    continue

                print(f'{cli_address} --> {line}')
                try:
                    nums = list(map(int, line.split(',')))
                except ValueError:
                    conn.sendall(
                        b'ERROR. 
                        Enter only integers separated by commas\n')
                    continue

                if mode == 'sum':
                    conn.sendall(b'Sum of input numbers: %a\r\n'
                        % str(sum(nums)))
                else:
                    conn.sendall(b'Product of input numbers: %a\r\n'
                        % str(reduce(mul, nums, 1)))
    finally:
        print(f'{cli_address} quit')
        file.close()
        conn.close()

if __name__ == '__main__':
    reactor('localhost', 8080)

我们服务器的反应器组件与之前的示例相同,因为事件循环处理相同类型的逻辑。在我们的用户业务逻辑部分(process_request()函数)中,我们仍然使用从makefile()方法返回的file对象来获取服务器客户端发送的数据。如果客户端发送字符串quit,则该客户端与服务器之间的连接仍将被停止。

该程序中的第一个新事物是process_request()函数中的本地变量mode。该变量指定了我们之前讨论过的计算模式,并且默认值为字符串sum。正如你所看到的,在process_request()函数的try块的最后,该变量决定了要发送回当前客户端的数据类型:

if mode == 'sum':
    conn.sendall(b'Sum of input numbers: %a\r\n'
        % str(sum(nums)))
else:
    conn.sendall(b'Product of input numbers: %a\r\n'
        % str(reduce(mul, nums, 1)))

此外,如果从客户端发送的数据等于字符串sum,那么mode变量将被设置为sum,对于字符串product也是一样。客户端还将收到一条消息,宣布计算模式已更改。这一逻辑包含在以下代码部分中:

if line == 'sum':
    conn.sendall(b'<switching to sum mode>\r\n')
    mode = 'sum'
    continue
if line == 'product':
    conn.sendall(b'<switching to product mode>\r\n')
    mode = 'product'
    continue

现在,让我们看看这个服务器在实际实验中的表现。执行程序运行服务器,你会看到类似于之前示例的输出:

> python3 example3.py
Server up, running, and waiting for call on localhost 8080

我们将再次使用 Telnet 来为该服务器创建客户端。当你通过 Telnet 客户端连接到服务器时,请尝试输入一些数据来测试我们实现的服务器逻辑。以下代码显示了我使用各种类型的输入所获得的结果:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
<welcome: starting in sum mode>
1,2
Sum of input numbers: '3'
4,9
Sum of input numbers: '13'
product
<switching to product mode>
0,-3
Product of input numbers: '0'
5,-9,10
Product of input numbers: '-450'
hello
ERROR. Enter only integers separated by commas
a,1
ERROR. Enter only integers separated by commas
quit
connection closed
Connection closed by foreign host.

您可以看到我们的服务器可以按我们的意图处理请求。具体来说,它可以计算给定正确格式的输入字符串的和和乘积;它可以适当地切换计算模式;如果输入字符串格式不正确,它可以向客户端发送错误消息。同样,这个长时间运行的服务器可以通过KeyboardInterrupt异常停止。

构建非阻塞服务器

我们将发现的一件事是,我们当前的服务器是阻塞的。换句话说,它无法同时处理多个客户端。在本节中,您将学习如何在当前服务器的基础上构建非阻塞服务器,使用 Python 关键字来促进并发编程,以及socket模块的低级功能。

分析服务器的并发性

我们现在将说明我们目前的服务器无法同时处理多个客户端。首先,执行Chapter18/example3.py文件再次运行服务器,如下所示:

> python3 example3.py
Server up, running, and waiting for call on localhost 8080

与之前的示例类似,现在让我们打开另一个终端并使用 Telnet 连接到正在运行的服务器:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
<welcome: starting in sum mode>

要为此服务器创建第二个客户端,请打开另一个终端并输入相同的telnet命令,如下所示:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.

在这里,我们已经看到服务器没有正确处理这第二个客户端:它没有将欢迎消息(<welcome: starting in sum mode>)发送给这个客户端。如果我们查看服务器的输出,我们还可以看到它只注册了一个客户端,具体来说,是两个客户端中的第一个:

> python3 example3.py
Server up, running, and waiting for call on localhost 8080
Received connection from ('127.0.0.1', 61099)

接下来,我们将尝试从每个客户端输入。我们会发现服务器只成功处理来自第一个客户端的请求。具体来说,以下是来自第一个客户端的输出,包括各种类型的输入:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
<welcome: starting in sum mode>
hello
ERROR. Enter only integers separated by commas
1,5
Sum of input numbers: '6'
product
<switching to product mode>
6,7
Product of input numbers: '42'

现在,第一个客户端仍然与服务器保持连接,切换到第二个客户端的终端并尝试输入自己的输入。你会发现,与第一个客户端不同,这个客户端没有从服务器那里收到任何消息:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
1,5
product
6,7

如果我们查看服务器的输出,我们会发现服务器只处理来自第一个客户端的请求:

> python3 example3.py
Server up, running, and waiting for call on localhost 8080
Received connection from ('127.0.0.1', 61099)
('127.0.0.1', 61099) --> hello
('127.0.0.1', 61099) --> 1,5
('127.0.0.1', 61099) --> 6,7

第二个客户端能够与服务器交互的唯一方法是第一个客户端断开与服务器的连接,换句话说,当我们停止第一个客户端与服务器之间的连接时:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
<welcome: starting in sum mode>
hello
ERROR. Enter only integers separated by commas
1,5
Sum of input numbers: '6'
product
<switching to product mode>
6,7
Product of input numbers: '42'
quit
connection closed
Connection closed by foreign host.

现在,如果你切换到第二个客户端的终端,你会发现客户端将被服务器之前应该接收的消息刷屏:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
1,5
product
6,7
<welcome: starting in sum mode>
ERROR. Enter only integers separated by commas
Sum of input numbers: '6'
<switching to product mode>
Product of input numbers: '42'

服务器的所有适当回复现在都存在,但它们一次性发送,而不是在每个输入消息之后。相同的信息激增也在我们服务器终端的输出中得到了体现:

> python3 example3.py
Server up, running, and waiting for call on localhost 8080
Received connection from ('127.0.0.1', 61099)
('127.0.0.1', 61099) --> hello
('127.0.0.1', 61099) --> 1,5
('127.0.0.1', 61099) --> 6,7
('127.0.0.1', 61099) quit
Received connection from ('127.0.0.1', 61100)
('127.0.0.1', 61100) --> hello
('127.0.0.1', 61100) --> 1,5
('127.0.0.1', 61100) --> 6,7

这个输出让人觉得服务器只在第一个客户端退出后才收到了来自第二个客户端的连接,但实际上,我们创建了两个客户端,并让它们同时与服务器通信。这是因为我们目前的服务器只能一次处理一个客户端,只有在当前客户端退出后,它才能继续处理请求通信通道的下一个客户端。我们称之为阻塞服务器。

Python 中的生成器

在下一节中,我们将讨论如何将我们目前拥有的阻塞服务器转换为非阻塞服务器,同时保留计算功能。为了做到这一点,我们首先需要了解 Python 编程中的另一个概念,称为生成器。你可能已经使用过 Python 生成器,但为了复习,我们将在本节中介绍生成器的关键特性。

生成器是返回迭代器并可以动态暂停和恢复的函数。生成器的返回值通常与列表对象进行比较,因为生成器迭代器是惰性的,只有在明确要求时才会产生结果。因此,当涉及大量数据时,生成器迭代器在内存管理方面更有效,因此通常比列表更受青睐。

每个生成器都被定义为一个函数,但是在函数块内部不使用关键字return,而是使用yield,这是为了表示返回值只是临时的,整个生成器本身在获得返回值后仍然可以恢复。让我们看看 Python 生成器在Chapter18/example4.py文件中的示例:

# Chapter18/example4.py

def read_data():
    for i in range(5):
        print('Inside the inner for loop...')
        yield i * 2

result = read_data()
for i in range(6):
    print('Inside the outer for loop...')
    print(next(result))

print('Finished.')

在这里,我们有一个名为read_data()的生成器,它以懒惰的方式返回从 0 到 8 的 2 的倍数。这是通过关键字yield来实现的,该关键字放在否则正常函数中的返回值i * 2的前面。请注意,yield关键字放在迭代器中应该发送回的单个元素的前面,这有助于懒惰生成。

现在,在我们的主程序中,我们正在获取整个迭代器并将其存储在变量result中。然后,我们使用next()函数循环遍历该迭代器六次(显然,返回传入的迭代器中的下一个元素)。执行代码后,您的输出应该类似于以下内容:

> python3 example4.py
Inside the outer for loop...
Inside the inner for loop...
0
Inside the outer for loop...
Inside the inner for loop...
2
Inside the outer for loop...
Inside the inner for loop...
4
Inside the outer for loop...
Inside the inner for loop...
6
Inside the outer for loop...
Inside the inner for loop...
8
Inside the outer for loop...
Traceback (most recent call last):
 File "example4.py", line 11, in <module>
 print(next(result))
StopIteration

您可以看到,即使在我们循环遍历迭代器之前,迭代器是从read_data()生成器中生成并返回的,但是生成器内部的实际指令只有在我们尝试从迭代器中获取更多项目时才会执行。

这可以通过输出中的打印语句交替放置来说明(来自外部for循环和内部for循环的一个打印语句交替出现):执行流程首先进入外部for循环,尝试访问迭代器中的下一个项目,进入生成器,然后进入自己的for循环。一旦执行流程到达yield关键字,它就会回到主程序。这个过程会一直持续,直到其中一个for循环终止;在我们的例子中,生成器中的for循环首先停止,因此在最后遇到了StopIteration错误。

迭代器的生成懒惰性来自于生成器在到达yield关键字时停止执行,并且只在外部指令要求时(在这种情况下是通过next()函数)才继续执行。再次强调,这种形式的数据生成在内存管理方面比简单生成可能需要迭代的所有内容(如列表)要高效得多。

异步生成器和发送方法

生成器与我们构建异步服务器的目的有何关联?我们当前的服务器无法处理多个客户端的原因是,我们在用户业务逻辑部分使用的readline()函数是一个阻塞函数,只要当前的file对象仍然打开,就会阻止执行流程转向其他潜在的客户端。这就是为什么当当前客户端与服务器断开连接时,下一个客户端立即收到我们之前看到的大量信息的原因。

如果我们能够将这个函数重写为一个异步函数,允许执行流程在所有连接到服务器的不同客户端之间切换,那么该服务器将变成非阻塞的。我们将使用异步生成器来同时从潜在的多个客户端并发生成数据,以供我们的服务器使用。

为了看到我们将用于服务器的异步生成器的基本结构,让我们首先考虑Chapter18/example5.py文件,如下所示:

# Chapter18/example5.py

import types

@types.coroutine
def read_data():
    def inner(n):
        try:
            print(f'Printing from read_data(): {n}')
            callback = gen.send(n * 2)
        except StopIteration:
            pass

    data = yield inner
    return data

async def process():
    try:
        while True:
            data = await read_data()
            print(f'Printing from process(): {data}')
    finally:
        print('Processing done.')

gen = process()
callback = gen.send(None)

def main():
    for i in range(5):
        print(f'Printing from main(): {i}')
        callback(i)

if __name__ == '__main__':
    main()

我们仍在考虑打印出 0 到 8 之间的 2 的倍数的任务。在这个例子中,process()函数是我们的异步生成器。您可以看到,实际上在生成器内部没有yield关键字;这是因为我们使用了await关键字。这个异步生成器负责打印出由另一个生成器read_data()计算的 2 的倍数。

@types.coroutine装饰器用于将生成器read_data()转换为一个返回基于生成器的协程的协程函数,这个协程函数仍然可以像常规生成器一样使用,但也可以被等待。这个基于生成器的协程是将我们的阻塞服务器转换为非阻塞服务器的关键。协程使用send()方法进行计算,这是一种向生成器提供输入的方法(在这种情况下,我们向process()生成器提供 2 的倍数)。

这个协程返回一个回调函数,稍后可以被我们的主程序调用。这就是为什么在主程序中循环range(5)之前,我们需要跟踪process()生成器本身(存储在变量gen中)和返回的回调(存储在变量callback中)。具体来说,回调是gen.send(None)的返回值,用于启动process()生成器的执行。最后,我们简单地循环遍历上述的range对象,并使用适当的输入调用callback对象。

关于使用异步生成器的理论已经有很多讨论。现在,让我们看看它的实际应用。执行程序,你应该会得到以下输出:

> python3 example5.py
Printing from main(): 0
Printing from read_data(): 0
Printing from process(): 0
Printing from main(): 1
Printing from read_data(): 1
Printing from process(): 2
Printing from main(): 2
Printing from read_data(): 2
Printing from process(): 4
Printing from main(): 3
Printing from read_data(): 3
Printing from process(): 6
Printing from main(): 4
Printing from read_data(): 4
Printing from process(): 8
Processing done.

在输出中(具体来说,是打印语句),我们仍然可以观察到任务切换事件,这对于之前章节中讨论的异步编程和产生输出的生成器来说是至关重要的。基本上,我们实现了与之前示例相同的目标(打印 2 的倍数),但在这里,我们使用了异步生成器(使用asyncawait关键字)来促进任务切换事件,并且我们还能够通过使用回调向生成器传递特定参数。这些技术的结合形成了将应用于我们当前阻塞服务器的基本结构。

使服务器非阻塞

最后,我们将再次考虑实现非阻塞服务器的问题。在这里,我们将之前讨论过的异步生成器应用于服务器的客户端接收数据的异步读取和处理。服务器的实际代码包含在Chapter18/example6.py文件中;我们将逐步介绍其中的各个部分,因为这是一个相对较长的程序。让我们先关注一下这个程序中将会有的全局变量:

# Chapter18/example6.py

from collections import namedtuple

###########################################################################
# Reactor

Session = namedtuple('Session', ['address', 'file'])

sessions = {}         # { csocket : Session(address, file)}
callback = {}         # { csocket : callback(client, line) }
generators = {}       # { csocket : inline callback generator }

为了成功地为多个客户端同时提供服务,我们将允许服务器同时拥有多个会话(每个客户端一个),因此,我们需要跟踪多个字典,每个字典将保存关于当前会话的特定信息。

具体来说,sessions字典将客户端套接字连接映射到一个Session对象,这是一个 Python 的namedtuple对象,其中包含客户端的地址和与该客户端连接关联的file对象。callback字典将客户端套接字连接映射到一个回调函数,这个回调函数是我们稍后将实现的异步生成器的返回值;每个这样的回调函数都以其对应的客户端套接字连接和从该客户端读取的数据作为参数。最后,generators字典将客户端套接字连接映射到其对应的异步生成器。

现在,让我们来看一下reactor函数:

# Chapter18/example6.py

import socket, select

# Main event loop
def reactor(host, port):
    sock = socket.socket()
    sock.bind((host, port))
    sock.listen(5)
    sock.setblocking(0) # Make asynchronous

    sessions[sock] = None
    print(f'Server up, running, and waiting for call on {host} {port}')

    try:
        while True:
            # Serve existing clients only if they already have data ready
            ready_to_read, _, _ = select.select(sessions, [], [], 0.1)
            for conn in ready_to_read:
                if conn is sock:
                    conn, cli_address = sock.accept()
                    connect(conn, cli_address)
                    continue

                line = sessions[conn].file.readline()
                if line:
                    callbackconn)
                else:
                    disconnect(conn)
    finally:
        sock.close()

除了我们之前阻塞服务器中已经有的内容,我们还添加了一些指令:我们使用socket模块中的setblocking()方法来潜在地使我们的服务器异步或非阻塞;因为我们正在启动一个服务器,我们还将特定的套接字注册到sessions字典中,暂时使用None值。

在我们的无限while循环(事件循环)中是我们试图实现的新的非阻塞特性的一部分。首先,我们使用select模块的select()方法来单独选择sessions字典中准备好被读取的套接字(换句话说,具有可用数据的套接字)。由于该方法的第一个参数是要读取的数据,第二个是要写入的数据,第三个是异常数据,我们只在第一个参数中传入sessions字典。第四个参数指定了方法的超时时间(以秒为单位);如果未指定,该方法将无限期地阻塞,直到sessions中至少有一项可用,这对于我们的非阻塞服务器来说是不合适的。

接下来,对于每个准备被读取的客户端套接字连接,如果连接对应于我们原始的服务器套接字,我们将接受该连接并调用connect()函数(我们将很快看到)。在这个for循环中,我们还将处理回调方法。具体来说,我们将访问当前套接字连接的会话的file属性(回想一下,每个会话都有一个address属性和一个file属性),并将使用readline()方法从中读取数据。现在,如果我们读到的是有效数据,那么我们将把它(连同当前客户端连接)传递给相应的回调;否则,我们将结束连接。

请注意,尽管我们的服务器通过将套接字设置为非阻塞而变成了异步的,但前面的readline()方法仍然是一个阻塞函数。readline()函数在输入数据中遇到回车符(ASCII 中的'\r'字符)时返回。这意味着,如果客户端发送的数据不包含回车符,那么readline()函数将无法返回。然而,由于服务器仍然是非阻塞的,将会引发错误异常,以便其他客户端不会被阻塞。

现在,让我们来看看我们的新辅助函数:

# Chapter18/example6.py

def connect(conn, cli_address):
    sessions[conn] = Session(cli_address, conn.makefile())

    gen = process_request(conn)
    generators[conn] = gen
    callback[conn] = gen.send(None) # Start the generator

def disconnect(conn):
    gen = generators.pop(conn)
    gen.close()
    sessions[conn].file.close()
    conn.close()

    del sessions[conn]
    del callback[conn]

connect()函数在客户端连接有准备好被读取的数据时将被调用,它将在与客户端的有效连接开始时启动指令。首先,它初始化与该特定客户端连接相关联的namedtuple对象(我们仍然在这里使用makefile()方法来创建file对象)。函数的其余部分是我们之前讨论过的异步生成器的用法模式:我们将客户端连接传递给现在是异步生成器的process_request(),将其注册到generators字典中;让它调用send(None)来启动生成器;并将返回值存储到callback字典中,以便稍后调用(具体来说,在我们刚刚看到的反应器中的事件循环的最后部分)。

另一方面,disconnect()函数在与客户端的连接停止时提供各种清理指令。它从generators字典中移除与客户端连接相关联的生成器,并关闭sessions字典中存储的file对象以及客户端连接本身。最后,它从剩余的字典中删除与客户端连接对应的键。

让我们把注意力转向现在是异步生成器的新process_request()函数:

# Chapter18/example6.py

from operator import mul
from functools import reduce

###########################################################################
# User's Business Logic

async def process_request(conn):
    print(f'Received connection from {sessions[conn].address}')
    mode = 'sum'

    try:
        conn.sendall(b'<welcome: starting in sum mode>\n')
        while True:
            line = await readline(conn)
            if line == 'quit':
                conn.sendall(b'connection closed\r\n')
                return
            if line == 'sum':
                conn.sendall(b'<switching to sum mode>\r\n')
                mode = 'sum'
                continue
            if line == 'product':
                conn.sendall(b'<switching to product mode>\r\n')
                mode = 'product'
                continue

            print(f'{sessions[conn].address} --> {line}')
            try:
                nums = list(map(int, line.split(',')))
            except ValueError:
                conn.sendall(
                    b'ERROR. Enter only integers separated by commas\n')
                continue

            if mode == 'sum':
                conn.sendall(b'Sum of input integers: %a\r\n'
                    % str(sum(nums)))
            else:
                conn.sendall(b'Product of input integers: %a\r\n'
                    % str(reduce(mul, nums, 1)))
    finally:
        print(f'{sessions[conn].address} quit')

处理客户端数据并执行计算的逻辑保持不变,这个新函数的唯一区别是async关键字(放在def关键字前面)和与新的readline()函数一起使用的await关键字。这些区别本质上将我们的process_request()函数转换为一个非阻塞函数,条件是新的readline()函数也是非阻塞的。

# Chapter18/example6.py

import types

@types.coroutine
def readline(conn):
    def inner(conn, line):
        gen = generators[conn]
        try:
            callback[conn] = gen.send(line) # Continue the generator
        except StopIteration:
            disconnect(conn)

    line = yield inner
    return line

类似于我们在前面的例子中看到的,我们从 Python 中导入types模块,并使用@types.coroutine装饰器将readline()函数变成基于生成器的协程,这是非阻塞的。每次调用回调函数(接受客户端连接和一行数据)时,执行流程将进入这个协程内部的inner()函数并执行指令。

具体来说,它将数据行发送到生成器,生成器将使process_request()中的指令异步处理并将返回值存储到适当的回调中,除非已经到达生成器的末尾,在这种情况下将调用disconnect()函数。

我们的最后一个任务是测试这个服务器是否真的能够同时处理多个客户端。为此,首先执行以下脚本:

> python3 example6.py
Server up, running, and waiting for call on localhost 8080

类似于您之前看到的,打开两个额外的终端并使用 Telnet 连接到正在运行的服务器:

> telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
<welcome: starting in sum mode>

正如您所看到的,两个客户端都被正确处理:两者都能够连接,并且都收到了欢迎消息。这也可以通过服务器输出来说明,如下所示:

> python3 example6.py
Server up, running, and waiting for call on localhost 8080
Received connection from ('127.0.0.1', 63855)
Received connection from ('127.0.0.1', 63856)

进一步的测试可能涉及同时向服务器发送消息,它仍然可以处理。服务器还可以跟踪独立于各个客户端的独特计算模式(换句话说,假设每个客户端都有一个单独的计算模式)。我们已经成功地从头开始构建了一个非阻塞的并发服务器。

总结

往往,低级网络编程涉及套接字的操作和处理(在特定计算机网络的节点内定义为理论端点,负责从它们所在的节点接收或发送数据)。服务器端通信的架构包括多个涉及套接字处理的步骤,如绑定、监听、接受、读取和写入。socket模块提供了一个直观的 API,便于进行这些步骤。

要使用socket模块创建非阻塞服务器,需要实现异步生成器,以便执行流程在任务和数据之间切换。这个过程还涉及使用回调,可以在以后执行流程运行。这两个元素允许服务器同时读取和处理来自多个客户端的数据,使服务器成为非阻塞。

我们将在下一章中结束我们的书,介绍设计和实现并发程序的实用技术。具体来说,我们将讨论如何系统地和有效地测试、调试和安排并发应用程序。

问题

  • 什么是套接字?它与网络编程有什么关系?

  • 当潜在客户端请求连接时,服务器端通信的程序是什么?

  • socket模块提供了哪些方法来便于服务器端的低级网络编程?

  • 什么是生成器?它们相对于 Python 列表的优势是什么?

  • 什么是异步生成器?它们如何应用于构建非阻塞服务器?

进一步阅读

要获取更多信息,您可以参考以下链接:

第十九章:测试、调试和并发应用程序的调度

在本章中,我们将讨论在更高层次上使用并发 Python 程序的过程。首先,您将学习如何安排 Python 程序在以后同时运行,无论是一次还是定期。我们将分析 APScheduler,这是一个允许我们在跨平台基础上做到这一点的 Python 库。此外,我们将讨论测试和调试,这是编程中必不可少但经常被忽视的组成部分。鉴于并发编程的复杂性,测试和调试甚至比传统应用程序更加困难。本章将涵盖一些有效测试和调试并发程序的策略。

本章将涵盖以下主题:

  • APScheduler 库及其在并发调度 Python 应用程序中的使用

  • Python 程序的不同测试技术

  • Python 编程中的调试实践,以及并发特定的调试技术

技术要求

本章的先决条件如下:

使用 APScheduler 进行调度

APSchedulerAdvanced Python Scheduler的缩写)是一个外部 Python 库,支持安排 Python 代码以便稍后执行,无论是一次还是定期。该库为我们提供了高级选项,以动态地向作业列表中添加/删除作业,以便安排和执行,以及决定如何将这些作业分配给不同的线程和进程。

有些人可能会认为 Celery(www.celeryproject.org/)是 Python 的首选调度工具。然而,虽然 Celery 是一个具有基本调度功能的分布式任务队列,但 APScheduler 恰恰相反:它是一个具有基本任务排队选项和高级调度功能的调度程序。此外,两种工具的用户都报告说 APScheduler 更容易设置和实现。

安装 APScheduler

与大多数常见的 Python 外部库一样,可以通过包管理器pip来安装 APScheduler,只需在终端中运行以下命令:

pip install apscheduler

如果pip命令不起作用,另一种安装此库的方法是从 PyPI 手动下载源代码,网址为pypi.org/project/APScheduler/。然后可以通过运行以下命令来提取和安装下载的文件:

python setup.py install

与往常一样,要测试您的 APScheduler 发行版是否已正确安装,请打开 Python 解释器并尝试导入库,如下所示:

>>> import apscheduler

如果没有返回错误,这意味着库已经完全安装并准备好使用。

不是调度服务

由于术语“调度程序”可能会对特定开发人员群体产生误导,让我们澄清 APScheduler 提供的功能,以及它不提供的功能。首先,该库可以用作跨平台调度程序,也可以是特定于应用程序的,而不是更常见的特定于平台的调度程序,比如 cron 守护程序(用于 Linux 系统)或 Windows 任务调度程序。

值得注意的是,APScheduler 本身并不是一个具有预构建 GUI 或命令行界面的调度服务。它仍然是一个必须在现有应用程序中导入和利用的 Python 库(这就是为什么它是特定于应用程序的)。然而,正如您将在后面了解到的,APScheduler 具有许多功能,可以利用来构建实际的调度服务。

例如,现在对于 Web 应用程序来说,调度作业(特别是后台作业)的能力是至关重要的,因为它们可以包括不同但重要的功能,如发送电子邮件或备份和同步数据。在这种情况下,APScheduler 可以说是调度云应用程序任务的最常见工具,这些任务涉及 Python 指令,如 Heroku 和 PythonAnywhere。

APScheduler 功能

让我们探索 APScheduler 库提供的一些最常见功能。在执行方面,它提供了三种不同的调度机制,这样我们就可以选择最适合自己应用程序的机制(有时也称为事件触发器):

  • Cron 风格调度:此机制允许作业具有预定的开始和结束时间

  • 基于间隔的执行:此机制以均匀的间隔运行作业(例如,每两分钟、每天),并可选择开始和结束时间

  • 延迟执行:此机制允许应用程序在执行作业列表中的项目之前等待特定的时间段

此外,APScheduler 允许我们将要在各种后端系统中执行的作业存储在常规内存、MongoDB、Redis、RethinkDB、SPLAlchemy 或 ZooKeeper 等系统中。无论是桌面程序、Web 应用程序还是简单的 Python 脚本,APScheduler 都很可能能够处理定时作业的存储方式。

除此之外,该库还可以与常见的 Python 并发框架(如 AsyncIO、Gevent、Tornado 和 Twisted)无缝配合工作。这意味着 APScheduler 库中包含的低级代码包含了可以协调安排和执行这些框架中实现的函数和程序的指令,使得该库更加动态。

最后,APScheduler 提供了不同的选项来实际执行计划代码,通过指定适当的执行器。具体来说,可以简单地以阻塞方式或后台方式执行作业。我们还可以选择使用线程或进程池以并发方式分发工作。稍后,我们将看一个示例,其中我们利用进程池来执行定时作业。

以下图表显示了 APScheduler 中包含的所有主要类和功能:

APScheduler-主要类和功能

APScheduler API

在本节中,我们将看看如何将 APScheduler 实际集成到现有的 Python 程序中,分析库提供的不同类和方法。当我们利用并发执行器运行我们的定时作业时,我们还将看看作业如何分布在不同的线程和进程中。

调度器类

首先,让我们看看我们的主调度器可用的选项,这是安排任务在以后执行过程中最重要的组件:

  • BlockingScheduler:当调度程序打算是进程中唯一运行的任务时,应使用此类。顾名思义,此类的实例将阻止同一进程中的任何其他指令。

  • BackgroundScheduler:与BlockingScheduler相反,此类允许在现有应用程序内后台执行定时作业。

此外,如果您的应用程序使用特定的并发框架,则还有调度器类可供使用:AsyncIOScheduler用于asyncio模块;GeventScheduler用于 Gevent;TornadoScheduler用于 Tornado 应用程序;TwistedScheduler用于 Twisted 应用程序;等等。

执行器类

在安排将来执行的作业的过程中,另一个重要的选择是:哪个执行器应该运行这些作业?通常建议使用默认执行器ThreadPoolExecutor,它在同一进程中的不同线程之间分配工作。然而,正如您所了解的,如果预定的作业包含利用 CPU 密集型操作的指令,则工作负载应该分布在多个 CPU 核心上,并且应该使用ProcessPoolExecutor

重要的是要注意,这两个执行器类与我们在早期章节中讨论的concurrent.futures模块进行交互,以便实现并发执行。这两个执行器类的默认最大工作线程数为10,可以在初始化时进行更改。

触发关键字

在构建调度器的过程中的最后一个决定是如何在将来执行预定的作业;这是我们之前提到的事件触发选项。APScheduler 提供了三种不同的触发机制;以下关键字应作为参数传递给调度器初始化程序,以指定事件触发类型:

  • '日期': 当工作需要在将来的特定时间点运行一次时使用此关键字。

  • '间隔': 当工作需要定期以固定时间间隔运行时使用此关键字。我们稍后在示例中将使用此关键字。

  • 'cron': 当作业需要在一天的特定时间定期运行时使用此关键字。

此外,可以混合和匹配多种类型的触发器。我们还可以选择在所有注册的触发器都指定时执行预定的作业,或者在至少一个触发器指定时执行。

常见的调度器方法

最后,让我们考虑在声明调度器时常用的方法,以及前面提到的类和关键字。具体来说,以下方法由scheduler对象调用:

  • add_executor(): 调用此方法来注册一个执行器以在将来运行作业。通常,我们将字符串'processpool'传递给此方法,以便将作业分布在多个进程中。否则,如前所述,默认执行器将使用线程池。此方法还返回一个可以进一步操作的执行器对象。

  • remove_executor(): 此方法用于在执行器对象上移除它。

  • add_job(): 此方法可用于将额外的作业添加到作业列表中,以便稍后执行。该方法首先接受一个可调用对象,该对象是作业列表中的新作业,以及用于指定作业应如何预定和执行的各种其他参数。与add_executor()类似,此方法可以返回一个可以在方法外部操作的job对象。

  • remove_job(): 类似地,此方法可以用于job对象,以将其从调度器中移除。

  • start(): 此方法启动预定的作业以及已实现的执行器,并开始处理作业列表。

  • shutdown(): 此方法停止调用调度器对象,以及其作业列表和已实现的执行器。如果在当前有作业运行时调用它,这些作业将不会被中断。

Python 示例

在本小节中,我们将看看我们讨论的一些 API 在示例 Python 程序中的使用方式。从 GitHub 页面下载本书的代码,然后转到Chapter19文件夹。

阻塞调度器

首先,让我们看一个阻塞调度器的示例,在Chapter19/example1.py文件中:

# Chapter19/example1.py

from datetime import datetime

from apscheduler.schedulers.background import BlockingScheduler

def tick():
    print(f'Tick! The time is: {datetime.now()}')

if __name__ == '__main__':
    scheduler = BlockingScheduler()
    scheduler.add_job(tick, 'interval', seconds=3)

    try:
        scheduler.start()
        print('Printing in the main thread.')
    except KeyboardInterrupt:
        pass

scheduler.shutdown()

在这个例子中,我们正在为前面代码中指定的tick()函数实现一个调度程序,该函数简单地打印出执行时的当前时间。在我们的主函数中,我们使用了从 APScheduler 导入的BlockingScheduler类的实例作为本程序的调度程序。除此之外,上述的add_job()方法被用来注册tick()作为稍后要执行的作业。具体来说,它应该定期执行,以均匀的间隔(由传入的'interval'字符串指定)——特别是每三秒钟(由参数seconds=3指定)。

请记住,阻塞调度程序将阻止在其运行的同一进程中的所有其他指令。为了测试这一点,我们还在启动调度程序后插入了一个print语句,以查看它是否会被执行。运行脚本后,您的输出应该类似于以下内容(除了正在打印的具体时间):

> python3 example1.py
Tick! The time is: 2018-10-31 17:25:01.758714
Tick! The time is: 2018-10-31 17:25:04.760088
Tick! The time is: 2018-10-31 17:25:07.762981

请注意,该调度程序将永远运行,除非它被KeyboardInterrupt事件或其他潜在异常停止,并且我们放在主程序末尾附近的打印语句将永远不会被执行。因此,只有在打算在其进程中运行的唯一任务时,才应该使用BlockingScheduler类。

后台调度程序

在这个例子中,我们将看看是否使用BackgroundScheduler类会有所帮助,如果我们想要在后台并发地执行我们的调度程序。此示例的代码包含在Chapter19/example2.py文件中,如下所示:

# Chapter19/example2.py

from datetime import datetime
import time

from apscheduler.schedulers.background import BackgroundScheduler

def tick():
    print(f'Tick! The time is: {datetime.now()}')

if __name__ == '__main__':
    scheduler = BackgroundScheduler()
    scheduler.add_job(tick, 'interval', seconds=3)
    scheduler.start()

    try:
        while True:
            time.sleep(2)
            print('Printing in the main thread.')
    except KeyboardInterrupt:
        pass

scheduler.shutdown()

这个例子中的代码几乎与我们之前的代码相同。然而,在这里,我们使用了后台调度程序的类,并且每两秒钟在一个无限的while循环中从主程序中打印出消息。理论上,如果scheduler对象确实可以在后台运行计划的作业,我们的输出将由主程序和tick()函数中的打印语句的组合组成。

执行脚本后,以下是我的输出:

> python3 example2.py
Printing in the main thread.
Tick! The time is: 2018-10-31 17:36:35.231531
Printing in the main thread.
Tick! The time is: 2018-10-31 17:36:38.231900
Printing in the main thread.
Printing in the main thread.
Tick! The time is: 2018-10-31 17:36:41.231846
Printing in the main thread.

同样,调度程序将一直继续下去,直到从键盘中产生中断。在这里,我们可以看到我们期望看到的东西:主程序和计划的作业的打印语句同时产生,表明调度程序确实在后台运行。

执行器池

APScheduler 提供的另一个功能是能够将计划的作业分发到多个 CPU 核心(或进程)上执行。在这个例子中,您将学习如何使用后台调度程序来实现这一点。转到Chapter19/example3.py文件并检查包含的代码,如下所示:

# Chapter19/example3.py

from datetime import datetime
import time
import os

from apscheduler.schedulers.background import BackgroundScheduler

def task():
    print(f'From process {os.getpid()}: The time is {datetime.now()}')
    print(f'Starting job inside {os.getpid()}')
    time.sleep(4)
    print(f'Ending job inside {os.getpid()}')

if __name__ == '__main__':
    scheduler = BackgroundScheduler()
    scheduler.add_executor('processpool')
    scheduler.add_job(task, 'interval', seconds=3, max_instances=3)
    scheduler.start()

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        pass

scheduler.shutdown()

在这个程序中,我们想要调度的作业(task()函数)在每次调用时打印出运行它的进程的标识符(使用os.getpid()方法),并且设计为持续约四秒钟。在主程序中,我们使用了上一个示例中使用的相同后台调度程序,但我们指定了计划的作业应该在一个进程池中执行:

scheduler.add_executor('processpool')

请记住,此进程池中进程数量的默认值为 10,可以更改为不同的值。接下来,当我们将作业添加到调度程序时,我们还必须指定此作业可以在多个进程实例中执行(在本例中为三个实例);这允许我们的进程池执行程序得到充分和高效地利用:

scheduler.add_job(task, 'interval', seconds=3, max_instances=3)

运行程序后,我的输出的前几行如下:

> python3 example3.py
From process 1213: The time is 2018-11-01 10:18:00.559319
Starting job inside 1213
From process 1214: The time is 2018-11-01 10:18:03.563195
Starting job inside 1214
Ending job inside 1213
From process 1215: The time is 2018-11-01 10:18:06.531825
Starting job inside 1215
Ending job inside 1214
From process 1216: The time is 2018-11-01 10:18:09.531439
Starting job inside 1216
Ending job inside 1215
From process 1217: The time is 2018-11-01 10:18:12.531940
Starting job inside 1217
Ending job inside 1216
From process 1218: The time is 2018-11-01 10:18:15.533720
Starting job inside 1218
Ending job inside 1217
From process 1219: The time is 2018-11-01 10:18:18.532843
Starting job inside 1219
Ending job inside 1218
From process 1220: The time is 2018-11-01 10:18:21.533668
Starting job inside 1220
Ending job inside 1219
From process 1221: The time is 2018-11-01 10:18:24.535861
Starting job inside 1221
Ending job inside 1220
From process 1222: The time is 2018-11-01 10:18:27.531543
Starting job inside 1222
Ending job inside 1221
From process 1213: The time is 2018-11-01 10:18:30.532626
Starting job inside 1213
Ending job inside 1222
From process 1214: The time is 2018-11-01 10:18:33.534703
Starting job inside 1214
Ending job inside 1213

从打印的进程标识中可以看出,计划任务是在不同的进程中执行的。您还会注意到第一个进程的 ID 是1213,而当我们的调度器开始使用 ID 为1222的进程时,它又切换回1213进程(请注意前面输出的最后几行)。这是因为我们的进程池包含 10 个工作进程,而1222进程是池的最后一个元素。

在云上运行

早些时候,我们提到了托管 Python 代码的云服务,如 Heroku 和 PythonAnywhere,是应用 APScheduler 功能的最常见的地方之一。在本小节中,我们将看一下 Heroku 网站用户指南中的一个示例,该示例可以在Chapter19/example4.py文件中找到:

# ch19/example4.py
# Copied from: http://devcenter.heroku.com/articles/clock-processes-python

from apscheduler.schedulers.blocking import BlockingScheduler

scheduler = BlockingScheduler()

@scheduler.scheduled_job('interval', minutes=3)
def timed_job():
    print('This job is run every three minutes.')

@scheduler.scheduled_job('cron', day_of_week='mon-fri', hour=17)
def scheduled_job():
    print('This job is run every weekday at 5pm.')

scheduler.start()

您可以看到,该程序使用装饰器为调度器注册了计划任务。具体来说,当scheduled_job()方法由scheduler对象调用时,整个指令可以作为函数的装饰器,将其转换为该调度器的调度任务。您还可以在前面的代码中看到一个cron计划的作业的示例,它可以在一天中的特定时间执行(在这种情况下,是每个工作日下午 5:00)。

最后关于 APScheduler 的一点说明,我们已经看到利用库 API 的指令也是 Python 代码,而不是一个独立的服务。然而,考虑到该库在提供不同的调度选项方面有多么灵活,以及在与外部服务(如基于云的服务)合作方面有多么可插拔,APScheduler 是调度 Python 应用程序的有价值的工具。

Python 中的测试和并发

如前所述,测试是软件开发特别是编程中一个重要的(但经常被忽视的)组成部分。测试的目标是引发错误,这些错误会表明我们程序中存在 bug。这与调试的过程相对,调试用于识别 bug 本身;我们将在下一节讨论调试的主题。

在最一般的意义上,测试是关于确定特定的功能和方法是否能够执行并产生我们期望的结果;通常是通过比较产生的结果来完成的。换句话说,测试是收集关于程序正确性的证据。

然而,测试不能确保在考虑中的程序中所有潜在的缺陷和 bug 都会被识别出来。此外,测试结果只有测试本身那么好,如果测试没有涵盖一些特定的潜在 bug,那么这些 bug 在测试过程中很可能不会被检测到。

测试并发程序

在本章中,我们将考虑与并发相关的测试的两个不同主题:测试并发程序同时测试程序。当涉及测试并发程序时,一般的共识是这是极其严格和难以正确完成的。正如您在前几章中看到的,诸如死锁或竞争条件之类的 bug 在并发程序中可能相当微妙,并且可能以多种方式表现出来。

此外,并发的一个显著特点是非确定性,这意味着并发 bug 可能在一个测试运行中被检测到,而在另一个测试运行中变得不可见。这是因为并发编程的一个重要组成部分是任务的调度,就像并发程序中执行不同任务的顺序一样,并发 bug 可能以不可预测的方式显示和隐藏自己。我们称这些测试为不可重现的,表示我们无法以一致的方式可靠地通过或失败这些测试来测试程序。

有一些通用策略可以帮助我们在测试并发程序的过程中进行导航。在接下来的部分中,我们将探讨各种工具,这些工具可以帮助我们针对测试并发程序的特定策略进行辅助。

单元测试

我们将考虑的第一种策略是单元测试。该术语表示一种测试程序考虑的各个单元的方法,其中单元是程序的最小可测试部分。因此,单元测试不适用于测试完整的并发系统。具体来说,建议您不要将并发程序作为一个整体进行测试,而是将程序分解为较小的组件并分别测试它们。

通常情况下,Python 提供了提供直观 API 来解决编程中最常见问题的库;在这种情况下,它是unittest模块。该模块最初受到了 Java 编程语言 JUnit 的单元测试框架的启发;它还提供了其他语言中常见的单元测试功能。让我们考虑一个快速示例,演示如何使用unittest来测试Chapter19/example5.py文件中的 Python 函数:

# Chapter19/example5.py

import unittest

def fib(i):
    if i in [0, 1]:
        return i

    return fib(i - 1) + fib(i - 2)

class FibTest(unittest.TestCase):
    def test_start_values(self):
        self.assertEqual(fib(0), 0)
        self.assertEqual(fib(1), 1)

    def test_other_values(self):
        self.assertEqual(fib(10), 55)

if __name__ == '__main__':
    unittest.main()

在这个例子中,我们想要测试fib()函数,该函数生成斐波那契数列中的特定元素(其中一个元素是其前两个元素的和),其起始值分别为01

现在,让我们把注意力集中在FibTest类上,该类扩展了unittest模块中的TestCase类。这个类包含了测试fib()函数返回的特定结果的不同方法。具体来说,我们有一个方法来查看这个函数的边界情况,即序列的前两个元素,还有一个方法来测试序列中的任意值。

在运行上述脚本之后,您的输出应该类似于以下内容:

> python3 unit_test.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

输出表明我们的测试通过了,没有任何错误。另外,正如类名所示,这个类是一个单独的测试用例,是测试的一个单元。您可以将不同的测试用例扩展为测试套件,它被定义为测试用例、测试套件或两者的集合。测试套件通常用于组合您想要一起运行的测试。

静态代码分析

识别并发程序中潜在错误和漏洞的另一种可行方法是进行静态代码分析。这种方法寻找代码本身的模式,而不是执行代码的一部分(或全部)。换句话说,静态代码分析通过视觉检查程序的结构、变量和指令的使用以及程序的不同部分如何相互交互来检查程序。

使用静态代码分析的主要优势在于,我们不仅依赖于程序的执行和在该过程中产生的结果(换句话说,动态测试)来确定程序是否设计正确。这种方法可以检测在实施测试中不会表现出来的错误和漏洞。因此,静态代码分析应该与其他测试方法结合使用,例如单元测试,以创建一个全面的测试过程。

静态代码分析通常用于识别微妙的错误或漏洞,例如未使用的变量、空的 catch 块,甚至不必要的对象创建。在并发编程方面,该方法可用于分析程序中使用的同步技术。具体来说,静态代码分析可以查找程序中共享资源的原子性,然后揭示任何不协调使用非原子资源的情况,这可能会产生有害的竞争条件。

Python 程序的静态代码分析有各种工具可用,其中一个比较常见的是 PMD(github.com/pmd/pmd)。话虽如此,这些工具的具体使用超出了本书的范围,我们不会进一步讨论它们。

并发测试程序。

结合测试和并发编程的另一个方面是以并发方式执行测试。这方面的测试比测试并发程序本身更直接和直观。在本小节中,我们将探索一个可以帮助我们简化这个过程的库concurrencytest,它可以与前面的unittest模块实现的测试用例无缝配合。

concurrencytest被设计为testtools的扩展,用于在运行测试套件时实现并发。可以通过 PyPI 使用pip安装它,如下所示:

pip install concurrencytest

另外,concurrencytest依赖于testtoolspypi.org/project/testtools/)和python-subunitpypi.org/project/python-subunit/)库,它们分别是测试扩展框架和测试结果的流程协议。这些库也可以通过pip安装,如下所示:

pip install testtools
pip install python-subunit

和往常一样,要验证你的安装,尝试在 Python 解释器中导入库:

>>> import concurrencytest

没有打印错误意味着库及其依赖项已成功安装。现在,让我们看看这个库如何帮助我们提高测试速度。转到Chapter19/example6.py文件并考虑以下代码:

# Chapter19/example6.py

import unittest

def fib(i):
    if i in [0, 1]:
        return i

    a, b = 0, 1
    n = 1
    while n < i:
        a, b = b, a + b
        n += 1

    return b

class FibTest(unittest.TestCase):
    def __init__(self, *args, **kwargs):
        super(FibTest, self).__init__(*args, **kwargs)
        self.mod = 10 ** 10

    def test_start_values(self):
        self.assertEqual(fib(0), 0)
        self.assertEqual(fib(1), 1)

    def test_big_value_v1(self):
        self.assertEqual(fib(499990) % self.mod, 9998843695)

    def test_big_value_v2(self):
        self.assertEqual(fib(499995) % self.mod, 1798328130)

    def test_big_value_v3(self):
        self.assertEqual(fib(500000) % self.mod, 9780453125)

if __name__ == '__main__':
    unittest.main()

本节示例的主要目标是测试生成斐波那契数列中具有大索引的数字的函数。我们拥有的fib()函数与之前的示例类似,尽管这个函数是迭代执行计算的,而不是使用递归。

在我们的测试用例中,除了两个起始值外,我们现在还在测试索引为 499,990、499,995 和 500,000 的数字。由于结果数字非常大,我们只测试每个数字的最后十位数(这是通过测试类的初始化方法中指定的mod属性完成的)。这个测试过程将在一个进程中以顺序方式执行。

运行程序,你的输出应该类似于以下内容:

> python3 example6.py
....
----------------------------------------------------------------------
Ran 4 tests in 8.809s

OK

再次强调,输出中指定的时间可能因系统而异。话虽如此,记住程序所花费的时间,以便与我们稍后考虑的其他程序的速度进行比较。

现在,让我们看看如何使用concurrencytest在多个进程中分发测试工作负载。考虑以下Chapter19/example7.py文件:

# Chapter19/example7.py

import unittest
from concurrencytest import ConcurrentTestSuite, fork_for_tests

def fib(i):
    if i in [0, 1]:
        return i

    a, b = 0, 1
    n = 1
    while n < i:
        a, b = b, a + b
        n += 1

    return b

class FibTest(unittest.TestCase):
    def __init__(self, *args, **kwargs):
        super(FibTest, self).__init__(*args, **kwargs)
        self.mod = 10 ** 10

    def test_start_values(self):
        self.assertEqual(fib(0), 0)
        self.assertEqual(fib(1), 1)

    def test_big_value_v1(self):
        self.assertEqual(fib(499990) % self.mod, 9998843695)

    def test_big_value_v2(self):
        self.assertEqual(fib(499995) % self.mod, 1798328130)

    def test_big_value_v3(self):
        self.assertEqual(fib(500000) % self.mod, 9780453125)

if __name__ == '__main__':
    suite = unittest.TestLoader().loadTestsFromTestCase(FibTest)
    concurrent_suite = ConcurrentTestSuite(suite, fork_for_tests(4))
    runner.run(concurrent_suite)

这个程序版本正在检查相同的fib()函数,使用相同的测试用例。然而,在主程序中,我们正在初始化concurrencytest库的ConcurrentTestSuite类的一个实例。这个实例接受一个测试套件,该测试套件是使用unittest模块的TestLoader()API 创建的,并使用fork_for_tests()函数,参数为4,以指定我们要利用四个独立进程来分发测试过程。

现在,让我们运行这个程序,并将其速度与之前的测试进行比较:

> python3 example7.py
....
----------------------------------------------------------------------
Ran 4 tests in 4.363s

OK

你可以看到,通过这种多进程方法,速度有了显著的提高。然而,这种改进并不完全达到了完美的可扩展性(在第十六章中讨论过,设计基于锁和无互斥的并发数据结构);这是因为创建可以在多个进程中执行的并发测试套件会产生相当大的开销。

我们还应该提到的一点是,通过使用我们在前几章讨论过的传统并发编程工具,如concurrent.futuresmultiprocessing,完全可以实现与我们在这里实现的相同的多进程设置。尽管如此,正如我们所看到的,concurrencytest库能够消除大量样板代码,因此提供了一个简单快速的 API。

调试并发程序

在最后一节中,我们将讨论各种高级调试策略,这些策略可以单独使用,也可以结合使用,以便检测和定位程序中的错误。

我们将讨论的策略包括一般的调试策略,以及调试并发应用程序中使用的特定技术。系统地应用这些策略将提高调试过程的效率和速度。

调试工具和技术

首先,让我们简要地看一下一些可以在 Python 中促进调试过程的常见技术和工具:

  • 打印调试:这可能是最基本和直观的调试方法。这种方法涉及在考虑的程序执行过程中的各个点插入打印语句,以输出变量的值或函数的状态。这样做可以让我们跟踪这些值和状态在程序中如何相互作用和改变,从而让我们了解特定错误或异常是如何引发的。

  • 日志记录:在计算机科学领域,日志记录是记录特定程序执行过程中发生的各种事件的过程。实质上,日志记录可能与打印调试非常相似;然而,前者通常会写入一个可以稍后查看的日志文件。Python 提供了出色的日志记录功能,包含在内置的logging模块中。用户可以指定日志记录过程的重要性级别;例如,通常情况下,可以仅记录重要事件和操作,但在调试期间将记录所有内容。

  • 跟踪:这是另一种跟踪程序执行的形式。跟踪遵循程序执行的实际低级细节,而不仅仅是变量和函数的变化。跟踪功能可以通过 Python 中的sys.settrace()方法实现。

  • 使用调试器:有时,最强大的调试选项可以通过自动调试器实现。Python 语言中最流行的调试器是 Python 调试器:pdb。该模块提供了一个交互式调试环境,实现了诸如断点、逐步执行源代码或检查堆栈等有用功能。

同样,上述策略适用于传统程序和并发程序,结合其中的一个或多个策略可以帮助程序员在调试过程中获得有价值的信息。

调试和并发

与测试并发程序的问题类似,调试并发时可能变得越来越复杂和困难。这是因为共享资源可以与(并且可以被)多个代理同时交互和改变。尽管如此,仍然有一些策略可以使调试并发程序的过程更加简单。这些策略包括以下内容:

  • 最小化:并发应用通常在复杂和相互连接的系统中实现。当发生错误时,调试整个系统可能会令人望而生畏,并且并不可行。策略是将系统的不同部分隔离成单独的、较小的程序,并识别与大型系统相同方式失败的部分。在这里,我们希望将一个大型程序分割成越来越小的部分,直到它们无法再分割。然后可以轻松地识别原始错误并有效地修复。

  • 单线程和处理:这种方法类似于最小化,但专注于并发编程的一个方面:不同线程/进程之间的交互。通过消除并发编程中最大的方面,可以将错误隔离到程序逻辑本身(即使按顺序运行时也可能导致错误)或线程/进程之间的交互(这可能是由我们在前几章中讨论的常见并发错误导致的)。

  • 操纵调度以放大潜在错误:我们实际上在前几章中看到了这种方法的应用。如果我们程序中实现的线程/进程没有按特定方式调度执行,一些并发错误可能不经常显现。例如,如果共享资源与其他代理之间的交互发生得如此之快,以至于它们不经常重叠,那么现有的竞争条件可能不会影响共享资源。这导致测试可能不会揭示竞争条件,即使它实际上存在于程序中。

可以在 Python 中实现各种方法,以放大并发错误导致的不正确值和操作。其中最常见的两种是模糊化,通过在线程/进程指令中的命令之间插入休眠函数来实现,以及最小化系统线程切换间隔,通过使用sys.setcheckinterval()方法(在第十七章中讨论,内存模型和原子类型上的操作)。这些方法以不同的方式干扰 Python 中线程和进程执行的常规调度协议,并可以有效地揭示隐藏的并发错误。

总结

在本章中,我们通过调度、测试和调试对 Python 中的并发程序进行了高层次的分析。可以通过 APScheduler 模块在 Python 中进行调度,该模块提供了强大而灵活的功能,以指定将来如何执行预定作业。此外,该模块允许预定的作业在不同的线程和进程中分布和执行,提供了测试速度的并发改进。

并发还在测试和调试方面引入了复杂的问题,这是由程序中代理之间的同时和并行交互导致的。然而,这些问题可以通过有条理的解决方案和适当的工具有效地解决。

这个主题标志着我们通过《Python 并发编程大师》的旅程结束。在整本书中,我们深入考虑和分析了使用 Python 语言进行并发编程的各种元素,如线程、多进程和异步编程。此外,还讨论了涉及并发性的强大应用,如上下文管理、减少操作、图像处理和网络编程,以及在 Python 中处理并发性的程序员面临的常见问题。

从最一般的意义上讲,这本书是对并发的一些更高级概念的指南;我希望通过阅读这本书,你有机会对并发编程的主题有所了解。

问题

  • APScheduler 是什么?为什么它不是一个调度服务?

  • APScheduler 的主要调度功能是什么?

  • APScheduler 与 Python 中另一个调度工具 Celery 之间有什么区别?

  • 编程中测试的目的是什么?在并发编程中有何不同?

  • 本章讨论了哪些测试方法?

  • 调试在编程中的目的是什么?在并发编程中有何不同?

  • 本章讨论了哪些调试方法?

进一步阅读

有关更多信息,您可以参考以下链接:

第二十章:评估

第一章

并发的背后是什么想法,为什么它有用?

并发是关于设计和构造程序命令和指令,以便程序的不同部分可以以有效的顺序执行,同时共享相同的资源。

并发编程和顺序编程之间有什么区别?

在顺序编程中,命令和指令是按顺序一个接一个地执行的。在并发编程中,一些部分可能以更有效的方式执行,以获得更好的执行时间。

并发编程和并行编程之间有什么区别?

在并行编程中,程序的各个部分彼此独立;它们不相互交互,因此可以同时执行。在并发编程中,各个任务共享相同的资源,因此需要它们之间的某种协调。

每个程序都可以并发或并行吗?

不。

什么是尴尬并行任务?

尴尬并行任务可以被分成独立的部分,几乎不需要任何努力。

什么是固有顺序任务?

任务中,单个部分的执行顺序对任务结果至关重要,无法并发或并行以获得更好的执行时间的任务被称为固有顺序。

I/O 绑定是什么意思?

这是一种情况,其中完成计算所需的时间主要由等待输入/输出操作完成的时间决定。

并发处理目前在现实世界中是如何使用的?

并发几乎无处不在:桌面和移动应用程序,视频游戏,Web 和互联网开发,人工智能等等。

第二章

什么是阿姆达尔定律?阿姆达尔定律试图解决什么问题?

阿姆达尔定律提供了一个估计,在固定工作负载下,可以预期系统资源改进后任务执行延迟的理论加速度。

解释阿姆达尔定律的公式及其组成部分。

阿姆达尔定律的公式如下:

在前面的公式中,以下适用:

  • S是考虑中的理论加速度。

  • B是固有顺序的整个任务的部分。

  • j是正在利用的处理器数量。

根据阿姆达尔定律,随着系统资源的改进,速度提升会无限增加吗?

不;随着处理器数量的增加,通过改进获得的效率减少。

阿姆达尔定律和收益递减定律之间的关系是什么?

您已经看到,在特定情况下(即只有处理器数量增加时),阿姆达尔定律类似于收益递减定律。具体来说,随着处理器数量的增加,通过改进获得的效率减少,加速曲线变平。

第三章

什么是线程?线程和进程之间的核心区别是什么?

执行线程是编程命令的最小单位。在同一个进程中可以实现多个线程,通常并发执行并访问/共享相同的资源,如内存,而单独的进程不会这样做。

Python 中thread模块提供了哪些 API 选项?

thread模块的主要特点是快速高效地创建新线程来执行函数:thread.start_new_thread()函数。除此之外,该模块仅支持一些低级的方式来处理多线程原语并共享它们的全局数据空间。此外,提供了简单的锁对象(例如互斥锁和信号量)用于同步目的。

threading模块在 Python 中提供了哪些 API 选项?

除了thread模块提供的所有与线程相关的功能外,threading模块还支持一些额外的方法,如下所示:

  • threading.activeCount(): 此函数返回程序中当前活动线程对象的数量。

  • threading.currentThread(): 此函数返回调用者当前线程控制中的线程对象数量。

  • threading.enumerate(): 此函数返回程序中当前活动线程对象的列表。

通过threadthreading模块创建新线程的过程是什么?

使用threadthreading模块创建新线程的过程如下:

  • thread模块中,创建新线程以并发执行函数。这样做的方法是使用thread.start_new_thread()函数:thread.start_new_thread(function, args[, kwargs])

  • 要使用threading模块创建和自定义新线程,需要遵循特定的步骤:

  1. 在我们的程序中定义threading.Thread类的子类

  2. 在子类中重写默认的__init__(self [,args])方法,以添加类的自定义参数

  3. 在子类中重写默认的run(self [,args])方法,以自定义线程类在初始化和启动新线程时的行为

使用锁进行线程同步的理念是什么?

在给定程序中,当一个线程正在访问/执行程序的临界区时,任何其他线程都需要等待,直到该线程执行完毕。线程同步的典型目标是避免多个线程访问其共享资源时可能出现的任何潜在数据不一致;只允许一个线程一次执行临界区可以保证在我们的多线程应用程序中不会发生数据冲突。应用线程同步的最常见方法之一是通过实现锁定机制。

使用锁在 Python 中实现线程同步的过程是什么?

在我们的threading模块中,threading.Lock类提供了一种简单直观的方法来创建和使用锁。其主要用法包括以下方法:

  • threading.Lock(): 此方法初始化并返回一个新的锁对象。

  • acquire(blocking): 当调用此方法时,所有线程将同步运行(即,一次只有一个线程可以执行临界区)。

  • release(): 当调用此方法时,锁被释放。

队列数据结构的理念是什么?

队列是一种抽象数据结构,是按特定顺序维护的不同元素的集合;这些元素可以是程序中的其他对象。

排队在并发编程中的主要应用是什么?

队列的概念在并发编程的子领域中更为普遍,因为队列中维护的元素顺序在多线程程序处理和操作其共享资源时起着重要作用。

常规队列和优先级队列之间的核心区别是什么?

优先级队列抽象数据结构类似于队列数据结构,但是优先级队列的每个元素,正如其名称所示,都有与之关联的优先级;换句话说,当元素被添加到优先级队列时,需要指定其优先级。与常规队列不同,优先级队列的出队原则依赖于元素的优先级:具有较高优先级的元素在具有较低优先级的元素之前被处理。

第四章

文件描述符是什么,以及在 Python 中可以如何处理它?

文件描述符用作程序中已打开外部文件的句柄。在 Python 中,可以通过使用open()close()函数或使用with语句来处理文件描述符;例如:

  • f = open(filename, 'r'); ... ; f.close()

  • with open(filename, 'r') as f: ...

当文件描述符没有小心处理时会出现什么问题?

系统只能在一个运行的进程中处理一定数量的已打开外部文件。当超过限制时,已打开文件的句柄将受到损害,并且会发生文件描述符泄漏。

锁是什么,以及在 Python 中如何处理它?

锁是并发和并行编程中执行线程同步的机制。在 Python 中,可以通过使用acquire()release()方法或使用with语句来处理threading.Lock对象;例如:

  • my_lock.acquire(); ... ; my_lock.release()

  • with my_lock: ...

当锁没有被小心处理时会出现什么问题?

当获取锁时发生异常时,如果不小心处理,锁将永远无法释放和重新获取,从而导致并发和并行编程中常见的死锁问题。

上下文管理器背后的理念是什么?

上下文管理器负责程序中资源的上下文;它们定义并处理其他实体与这些资源的交互,并在程序退出上下文后执行清理任务。

with语句在 Python 中提供了哪些选项,就上下文管理而言?

Python 中的with语句提供了一种直观和方便的方式来管理资源,同时确保错误和异常被正确处理。除了更好的错误处理和保证的清理任务外,with语句还提供了程序的额外可读性,这是 Python 为其开发人员提供的最强大的功能之一。

第五章

HTML 是什么?

HTML 代表超文本标记语言,是开发网页和 Web 应用程序的标准和最常见的标记语言。

HTTP 请求是什么?

通过互联网进行的大部分通信(更具体地说,是万维网)都使用 HTTP。在 HTTP 中,请求方法用于传达有关请求的数据以及应该从服务器返回的信息。

HTTP 响应状态码是什么?

HTTP 响应状态码是三位数字,表示服务器和客户端之间通信状态。它们分为五类,每一类表示特定的通信状态。

requests模块如何帮助进行网络请求?

requests模块通过 HTTP 请求管理 Python 程序与 Web 服务器之间的通信。

Ping 测试是什么,通常如何设计?

Ping 测试是一个通常由 Web 管理员使用的工具,用于确保他们的网站对客户端仍然可用。Ping 测试通过向考虑中的网站发出请求并分析返回的响应状态码来实现这一点

为什么并发适用于进行网络请求?

向 Web 服务器发出不同请求的过程以及解析和处理下载的 HTML 源代码的过程在不同请求之间是独立的。

在开发网络抓取应用程序时需要考虑哪些因素?

在开发进行并发网络请求的应用程序时,应考虑以下因素:

  • 服务条款和数据收集政策

  • 错误处理

  • 定期更新您的程序

  • 避免过度抓取

第六章

进程是什么?进程和线程之间的核心区别是什么?

进程是操作系统执行的特定计算机程序或软件的实例。进程包含程序代码及其当前的活动和与其他实体的交互。可以在同一个进程中实现多个线程以访问和共享内存或其他资源,而不同进程不以这种方式互动。

多进程是什么?多进程和多线程之间的核心区别是什么?

在操作系统中,多进程是指从操作系统中执行多个并发进程,其中每个进程在单独的 CPU 上执行,而不是在任何给定时间内执行单个进程。另一方面,多线程是指执行多个线程,这些线程可以在同一个进程内。

multiprocessing模块提供了哪些 API 选项?

multiprocessing模块提供了对Process类的 API,其中包含了进程的实现,同时提供了类似于threading模块的 API 来生成和与进程交互的方法。该模块还提供了Pool类,主要用于实现一组进程,每个进程将执行提交的任务。

multiprocessing模块的Process类和Pool类之间的核心区别是什么?

Pool类实现了一组进程,每个进程将执行提交给Pool对象的任务。一般来说,Pool类比Process类更方便,特别是如果并发应用程序返回的结果应该是有序的。

在 Python 程序中确定当前进程的选项是什么?

multiprocessing模块提供了current_process()方法,它将返回程序中当前正在运行的Process对象。在程序中跟踪运行的进程的另一种方法是通过os模块查看各个进程的进程 ID。

守护进程是什么?在多进程程序中,它们的目的是什么?

守护进程在后台运行,不会阻止主程序退出。当主程序没有简单的方法告知何时中断进程,或者在不影响最终结果的情况下退出主程序而不完成工作时,这种规范是常见的。

你如何终止一个进程?为什么有时终止进程是可以接受的?

multiprocessing.Process类的terminate()方法提供了一种快速终止进程的方式。如果程序中的进程从不与共享资源交互,则terminate()方法非常有用,特别是如果进程看起来无响应或死锁。

在 Python 中,促进进程间通信的方式有哪些?

虽然锁是用于线程间通信的最常见的同步原语之一,但管道和队列是不同进程之间通信的主要方式。具体来说,它们提供了消息传递选项,以促进进程之间的通信:管道用于两个进程之间的连接,队列用于多个生产者和消费者。

第七章

缩减运算符是什么?必须满足什么条件,使操作符成为缩减运算符?

如果操作符满足以下条件,则为缩减操作符:

  • 操作符可以将一组元素缩减为一个标量值

  • 通过创建和计算部分任务来获得最终结果(标量值)

具有与所需条件等效的缩减运算符的属性是什么?

交换和结合性属性被认为等效于缩减运算符的要求。

缩减运算符与并发编程之间有什么联系?

减少运算符需要交换和结合属性。因此,它们的子任务必须能够独立处理,这使并发和并行性适用。

在使用 Python 中促进进程间通信的多进程程序时,必须考虑的一些因素是什么?

一些考虑包括实施毒丸技术,以便将子任务分布到所有消费者进程;每次调用get()函数时,在任务队列上调用task_done(),以确保join()函数不会无限期地阻塞;避免使用qsize()方法,该方法不可靠,并且在 Unix 操作系统上未实现。

并发减少运算符的一些现实应用是什么?

一些现实应用包括重型数值运算符和利用逻辑运算符的复杂程序。

第八章

什么是图像处理任务?

图像处理是分析和操作数字图像文件以创建图像的新版本,或从中提取重要数据的任务。

数字成像的最小单位是什么?它在计算机中是如何表示的?

数字成像的最小单位是像素,通常包含 RGB 值:0 到 255 之间的整数元组。

什么是灰度化?这种技术有什么作用?

灰度化是将图像转换为灰色的过程,只考虑每个像素的强度信息,表示为可用光的数量。它通过将传统的三维颜色数据映射到一维灰色数据,减少了图像像素矩阵的维度。

什么是阈值处理?这种技术有什么作用?

阈值处理将图像中的每个像素替换为白色像素,如果像素的强度大于先前指定的阈值,则替换为黑色像素,如果像素的强度小于该阈值。在图像上执行阈值处理后,该图像的每个像素只能容纳两个可能的值,大大降低了图像数据的复杂性。

为什么应该使图像处理并发?

当涉及到图像处理时,通常涉及到大量的计算数值过程,因为每个图像都是一个整数元组的矩阵。然而,这些过程可以独立执行,这表明整个任务应该是并发的。

一些并发图像处理的良好实践是什么?

一些并发图像处理的良好实践如下:

  • 选择正确的方法(众多方法中的一种)

  • 生成适当数量的进程

  • 同时处理输入/输出

第九章

异步编程背后的理念是什么?

异步编程是一种编程模型,专注于协调应用程序中的不同任务,以便应用程序使用最少的时间来完成执行这些任务。异步程序在等待和处理时间之间创建重叠,从而缩短完成整个程序所需的总时间。

异步编程与同步编程有何不同?

在同步编程中,程序的指令是顺序执行的:在程序中的下一个任务开始处理之前,必须完成当前任务的执行。使用异步编程,如果当前任务需要较长时间才能完成,您可以在任务执行期间的某个时间指定切换到另一个任务。

异步编程与线程和多进程有何不同?

异步编程将程序的所有指令保持在同一个线程和进程中。异步编程的主要思想是,如果单个执行程序从一个任务切换到另一个任务更有效(就执行时间而言),那么只需等待第一个任务一段时间,同时处理第二个任务。

第十章

什么是异步编程?它提供了哪些优势?

异步编程是一种利用协调计算任务以重叠等待和处理时间的编程模型。如果成功实现,异步编程既提供了响应性,又提高了速度,与同步编程相比。

异步程序的主要元素是什么?它们如何相互交互?

异步程序有三个主要组件:事件循环、协程和期货。事件循环负责通过使用其任务队列调度和管理协程;协程是要异步执行的计算任务,每个协程都必须在其函数内部指定在哪里将执行流程返回给事件循环(即任务切换事件);期货是包含从协程获得的结果的占位符对象。

asyncawait关键字是什么?它们有什么作用?

Python 语言提供了asyncawait关键字,用于在低级别实现异步编程。async关键字放在函数前面,以声明它为协程,而await关键字指定任务切换事件。

asyncio模块在实现异步编程方面提供了哪些选项?

asyncio模块提供了易于使用的 API 和直观的框架来实现异步程序;此外,该框架使异步代码与同步代码一样易读,这在异步编程中通常是相当罕见的。

Python 3.7 中提供的有关异步编程的改进是什么?

Python 3.7 改进了启动和运行异步程序的主要事件循环的 API,同时将asyncawait保留为官方 Python 关键字。

什么是阻塞函数?为什么它们对传统的异步编程构成问题?

阻塞函数具有不间断的执行,因此阻止任何尝试在异步程序中协作切换任务。如果被迫释放执行流程返回到事件循环,阻塞函数将简单地暂停执行,直到轮到它们再次运行。虽然在这种情况下仍然实现了更好的响应性,但异步编程未能提高程序的速度;事实上,由于各种开销,异步版本的程序通常需要更长的时间来完成执行。

concurrent.futures如何为异步编程中的阻塞函数提供解决方案?它提供了哪些选项?

concurrent.futures模块实现了线程和多进程,用于在异步程序中执行协程。它为异步编程提供了ThreadPoolExecutorProcessPoolExecutor,分别在单独的线程和单独的进程中执行。

第十一章

什么是通信通道?它与异步编程有什么关系?

通信通道用于表示不同系统之间的物理连接以及促进计算机网络的数据逻辑通信。后者与计算有关,与异步编程的概念更相关。异步编程可以提供补充过程,以有效地促进通信通道的功能。

开放系统互联(OSI)模型协议层有哪两个主要部分?它们各自的目的是什么?

媒体层包含与通信通道的基础过程进行交互的相当低级别的操作,而主机层处理高级数据通信和操作。

传输层是什么?为什么它对通信通道至关重要?

传输层通常被视为媒体层和主机层之间的概念性过渡,负责在不同系统之间的端到端连接中发送数据。

asyncio如何促进服务器端通信通道的实现?

在服务器端,asyncio模块将传输的抽象与异步程序的实现结合在一起。具体而言,通过其BaseTransportBaseProtocol类,asyncio提供了不同的方式来定制通信通道的底层架构。

asyncio如何促进客户端通信通道的实现?

aiohttp模块以及特别是aiohttp.ClientSessionasyncio一起,也提供了关于客户端通信过程的效率和灵活性,通过异步请求和读取返回的响应。

aiofiles是什么?

aiofiles模块可以与asyncioaiohttp一起使用,有助于促进异步文件读取/写入。

第十二章

什么会导致死锁情况,为什么这是不可取的?

不同锁对象之间缺乏(或处理不当的)协调可能导致死锁,其中无法取得任何进展,程序被锁定在当前状态。

哲学家就餐问题与死锁问题有何关联?

在哲学家就餐问题中,每个哲学家只用左手拿着一把叉子,因此他们无法继续进食或放下他们手中的叉子。哲学家得以进食的唯一方式是邻座的哲学家放下叉子,而这只有在他们自己能够进食时才可能发生;这造成了一种永无止境的条件循环,永远无法满足。这种情况本质上就是死锁的本质,其中系统的所有元素都被困在原地,无法取得任何进展。

科夫曼条件有哪四个?

死锁还由并发程序需要同时具备的必要条件来定义,以便发生死锁。这些条件最初由计算机科学家爱德华·G·科夫曼(Edward G. Coffman, Jr.)提出,因此被称为科夫曼条件。这些条件如下:

  • 至少有一个资源必须处于不可共享的状态。这意味着该资源由一个单独的进程(或线程)持有,并且不能被其他进程访问;该资源在任何给定时间只能被一个进程(或线程)访问和持有。这种条件也被称为互斥

  • 存在一个同时访问资源并等待其他进程(或线程)持有的资源的进程(或线程)。换句话说,这个进程(或线程)需要访问两个资源才能执行其指令,其中一个它已经持有,另一个是它正在等待其他进程(或线程)提供的。这个条件被称为持有和等待

  • 只有当进程(或线程)持有资源的特定指令时,资源才能被释放。这意味着,除非进程(或线程)自愿并积极地释放资源,否则资源将保持在不可共享的状态。这就是无抢占条件。

  • 最后一个条件称为循环等待。正如其名称所示,该条件指定存在一组进程(或线程),使得该组中的第一个进程(或线程)处于等待状态,等待第二个进程(或线程)释放资源,而第二个进程(或线程)又需要等待第三个进程(或线程);最后,该组中的最后一个进程(或线程)又在等待第一个进程。

资源排序如何解决死锁问题?实施这一方法可能会导致哪些其他问题?

如果进程(或线程)按照预定的静态顺序访问资源,而不是任意访问资源,它们获取和等待资源的循环性质将被消除。然而,如果在并发程序的资源上放置足够的锁,它将在执行上变得完全顺序,并且与并发编程功能的开销结合起来,其速度甚至比程序的纯顺序版本更差。

忽略锁如何解决死锁问题?实施这一方法可能会导致哪些其他问题?

通过忽略锁,我们的程序资源有效地在并发程序中的不同进程/线程之间共享,从而消除了 Coffman 条件中的第一个条件,互斥。然而,这样做可能被视为完全误解了问题。我们知道锁被利用是为了让进程和线程可以以有系统、协调的方式访问程序中的共享资源,以避免对数据的错误处理。在并发程序中移除任何锁定机制意味着共享资源的可能性,这些资源现在不受访问限制,被以不协调的方式操纵(因此变得损坏)的可能性显著增加。

活锁与死锁有什么关系?

在活锁情况下,并发程序中的进程(或线程)能够切换它们的状态,但它们只是无限地来回切换,无法取得任何进展。

第十三章

什么是饥饿,为什么在并发程序中是不可取的?

饥饿是并发系统中的一个问题,其中一个进程(或线程)无法获得必要的资源来继续执行,因此无法取得任何进展。

饥饿的根本原因是什么?可以从根本原因中产生哪些常见的表面原因?

大多数情况下,一组调度指令的协调不佳是饥饿的主要原因。一些导致饥饿的高级原因可能包括以下内容:

  • 具有高优先级的进程(或线程)主导 CPU 中的执行流程,因此,低优先级的进程(或线程)没有机会执行自己的指令。

  • 具有高优先级的进程(或线程)主导使用不可共享的资源,因此,低优先级的进程(或线程)没有机会执行自己的指令。这种情况类似于第一种情况,但是涉及到访问资源的优先级,而不是执行本身的优先级。

  • 具有低优先级的进程(或线程)正在等待资源来执行它们的指令,但是一旦资源变得可用,具有更高优先级的其他进程(或线程)立即获得了访问权限,因此低优先级的进程(或线程)无限等待。

死锁和饥饿之间有什么联系?

死锁情况也可能导致饥饿,因为饥饿的定义表明,如果存在一个进程(或线程)由于无法获得必要的进程而无法取得任何进展,那么该进程(或线程)正在经历饥饿。这也在哲学家就餐问题中有所体现。

读者写者问题是什么?

读者写者问题要求一个调度算法,使读者和写者可以适当和高效地访问文本文件,而不会错误处理/损坏其中包含的数据。

读者写者问题的第一种方法是什么?为什么在这种情况下会出现饥饿现象?

第一种方法允许多个读者同时访问文本文件,因为读者只是读取文本文件,不会更改其中的数据。第一种方法的问题在于,当一个读者正在访问文本文件并且一个写者正在等待文件解锁时,如果另一个读者开始执行并且想要访问文件,它将优先于已经等待的写者。此外,如果越来越多的读者继续请求访问文件,写者将无限等待。

第二种方法是什么?为什么在这种情况下会出现饥饿现象?

这种方法实现了一旦写者请求访问文件,就不应该有读者能够插队并在该写者之前访问文件。与读者写者问题的第一种解决方案相反,这种解决方案给予了写者优先权,因此读者会饥饿。

读者写者问题的第三种方法是什么?为什么它成功地解决了饥饿问题?

这种方法对读者和写者都实施了锁。然后,所有线程将受到锁的限制,并且不同线程之间将实现相同的优先级。

一些常见的解决饥饿问题的方法是什么?

一些常见的解决饥饿问题的方法包括以下:

  • 提高低优先级线程的优先级

  • 实施先进先出的线程队列

  • 一个优先级队列,还逐渐增加了长时间等待在队列中的线程的优先级

  • 或者如果一个线程已经多次能够访问共享资源,它将被给予较低的优先级

第十四章

什么是临界区?

临界区指示在并发应用程序中由多个进程或线程访问的共享资源,这可能导致意外甚至错误的行为。

什么是竞争条件,为什么在并发程序中是不可取的?

当两个或更多线程/进程同时访问和更改共享资源时,就会发生竞争条件,导致数据错误和损坏。

竞争条件的根本原因是什么?

竞争条件的根本原因是多个线程/进程同时读取和更改共享资源;当所有线程/进程完成执行时,只有最后一个线程/进程的结果被注册。

锁如何解决竞争条件的问题?

由于竞争条件是由多个线程或进程同时访问和写入共享资源引起的,因此解决方案是隔离不同线程/进程的执行,特别是在与共享资源交互时。通过锁,我们可以将并发程序中的共享资源转换为临界区,保证其数据的完整性得到保护。

为什么锁有时在并发程序中是不可取的?

使用锁存在一些缺点:在并发程序中实现足够多的锁,整个程序可能变得完全顺序化;锁并不锁定任何东西。

竞争条件在现实生活系统和应用中引发了什么问题?

现实生活系统和应用中竞争条件引发的问题如下:

  • 安全性:竞争条件既可以被利用作为安全漏洞(给外部代理非法访问系统),也可以用作随机密钥生成,用于安全流程。

  • 操作系统:当两个代理(用户和应用程序)与相同的内存空间交互时,竞争条件可能导致不可预测的行为。

  • 网络:在网络中,竞争条件可能导致多个用户在网络中拥有强大的特权。

第十五章

Python 和 C++之间的内存管理有何不同?

C++通过简单地将值写入变量的内存位置来将变量与其值关联起来;Python 的变量引用指向它们所持有的值的内存位置。因此,Python 需要维护其内存空间中每个值的引用计数。

GIL 为 Python 解决了什么问题?

为了避免竞争条件,因此避免值引用计数的损坏,GIL 被实现为只有一个线程可以在任何给定时间访问和改变计数。

GIL 为 Python 创建了什么问题?

GIL 有效地阻止多个线程利用 CPU 并同时执行 CPU 绑定的指令。这意味着如果多个线程被设计为并发执行且 CPU 绑定,它们实际上将被顺序执行。

有哪些绕过 Python 程序中的 GIL 的方法?

有几种方法可以处理 Python 应用程序中的 GIL;即,实现多进程而不是多线程,并利用其他替代 Python 解释器。

第十六章

解决锁不锁任何东西的问题的主要方法是什么?

主要方法是在数据结构的类属性和方法内部实现锁定,以便外部函数和程序无法绕过这些锁定并同时访问共享的并发对象。

描述并发编程中的可伸缩性概念。

通过程序的可伸缩性,我们指的是当程序需要处理的任务数量增加时,性能的变化。Andre B. Bondi 将可伸缩性定义为“系统、网络或进程处理不断增长的工作量的能力,或者它扩大以适应这种增长的潜力。”

天真的锁定机制如何影响并发程序的可伸缩性?

简单基于锁的数据结构的可伸缩性是非常不理想的:随着程序添加更多线程来执行更多任务,程序的性能会线性下降。由于在任何给定时间只有一个线程可以访问和增加共享计数器,程序需要执行的增量越多,完成所有增量任务所需的时间就越长。

什么是近似计数器,它如何帮助解决并发编程中的可伸缩性问题?

近似计数器的基本思想是将工作(增加共享全局计数器)分布到其他低级计数器中。当活动线程执行并想要增加全局计数器时,首先必须增加其对应的本地计数器。通过为每个线程设置一个单独的计数器对象,线程可以独立和同时更新其对应的本地计数器,从而创建重叠,从而提高程序的速度性能。

Python 中是否可能存在无锁数据结构?为什么?

由于存在全局解释器锁(GIL),在 CPython 中实现无锁特性是不可能的,它阻止多个线程在任何给定时间执行 CPU。

什么是无互斥锁并发数据结构,它与基于锁的并发数据结构有何不同?

无互斥锁并发数据结构这个术语表示缺乏锁定机制,并使用其他同步机制来保护数据。

RCU 技术是什么,它解决了无互斥并发数据结构的什么问题?

为了保护并发数据结构的完整性,RCU 技术在线程或进程请求读取或写入访问时创建和维护数据结构的另一个版本。通过在单独的副本中隔离数据结构和线程/进程之间的交互,RCU 确保不会发生冲突的数据。

第十七章

Python 内存管理器的主要组件是什么?

Python 内存管理器的主要组件如下:

  • 原始内存分配器通过与操作系统的内存管理器交互,处理低级内存分配。

  • 特定对象的内存分配器与 Python 中的对象和值的私有堆交互。这些分配器执行特定于给定数据和对象类型的内存操作。

  • 标准 C 库的系统分配器负责帮助原始内存分配器与操作系统的内存管理器交互。

Python 内存模型如何类似于带标签的有向图?

内存模型仅通过指针跟踪其数据和变量:每个变量的值都是一个指针,这个指针可以指向符号、数字或子程序。因此,这些指针是对象图中的有向边,而实际值(符号、数字和子程序)是图中的节点。

就 Python 内存模型而言,开发 Python 并发应用程序的优缺点是什么?

推理并发程序的行为可能比在其他编程语言中更容易。然而,在 Python 中理解和调试并发程序的便利性也伴随着性能的降低。

什么是原子操作,为什么在并发编程中它是可取的?

原子操作是在执行过程中不能被中断的指令。原子性是并发操作的一个理想特征,因为它保证了在不同线程之间共享的数据的安全性。

给出 Python 中三个固有的原子操作的例子。

一些例子如下:

  • 将预定义对象附加到列表

  • 用另一个列表扩展列表

  • 从列表中获取元素

  • 从列表中弹出

  • 对列表进行排序

  • 将变量分配给另一个变量

  • 将变量分配给对象的属性

  • 为字典创建一个新条目

  • 用另一个字典更新字典

第十八章

什么是套接字?在网络编程中它有什么作用?

低级网络编程往往涉及套接字的操作和处理,套接字被定义为特定计算机网络节点内的理论端点,负责从它们所在的节点接收或发送数据。

潜在客户端发出连接请求时,服务器端通信的程序是什么?

要从服务器端打开通信通道,网络程序员必须首先创建套接字并将其绑定到特定地址。然后服务器开始监听网络中由客户端创建的任何潜在通信请求。在收到来自潜在客户端的连接请求后,服务器现在可以决定是否接受该请求。然后在网络中建立两个系统之间的连接,这意味着它们可以开始相互通信和共享数据。当客户端通过通信通道向服务器发送消息时,服务器会处理消息,最终通过相同的通道向客户端发送响应;这个过程会一直持续,直到它们之间的连接结束,要么是其中一个退出连接通道,要么是通过一些外部因素。

套接字模块提供了哪些方法来方便服务器端的低级网络编程?

以下是一些重要方法:

  • socket.bind()将调用套接字绑定到传递给该方法的地址

  • socket.listen()允许我们创建的服务器接受潜在客户端的连接

  • socket.accept()接受调用套接字对象具有的特定连接

  • socket.makefile()返回一个与调用套接字对象关联的文件对象

  • socket.sendall()将传递的数据作为参数发送到调用套接字对象

  • socket.close()标记调用套接字对象为关闭状态

生成器是什么?它们相对于 Python 列表的优势是什么?

生成器是返回迭代器并能够动态暂停和恢复的函数。生成器迭代器是惰性的,只有在特别要求时才产生结果。因此,生成器迭代器在内存管理方面更有效,并且通常在涉及大量数据时更受青睐。

什么是异步生成器?如何应用它们以构建非阻塞服务器?

异步生成器允许执行流在生成任务之间切换。结合可以在以后运行的回调,服务器可以同时读取和处理来自多个客户端的数据。

第十九章

APScheduler 是什么?为什么它不是一个调度服务?

APScheduler 是一个外部 Python 库,支持安排 Python 代码以后执行。APScheduler 本身不是一个具有内置 GUI 或命令行界面的调度服务。它仍然是一个必须在现有应用程序中导入和利用的 Python 库。但是,APScheduler 具有许多功能,可以利用这些功能来构建实际的调度服务。

APScheduler 的主要调度功能是什么?

它提供三种不同的调度机制:类似 cron 的调度、基于间隔的执行和延迟执行。此外,APScheduler 允许将要执行的作业存储在各种后端系统中,并与常见的 Python 并发框架一起使用,如 AsyncIO、Gevent、Tornado 和 Twisted。最后,APScheduler 提供了不同的选项来实际执行调度的代码,通过指定适当的执行者。

Python 中 APScheduler 和另一个调度工具 Celery 之间有什么区别?

虽然 Celery 是一个具有基本调度功能的分布式任务队列,但 APScheduler 恰恰相反:一个具有基本任务排队选项和高级调度功能的调度程序。用户报告称 APScheduler 比 Celery 更容易设置和实现。

编程中测试的目的是什么?在并发编程中有什么不同?

测试引发错误,表明程序中存在错误。测试并发程序通常很困难,因为非确定性允许在测试的一个运行中检测到并在另一个运行中变得不可见。我们称可能在测试之间变得不可见的并发错误为不可重现的,并且它们是我们不能依靠测试一致地检测所有并发错误的主要原因。

在本章中讨论了哪些测试方法?

单元测试应用于考虑中的程序的各个单元,其中单元是程序的最小可测试部分。另一方面,静态代码分析查看实际的代码本身而不执行它。静态代码分析扫描代码结构和变量和函数使用中的可视错误。

编程中调试的目的是什么?在并发编程中有什么不同?

调试是程序员试图识别和解决问题或缺陷的过程,否则这些问题或缺陷会导致计算机应用程序产生不正确的结果,甚至停止运行。与测试并发程序的问题类似,调试并发程序时可能变得越来越复杂和困难,因为共享资源可以与(并且可以被)多个代理同时交互。

本章讨论了哪些调试方法?

一般的调试方法包括打印调试、日志记录、跟踪和使用调试器。调试并发程序的过程可以利用最小化、单线程/处理和操纵调度来放大潜在的错误。

posted @ 2024-05-04 21:31  绝不原创的飞龙  阅读(33)  评论(0编辑  收藏  举报