精通 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值