Python-AsyncIO-并发指南-全-

Python AsyncIO 并发指南(全)

原文:Python Concurrency with asyncio

译者:飞龙

协议:CC BY-NC-SA 4.0

目录

前言

近 20 年前,我开始从事专业的软件开发工作,编写 Matlab、C++和 VB.net 代码的混合体,用于控制和分析质谱仪和其他实验室设备的数据。看到一行代码触发机器按照我的意愿移动的兴奋感一直伴随着我,从那时起,我就知道软件工程是我想要从事的职业。多年来,我逐渐转向 API 开发和分布式系统,主要关注 Java 和 Scala,并在过程中学习了很多 Python。

我在 2015 年左右开始接触 Python,主要是通过开发一个机器学习流程来处理传感器数据,并利用这些数据来做出预测——例如睡眠追踪、步数统计、坐立转换等类似活动——关于传感器的佩戴者。当时,这个机器学习流程运行缓慢,以至于变成了客户问题。我解决这个问题的方法之一是利用并发。当我深入研究 Python 中学习并发编程的知识时,我发现与我在 Java 世界中所习惯的相比,这些内容很难导航和学习。为什么多线程在 Python 中不与 Java 中的工作方式相同?使用多进程是否更合理?那么新引入的 asyncio 呢?全局解释器锁是什么,为什么存在?关于 Python 中并发主题的书籍并不多,而且大部分知识都散布在文档和一系列质量参差不齐的博客中。快进到今天,情况并没有太大变化。虽然资源更多了,但这个领域仍然稀疏、分散,并且对于想要学习并发编程的新手来说并不像应该的那样友好。

当然,在过去的几年里,发生了许多变化。当时,asyncio 还处于起步阶段,而现在已经成为了 Python 中的一个重要模块。现在,单线程并发模型和协程已经成为 Python 并发中的核心组件,除了多线程和多进程。这意味着 Python 中的并发领域已经变得更大、更复杂,而对于想要学习的人来说,仍然没有全面的学习资源。

我写这本书的动机是为了填补 Python 领域在并发主题上存在的这一空白,特别是关于 asyncio 和单线程并发。我希望让复杂且文档不足的单线程并发主题对各个技能水平的开发者更加易于理解。我还想写一本能够增强对 Python 以外并发主题的通用理解的书籍。例如,Node.js 这样的框架和 Kotlin 这样的语言都有单线程并发模型和协程,所以在这里获得的知识在这些领域也很有帮助。我希望能让所有阅读这本书的开发者都能在他们的日常工作中找到这本书的有用之处——不仅是在 Python 领域,而且在并发编程的领域。

致谢

首先,我想感谢我的妻子凯西,她总是在我不确定某件事是否合理时为我校对,在整个过程中都给予了我极大的支持。排名第二的是我的狗杜格,它总是围绕在我身边,把球扔在我附近,提醒我停下来休息一下,去玩玩。

接下来,我想感谢我的编辑 Doug Rudder 和我的技术审稿人 Robert Wenner。你们的反馈对于帮助这本书按时完成并保持高质量至关重要,确保了我的代码和解释是合理且易于理解的。

致所有审稿人:Alexey Vyskubov、Andy Miles、Charles M. Shelton、Chris Viner、Christopher Kottmyer、Clifford Thurber、Dan Sheikh、David Cabrero、Didier Garcia、Dimitrios Kouzis-Loukas、Eli Mayost、Gary Bake、Gonzalo Gabriel Jiménez Fuentes、Gregory A. Lussier、James Liu、Jeremy Chen、Kent R. Spillner、Lakshmi Narayanan Narasimhan、Leonardo Taccari、Matthias Busch、Pavel Filatov、Phillip Sorensen、Richard Vaughan、Sanjeev Kilarapu、Simeon Leyzerzon、Simon Tschöke、Simone Sguazza、Sumit K. Singh、Viron Dadala、William Jamir Silva 和 Zoheb Ainapore,你们的建议帮助使这本书变得更好。

最后,我想感谢过去几年中无数的老师、同事和导师。我从你们那里学到了很多,也成长了很多。我们共同经历的这些经历给了我完成这项工作以及在我的职业生涯中取得成功的工具。没有你们,我今天就不会在这里。谢谢!

关于本书

Python Concurrency with asyncio 被编写来教你如何利用 Python 中的并发来提高应用程序的性能、吞吐量和响应速度。我们首先关注核心并发主题,解释 asyncio 的单线程并发模型是如何工作的,以及协程和 async/await 语法是如何工作的。然后,我们转向并发的实际应用,例如并发地执行多个网络请求或数据库查询,管理线程和进程,构建网络应用程序,以及处理同步问题。

谁应该阅读这本书?

这本书是为那些希望更好地理解和利用并发性的中级到高级开发者而写的。本书的一个目标是用简单、易于理解的语言解释复杂的并发主题。为此,不需要有并发性的先验经验,尽管当然有帮助。在这本书中,我们将涵盖广泛的应用,从基于 Web 的 API 到命令行应用程序,因此这本书应该适用于作为开发者需要解决的许多问题。

本书是如何组织的:一个路线图

本书分为 14 章,逐步介绍更高级的主题,这些主题建立在前面章节所学的基础上。

  • 第一章专注于 Python 中的基本并发知识。我们学习什么是 CPU 密集型和 I/O 密集型工作,并介绍 asyncio 的单线程并发模型是如何工作的。

  • 第二章专注于 asyncio 协程的基础以及如何使用 async/await 语法构建利用并发的应用程序。

  • 第三章专注于非阻塞套接字和选择器的运作方式,以及如何使用 asyncio 构建一个回声服务器。

  • 第四章专注于如何并发地发起多个 Web 请求。通过这样做,我们将更多地了解运行协程并发的核心 asyncio API。

  • 第五章专注于如何使用连接池并发地执行多个数据库查询。我们还将学习在数据库上下文中异步上下文管理器和异步生成器。

  • 第六章专注于多进程,特别是如何利用 asyncio 处理 CPU 密集型工作。我们将构建一个 map/reduce 应用程序来演示这一点。

  • 第七章专注于多线程,特别是如何利用 asyncio 处理阻塞 I/O。这对于没有原生 asyncio 支持的库来说很有用,但仍然可以从并发中受益。

  • 第八章专注于网络流和协议。我们将利用这些内容创建一个能够同时处理多个用户的聊天服务器和客户端。

  • 第九章专注于由 asyncio 驱动的 Web 应用程序和 ASGI(异步服务器网关接口)。我们将探索几个 ASGI 框架,并讨论如何使用它们构建 Web API。我们还将探索 WebSocket。

  • 第十章描述了如何使用基于 asyncio 的 Web API 构建一个假设的微服务架构。

  • 第十一章专注于单线程并发同步问题以及如何解决这些问题。我们将深入研究锁、信号量、事件和条件。

  • 第十二章专注于异步队列。我们将使用这些队列构建一个能够即时响应用户请求的 Web 应用程序,尽管后台正在进行耗时的工作。

  • 第十三章专注于创建和管理子进程,展示如何从它们读取数据以及向它们写入数据。

  • 第十四章专注于高级主题,例如强制事件循环迭代、上下文变量以及创建自己的事件循环。这些信息对于 asyncio API 设计者和对 asyncio 事件循环内部工作原理感兴趣的人来说将非常有用。

至少你应该阅读前四章,以全面了解 asyncio 的工作原理,如何构建你的第一个实际应用,以及如何使用核心 asyncio API 来并发运行协程(第四章中介绍)。在此之后,你可以根据自己的兴趣自由地在书中移动。

关于代码

本书包含许多代码示例,既有编号列表,也有内联代码。一些代码列表在相同章节的后续列表中作为导入重复使用,一些则在多个章节中重复使用。跨多个章节重复使用的代码将假设你已经创建了一个名为util的模块;你将在第二章中创建这个模块。对于每个单独的代码列表,我们将假设你已经为该章节创建了一个名为chapter_{chapter_number}的模块,然后将代码放入该模块中名为listing_{chapter_number}_{listing_number}.py的文件中。例如,第二章中编号为 2.2 的代码将在名为chapter_2的模块中的listing_2_2.py文件中。

书中多处会展示性能数据,例如程序完成所需时间或每秒完成的 Web 请求数量。本书中的代码示例是在 2019 年 MacBook Pro 上运行的,该电脑配备 2.4 GHz 8 核心 Intel Core i9 处理器和 32 GB 2667 MHz DDR4 RAM,使用千兆无线网络连接进行测试和基准测试。根据你运行的机器不同,这些数字会有所不同,加速或改进的因素也会不同。

可执行代码片段可以在本书的 liveBook(在线)版本中找到,网址为livebook.manning.com/book/python-concurrency-with-asyncio。完整的源代码可以免费从 Manning 网站下载,网址为www.manning.com/books/python-concurrency-with-asyncio,同时也在 GitHub 上提供,网址为github.com/concurrency-in-python-with-asyncio

liveBook 讨论论坛

购买《Python Concurrency with asyncio》包括免费访问 liveBook,曼宁的在线阅读平台。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定章节或段落添加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/#!/book/python-concurrency-with-asyncio/discussion。您还可以在livebook.manning.com/#!/discussion了解更多关于曼宁论坛和行为准则的信息。

曼宁对读者的承诺是提供一个平台,让读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要这本书还在印刷,论坛和先前讨论的存档将可通过出版社的网站访问。

关于作者

Fowler 马修·福勒拥有近 20 年的软件工程经验,从软件架构师到工程总监的角色都有涉猎。他最初为科学应用编写软件,然后转向全栈网络开发和分布式系统,最终领导多个开发者和经理团队为拥有数千万用户的电子商务网站工作。他与妻子凯西住在马萨诸塞州的列克星敦。

关于封面插图

《Python Concurrency with asyncio》封面上的图像是“Paysanne du Marquisat de Bade”,或称巴登侯国的农妇,取自雅克·格拉塞·德·圣索沃尔于 1797 年出版的一本书。每一幅插图都是手工精心绘制和着色的。

在那些日子里,人们通过他们的服饰很容易就能识别出他们住在哪里,以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地区文化的封面设计,庆祝计算机行业的创新精神和主动性,这些图片来自像这样的收藏。

1 了解 asyncio

本章涵盖

  • 什么是 asyncio 及其提供的优势

  • 并发、并行、线程和进程

  • 全局解释器锁及其对并发的挑战

  • 非阻塞套接字如何仅用一个线程实现并发

  • 基于事件循环的并发工作原理

许多应用程序,尤其是在当今的 Web 应用程序世界中,严重依赖于 I/O(输入/输出)操作。这些类型的操作包括从互联网下载网页内容、通过网络与一组微服务进行通信,或者对数据库(如 MySQL 或 Postgres)运行多个查询。Web 请求或与微服务的通信可能需要数百毫秒,如果网络慢,甚至可能需要几秒。数据库查询可能耗时较长,尤其是如果数据库负载高或查询复杂。Web 服务器可能需要同时处理数百或数千个请求。

同时进行许多 I/O 请求可能会导致显著的性能问题。如果我们像在顺序运行的应用程序中那样一个接一个地运行这些请求,我们将看到累积的性能影响。例如,如果我们正在编写一个需要下载 100 个网页或运行 100 个查询的应用程序,每个查询执行需要 1 秒,那么我们的应用程序至少需要 100 秒才能运行。然而,如果我们利用并发性,同时开始下载并等待,理论上,我们可以将这些操作完成在 1 秒内。

asyncio 首次在 Python 3.4 中引入,作为处理这些高度并发工作负载的额外方式,而不仅仅是多线程和多进程。正确利用这个库可以为使用 I/O 操作的应用程序带来显著的性能和资源利用率改进,因为它允许我们同时启动许多这些长时间运行的任务。

在本章中,我们将介绍并发的基础知识,以便更好地理解我们如何使用 Python 和 asyncio 库实现它。我们将探讨 CPU 密集型工作与 I/O 密集型工作的区别,以了解哪种并发模型最适合我们的特定需求。我们还将学习进程和线程的基础知识以及 Python 的全局解释器锁(GIL)对并发带来的独特挑战。最后,我们将了解如何利用名为非阻塞 I/O的概念以及事件循环,仅使用一个 Python 进程和线程来实现并发。这是 asyncio 的主要并发模型。

1.1 什么是 asyncio?

在同步应用程序中,代码按顺序执行。下一行代码将在前一行代码完成后立即运行,并且一次只发生一件事。这种模型对于许多,如果不是大多数应用程序来说都很好。然而,如果有一行代码特别慢怎么办?在这种情况下,我们慢行之后的所有其他代码都将陷入等待该行完成。这些可能缓慢的行可能会阻止应用程序运行任何其他代码。我们中的许多人以前都见过这种情况,在有缺陷的用户界面中,我们愉快地点击,直到应用程序冻结,留下我们一个旋转器或无响应的用户界面。这是应用程序被阻塞导致用户体验不佳的一个例子。

虽然任何操作如果耗时足够长都可能阻塞应用程序,但许多应用程序会因等待 I/O 而阻塞。I/O 指的是计算机的输入和输出设备,例如键盘、硬盘驱动器,以及最常见的网络卡。这些操作等待用户输入或从基于 Web 的 API 检索内容。在同步应用程序中,我们将陷入等待这些操作完成,然后才能运行其他任何操作。这可能导致性能和响应性问题,因为我们只能在任何给定时间运行一个长时间的操作,而这个操作将阻止我们的应用程序执行其他任何操作。

解决这个问题的方法之一是引入并发。用最简单的话来说,并发意味着允许同时处理多个任务。在并发 I/O 的情况下,例子包括允许同时发起多个网络请求或允许同时连接到 Web 服务器。

在 Python 中实现这种并发有几种方法。Python 生态系统中最新的添加之一是 asyncio 库。asyncio异步 I/O的缩写。它是一个 Python 库,允许我们使用异步编程模型运行代码。这让我们能够同时处理多个 I/O 操作,同时仍然允许我们的应用程序保持响应。

那么,异步编程是什么意思呢?这意味着可以将在后台独立于主应用程序运行的长运行任务。而不是阻塞所有其他应用程序代码等待那个长运行任务完成,系统可以自由地执行不依赖于该任务的其他工作。然后,一旦长运行任务完成,我们将被通知它已完成,这样我们就可以处理结果。

在 Python 3.4 版本中,asyncio 库首次引入,与装饰器和生成器yield from语法一起使用来定义协程。协程是一种方法,当执行一个可能长时间运行的任务时可以被暂停,并在该任务完成后继续执行。在 Python 3.5 版本中,语言实现了对协程和异步编程的一级支持,通过显式地将asyncawait关键字添加到语言中。这种语法,在 C#和 JavaScript 等其他编程语言中很常见,允许我们将异步代码看起来像同步运行。这使得异步代码易于阅读和理解,因为它看起来像大多数软件工程师熟悉的顺序流程。asyncio 是一个库,它使用称为单线程事件循环的并发模型以异步方式执行这些协程。

虽然 asyncio 的名字可能让我们认为这个库只适用于 I/O 操作,但它通过与多线程和多进程的互操作,也有处理其他类型操作的功能。有了这种互操作性,我们可以使用asyncawait语法与线程和进程一起使用,使这些工作流程更容易理解。这意味着这个库不仅适用于基于 I/O 的并发,还可以与 CPU 密集型代码一起使用。为了更好地理解 asyncio 可以帮助我们处理哪种类型的工作负载以及哪种并发模型最适合每种类型的并发,让我们来探讨 I/O 和 CPU 密集型操作之间的区别。

1.2 什么是 I/O 密集型和 CPU 密集型?

当我们提到一个操作是 I/O 密集型或 CPU 密集型时,我们是指阻止该操作运行更快的限制因素。这意味着如果我们提高了该操作所依赖的性能,该操作将更快完成。

在 CPU 密集型操作的情况下,如果我们的 CPU 更强大,例如通过将时钟速度从 2 GHz 增加到 3 GHz,它将更快完成。在 I/O 密集型操作的情况下,如果我们的 I/O 设备能够在更短的时间内处理更多数据,它将更快。这可以通过通过我们的 ISP 增加网络带宽或升级到更快的网络卡来实现。

在 Python 世界中,CPU 密集型操作通常是计算和处理代码。这方面的例子包括计算π的数字或遍历字典的内容,应用业务逻辑。在 I/O 密集型操作中,我们大部分时间都在等待网络或其他 I/O 设备。一个 I/O 密集型操作的例子是对 Web 服务器发出请求或从我们的机器硬盘上读取文件。

列表 1.1 I/O 密集型和 CPU 密集型操作

import requests

response = requests.get('https:/ / www .example .com')     ❶

items = response.headers.items()

headers = [f'{key}: {header}' for key, header in items]    ❷

formatted_headers = '\n'.join(headers)                     ❸

with open('headers.txt', 'w') as file:
    file.write(formatted_headers)                          ❹

❶ I/O 密集型网络请求

❷ CPU 密集型响应处理

❸ CPU 密集型字符串连接

❹ I/O 密集型写入磁盘

I/O 密集型和 CPU 密集型操作通常并排存在。我们首先发起一个 I/O 密集型请求来下载 https://www.example.com 的内容。一旦我们得到响应,我们执行一个 CPU 密集型循环来格式化响应的头部,并将它们转换成由换行符分隔的字符串。然后我们打开一个文件并将字符串写入该文件,这两个都是 I/O 密集型操作。

异步 I/O 允许我们在有 I/O 操作时暂停特定方法的执行;我们可以在等待初始 I/O 在后台完成的同时运行其他代码。这允许我们并发执行许多 I/O 操作,可能加快我们的应用程序。

1.3 理解并发、并行和多任务

为了更好地理解并发如何帮助我们的应用程序性能更好,首先重要的是学习和完全理解并发编程的术语。我们将了解更多关于并发意味着什么以及 asyncio 如何使用一个称为多任务的概念来实现它。并发和并行是两个帮助我们理解编程如何调度和执行各种任务、方法和例程的概念,这些任务、方法和例程驱动着行动。

1.3.1 并发

当我们说两个任务正在并发发生时,我们的意思是这些任务是在同一时间发生的。以一个面包师同时烤制两个不同的蛋糕为例。为了烤制这些蛋糕,我们需要预热烤箱。预热可能需要数十分钟,这取决于烤箱和烘焙温度,但我们不必在开始其他任务,如将面粉和糖与鸡蛋混合之前等待烤箱预热。我们可以在烤箱哔哔声响起,通知我们烤箱已预热之前做其他工作。

我们也不必限制自己在完成第一个蛋糕之前就开始制作第二个蛋糕。我们可以开始制作一个蛋糕糊,放入立式搅拌机中,同时在第一个蛋糕糊完成搅拌的过程中开始准备第二个蛋糕糊。在这个模型中,我们是在不同任务之间进行切换。这种在任务之间的切换(在烤箱加热时做其他事情,在两个不同的蛋糕之间切换)是并发行为。

1.3.2 并行

虽然并发意味着多个任务同时进行,但它并不意味着它们是并行运行的。当我们说某事正在并行运行时,我们的意思不仅是有两个或更多任务正在并发发生,而且它们也是同时执行的。回到我们的蛋糕烘焙例子,想象我们有第二个面包师的帮助。在这种情况下,我们可以在第二个面包师制作第二个蛋糕的同时制作第一个蛋糕。两个人同时制作蛋糕糊是并行的,因为我们有两个不同的任务正在并发运行(图 1.1)。

01-01

图 1.1 在 并发 的情况下,我们同时进行多个任务,但在某一特定时刻,我们只积极进行一个任务。在 并行 的情况下,我们同时进行多个任务,并且积极同时进行多个任务。

将这应用到我们操作系统运行的应用程序上,让我们想象它正在运行两个应用程序。在一个只有并发性的系统中,我们可以在这两个应用程序之间切换,先运行一个应用程序一段时间,然后再让另一个运行。如果我们这样做得足够快,就会给人一种同时发生两件事的印象。在一个并行系统中,两个应用程序是同时运行的,我们积极同时运行两个并发任务。

并发和并行的概念相似(见图 1.2),但区分它们有些令人困惑,但了解它们之间的区别很重要。

01-02

图 1.2 在并发的情况下,我们在运行两个应用程序之间切换。在并行的情况下,我们积极同时运行两个应用程序。

1.3.3 并发与并行的区别

并发是指多个任务可以独立发生。我们可以在只有一个核心的 CPU 上实现并发,因为操作将使用 预先多任务处理(在下一节中定义)在任务之间切换。然而,并行意味着我们必须同时执行两个或更多任务。在一个只有一个核心的机器上,这是不可能的。为了实现这一点,我们需要一个具有多个核心的 CPU,可以同时运行两个任务。

虽然并行性暗示了并发性,但并发性并不总是意味着并行性。在多核心机器上运行的多线程应用程序既是并发的也是并行的。在这个设置中,我们有多个任务同时运行,有两个核心独立执行与这些任务相关的代码。然而,在多任务处理的情况下,我们可以有多个任务并发发生,但在某一特定时刻只有一个任务在执行。

1.3.4 什么是多任务处理?

在当今世界,多任务处理无处不在。我们在做早餐时进行多任务处理,一边接电话或回短信,一边等待水烧开泡茶。我们甚至在通勤去工作时进行多任务处理,一边读书,一边乘坐火车到我们的车站。本节讨论了两种主要的多任务处理类型:预先多任务处理协作多任务处理

预先多任务处理

在这个模型中,我们让操作系统通过一个称为 时间切片 的过程来决定如何切换当前正在执行的工作。当操作系统在任务之间切换时,我们称之为 抢占

这种机制在底层是如何工作的,取决于操作系统本身。它主要通过使用多个线程或多个进程来实现。

协作多任务处理

在这个模型中,我们不是依赖于操作系统来决定何时在哪些工作之间切换,而是在我们的应用程序中明确编码可以允许其他任务运行的位置。我们应用程序中的任务在一个协作的模型中运行,明确表示,“我将在一段时间内暂停我的任务;请继续运行其他任务。”

1.3.5 协作多任务的优势

asyncio 使用协作多任务来实现并发。当我们的应用程序达到一个可能需要等待一段时间以获取结果的状态时,我们会在代码中明确标记这一点。这允许其他代码在我们等待结果返回的背景中运行。一旦我们标记的任务完成,我们实际上“唤醒”并继续执行该任务。这给我们带来了一种并发形式,因为我们可以在同一时间启动多个任务,但重要的是,它们不是并行执行的,因为它们不是同时执行代码。

协作多任务相较于抢占式多任务有优势。首先,协作多任务资源消耗更少。当操作系统需要在运行线程或进程之间切换时,它涉及到一个上下文切换。上下文切换是密集型操作,因为操作系统必须保存有关正在运行的过程或线程的信息,以便能够重新加载它。

第二个优势是粒度。操作系统知道根据它使用的哪个调度算法,线程或任务应该被暂停,但这可能不是暂停的最佳时机。在协作多任务中,我们明确标记了最适合暂停我们的任务的区域。这使我们获得了一些效率提升,因为我们只在明确知道这是正确的时间时切换任务。现在我们理解了并发、并行和多任务,我们将使用这些概念来了解如何在 Python 中使用线程和进程来实现它们。

1.4 理解进程、线程、多线程和多进程

为了更好地让我们理解 Python 世界中的并发是如何工作的,我们首先需要了解线程和进程的基本工作原理。然后我们将检查如何使用它们进行多线程和多进程以并发地执行工作。让我们从关于进程和线程的一些定义开始。

1.4.1 进程

进程是一个具有其他应用程序无法访问的内存空间的运行中的应用程序。创建 Python 进程的一个例子是运行一个简单的“hello world”应用程序,或者在命令行中键入python以启动 REPL(读取评估打印循环)。

单个机器上可以运行多个进程。如果我们在一台具有多个核心的 CPU 的机器上,我们可以同时执行多个进程。如果我们在一核 CPU 上,我们仍然可以通过时间切片同时运行多个应用程序。当操作系统使用时间切片时,它将在一段时间后自动在运行的进程之间切换。确定何时发生这种切换的算法因操作系统而异。

1.4.2 线程

线程可以被视为比进程更轻量级的实体。此外,它们是操作系统可以管理的最小结构。它们没有自己的内存,就像进程一样;相反,它们共享创建它们的进程的内存。线程与创建它们的进程相关联。一个进程将始终至少有一个与之关联的线程,通常被称为主线程。进程还可以创建其他线程,这些线程更常见地被称为工作线程后台线程。这些线程可以在主线程旁边并行执行其他工作。线程,就像进程一样,可以在多核 CPU 上并行运行,操作系统也可以通过时间切片在它们之间切换。当我们运行一个普通的 Python 应用程序时,我们创建了一个进程以及一个主线程,该线程将负责运行我们的 Python 应用程序。

列表 1.2 简单 Python 应用程序中的进程和线程

import os
import threading

print(f'Python process running with process id: {os.getpid()}')
total_threads = threading.active_count()
thread_name = threading.current_thread().name

print(f'Python is currently running {total_threads} thread(s)')
print(f'The current thread is {thread_name}')

01-03

图 1.3 一个只有一个主线程的进程从内存中读取

在图 1.3 中,我们概述了列表 1.2 的过程。我们创建了一个简单的应用程序来展示主线程的基本知识。我们首先获取进程 ID(进程的唯一标识符)并将其打印出来,以证明我们确实有一个专用的进程在运行。然后我们获取正在运行的线程的活跃计数以及当前线程的名称,以显示我们正在运行一个线程——主线程。虽然每次运行此代码时进程 ID 都会不同,但运行列表 1.2 将给出类似于以下内容的输出:

Python process running with process id: 98230
Python currently running 1 thread(s)
The current thread is MainThread

进程还可以创建其他线程,这些线程共享主进程的内存。这些线程可以通过所谓的多线程为我们执行其他并行工作。

列表 1.3 创建一个多线程 Python 应用程序

import threading

def hello_from_thread():
    print(f'Hello from thread {threading.current_thread()}!')

hello_thread = threading.Thread(target=hello_from_thread)
hello_thread.start()

total_threads = threading.active_count()
thread_name = threading.current_thread().name

print(f'Python is currently running {total_threads} thread(s)')
print(f'The current thread is {thread_name}')

hello_thread.join()

01-04

图 1.4 具有两个工作线程和一个主线程的多线程程序,每个线程共享进程的内存

在图 1.4 中,我们概述了列表 1.3 的过程和线程。我们创建了一个方法来打印当前线程的名称,然后创建一个线程来运行该方法。然后我们调用线程的start方法来启动它。最后,我们调用join方法。join将导致程序暂停,直到我们启动的线程完成。如果我们运行前面的代码,我们将看到类似于以下内容的输出:

Hello from thread <Thread(Thread-1, started 123145541312512)>!
Python is currently running 2 thread(s)
The current thread is MainThread

注意,当运行此程序时,你可能会在同一行看到“hello from thread”和“python is currently running 2 thread(s)”的消息。这是一个竞态条件;我们将在下一节以及第六章和第七章中探讨这一点。

在许多编程语言中,多线程应用是实现并发的一种常见方式。然而,在 Python 中利用线程实现并发有几个挑战。多线程仅适用于 I/O 密集型工作,因为我们受到全局解释器锁的限制,这在第 1.5 节中讨论过。

多线程不是我们实现并发的唯一方式;我们还可以创建多个进程来为我们并发地执行工作。这被称为多进程。在多进程中,父进程创建一个或多个子进程,它管理这些子进程。然后,它可以向子进程分配工作。

Python 为我们提供了多进程模块来处理这个问题。API 与线程模块类似。我们首先使用一个target函数创建一个进程。然后,我们调用其start方法来执行它,最后调用其join方法来等待它完成运行。

列表 1.4 创建多个进程

import multiprocessing
import os

def hello_from_process():
    print(f'Hello from child process {os.getpid()}!')
if __name__ == '__main__':
    hello_process = multiprocessing.Process(target=hello_from_process)
    hello_process.start()

    print(f'Hello from parent process {os.getpid()}')

    hello_process.join()

01-05

图 1.5 使用一个父进程和两个子进程的应用程序利用多进程

在图 1.5 中,我们概述了列表 1.4 中的过程和线程。我们创建了一个子进程,该进程打印其进程 ID,我们还打印出父进程 ID 以证明我们在运行不同的进程。在处理 CPU 密集型工作方面,多进程通常是最好的选择。

多线程和多进程可能看起来像是使用 Python 实现并发的神奇子弹。然而,这些并发模型的力量受到 Python 实现细节的限制——全局解释器锁。

1.5 理解全局解释器锁

全局解释器锁,简称 GIL,发音为gill,在 Python 社区中是一个有争议的话题。简而言之,GIL 防止一个 Python 进程在任何给定时间执行多个 Python 字节码指令。这意味着即使我们在具有多个核心的机器上有多个线程,Python 进程一次也只能运行一个线程。在一个拥有多核心 CPU 的世界里,这可能会对希望利用多线程来提高其应用程序性能的 Python 开发者构成重大挑战。

注意:多进程可以并发运行多个字节码指令,因为每个 Python 进程都有自己的 GIL。

那么为什么 GIL 存在呢?答案在于 CPython 中内存是如何管理的。在 CPython 中,内存主要是由一个称为引用计数的过程来管理的。引用计数通过跟踪谁目前需要访问特定的 Python 对象,例如整数、字典或列表。引用计数是一个整数,用于跟踪有多少地方引用了该特定对象。当某人不再需要该引用对象时,引用计数会递减,当其他人需要它时,它会递增。当引用计数达到零时,没有人引用该对象,它可以从内存中删除。

什么是 CPython?

CPython 是 Python 的参考实现。通过参考实现,我们指的是它是语言的标准实现,并被用作语言的参考以实现正确的语言行为。Python 还有其他实现,例如 Jython,它设计用于在 Java 虚拟机上运行,以及 IronPython,它设计用于.NET 框架。

与线程的冲突在于 CPython 的实现不是线程安全的。当我们说 CPython 不是线程安全时,我们的意思是如果两个或多个线程修改一个共享变量,该变量可能会最终处于一个意外的状态。这种意外的状态取决于线程访问变量的顺序,通常称为竞争条件。当两个线程需要同时引用一个 Python 对象时,可能会出现竞争条件。

如图 1.6 所示,如果两个线程同时增加引用计数,我们可能会遇到一种情况,其中一个线程在另一个线程仍在使用该对象时将其引用计数设置为零。这种情况下,当我们尝试读取可能已被删除的内存时,很可能会发生应用程序崩溃。

01-06

图 1.6 两个线程试图同时增加引用计数的竞争条件。我们得到的不是预期的两个计数,而是一个。

为了演示 GIL 对多线程编程的影响,让我们检查计算斐波那契序列中的第n个数的 CPU 密集型任务。我们将使用一个相当慢的算法实现来演示耗时操作。一个合适的解决方案将利用记忆化或数学技术来提高性能。

列表 1.5 生成和计时斐波那契序列

import time

def print_fib(number: int) -> None:
    def fib(n: int) -> int:
        if n == 1:
            return 0
        elif n == 2:
            return 1
        else:
            return fib(n - 1) + fib(n - 2)

    print(f'fib({number}) is {fib(number)}')

def fibs_no_threading():
    print_fib(40)
    print_fib(41)

start = time.time()

fibs_no_threading()

end = time.time()

print(f'Completed in {end - start:.4f} seconds.')

这种实现使用递归,整体上是一个相对较慢的算法,需要指数时间 O(2^N) 来完成。如果我们需要打印两个斐波那契数,同步调用它们并计时是足够的简单,就像我们在前面的列表中做的那样。

根据我们运行的 CPU 的速度,我们会看到不同的计时结果,但运行列表 1.5 中的代码将产生类似于以下内容的输出:

fib(40) is 63245986
fib(41) is 102334155
Completed in 65.1516 seconds.

这是一个相当长的计算,但我们对print_fibs的函数调用是相互独立的。这意味着它们可以被放入多个线程中,我们的 CPU 理论上可以在多个核心上并发运行,从而加快我们的应用程序。

列表 1.6:多线程计算斐波那契数列

import threading
import time

def print_fib(number: int) -> None:
    def fib(n: int) -> int:
        if n == 1:
            return 0
        elif n == 2:
            return 1
        else:
            return fib(n - 1) + fib(n - 2)

def fibs_with_threads():
    fortieth_thread = threading.Thread(target=print_fib, args=(40,))
    forty_first_thread = threading.Thread(target=print_fib, args=(41,))

    fortieth_thread.start()
    forty_first_thread.start()

    fortieth_thread.join()
    forty_first_thread.join()

start_threads = time.time()

fibs_with_threads()

end_threads = time.time()

print(f'Threads took {end_threads - start_threads:.4f} seconds.')

在前面的列表中,我们创建了两个线程,一个用于计算fib(40),另一个用于计算fib(41),并通过在每个线程上调用start()来并发启动它们。然后我们调用join(),这将导致我们的主程序等待直到线程完成。鉴于我们同时启动了fib(40)fib(41)的计算,并且并发运行它们,你可能会认为我们可以看到合理的加速;然而,即使在多核机器上,我们也会看到如下所示的输出。

fib(40) is 63245986
fib(41) is 102334155
Threads took 66.1059 seconds.

我们的多线程版本花费的时间几乎相同。实际上,它甚至稍微慢了一点!这几乎完全归因于 GIL 以及创建和管理线程的开销。虽然线程是并发运行的,但由于锁的存在,一次只能允许一个线程运行 Python 代码。这导致其他线程处于等待状态,直到第一个线程完成,这完全抵消了多线程的价值。

1.5.1 GIL 是否会被释放?

基于前面的例子,你可能想知道,鉴于 GIL 阻止了 Python 中两条代码的并发执行,Python 中的线程能否实现并发。然而,GIL 并不是永远持有的,这样我们就可以利用多个线程的优势。

当发生 I/O 操作时,全局解释器锁(GIL)会被释放。这使我们能够在进行 I/O 操作时使用线程来执行并发工作,但并不适用于 CPU 密集型的 Python 代码本身(在某些情况下,有一些显著的例外,这些例外会在特定情况下释放 GIL 以进行 CPU 密集型工作,我们将在后面的章节中探讨这些内容)。为了说明这一点,让我们用一个读取网页状态码的例子来说明。

列表 1.7:同步读取状态码

import time
import requests

def read_example() -> None:
    response = requests.get('https:/ / www .example .com')
    print(response.status_code)

sync_start = time.time()

read_example()
read_example()

sync_end = time.time()

print(f'Running synchronously took {sync_end - sync_start:.4f} seconds.')

在前面的列表中,我们检索了 example.com 的内容,并打印了状态码两次。根据我们的网络连接速度和位置,当我们运行此代码时,我们会看到如下所示的输出:

200
200
Running synchronously took 0.2306 seconds.

现在我们已经有一个同步版本的基线,我们可以编写一个多线程版本来与之比较。在我们的多线程版本中,为了尝试并发运行它们,我们将为每个 example.com 的请求创建一个线程。

列表 1.8:多线程读取状态码

import time
import threading
import requests

def read_example() -> None:
    response = requests.get('https:/ / www .example .com')
    print(response.status_code)

thread_1 = threading.Thread(target=read_example)
thread_2 = threading.Thread(target=read_example)

thread_start = time.time()

thread_1.start()
thread_2.start()

print('All threads running!')

thread_1.join()
thread_2.join()

thread_end = time.time()

print(f'Running with threads took {thread_end - thread_start:.4f} seconds.')

当我们执行前面的列表时,我们会看到如下所示的输出,这取决于我们的网络连接和位置:

All threads running!
200
200
Running with threads took 0.0977 seconds.

这比我们没有使用线程的原始版本快大约两倍,因为我们几乎同时运行了这两个请求!当然,根据你的互联网连接和机器规格,你将看到不同的结果,但数字应该方向上相似。

那么我们是如何释放 GIL 以实现 I/O 并发,而不对 CPU 密集型操作进行释放的呢?答案在于在后台进行的系统调用。在 I/O 的情况下,低级系统调用是在 Python 运行时之外进行的。这允许 GIL 被释放,因为它并没有直接与 Python 对象交互。在这种情况下,GIL 只在数据被转换回 Python 对象时重新获取。然后,在操作系统级别,I/O 操作可以并发执行。这种模型给我们带来了并发性,但没有并行性。在其他语言中,例如 Java 或 C++,我们可以在多核机器上获得真正的并行性,因为我们没有 GIL 并且可以同时执行。然而,在 Python 中,由于 GIL,我们最好的选择是 I/O 操作的并发性,并且一次只有一个 Python 代码块在执行。

1.5.2 asyncio 和 GIL

asyncio 利用 I/O 操作释放 GIL 的特性,即使在只有一个线程的情况下也能给我们带来并发性。当我们使用 asyncio 时,我们创建称为 协程 的对象。协程可以被视为执行轻量级线程。就像我们可以同时运行多个线程,每个线程都有自己的并发 I/O 操作一样,我们也可以同时运行许多协程。当我们等待 I/O 密集型协程完成时,我们仍然可以执行其他 Python 代码,从而给我们带来并发性。重要的是要注意,asyncio 并没有绕过 GIL,我们仍然受其影响。如果我们有一个 CPU 密集型任务,我们仍然需要使用多个进程来并发执行它(这可以通过 asyncio 本身完成);否则,我们将在应用程序中引起性能问题。现在我们知道,仅使用单个线程就可以实现 I/O 的并发性,让我们深入了解这是如何与非阻塞套接字一起工作的具体细节。

1.6 单线程并发的工作原理

在上一节中,我们介绍了多线程作为实现 I/O 操作并发的机制。然而,我们并不需要多个线程来实现这种并发。我们可以在一个进程和一个线程的范围内完成所有这些操作。我们通过利用这样一个事实来实现:在系统级别,I/O 操作可以并发完成。为了更好地理解这一点,我们需要深入了解套接字的工作原理,特别是非阻塞套接字的工作原理。

1.6.1 什么是套接字?

套接字 是在网络中发送和接收数据的一个低级抽象。它是数据从服务器到服务器以及从服务器到客户端传输的基础。套接字支持两种主要操作:发送字节和接收字节。我们将字节写入套接字,然后这些字节将被发送到远程地址,通常是某种服务器。一旦我们发送了这些字节,我们就等待服务器将其响应写回到我们的套接字。一旦这些字节被发送回我们的套接字,我们就可以读取结果。

套接字是一个低级概念,如果你把它们想象成邮箱,就相对容易理解。你可以把一封信放进你的邮箱,然后你的信使会取走并把它送到收件人的邮箱。收件人打开他们的邮箱和你的信。根据内容,收件人可能会给你回一封信。在这个类比中,你可以把信想象成我们想要发送的数据或字节。考虑一下,把信放进邮箱的行为是将字节写入套接字,而打开邮箱读取信的行为是从套接字读取字节。信使可以被看作是互联网上的传输机制,将数据路由到正确的地址。

就像我们之前看到的从 example.com 获取内容的情况,我们打开一个连接到 example.com 服务器的套接字。然后我们向该套接字写入一个请求以获取内容,并等待服务器回复结果:在这种情况下,网页的 HTML。我们可以在图 1.7 中可视化字节从服务器到服务器的流动。

01-07

图 1.7 向套接字写入字节和从套接字读取字节

套接字默认是阻塞的。简单来说,这意味着当我们等待服务器回复数据时,我们会暂停我们的应用程序或阻塞它,直到我们得到可以读取的数据。因此,我们的应用程序停止运行任何其他任务,直到我们从服务器获取数据、发生错误或超时。

在操作系统级别,我们不需要这样做阻塞。套接字可以以非阻塞模式运行。在非阻塞模式下,当我们向套接字写入字节时,我们只需触发写入或读取操作,然后我们的应用程序可以继续执行其他任务。稍后,操作系统可以告诉我们我们已经收到了字节,我们可以在那时处理它。这使得应用程序在等待字节返回时可以做任何数量的事情。而不是阻塞并等待数据到来,我们变得更加反应性,让操作系统在有数据供我们操作时通知我们。

在后台,这由几个不同的事件通知系统执行,具体取决于我们运行的操作系统。asyncio 足够抽象,可以在不同的通知系统之间切换,取决于我们的操作系统支持哪个。以下是由特定操作系统使用的通知系统:

  • kqueue—FreeBSD 和 MacOS

  • epoll—Linux

  • IOCP (I/O completion port)—Windows

这些系统跟踪我们的非阻塞套接字,并在它们准备好让我们对它们进行操作时通知我们。这个通知系统是 asyncio 实现并发的基础。在 asyncio 的并发模型中,我们任何时候只有一个线程在执行 Python。当我们遇到 I/O 操作时,我们将其交给操作系统的事件通知系统来跟踪。一旦我们完成这个移交,我们的 Python 线程就可以自由地运行其他 Python 代码或为操作系统添加更多非阻塞套接字,以便它为我们跟踪。当我们的 I/O 操作完成时,我们“唤醒”等待结果的任务,然后继续运行在该 I/O 操作之后到来的任何其他 Python 代码。我们可以用图 1.8 中的几个独立操作来可视化这个流程,每个操作都依赖于套接字。

01-08

图 1.8 制作非阻塞 I/O 请求立即返回并告诉操作系统监视套接字以获取数据。这允许 execute_other_code()立即运行,而不是等待 I/O 请求完成。稍后,当 I/O 完成时,我们可以被通知并处理响应。

但我们如何跟踪哪些任务是等待 I/O 操作,而不是那些可以因为它们是常规 Python 代码而直接运行的?答案在于一个称为事件循环的结构。

1.7 事件循环的工作原理

事件循环是每个 asyncio 应用程序的核心。事件循环是许多系统中相当常见的设计模式,并且已经存在了一段时间。如果你曾经使用浏览器中的 JavaScript 进行异步 Web 请求,你就在事件循环上创建了一个任务。Windows GUI 应用程序在幕后使用所谓的消息循环作为处理事件(如键盘输入)的主要机制,同时仍然允许 UI 进行绘制。

最基本的事件循环非常简单。我们创建一个队列,其中包含事件或消息的列表。然后我们永远循环,逐个处理队列中进入的消息。在 Python 中,一个基本的事件循环可能看起来像这样:

from collections import deque

messages = deque()

while True:
    if messages:
        message = messages.pop()
        process_message(message)

在 asyncio 中,事件循环保留一个任务队列而不是消息队列。任务是对协程的包装。当协程遇到 I/O 操作时,它可以暂停执行,并让事件循环运行其他不等待 I/O 操作完成的任务。

当我们创建一个事件循环时,我们创建一个空的任务队列。然后我们可以将任务添加到队列中以便运行。事件循环的每次迭代都会检查需要运行的任务,并且会逐个运行它们,直到一个任务遇到 I/O 操作。那时任务将被“暂停”,我们指示我们的操作系统监视任何套接字以完成 I/O。然后我们寻找下一个要运行的任务。在事件循环的每次迭代中,我们会检查是否有我们的 I/O 已经完成;如果已经完成,我们将“唤醒”任何被暂停的任务,并让它们完成运行。我们可以如下在图 1.9 中可视化这一点:主线程将任务提交给事件循环,然后它可以运行它们。

01-09

图 1.9 一个线程将任务提交给事件循环的示例

为了说明这一点,让我们想象我们有三个任务,每个任务都进行异步网络请求。想象这些任务有一段用于设置的代码,这是 CPU 密集型的,然后它们发起网络请求,接着执行一些 CPU 密集型的后处理代码。现在,让我们将这些任务同时提交给事件循环。在伪代码中,我们会写一些像这样的事情:

def make_request():
    cpu_bound_setup()
    io_bound_web_request()
    cpu_bound_postprocess()

task_one = make_request()
task_two = make_request()
task_three = make_request()

所有三个任务都以 CPU 密集型工作开始,我们都是单线程的,所以只有第一个任务开始执行代码,其他两个则等待运行。一旦任务 1 中的 CPU 密集型设置工作完成,它遇到 I/O 密集型操作并将自己暂停,表示“我在等待 I/O;任何其他等待运行的任务可以运行。”

一旦发生这种情况,任务 2 就可以开始执行。任务 2 开始执行其 CPU 密集型代码,然后暂停,等待 I/O。在这个时候,任务 1 和任务 2 都在并发地等待它们的网络请求完成。由于任务 1 和任务 2 都暂停等待 I/O,我们开始运行任务 3。

现在想象一旦任务 3 暂停等待其 I/O 完成,任务 1 的网络请求已经完成。我们现在被我们的操作系统的事件通知系统提醒,这个 I/O 已经完成。我们现在可以继续执行任务 1,同时任务 2 和任务 3 都在等待它们的 I/O 完成。

在图 1.10 中,我们展示了我们刚刚描述的伪代码的执行流程。如果我们观察这个图中的任何垂直切片,我们可以看到在任何给定时间点,只有一个 CPU 密集型的工作正在运行;然而,我们同时最多有两个 I/O 密集型操作在进行。这种每个任务等待 I/O 重叠的地方正是 asyncio 节省时间的真正所在。

01-10

图 1.10 使用 I/O 操作并发执行多个任务

摘要

  • CPU 密集型工作主要是利用我们的计算机处理器的工作,而 I/O 密集型工作主要是利用我们的网络或其他输入/输出设备。asyncio 主要帮助我们使 I/O 密集型工作并发,但它也提供了用于使 CPU 密集型工作并发的 API。

  • 进程和线程是操作系统级别的最基本并发单元。进程可以用于 I/O 和计算密集型工作负载,而线程(通常)只能用于在 Python 中有效地管理 I/O 密集型工作,因为全局解释器锁(GIL)阻止代码并行执行。

  • 我们已经看到了如何使用非阻塞套接字,在我们等待数据到来时,不是停止我们的应用程序,而是可以指示操作系统在数据到来时通知我们。利用这一点是 asyncio 能够仅使用单个线程实现并发的一部分。

  • 我们已经介绍了事件循环,它是 asyncio 应用程序的核心。事件循环永远循环,寻找需要运行的计算密集型任务的,同时暂停等待 I/O 的任务。

2 asyncio 基础知识

本章涵盖

  • async await 语法和协程的基本知识

  • 使用任务并行运行协程

  • 取消任务

  • 手动创建事件循环

  • 测量协程的执行时间

  • 在运行协程时保持警惕,注意可能出现的问题

第一章深入探讨了并发,探讨了如何通过进程和线程实现并发。我们还探讨了如何利用非阻塞 I/O 和事件循环仅使用一个线程来实现并发。在本章中,我们将介绍如何使用 asyncio 编写使用单线程并发模型的程序的基础。使用本章中的技术,您将能够将长时间运行的操作,如 Web 请求、数据库查询和网络连接,同时执行。

我们将更深入地了解协程结构以及如何使用async await语法来定义和运行协程。我们还将检查如何通过使用任务来并行运行协程,并检查通过创建可重用计时器从并行运行中获得的节省的时间。最后,我们将探讨软件工程师在使用 asyncio 时可能犯的常见错误,以及如何使用调试模式来发现这些问题。

2.1 介绍协程

将协程想象成普通的 Python 函数,但它拥有一个超级能力,即当它遇到可能需要较长时间完成的操作时可以暂停其执行。当长时间运行的操作完成时,我们可以“唤醒”暂停的协程并完成该协程中任何其他代码的执行。当暂停的协程等待其暂停的操作完成时,我们可以运行其他代码。这种在等待时运行其他代码的能力为我们提供了并发性。我们还可以并行运行多个耗时操作,这可以为我们的应用程序带来显著的性能提升。

要创建和暂停协程,我们需要学习如何使用 Python 的asyncawait关键字。async关键字将允许我们定义协程;await关键字将允许我们在遇到可能需要较长时间完成的操作时暂停我们的协程。

我应该使用哪个 Python 版本?

本书中的代码假设您正在使用编写时的最新 Python 版本,即 Python 3.10。使用早于此版本的代码可能缺少某些 API 方法,可能功能不同,或者可能存在错误。

2.1.1 使用async关键字创建协程

创建协程很简单,与创建普通 Python 函数没有太大区别。唯一的区别是,我们不是使用def关键字,而是使用async defasync关键字将函数标记为协程而不是普通 Python 函数。

列表 2.1 使用async关键字

async def my_coroutine() -> None
    print(‘Hello world!’)

前面的列表中的协程目前什么也没做,只是打印“Hello world!”也值得注意,这个协程不执行任何长时间运行的操作;它只是打印我们的消息并返回。这意味着,当我们把协程放在事件循环上时,它将立即执行,因为我们没有阻塞 I/O,而且还没有任何操作暂停执行。

这种语法很简单,但我们正在创建与普通 Python 函数非常不同的东西。为了说明这一点,让我们创建一个函数,它将一个整数加一,以及一个执行相同操作的协程,并比较调用每个的结果。我们还将使用 type 便利函数来查看调用协程返回的类型与调用我们的正常函数返回的类型进行比较。

列表 2.2 比较协程与普通函数

async def coroutine_add_one(number: int) -> int:
    return number + 1

def add_one(number: int) -> int:
    return number + 1

function_result = add_one(1)
coroutine_result = coroutine_add_one(1)

print(f'Function result is {function_result} and the type is {type(function_result)}')
print(f'Coroutine result is {coroutine_result} and the type is {type(coroutine_result)}')

当我们运行此代码时,我们将看到以下输出:

Method result is 2 and the type is <class 'int'>
Coroutine result is <coroutine object coroutine_add_one at 0x1071d6040> and the type is <class 'coroutine'>

注意,当我们调用我们的正常 add_one 函数时,它立即执行并返回我们预期的结果,即另一个整数。然而,当我们调用 coroutine_add_one 时,我们根本不会执行协程中的代码。我们得到的是一个 协程对象

这是一个重要的观点,因为当我们直接调用协程时,协程不会执行。相反,我们创建了一个协程对象,稍后可以运行。要运行一个协程,我们需要显式地在事件循环上运行它。那么我们如何创建事件循环并运行我们的协程呢?

在 Python 3.7 之前的版本中,如果我们没有创建一个事件循环,我们必须创建一个。然而,asyncio 库添加了几个抽象事件循环管理的函数。有一个便利函数 asyncio.run,我们可以用它来运行我们的协程。以下是一个示例。

列表 2.3 运行一个协程

import asyncio

async def coroutine_add_one(number: int) -> int:
    return number + 1

result = asyncio.run(coroutine_add_one(1))

print(result)

运行列表 2.3 将打印出“2,”正如我们预期的那样,用于返回下一个整数。我们已经正确地将协程放在事件循环上,并且已经执行了它!

在这个场景中,asyncio.run 做了几件重要的事情。首先,它创建了一个全新的事件。一旦成功创建,它就会运行我们传递给它的任何协程,直到它完成,并返回结果。此函数还会清理主协程完成后可能留下的任何东西。一旦一切完成,它就会关闭并关闭事件循环。

asyncio.run 最重要的一点可能是,它打算成为我们创建的 asyncio 应用程序的主要入口点。它只执行一个协程,而这个协程应该启动我们应用程序的所有其他方面。随着我们进一步学习,我们将使用此函数作为几乎所有应用程序的入口点。asyncio.run 执行的协程将创建并运行其他协程,这将使我们能够利用 asyncio 的并发特性。

2.1.2 使用 await 关键字暂停执行

我们在列表 2.3 中看到的示例不需要是协程,因为它只执行了非阻塞的 Python 代码。asyncio 的真正好处是能够在长时间运行的操作期间暂停执行,让事件循环运行其他任务。为了暂停执行,我们使用 await 关键字。await 关键字通常后跟对协程的调用(更具体地说,是一个称为 awaitable 的对象,它不总是协程;我们将在本章后面了解更多关于 awaitable 的内容)。

使用 await 关键字会导致其后的协程运行,与直接调用协程不同,后者会产生一个协程对象。await 表达式还会暂停包含它的协程,直到我们等待的协程完成并返回结果。当我们等待的协程完成时,我们将能够访问它返回的结果,并且包含的协程将“醒来”以处理该结果。

我们可以通过在协程调用前放置 await 关键字来使用 await 关键字。扩展我们之前的程序,我们可以编写一个程序,在其中我们调用“main”异步函数内的 add_one 函数并获取结果。

列表 2.4 使用 await 等待协程的结果

import asyncio

async def add_one(number: int) -> int:
    return number + 1

async def main() -> None:
    one_plus_one = await add_one(1)    ❶
    two_plus_one = await add_one(2)    ❷
    print(one_plus_one)
    print(two_plus_one)

asyncio.run(main())

❶ 暂停,等待 add_one(1) 的结果。

❷ 暂停,等待 add_one(2) 的结果。

在列表 2.4 中,我们暂停执行两次。我们首先 awaitadd_one(1) 的调用。一旦我们有了结果,主函数将“暂停”,我们将 add_one(1) 的返回值分配给变量 one_plus_one,在这种情况下将是 2。然后我们对 add_one(2) 做同样的操作,然后打印结果。我们可以可视化应用程序的执行流程,如图 2.1 所示。图中的每个块代表在任何给定时刻一个或多个代码行正在发生的事情。

02-01

图 2.1 当我们遇到 await 表达式时,我们会暂停父协程并运行 await 表达式中的协程。一旦它完成,我们将恢复父协程并分配返回值。

目前为止,这段代码与正常、顺序代码的操作没有不同。实际上,我们正在模拟一个正常的调用栈。接下来,让我们看看一个简单的例子,说明如何在等待时通过引入一个模拟的 sleep 操作来运行其他代码。

2.2 使用 sleep 引入长时间运行的协程

我们之前的例子没有使用任何慢速操作,它们被用来帮助我们学习协程的基本语法。为了完全看到好处并展示我们如何同时运行多个事件,我们需要引入一些长时间运行的操作。我们不会立即进行网络 API 或数据库查询,因为它们在所需时间上是不确定的,我们将通过指定我们想要等待多长时间来模拟长时间运行的操作。我们将使用 asyncio.sleep 函数来完成此操作。

我们可以使用 asyncio.sleep 使一个协程“睡眠”给定的时间数。这将暂停我们的协程,持续给定的时间,模拟如果有一个长时间运行的数据库或 Web API 调用会发生的情况。

asyncio.sleep 本身就是一个协程,因此我们必须使用 await 关键字来使用它。如果我们只调用它本身,我们将得到一个协程对象。由于 asyncio.sleep 是一个协程,这意味着当一个协程等待它时,其他代码将能够运行。

让我们检查一个简单的示例,如以下列表所示,该示例睡眠 1 秒然后打印一个 'Hello World!' 消息。

列表 2.5 使用 sleep 的第一个应用程序

import asyncio

async def hello_world_message() -> str:
    await asyncio.sleep(1)                  ❶
    return ‘Hello World!’

async def main() -> None:
hello_world_message()   ❷
    print(message)

asyncio.run(main())

❶ 暂停 hello_world_message 1 秒。

❷ 暂停主程序,直到 hello_world_message 完成。

当我们运行这个应用程序时,我们的程序将在打印 'Hello World!' 消息之前等待 1 秒。由于 hello_world_message 是一个协程,并且我们使用 asyncio.sleep 暂停它 1 秒,我们现在有 1 秒的时间可以运行其他代码并发执行。

在接下来的几个示例中,我们将大量使用 sleep,因此让我们花时间创建一个可重用的协程,它为我们睡眠并打印一些有用的信息。我们将把这个协程称为 delay。这将在下面的列表中展示。

列表 2.6 可重用延迟函数

import asyncio

async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

delay 将接收一个整数,表示我们希望函数睡眠的秒数,并在完成睡眠后将其返回给调用者。我们还将打印睡眠开始和结束的时间。这将帮助我们了解在协程暂停时,是否有其他代码正在并发运行。

为了使在未来的代码列表中引用这个实用函数更容易,我们将创建一个模块,在本书的其余部分需要时我们将导入它。我们还将在此模块中添加额外的可重用函数。我们将把这个模块称为 util,并将我们的延迟函数放在一个名为 delay_functions.py 的文件中。我们还将添加一个 __init__.py 文件,其中包含以下行,这样我们就可以很好地导入计时器:

from util.delay_functions import delay

从现在起,在这本书中,每当我们需要使用 delay 函数时,我们将使用 from util import delay。现在我们有了可重用的延迟协程,让我们将其与之前的协程 add_one 结合起来,看看我们是否可以使简单的加法在 hello_world_message 暂停时并发运行。

列表 2.7 运行两个协程

import asyncio
from util import delay

async def add_one(number: int) -> int:
    return number + 1

async def hello_world_message() -> str:
    await delay(1)
    return ‘Hello World!’

async def main() -> None:
    message = await hello_world_message()    ❶
    one_plus_one = await add_one(1)          ❷
    print(one_plus_one)
    print(message)

asyncio.run(main())

❶ 暂停主程序,直到 hello_world_message 返回。

❷ 暂停主程序,直到 add_one 返回。

当我们运行这个程序时,在打印出两个函数调用的结果之前,需要经过 1 秒钟。我们真正想要的是在hello_world_message()并发运行的同时立即打印出add_one(1)的值。那么为什么这段代码没有这样做呢?答案是await会暂停当前的协程,并且不会执行该协程内的任何其他代码,直到await表达式返回一个值。由于hello_world_message函数需要 1 秒钟才能返回一个值,主协程将会暂停 1 秒钟。在这种情况下,我们的代码表现得就像它是顺序执行的。这种行为在图 2.2 中得到了说明。

02-02

图 2.2 列表 2.7 的执行流程

在我们等待delay(1)完成时,mainhello_world都被暂停了。一旦它完成,main就会恢复并可以执行add_one

我们希望摆脱这种顺序模型,并使add_onehello_world并发运行。为了实现这一点,我们需要引入一个称为任务的概念。

2.3 使用任务进行并发运行

之前我们看到,当我们直接调用一个协程时,我们不会将其放入事件循环中运行。相反,我们得到一个协程对象,然后我们需要在它上面使用await关键字或者将其传递给asyncio.run来运行并获取一个值。仅使用这些工具,我们可以编写异步代码,但我们不能并发运行任何东西。为了并发运行协程,我们需要引入任务

任务是围绕一个协程的包装器,它会在事件循环上尽可能快地调度一个协程的运行。这种调度和执行是非阻塞的,这意味着一旦我们创建了一个任务,我们就可以在任务运行的同时立即执行其他代码。这与使用await关键字的行为形成对比,await关键字是阻塞的,意味着我们会暂停整个协程,直到await表达式的结果返回。

我们可以创建任务并将它们调度到事件循环上立即运行的事实意味着我们可以大致同时执行多个任务。当这些任务包装了长时间运行的操作时,任何等待都会并发发生。为了说明这一点,让我们创建两个任务并尝试同时运行它们。

2.3.1 创建任务的基本知识

创建一个任务是通过使用asyncio.create_task函数实现的。当我们调用这个函数时,我们给它一个要运行的协程,它会立即返回一个任务对象。一旦我们有了任务对象,我们可以将其放入一个await表达式中,一旦它完成,就会提取返回值。

列表 2.8 创建任务

import asyncio
from util import delay

async def main():
    sleep_for_three = asyncio.create_task(delay(3))
    print(type(sleep_for_three))
    result = await sleep_for_three
    print(result)

asyncio.run(main())

在前面的列表中,我们创建了一个需要 3 秒钟才能完成的任务。我们还打印出了任务的类型,在这种情况下,<class '_asyncio.Task'>,以表明它与协程不同。

这里还有一个需要注意的事项,那就是我们的打印语句在我们运行任务后立即执行。如果我们只是对延迟协程使用了await,我们将在输出消息之前等待 3 秒钟。

一旦我们打印了我们的消息,我们就将await表达式应用于任务sleep_for_three。这将挂起我们的main协程,直到我们从任务中获得结果。

重要的是要知道,我们通常应该在应用程序的某个地方使用await关键字在我们的任务上。在列表 2.8 中,如果我们没有使用await,我们的任务将被安排运行,但几乎在asyncio.run关闭事件循环时立即停止并“清理”。在我们的应用程序中使用await任务也有对异常处理的影响,我们将在第三章中探讨。现在我们已经看到了如何创建任务并允许其他代码并发运行,我们可以学习如何同时运行多个长时间运行的操作。

2.3.2 并发运行多个任务

由于任务可以立即创建并且被安排为尽可能快地运行,这使我们能够并发运行许多长时间运行的任务。我们可以通过按顺序启动多个长时间运行的协程来实现这一点。

列表 2.9 并发运行多个任务

import asyncio
from util import delay

async def main():
    sleep_for_three = asyncio.create_task(delay(3))
    sleep_again = asyncio.create_task(delay(3))
    sleep_once_more = asyncio.create_task(delay(3))

    await sleep_for_three
    await sleep_again
    await sleep_once_more

asyncio.run(main())

在前面的列表中,我们启动了三个任务,每个任务需要 3 秒钟来完成。每次调用create_task都会立即返回,因此我们立即到达await sleep_for_three语句。之前我们提到,任务被安排为“尽可能快地”运行。通常情况下,这意味着在创建任务后第一次遇到await语句时,任何挂起的任务都会在await触发事件循环迭代时运行。

由于我们遇到了await sleep_for_three,所有三个任务开始运行,并将并发执行任何睡眠操作。这意味着列表 2.9 中的程序将在大约 3 秒内完成。我们可以通过图 2.3 可视化并发性,注意所有三个任务都在同时运行它们的睡眠协程。

02-03

图 2.3 列表 2.9 的执行流程

注意,在图 2.3 中,标记为 RUN delay(3)(在这种情况下,一些打印语句)的任务中的代码不会与其他任务并发运行;只有睡眠协程会并发运行。如果我们按顺序运行这些延迟操作,我们的应用程序运行时间将略超过 9 秒。通过这种方式并发运行,我们已经将这个应用程序的总运行时间减少了三倍!

注意:随着我们添加更多任务,这种好处会累积;如果我们启动了 10 个这样的任务,我们仍然需要大约 3 秒钟,这将给我们带来 10 倍的速度提升。

并行执行这些长时间运行的操作是 asyncio 真正发光发热的地方,它为我们应用程序的性能带来了显著的提升,但好处并不止于此。在 2.9 列表中,我们的应用程序在积极做些无用功,同时它等待 3 秒钟以完成我们的延迟协程。当我们的代码等待时,我们可以执行其他代码。作为一个例子,让我们假设我们想在运行一些长时间任务的同时每秒打印一条状态信息。

列表 2.10 在其他操作完成时运行代码

import asyncio
from util import delay

async def hello_every_second():
    for i in range(2):
        await asyncio.sleep(1)
        print("I'm running other code while I'm waiting!")

async def main():
    first_delay = asyncio.create_task(delay(3))
    second_delay = asyncio.create_task(delay(3))
    await hello_every_second()
    await first_delay
    await second_delay

在前面的列表中,我们创建了两个任务,每个任务需要 3 秒钟来完成。当这些任务等待时,我们的应用程序处于空闲状态,这给了我们运行其他代码的机会。在这个例子中,我们运行了一个协程hello_every_second,每秒打印一条信息 2 次。当我们的两个任务运行时,我们会看到输出信息,如下所示:

sleeping for 3 second(s)
sleeping for 3 second(s)
I'm running other code while I'm waiting!
I'm running other code while I'm waiting!
finished sleeping for 3 second(s)
finished sleeping for 3 second(s)

我们可以将执行流程想象成图 2.4 所示的那样。

02-04

图 2.4 列表 2.10 的执行流程

首先,我们启动两个睡眠 3 秒钟的任务;然后,当我们的两个任务空闲时,我们开始看到每秒打印出I’m running other code while I’m waiting!的信息。这意味着即使我们在运行耗时操作时,我们的应用程序仍然可以执行其他任务。

任务的一个潜在问题是它们可能需要不定的时间来完成。我们可能会希望停止一个耗时太长的任务。任务通过允许取消来支持这种用例。

2.4 取消任务和设置超时

网络连接可能不可靠。用户的连接可能因为网络减速而断开,或者一个 Web 服务器可能崩溃,留下现有的请求处于悬而未决的状态。当我们发起这类请求时,我们需要特别注意不要无限期地等待。这样做可能会导致我们的应用程序挂起,永远等待可能永远不会到来的结果。这也可能导致用户体验不佳;如果我们允许用户发起耗时太长的请求,他们不太可能永远等待响应。此外,我们可能希望允许用户在任务继续运行时有所选择。用户可能会主动决定事情耗时太长,或者他们可能想要停止他们错误发起的任务。

在我们之前的例子中,如果我们的任务需要无限期的时间,我们会陷入等待await语句完成而没有反馈的状态。如果我们想停止它们,我们也无法做到。asyncio 通过允许任务被取消以及允许它们指定超时来支持这两种情况。

2.4.1 取消任务

取消任务很简单。每个任务对象都有一个名为cancel的方法,我们可以随时调用它来停止任务。取消任务会导致当我们在await它时,该任务抛出一个CancelledError,然后我们可以根据需要处理它。

为了说明这一点,让我们假设我们启动了一个我们不想运行超过 5 秒钟的长时间运行的任务。如果任务在 5 秒内未完成,我们希望停止该任务,向用户报告它花费了太长时间,我们正在停止它。我们还希望每秒打印一个状态更新,以便向用户提供最新的信息,这样他们就不会在没有信息的情况下等待几秒钟。

列表 2.11 取消任务

import asyncio
from asyncio import CancelledError
from util import delay

async def main():
    long_task = asyncio.create_task(delay(10))

    seconds_elapsed = 0

    while not long_task.done():
        print('Task not finished, checking again in a second.')
        await asyncio.sleep(1)
        seconds_elapsed = seconds_elapsed + 1
        if seconds_elapsed == 5:
            long_task.cancel()

    try:
        await long_task
    except CancelledError:
        print('Our task was cancelled')

asyncio.run(main())

在前面的列表中,我们创建了一个需要 10 秒钟来运行的任务。然后我们创建了一个while循环来检查该任务是否完成。任务上的done方法如果任务已完成则返回True,否则返回False。每秒钟,我们检查任务是否完成,并记录我们已经检查了多少秒。如果我们的任务已经进行了 5 秒,我们将取消该任务。然后,我们将继续await long_task,我们会看到打印出我们的任务已被取消,这表明我们已经捕获了一个CancelledError

关于取消的一个重要注意事项是,CancelledError只能从一个await语句中抛出。这意味着如果我们在一个任务执行纯 Python 代码时调用取消,该代码将一直运行到完成,直到我们遇到下一个await语句(如果有的话),然后可以抛出CancelledError。调用取消不会神奇地停止任务;它只会停止任务,如果你当前处于一个await点或其下一个await点。

2.4.2 使用wait_for设置超时和取消

每秒或在某些其他时间间隔检查,然后取消任务,就像我们在前面的例子中所做的那样,并不是处理超时的最简单方法。理想情况下,我们会有一个辅助函数,允许我们指定这个超时并为我们处理取消。

asyncio 通过一个名为asyncio.wait_for的函数提供此功能。此函数接受一个协程或任务对象,以及以秒为单位指定的超时。然后它返回一个我们可以await的协程。如果任务完成所需时间超过我们给出的超时时间,将引发TimeoutException。一旦达到超时阈值,任务将自动取消。

为了说明wait_for的工作原理,我们将查看一个案例,其中我们有一个需要 2 秒钟才能完成的任务,但我们只允许它 1 秒钟完成。当我们遇到TimeoutError异常时,我们将捕获该异常并检查任务是否被取消。

列表 2.12 使用wait_for为任务创建超时

import asyncio
from util import delay

async def main():
    delay_task = asyncio.create_task(delay(2))
    try:
        result = await asyncio.wait_for(delay_task, timeout=1)
        print(result)
    except asyncio.exceptions.TimeoutError:
        print('Got a timeout!')
        print(f'Was the task cancelled? {delay_task.cancelled()}')

asyncio.run(main())

当我们运行前面的列表时,我们的应用程序将大约需要 1 秒钟才能完成。1 秒后,我们的wait_for语句将引发一个TimeoutError,然后我们处理它。然后我们会看到我们的原始delay任务已被取消,以下输出如下:

sleeping for 2 second(s)
Got a timeout!
Was the task cancelled? True

如果任务执行时间超过预期,自动取消任务通常是一个好主意。否则,我们可能有一个协程无限期地等待,占用可能永远不会释放的资源。然而,在某些情况下,我们可能希望让我们的协程继续运行。例如,我们可能希望在经过一段时间后通知用户某事比预期花费的时间更长,但在超时时不取消任务。

要做到这一点,我们可以使用asyncio.shield函数来包装我们的任务。这个函数将防止取消我们传递的协程,给它一个“护盾”,这样取消请求就会被忽略。

列表 2.13 保护任务免受取消

import asyncio
from util import delay

async def main():
    task = asyncio.create_task(delay(10))

    try:
        result = await asyncio.wait_for(asyncio.shield(task), 5)
        print(result)
    except TimeoutError:
        print("Task took longer than five seconds, it will finish soon!")
        result = await task
        print(result)

asyncio.run(main())

在前面的列表中,我们首先创建一个任务来包装我们的协程。这与我们的第一个取消示例不同,因为我们需要在except块中访问任务。如果我们传递了一个协程,wait_for会将其包装在任务中,但我们无法引用它,因为它在函数内部是内部的。

然后,在try块内部,我们调用wait_for并将任务包装在shield中,这将防止任务被取消。在我们的异常块中,我们向用户打印一条有用的消息,让他们知道任务仍在运行,然后我们await最初创建的任务。这将允许它完整地完成,程序的输出将如下所示:

sleeping for 10 second(s)
Task took longer than five seconds!
finished sleeping for 10 second(s)
finished <function delay at 0x10e8cf820> in 10 second(s)

取消和屏蔽是有些棘手的话题,有几个值得注意的情况。我们下面介绍基础知识,但随着我们进入更复杂的情况,我们将更深入地探讨取消的工作原理。

我们现在已经介绍了任务和协程的基础知识。这些概念相互交织。在下一节中,我们将探讨任务和协程之间的关系,并更深入地了解 asyncio 的结构。

2.5 任务、协程、futures 和 awaitables

协程和任务都可以用在await表达式中。那么它们之间的共同点是什么呢?为了理解这一点,我们需要了解future以及awaitable。通常你不需要使用future,但了解它们是理解 asyncio 内部工作原理的关键。由于一些 API 返回future,我们将在本书的其余部分引用它们。

2.5.1 介绍 futures

future是一个 Python 对象,它包含一个你预期在未来某个时刻获得但可能尚未有的单一值。通常,当你创建一个future时,它围绕的值没有值,因为它还不存在。在这个状态下,它被认为是未完成的、未解决的或简单地未完成。然后,一旦你得到一个结果,你可以设置future的值。这将完成future;那时,我们可以认为它是完成的,并从future中提取结果。为了了解future的基本知识,让我们尝试创建一个,设置其值并提取该值。

列表 2.14 future的基本知识

from asyncio import Future

my_future = Future()

print(f'Is my_future done? {my_future.done()}')

my_future.set_result(42)

print(f'Is my_future done? {my_future.done()}')
print(f'What is the result of my_future? {my_future.result()}')

我们可以通过调用其构造函数来创建一个future。在这个时候,future上还没有结果集,因此调用其done方法将返回False。然后我们使用set_result方法设置future的值,这将标记futuredone。或者,如果我们想在future上设置一个异常,我们可以调用set_exception

注意:在设置结果之前我们不调用结果方法,因为如果这样做,result方法将抛出一个无效状态异常。

future也可以用在await表达式中。如果我们await一个future,这意味着“暂停直到future有一个可以工作的值被设置,一旦我有了值,醒来并让我处理它。”

为了理解这一点,让我们考虑一个创建返回future的 Web 请求的例子。返回future的请求应该立即完成,但由于请求需要一些时间,future尚未定义。然后,稍后,一旦请求完成,结果将被设置,然后我们可以访问它。如果你以前使用过 JavaScript,这个概念类似于promises。在 Java 世界中,这些被称为completable futures

列表 2.15 等待一个future

from asyncio import Future
import asyncio

def make_request() -> Future:
    future = Future()
    asyncio.create_task(set_future_value(future))    ❶
    return future

async def set_future_value(future) -> None:
    await asyncio.sleep(1)                           ❷
    future.set_result(42)

async def main():
    future = make_request()
    print(f'Is the future done? {future.done()}')
    value = await future                             ❸
    print(f'Is the future done? {future.done()}')
    print(value)

asyncio.run(main())

❶ 创建一个任务以异步设置future的值。

❷ 在设置future的值之前等待 1 秒钟。

❸ 暂停主函数直到future的值被设置。

在前面的列表中,我们定义了一个函数make_request。在那个函数中,我们创建了一个future并创建了一个task,该task将在 1 秒后将future的结果异步设置。然后,在主函数中,我们调用make_request。当我们调用这个函数时,我们会立即得到一个没有结果的future;因此,它是未完成的。然后,我们await这个future。等待这个future将暂停main函数 1 秒钟,直到我们等待future的值被设置。一旦完成,value将是42futuredone的。

asyncio的世界里,你很少需要处理future。尽管如此,你可能会遇到一些返回futureasyncio API,你可能需要与基于回调的代码一起工作,这可能需要future。你也可能需要自己阅读或调试一些asyncio API 代码。这些asyncio API 的实现高度依赖于future,因此了解它们是如何工作的非常有用。

2.5.2 futuretask和协程之间的关系

任务和future之间存在紧密的关系。实际上,task直接继承自futurefuture可以被视为代表我们暂时不会拥有的一个值。task可以被视为一个协程和future的组合。当我们创建一个task时,我们创建了一个空的future并运行协程。然后,当协程完成,无论是异常还是结果,我们都会设置future的结果或异常。

考虑到未来和任务之间的关系,任务和协程之间是否存在类似的关系?毕竟,所有这些类型都可以用在 await 表达式中。

这些之间的共同点是 Awaitable 抽象基类。这个类定义了一个抽象的双下划线方法 await。我们不会深入讨论如何创建我们自己的 awaitables,但任何实现了 __await__ 方法的对象都可以用在 await 表达式中。协程直接继承自 Awaitablefutures 也是如此。任务然后扩展了 futures,这给我们带来了如图 2.5 所示的继承图。

02-05

图 2.5 Awaitable 的类继承层次结构

从现在开始,我们将开始使用可以在 await 表达式中使用的对象称为 awaitables。你会在 asyncio 文档中经常看到 awaitable 这个术语被引用,因为许多 API 方法并不关心你传递的是协程、任务还是未来。

现在我们已经了解了协程、任务和未来的基础知识,我们如何评估它们的性能呢?到目前为止,我们只理论化了它们所需的时间。为了使事情更加严谨,让我们添加一些功能来测量执行时间。

2.6 使用装饰器测量协程执行时间

到目前为止,我们讨论了大致了解应用程序运行所需时间的方法,而没有进行计时。要真正理解和分析这些内容,我们需要引入一些代码来帮助我们跟踪这些信息。

作为第一次尝试,我们可以包装每个 await 语句并跟踪协程的开始和结束时间:

import asyncio
import time

async def main():
    start = time.time()
    await asyncio.sleep(1)
    end = time.time()
    print(f'Sleeping took {end - start} seconds')

asyncio.run(main())

然而,当我们有多个 await 语句和需要跟踪的任务时,这会很快变得混乱。一个更好的方法是找到一个可重用的方法来跟踪任何协程完成所需的时间。我们可以通过创建一个装饰器来实现这一点,该装饰器将为我们运行 await 语句(列表 2.16)。我们将这个装饰器称为 async_timed

什么是装饰器?

装饰器 是 Python 中的一种模式,它允许我们在不改变现有函数代码的情况下为其添加功能。我们可以“拦截”一个函数在调用时的行为,并在该调用之前或之后应用我们想要的任何装饰器代码。装饰器是解决横切关注点的一种方式。以下列表展示了示例装饰器。

列表 2.16 用于计时协程的装饰器

import functools
import time
from typing import Callable, Any

def async_timed():
    def wrapper(func: Callable) -> Callable:
        @functools.wraps(func)
        async def wrapped(*args, **kwargs) -> Any:
            print(f'starting {func} with args {args} {kwargs}')
            start = time.time()
            try:
                return await func(*args, **kwargs)
            finally:
                end = time.time()
                total = end - start
                print(f'finished {func} in {total:.4f} second(s)')

        return wrapped

    return wrapper

在这个装饰器中,我们创建了一个名为 wrapped 的新协程。这是一个围绕我们的原始协程的包装器,它接受其参数 *args**kwargs,调用一个 await 语句,然后返回结果。我们在开始运行函数时围绕那个 await 语句发送一条消息,在结束运行函数时发送另一条消息,以记录开始和结束时间,这与我们在早期开始时间和结束时间示例中所做的方式非常相似。现在,如列表 2.17 所示,我们可以在任何协程上放置这个注解,每次运行它时,我们都会看到它运行了多长时间。

列表 2.17 使用装饰器计时两个并发任务

import asyncio

@async_timed()
async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

@async_timed()
async def main():
    task_one = asyncio.create_task(delay(2))
    task_two = asyncio.create_task(delay(3))

    await task_one
    await task_two

asyncio.run(main())

当我们运行前面的列表时,我们会看到类似于以下内容的控制台输出:

starting <function main at 0x109111ee0> with args () {}
starting <function delay at 0x1090dc700> with args (2,) {}
starting <function delay at 0x1090dc700> with args (3,) {}
finished <function delay at 0x1090dc700> in 2.0032 second(s)
finished <function delay at 0x1090dc700> in 3.0003 second(s)
finished <function main at 0x109111ee0> in 3.0004 second(s)

我们可以看到,我们的两个delay调用分别在大约 2 秒和 3 秒内开始和完成,总共 5 秒。然而,请注意,我们的主协程只用了 3 秒就完成了,因为我们是在并发等待。

我们将在接下来的几章中使用这个装饰器和产生的输出来说明我们的协程执行所需的时间,以及它们何时开始和完成。这将给我们一个清晰的画面,了解我们在并发执行操作时看到性能提升的地方。

为了使在未来的代码列表中引用这个实用装饰器更容易,让我们将其添加到我们的util模块中。我们将把我们的计时器放在一个名为async_timer.py的文件中。我们还将向模块的__init__.py文件中添加一行,如下所示,以便我们可以优雅地导入计时器:

from util.async_timer import async_timed

在本书的其余部分,每当我们需要使用计时器时,我们将使用from util import async_timed

现在我们可以使用我们的装饰器来理解 asyncio 在并发运行任务时可以提供的性能提升,我们可能会想尝试在我们的现有应用程序中到处使用 asyncio。这可以行得通,但我们需要小心,不要遇到任何可能降低我们应用程序性能的 asyncio 常见陷阱。

2.7 协程和任务的陷阱

当我们看到通过并发运行一些较长的任务可以获得性能提升时,我们可能会被诱惑在我们的应用程序的每个地方开始使用协程和任务。虽然这取决于你正在编写的应用程序,但仅仅将函数标记为async并将它们包装在任务中可能不会帮助提高应用程序的性能。在某些情况下,这可能会降低你应用程序的性能。

在尝试将你的应用程序异步化时,会出现两个主要错误。第一个是在不使用多进程的情况下尝试在任务或协程中运行 CPU 密集型代码;第二个是使用阻塞 I/O 密集型 API 而不使用多线程。

2.7.1 运行 CPU 密集型代码

我们可能有一些执行计算量大的函数,例如遍历一个大的字典或进行数学计算。当我们有多个这样的函数,并且它们有可能并发运行时,我们可能会想到将它们分别运行在不同的任务中。从概念上讲,这是一个好主意,但请记住,asyncio 有一个单线程的并发模型。这意味着我们仍然受到单线程和全局解释器锁的限制。

为了证明这一点,让我们尝试并发运行一些 CPU 密集型函数。

列表 2.18 尝试并发运行 CPU 密集型代码

import asyncio
from util import delay

@async_timed()
async def cpu_bound_work() -> int:
    counter = 0
    for i in range(100000000):
        counter = counter + 1
    return counter

@async_timed()
async def main():
    task_one = asyncio.create_task(cpu_bound_work())
    task_two = asyncio.create_task(cpu_bound_work())
    await task_one
    await task_two

asyncio.run(main())

当我们运行前面的列表时,我们会看到,尽管创建了两个任务,但我们的代码仍然顺序执行。首先,我们运行任务 1,然后运行任务 2,这意味着我们的总运行时间将是两次调用cpu_bound_work的总和:

starting <function main at 0x10a8f6c10> with args () {}
starting <function cpu_bound_work at 0x10a8c0430> with args () {}
finished <function cpu_bound_work at 0x10a8c0430> in 4.6750 second(s)
starting <function cpu_bound_work at 0x10a8c0430> with args () {}
finished <function cpu_bound_work at 0x10a8c0430> in 4.6680 second(s)
finished <function main at 0x10a8f6c10> in 9.3434 second(s)

观察上面的输出,我们可能会认为将所有代码都使用asyncawait没有缺点。毕竟,它最终花费的时间与顺序运行相同。然而,通过这样做,我们可能会遇到应用程序性能下降的情况。这在我们有其他包含await表达式的协程或任务时尤其如此。考虑创建两个 CPU 密集型任务与一个长时间运行的任务(如我们的delay协程)一起。

列表 2.19 CPU 密集型代码与任务

import asyncio
from util import async_timed, delay

@async_timed()
async def cpu_bound_work() -> int:
    counter = 0
    for i in range(100000000):
        counter = counter + 1
    return counter

@async_timed()
async def main():
    task_one = asyncio.create_task(cpu_bound_work())
    task_two = asyncio.create_task(cpu_bound_work())
    delay_task = asyncio.create_task(delay(4))
    await task_one
    await task_two
    await delay_task

asyncio.run(main())

运行前面的列表,我们可能会期望花费的时间与列表 2.18 相同。毕竟,delay_task不会与 CPU 密集型工作并行运行吗?在这个例子中不会,因为我们首先创建了两个 CPU 密集型任务,这实际上阻止了事件循环运行任何其他任务。这意味着我们应用程序的运行时间将是我们的两个cpu_bound_work任务完成所需时间的总和加上我们的delay任务所花费的 4 秒钟。

如果我们需要执行 CPU 密集型工作,同时还想使用async / await语法,我们可以这样做。为此,我们仍然需要使用多进程,并需要告诉 asyncio 在进程池中运行我们的任务。我们将在第六章学习如何做到这一点。

2.7.2 运行阻塞 API

我们也可能倾向于通过将它们包装在协程中来使用现有的用于 I/O 密集型操作的库。然而,这会产生与 CPU 密集型操作相同的问题。这些 API 会阻塞main线程。因此,当我们在一个协程中运行阻塞的 API 调用时,我们实际上是在阻塞事件循环线程本身,这意味着我们阻止了任何其他协程或任务的执行。阻塞 API 调用的例子包括requests库或time.sleep。一般来说,任何不是协程且执行 I/O 操作或进行耗时 CPU 操作的函数都可以被认为是阻塞的。

例如,让我们尝试三次并发获取www.example.com的状态码,使用requests库。当我们运行这个时,由于我们是在并发运行,我们预计这个应用程序将花费大约获取状态码一次所需的时间来完成。

列表 2.20 在协程中不正确地使用阻塞 API

import asyncio
import requests
from util import async_timed

@async_timed()
async def get_example_status() -> int:
    return requests.get('http:/ / www .example .com').status_code

@async_timed()
async def main():
    task_1 = asyncio.create_task(get_example_status())
    task_2 = asyncio.create_task(get_example_status())
    task_3 = asyncio.create_task(get_example_status())
    await task_1
    await task_2
    await task_3

asyncio.run(main())

当运行前面的列表时,我们将看到类似于以下输出的内容。注意,主协程的总运行时间大致是所有任务获取我们运行的状态所需时间的总和,这意味着我们没有获得任何并发优势:

starting <function main at 0x1102e6820> with args () {}
starting <function get_example_status at 0x1102e6700> with args () {}
finished <function get_example_status at 0x1102e6700> in 0.0839 second(s)
starting <function get_example_status at 0x1102e6700> with args () {}
finished <function get_example_status at 0x1102e6700> in 0.0441 second(s)
starting <function get_example_status at 0x1102e6700> with args () {}
finished <function get_example_status at 0x1102e6700> in 0.0419 second(s)
finished <function main at 0x1102e6820> in 0.1702 second(s)

这再次是因为 requests 库是阻塞的,这意味着它将阻塞运行在其上的任何线程。由于 asyncio 只有一个线程,因此 requests 库会阻止事件循环进行并发操作。

通常情况下,你现在使用的多数 API 都是阻塞的,并且不能与 asyncio 无缝工作。你需要使用支持协程并利用非阻塞套接字的库。这意味着,如果你使用的库不返回协程,并且你自己的协程中没有使用 await,那么你很可能会进行阻塞调用。

在上面的示例中,我们可以使用像 aiohttp 这样的库,它使用非阻塞套接字并返回协程以获得适当的并发性。我们将在第四章中介绍这个库。

如果你需要使用 requests 库,你仍然可以使用 async 语法,但你需要明确告诉 asyncio 使用带有 线程池执行器 的多线程。我们将在第七章中看到如何做到这一点。

我们现在已经看到了在使用 asyncio 时需要注意的一些事情,并构建了一些简单的应用程序。到目前为止,我们还没有自己创建或配置事件循环,而是依赖便利的方法来为我们完成这些工作。接下来,我们将学习如何创建事件循环,这将使我们能够访问更底层的 asyncio 功能和事件循环配置属性。

2.8 访问和手动管理事件循环

到目前为止,我们一直使用方便的 asyncio.run 来运行我们的应用程序,并在幕后为我们创建事件循环。鉴于其易用性,这是创建事件循环的首选方法。然而,可能存在我们不想使用 asyncio.run 提供的功能的情况。例如,我们可能想要执行自定义逻辑来停止与 asyncio.run 所做不同的任务,例如让任何剩余的任务完成而不是停止它们。

此外,我们可能还想访问事件循环本身上的方法。这些方法通常是低级别的,因此应该谨慎使用。然而,如果你想要执行任务,例如直接与套接字工作或安排在未来的某个时间运行的任务,你需要访问事件循环。虽然我们不会,也不应该,大量管理事件循环,但有时这将是必要的。

2.8.1 手动创建事件循环

我们可以通过使用 asyncio.new_event_loop 方法来创建事件循环。这将返回一个事件循环实例。有了这个,我们就有了访问事件循环提供的所有底层方法。有了事件循环,我们可以访问一个名为 run_until_complete 的方法,它接受一个协程并在其完成前运行它。一旦我们完成事件循环,我们需要关闭它以释放它所使用的任何资源。这通常应该在 finally 块中完成,这样任何抛出的异常都不会阻止我们关闭循环。使用这些概念,我们可以创建一个循环并运行 asyncio 应用程序。

列表 2.21 手动创建事件循环

import asyncio

async def main():
    await asyncio.sleep(1)

loop = asyncio.new_event_loop()

try:
    loop.run_until_complete(main())
finally:
    loop.close()

列表中的代码与我们调用asyncio.run时发生的情况类似,区别在于这不会取消任何剩余的任务。如果我们想要任何特殊的清理逻辑,我们将在finally子句中这样做。

2.8.2 访问事件循环

有时,我们可能需要访问当前运行的事件循环。asyncio 公开了asyncio.get_running_loop函数,允许我们获取当前事件循环。作为一个例子,让我们看看call_soon,它将在事件循环的下一个迭代上安排一个函数运行。

列表 2.22 访问事件循环

import asyncio

def call_later():
    print("I'm being called in the future!")
async def main():
    loop = asyncio.get_running_loop()
    loop.call_soon(call_later)
    await delay(1)

asyncio.run(main())

在前面的列表中,我们的主协程使用asyncio.get _running_loop获取事件循环,并告诉它运行call_later,该函数将在事件循环的下一个迭代上运行。此外,还有一个asyncio.get_event _loop函数,允许你访问事件循环。

如果在未运行事件循环时调用此函数,它可能会创建一个新的事件循环,从而导致奇怪的行为。建议使用get_ running_loop,因为它会在事件循环未运行时抛出异常,避免任何意外。

虽然我们不应该在我们的应用程序中频繁使用事件循环,但有时我们仍需要配置事件循环的设置或使用底层函数。我们将在下一节关于调试 模式中看到一个配置事件循环的例子。

2.9 使用调试模式

在前面的章节中,我们提到了协程应该在应用程序的某个点上始终被等待。我们也看到了在协程和任务中运行 CPU 密集型和其他阻塞代码的缺点。然而,判断一个协程是否在 CPU 上花费了太多时间,或者我们是否在应用程序中不小心遗漏了一个await,可能有些困难。幸运的是,asyncio 为我们提供了一个调试模式来帮助我们诊断这些情况。

当我们在debug模式下运行时,如果协程或任务运行时间超过 100 毫秒,我们会看到一些有用的日志消息。此外,如果我们没有await一个协程,会抛出一个异常,因此我们可以看到在哪里正确添加await。有几种不同的方式可以在调试模式下运行。

2.9.1 使用 asyncio.run

我们用来运行协程的asyncio.run函数公开了一个debug参数。默认情况下,这个参数设置为False,但我们可以将其设置为True以启用调试模式:

asyncio.run(coroutine(), debug=True)

2.9.2 使用命令行参数

通过在启动 Python 应用程序时传递命令行参数,我们可以启用调试模式。为此,我们应用-X dev

python3 -X dev program.py

2.9.3 使用环境变量

我们还可以通过设置PYTHONASYNCIODEBUG变量为1来使用环境变量启用调试模式:

PYTHONASYINCIODEBUG=1 python3 program.py

注意:在 Python 3.9 之前的版本中,调试模式下存在一个错误。当使用asyncio.run时,只有boolean调试参数将工作。命令行参数和环境变量只有在手动管理事件循环时才会工作。

在调试模式下,当协程运行时间过长时,我们会看到记录的具有信息性的消息。让我们通过尝试在任务中运行 CPU 密集型代码来测试这一点,看看是否会收到警告,如下列所示。

列表 2.23 在调试模式下运行 CPU 密集型代码

import asyncio
from util import async_timed

@async_timed()
async def cpu_bound_work() -> int:
    counter = 0
    for i in range(100000000):
        counter = counter + 1
    return counter

async def main() -> None:
    task_one = asyncio.create_task(cpu_bound_work())
    await task_one

asyncio.run(main(), debug=True)

当运行此操作时,我们会看到一个有用的消息,表明task_one花费了太多时间,因此阻止了事件循环运行任何其他任务:

Executing <Task finished name='Task-2' coro=<cpu_bound_work() done, defined at listing_2_9.py:5> result=100000000 created at tasks.py:382> took 4.829 seconds

这对于调试可能无意中进行的阻塞调用的问题很有帮助。默认设置会在协程运行时间超过 100 毫秒时记录一个警告,但这可能比我们希望的更长或更短。要更改此值,我们可以通过像列表 2.24 中那样访问事件循环并设置slow_callback_duration来设置慢速回调持续时间。这是一个表示我们希望慢速回调持续时间秒数的浮点值。

列表 2.24 更改慢速回调持续时间

import asyncio

async def main():
    loop = asyncio.get_event_loop()
    loop.slow_callback_duration = .250

asyncio.run(main(), debug=True)

上述列表将慢速回调持续时间设置为 250 毫秒,这意味着如果任何协程运行时间超过 250 毫秒的 CPU 时间,我们将得到一条打印出来的消息。

摘要

  • 我们已经学会了如何使用async关键字创建协程。协程可以在阻塞操作上挂起其执行。这允许其他协程运行。一旦协程挂起的操作完成,我们的协程将醒来并从上次离开的地方继续执行。

  • 我们学会了在协程调用前使用await来运行它并等待它返回一个值。为此,包含await的协程将挂起其执行,等待结果。这允许其他协程在第一个协程等待其结果时运行。

  • 我们已经学会了如何使用asyncio.run来执行单个协程。我们可以使用此函数来运行作为我们应用程序主要入口点的协程。

  • 我们已经学会了如何使用任务来并发运行多个长时间运行的操作。任务是对协程的封装,将在事件循环上运行。当我们创建一个任务时,它将被安排在尽可能早的时间在事件循环上运行。

  • 我们已经学会了如何取消任务以停止它们,以及如何为任务添加超时以防止它们无限期地运行。取消任务将在我们等待它时引发CancelledError。如果我们对任务应该运行多长时间有限制,我们可以通过使用asycio.wait_for来设置超时。

  • 我们已经学会了避免在使用 asyncio 时新手常犯的常见问题。第一个问题是将 CPU 密集型代码运行在协程中。由于我们仍然是单线程的,CPU 密集型代码会阻塞事件循环运行其他协程。第二个问题是阻塞 I/O,因为我们不能使用常规库与 asyncio 一起使用,你必须使用返回协程的 asyncio 特定库。如果你的协程中没有 await,你应该考虑它是可疑的。尽管如此,我们仍然有方法在使用 asyncio 时使用 CPU 密集型和阻塞 I/O,这些方法将在第六章和第七章中讨论。

  • 我们已经学会了如何使用调试模式。调试模式可以帮助我们诊断 asyncio 代码中的常见问题,例如在协程中运行 CPU 密集型代码。

3 一个基本的 asyncio 应用程序

本章涵盖了

  • 使用套接字在网络中传输数据

  • 使用 telnet 与基于套接字的应用程序通信

  • 使用选择器构建非阻塞套接字的简单事件循环

  • 创建一个支持多个连接的非阻塞回声服务器

  • 在任务中处理异常

  • 向 asyncio 应用程序添加自定义关闭逻辑

在第一章和第二章中,我们介绍了协程、任务和事件循环。我们还探讨了如何并发运行长时间操作,并探索了一些简化此过程的 asyncio API。然而,到目前为止,我们只使用sleep函数模拟了长时间操作。

由于我们希望构建的不仅仅是演示应用程序,我们将使用一些现实世界的阻塞操作来演示如何创建一个可以同时处理多个用户的服务器。我们只使用一个线程来完成这个任务,这将比涉及线程或多个进程的其他解决方案更高效、更简单。我们将利用我们对协程、任务和 asyncio API 方法的知识,使用套接字构建一个可工作的命令行回声服务器应用程序来演示这一点。到本章结束时,你将能够使用 asyncio 构建基于套接字的网络应用程序,这些应用程序可以使用一个线程同时处理多个用户。

首先,我们将学习如何使用阻塞套接字发送和接收数据的基础知识。然后,我们将使用这些套接字尝试构建一个多客户端回声服务器。在这个过程中,我们将证明仅使用单个线程无法构建同时为多个客户端正确工作的回声服务器。然后,我们将学习如何通过使套接字非阻塞并使用操作系统的事件通知系统来解决这些问题。这将帮助我们理解 asyncio 事件循环的底层机制。然后,我们将使用 asyncio 的非阻塞套接字协程来允许多个客户端正确连接。这个应用程序将允许多个用户同时连接,让他们能够并发地发送和接收消息。最后,我们将向我们的应用程序添加自定义关闭逻辑,以便当我们的服务器关闭时,我们给正在传输的消息一些时间来完成。

3.1 与阻塞套接字一起工作

在第一章中,我们介绍了套接字的概念。回想一下,套接字是一种在网络中读写数据的方式。我们可以将套接字想象成一个邮箱:我们放入一封信,它就会被送到收件人的地址。收件人可以阅读那封信,并可能给我们回一封信。

为了开始,我们将创建主邮箱套接字,我们将称之为服务器套接字。这个套接字将首先接受来自想要与我们通信的客户端的连接消息。一旦我们的服务器套接字确认了这个连接,我们将创建一个我们可以用来与客户端通信的套接字。这意味着我们的服务器开始看起来更像一个拥有多个邮政信箱的邮局,而不仅仅是单个邮箱。客户端仍然可以被视为拥有一个单独的邮箱,因为他们将有一个套接字来与我们通信。当客户端连接到我们的服务器时,我们为他们提供一个邮政信箱。然后我们使用那个邮政信箱来发送和接收来自该客户端的消息(见图 3.1)。

03-01

图 3.1 客户端连接到我们的服务器套接字。然后服务器创建一个新的套接字来与客户端通信。

我们可以使用 Python 内置的 socket 模块来创建这个服务器套接字。此模块提供了读取、写入和操作套接字的功能。要开始创建套接字,我们将创建一个简单的服务器,该服务器监听来自客户端的连接,并在成功连接时打印一条消息。此套接字将绑定到主机名和端口号,并将作为任何客户端将与之通信的主要“服务器套接字”。

创建套接字需要几个步骤。我们首先使用socket函数创建一个套接字:

import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

在这里,我们向套接字函数指定了两个参数。第一个是socket.AF_INET——这告诉我们我们的套接字将能够与哪种类型的地址交互;在这种情况下是一个主机名和一个端口号。第二个是socket.SOCK_STREAM;这意味着我们使用 TCP 协议进行通信。

什么是 TCP 协议?

TCP,或传输控制协议,是一种设计用于在网络中在应用程序之间传输数据的协议。这个协议在设计时考虑了可靠性。它执行错误检查,按顺序交付数据,并在需要时重新传输数据。这种可靠性是以一些开销为代价的。绝大多数的 Web 都是建立在 TCP 之上的。TCP 与 UDP,或用户数据报协议相对,UDP 可靠性较低,但比 TCP 有更少的开销,并且通常性能更好。本书将专门关注 TCP 套接字。

我们还调用setsockopt来设置SO_REUSEADDR标志为1。这将允许我们在停止和重新启动应用程序后重用端口号,避免任何“地址已在使用”的错误。如果我们不这样做,操作系统可能需要一些时间来解绑此端口,并且我们的应用程序可能无法无错误地启动。

调用socket.socket允许我们创建一个套接字,但我们还不能与之通信,因为我们还没有将其绑定到客户端可以与之交谈的地址(我们的邮局需要一个地址!)。对于这个例子,我们将套接字绑定到我们自己的计算机上的地址127.0.0.1,并且我们将选择一个任意的端口号 8000:

address = (127.0.0.1, 8000)
server_socket.bind(server_address)

现在我们已经将套接字设置在地址 127.0.0.1:8000 上。这意味着客户端将能够使用此地址向我们的服务器发送数据,如果我们向客户端写入数据,它们将看到这是数据来源的地址。

接下来,我们需要积极监听想要连接到我们服务器的客户端的连接。为此,我们可以在我们的套接字上调用 listen 方法。这告诉套接字监听传入的连接,这将允许客户端连接到我们的服务器套接字。然后,我们通过在套接字上调用 accept 方法等待连接。此方法将阻塞,直到我们获得连接,当我们这样做时,它将返回一个连接和连接客户端的地址。连接只是另一个我们可以用来从客户端读取数据和向客户端写入数据的套接字:

server_socket.listen()
connection, client_address = server_socket.accept()

通过这些组件,我们拥有了创建基于套接字的等待连接并打印消息的服务器应用程序所需的所有构建块。

列表 3.1 启动服务器并监听连接

import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    ❶
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

server_address = ('127.0.0.1', 8000)                                 ❷
server_socket.bind(server_address)                                   ❸
server_socket.listen()

connection, client_address = server_socket.accept()                  ❹
print(f'I got a connection from {client_address}!')

❶ 创建一个 TCP 服务器套接字。

❷ 将套接字地址设置为 127.0.0.1:8000。

❸ 监听连接或“开设邮局”。

❹ 等待连接并为客户端分配一个邮政信箱。

在前面的列表中,当客户端连接时,我们获得他们的连接套接字以及他们的地址,并打印出我们已获得连接。

因此,现在我们已经构建了这个应用程序,我们如何连接到它来测试它?虽然有很多工具可以做到这一点,但在本章中,我们将使用 telnet 命令行应用程序。

3.2 使用 Telnet 连接到服务器

我们接受连接的简单示例没有给我们提供连接的方法。有许多命令行应用程序可以读取和写入服务器上的数据,但一个流行且存在已久的应用程序是 Telnet。

Telnet 首次于 1969 年开发,缩写为“远程网络”。Telnet 建立与指定服务器的 TCP 连接。一旦这样做,就会建立一个终端,我们可以自由地发送和接收字节,所有这些都会在终端中显示。

在 Mac OS 上,您可以使用 Homebrew 通过命令 brew install telnet 安装 telnet(有关安装 Homebrew 的信息,请参阅 brew.sh/)。在 Linux 发行版中,您需要使用系统包管理器来安装(例如 apt-get install telnet 或类似命令)。在 Windows 上,PuTTY 是最佳选择,您可以从 putty.org 下载它。

注意:在使用 PuTTY 时,您需要将此书中的代码示例的本地行编辑打开,以便它们能够正常工作。为此,请转到 PuTTY 配置窗口左侧的 Terminal,并将 Local line editing 设置为 Force on

要连接到列表 3.1 中构建的服务器,我们可以在命令行中使用 Telnet 命令并指定我们想要连接到本地主机上的端口 8000:

telnet localhost 8000

一旦这样做,我们将在我们的终端上看到一些输出,告诉我们我们已经成功连接。然后 Telnet 将显示一个光标,这允许我们输入并选择 [Enter] 来向服务器发送数据。

telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.

在我们的服务器应用程序的控制台输出中,我们现在应该看到以下输出,显示我们已经与我们的 Telnet 客户端建立了连接:

I got a connection from ('127.0.0.1', 56526)!

你还会在服务器代码退出时看到一条 Connection closed by foreign host 消息,这表明服务器已经关闭了与我们的客户端的连接。我们现在有了一种连接到服务器并从它那里写入和读取字节的方法,但我们的服务器本身不能读取或发送任何数据。我们可以通过使用客户端套接字的 sendallrecv 方法来实现这一点。

3.2.1 从套接字读取和写入数据

现在我们已经创建了一个能够接受连接的服务器,让我们来探讨如何从我们的连接中读取数据。套接字类有一个名为 recv 的方法,我们可以使用它从特定的套接字获取数据。这个方法接受一个整数,表示我们在给定时间内希望读取的字节数。这是很重要的,因为我们不能一次从套接字中读取所有数据;我们需要缓冲直到达到输入的末尾。

在这种情况下,我们将输入的结束视为回车符加换行符或 '\r\n'。这是当用户在 telnet 中按下 [Enter] 键时附加到输入的内容。为了演示缓冲区如何与短消息一起工作,我们将故意设置一个较低的缓冲区大小。在实际应用中,我们会使用更大的缓冲区大小,例如 1024 字节。我们通常会想要更大的缓冲区大小,因为这将利用操作系统级别的缓冲,这比在应用程序中做更有效率。

列表 3.2 从套接字读取数据

import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()

try:
    connection, client_address = server_socket.accept()
    print(f'I got a connection from {client_address}!')

    buffer = b''

    while buffer[-2:] != b'\r\n':
        data = connection.recv(2)
        if not data:
            break
        else:
            print(f'I got data: {data}!')
            buffer = buffer + data

    print(f"All the data is: {buffer}")
finally:
    server_socket.close()

在前面的列表中,我们像之前一样使用 server_socket.accept 等待连接。一旦我们得到一个连接,我们尝试接收两个字节并将它们存储在我们的缓冲区中。然后,我们进入一个循环,检查每次迭代以查看我们的缓冲区是否以回车符和换行符结尾。如果不是,我们再获取两个字节并打印出我们接收的字节,并将它们追加到缓冲区中。如果我们得到 '\r\n',那么我们结束循环并打印出我们从客户端收到的完整消息。我们还在 finally 块中关闭服务器套接字。这确保了即使在读取数据时发生异常,我们也会关闭连接。如果我们使用 telnet 连接到这个应用程序并发送消息 'testing123',我们会看到以下输出:

I got a connection from ('127.0.0.1', 49721)!
I got data: b'te'!
I got data: b'st'!
I got data: b'in'!
I got data: b'g1'!
I got data: b'23'!
I got data: b'\r\n'!
All the data is: b'testing123\r\n'

现在,我们能够从套接字中读取数据,但我们是怎样将数据写回客户端的呢?套接字有一个名为 sendall 的方法,它将消息写回客户端。我们可以将列表 3.2 中的代码进行适配,通过调用 connection.sendall 并将缓冲区传递给它,来回显客户端发送给我们的消息:

    while buffer[-2:] != b'\r\n':
        data = connection.recv(2)
        if not data:
            break
        else:
            print(f'I got data: {data}!')
            buffer = buffer + data
    print(f"All the data is: {buffer}")
    connection.sendall(buffer)

现在我们连接到这个应用程序并向它发送一个来自 Telnet 的消息,我们应该看到这条消息在我们的 Telnet 终端上打印出来。我们已经使用套接字创建了一个非常基础的回显服务器!

目前这个应用程序一次只处理一个客户端,但多个客户端可以连接到单个服务器套接字。让我们修改这个示例,以允许同时连接多个客户端。在这个过程中,我们将展示我们如何无法正确地使用阻塞套接字来支持多个客户端。

3.2.2 允许多个连接和阻塞的风险

监听模式下的套接字允许同时进行多个客户端连接。这意味着我们可以反复调用 socket.accept,每次客户端连接时,我们都会得到一个新的连接套接字,用于读取和写入数据到该客户端。有了这个知识,我们可以直接将之前的示例修改为处理多个客户端。我们无限循环,调用 socket.accept 来监听新的连接。每次我们得到一个连接,我们就将它追加到迄今为止我们得到的连接列表中。然后,我们遍历每个连接,接收传入的数据,并将这些数据写回客户端连接。

列表 3.3 允许多个客户端连接

import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()

connections = []

try:
    while True:
        connection, client_address = server_socket.accept()
        print(f'I got a connection from {client_address}!')
        connections.append(connection)
        for connection in connections:
            buffer = b''

            while buffer[-2:] != b'\r\n':
                data = connection.recv(2)
                if not data:
                    break
                else:
                    print(f'I got data: {data}!')
                    buffer = buffer + data

            print(f"All the data is: {buffer}")

            connection.send(buffer)
finally:
    server_socket.close()

我们可以通过使用 telnet 建立一个连接并输入一条消息来尝试这个。然后,一旦我们这样做,我们就可以使用第二个 telnet 客户端并发送另一条消息。然而,如果我们这样做,我们马上就会注意到一个问题。我们的第一个客户端将正常工作,并回显我们预期的消息,但第二个客户端不会接收到任何回显。这是由于套接字的默认阻塞行为。acceptrecv 方法会在接收到数据之前阻塞。这意味着一旦第一个客户端连接,我们就会阻塞等待它发送它的第一个回显消息给我们。这导致其他客户端陷入等待下一次循环迭代的困境,而这只有在第一个客户端发送数据给我们之后才会发生(图 3.2)。

03-02

图 3.2 在阻塞套接字的情况下,客户端 1 连接,但客户端 2 被阻塞,直到客户端 1 发送数据。

这显然不是一个令人满意的用户体验;我们创建了一个在用户超过一个时无法正确扩展的东西。我们可以通过将我们的套接字设置为非阻塞模式来解决这个问题。当我们标记套接字为非阻塞时,其方法在移动到执行下一行代码之前不会阻塞等待接收数据。

3.3 使用非阻塞套接字

我们之前的回声服务器允许多个客户端连接;然而,当连接超过一个时,我们遇到了一个问题,其中一个客户端可能会使其他客户端等待它发送数据。我们可以通过将套接字置于非阻塞模式来解决此问题。当我们这样做时,任何会阻塞的方法调用,如recv,都会立即返回。如果套接字有数据准备好处理,那么我们将像在阻塞套接字中一样得到数据返回。如果没有,套接字会立即通知我们它没有准备好任何数据,我们可以自由地继续执行其他代码。

列表 3.4 创建非阻塞套接字

import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('127.0.0.1', 8000))
server_socket.listen()
server_socket.setblocking(False)

基本上,创建一个非阻塞套接字与创建一个阻塞套接字没有区别,只是我们必须使用setblocking方法将False作为参数。默认情况下,套接字将具有此值设置为True,表示它是阻塞的。现在让我们看看我们在原始应用程序中这样做会发生什么。这能解决问题吗?

列表 3.5 非阻塞服务器的第一次尝试

import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()
server_socket.setblocking(False)                             ❶

connections = []

try:
    while True:
        connection, client_address = server_socket.accept()
        connection.setblocking(False)                        ❷
        print(f'I got a connection from {client_address}!')
        connections.append(connection)

        for connection in connections:
            buffer = b''

            while buffer[-2:] != b'\r\n':
                data = connection.recv(2)
                if not data:
                    break
                else:
                    print(f'I got data: {data}!')
                    buffer = buffer + data

            print(f"All the data is: {buffer}")
            connection.send(buffer)
finally:
    server_socket.close()

❶ 将服务器套接字标记为非阻塞。

❷ 将客户端套接字标记为非阻塞。

当我们运行列表 3.5 时,我们会立即注意到一些不同。我们的应用程序几乎立即崩溃!我们会收到一个BlockingIOError异常,因为我们的服务器套接字还没有连接,因此没有数据可以处理:

Traceback (most recent call last):
  File "echo_server.py", line 14, in <module>
    connection, client_address = server_socket.accept()
  File " python3.8/socket.py", line 292, in accept
    fd, addr = self._accept()
BlockingIOError: [Errno 35] Resource temporarily unavailable

这是一种套接字以某种不太直观的方式告诉我们,“我没有数据,稍后再调用我。”我们没有简单的方法来判断套接字是否现在有数据,所以一种解决方案是简单地捕获异常,忽略它,并继续循环,直到我们有数据。使用这种策略,我们将不断地尽可能快地检查新的连接和数据。这应该解决我们的阻塞套接字回声服务器存在的问题。

列表 3.6 捕获并忽略阻塞 IO 错误

import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()
server_socket.setblocking(False)

connections = []

try:
    while True:
        try:
            connection, client_address = server_socket.accept()
            connection.setblocking(False)
            print(f'I got a connection from {client_address}!')
            connections.append(connection)
        except BlockingIOError:
            pass

        for connection in connections:
            try:
                buffer = b''

                while buffer[-2:] != b'\r\n':
                    data = connection.recv(2)
                    if not data:
                        break
                    else:
                        print(f'I got data: {data}!')
                        buffer = buffer + data

                print(f"All the data is: {buffer}")
                connection.send(buffer)
            except BlockingIOError:
                pass

finally:
    server_socket.close()

每当我们通过无限循环的一次迭代,我们的acceptrecv调用都不会阻塞,我们要么立即抛出一个我们忽略的异常,要么有数据准备好处理,然后我们处理它。这个循环的每次迭代都很快,我们永远不会依赖于任何人发送数据来执行下一行代码。这解决了我们的阻塞服务器问题,并允许多个客户端并发连接和发送数据。

这种方法有效,但代价是代码质量。在我们可能还没有数据的情况下捕获异常会很快变得冗长,并且可能存在错误风险。第二个问题是资源问题。如果你在笔记本电脑上运行这个程序,你可能会注意到几秒钟后风扇开始变得更响。这个应用程序将始终使用我们 CPU 处理能力的近 100%(图 3.3)。这是因为我们一直在循环中尽可能快地捕获异常,导致了一个 CPU 密集型的工作负载。

03-03

图 3.3 当循环捕获异常时,CPU 使用率会飙升到 100%并保持在那里。

之前,我们提到了操作系统特定的通知系统,这些系统可以在套接字有我们可以操作的数据时通知我们。这些系统依赖于硬件级别的通知,并且不涉及我们刚才所做的轮询,即使用 while 循环。Python 内置了一个用于使用此事件通知系统的库。接下来,我们将使用这个库来解决我们的 CPU 利用率问题,并为套接字事件构建一个迷你事件循环。

3.4 使用 selectors 模块构建套接字事件循环

操作系统内置了高效的 API,允许我们监视套接字以获取传入数据和其它事件。虽然实际的 API 依赖于操作系统(如 kqueue、epoll 和 IOCP 是一些常见的例子),但所有这些 I/O 通知系统都基于相似的概念。我们向它们提供一个我们想要监视事件的套接字列表,操作系统会明确地告诉我们套接字何时有数据,而不是不断地检查每个套接字是否有数据。

因为这是在硬件级别实现的,所以在监控期间使用的 CPU 利用率非常低,从而允许高效地使用资源。这些通知系统是 asyncio 实现并发性的核心。了解它是如何工作的,让我们看到了 asyncio 的底层机制是如何运作的。

事件通知系统根据操作系统而有所不同。幸运的是,Python 的 selectors 模块进行了抽象,这样我们就可以在代码运行的任何地方获取适当的事件。这使得我们的代码可以在不同的操作系统之间移植。

这个库公开了一个名为 BaseSelector 的抽象基类,它为每个事件通知系统提供了多个实现。它还包含一个 DefaultSelector 类,该类会自动选择对我们系统最有效的实现。

BaseSelector 类包含重要的概念。第一个是 注册。当我们对获取通知的套接字感兴趣时,我们将它注册到选择器中,并告诉它我们感兴趣的事件。这些事件包括读取和写入。相反,我们也可以注销不再感兴趣的套接字。

第二个主要概念是 选择select 将会阻塞,直到发生一个事件,一旦发生,调用将返回一个列表,其中包含准备好处理的事件套接字以及触发该事件的那个事件。它还支持超时,在指定时间后返回一个空的事件集。

给定这些构建块,我们可以创建一个非阻塞的回声服务器,它不会对我们的 CPU 造成压力。一旦我们创建了服务器套接字,我们将将其注册到默认选择器,以便监听来自客户端的任何连接。然后,每当有人连接到我们的服务器套接字时,我们将客户端的连接套接字注册到选择器,以便监视任何发送的数据。如果我们从不是服务器套接字的套接字接收到数据,我们知道它来自已发送数据的客户端。然后我们接收这些数据并将其写回客户端。我们还将添加一个超时来演示,在我们等待事情发生的同时,我们可以执行其他代码。

列表 3.7 使用选择器构建非阻塞服务器

import selectors
import socket
from selectors import SelectorKey
from typing import List, Tuple

selector = selectors.DefaultSelector()

server_socket = socket.socket()
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

server_address = ('127.0.0.1', 8000)
server_socket.setblocking(False)
server_socket.bind(server_address)
server_socket.listen()

selector.register(server_socket, selectors.EVENT_READ)

while True:
    events: List[Tuple[SelectorKey, int]] = selector.select(timeout=1)  ❶

    if len(events) == 0:                                                ❷
        print('No events, waiting a bit more!')

    for event, _ in events:
        event_socket = event.fileobj                                    ❸

        if event_socket == server_socket:                               ❹
            connection, address = server_socket.accept()
            connection.setblocking(False)
            print(f"I got a connection from {address}")
            selector.register(connection, selectors.EVENT_READ)         ❺
        else:
            data = event_socket.recv(1024)                              ❻
            print(f"I got some data: {data}")
            event_socket.send(data)

❶ 创建一个在 1 秒后超时的选择器。

❷ 如果没有事件发生,则打印出来。这发生在超时发生时。

❸ 获取事件套接字,它存储在 fileobj 字段中。

❹ 如果事件套接字与服务器套接字相同,我们知道这是一个连接尝试。

❺ 使用我们的选择器注册已连接的客户端。

❻ 如果事件套接字不是服务器套接字,则从客户端接收数据并将其回显。

当我们运行列表 3.7 时,除非我们得到连接事件,否则大约每秒会打印出“没有事件,再等一会儿!”除非我们得到连接事件。一旦我们得到连接,我们将该连接注册为监听读取事件。然后,如果客户端发送数据给我们,我们的选择器将返回一个事件,表示我们有数据准备好,我们可以使用socket.recv读取它。

这是一个完全功能的回声服务器,支持多个客户端。这个服务器没有阻塞问题,因为我们只有在有数据要处理时才会读取或写入数据。它还非常节省 CPU 资源,因为我们正在使用操作系统的有效事件通知系统(图 3.4)。

03-04

图 3.4 使用选择器的回声服务器 CPU 图。使用此方法时,利用率在 0 和 1%之间徘徊。

我们所构建的类似于 asyncio 事件循环在底层所做的大部分工作。在这种情况下,重要的事件是套接字接收数据。我们的事件循环和 asyncio 事件循环的每次迭代都是由套接字事件发生或超时触发循环迭代的。在 asyncio 事件循环中,当这两者中的任何一个发生时,等待运行的协程将执行,直到它们完成或遇到下一个await语句。当我们在一个使用非阻塞套接字的协程中遇到await时,它将注册该套接字到系统的选择器,并跟踪协程暂停等待结果。我们可以将此概念转换为伪代码:

paused = []
ready = []

while True:
    paused, new_sockets = run_ready_tasks(ready)
selector.register(new_sockets)
    timeout = calculate_timeout()
    events = selector.select(timeout)
    ready = process_events(events)

我们运行任何准备就绪的协程,直到它们在 await 语句上暂停,并将它们存储在 paused 数组中。我们还跟踪任何需要从运行这些协程中监视的新套接字,并将它们注册到选择器中。然后我们计算调用 select 时所需的超时时间。虽然这个超时计算有些复杂,但它通常是在查看我们计划在特定时间或特定持续时间运行的计划任务。一个例子是 asyncio.sleep。然后我们调用 select 并等待任何套接字事件或超时。一旦发生其中之一,我们就处理这些事件,并将它们转换成可以运行的协程列表。

我们构建的事件循环仅用于套接字事件,但它展示了使用选择器注册我们关心的套接字的主要概念,只有在需要处理的事情发生时才会被唤醒。我们将在本书的末尾更深入地了解如何构建自定义事件循环。

现在,我们已经理解了使 asyncio 运转的大部分机制。然而,如果我们仅仅使用选择器来构建我们的应用程序,我们就可能需要实现自己的事件循环来实现与 asyncio 提供的相同的功能。为了了解如何使用 asyncio 实现这一点,让我们将我们所学的内容翻译成 async / await 代码,并使用为我们已经实现的事件循环。

3.5 在 asyncio 事件循环上的回声服务器

select 一起工作对于大多数应用程序来说太低级了。我们可能希望在等待套接字数据到来时,让代码在后台运行,或者我们可能希望在计划中运行后台任务。如果我们只使用选择器来做这件事,我们很可能会构建自己的事件循环,而 asyncio 已经提供了一个现成的、很好地实现的事件循环可供使用。此外,协程和任务在选择器之上提供了抽象,这使得我们的代码更容易实现和维护,因为我们根本不需要考虑选择器。

现在我们对 asyncio 事件循环的工作原理有了更深入的了解,让我们再次构建我们在上一节中构建的回声服务器,这次我们将使用协程和任务来构建它。我们仍然会使用低级套接字来完成这项工作,但我们将使用基于 asyncio 的 API 来管理它们,这些 API 返回协程。我们还将向我们的回声服务器添加一些更多功能,以展示几个关键概念,以说明 asyncio 的工作方式。

3.5.1 套接字事件循环协程

由于套接字是一个相对低级的概念,处理它们的方法就在 asyncio 的事件循环本身。我们将想要与之合作的三个主要协程是:sock_acceptsock_recvsock_sendall。这些方法与我们在之前使用的套接字方法类似,但它们接受一个套接字作为参数,并返回我们可以 await 直到有数据可以处理的协程。

让我们从 sock_accept 开始。这个协程类似于我们在第一次实现中看到的 socket.accept 方法。此方法将返回一个包含套接字连接和客户端地址的元组(一个存储有序值序列的数据结构)。我们传入我们感兴趣的套接字,然后我们可以 await 返回的协程。一旦该协程完成,我们就会得到我们的连接和地址。这个套接字必须是非阻塞的,并且应该已经绑定到一个端口:

connection, address = await loop.sock_accept(socket)

sock_recvsock_sendall 的调用方式类似于 sock_accept。它们接受一个套接字,然后我们可以 await 结果。sock_recvawait 直到套接字有我们可以处理的数据。sock_sendall 接受一个套接字和我们想要发送的数据,并将等待直到所有我们想要发送到套接字的数据都已发送,并在成功时返回 None

data = await loop.sock_recv(socket)
success = await loop.sock_sendall(socket, data)

使用这些构建块,我们将能够将我们之前的方法转换为使用协程和任务的方法。

3.5.2 设计 asyncio 回显服务器

在第二章中,我们介绍了协程和任务。那么我们应该在何时只使用协程,在何时将协程包装在任务中以用于我们的回显服务器?让我们检查我们希望我们的应用程序如何行为,以便做出这个决定。

让我们从我们希望在应用程序中监听连接的方式开始。当我们正在监听连接时,我们一次只能处理一个连接,因为 socket.accept 只会给我们一个客户端连接。在幕后,如果我们在同一时间收到多个连接,传入的连接将被存储在一个称为 backlog 的队列中,但在这里,我们不会深入探讨它是如何工作的。

由于我们不需要同时处理多个连接,一个无限循环的单个协程是有意义的。这将允许我们在等待连接时,其他代码可以并发运行。我们将定义一个名为 listen_for_connections 的协程,它将无限循环并监听任何传入的连接:

async def listen_for_connections(server_socket: socket,
                                 loop: AbstractEventLoop):
    while True:
        connection, address = await loop.sock_accept(server_socket)
        connection.setblocking(False)
        print(f"Got a connection from {address}")

现在我们已经有了用于监听连接的协程,那么如何读取和写入已连接客户端的数据呢?这应该是一个协程,还是我们将其包装在任务中的协程?在这种情况下,我们将有多个连接,每个连接都可能随时向我们发送数据。我们不希望等待一个连接的数据而阻塞另一个连接,因此我们需要从多个客户端并发地读取和写入数据。由于我们需要同时处理多个连接,为每个连接创建一个读取和写入数据的任务是有意义的。对于每个我们获得的连接,我们将创建一个任务来从该连接读取数据并写入数据。

我们将创建一个名为 echo 的协程,该协程负责处理连接的数据。这个协程将无限循环地监听来自客户端的数据。一旦它接收到数据,它就会将其发送回客户端。

然后,在listen_for_connections中,我们将为每个接收到的连接创建一个新的任务,该任务封装了我们的echo协程。有了这两个协程定义,我们现在拥有了构建异步 io 回显服务器所需的一切。

列表 3.8 构建异步 io 回显服务器

import asyncio
import socket
from asyncio import AbstractEventLoop
async def echo(connection: socket,
               loop: AbstractEventLoop) -> None:
    while data := await loop.sock_recv(connection, 1024):                ❶
        await loop.sock_sendall(connection, data)                        ❷

async def listen_for_connection(server_socket: socket,
                                loop: AbstractEventLoop):
    while True:
        connection, address = await loop.sock_accept(server_socket)
        connection.setblocking(False)
        print(f"Got a connection from {address}")
        asyncio.create_task(echo(connection, loop))                      ❸

async def main():
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    server_address = ('127.0.0.1', 8000)
    server_socket.setblocking(False)
    server_socket.bind(server_address)
    server_socket.listen()

    await listen_for_connection(server_socket, asyncio.get_event_loop()) ❹

asyncio.run(main())

❶ 无限循环等待客户端连接的数据

❷ 一旦我们有数据,就将其发送回那个客户端。

❸ 每当我们得到一个连接时,创建一个回显任务来监听客户端数据。

❹ 启动协程以监听连接。

前面的代码架构看起来像图 3.5。我们有一个协程listen_for_connection在监听连接。一旦客户端连接,我们的协程为每个客户端产生一个echo任务,然后监听数据并将其写回客户端。

03-05

图 3.5 协程监听连接为每个接收到的连接产生一个任务。

当我们运行这个应用程序时,我们将能够同时连接多个客户端并发地向它们发送数据。在底层,这所有的一切都是使用我们在之前看到的选择器,所以我们的 CPU 利用率保持很低。

我们现在已经使用 asyncio 完全构建了一个功能齐全的回显服务器!那么我们的实现是否没有错误?结果证明,我们设计这个回显服务器的方式在回显任务失败时确实存在一个问题,我们需要处理。

3.5.3 处理任务中的错误

网络连接通常不可靠,我们可能在应用程序代码中遇到我们不期望的异常。如果读取或写入客户端失败并抛出异常,我们的应用程序会如何表现?为了测试这一点,让我们改变echo的实现,当客户端传递给我们一个特定的关键字时抛出异常:

async def echo(connection: socket,
               loop: AbstractEventLoop) -> None:
    while data := await loop.sock_recv(connection, 1024):
        if data == b'boom\r\n':
            raise Exception("Unexpected network error")
        await loop.sock_sendall(connection, data)

现在,每当一个客户端发送“boom”给我们时,我们将抛出一个异常,我们的任务将会崩溃。那么,当我们把一个客户端连接到我们的服务器并发送这条消息时会发生什么?我们会看到一个带有如下警告的回溯:

Task exception was never retrieved
future: <Task finished name='Task-2' coro=<echo() done, defined at asyncio_echo.py:5> exception=Exception('Unexpected network error')>
Traceback (most recent call last):
  File "asyncio_echo.py", line 9, in echo
    raise Exception("Unexpected network error")
Exception: Unexpected network error

这里重要的是Task exception was never retrieved。这意味着什么?当一个任务内部抛出异常时,该任务被认为已经完成,其结果是一个异常。这意味着不会向上抛出异常。此外,我们这里没有清理。如果这个异常被抛出,我们无法对任务失败做出反应,因为我们从未检索到异常。

要让异常传达到我们这里,我们必须在await表达式中使用任务。当我们等待一个失败的任务时,异常将在我们执行await的地方被抛出,并且回溯将反映这一点。如果我们不在应用程序的某个地方等待任务,我们就有可能永远看不到任务抛出的异常。虽然我们在示例中看到了异常输出,这可能会让我们认为这不是一个大问题,但我们可以以微妙的方式改变我们的应用程序,以至于我们永远不会看到这条消息。

作为这个的演示,让我们假设,我们不是忽略在listen_for_connections中创建的回显任务,而是像这样在列表中跟踪它们:

tasks = []

async def listen_for_connection(server_socket: socket,
                                loop: AbstractEventLoop):
    while True:
        connection, address = await loop.sock_accept(server_socket)
        connection.setblocking(False)
        print(f"Got a connection from {address}")
        tasks.append(asyncio.create_task(echo(connection, loop)))

人们会期望这会像以前一样表现。如果我们发送“boom”消息,我们会看到异常被打印出来,同时还会有一条警告说我们从未检索到任务异常。然而,情况并非如此,因为我们实际上什么都不会打印出来,直到我们强制终止我们的应用程序!

这是因为我们保留了对任务的引用。asyncio 只能在任务被垃圾回收时打印出失败任务的这条消息和跟踪信息。这是因为它没有方法来判断该任务是否会在应用程序的某个其他点被等待,从而引发异常。由于这些复杂性,我们可能需要await我们的任务或处理所有任务可能抛出的异常。那么,我们如何在我们的回显服务器中做到这一点呢?

我们可以做的第一件事是在我们的回显协程代码中包裹一个try/catch语句,记录异常,并关闭连接:

import logging

async def echo(connection: socket,
               loop: AbstractEventLoop) -> None:
    try:
        while data := await loop.sock_recv(connection, 1024):
            print('got data!')
            if data == b'boom\r\n':
                raise Exception("Unexpected network error")
            await loop.sock_sendall(connection, data)
    except Exception as ex:
        logging.exception(ex)
    finally:
        connection.close()

这将解决由异常引起的即时问题,我们的服务器会抱怨任务异常从未被检索,因为我们已经在协程中处理了它。它还会在finally块中正确关闭套接字,这样在失败的情况下我们就不会留下悬而未决的未关闭异常。

重要的是要注意,这个实现将在应用程序关闭时正确关闭我们打开的任何客户端连接。为什么是这样?在第二章中,我们注意到asyncio.run将在我们的应用程序关闭时取消我们剩余的所有任务。我们还了解到,当我们取消一个任务时,每次我们尝试await它时都会抛出一个CancelledError

这里重要的是要注意异常被抛出的位置。如果我们的任务正在等待一个如await loop.sock_recv这样的语句,并且我们取消了这个任务,那么await loop.sock_recv行会抛出一个CancelledError。这意味着在上面的情况下,我们的finally块将被执行,因为我们取消任务时在await表达式中抛出了异常。如果我们将异常块改为捕获并记录这些异常,你将看到每个创建的任务都会有一个CancelledError

我们现在已经处理了当我们的回显任务失败时处理错误的问题。如果我们想在应用程序关闭时提供任何错误或剩余任务的清理,该怎么办?我们可以使用 asyncio 的信号处理器来完成这个任务。

3.6 优雅地关闭

现在,我们已经创建了一个可以处理多个并发连接并正确记录错误、在发生异常时清理的回声服务器。如果我们需要关闭我们的应用程序会发生什么?如果我们能在关闭之前允许所有正在传输的消息完成,那岂不是很好?我们可以通过向应用程序中添加自定义关闭逻辑来实现这一点,这样任何正在进行的任务就有几秒钟的时间来发送它们可能想要发送的消息。虽然这不会是一个适合生产的实现,但我们将学习关于关闭以及取消所有正在运行的异步应用程序任务的概念。

Windows 上的信号

Windows 不支持信号。因此,本节仅适用于基于 Unix 的系统。Windows 使用不同的系统来处理这个问题,在撰写本书时,它不与 Python 兼容。要了解更多关于如何使此代码以跨平台方式工作的信息,请参阅以下 Stack Overflow 上的答案: stackoverflow.com/questions/35772001

3.6.1 监听信号

信号是类 Unix 操作系统中的一个概念,用于异步通知进程在操作系统级别发生的事件。虽然这听起来非常底层,但你可能熟悉一些信号。例如,一个常见的信号是 SIGINT,代表 信号中断。当你按下 CTRL-C 来终止命令行应用程序时,它会触发。在 Python 中,我们通常可以通过捕获 KeyboardInterrupt 异常来处理它。另一个常见的信号是 SIGTERM,代表 信号终止。当我们运行 kill 命令来停止特定进程的执行时,它会触发。

要实现自定义关闭逻辑,我们将在应用程序中为 SIGINT 和 SIGTERM 信号实现监听器。然后,在这些监听器中,我们将实现逻辑,允许我们拥有的任何回声任务有几分钟的时间来完成。

我们如何在应用程序中监听信号?asyncio 事件循环允许我们通过 add_signal_handler 方法直接监听任何指定的事件。这与使用 signal.signal 函数在信号模块中设置的信号处理程序不同,因为 add_signal_handler 可以安全地与事件循环交互。此函数接受我们想要监听的信号以及当我们的应用程序接收到该信号时我们将调用的函数。为了演示这一点,让我们看看如何添加一个取消所有当前运行任务的信号处理程序。asyncio 有一个便利函数,它返回所有正在运行的任务集合,名为 asyncio.all_tasks

列表 3.9 添加信号处理程序以取消所有任务

import asyncio, signal
from asyncio import AbstractEventLoop
from typing import Set

from util.delay_functions import delay

def cancel_tasks():
    print('Got a SIGINT!')
    tasks: Set[asyncio.Task] = asyncio.all_tasks()
    print(f'Cancelling {len(tasks)} task(s).')
    [task.cancel() for task in tasks]

async def main():
    loop: AbstractEventLoop = asyncio.get_running_loop()

    loop.add_signal_handler(signal.SIGINT, cancel_tasks)

    await delay(10)

asyncio.run(main())

当我们运行这个应用程序时,我们会看到我们的延迟协程立即开始并等待 10 秒。如果我们在这 10 秒内按下 CTRL-C,我们应该会看到打印出 got a SIGINT!,然后是一个取消任务的消息。我们还应该看到从 asyncio.run(main()) 抛出的 CancelledError,因为我们已经取消了那个任务。

3.6.2 等待挂起任务完成

在原始问题说明中,我们希望在我们关闭之前给我们的回声服务器回声任务一些时间继续运行。我们做到这一点的办法是将所有回声任务包裹在 wait_for 中,然后 await 这些包裹的任务。这些任务将在超时后抛出 TimeoutError,然后我们可以终止我们的应用程序。

你会注意到我们的关闭处理程序是一个正常的 Python 函数,所以我们不能在其中运行任何 await 语句。这对我们来说是个问题,因为我们的解决方案涉及 await。一个可能的解决方案是创建一个执行关闭逻辑的协程,然后在我们的关闭处理程序中将其包裹在任务中:

async def await_all_tasks():
    tasks = asyncio.all_tasks()
    [await task for task in tasks]

async def main():
    loop = asyncio.get_event_loop()
    loop.add_signal_handler(signal.SIGINT,
                            lambda: asyncio.create_task(await_all_tasks()))

这种方法可以工作,但缺点是如果在 await_all_tasks 中发生异常,我们将留下一个失败的孤儿任务和一个“异常从未检索”的警告。那么,有没有更好的方法来做这件事呢?

我们可以通过抛出一个自定义异常来停止我们的主协程运行来处理这个问题。然后,当我们运行主协程时,我们可以捕获这个异常并运行任何关闭逻辑。为此,我们需要自己创建一个事件循环而不是使用 asyncio.run。这是因为当发生异常时,asyncio.run 将取消所有正在运行的任务,这意味着我们无法将我们的回声任务包裹在 wait_for 中:

class GracefulExit(SystemExit):
    pass

def shutdown():
    raise GracefulExit()

loop = asyncio.get_event_loop()

loop.add_signal_handler(signal.SIGINT, shutdown)

try:
    loop.run_until_complete(main())
except GracefulExit:
    loop.run_until_complete(close_echo_tasks(echo_tasks))
finally:
    loop.close()

考虑到这种方法,让我们编写我们的关闭逻辑:

async def close_echo_tasks(echo_tasks: List[asyncio.Task]):
    waiters = [asyncio.wait_for(task, 2) for task in echo_tasks]
    for task in waiters:
        try:
            await task
        except asyncio.exceptions.TimeoutError:
            # We expect a timeout error here
            pass

close_echo_tasks 中,我们取一个回声任务列表,并将它们全部包裹在一个 2 秒超时的 wait_for 任务中。这意味着任何回声任务都将有 2 秒的时间完成,然后我们取消它们。一旦我们这样做,我们就遍历所有这些包裹的任务并 await 它们。我们捕获任何 TimeoutErrors,因为我们预计在 2 秒后我们的任务会抛出这个错误。将这些部分综合起来,我们的带关闭逻辑的回声服务器看起来如下所示。

列表 3.10 优雅的关闭

import asyncio
from asyncio import AbstractEventLoop
import socket
import logging
import signal
from typing import List

async def echo(connection: socket,
               loop: AbstractEventLoop) -> None:
    try:
        while data := await loop.sock_recv(connection, 1024):
            print('got data!')
            if data == b'boom\r\n':
                raise Exception("Unexpected network error")
            await loop.sock_sendall(connection, data)
    except Exception as ex:
        logging.exception(ex)
    finally:
        connection.close()

echo_tasks = []

async def connection_listener(server_socket, loop):
    while True:
        connection, address = await loop.sock_accept(server_socket)
        connection.setblocking(False)
        print(f"Got a connection from {address}")
        echo_task = asyncio.create_task(echo(connection, loop))
        echo_tasks.append(echo_task)

class GracefulExit(SystemExit):
    pass

def shutdown():
    raise GracefulExit()

async def close_echo_tasks(echo_tasks: List[asyncio.Task]):
    waiters = [asyncio.wait_for(task, 2) for task in echo_tasks]
    for task in waiters:
        try:
            await task
        except asyncio.exceptions.TimeoutError:
            # We expect a timeout error here
            pass

async def main():
    server_socket = socket.socket()
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    server_address = ('127.0.0.1', 8000)
    server_socket.setblocking(False)
    server_socket.bind(server_address)
    server_socket.listen()

    for signame in {'SIGINT', 'SIGTERM'}:
        loop.add_signal_handler(getattr(signal, signame), shutdown)
    await connection_listener(server_socket, loop)

loop = asyncio.new_event_loop()

try:
    loop.run_until_complete(main())
except GracefulExit:
    loop.run_until_complete(close_echo_tasks(echo_tasks))
finally:
    loop.close()

假设我们至少有一个客户端连接,如果我们使用 CTRL-C 停止这个应用程序,或者向我们的进程发出 kill 命令,我们的关闭逻辑将执行。我们会看到应用程序等待 2 秒,在它停止运行之前允许我们的回声任务有时间完成。

有几个原因说明这不是一个值得在生产环境中使用的关闭方式。第一个原因是我们在等待我们的回声任务完成时没有关闭我们的连接监听器。这意味着,当我们关闭时,一个新的连接可能会进来,然后我们就无法添加 2 秒的关闭时间。另一个问题是,在我们的关闭逻辑中,我们等待每个关闭的回声任务,并且只捕获TimeoutExceptions。这意味着如果我们的任务抛出了其他类型的异常,我们会捕获那个异常,并且任何可能出现的后续任务中的异常都将被忽略。在第四章中,我们将看到一些 asyncio 方法,用于更优雅地处理一组 awaitables 的失败。

虽然我们的应用程序并不完美,只是一个玩具示例,但我们已经使用 asyncio 构建了一个完全功能的服务器。这个服务器可以同时处理许多用户——所有这些都在一个单独的线程中完成。使用我们之前看到的阻塞方法,我们需要转向多线程来处理多个客户端,这增加了应用程序的复杂性并增加了资源利用率。

摘要

在本章中,我们学习了阻塞和非阻塞套接字,并更深入地探讨了 asyncio 事件循环的功能。我们还制作了我们的第一个 asyncio 应用程序,一个高度并发的回声服务器。我们检查了如何在任务中处理错误,并在我们的应用程序中添加自定义关闭逻辑。

  • 我们已经学习了如何使用阻塞套接字创建简单的应用程序。阻塞套接字在等待数据时会停止整个线程。这阻止了我们实现并发,因为我们一次只能从一位客户端获取数据。

  • 我们已经学习了如何使用非阻塞套接字构建应用程序。这些套接字总是会立即返回,要么是因为我们有数据,要么是因为一个异常表明我们没有数据。这些套接字使我们能够实现并发,因为它们的方 法永远不会阻塞并立即返回。

  • 我们已经学习了如何使用 selectors 模块以高效的方式监听套接字上的事件。这个库允许我们注册我们想要跟踪的套接字,并且会告诉我们当非阻塞套接字准备好数据时。

  • 如果我们将 select 放入一个无限循环中,我们就复制了 asyncio 事件循环的核心功能。我们注册了我们感兴趣的套接字,并且无限循环,一旦套接字有数据可供操作,就运行任何我们想要的代码。

  • 我们学习了如何使用 asyncio 的事件循环方法来构建使用非阻塞套接字的应用程序。这些方法接收一个套接字并返回一个协程,然后我们可以将其用于await表达式。这将挂起我们的父协程,直到套接字有数据。在底层,这是使用 selectors 库。

  • 我们已经看到了如何使用任务来实现基于 asyncio 的回声服务器在多个客户端同时发送和接收数据时的并发性。我们还检查了如何处理这些任务中的错误。

  • 我们已经学会了如何为 asyncio 应用程序添加自定义的关闭逻辑。在我们的例子中,我们决定当我们的服务器关闭时,我们会给它几秒钟的时间,让任何剩余的客户完成数据发送。利用这个知识,我们可以在应用程序关闭时添加任何需要的逻辑。

4 并发网络请求

本章涵盖

  • 异步上下文管理器

  • 使用 aiohttp 进行异步网络请求

  • 使用gather并发运行网络请求

  • 使用as completed处理结果

  • 使用wait跟踪进行中的请求

  • 为请求组设置和处理超时以及取消请求

在第三章,我们学习了套接字的内部工作原理,并构建了一个基本的回声服务器。现在我们已经看到了如何设计一个基本的应用程序,我们将运用这些知识来制作并发、非阻塞的网络请求。利用 asyncio 进行网络请求允许我们同时进行数百次请求,与同步方法相比,可以减少应用程序的运行时间。这在我们必须对一组 REST API 进行多次请求时很有用,例如在微服务架构中或当我们有网络爬虫任务时。这种方法还允许在我们等待可能很长的网络请求完成时运行其他代码,使我们能够构建更响应式的应用程序。

在本章中,我们将学习一个名为aiohttp的异步库,它使得这一点成为可能。这个库使用非阻塞套接字进行网络请求,并为这些请求返回协程,然后我们可以await以获取结果。具体来说,我们将学习如何获取我们想要获取内容的数百个 URL 列表,并并发运行所有这些请求。在这样做的时候,我们将检查 asyncio 提供的各种 API 方法,这些方法可以在一次运行多个协程,允许我们在等待所有内容完成后再继续,或者处理结果尽可能快。此外,我们还将了解如何为这些请求设置超时,包括在单个请求级别以及请求组级别。我们还将看到如何根据其他请求的表现取消一组正在进行的请求。这些 API 方法不仅适用于制作网络请求,而且在我们需要并发运行一组协程或任务时也很有用。实际上,我们将在这里使用的函数贯穿整本书,作为 asyncio 开发者,你将广泛使用它们。

4.1 介绍 aiohttp

在第二章中,我们提到,初学者在使用 asyncio 时面临的一个问题是尝试将现有的代码与asyncawait结合,希望获得性能提升。在大多数情况下,这不会奏效,尤其是在处理网络请求时,因为大多数现有的库都是阻塞的。

制作网络请求的一个流行库是requests库。这个库与 asyncio 配合不佳,因为它使用阻塞套接字。这意味着如果我们发起一个请求,它将阻塞运行它的线程,由于 asyncio 是单线程的,我们的整个事件循环将暂停,直到该请求完成。

为了解决这个问题并获得并发性,我们需要使用一个库,该库在套接字层也是非阻塞的。aiohttp(异步 HTTP 客户端/服务器,用于 asyncio 和 Python)是解决这个问题的库之一,它使用非阻塞套接字。

aiohttp 是一个开源库,它是 aio-libs 项目的一部分,该项目自称为“一套使用高质量构建的基于 asyncio 的库”(见 github.com/aio-libs)。这个库是一个功能齐全的 Web 客户端,也是一个 Web 服务器,这意味着它可以发起 Web 请求,开发者可以使用它来创建异步 Web 服务器。(该库的文档可在 docs.aiohttp.org/ 找到。)在本章中,我们将关注 aiohttp 的客户端部分,但稍后我们将在书中看到如何使用它来构建 Web 服务器。

那么,我们如何开始使用 aiohttp 呢?首先需要学习的是如何发起一个 HTTP 请求。我们首先需要了解一些新的异步上下文管理器的语法。使用这种语法可以使我们干净地获取和关闭 HTTP 会话。作为一个 asyncio 开发者,你将频繁地使用这种语法来异步获取资源,例如数据库连接。

4.2 异步上下文管理器

在任何编程语言中,处理必须先打开后关闭的资源,如文件,是常见的。在处理这些资源时,我们需要小心可能抛出的任何异常。这是因为如果我们打开了一个资源并且抛出了异常,我们可能永远不会执行任何清理代码,导致资源泄漏。在 Python 中,使用 finally 块处理这个问题很简单。尽管这个例子并不完全符合 Python 风格,我们即使在抛出异常的情况下也可以关闭文件:

file = open('example.txt')

try:
    lines = file.readlines()
finally:
    file.close()

这解决了在 file.readlines 期间发生异常时文件句柄被留下打开的问题。缺点是我们必须记住将所有内容包裹在 try finally 中,并且我们还需要记住调用以正确关闭我们的资源的方法。对于文件来说,这并不太难做,因为我们只需要记住关闭它们,但我们仍然希望有更可重用的东西,尤其是我们的清理可能比仅仅调用一个方法更复杂。Python 有一个处理这种问题的语言特性,称为 上下文管理器。使用它,我们可以将关闭逻辑与 try/finally 块一起抽象化:

with open(‘example.txt’) as file:
    lines = file.readlines()

这种 Pythonic 的文件管理方式要干净得多。如果在 with 块中抛出异常,我们的文件将自动关闭。这对于同步资源有效,但如果我们想使用这种语法异步地使用资源呢?在这种情况下,上下文管理器语法将不起作用,因为它设计用于仅与同步 Python 代码一起工作,而不是协程和任务。Python 引入了一个新的语言特性来支持这种用例,称为 异步上下文管理器。语法几乎与同步上下文管理器相同,区别在于我们说 async with 而不是仅仅 with

异步上下文管理器是实现了两个特殊协程方法的类,__aenter__,它异步获取资源,以及 __aexit__,它关闭该资源. __aexit__ 协程接受几个与任何发生的异常相关的参数,我们将在本章中不进行回顾。

为了完全理解异步上下文管理器,让我们使用第三章中引入的套接字实现一个简单的异步上下文管理器。我们可以将客户端套接字连接视为我们想要管理的资源。当客户端连接时,我们获取客户端连接。一旦我们完成使用,我们就清理并关闭连接。在第三章中,我们使用 try/finally 块包装了所有内容,但我们可以实现一个异步上下文管理器来代替这样做。

列表 4.1 等待客户端连接的异步上下文管理器

import asyncio
import socket
from types import TracebackType
from typing import Optional, Type

class ConnectedSocket:

    def __init__(self, server_socket):
        self._connection = None
        self._server_socket = server_socket

    async def __aenter__(self):                                      ❶
        print('Entering context manager, waiting for connection')
        loop = asyncio.get_event_loop()
        connection, address = await loop.sock_accept(self._server_socket)
        self._connection = connection
        print('Accepted a connection')
        return self._connection

    async def __aexit__(self,
                        exc_type: Optional[Type[BaseException]],
                        exc_val: Optional[BaseException],
                        exc_tb: Optional[TracebackType]):            ❷
        print('Exiting context manager')
        self._connection.close()
        print('Closed connection')

async def main():
    loop = asyncio.get_event_loop()

    server_socket = socket.socket()
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_address = ('127.0.0.1', 8000)
    server_socket.setblocking(False)
    server_socket.bind(server_address)
    server_socket.listen()

    async with ConnectedSocket(server_socket) as connection:         ❸
        data = await loop.sock_recv(connection, 1024)
        print(data)                                                  ❹

asyncio.run(main())

❶ 当我们进入 with 块时,会调用这个协程。它等待客户端连接,并返回连接。

❷ 当我们退出 with 块时,会调用这个协程。在其中,我们清理我们使用的任何资源。在这种情况下,我们关闭连接。

❸ 这调用 __aenter__ 并等待客户端连接。

❹ 在这个语句之后,__aenter__ 将执行,我们将关闭我们的连接。

在前面的列表中,我们创建了一个 ConnectedSocket 异步上下文管理器。这个类接受一个服务器套接字,并在我们的 __aenter__ 协程中等待客户端连接。一旦客户端连接,我们就返回该客户端的连接。这使得我们可以在 async with 语句的 as 部分访问该连接。然后,在我们的 async with 块内部,我们使用该连接等待客户端发送数据。一旦这个块执行完毕,__aexit__ 协程就会运行并关闭连接。假设客户端使用 Telnet 连接并发送一些测试数据,当运行这个程序时,我们应该看到以下输出:

Entering context manager, waiting for connection
Accepted a connection
b'test\r\n'
Exiting context manager
Closed connection

aiohttp 广泛使用异步上下文管理器来获取 HTTP 会话和连接,我们将在第五章处理异步数据库连接和事务时使用它。通常,你不需要编写自己的异步上下文管理器,但了解它们的工作原理以及与普通上下文管理器的区别是有帮助的。现在我们已经介绍了上下文管理器和它们的工作方式,让我们使用 aiohttp 来看看如何发起异步网络请求。

4.2.1 使用 aiohttp 发起网络请求

我们首先需要安装 aiohttp 库。我们可以通过运行以下命令使用 pip 来完成此操作:

pip install -Iv aiohttp==3.8.1

这将安装 aiohttp 的最新版本(在撰写本文时为 3.8.1)。一旦完成,你就可以开始发起请求了。

aiohttp 以及网络请求通常采用 会话 的概念。将会话想象成打开一个新的浏览器窗口。在一个新的浏览器窗口中,你可以连接到任意数量的网页,这些网页可能会发送给你浏览器为你保存的 cookie。使用会话,你可以保持多个连接打开,这些连接随后可以被回收。这被称为连接池。连接池 是一个重要的概念,有助于提高我们基于 aiohttp 的应用程序的性能。由于创建连接是资源密集型的,创建可重用的连接池可以减少资源分配成本。会话还会内部保存我们接收到的任何 cookie,尽管如果需要,这个功能可以被关闭。

通常,我们希望利用连接池,因此大多数基于 aiohttp 的应用程序在整个应用程序中运行一个会话。然后,这个会话对象被传递到需要它的方法中。会话对象上有用于发起任何数量网络请求的方法,例如 GET、PUT 和 POST。我们可以通过使用 async with 语法和 aiohttp.ClientSession 异步上下文管理器来创建一个会话。

列表 4.2 发起 aiohttp 网络请求

import asyncio
import aiohttp
from aiohttp import ClientSession
from util import async_timed

@async_timed()
async def fetch_status(session: ClientSession, url: str) -> int:
    async with session.get(url) as result:
        return result.status

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        url = 'https:/ / www .example .com'
        status = await fetch_status(session, url)
        print(f'Status for {url} was {status}')

asyncio.run(main())

当我们运行这个命令时,我们应该看到输出 Status for http:/ / www .example .com was 200。在上面的列表中,我们首先在 async with 块中创建了一个客户端会话 aiohttp.ClientSession()。一旦我们有了客户端会话,我们就可以自由地发起任何想要的网络请求。在这种情况下,我们定义了一个便利方法 fetch_status_code,它将接受一个会话和一个 URL,并返回给定 URL 的状态码。在这个函数中,我们还有一个 async with 块,并使用会话对 URL 运行一个 GET HTTP 请求。这将给我们一个结果,我们可以在 with 块中对其进行处理。在这种情况下,我们只是获取状态码并返回。

注意,ClientSession 默认会创建最多 100 个连接,这为我们可以发起的并发请求数量提供了一个隐含的上限。要更改此限制,我们可以创建一个 aiohttp TCPConnector 的实例,指定最大连接数,并将其传递给 ClientSession。要了解更多信息,请查阅 aiohttp 文档:docs.aiohttp.org/en/stable/client_advanced.html#connectors

我们将在本章中重复使用 fetch_status 函数,所以让我们使这个函数可重用。我们将创建一个名为 chapter_04 的 Python 模块,其中包含 __init__.py 文件,并包含这个函数。然后,我们将在本章的后续示例中导入它,作为 from chapter_04 import fetch_status.

Windows 用户注意事项

目前,aiohttp 在 Windows 上存在一个问题,即使你的应用程序运行正常,你也可能会看到诸如 RuntimeError: Event loop is closed 这样的错误。更多关于此问题的信息,请参阅 github.com/aio-libs/aiohttp/issues/4324bugs.python.org/issue39232。为了解决这个问题,你可以手动管理事件循环,如第二章中所示,使用 asyncio.get_event_loop().run_until_complete(main()),或者你可以在调用 asyncio.run(main()) 之前,通过调用 asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 将事件循环策略更改为 Windows 选择器事件循环策略。

4.2.2 使用 aiohttp 设置超时

之前我们看到,我们可以通过使用 asyncio.wait_for 来指定可等待对象的超时。这同样适用于设置 aiohttp 请求的超时,但设置超时的一种更干净的方法是使用 aiohttp 提供的内置功能。

默认情况下,aiohttp 的超时时间为五分钟,这意味着没有任何单个操作应该超过这个时间。这是一个较长的超时时间,许多应用程序开发者可能希望将其设置得更低。我们可以在会话级别指定超时,这将适用于每个操作,或者在请求级别指定超时,这提供了更细粒度的控制。

我们可以使用 aiohttp 特定的 ClientTimeout 数据结构 来指定超时。这个结构不仅允许我们为整个请求指定秒数总超时,还允许我们设置建立连接或读取数据的超时。让我们通过为我们的会话和单个请求指定超时来查看如何使用它。

列表 4.3 使用 aiohttp 设置超时

import asyncio
import aiohttp
from aiohttp import ClientSession

async def fetch_status(session: ClientSession,
                       url: str) -> int:
    ten_millis = aiohttp.ClientTimeout(total=.01)
    async with session.get(url, timeout=ten_millis) as result:
        return result.status

async def main():
    session_timeout = aiohttp.ClientTimeout(total=1, connect=.1)
    async with aiohttp.ClientSession(timeout=session_timeout) as session:
        await fetch_status(session, 'https:/ / example .com')

asyncio.run(main())

在前面的列表中,我们设置了两个超时。第一个超时是在客户端会话级别。在这里,我们设置了一个总超时时间为 1 秒,并明确设置了一个连接超时时间为 100 毫秒。然后,在fetch_status中,我们覆盖了这一点,为我们的get请求设置了一个总超时时间为 10 毫秒。在这种情况下,如果我们的请求到 example.com 需要超过 10 毫秒,当我们await fetch_status时,将引发一个asyncio.TimeoutError。在这个例子中,10 毫秒应该足够让请求到 example.com 完成,所以我们不太可能看到异常。如果您想检查这个异常,请将 URL 更改为下载时间比 10 毫秒长一点的页面。

这些示例向我们展示了 aiohttp 的基础。然而,仅通过 asyncio 运行单个请求并不会提高我们应用程序的性能。当我们并发运行多个网络请求时,我们才会看到真正的益处。

4.3 再次探讨并发运行任务

在本书的前几章中,我们学习了如何创建多个任务以并发运行协程。为此,我们使用了asyncio.create_task,然后像下面这样等待任务:

import asyncio

async def main() -> None:
    task_one = asyncio.create_task(delay(1))
    task_two = asyncio.create_task(delay(2))

    await task_one
    await task_two

这适用于像之前的例子那样的简单情况,其中我们想要并发启动一个或两个协程。然而,在一个可能同时进行数百、数千甚至更多网络请求的世界里,这种风格会变得冗长且混乱。

我们可能会倾向于使用for循环或列表推导式来使这个过程更加流畅,如下面的列表所示。然而,如果编写不当,这种方法可能会引起问题。

列表 4.4 使用列表推导式错误地使用任务

import asyncio
from util import async_timed, delay

@async_timed()
async def main() -> None:
    delay_times = [3, 3, 3]
    [await asyncio.create_task(delay(seconds)) for seconds in delay_times]

asyncio.run(main())

由于我们理想情况下希望delay任务能够并发运行,我们预计主方法大约在 3 秒内完成。然而,在这种情况下,运行时间达到了 9 秒,因为所有操作都是顺序执行的:

starting <function main at 0x10f14a550> with args () {}
starting <function delay at 0x10f7684c0> with args (3,) {}
sleeping for 3 second(s)
finished sleeping for 3 second(s)
finished <function delay at 0x10f7684c0> in 3.0008 second(s)
starting <function delay at 0x10f7684c0> with args (3,) {}
sleeping for 3 second(s)
finished sleeping for 3 second(s)
finished <function delay at 0x10f7684c0> in 3.0009 second(s)
starting <function delay at 0x10f7684c0> with args (3,) {}
sleeping for 3 second(s)
finished sleeping for 3 second(s)
finished <function delay at 0x10f7684c0> in 3.0020 second(s)
finished <function main at 0x10f14a550> in 9.0044 second(s)

这里的问题很微妙。这是由于我们在创建任务后立即使用了await。这意味着对于每个我们创建的delay任务,我们都会暂停列表推导式和主协程,直到该delay任务完成。在这种情况下,我们将在任何给定时间只运行一个任务,而不是并发运行多个任务。修复方法是简单的,尽管有点冗长。我们可以在一个列表推导式中创建任务,并在第二个中await。这可以让一切并发运行。

列表 4.5 使用列表推导式并发使用任务

import asyncio
from util import async_timed, delay

@async_timed()
async def main() -> None:
    delay_times = [3, 3, 3]
    tasks = [asyncio.create_task(delay(seconds)) for seconds in delay_times]
    [await task for task in tasks]

asyncio.run(main())

这段代码在tasks列表中一次性创建了多个任务。一旦我们创建了所有任务,我们就在一个单独的列表推导式中等待它们的完成。这是因为create_task会立即返回,我们不会在所有任务创建之前进行任何等待。这确保了它只需要最多delay_times中的最大暂停时间,从而运行时间大约为 3 秒:

starting <function main at 0x10d4e1550> with args () {}
starting <function delay at 0x10daff4c0> with args (3,) {}
sleeping for 3 second(s)
starting <function delay at 0x10daff4c0> with args (3,) {}
sleeping for 3 second(s)
starting <function delay at 0x10daff4c0> with args (3,) {}
sleeping for 3 second(s)
finished sleeping for 3 second(s)
finished <function delay at 0x10daff4c0> in 3.0029 second(s)
finished sleeping for 3 second(s)
finished <function delay at 0x10daff4c0> in 3.0029 second(s)
finished sleeping for 3 second(s)
finished <function delay at 0x10daff4c0> in 3.0029 second(s)
finished <function main at 0x10d4e1550> in 3.0031 second(s)

虽然这做到了我们想要的事情,但仍然存在一些缺点。第一个缺点是它由多行代码组成,我们必须明确记住将任务创建与 await 分离。第二个缺点是不灵活,如果我们的协程比其他协程先完成,我们将被困在第二个列表解析中,等待所有其他协程完成。虽然这在某些情况下可能是可以接受的,但我们可能希望更加响应,一旦结果到达就处理它们。第三个,也是潜在的最大问题是异常处理。如果我们的协程中有一个异常,当我们在 await 失败的任务时,它将被抛出。这意味着我们无法处理任何成功完成的任务,因为那个异常将停止我们的执行。

asyncio 提供了一些便利函数来处理所有这些情况以及更多。当并发运行多个任务时,建议使用这些函数。在接下来的几节中,我们将查看其中的一些,并检查如何在并发发出多个网络请求的上下文中使用它们。

4.4 使用 gather 并发运行请求

一个广泛使用的 asyncio API,用于并发运行可等待对象的是 asyncio.gather。这个函数接受一个可等待对象的序列,并允许我们一行代码中并发运行它们。如果我们传递的可等待对象中有一个是协程,gather 将自动将其包装在一个任务中,以确保它并发运行。这意味着我们不需要像上面那样单独使用 asyncio.create_task 将所有内容包装起来。

asyncio.gather 返回一个可等待对象。当我们将其用于 await 表达式中时,它将暂停,直到我们传递给它的所有可等待对象都完成。一旦我们传递的所有内容都完成,asyncio.gather 将返回一个包含完成结果的列表。

我们可以使用这个函数并发运行我们想要的任意数量的网络请求。为了说明这一点,让我们看一个例子,我们同时发出 1,000 个请求,并获取每个响应的状态码。我们将使用 @async_timed 装饰器装饰我们的主协程,以便我们知道事情花费了多长时间。

列表 4.6 使用 gather 并发运行请求

import asyncio
import aiohttp
from aiohttp import ClientSession
from chapter_04 import fetch_status
from util import async_timed

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        urls = ['https:/ / example .com' for _ in range(1000)]
        requests = [fetch_status(session, url) for url in urls]   ❶
        status_codes = await asyncio.gather(*requests)            ❷
        print(status_codes)

asyncio.run(main())

❶ 为我们想要发出的每个请求生成一个协程列表。

❷ 等待所有请求完成。

在前面的列表中,我们首先生成一个我们想要从中检索状态码的 URL 列表;为了简单起见,我们将反复请求 example.com。然后,我们取出这个 URL 列表,调用fetch_status_code来生成一个协程列表,然后我们将这些协程传递给gather。这将每个协程包装在一个任务中,并开始并发运行它们。当我们执行此代码时,我们会看到 1,000 条消息打印到标准输出,表示fetch_status_code协程依次启动,表明有 1,000 个请求并发启动。随着结果的到来,我们会看到类似finished <function fetch_status_code at 0x10f3fe3a0> in 0.5453 second(s)的消息。一旦我们检索到我们请求的所有 URL 的内容,我们就会看到状态码开始打印出来。这个过程很快,取决于互联网连接和机器的速度,这个脚本可以在 500-600 毫秒内完成。

那么,这与同步操作相比如何呢?很容易通过在调用fetch_status_code时使用await来修改主函数,使其在每个请求上阻塞。这将使每个 URL 的主协程暂停,从而有效地使操作同步:

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        urls = ['https:/ / example .com' for _ in range(1000)]
        status_codes = [await fetch_status_code(session, url) for url in urls]
        print(status_codes)

如果我们运行这个,我们会注意到事情会花费更长的时间。我们还会注意到,与得到 1,000 条starting function fetch_status_code消息后跟 1,000 条finished function fetch_status_code消息的情况不同,每个请求都会显示类似以下的内容:

starting <function fetch_status_code at 0x10d95b310>
finished <function fetch_status_code at 0x10d95b310> in 0.01884 second(s)

这表明请求是依次发生的,等待每个fetch_status_code调用完成后再进行下一个请求。那么,这比使用我们的异步版本慢多少?虽然这取决于你的互联网连接和运行此代码的机器,但顺序运行可能需要大约 18 秒才能完成。与我们的异步版本相比,后者大约需要 600 毫秒,后者运行速度提高了 33 倍。

值得注意的是,我们传递给每个可等待对象的每个结果可能不会以确定性的顺序完成。例如,如果我们按顺序将协程ab传递给gatherb可能会在a之前完成。gather的一个不错的特点是,无论我们的可等待对象何时完成,我们都保证结果将以我们传递它们的顺序返回。让我们通过查看我们刚刚用delay函数描述的场景来演示这一点。

列表 4.7 可等待对象完成顺序混乱

import asyncio
from util import delay

async def main():
    results = await asyncio.gather(delay(3), delay(1))
    print(results)

asyncio.run(main())

在前面的列表中,我们向 gather 传递了两个协程。第一个需要 3 秒钟完成,第二个需要 1 秒钟。我们可能期望这个结果为 [1, 3],因为我们的 1 秒钟协程在 3 秒钟协程之前完成,但实际结果是 [3, 1] — 我们传递内容的顺序。gather 函数即使在后台存在固有的非确定性情况下,也能保持结果排序的确定性。在后台,gather 使用一种特殊的 future 实现来完成这个任务。对于好奇的读者,查看 gather 的源代码可以是一个了解许多 asyncio API 是如何使用 futures 构建的有益方式。

在上面的示例中,假设没有任何请求会失败或抛出异常。这对于“顺利路径”来说效果很好,但当一个请求失败时会发生什么呢?

4.4.1 使用 gather 处理异常

当然,当我们发起一个网络请求时,我们并不总是能得到一个返回值;我们可能会遇到异常。由于网络可能不可靠,可能出现不同的故障情况。例如,我们可能传递了一个无效的地址,或者由于网站已被关闭而变得无效。我们连接的服务器也可能关闭或拒绝我们的连接。

asyncio.gather 函数提供了一个可选参数 return_exceptions,它允许我们指定如何处理 awaitables 中的异常。return_exceptions 是一个布尔值;因此,我们可以从以下两种行为中选择:

  • return_exceptions=False — 这是 gather 的默认值。在这种情况下,如果我们的任何协程抛出异常,当我们在 await 它时,gather 调用也会抛出那个异常。然而,尽管我们的一个协程失败了,但其他协程并没有被取消,只要我们处理了异常,或者异常没有导致事件循环停止并取消任务,它们将继续运行。

  • return_exceptions=True — 在这种情况下,当我们在 await gather 时,gather 将将任何异常作为它返回的结果列表的一部分。gather 调用本身不会抛出任何异常,我们可以按我们的意愿处理所有异常。

为了说明这些选项是如何工作的,让我们将我们的 URL 列表更改为包含一个无效的网址。这将导致当我们尝试发起请求时,aiohttp 会抛出一个异常。然后我们将这个异常传递给 gather,并查看每个 return_exceptions 的行为:

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        urls = ['https:/ / example .com', 'python:/ / example .com']
        tasks = [fetch_status_code(session, url) for url in urls]
        status_codes = await asyncio.gather(*tasks)
        print(status_codes)

如果我们将我们的 URL 列表更改为上述内容,对 'python:// example .com' 的请求将失败,因为这个 URL 是无效的。由于这个原因,我们的 fetch_status_code 协程将抛出一个 AssertionError,这意味着 python:/ / 并不转换为一个端口。当我们在 await 我们的 gather 协程时,这个异常将被抛出。如果我们运行这个程序并查看输出,我们会看到我们的异常被抛出,但也会看到我们的其他请求继续运行(为了简洁,我们已移除详细的跟踪信息):

starting <function main at 0x107f4a4c0> with args () {}
starting <function fetch_status_code at 0x107f4a3a0>
starting <function fetch_status_code at 0x107f4a3a0>
finished <function fetch_status_code at 0x107f4a3a0> in 0.0004 second(s)
finished <function main at 0x107f4a4c0> in 0.0203 second(s)
finished <function fetch_status_code at 0x107f4a3a0> in 0.0198 second(s)
Traceback (most recent call last):
  File "gather_exception.py", line 22, in <module>
    asyncio.run(main())
AssertionError

Process finished with exit code 1

如果 asyncio.gather 失败,它不会取消任何其他正在运行的任务。这可能对许多用例来说是可接受的,但这是 gather 的缺点之一。我们将在本章后面看到如何取消并发运行的任务。

上述代码的另一个潜在问题是,如果发生多个异常,我们只能在 await gather 时看到第一个发生的异常。我们可以通过使用 return_exceptions=True 来修复这个问题,这将返回我们在运行协程时遇到的全部异常。然后我们可以过滤掉任何异常,并按需处理它们。让我们通过检查无效 URL 的前一个示例来了解这是如何工作的:

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        urls = ['https:/ / example .com', 'python:/ / example .com']
        tasks = [fetch_status_code(session, url) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        exceptions = [res for res in results if isinstance(res, Exception)]
        successful_results = [res for res in results if not isinstance(res, Exception)]

        print(f'All results: {results}')
        print(f'Finished successfully: {successful_results}')
        print(f'Threw exceptions: {exceptions}')

当运行此代码时,请注意没有抛出异常,我们得到了 gather 返回的列表中所有成功的和失败的异常。然后我们过滤掉所有异常实例,以检索成功的响应列表,结果如下:

All results: [200, AssertionError()]
Finished successfully: [200]
Threw exceptions: [AssertionError()]

这解决了无法看到协程抛出的所有异常的问题。现在我们不再需要显式地使用 try catch 块来处理任何异常,因为我们不再在 await 时抛出异常。尽管我们仍然必须从成功的结果中过滤掉异常,但 API 并不完美。

gather 有几个缺点。第一个,前面已经提到,就是如果其中一个任务抛出异常,取消我们的任务并不容易。想象一下,如果我们向同一个服务器发送请求,如果一个请求失败,所有其他请求也会失败,比如达到速率限制。在这种情况下,我们可能想要取消请求以释放资源,但这并不容易做到,因为我们的协程在后台被任务封装。

第二个问题是,我们必须等待所有协程完成,我们才能处理我们的结果。如果我们想在结果完成时立即处理它们,这会引发一个问题。例如,如果我们有一个请求需要 100 毫秒,但另一个请求持续 20 秒,我们将在处理只花费 100 毫秒完成的请求之前,被卡在等待 20 秒。

asyncio 提供了 API,使我们能够解决这两个问题。让我们首先看看如何处理结果一出现就进行处理的问题。

4.5 在请求完成时处理请求

虽然 asyncio.gather 在许多情况下都能正常工作,但它有一个缺点,那就是它需要在所有可等待对象完成之前等待,才允许访问任何结果。如果我们希望一有结果就处理它们,这会成为一个问题。如果我们有一些可等待对象可以快速完成,而有一些可能需要一些时间,那么 gather 等待所有东西完成也会成为一个问题。这可能导致我们的应用程序变得无响应;想象一下,一个用户发出了 100 个请求,其中两个很慢,但其余的很快完成。如果一旦请求开始完成,我们就能向用户输出一些信息,那会很好。

为了处理这种情况,asyncio 提供了一个名为 as_completed 的 API 函数。此方法接受一个可等待对象的列表,并返回一个 future 的迭代器。然后我们可以遍历这些 future,等待每一个。当 await 表达式完成时,我们将从所有我们的可等待对象中检索第一个完成的协程的结果。这意味着我们将在结果可用时立即处理它们,但由于我们没有关于哪个请求将首先完成的保证,因此现在没有结果的确定性排序。

为了展示这是如何工作的,让我们模拟一个情况,其中一个请求很快完成,而另一个需要更多时间。我们将在 fetch_status 函数中添加一个 delay 参数,并调用 asyncio.sleep 来模拟一个长时间请求,如下所示:

async def fetch_status(session: ClientSession,
                       url: str,
                       delay: int = 0) -> int:
    await asyncio.sleep(delay)
    async with session.get(url) as result:
        return result.status

然后,我们将使用一个 for 循环来遍历 as_completed 返回的迭代器。

列表 4.8 使用 as_completed

import asyncio
import aiohttp
from aiohttp import ClientSession
from util import async_timed
from chapter_04 import fetch_status

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        fetchers = [fetch_status(session, 'https:/ / www.example .com', 1),
                    fetch_status(session, 'https:/ / www.example .com', 1),
                    fetch_status(session, 'https:/ / www.example .com', 10)]

        for finished_task in asyncio.as_completed(fetchers):
            print(await finished_task)

asyncio.run(main())

在前面的列表中,我们创建了三个协程——两个需要大约 1 秒来完成,一个将需要 10 秒。然后我们将这些传递给 as_completed。在底层,每个协程都被包装在一个任务中并开始并发运行。该协程立即返回一个迭代器,开始循环。当我们进入 for 循环时,我们遇到 await finished_task。在这里我们暂停执行并等待第一个结果到来。在这种情况下,我们的第一个结果在 1 秒后到来,我们打印状态码。然后我们再次遇到 await result,由于我们的请求是并发运行的,我们应该几乎立即看到第二个结果。最后,我们的 10 秒请求将完成,循环将结束。执行此操作将给出以下输出:

starting <function fetch_status at 0x10dbed4c0>
starting <function fetch_status at 0x10dbed4c0>
starting <function fetch_status at 0x10dbed4c0>
finished <function fetch_status at 0x10dbed4c0> in 1.1269 second(s)
200
finished <function fetch_status at 0x10dbed4c0> in 1.1294 second(s)
200
finished <function fetch_status at 0x10dbed4c0> in 10.0345 second(s)
200
finished <function main at 0x10dbed5e0> in 10.0353 second(s)

总体来说,遍历 result_iterator 仍然需要大约 10 秒,就像我们使用 asyncio.gather 一样;然而,我们能够在第一个请求完成时立即执行代码来打印其结果。这给了我们额外的时间来处理第一个成功完成的协程的结果,同时其他协程仍在等待完成,这使得我们的应用程序在任务完成时更加响应。

此函数还提供了更好的异常处理控制。当一个任务抛出异常时,我们将在它发生时处理它,因为异常是在我们 await future 时抛出的。

4.5.1 使用 as_completed 的超时

任何基于 Web 的请求都有可能花费很长时间。服务器可能正承受着沉重的资源负载,或者我们可能有一个糟糕的网络连接。之前,我们看到了如何为特定请求添加超时,但如果我们想为一组请求设置超时怎么办?as_completed 函数通过提供一个可选的超时参数来支持这种用例,这允许我们指定秒数。这将跟踪 as_completed 调用所花费的时间;如果它超过了超时时间,当我们在迭代器中 await 它时,每个可等待对象都会抛出一个 TimeoutException

为了说明这一点,让我们以之前的例子为基础,创建两个需要 10 秒才能完成的请求和一个需要 1 秒的请求。然后,我们在 as_completed 上设置 2 秒的超时。一旦我们完成循环,我们将打印出所有当前正在运行的任务。

列表 4.9 在 as_completed 上设置超时

import asyncio
import aiohttp
from aiohttp import ClientSession
from util import async_timed
from chapter_04 import fetch_status

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        fetchers = [fetch_status(session, 'https:/ / example .com', 1),
                    fetch_status(session, 'https:/ / example .com', 10),
                    fetch_status(session, 'https:/ / example .com', 10)]

        for done_task in asyncio.as_completed(fetchers, timeout=2):
            try:
                result = await done_task
                print(result)
            except asyncio.TimeoutError:
                print('We got a timeout error!')

        for task in asyncio.tasks.all_tasks():
            print(task)

asyncio.run(main())

当我们运行这个程序时,我们会注意到第一次抓取的结果,2 秒后,我们会看到两个超时错误。我们还会看到还有两个抓取仍在运行,输出类似于以下内容:

starting <function main at 0x109c7c430> with args () {}
200
We got a timeout error!
We got a timeout error!
finished <function main at 0x109c7c430> in 2.0055 second(s)
<Task pending name='Task-2' coro=<fetch_status_code()>>
<Task pending name='Task-1' coro=<main>>
<Task pending name='Task-4' coro=<fetch_status_code()>>

as_completed 在尽可能快地获取结果方面表现良好,但也有一些缺点。第一个缺点是,虽然我们随着结果的出现而获取结果,但由于顺序是完全非确定的,没有简单的方法可以看到我们正在等待哪个协程或任务。如果我们不关心顺序,这可能没问题,但如果我们需要以某种方式将结果关联到请求,我们就会面临挑战。

第二个缺点是,使用超时,虽然我们会正确地抛出异常并继续执行,但任何创建的任务仍然会在后台运行。由于很难确定哪些任务仍在运行,如果我们想取消它们,我们就会面临另一个挑战。如果这些问题是我们需要处理的,那么我们就需要更细粒度的知识,了解哪些可等待对象已经完成,哪些还没有。为了处理这种情况,asyncio 提供了另一个 API 函数,称为 wait

4.6 使用 wait 进行更细粒度的控制

gatheras_completed 的一个缺点是,当我们遇到异常时,没有简单的方法来取消已经运行的任务。这在许多情况下可能没问题,但想象一下这样的用例:我们进行了几个协程调用,如果第一个调用失败,其余的也会失败。这种情况的一个例子是将无效参数传递给 Web 请求或达到 API 速率限制。这可能会引起性能问题,因为我们可能会消耗比所需更多的资源。我们注意到 as_completed 的另一个缺点是,由于迭代顺序是非确定的,很难跟踪确切哪个任务已经完成。

asyncio中的wait类似于gather,它提供了更具体的控制来处理这些情况。此方法有几个选项可供选择,具体取决于我们何时想要结果。此外,此方法返回两个集合:一个集合包含已完成任务的结果或异常,另一个集合包含仍在运行的任务。此函数还允许我们指定一个与其他 API 方法操作不同的超时时间;它不会抛出异常。当需要时,此函数可以解决我们之前使用其他asyncio API 函数时遇到的一些问题。

wait的基本签名是一个可等待对象列表,后面跟着一个可选的超时时间和一个可选的return_when字符串。这个字符串有几个预定义的值,我们将对其进行检查:ALL_COMPLETEDFIRST_EXCEPTIONFIRST_COMPLETED。它默认为ALL_COMPLETED。虽然截至本文写作时,wait接受一个可等待对象列表,但在未来的 Python 版本中,它将只接受task对象。我们将在本节末尾看到原因,但为了这些代码示例,作为最佳实践,我们将所有协程包装在任务中。

4.6.1 等待所有任务完成

如果没有指定return_when,则此选项是默认行为,并且它在行为上与asyncio.gather最接近,尽管它有一些差异。如前所述,使用此选项将在返回之前等待所有任务完成。让我们将此应用于我们的并发执行多个网络请求的示例,以了解此函数的工作方式。

列表 4.10 检查wait的默认行为

import asyncio
import aiohttp
from aiohttp import ClientSession
from util import async_timed
from chapter_04 import fetch_status

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        fetchers = \
            [asyncio.create_task(fetch_status(session, 'https:/ /example.com')),
             asyncio.create_task(fetch_status(session, 'https:/ /example.com'))]
        done, pending = await asyncio.wait(fetchers)

        print(f'Done task count: {len(done)}')
        print(f'Pending task count: {len(pending)}')

        for done_task in done:
            result = await done_task
            print(result)

asyncio.run(main())

在前面的列表中,我们通过向wait传递协程列表来并发运行两个网络请求。当我们await``wait时,一旦所有请求完成,它将返回两个集合:一个是所有已完成的任务集合,另一个是仍在运行的任务集合。done集合包含所有成功完成或抛出异常的任务。pending集合包含所有尚未完成的任务。在这个例子中,因为我们使用了ALL_COMPLETED选项,所以pending集合始终为零,因为asyncio.wait不会在所有内容完成之前返回。这将给我们以下输出:

starting <function main at 0x10124b160> with args () {}
Done task count: 2
Pending task count: 0
200
200
finished <function main at 0x10124b160> in 0.4642 second(s)

如果我们的请求之一抛出异常,它不会像asyncio.gather那样在asyncio.wait调用中抛出。在这个例子中,我们将像之前一样得到donepending集合,但直到我们await失败的任务,我们才看不到异常。

使用这种范式,我们有几种处理异常的方法。我们可以使用await并让异常抛出,我们可以使用await并在try``except块中包装它来处理异常,或者我们可以使用task.result()task.exception()方法。我们可以安全地调用这些方法,因为done集中的任务保证是已完成的任务;如果它们没有调用这些方法,那么将产生异常。

假设我们不想抛出异常并使我们的应用程序崩溃。相反,如果我们有任务的结果,我们希望打印任务的结果,如果有异常,则记录错误。在这种情况下,使用 Task 对象的方法是合适的解决方案。让我们看看如何使用这两个 Task 方法来处理异常。

列表 4.11 使用 wait 的异常

import asyncio
import logging

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        good_request = fetch_status(session, 'https:/ / www .example .com')
        bad_request = fetch_status(session, 'python:/ /bad')

        fetchers = [asyncio.create_task(good_request),
                    asyncio.create_task(bad_request)]

        done, pending = await asyncio.wait(fetchers)

        print(f'Done task count: {len(done)}')
        print(f'Pending task count: {len(pending)}')

        for done_task in done:
            # result = await done_task will throw an exception
            if done_task.exception() is None:
                print(done_task.result())
            else:
                logging.error("Request got an exception",
                              exc_info=done_task.exception())

asyncio.run(main())

使用 done_task.exception() 将检查我们是否有异常。如果没有,我们可以继续使用 done_taskresult 方法来获取结果。在这里执行 result = await done_task 也是安全的,尽管它可能会抛出异常,这可能不是我们想要的。如果异常不是 None,那么我们知道可等待对象有异常,我们可以按需处理它。在这里,我们只是打印出异常的堆栈跟踪。运行此操作将产生类似于以下输出的结果(为了简洁,我们已删除冗长的跟踪信息):

starting <function main at 0x10401f1f0> with args () {}
Done task count: 2
Pending task count: 0
200
finished <function main at 0x10401f1f0> in 0.12386679649353027 second(s)
ERROR:root:Request got an exception
Traceback (most recent call last):
AssertionError

4.6.2 监视异常

ALL_COMPLETED 的缺点与我们在 gather 中看到的缺点类似。在我们等待其他协程完成时,我们可能会有任何数量的异常,但我们直到所有任务完成才看不到。如果我们因为一个异常而想取消其他正在运行的任务,这可能会成为一个问题。我们可能还希望立即处理任何错误,以确保响应性并继续等待其他协程完成。

为了支持这些用例,wait 支持了 FIRST_EXCEPTION 选项。当我们使用此选项时,我们将根据我们的任务是否抛出异常获得两种不同的行为。

没有来自任何可等待对象的异常

如果我们的任务中没有异常,那么此选项与 ALL_COMPLETED 等效。我们将等待所有任务完成,然后 done 集合将包含所有完成的任务,而 pending 集合将为空。

来自任务的一个或多个异常

如果任何任务抛出异常,一旦抛出异常,wait 将立即返回。done 集合将包含所有成功完成的协程以及带有异常的协程。在这种情况下,done 集合至少保证有一个失败的任务,但也可能包含成功完成的任务。pending 集合可能为空,但也可能包含仍在运行的任务。然后我们可以使用这个 pending 集合来管理当前正在运行的任务,就像我们希望的那样。

为了说明 wait 在这些场景中的行为,看看当我们有几个长时间运行的 Web 请求,我们希望在其中一个协程立即因异常失败时取消这些请求会发生什么。

列表 4.12 在异常发生时取消正在运行的任务

import aiohttp
import asyncio
import logging
from chapter_04 import  fetch_status
from util import async_timed

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        fetchers = \
            [asyncio.create_task(fetch_status(session, 'python:/ / bad.com')),
             asyncio.create_task(fetch_status(session, 'https:/ / www.example
                                              .com', delay=3)),
             asyncio.create_task(fetch_status(session, 'https:/ / www.example
                                              .com', delay=3))]

        done, pending = await asyncio.wait(fetchers, return_when=asyncio.FIRST_EXCEPTION)

        print(f'Done task count: {len(done)}')
        print(f'Pending task count: {len(pending)}')
        for done_task in done:
            if done_task.exception() is None:
                print(done_task.result())
            else:
                logging.error("Request got an exception",
                              exc_info=done_task.exception())

        for pending_task in pending:
            pending_task.cancel()

asyncio.run(main())

在前面的列表中,我们发出一个错误请求和两个正确请求;每个请求持续 3 秒。当我们等待wait语句时,我们几乎立即返回,因为我们的错误请求立即出错。然后我们遍历done任务。在这种情况下,done集合中只有一个任务,因为我们的第一个请求立即以异常结束。为此,我们将执行打印异常的分支。

pending集合将有两个元素,因为我们有两个请求,每个请求大约需要 3 秒来运行,我们的第一个请求几乎立即失败。由于我们希望停止这些请求的运行,我们可以调用它们的cancel方法。这将给我们以下输出:

starting <function main at 0x105cfd280> with args () {}
Done task count: 1
Pending task count: 2
finished <function main at 0x105cfd280> in 0.0044 second(s)
ERROR:root:Request got an exception

注意:我们的应用程序运行时间几乎为零,因为我们迅速反应到我们的一个请求抛出了异常;使用此选项的强大之处在于我们实现了快速失败行为,迅速对出现的问题做出反应。

4.6.3:按完成顺序处理结果

ALL_COMPLETEDFIRST_EXCEPTION的缺点是,在协程成功且没有抛出异常的情况下,我们必须等待所有协程完成。根据用例,这可能是可以接受的,但如果我们处于希望一成功完成就响应协程的情况,我们就无计可施了。

在我们希望一完成就响应结果的情况下,我们可以使用as_completed;然而,as_completed的问题是没有简单的方法可以看到哪些任务仍在进行中,哪些任务已经完成。我们只能通过迭代器一次获取一个。

好消息是return_when参数接受一个FIRST_COMPLETED选项。这个选项将使wait协程一有至少一个结果就返回。这可能是一个失败的协程或成功运行的协程。然后我们可以根据我们的用例取消其他正在运行的协程或调整要继续运行的协程。让我们使用这个选项来发出几个网络请求并处理第一个完成的请求。

列表 4.13:按完成顺序处理

import asyncio
import aiohttp
from util import async_timed
from chapter_04 import fetch_status

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        url = 'https:/ / www .example .com'
        fetchers = [asyncio.create_task(fetch_status(session, url)),
                    asyncio.create_task(fetch_status(session, url)),
                    asyncio.create_task(fetch_status(session, url))]

        done, pending = await asyncio.wait(fetchers, return_when=asyncio.FIRST_COMPLETED)

        print(f'Done task count: {len(done)}')
        print(f'Pending task count: {len(pending)}')

        for done_task in done:
            print(await done_task)

asyncio.run(main())

在前面的列表中,我们并发启动了三个请求。我们的wait协程将在任何这些请求完成时返回。这意味着done将有一个完成的请求,而pending将包含仍在运行的内容,输出如下:

starting <function main at 0x10222f1f0> with args () {}
Done task count: 1
Pending task count: 2
200
finished <function main at 0x10222f1f0> in 0.1138 second(s)

这些请求几乎可以同时完成,所以我们也可能看到输出显示有两个或三个任务已经完成。尝试多次运行这个列表,看看结果如何变化。

这种方法让我们可以在第一个任务完成时立即做出响应。如果我们想像 as_completed 一样处理所有结果,该怎么办?上述示例可以轻松地循环 pending 任务,直到它们为空。这将给我们带来类似于 as_completed 的行为,好处是,在每一步我们都能确切地知道哪些任务已完成,哪些任务仍在运行。

列表 4.14 处理所有结果,随着它们的到来

import asyncio
import aiohttp
from chapter_04 import fetch_status
from util import async_timed

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        url = 'https:/ / www .example .com'
        pending = [asyncio.create_task(fetch_status(session, url)),
                   asyncio.create_task(fetch_status(session, url)),
                   asyncio.create_task(fetch_status(session, url))]

        while pending:
            done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)

            print(f'Done task count: {len(done)}')
            print(f'Pending task count: {len(pending)}')

            for done_task in done:
                print(await done_task)

asyncio.run(main())

在前面的列表中,我们创建了一个名为 pending 的集合,并将其初始化为我们想要运行的协程。我们循环,直到 pending 集合中有项目,并在每次迭代中调用 wait。一旦我们从 wait 获得结果,我们就更新 donepending 集合,然后打印出任何 done 任务。这将给我们带来类似于 as_completed 的行为,区别在于我们更好地了解哪些任务已完成,哪些任务仍在运行。运行此代码,我们将看到以下输出:

starting <function main at 0x10d1671f0> with args () {}
Done task count: 1
Pending task count: 2
200
Done task count: 1
Pending task count: 1
200
Done task count: 1
Pending task count: 0
200
finished <function main at 0x10d1671f0> in 0.1153 second(s)

由于请求函数可能很快完成,以至于所有请求同时完成,所以我们看到类似以下输出的情况也是可能的:

starting <function main at 0x1100f11f0> with args () {}
Done task count: 3
Pending task count: 0
200
200
200
finished <function main at 0x1100f11f0> in 0.1304 second(s)

4.6.4 处理超时

除了允许我们对协程完成的方式有更细粒度的控制外,wait 还允许我们设置超时时间来指定所有可等待对象完成所需的时间。为了启用此功能,我们可以使用期望的最大秒数设置 timeout 参数。如果我们超过了这个超时时间,wait 将返回 donepending 任务集。与之前在 wait_foras_completed 中看到的不同,wait 中的超时行为有几个差异。

协程不会被取消

当我们使用 wait_for 时,如果我们的协程超时,它会自动为我们请求取消。但在 wait 中并非如此;它的行为更接近我们之前在 gatheras_completed 中看到的。如果我们想因为超时而取消协程,我们必须显式地遍历任务并取消它们。

超时错误不会被抛出

wait_foras_completed 不同,wait 在超时的情况下不依赖于异常。相反,如果发生超时,wait 将返回所有已完成的任务和超时发生时仍挂起的所有任务。

例如,让我们考察一个有两个请求很快完成,而另一个需要几秒钟的情况。我们将使用 wait 的 1 秒超时来理解当我们有超过超时时间的任务时会发生什么。对于 return_when 参数,我们将使用默认值 ALL_COMPLETED

列表 4.15 使用 wait 设置超时

@async_timed()
async def main():
    async with aiohttp.ClientSession() as session:
        url = 'https:/ / example .com'
        fetchers = [asyncio.create_task(fetch_status(session, url),
                    asyncio.create_task(fetch_status(session, url),
                    asyncio.create_task(fetch_status(session, url, delay=3))]

        done, pending = await asyncio.wait(fetchers, timeout=1)

        print(f'Done task count: {len(done)}')
        print(f'Pending task count: {len(pending)}')
        for done_task in done:
            result = await done_task
            print(result)

asyncio.run(main())

运行前面的列表,我们的wait调用将在 1 秒后返回我们的donepending集合。在done集合中,我们将看到我们的两个快速请求,因为它们在 1 秒内完成了。我们的慢速请求仍在运行,因此它在pending集合中。然后我们await完成的任务以提取它们的返回值。如果我们愿意,我们也可以取消pending任务。运行这段代码,我们将看到以下输出:

starting <function main at 0x11c68dd30> with args () {}
Done task count: 2
Pending task count: 1
200
200
finished <function main at 0x11c68dd30> in 1.0022 second(s)

注意,与之前一样,我们pending集合中的任务没有被取消,并且即使超时也会继续运行。如果我们有一个用例想要终止pending任务,我们需要显式地遍历pending集合并对每个任务调用cancel

4.6.5 为什么要在任务中包装一切?

在本节的开始,我们提到将传递给wait的协程包装在任务中是最佳实践。为什么是这样?让我们回到我们之前的超时示例并稍作修改。假设我们有对两个不同 Web API 的请求,我们将它们称为 API A 和 API B。两者都可能很慢,但我们的应用程序可以在没有 API B 的结果的情况下运行,所以它只是一个“锦上添花”的功能。由于我们希望应用程序响应迅速,我们为请求完成设置了 1 秒的超时。如果 API B 的请求在超时后仍然挂起,我们将取消它并继续。让我们看看如果我们不将请求包装在任务中实现会发生什么。

列表 4.16 取消慢速请求

import asyncio
import aiohttp
from chapter_04 import fetch_status

async def main():
    async with aiohttp.ClientSession() as session:
        api_a = fetch_status(session, 'https:/ / www .example .com')
        api_b = fetch_status(session, 'https:/ / www .example .com', delay=2)

        done, pending = await asyncio.wait([api_a, api_b], timeout=1)

        for task in pending:
            if task is api_b:
                print('API B too slow, cancelling')
                task.cancel()

asyncio.run(main())

我们预计这段代码会打印出API B is too slow and cancelling,但如果我们根本看不到这条消息会怎样?这种情况可能发生,因为我们调用wait时只传递了协程,它们会被自动包装在任务中,返回的donepending集合是wait为我们创建的任务。这意味着我们无法进行任何比较来查看pending集合中哪个特定的任务,例如if task is api_b,因为我们将会比较一个task对象,我们没有访问协程的能力。然而,如果我们把fetch_status包装在任务中,wait就不会创建任何新对象,并且if task is api_b的比较将像我们预期的那样工作。在这种情况下,我们正确地比较了两个task对象。

摘要

  • 我们已经学会了如何使用和创建我们自己的异步上下文管理器。这些是特殊的类,允许我们异步获取资源然后释放它们,即使发生了异常。这些让我们可以以非冗余的方式清理我们可能获取的任何资源,并且当与 HTTP 会话以及数据库连接一起工作时也非常有用。我们可以使用特殊的async with语法来使用它们。

  • 我们可以使用 aiohttp 库来执行异步网络请求。aiohttp 是一个使用非阻塞套接字的 Web 客户端和服务器。使用 Web 客户端,我们可以以不阻塞事件循环的方式并发执行多个网络请求。

  • asyncio.gather函数允许我们并发运行多个协程并等待它们完成。这个函数将在我们传递给它的所有 awaitables 都完成后返回。如果我们想要跟踪发生的任何错误,我们可以将return_exceptions设置为True。这将返回成功完成的 awaitables 的结果以及我们收到的任何异常。

  • 我们可以使用as_completed函数来处理一组 awaitables 的结果,一旦它们完成。这将给我们一个 future 迭代器,我们可以遍历它。一旦协程或任务完成,我们就能访问结果并对其进行处理。

  • 如果我们想要同时运行多个任务,但又想了解哪些任务已完成以及哪些仍在运行,我们可以使用wait函数。这个函数还允许我们在何时返回结果上拥有更大的控制权。当它返回时,我们将得到一组已完成的任务和一组仍在运行的任务。然后我们可以取消任何我们希望取消的任务,或者执行任何其他等待的操作。

5 非阻塞数据库驱动程序

本章涵盖

  • 使用 asyncpg 运行与 asyncio 兼容的数据库查询

  • 创建数据库连接池以并发运行多个 SQL 查询

  • 管理异步数据库事务

  • 使用异步生成器来流式传输查询结果

第四章探讨了使用 aiohttp 库进行非阻塞网络请求,并讨论了使用多个不同的 asyncio API 方法来并发运行这些请求。结合 asyncio API 和 aiohttp 库,我们可以并发运行多个长时间运行的网络请求,从而提高我们应用程序的运行时间。我们在第四章中学到的概念不仅适用于网络请求,也适用于运行 SQL 查询,并且可以提高数据库密集型应用程序的性能。

与网络请求类似,我们需要使用一个与 asyncio 兼容的库,因为典型的 SQL 库会在检索到结果之前阻塞主线程,从而阻塞事件循环。在本章中,我们将学习如何使用 asyncpg 库进行异步数据库访问。我们首先创建一个简单的模式来跟踪电子商务店面中的产品,然后我们将使用它来异步地运行查询。然后,我们将探讨如何在数据库中管理事务和回滚,以及设置连接池。

5.1 介绍 asyncpg

正如我们之前提到的,我们现有的阻塞库不能与协程无缝工作。为了并发地对数据库运行查询,我们需要使用一个使用非阻塞套接字的与 asyncio 兼容的库。为此,我们将使用一个名为 asyncpg 的库,它将允许我们异步地连接到 Postgres 数据库并对它们运行查询。

在本章中,我们将专注于 Postgres 数据库,但我们在这里学到的知识也适用于 MySQL 和其他数据库。aiohttp 的创建者也创建了 aiomysql 库,该库可以连接并针对 MySQL 数据库运行查询。虽然有一些差异,但 API 非常相似,知识可以迁移。值得注意的是,asyncpg 库没有实现 PEP-249 中定义的 Python 数据库 API 规范(可在 www.python.org/dev/peps/pep-0249 查找)。这是库实现者做出的一个有意识的选择,因为并发实现与同步实现本质上是不同的。然而,aiomysql 的创建者采取了不同的路线,并实现了 PEP-249,因此这个库的 API 对于那些使用过 Python 同步数据库驱动程序的人来说会感觉熟悉。

当前 asynpg 的文档可在 magicstack.github.io/asyncpg/current/ 查找。既然我们已经对将要使用的驱动程序有了一些了解,让我们连接到我们的第一个数据库。

5.2 连接到 Postgres 数据库

要开始使用 asyncpg,我们将使用创建电子商务店面产品数据库的真实场景。我们将在这个章节中使用这个示例数据库来演示我们可能需要解决的该领域数据库问题。

开始创建我们的产品数据库并运行查询的第一步是建立数据库连接。在本节和本章的其余部分,我们假设您在本地机器上以默认端口 5432 运行了一个 Postgres 数据库,并且我们假设默认用户 postgres 的密码是 'password'

警告 我们将在这些代码示例中硬编码密码以实现透明度和学习目的;但请注意,您绝对不应该在代码中硬编码密码,因为这违反了安全原则。始终将密码存储在环境变量或某种其他配置机制中。

您可以从 www.postgresql.org/download/ 下载并安装 Postgres 的一个副本;只需选择您正在工作的适当操作系统即可。您还可以考虑使用 Docker Postgres 镜像;更多信息可以在 hub.docker.com/_/postgres/ 找到。

一旦我们设置了数据库,我们将安装 asyncpg 库。我们将使用 pip3 来完成这个任务,并且我们将安装写作时的最新版本,0.0.23

pip3 install -Iv asyncpg==0.23.0

安装完成后,我们现在可以导入库并建立与数据库的连接。asyncpg 通过 asyncpg.connect 函数提供这个功能。让我们使用这个功能来连接并打印数据库版本号。

列表 5.1 以默认用户连接到 Postgres 数据库

import asyncpg
import asyncio

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='postgres',
                                       password='password')
    version = connection.get_server_version()
    print(f'Connected! Postgres version is {version}')
    await connection.close()

asyncio.run(main())

在前面的列表中,我们以默认的 postgres 用户和默认的 postgres 数据库创建了一个连接到我们的 Postres 实例。假设我们的 Postgres 实例正在运行,我们应该在我们的控制台上看到类似 "Connected! Postgres version is ServerVersion(major=12, minor=0, micro=3, releaselevel='final' serial=0)" 的输出,这表明我们已经成功连接到我们的数据库。最后,我们使用 await connection.close() 关闭数据库连接。

现在我们已经连接上了,但我们的数据库中目前还没有存储任何内容。下一步是创建一个我们可以与之交互的产品模式。在创建此模式时,我们将学习如何使用 asyncpg 执行基本查询。

5.3 定义数据库模式

要开始对数据库运行查询,我们需要创建一个数据库模式。我们将选择一个简单的模式,我们将其称为 products,模拟在线店面可能拥有的真实产品。让我们定义几个不同的实体,然后我们可以将它们转换为数据库中的表。

品牌

一个 品牌 是许多不同产品的制造商。例如,福特是一个生产许多不同车型(例如,福特 F150、福特 Fiesta 等)的品牌。

产品

一个 产品 与一个品牌相关联,品牌和产品之间存在一对一和多对一的关系。为了简化,在我们的产品数据库中,一个产品将只有一个产品名称。在福特示例中,一个产品是名为 Fiesta 的小型汽车;品牌是 Ford。此外,我们数据库中的每个产品将提供多种尺寸和颜色。我们将可用的尺寸和颜色定义为 SKU。

SKU

SKU 代表 库存单位。SKU 代表店面有售的独立项目。例如,牛仔裤可能是一个产品,SKU 可能是 Jeans, size: medium, color: blue;jeans, size: small, color: black。产品与 SKU 之间存在一对一和多对一的关系。

产品尺寸

一个产品可以有多种尺寸。在这个例子中,我们将考虑只有三种尺寸可供选择:小号、中号和大号。每个 SKU 都有一个与之关联的产品尺寸,因此产品尺寸和 SKU 之间存在一对一和多对一的关系。

产品颜色

一个产品可以有多种颜色。在这个例子中,我们将说我们的库存只有两种颜色:黑色和蓝色。产品颜色和 SKU 之间存在一对一和多对一的关系。

05-01

图 5.1 产品数据库的实体图

将所有这些放在一起,我们将模拟一个数据库模式,如图 5.1 所示。

现在,让我们使用 SQL 定义一些变量来创建此模式。使用 asyncpg,我们将执行这些语句来创建我们的产品数据库。由于我们的尺寸和颜色是提前知道的,我们还将插入一些记录到 product_sizeproduct_color 表中。我们将在接下来的代码列表中引用这些变量,因此我们不需要重复冗长的 SQL 创建语句。

列表 5.2 产品模式表创建语句

CREATE_BRAND_TABLE = \
    """
    CREATE TABLE IF NOT EXISTS brand(
        brand_id SERIAL PRIMARY KEY,
        brand_name TEXT NOT NULL
    );"""

CREATE_PRODUCT_TABLE = \
    """
    CREATE TABLE IF NOT EXISTS product(
        product_id SERIAL PRIMARY KEY,
        product_name TEXT NOT NULL,
        brand_id INT NOT NULL,
        FOREIGN KEY (brand_id) REFERENCES brand(brand_id)
    );"""

CREATE_PRODUCT_COLOR_TABLE = \
    """
    CREATE TABLE IF NOT EXISTS product_color(
        product_color_id SERIAL PRIMARY KEY,
        product_color_name TEXT NOT NULL
    );"""

CREATE_PRODUCT_SIZE_TABLE = \
    """
    CREATE TABLE IF NOT EXISTS product_size(
        product_size_id SERIAL PRIMARY KEY,
        product_size_name TEXT NOT NULL
    );"""

CREATE_SKU_TABLE = \
    """
    CREATE TABLE IF NOT EXISTS sku(
       sku_id SERIAL PRIMARY KEY,
       product_id INT NOT NULL,
       product_size_id INT NOT NULL,
       product_color_id INT NOT NULL,
       FOREIGN KEY (product_id)
       REFERENCES product(product_id),
       FOREIGN KEY (product_size_id)
       REFERENCES product_size(product_size_id),
       FOREIGN KEY (product_color_id)
       REFERENCES product_color(product_color_id)
    );"""

COLOR_INSERT = \
    """
    INSERT INTO product_color VALUES(1, 'Blue');
    INSERT INTO product_color VALUES(2, 'Black');
    """

SIZE_INSERT = \
    """
    INSERT INTO product_size VALUES(1, 'Small');
    INSERT INTO product_size VALUES(2, 'Medium');
    INSERT INTO product_size VALUES(3, 'Large');
    """

现在我们有了创建我们的表和插入我们的尺寸和颜色的语句,我们需要一种方法来对这些语句进行查询。

5.4 使用 asyncpg 执行查询

要对我们的数据库运行查询,我们首先需要连接到我们的 Postgres 实例并在 Python 之外直接创建数据库。我们可以通过以下语句创建数据库,一旦连接到数据库作为默认的 Postgres 用户:

CREATE DATABASE products;

您可以通过在命令行中运行 sudo -u postgres psql -c "CREATE TABLE products;" 来执行此操作。在接下来的示例中,我们假设您已经执行了此语句,因为我们将会直接连接到产品数据库。

现在我们已经创建了我们的产品数据库,我们将连接到它并执行我们的 create 语句。连接类有一个名为 execute 的协程,我们可以用它逐个运行我们的创建语句。这个协程返回一个字符串,表示 Postgres 返回的查询状态。让我们执行上一节中创建的语句。

列表 5.3 使用 execute 协程运行创建语句

import asyncpg
import asyncio

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    statements = [CREATE_BRAND_TABLE,
                  CREATE_PRODUCT_TABLE,
                  CREATE_PRODUCT_COLOR_TABLE,
                  CREATE_PRODUCT_SIZE_TABLE,
                  CREATE_SKU_TABLE,
                  SIZE_INSERT,
                  COLOR_INSERT]

    print('Creating the product database...')
    for statement in statements:
        status = await connection.execute(statement)
        print(status)
    print('Finished creating the product database!')
    await connection.close()

asyncio.run(main())

我们首先以与我们的第一个例子类似的方式创建到产品数据库的连接,不同之处在于这里我们连接到产品数据库。一旦我们有了这个连接,我们就开始逐个使用connection.execute()执行我们的CREATE TABLE语句。请注意,execute()是一个协程,所以为了运行我们的 SQL,我们需要await调用。假设一切正常,每个execute语句的状态应该是CREATE TABLE,每个insert语句应该是INSERT 0 1。最后,我们关闭产品数据库的连接。请注意,在这个例子中,我们在for循环中等待每个 SQL 语句,这确保了INSERT语句是同步运行的。由于某些表依赖于其他表,我们不能并发运行它们。

这些语句没有与之关联的结果,所以让我们插入一些数据并运行一些简单的选择查询。我们首先插入一些品牌,然后查询它们以确保我们已经正确插入。我们可以使用execute协程来插入数据,就像之前一样,我们也可以使用fetch协程来运行查询。

列表 5.4 插入和选择品牌

import asyncpg
import asyncio
from asyncpg import Record
from typing import List
async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    await connection.execute("INSERT INTO brand VALUES(DEFAULT, 'Levis')")
    await connection.execute("INSERT INTO brand VALUES(DEFAULT, 'Seven')")

    brand_query = 'SELECT brand_id, brand_name FROM brand'
    results: List[Record] = await connection.fetch(brand_query)

    for brand in results:
        print(f'id: {brand["brand_id"]}, name: {brand["brand_name"]}')

    await connection.close()

asyncio.run(main())

我们首先将两个品牌插入到brand表中。一旦完成,我们使用connection.fetch从我们的品牌表中获取所有品牌。一旦这个查询完成,我们将在results变量中拥有所有结果。每个结果都将是一个asyncpg Record对象。这些对象的行为类似于字典;它们允许我们通过传递一个列名和下标语法来访问数据。执行此操作将给出以下输出:

id: 1, name: Levis
id: 2, name: Seven

在这个例子中,我们将查询的所有数据都提取到一个列表中。如果我们只想获取单个结果,我们可以调用connection.fetchrow(),这将返回查询的单个记录。默认的 asyncpg 连接将把查询的所有结果拉入内存,所以目前fetchrowfetch之间没有性能差异。在本章的后面,我们将看到如何使用游标与流式结果集一起使用。这些只会一次拉取少量结果到内存中,这对于查询可能返回大量数据的情况来说是一种有用的技术。

这些示例是依次运行查询的,我们本可以使用非 asyncio 数据库驱动程序来获得类似性能。然而,由于我们现在返回的是协程,我们可以使用我们在第四章中学到的 asyncio API 方法来并发执行查询。

5.5 使用连接池并发执行查询

asyncio 对于 I/O 密集型操作的真实好处是能够并发运行多个任务。我们需要重复执行且相互独立的查询是我们可以应用并发来提高应用程序性能的好例子。为了演示这一点,让我们假装我们是一个成功的电子商务店面。我们的公司为 1000 个不同的品牌提供 10 万个 SKU。

我们还将假装我们通过合作伙伴销售我们的商品。这些合作伙伴通过我们构建的批量处理过程在给定时间内为成千上万的产品发出请求。连续运行所有这些查询可能会很慢,因此我们希望创建一个应用程序,以并发方式执行这些查询,以确保快速体验。

由于这是一个示例,我们没有 10 万个 SKU,因此我们将首先在我们的数据库中创建一个假的产品和 SKU 记录。我们将为随机品牌和产品随机生成 10 万个 SKU,并将这个数据集作为运行查询的基础。

5.5.1 将随机 SKU 插入产品数据库

由于我们不希望我们自己列出品牌、产品和 SKU,我们将随机生成它们。我们将从 1,000 个最常出现的英语单词列表中随机选择名称。为了这个示例,我们假设我们有一个包含这些单词的文本文件,称为 common_words.txt。您可以从本书的 GitHub 数据存储库中下载此文件的副本,网址为 github.com/concurrency-in-python-with-asyncio/data

我们首先想要做的是插入我们的品牌,因为我们的产品表依赖于 brand_id 作为外键。我们将使用 connection.executemany 协程来编写参数化 SQL 以插入这些品牌。这将允许我们编写一个 SQL 查询并传递我们想要插入的参数列表,而不是为每个品牌创建一个 INSERT 语句。

executemany 协程接受一个 SQL 语句和一个包含我们想要插入的值的元组列表。我们可以通过使用 $1, $2 ... $N 语法来参数化 SQL 语句。每个美元符号后面的数字代表我们想要在 SQL 语句中使用的元组的索引。例如,如果我们编写了一个查询 "INSERT INTO table VALUES($1, $2)" 并且有一个元组列表 [('a', 'b'), ('c', 'd')],这将为我们执行两个插入操作:

INSERT INTO table ('a', 'b')
INSERT INTO table ('c', 'd')

我们首先将从常见单词列表中生成 100 个随机品牌名称。我们将这个列表作为包含一个值的元组列表返回,这样我们就可以在 executemany 协程中使用它。一旦我们创建了此列表,我们只需将参数化的 INSERT 语句与这个元组列表一起传递即可。

列表 5.5 插入随机品牌

import asyncpg
import asyncio
from typing import List, Tuple, Union
from random import sample

def load_common_words() -> List[str]:
    with open('common_words.txt') as common_words:
        return common_words.readlines()
def generate_brand_names(words: List[str]) -> List[Tuple[Union[str, ]]]:
    return [(words[index],) for index in sample(range(100), 100)]

async def insert_brands(common_words, connection) -> int:
    brands = generate_brand_names(common_words)
    insert_brands = "INSERT INTO brand VALUES(DEFAULT, $1)"
    return await connection.executemany(insert_brands, brands)

async def main():
    common_words = load_common_words()
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    await insert_brands(common_words, connection)

asyncio.run(main())

内部,executemany 将遍历我们的品牌列表并为每个品牌生成一个 INSERT 语句。然后它将一次性执行所有这些 insert 语句。这种参数化方法还可以防止我们遭受 SQL 注入攻击,因为输入数据已经被清理。一旦我们运行这个程序,我们的系统中应该会有 100 个带有随机名称的品牌。

现在我们已经看到了如何插入随机品牌,让我们使用同样的技术来插入产品和 SKU。对于产品,我们将创建一个由 10 个随机单词和一个随机品牌 ID 组成的描述。对于 SKU,我们将随机选择一个尺寸、颜色和产品。我们假设我们的品牌 ID 从 1 开始,到 100 结束。

列表 5.6 插入随机产品和 SKU

import asyncio
import asyncpg
from random import randint, sample
from typing import List, Tuple
from chapter_05.listing_5_5 import load_common_words

def gen_products(common_words: List[str],
                 brand_id_start: int,
                 brand_id_end: int,
                 products_to_create: int) -> List[Tuple[str, int]]:
    products = []
    for _ in range(products_to_create):
        description = [common_words[index] for index in sample(range(1000), 10)]
        brand_id = randint(brand_id_start, brand_id_end)
        products.append((" ".join(description), brand_id))
    return products

def gen_skus(product_id_start: int,
             product_id_end: int,
             skus_to_create: int) -> List[Tuple[int, int, int]]:
    skus = []
    for _ in range(skus_to_create):
        product_id = randint(product_id_start, product_id_end)
        size_id = randint(1, 3)
        color_id = randint(1, 2)
        skus.append((product_id, size_id, color_id))
    return skus

async def main():
    common_words = load_common_words()
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')

    product_tuples = gen_products(common_words,
                                  brand_id_start=1,
                                  brand_id_end=100,
                                  products_to_create=1000)
    await connection.executemany("INSERT INTO product VALUES(DEFAULT, $1, $2)",
                                 product_tuples)

    sku_tuples = gen_skus(product_id_start=1,
                          product_id_end=1000,
                          skus_to_create=100000)
    await connection.executemany("INSERT INTO sku VALUES(DEFAULT, $1, $2, $3)",
                                 sku_tuples)

    await connection.close()

asyncio.run(main())

当我们运行这个列表时,我们应该有一个包含 1,000 个产品和 100,000 个 SKU 的数据库。根据你的机器,这可能需要几秒钟的时间来运行。通过一些连接,我们现在可以查询特定产品的所有可用 SKU。让我们看看对于product id 100这个查询会是什么样子:

product_query = \
"""
SELECT
p.product_id,
p.product_name,
p.brand_id,
s.sku_id,
pc.product_color_name,
ps.product_size_name
FROM product as p
JOIN sku as s on s.product_id = p.product_id
JOIN product_color as pc on pc.product_color_id = s.product_color_id
JOIN product_size as ps on ps.product_size_id = s.product_size_id
WHERE p.product_id = 100"""

当我们执行这个查询时,我们将为每个产品的 SKU 得到一行,我们还将得到尺寸和颜色的正确英文名称,而不是 ID。假设我们一次想要查询很多产品 ID,这为我们提供了一个应用并发的良好机会。我们可能会天真地尝试使用现有的连接和asyncio.gather来这样做:

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    print('Creating the product database...')
    queries = [connection.execute(product_query),
               connection.execute(product_query)]
    results = await asyncio.gather(*queries)       

然而,如果我们运行这个,我们会遇到一个错误:

RuntimeError: readexactly() called while another coroutine is already waiting for incoming data

为什么会这样?在 SQL 世界中,一个连接意味着一个套接字连接到我们的数据库。由于我们只有一个连接,并且我们试图并发地读取多个查询的结果,所以我们遇到了错误。我们可以通过创建多个连接到我们的数据库并每个连接执行一个查询来解决这个问题。由于创建连接是资源密集型的,因此当需要时缓存它们以便访问是有意义的。这通常被称为连接

5.5.2 创建连接池以并发运行查询

由于我们每次只能在一个连接上运行一个查询,因此我们需要一个机制来创建和管理多个连接。连接池正是为此而设计的。你可以将连接池视为对数据库实例现有连接的缓存。它们包含有限数量的连接,当我们需要运行查询时可以访问这些连接。

使用连接池,我们在需要运行查询时获取连接。获取一个连接意味着我们向池询问:“池目前是否有可用的连接?如果有,给我一个,这样我就可以运行我的查询了。”连接池促进了这些连接的复用以执行查询。换句话说,一旦从池中获取连接来运行查询并且该查询完成,我们就将其“返回”或“释放”回池,供其他人使用。这很重要,因为与数据库建立连接是耗时的。如果我们必须为每个想要运行的查询创建一个新的连接,那么我们应用程序的性能会迅速下降。

由于连接池中连接数量有限,我们可能需要等待一段时间才能有一个连接变得可用,因为其他连接可能正在使用中。这意味着连接获取是一个可能需要时间才能完成的操作。如果我们池中有 10 个连接,每个都在使用中,而我们请求另一个,我们需要等待直到这 10 个连接中的 1 个变得可用以执行我们的查询。

为了说明这在 asyncio 中的工作方式,让我们想象我们有一个包含两个连接的连接池。让我们再想象我们有三个协程,每个都运行一个查询。我们将这些三个协程作为任务并发运行。以这种方式设置连接池,尝试运行查询的前两个协程将获取两个可用的连接并开始运行它们的查询。在这个过程中,第三个协程将等待一个连接变得可用。当第一个两个协程中的任何一个完成其查询的运行时,它将释放其连接并将其返回到池中。这使得第三个协程能够获取它并开始使用它来运行其查询(图 5.2)。

05-02

图 5.2 协程 1 和 2 在协程 3 等待连接时获取连接以运行它们的查询。一旦协程 1 或 2 完成,协程 3 将能够使用新释放的连接并执行其查询。

在这个模型中,我们最多可以同时运行两个查询。通常,连接池会更大一些,以允许更多的并发。在我们的示例中,我们将使用一个包含六个连接的连接池,但您想要使用的实际数量取决于您的数据库和应用程序运行的硬件。在这种情况下,您需要基准测试哪种连接池大小最适合。请记住,更大的不一定更好,但这是一个更大的话题。

现在我们已经了解了连接池的工作原理,我们如何使用 asyncpg 创建一个?asyncpg 公开了一个名为create_pool的协程来完成这个任务。我们使用这个而不是我们之前用来建立数据库连接的connect函数。当我们调用create_pool时,我们将指定我们希望在池中创建的连接数量。我们将使用min_sizemax_size参数来完成这个操作。min_size参数指定了我们连接池中的最小连接数。这意味着一旦我们设置了池,我们就保证池中已经建立了这个数量的连接。max_size参数指定了我们希望在池中的最大连接数,这决定了我们可以拥有的最大连接数。如果我们没有足够的连接可用,当新连接不会使池大小超过max_size中设置的值时,池将为我们创建一个新的连接。在我们的第一个示例中,我们将这两个值都设置为六。这保证了我们始终有六个可用的连接。

asyncpg 连接池是异步上下文管理器,这意味着我们必须使用async with语法来创建一个池。一旦我们建立了一个池,我们可以使用acquire协程来获取连接。这个协程将暂停执行,直到我们有可用的连接。一旦我们有了连接,我们就可以使用它来执行我们想要的任何 SQL 查询。获取连接也是一个异步上下文管理器,当我们在完成后将连接返回到池中,所以我们需要像创建池时那样使用async with语法。使用这种方法,我们可以重写我们的代码以并发运行多个查询。

列表 5.7 建立连接池并并发运行查询

import asyncio
import asyncpg

product_query = \
    """
SELECT
p.product_id,
p.product_name,
p.brand_id,
s.sku_id,
pc.product_color_name,
ps.product_size_name
FROM product as p
JOIN sku as s on s.product_id = p.product_id
JOIN product_color as pc on pc.product_color_id = s.product_color_id
JOIN product_size as ps on ps.product_size_id = s.product_size_id
WHERE p.product_id = 100"""

async def query_product(pool):
    async with pool.acquire() as connection:
        return await connection.fetchrow(product_query)

async def main():
    async with asyncpg.create_pool(host='127.0.0.1',
                                   port=5432,
                                   user='postgres',
                                   password='password',
                                   database='products',
                                   min_size=6,
                                   max_size=6) as pool:    ❶

        await asyncio.gather(query_product(pool),
                             query_product(pool))          ❷

asyncio.run(main())

❶ 创建一个包含六个连接的连接池。

❷ 并发执行两个产品查询。

在前面的列表中,我们首先创建了一个包含六个连接的连接池。然后我们创建了两个查询协程对象,并使用asyncio.gather将它们安排为并发运行。在我们的query_product协程中,我们首先使用pool.acquire()从池中获取一个连接。这个协程将暂停运行,直到从连接池中可用一个连接。我们使用async with块来做这件事;这将确保一旦我们离开该块,连接将被返回到池中。这是很重要的,因为我们如果不这样做,可能会耗尽连接,最终导致应用程序永远挂起,等待永远不会出现的连接。一旦我们获取了一个连接,我们就可以像之前示例中那样运行我们的查询。

我们可以将这个例子扩展到运行 10,000 个查询,通过创建 10,000 个不同的查询协程对象。为了使这个例子更有趣,我们将编写一个同步运行查询的版本,并比较它们所需的时间。

列表 5.8 同步查询与并发

import asyncio
import asyncpg
from util import async_timed

product_query = \
    """
SELECT
p.product_id,
p.product_name,
p.brand_id,
s.sku_id,
pc.product_color_name,
ps.product_size_name
FROM product as p
JOIN sku as s on s.product_id = p.product_id
JOIN product_color as pc on pc.product_color_id = s.product_color_id
JOIN product_size as ps on ps.product_size_id = s.product_size_id
WHERE p.product_id = 100"""

async def query_product(pool):
    async with pool.acquire() as connection:
        return await connection.fetchrow(product_query)

@async_timed()
async def query_products_synchronously(pool, queries):
    return [await query_product(pool) for _ in range(queries)]

@async_timed()
async def query_products_concurrently(pool, queries):
    queries = [query_product(pool) for _ in range(queries)]
    return await asyncio.gather(*queries)

async def main():
    async with asyncpg.create_pool(host='127.0.0.1',
                                   port=5432,
                                   user='postgres',
                                   password='password',
                                   database='products',
                                   min_size=6,
                                   max_size=6) as pool:
        await query_products_synchronously(pool, 10000)
        await query_products_concurrently(pool, 10000)

asyncio.run(main())

query_products_synchronously中,我们在列表推导式中放入了一个await,这将强制每个query_product调用按顺序运行。然后,在query_products_concurrently中,我们创建了一个我们想要运行的协程列表,并使用gather并发运行它们。在主协程中,我们使用 10,000 个查询分别运行我们的同步和并发版本。虽然具体结果可能会根据你的硬件有很大差异,但并发版本的速度几乎是串行版本的五倍:

starting <function query_products_synchronously at 0x1219ea1f0> with args (<asyncpg.pool.Pool object at 0x12164a400>, 10000) {}
finished <function query_products_synchronously at 0x1219ea1f0> in 21.8274 second(s)
starting <function query_products_concurrently at 0x1219ea310> with args (<asyncpg.pool.Pool object at 0x12164a400>, 10000) {}
finished <function query_products_concurrently at 0x1219ea310> in 4.8464 second(s)

这样的改进意义重大,但如果我们需要更高的吞吐量,我们还可以进行更多改进。由于我们的查询相对较快,这段代码是 CPU 密集型与 I/O 密集型的混合。在第六章中,我们将看到如何从这个设置中挤出更多的性能。

到目前为止,我们已经看到了在没有失败的情况下如何将数据插入到我们的数据库中。但是,如果我们正在插入产品过程中遇到失败,会发生什么?我们不希望数据库中出现不一致的状态,因此这就是数据库事务发挥作用的地方。接下来,我们将看到如何使用异步上下文管理器来获取和管理事务。

5.6 使用 asyncpg 管理事务

事务是许多数据库中的一个核心概念,它满足 ACID(原子性、一致性、隔离性、持久性)属性。一个 事务 由一个或多个作为单个原子单元执行的 SQL 语句组成。如果我们执行事务内的语句时没有发生错误,我们将 提交 语句到数据库,使任何更改成为数据库的永久部分。如果有任何错误,我们将 回滚 语句,就好像它们从未发生过一样。在我们的产品数据库的上下文中,如果我们尝试插入一个重复的品牌,或者如果我们违反了我们设置的数据库约束,我们可能需要回滚一组更新。

在 asyncpg 中,处理事务的最简单方法是通过使用 connection .transaction 异步上下文管理器来启动它们。然后,如果在 async with 块中发生异常,事务将自动回滚。如果一切执行成功,它将自动提交。让我们看看如何创建一个事务并执行两个简单的 insert 语句来添加几个品牌。

列表 5.9 创建事务

import asyncio
import asyncpg

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    async with connection.transaction():                      ❶
        await connection.execute("INSERT INTO brand "
                                 "VALUES(DEFAULT, 'brand_1')")
        await connection.execute("INSERT INTO brand "
                                 "VALUES(DEFAULT, 'brand_2')")

    query = """SELECT brand_name FROM brand
                WHERE brand_name LIKE 'brand%'"""
    brands = await connection.fetch(query)                    ❷
    print(brands)

    await connection.close()

asyncio.run(main())

❶ 开始数据库事务。

❷ 选择品牌以确保我们的事务已提交。

假设我们的事务成功提交,我们应该看到 [<Record brand_name='brand_1'>, <Record brand_name='brand_2'>] 打印到控制台。这个例子假设运行两个 insert 语句时没有错误,并且一切都被成功提交。为了演示回滚发生时会发生什么,让我们强制一个 SQL 错误。为了测试这一点,我们将尝试插入两个具有相同主 key id 的品牌。我们的第一个插入将成功,但我们的第二个插入将引发重复键错误。

列表 5.10 处理事务中的错误

import asyncio
import logging
import asyncpg

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    try:
        async with connection.transaction():
            insert_brand = "INSERT INTO brand VALUES(9999, 'big_brand')"
            await connection.execute(insert_brand)
            await connection.execute(insert_brand)            ❶
    except Exception:
        logging.exception('Error while running transaction')  ❷
    finally:
        query = """SELECT brand_name FROM brand
                    WHERE brand_name LIKE 'big_%'"""
        brands = await connection.fetch(query)                ❸
        print(f'Query result was: {brands}')

        await connection.close()

asyncio.run(main())

❶ 这个插入语句会因为重复的主键而出错。

❷ 如果发生异常,记录错误。

❸ 选择品牌以确保我们没有插入任何内容。

在以下代码中,我们的第二个 insert 语句抛出了一个错误。这导致以下输出:

ERROR:root:Error while running transaction
Traceback (most recent call last):
  File "listing_5_10.py", line 16, in main
    await connection.execute("INSERT INTO brand "
  File "asyncpg/connection.py", line 272, in execute
    return await self._protocol.query(query, timeout)
  File "asyncpg/protocol/protocol.pyx", line 316, in query
asyncpg.exceptions.UniqueViolationError: duplicate key value violates unique constraint "brand_pkey"
DETAIL:  Key (brand_id)=(9999) already exists.
Query result was: []

我们首先检索了一个异常,因为我们尝试插入一个重复的键,然后看到我们的 select 语句的结果为空,这表明我们成功回滚了事务。

5.6.1 嵌套事务

asyncpg 还通过 Postgres 的一个名为保存点的功能支持嵌套事务的概念。保存点是通过 Postgres 中的SAVEPOINT命令定义的。当我们定义一个保存点时,我们可以回滚到那个保存点,并且在此保存点之后执行的任何查询都将回滚,但在此保存点之前成功执行的任何查询则不会回滚。

在 asyncpg 中,我们可以在现有事务中调用connection.transaction上下文管理器来创建一个保存点。然后,如果这个内部事务中发生任何错误,它将被回滚,但外部事务不受影响。让我们通过在一个事务中插入一个品牌,然后在嵌套事务中尝试插入一个已经存在于我们数据库中的颜色来尝试这个方法。

列表 5.11 一个嵌套事务

import asyncio
import asyncpg
import logging

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    async with connection.transaction():
        await connection.execute("INSERT INTO brand VALUES(DEFAULT, 'my_new_brand')")

        try:
            async with connection.transaction():
                await connection.execute("INSERT INTO product_color VALUES(1, 'black')")
        except Exception as ex:
            logging.warning('Ignoring error inserting product color', exc_info=ex)

    await connection.close()

asyncio.run(main())

当我们运行这段代码时,我们的第一个INSERT语句成功执行,因为我们数据库中还没有这个品牌。我们的第二个INSERT语句因为重复键错误而失败。由于这个第二个插入语句在一个事务中,我们捕获并记录了异常,尽管有错误,但我们的外部事务没有被回滚,品牌被正确插入。如果我们没有嵌套事务,第二个插入语句也会回滚我们的品牌插入。

5.6.2 手动管理事务

到目前为止,我们一直使用异步上下文管理器来处理提交和回滚我们的事务。由于这比我们自己管理事物更简洁,所以通常是最好的方法。然而,我们可能会发现自己处于需要手动管理事务的情况。例如,我们可能希望在回滚时执行自定义代码,或者我们可能希望在除异常之外的条件回滚。

要手动管理一个事务,我们可以在上下文管理器之外使用由connection.transaction返回的事务管理器。当我们这样做时,我们需要手动调用它的start方法来启动一个事务,然后在成功时调用commit,在失败时调用rollback。让我们通过重写我们的第一个示例来看看如何做。

列表 5.12 手动管理事务

import asyncio
import asyncpg
from asyncpg.transaction import Transaction

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    transaction: Transaction = connection.transaction()          ❶
    await transaction.start()                                    ❷
    try:
        await connection.execute("INSERT INTO brand "
                                 "VALUES(DEFAULT, 'brand_1')")
        await connection.execute("INSERT INTO brand "
                                 "VALUES(DEFAULT, 'brand_2')")
    except asyncpg.PostgresError:
        print('Errors, rolling back transaction!')
        await transaction.rollback()                             ❸
    else:
        print('No errors, committing transaction!')
        await transaction.commit()                               ❹

    query = """SELECT brand_name FROM brand
                WHERE brand_name LIKE 'brand%'"""
    brands = await connection.fetch(query)
    print(brands)

    await connection.close()

asyncio.run(main())

❶ 创建一个事务实例。

❷ 启动事务。

❸ 如果有异常,回滚。

❹ 如果没有异常,提交。

我们首先使用与 async 上下文管理器语法相同的调用方法创建一个事务,但这次我们存储这个调用返回的 Transaction 实例。将这个类视为我们事务的管理器,因为有了这个,我们将能够执行所需的任何提交和回滚。一旦我们有了事务实例,我们就可以调用 start 协程。这将执行一个查询以在 Postgres 中启动事务。然后,在 try 块中,我们可以执行任何我们想要的查询。在这种情况下,我们插入两个品牌。如果有任何 INSERT 语句出错,我们将遇到 except 块,并通过调用 rollback 协程回滚事务。如果没有错误,我们调用 commit 协程,这将结束事务,并将事务中的任何更改永久保存在数据库中。

到目前为止,我们一直在一次性将所有查询结果拉入内存的方式运行我们的查询。这对于许多应用来说是合理的,因为许多查询会返回小的结果集。然而,我们可能遇到的情况是处理一个可能不会一次性放入内存的大结果集。在这些情况下,我们可能希望流式传输结果以避免对系统的随机访问内存(RAM)造成压力。接下来,我们将探讨如何使用 asyncpg 来实现这一点,并在过程中介绍异步生成器。

5.7 异步生成器和流式结果集

asyncpg 默认 fetch 实现的一个缺点是它会将我们从任何查询中获取的所有数据拉入内存。这意味着如果我们有一个返回数百万行的查询,我们将尝试将整个集合从数据库传输到请求的机器。回到我们的产品数据库示例,假设我们更加成功,有数十亿个产品可用。我们很可能会有一些查询会返回非常大的结果集,这可能会损害性能。

当然,我们可以在查询中应用 LIMIT 语句并分页,这对于许多应用来说是有意义的,如果不是大多数应用。然而,这种方法存在开销,因为我们可能需要多次发送相同的查询,这可能会给数据库带来额外的压力。如果我们发现自己受到这些问题的阻碍,那么在需要时仅对特定查询进行流式处理可能是有意义的。这将减少我们应用层的内存消耗,同时减轻数据库的负载。然而,这也意味着需要通过网络到数据库进行更多的往返。

Postgres 支持通过 游标 概念进行流式查询结果。将游标视为我们在迭代结果集时当前所在位置的指针。当我们从流式查询中获取单个结果时,我们将游标向前推进到下一个元素,依此类推,直到没有更多结果为止。

使用 asyncpg,我们可以直接从连接中获取游标,然后我们可以使用它来执行流式查询。asyncpg 中的游标使用了一个我们尚未使用的异步特性,称为异步生成器。异步生成器异步地逐个生成结果,类似于常规 Python 生成器。它们还允许我们使用特殊的for循环语法来遍历我们得到的结果。为了完全理解这是如何工作的,我们将首先介绍异步生成器以及async for语法来循环这些生成器。

5.7.1 异步生成器的介绍

许多开发者对同步 Python 世界中的生成器很熟悉。生成器是“四人帮”在 1994 年出版的《设计模式:可复用面向对象软件元素》一书中使迭代器设计模式闻名的一种实现。这种模式允许我们“懒加载”地定义数据序列,并逐个元素遍历它们。这对于可能非常大的数据序列很有用,我们不需要一次性将所有内容存储在内存中。

一个简单的同步生成器是一个包含yield语句而不是return语句的正常 Python 函数。例如,让我们看看如何创建和使用生成器来返回正整数,从零开始直到指定的结束。

列表 5.13 一个同步生成器

def positive_integers(until: int):
    for integer in range(until):
        yield integer

positive_iterator = positive_integers(2)

print(next(positive_iterator))
print(next(positive_iterator))

在前面的列表中,我们创建了一个函数,该函数接受一个整数,我们希望对其进行计数。然后我们启动一个循环,直到达到指定的结束整数。然后,在循环的每次迭代中,我们yield序列中的下一个整数。当我们调用positive_integers(2)时,我们不会返回整个列表,甚至不会在我们的方法中运行循环。实际上,如果我们检查positive_iterator的类型,我们会得到<class 'generator'>`。

然后,我们使用next实用函数遍历我们的生成器。每次我们调用next,这将在positive_integers中触发一次for循环的迭代,给我们每个迭代的yield语句的结果。因此,列表 5.13 中的代码将打印01到控制台。我们也可以使用带有生成器的for循环来遍历生成器中的所有值,而不是使用next

这适用于同步方法,但如果我们想使用协程异步地生成一系列值怎么办?使用我们的数据库示例,如果我们想生成从数据库“懒加载”的一系列行,怎么办?我们可以使用 Python 的异步生成器和特殊的async for语法来做这件事。为了演示一个简单的异步生成器,让我们从我们的正整数示例开始,但引入一个需要几秒钟才能完成的协程调用。我们将使用第二章中的delay函数来做这个示例。

列表 5.14 一个简单的异步生成器

import asyncio
from util import delay, async_timed

async def positive_integers_async(until: int):
    for integer in range(1, until):
        await delay(integer)
        yield integer

@async_timed()
async def main():
    async_generator = positive_integers_async(3)
    print(type(async_generator))
    async for number in async_generator:
        print(f'Got number {number}')

asyncio.run(main())

运行上述列表,我们将看到类型不再是普通的生成器,而是<class 'async_generator'>,一个异步生成器。异步生成器与普通生成器的不同之处在于,它不是生成普通 Python 对象作为元素,而是生成我们可以等待直到获取结果的协程。正因为如此,我们的普通 for 循环和next函数不能与这些类型的生成器一起使用。相反,我们有特殊的语法async for来处理这些类型的生成器。在这个例子中,我们使用这种语法来遍历positive_integers_async

此代码将打印数字 1 和 2,在返回第一个数字前等待 1 秒,在返回第二个数字前等待 2 秒。请注意,这并不是在并发运行生成的协程;相反,它是在一系列中逐个生成和等待这些协程。

5.7.2 使用流式光标与异步生成器

异步生成器的概念与流式数据库光标的概念很好地结合在一起。使用这些生成器,我们将能够使用简单的for循环语法逐行获取数据。要使用 asyncpg 进行流式处理,我们首先需要开始一个事务,因为 Postgres 要求使用光标时必须这样做。一旦我们开始了一个事务,我们就可以在Connection类上调用cursor方法来获取一个光标。当我们调用cursor方法时,我们将传递我们想要流式传输的查询。此方法将返回一个异步生成器,我们可以使用它一次流式传输一个结果。

为了熟悉如何进行此操作,让我们运行一个查询以从我们的数据库中获取所有产品,并使用光标。然后我们将使用async for语法逐个从结果集中获取元素。

列表 5.15 逐个流式传输结果

import asyncpg
import asyncio
import asyncpg

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')

    query = 'SELECT product_id, product_name FROM product'
    async with connection.transaction():
        async for product in connection.cursor(query):
            print(product)

    await connection.close()

asyncio.run(main())

上述列表将逐个打印出我们的所有产品。尽管我们在这个表中放入了 1,000 个产品,但我们一次只会将几个拉入内存。在撰写本文时,为了减少网络流量,光标默认一次预取 50 条记录。我们可以通过设置prefetch参数来更改此行为,以预取我们想要的任何数量的元素。

我们还可以使用这些光标在结果集中跳转,并一次获取任意数量的行。让我们通过从我们刚才使用的查询中间获取一些记录来查看如何做到这一点。

列表 5.16 移动光标并获取记录

import asyncpg
import asyncio

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    async with connection.transaction():
        query = 'SELECT product_id, product_name from product'
        cursor = await connection.cursor(query)                 ❶
        await cursor.forward(500)                               ❷
        products = await cursor.fetch(100)                      ❸
        for product in products:
            print(product)

    await connection.close()

asyncio.run(main())

❶ 为查询创建一个光标。

❷ 将光标向前移动 500 条记录。

❸ 获取下 100 条记录。

前面的代码列表将首先为我们的查询创建一个游标。请注意,我们像协程一样在 await 语句中使用它,而不是异步生成器;这是因为在一个异步生成器中,游标既是异步生成器也是可等待的。在大多数情况下,这类似于使用异步生成器,但在以这种方式创建游标时,预取行为会有所不同。使用这种方法,我们无法设置预取值。这样做会引发一个 InterfaceError

一旦我们有了游标,我们就使用它的 forward 协程方法在结果集中前进。这将有效地跳过我们产品表中的前 500 条记录。一旦我们移动了游标,我们就获取下一个 100 个产品并将它们逐个打印到控制台。

这些类型的游标默认情况下是不可滚动的,这意味着我们只能向前移动结果集。如果您想使用可以向前和向后移动的可滚动游标,您需要手动执行 DECLARE ... SCROLL CURSOR SQL 语句来实现(您可以在 Postgres 文档中了解更多关于如何做到这一点的信息,请参阅www.postgresql.org/docs/current/plpgsql-cursors.html)。

如果我们有一个非常大的结果集,并且不想让整个集合驻留在内存中,这两种技术都是有用的。我们在列表 5.16 中看到的 async for 循环对于遍历整个集合是有用的,而创建一个游标并使用 fetch 协程方法对于获取一批记录或跳过一组记录是有用的。

然而,如果我们只想在预取的同时检索固定数量的元素,并且仍然使用 async for 循环,那该怎么办呢?我们可以在我们的 async for 循环中添加一个计数器,在看到一定数量的元素后退出,但如果我们需要在代码中经常这样做,这并不是特别可重用的。为了使这个过程更容易,我们可以构建自己的异步生成器。我们将这个生成器称为 take。这个生成器将接受一个异步生成器和我们要提取的元素数量。让我们来研究一下如何创建这个生成器,并从结果集中获取前五个元素。

列表 5.17 使用异步生成器获取特定数量的元素

import asyncpg
import asyncio

async def take(generator, to_take: int):
    item_count = 0
    async for item in generator:
        if item_count > to_take - 1:
            return
        item_count = item_count + 1
        yield item

async def main():
    connection = await asyncpg.connect(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       database='products',
                                       password='password')
    async with connection.transaction():
        query = 'SELECT product_id, product_name from product'
        product_generator = connection.cursor(query)
        async for product in take(product_generator, 5):
            print(product)

        print('Got the first five products!')

    await connection.close()

asyncio.run(main())

我们的 take 异步生成器使用 item_count 跟踪到目前为止我们已经看到了多少项。然后我们进入一个 async_for 循环并 yield 我们看到的每个记录。一旦我们 yield,我们就检查 item_count 来看我们是否已经 yield 了调用者请求的项数。如果我们已经 yield 了,我们就 return,这会结束异步生成器。在我们的主协程中,我们可以在正常的异步 for 循环中使用 take。在这个例子中,我们使用它来请求游标的前五个元素,得到以下输出:

<Record product_id=1 product_name='among paper foot see shoe ride age'>
<Record product_id=2 product_name='major wait half speech lake won't'>
<Record product_id=3 product_name='war area speak listen horse past edge'>
<Record product_id=4 product_name='smell proper force road house planet'>
<Record product_id=5 product_name='ship many dog fine surface truck'>
Got the first five products!

虽然我们已经在代码中自行定义了这一点,但一个开源库aiostream提供了处理异步生成器的这个功能以及更多。你可以在aiostream.readthedocs.io查看这个库的文档。

摘要

在本章中,我们学习了使用异步数据库连接在 Postgres 中创建和选择记录的基础知识。现在你应该能够运用这些知识来创建并发数据库客户端。

  • 我们学习了如何使用 asyncpg 连接到 Postgres 数据库。

  • 我们学习了如何使用各种 asyncpg 协程来创建表、插入记录和执行单个查询。

  • 我们学习了如何使用 asyncpg 创建连接池。这允许我们使用 asyncio 的 API 方法,如gather,同时运行多个查询。通过这种方式,我们可以通过并行运行查询来潜在地加快我们的应用程序的速度。

  • 我们学习了如何使用 asyncpg 管理事务。事务允许我们在失败的结果下回滚对数据库所做的任何更改,即使在发生意外情况时也能保持数据库的一致状态。

  • 我们学习了如何创建异步生成器以及如何使用它们进行流式数据库连接。我们可以将这两个概念结合起来处理无法一次性全部放入内存的大数据集。

6 处理 CPU 密集型工作

本章涵盖

  • multiprocessing 库

  • 创建进程池以处理 CPU 密集型工作

  • 使用asyncawait管理 CPU 密集型工作

  • 使用 MapReduce 在 asyncio 中解决 CPU 密集型问题

  • 使用锁处理多个进程之间的共享数据

  • 通过 CPU 和 I/O 密集型操作提高工作性能

到目前为止,我们一直专注于在并发运行 I/O 密集型工作时,我们可以通过 asyncio 获得性能提升。运行 I/O 密集型工作是 asyncio 的核心,而且按照我们迄今为止编写的代码,我们需要小心不要在我们的协程中运行任何 CPU 密集型代码。这似乎严重限制了 asyncio,但这个库的功能远不止处理 I/O 密集型工作。

asyncio 有一个 API 可以与 Python 的 multiprocessing 库交互。这让我们可以使用 async await语法以及与多个进程一起使用的 asyncio API。使用这个,我们可以在使用 CPU 密集型代码时获得 asyncio 库的好处。这允许我们在进行数学计算或数据处理等 CPU 密集型工作时获得性能提升,从而绕过全局解释器锁,充分利用多核机器。

在本章中,我们将首先学习 multiprocessing 模块,以便熟悉执行多个进程的概念。然后,我们将学习进程池执行器以及如何将它们钩入 asyncio。然后,我们将利用这些知识使用 MapReduce 解决 CPU 密集型问题。我们还将学习在多个进程之间管理共享状态,并介绍锁的概念以避免并发错误。最后,我们将探讨如何使用 multiprocessing 来提高第五章中提到的既 I/O 密集型又 CPU 密集型应用程序的性能。

6.1 介绍 multiprocessing 库

在第一章中,我们介绍了全局解释器锁。全局解释器锁防止 Python 字节码并行执行。这意味着,对于除了 I/O 密集型任务以外的任何任务,排除一些小异常,使用多线程不会提供任何性能优势,就像在 Java 和 C++等语言中那样。看起来我们可能无法在 Python 中找到解决方案来处理可并行化的 CPU 密集型工作,但这就是 multiprocessing 库提供解决方案的地方。

与我们的父进程通过创建线程来并行化任务不同,我们反而创建子进程来处理我们的工作。每个子进程都将拥有自己的 Python 解释器和受到 GIL(全局解释器锁)的影响,但我们将拥有多个解释器,每个解释器都有自己的 GIL。假设我们在一个拥有多个 CPU 核心的机器上运行,这意味着我们可以有效地并行化任何 CPU 密集型工作负载。即使我们的进程数量超过了核心数量,我们的操作系统也会使用抢占式多任务处理来允许我们的多个任务并发运行。这种设置既是并发的,也是并行的。

要开始使用多进程库,让我们先并行运行几个函数。我们将使用一个非常简单的 CPU 密集型函数,即从零计数到一个大数,以检查 API 的工作方式以及性能优势。

列表 6.1 使用多进程的并行进程

import time
from multiprocessing import Process

def count(count_to: int) -> int:
    start = time.time()
    counter = 0
    while counter < count_to:
        counter = counter + 1
    end = time.time()
    print(f'Finished counting to {count_to} in {end-start}')
    return counter

if __name__ == "__main__":
    start_time = time.time()

    to_one_hundred_million = Process(target=count, args=(100000000,))  ❶
    to_two_hundred_million = Process(target=count, args=(200000000,))

    to_one_hundred_million.start()                                     ❷
    to_two_hundred_million.start()

    to_one_hundred_million.join()                                      ❸
    to_two_hundred_million.join()

    end_time = time.time()
    print(f'Completed in {end_time-start_time}')

❶ 创建一个运行倒计时函数的进程。

❷ 启动进程。此方法会立即返回。

❸ 等待进程完成。此方法会阻塞,直到进程完成。

在前面的代码示例中,我们创建了一个简单的计数函数,该函数接受一个整数并逐个计数,直到我们数到传递给我们的整数。然后我们创建了两个进程,一个用于计数到 10 亿,另一个用于计数到 20 亿。Process类接受两个参数,一个target参数,表示我们希望在进程中运行的函数名称,以及一个args参数,表示传递给函数的参数元组。然后我们在每个进程中调用start方法。此方法会立即返回,并开始运行进程。在这个例子中,我们一个接一个地启动了这两个进程。然后我们在每个进程中调用join方法。这将导致我们的主进程阻塞,直到每个进程完成。如果没有这个,我们的程序几乎会立即退出并终止子进程,因为没有东西在等待它们的完成。列表 6.1 并行运行了两个计数函数;假设我们在至少拥有两个 CPU 核心的机器上运行,我们应该看到速度的提升。当这段代码在一个 2.5 GHz 8 核心的机器上运行时,我们得到了以下结果:

Finished counting down from 100000000 in 5.3844
Finished counting down from 200000000 in 10.6265
Completed in 10.8586

总的来说,我们的倒计时函数总共花费了略超过 16 秒,但我们的应用程序仅用了不到 11 秒就完成了。这使我们相对于顺序运行节省了大约 5 秒的时间。当然,当你运行这段代码时,你看到的结果将高度依赖于你的机器,但你应该看到与这个方向上相当的结果。

注意到在我们的应用程序中添加了之前没有的 if __name__ == "__main__": 这一行。这是多进程库的一个特性;如果你不添加这一行,你可能会收到以下错误:An attempt has been made to start a new process before the current process has finished its bootstrapping phase. 这种情况发生的原因是为了防止其他人可能无意中导入你的代码时启动多个进程。

这给我们带来了相当的性能提升;然而,这很尴尬,因为我们必须为每个启动的进程调用 startjoin。我们也不知道哪个进程会先完成;如果我们想做一些像 asyncio.as_completed 的事情,在结果完成时进行处理,那我们就无能为力了。join 方法也不会返回目标函数返回的值;实际上,目前没有不用共享进程间内存就能获取函数返回值的方法!

这个 API 对于简单情况是有效的,但如果我们的函数需要获取返回值或者希望结果一出现就进行处理,那么它显然是不适用的。幸运的是,进程池为我们提供了一种处理这种情况的方法。

6.2 使用进程池

在上一个例子中,我们手动创建了进程,并调用它们的 startjoin 方法来运行并等待它们。我们发现了这种方法存在几个问题,从代码质量到无法访问进程返回的结果。多进程模块提供了一个 API,让我们可以处理这个问题,称为进程池

进程池是一个类似于我们在第五章中看到的连接池的概念。在这个情况下,区别在于,我们不是创建到数据库的连接集合,而是创建了一个可以用来并行运行函数的 Python 进程集合。当我们有一个希望在进程中运行的 CPU 密集型函数时,我们直接请求池为我们运行它。在幕后,这将在一个可用的进程中执行这个函数,运行它并返回该函数的返回值。为了了解进程池是如何工作的,让我们创建一个简单的进程池,并用它来运行几个“hello world”风格的函数。

列表 6.2 创建进程池

from multiprocessing import Pool

def say_hello(name: str) -> str:
    return f'Hi there, {name}'

if __name__ == "__main__":
    with Pool() as process_pool:                                 ❶
        hi_jeff = process_pool.apply(say_hello, args=('Jeff',))  ❷
        hi_john = process_pool.apply(say_hello, args=('John',))
        print(hi_jeff)
        print(hi_john)

❶ 创建一个新的进程池。

❷ 在一个单独的进程中运行 say_hello 函数,并传递参数 'Jeff ',然后获取结果。

在前面的列表中,我们使用 with Pool() as process_pool 创建了一个进程池。这是一个上下文管理器,因为一旦我们用完池,我们就需要适当地关闭我们创建的 Python 进程。如果我们不这样做,我们可能会遇到进程泄漏的问题,这可能导致资源利用率问题。当我们实例化这个池时,它将自动创建与你在其上运行的机器上的 CPU 核心数量相等的 Python 进程。你可以通过运行 multiprocessing.cpu_count() 函数来确定你有多少个 CPU 核心。当你调用 Pool() 时,你可以将 processes 参数设置为任何你想要的整数。默认值通常是一个很好的起点。

接下来,我们使用进程池的 apply 方法在单独的进程中运行我们的 say_hello 函数。这个方法看起来与我们之前使用 Process 类所做的方法很相似,其中我们传递了一个目标函数和一个参数元组。这里的区别是,我们不需要自己启动进程或对其调用 join。我们还得到了函数的返回值,这在之前的例子中是无法做到的。运行此代码,你应该会看到以下内容打印出来:

Hi there, Jeff
Hi there, John

这可以工作,但有一个问题。apply 方法会阻塞直到我们的函数完成。这意味着,如果每个 say_hello 调用需要 10 秒,我们的整个程序运行时间将大约是 20 秒,因为我们已经按顺序运行了事情,这抵消了并行运行的意义。我们可以通过使用进程池的 apply_async 方法来解决这个问题。

6.2.1 使用异步结果

在上一个示例中,每次对 apply 的调用都会阻塞,直到我们的函数完成。如果我们想要构建一个真正并行的流程,这是不行的。为了解决这个问题,我们可以使用 apply_async 方法代替。这个方法会立即返回一个 AsyncResult,并将在后台开始运行进程。一旦我们有了 AsyncResult,我们可以使用它的 get 方法来阻塞并获取函数调用的结果。让我们以我们的 say_hello 示例为例,并对其进行修改以使用异步结果。

列表 6.3 使用进程池异步结果

from multiprocessing import Pool

def say_hello(name: str) -> str:
    return f'Hi there, {name}'

if __name__ == "__main__":
    with Pool() as process_pool:
        hi_jeff = process_pool.apply_async(say_hello, args=('Jeff',))
        hi_john = process_pool.apply_async(say_hello, args=('John',))
        print(hi_jeff.get())
        print(hi_john.get())

当我们调用 apply_async 时,我们的两个 say_hello 调用将立即在不同的进程中开始。然后,当我们调用 get 方法时,我们的父进程将阻塞,直到每个进程返回一个值。这使得事物可以并发运行,但如果我们假设 hi_jeff 需要 10 秒,而 hi_john 只需要 1 秒,会发生什么?在这种情况下,由于我们首先在 hi_jeff 上调用 get,我们的程序将阻塞 10 秒,然后才打印出我们的 hi_john 消息,尽管我们实际上在 1 秒后就准备好了。如果我们想要在事情完成时立即做出响应,我们就会遇到问题。在这种情况下,我们真正想要的是类似 asyncio 的 as_completed 的东西。接下来,让我们看看如何使用 asyncio 与进程池执行器一起使用,以便我们能够解决这个问题。

6.3 使用 asyncio 与进程池执行器一起使用

我们已经看到了如何使用进程池来并发运行 CPU 密集型操作。这些进程池对于简单的用例来说很好,但 Python 在concurrent.futures模块中提供了对多进程进程池的抽象。此模块包含用于进程和线程的executors,这些 executors 可以单独使用,也可以与 asyncio 互操作。为了开始,我们将学习ProcessPoolExecutor的基础知识,它与ProcessPool类似。然后,我们将看到如何将其与 asyncio 集成,这样我们就可以使用其 API 函数的强大功能,例如gather

6.3.1 介绍进程池执行器

Python 的进程池 API 与进程紧密耦合,但多进程是实现抢占式多任务处理的两种方法之一,另一种是多线程。如果我们需要轻松地更改处理并发的方式,无缝地在进程和线程之间切换怎么办?如果我们想要这样的设计,我们需要构建一个抽象,它包含将工作分配给一组资源的核心,而这些资源是进程、线程还是其他结构无关紧要。

concurrent.futures模块通过Executor抽象类为我们提供了这个抽象。这个类定义了两个用于异步运行工作的方法。第一个是submit,它将接受一个可调用对象并返回一个Future(注意,这与 asyncio futures 不同,但它是concurrent.futures模块的一部分)——这相当于我们在上一节中看到的Pool.apply_async方法。第二个是map。这个方法将接受一个可调用对象和一个函数参数列表,然后异步执行列表中的每个参数。它返回一个结果迭代器,类似于asyncio.as_completed,因为结果一旦完成就可用。Executor有两个具体实现:ProcessPoolExecutorThreadPoolExecutor。由于我们正在使用多个进程来处理 CPU 密集型工作,我们将重点关注ProcessPoolExecutor。在第七章中,我们将使用ThreadPoolExecutor来检查线程。为了了解ProcessPoolExecutor的工作原理,我们将使用一些小数字和一些大数字重用我们的计数示例,以展示结果是如何到来的。

列表 6.4 进程池执行器

import time
from concurrent.futures import ProcessPoolExecutor

def count(count_to: int) -> int:
    start = time.time()
    counter = 0
    while counter < count_to:
        counter = counter + 1
    end = time.time()
    print(f'Finished counting to {count_to} in {end - start}')
    return counter

if __name__ == "__main__":
    with ProcessPoolExecutor() as process_pool:
        numbers = [1, 3, 5, 22, 100000000]
        for result in process_pool.map(count, numbers):
            print(result)

与之前类似,我们在context manager中创建一个ProcessPoolExecutor。资源数量默认为机器的 CPU 核心数,就像进程池一样。然后我们使用process_pool.map和我们的count函数以及我们想要计数的数字列表。

当我们运行这个程序时,我们会看到,当我们用较小的数字调用countdown时,它们会很快完成并几乎立即打印出来。然而,使用100000000的调用将会花费更长的时间,并且会在几个较小的数字之后打印出来,给我们以下输出:

Finished counting down from 1 in 9.5367e-07
Finished counting down from 3 in 9.5367e-07
Finished counting down from 5 in 9.5367e-07
Finished counting down from 22 in 3.0994e-06
1
3
5
22
Finished counting down from 100000000 in 5.2097
100000000

虽然看起来这与 asyncio.as_completed 的效果相同,但迭代的顺序是基于我们传递给 numbers 列表的顺序确定的。这意味着如果 100000000 是我们的第一个数字,我们会在打印出其他更早完成的结果之前,一直等待这个调用完成。这意味着我们的响应性并不如 asyncio.as_completed

6.3.2 使用 asyncio 事件循环的进程池执行器

现在我们已经了解了进程池执行器的基本工作原理,让我们看看如何将它们连接到 asyncio 的 event 循环中。这将使我们能够使用我们在第四章中学到的 API 函数,如 gatheras_completed,来管理多个进程。

使用 asyncio 创建进程池执行器与我们刚刚学到的没有区别;也就是说,我们在上下文管理器中创建一个。一旦我们有了池,我们就可以使用 asyncio 事件循环上的一个特殊方法 run_in_executor。这个方法将接受一个可调用对象和一个执行器(可以是线程池或进程池),并在池中运行那个可调用对象。然后它返回一个可等待对象,我们可以在 await 语句中使用它,或者将其传递给 API 函数,如 gather

让我们使用进程池执行器实现我们之前的计数示例。我们将向执行器提交多个计数任务,并使用 gather 等待它们全部完成。run_in_executor 只接受一个可调用对象,不允许我们提供函数参数;因此,为了解决这个问题,我们将使用部分函数应用来构建不带参数的 countdown 调用。

什么是部分函数应用?

部分函数应用在 functools 模块中实现。部分应用接受一个接受一些参数的函数,并将其转换为一个接受较少参数的函数。它是通过“冻结”我们提供的某些参数来实现的。例如,我们的 count 函数接受一个参数。我们可以通过使用 functools.partial 并传递我们想要调用的参数来将其转换为一个不带参数的函数。如果我们想要有一个 count(42) 的调用,但传递没有参数,我们可以这样写 call_with_42 = functools.partial(count, 42),然后我们可以调用它为 call_with_42().

列表 6.5 使用 asyncio 的进程池执行器

import asyncio
from asyncio.events import AbstractEventLoop
from concurrent.futures import ProcessPoolExecutor
from functools import partial
from typing import List

def count(count_to: int) -> int:
    counter = 0
    while counter < count_to:
        counter = counter + 1
    return counter

async def main():
    with ProcessPoolExecutor() as process_pool:
        loop: AbstractEventLoop = asyncio.get_running_loop()              ❶
        nums = [1, 3, 5, 22, 100000000]
        calls: List[partial[int]] = [partial(count, num) for num in nums] ❷
        call_coros = []

        for call in calls:
            call_coros.append(loop.run_in_executor(process_pool, call))

        results = await asyncio.gather(*call_coros)                       ❸

        for result in results:
            print(result)

if __name__ == "__main__":
    asyncio.run(main())

❶ 为带有其参数的 countdown 创建一个部分应用函数。

❷ 将每个调用提交给进程池并将它追加到列表中。

❸ 等待所有结果完成。

我们首先创建一个进程池执行器,就像之前做的那样。一旦我们有了这个,我们就获取 asyncio 事件循环,因为 run_in_executorAbstractEventLoop 上的一个方法。然后我们将 nums 中的每个数字部分应用到计数函数上,因为我们不能直接调用计数函数。一旦我们有了计数函数调用,我们就可以将它们提交给执行器。我们遍历这些调用,对每个部分应用的计数函数调用 loop.run_in_executor,并在 call_coros 中跟踪它返回的可等待对象。然后我们等待这个列表中的所有内容都完成,使用 asyncio.gather

如果我们愿意,我们也可以使用 asyncio.as_completed 来获取子进程完成时的结果。这将解决我们之前在进程池的 map 方法中看到的问题,如果我们有一个耗时较长的任务。

我们现在已经看到了使用 asyncio 的进程池所需的所有内容。接下来,让我们看看如何通过多进程和 asyncio 提高实际问题的性能。

6.4 使用 asyncio 解决 MapReduce 问题

为了理解我们可以用 MapReduce 解决的问题类型,我们将介绍一个假设的现实世界问题。然后我们将使用这种理解来解决一个类似的问题,使用一个大型、免费可用的数据集。

回到第五章中我们关于电子商务店面示例,我们将假装我们的网站通过客户支持门户的 问题和关注 字段接收大量的文本数据。由于我们的网站很成功,这个客户反馈数据集的大小已经达到数个太字节,并且每天都在增长。

为了更好地理解我们的用户面临的一些常见问题,我们被要求找到这个数据集中最常用的单词。一个简单的解决方案是使用单个进程遍历每个评论并跟踪每个单词出现的次数。这将有效,但由于我们的数据量很大,按顺序处理可能需要很长时间。我们是否有更快的方法来处理这类问题?

这正是 MapReduce 可以解决的问题类型。MapReduce 编程模型通过首先将大型数据集划分为更小的块来解决问题。然后我们可以为这些更小的数据子集解决问题,而不是整个集合——这被称为 映射,因为我们“映射”我们的数据到部分结果。

一旦每个子集的问题得到解决,我们就可以将结果组合成一个最终答案。这一步被称为 归约,因为我们“归约”多个答案到一个。在大型文本数据集中计数单词频率是一个经典的 MapReduce 问题。如果我们有一个足够大的数据集,将其拆分为更小的块可以带来性能优势,因为每个映射操作可以并行运行,如图 6.1 所示。

06-01

图 6.1 一组大量数据被分割成分区,然后映射函数产生中间结果。这些中间结果被组合成一个结果。

类似于 Hadoop 和 Spark 这样的系统可以在计算机集群中执行 MapReduce 操作,以处理真正的大型数据集。然而,许多较小的负载可以在一台计算机上通过多进程处理。在本节中,我们将看到如何使用多进程实现 MapReduce 工作流程,以找出自 1500 年以来某些词在文献中出现的频率。

6.4.1 简单的 MapReduce 示例

为了完全理解 MapReduce 是如何工作的,让我们通过一个具体的例子来了解。假设我们有一个文件中的每一行都有文本数据。对于这个例子,我们将假设我们有四行需要处理:

I know what I know.
I know that I know.
I don’t know that much.
They don’t know much.

我们想计算每个不同的词在这个数据集中出现的次数。这个例子足够小,我们可以用简单的for循环来解决它,但让我们使用 MapReduce 模型来处理。

首先,我们需要将这个数据集划分为更小的块。为了简单起见,我们将较小的块定义为一行文本。接下来,我们需要定义映射操作。由于我们想要计算词频,我们将文本行按空格分割。这将得到字符串中每个单独单词的数组。然后我们可以遍历这个数组,在一个字典中跟踪文本行中的每个不同的单词。

最后,我们需要定义一个reduce操作。这个操作将从一个或多个 map 操作的结果中取出,并将它们组合成一个答案。在这个例子中,我们需要从 map 操作中取出两个字典,并将它们合并成一个。如果一个词同时存在于两个字典中,我们将它们的词频相加;如果不存在,我们将词频复制到结果字典中。一旦我们定义了这些操作,我们就可以运行我们的map操作对每一行文本,以及我们的reduce操作对 map 操作的结果的每一对进行。

列表 6.6 单线程 MapReduce

import functools
from typing import Dict

def map_frequency(text: str) -> Dict[str, int]:
    words = text.split(' ')
    frequencies = {}
    for word in words:
        if word in frequencies:
            frequencies[word] = frequencies[word] + 1           ❶
        else:
            frequencies[word] = 1                               ❷
    return frequencies

def merge_dictionaries(first: Dict[str, int],
                       second: Dict[str, int]) -> Dict[str, int]:
    merged = first
    for key in second:
        if key in merged:
            merged[key] = merged[key] + second[key]             ❸
        else:
            merged[key] = second[key]                           ❹
    return merged

lines = ["I know what I know",
         "I know that I know",
         "I don't know much",
         "They don't know much"]

mapped_results = [map_frequency(line) for line in lines]        ❺

for result in mapped_results:
    print(result)

print(functools.reduce(merge_dictionaries, mapped_results))     ❻

❶ 如果我们的频率字典中有这个词,将计数加一。

❷ 如果我们的频率字典中没有这个词,将其计数设置为 1。

❸ 如果这个词同时存在于两个字典中,合并频率计数。

❹ 如果这个词不在两个字典中,复制频率计数。

❺ 对于每一行文本,执行我们的 map 操作。

❻ 将所有中间频率计数合并成一个结果。

对于每一行文本,我们应用我们的map操作,得到每一行文本的频率计数。一旦我们有了这些映射的局部结果,我们就可以开始将它们组合起来。我们使用我们的合并函数merge_dictionaries以及functools .reduce函数。这将取我们的中间结果并将它们相加,得到以下输出:

Mapped results:
{'I': 2, 'know': 2, 'what': 1}
{'I': 2, 'know': 2, 'that': 1}
{'I': 1, "don't": 1, 'know': 1, 'much': 1}
{'They': 1, "don't": 1, 'know': 1, 'much': 1}

Final Result:
{'I': 5, 'know': 6, 'what': 1, 'that': 1, "don't": 2, 'much': 2, 'They': 1}

现在我们已经通过一个示例问题了解了 MapReduce 的基础,我们将看到如何将其应用于一个真实世界的数据集,其中多进程可以带来性能提升。

6.4.2 Google 图书 Ngram 数据集

我们需要一个足够大的数据集来处理,以便看到多进程 MapReduce 的好处。如果我们的数据集太小,我们将看不到 MapReduce 的好处,并且可能会因为管理进程的开销而看到性能下降。几个未压缩的数据集应该足以让我们展示有意义的性能提升。

这个 Google Books Ngram 数据集对于这个目的来说是一个足够大的数据集。为了理解这个数据集是什么,我们首先定义一下 n-gram 是什么。

n-gram 是自然语言处理中的一个概念,是从给定文本样本中提取的 N 个单词的短语。短语“the fast dog”有六个 n-gram。三个 1-gram 或 unigrams,每个都是一个单词(thefastdog),两个 2-grams 或 digramsthe fastfast dog),以及一个 3-gram 或 trigramthe fast dog)。

Google Books Ngram 数据集是对超过 8,000,000 本书中的 n-gram 的扫描,追溯到 1500 年,占所有出版书籍的超过六分之一。它按年份分组统计了不同 n-gram 在文本中出现的次数。这个数据集包含从单语元到 5-gram 的所有内容,以制表符分隔格式。这个数据集的每一行都有一个 n-gram、它被看到的年份、它出现的次数以及它出现在多少本书中。让我们看看单词 aardvark 在单语元数据集中的前几个条目:

Aardvark   1822   2   1
Aardvark   1824   3   1
Aardvark   1827   10  7

这意味着在 1822 年,单词 aardvark 在一本书中出现了两次。然后,在 1827 年,单词 aardvark 在七本不同的书中出现了十次。数据集中有更多关于 aardvark 的条目(例如,2007 年 aardvark 出现了 1,200 次),展示了 aardvarks 在文学中多年的上升趋势。

为了这个示例,我们将统计以 a 开头的单词(单语元)的出现次数。这个数据集大约有 1.8 GB 的大小。我们将汇总自 1500 年以来每个单词在文献中出现的次数。我们将使用这些数据来回答问题:“自 1500 年以来,单词 aardvark 在文献中出现了多少次?”我们想要处理的相关文件可以在 storage.googleapis.com/books/ngrams/books/googlebooks-eng-all-1gram-20120701-a.gzmattfowler.io/data/googlebooks-eng-all-1gram-20120701-a.gz 下载。您也可以从 storage.googleapis.com/books/ngrams/books/datasetsv2.html 下载数据集的任何其他部分。

6.4.3 使用 asyncio 进行映射和归约

为了有一个比较的基准,让我们首先编写一个同步版本来统计单词的频率。然后我们将使用这个频率字典来回答问题:“自 1500 年以来,单词aardvark在文献中出现了多少次?”我们首先将数据集的全部内容加载到内存中。然后我们可以使用一个字典来跟踪单词到它们出现总时间的映射。对于文件中的每一行,如果那一行的单词在我们的字典中,我们就将字典中该单词的计数增加。如果不是,我们将那一行的单词和计数添加到字典中。

列表 6.7 计算以*开头单词的频率

import time

freqs = {}

with open('googlebooks-eng-all-1gram-20120701-a', encoding='utf-8') as f:
    lines = f.readlines()

    start = time.time()

    for line in lines:
        data = line.split('\t')
        word = data[0]
        count = int(data[2])
        if word in freqs:
            freqs[word] = freqs[word] + count
        else:
            freqs[word] = count

    end = time.time()
    print(f'{end-start:.4f}')

为了测试 CPU 密集型操作需要多长时间,我们将计时频率计数需要多长时间,而不会包括加载文件所需的时间。为了多进程成为一个可行的解决方案,我们需要在一个有足够 CPU 核心的机器上运行,以便使并行化值得付出努力。为了看到足够的收益,我们可能需要一个比大多数笔记本电脑都有更多 CPU 的机器。为了在这样的机器上进行测试,我们将使用亚马逊网络服务(AWS)上的大型弹性计算云(EC2)实例。

AWS 是由亚马逊运营的云计算服务。AWS 是一组云服务,使用户能够处理从文件存储到大规模机器学习任务的所有任务,而无需管理自己的物理服务器。提供的一项服务是 EC2。使用它,你可以在 AWS 上租用一个虚拟机来运行你想要的任何应用程序,指定你的虚拟机上需要多少 CPU 核心和内存。你可以在aws.amazon.com/ec2了解更多关于 AWS 和 EC2 的信息。

我们将在 c5ad.8xlarge 实例上进行测试。在撰写本文时,这台机器有 32 个 CPU 核心、64GB 的 RAM 和一个固态硬盘,或 SSD。在这个实例上,列出 6.7 的脚本需要大约 76 秒。让我们看看我们是否可以通过多进程和 asyncio 做得更好。如果你在一台 CPU 核心数较少或其他资源较少的机器上运行这个程序,你的结果可能会有所不同。

我们的第一步是将我们的数据集分成更小的数据块。让我们定义一个分区生成器,它可以接受我们的大列表数据并获取任意大小的数据块。

def partition(data: List,
              chunk_size: int) -> List:
    for i in range(0, len(data), chunk_size):
        yield data[i:i + chunk_size]

我们可以使用这个分区生成器来创建长度为chunk_size的数据切片。我们将使用它来生成传递给我们的映射函数的数据,然后我们将并行运行这些函数。接下来,让我们定义我们的映射函数。这几乎与上一个示例中的映射函数相同,只是调整了一下以适应我们的数据集。

def map_frequencies(chunk: List[str]) -> Dict[str, int]:
    counter = {}
    for line in chunk:
        word, _, count, _ = line.split('\t')
        if counter.get(word):
            counter[word] = counter[word] + int(count)
        else:
            counter[word] = int(count)
    return counter

现在,我们将保持我们的归约操作,就像上一个示例中那样。我们现在有了所有需要的块来并行化我们的映射操作。我们将创建一个进程池,将数据分成块,并在池中的资源(“工作者”)上为每个分区运行map_frequencies。我们几乎有了所有需要的东西,但还有一个问题:我应该使用多大的分区大小?

对于这个问题没有简单的答案。一个经验法则是“金发姑娘方法”;也就是说,分区不应该太大或太小。分区大小不应该太小的原因是,当我们创建分区时,它们会被序列化(“腌制”)并发送到我们的工作进程,然后工作进程将它们反序列化。序列化和反序列化这些数据的过程可能会占用相当多的时间,如果我们过于频繁地这样做,会迅速消耗掉任何性能提升。例如,如果块大小为两个,这将是一个糟糕的选择,因为我们会有近 100 万次序列化和反序列化操作。

我们也不希望分区的大小太大;否则,我们可能无法充分利用我们机器的功率。例如,如果我们有 10 个 CPU 核心,但只创建了两个分区,我们就错过了八个可以并行运行工作负载的核心。

对于这个例子,我们将选择分区大小为 60,000,因为这个大小似乎为我们使用的 AWS 机器提供了合理的性能,基于基准测试。如果你考虑将这种方法用于你的数据处理任务,你需要测试几个不同的分区大小,以找到适合你的数据和运行机器的大小,或者开发一个启发式算法来确定正确的分区大小。现在我们可以使用进程池和事件循环的run_in_executor协程将这些部分组合在一起,以并行化我们的映射操作。

列表 6.8 使用进程池的并行 MapReduce

import asyncio
import concurrent.futures
import functools
import time
from typing import Dict, List

def partition(data: List,
              chunk_size: int) -> List:
    for i in range(0, len(data), chunk_size):
        yield data[i:i + chunk_size]

def map_frequencies(chunk: List[str]) -> Dict[str, int]:
    counter = {}
    for line in chunk:
        word, _, count, _ = line.split('\t')
        if counter.get(word):
            counter[word] = counter[word] + int(count)
        else:
            counter[word] = int(count)
    return counter

def merge_dictionaries(first: Dict[str, int],
                       second: Dict[str, int]) -> Dict[str, int]:
    merged = first
    for key in second:
        if key in merged:
            merged[key] = merged[key] + second[key]
        else:
            merged[key] = second[key]
    return merged

async def main(partition_size: int):
    with open('googlebooks-eng-all-1gram-20120701-a', encoding='utf-8') as f:
        contents = f.readlines()
        loop = asyncio.get_running_loop()
        tasks = []
        start = time.time()
        with concurrent.futures.ProcessPoolExecutor() as pool:
            for chunk in partition(contents, partition_size):
                tasks.append(loop.run_in_executor(pool, functools.partial(map_frequencies, chunk)))                  ❶

            intermediate_results = await asyncio.gather(*tasks)                                                      ❷
            final_result = functools.reduce(merge_dictionaries, intermediate_results)                                ❸

            print(f"Aardvark has appeared {final_result['Aardvark']} times.")

            end = time.time()
            print(f'MapReduce took: {(end - start):.4f} seconds')

if __name__ == "__main__":
    asyncio.run(main(partition_size=60000))

❶ 对于每个分区,在一个单独的进程中运行我们的映射操作。

❷ 等待所有映射操作完成。

❸ 将所有中间映射结果汇总成一个结果。

main协程中,我们创建一个进程池并分区数据。对于每个分区,我们在一个单独的进程中启动map_frequencies函数。然后我们使用asyncio.gather等待所有中间字典完成。一旦所有映射操作完成,我们运行我们的归约操作以生成我们的结果。

在我们描述的实例上运行此代码,大约需要 18 秒完成,与我们的串行版本相比,速度有了显著提升。这对于不是很多额外的代码来说是一个相当不错的性能提升!你也可以尝试在一个具有更多 CPU 核心的机器上运行,看看你是否可以进一步提高这个算法的性能。

你可能会注意到,在这个实现中,我们仍然在我们的父进程中有一些可以并行化的 CPU 密集型工作。我们的归约操作需要处理成千上万的字典并将它们合并在一起。我们可以应用我们在原始数据集上使用的分区逻辑,将这些字典分成块,并在多个进程中合并它们。让我们编写一个新的reduce函数来完成这个任务。在这个函数中,我们将分区列表,并在工作进程中调用每个块的reduce。一旦完成,我们将继续分区和归约,直到只剩下一个字典。(在这个列表中,为了简洁,我们省略了分区、映射和合并函数。)

列表 6.9 并行化归约操作

import asyncio
import concurrent.futures
import functools
import time
from typing import Dict, List
from chapter_06.listing_6_8 import partition, merge_dictionaries, map_frequencies
async def reduce(loop, pool, counters, chunk_size) -> Dict[str, int]:
    chunks: List[List[Dict]] = list(partition(counters, chunk_size))   ❶
    reducers = []
    while len(chunks[0]) > 1:
        for chunk in chunks:
            reducer = functools.partial(functools.reduce,              ❷
                merge_dictionaries, chunk)                             ❷
            reducers.append(loop.run_in_executor(pool, reducer))
        reducer_chunks = await asyncio.gather(*reducers)               ❸
        chunks = list(partition(reducer_chunks, chunk_size))           ❹
        reducers.clear()
    return chunks[0][0]

async def main(partition_size: int):
    with open('googlebooks-eng-all-1gram-20120701-a', encoding='utf-8') as f:
        contents = f.readlines()
        loop = asyncio.get_running_loop()
        tasks = []
        with concurrent.futures.ProcessPoolExecutor() as pool:
            start = time.time()

            for chunk in partition(contents, partition_size):
                tasks.append(loop.run_in_executor(pool, functools.partial(map_frequencies, chunk)))

            intermediate_results = await asyncio.gather(*tasks)
            final_result = await reduce(loop, pool, intermediate_results, 500)

            print(f"Aardvark has appeared {final_result['Aardvark']} times.")

            end = time.time()
            print(f'MapReduce took: {(end - start):.4f} seconds')

if __name__ == "__main__":
    asyncio.run(main(partition_size=60000))

❶ 将字典分区成可并行处理的块。

❷ 将每个分区归约成一个单一的字典。

❸ 等待所有归约操作完成。

❹ 再次分区结果,并开始循环的新迭代。

如果我们运行这个并行化的reduce,我们可能会看到一些小的性能提升,或者根据你运行的机器,可能只有很小的提升。在这种情况下,将中间字典序列化并发送到子进程的开销将消耗掉并行运行节省的大部分时间。这种优化可能不会在很大程度上减轻这个问题,但是,如果我们的reduce操作更密集地使用 CPU,或者我们有一个更大的数据集,这种方法可以带来好处。

我们的多进程方法在性能上比同步方法有明显的优势,但现在还没有一种简单的方法来查看在任何给定时间我们完成了多少个映射操作。在同步版本中,我们只需要添加一个计数器,每处理一行就增加一次,就可以看到我们进展到了哪里。由于默认情况下多个进程不共享任何内存,我们如何创建一个计数器来跟踪我们的工作进度呢?

6.5 共享数据和锁

在第一章中,我们讨论了在多进程中,每个进程都有自己的内存,与其他进程分开。当我们需要跟踪共享状态时,这提出了一个挑战。那么,如果它们的内存空间都是独立的,我们如何在不同进程之间共享数据呢?

多进程支持一个称为共享内存对象的概念。共享内存对象是一块分配的内存,一组独立的进程可以访问。如图 6.2 所示,每个进程都可以根据需要读取和写入该内存空间。

06-02

图 6.2 一个父进程和两个子进程,它们共享内存

共享状态很复杂,如果实现不当,可能会导致难以重现的错误。一般来说,如果可能的话,最好避免共享状态。尽管如此,有时引入共享状态是必要的。其中一个例子是共享计数器。

要了解更多关于共享数据的信息,我们将使用上面的 MapReduce 示例,并记录我们完成了多少个 map 操作。然后我们将定期输出这个数字,以显示我们离用户有多远。

6.5.1 共享数据和竞争条件

多进程支持两种类型的共享数据:值和数组。是一个单一值,例如整数或浮点数。数组是一组单一值。我们可以在内存中共享的数据类型受 Python 数组模块中定义的类型限制,该模块可在docs.python.org/3/library/array .html#module-array找到。

要创建一个值或数组,我们首先需要使用数组模块中的 typecode,它只是一个字符。让我们创建两个共享的数据块——一个整数值和一个整型数组。然后我们将创建两个进程来并行地增加这些共享数据块。

列表 6.10 共享值和数组

from multiprocessing import Process, Value, Array

def increment_value(shared_int: Value):
    shared_int.value = shared_int.value + 1

def increment_array(shared_array: Array):
    for index, integer in enumerate(shared_array):
        shared_array[index] = integer + 1

if __name__ == '__main__':
    integer = Value('i', 0)
    integer_array = Array('i', [0, 0])

    procs = [Process(target=increment_value, args=(integer,)),
             Process(target=increment_array, args=(integer_array,))]

    [p.start() for p in procs]
    [p.join() for p in procs]

    print(integer.value)
    print(integer_array[:])

在前面的列表中,我们创建了两个进程——一个用于增加我们的共享整数值,另一个用于增加我们的共享数组中的每个元素。一旦我们的两个子进程完成,我们将打印出数据。

由于我们的两个数据块永远不会被不同的进程接触,这段代码运行良好。如果我们有多个进程修改相同的共享数据,这段代码还会继续工作吗?让我们通过创建两个进程来并行增加共享整数值来测试这一点。我们将反复在循环中运行此代码,以查看我们是否得到一致的结果。由于我们有两个进程,每个进程增加共享计数器一次,一旦进程完成,我们预计共享值始终为二。

列表 6.11 并行增加共享计数器

from multiprocessing import Process, Value

def increment_value(shared_int: Value):
    shared_int.value = shared_int.value + 1

if __name__ == '__main__':
    for _ in range(100):
        integer = Value('i', 0)
        procs = [Process(target=increment_value, args=(integer,)),
                 Process(target=increment_value, args=(integer,))]

        [p.start() for p in procs]
        [p.join() for p in procs]
        print(integer.value)
        assert(integer.value == 2)

虽然你将看到不同的输出,因为这个问题是非确定性的,但最终你应该看到结果并不总是 2。

2
2
2
Traceback (most recent call last):
  File "listing_6_11.py", line 17, in <module>
    assert(integer.value == 2)
AssertionError
1

有时我们的结果是 1!这是为什么?我们遇到的是所谓的竞争条件。当一组操作的输出依赖于哪个操作先完成时,就会发生竞争条件。你可以想象这些操作就像在相互竞赛;如果操作以正确的顺序赢得比赛,那么一切都会正常。如果它们以错误的顺序赢得比赛,就会产生奇怪的行为。

在我们的例子中,竞争发生在哪里?问题在于增加一个值涉及到读和写操作。要增加一个值,我们首先需要读取该值,然后加一,最后将结果写回内存。每个进程在共享数据中看到的值完全取决于它何时读取共享值。

如果进程按照以下顺序运行,一切都会正常,如图 6.3 所示。

在这个例子中,进程 1 在进程 2 读取之前增加值,并赢得了比赛。由于进程 2 是第二个完成的,这意味着它将看到正确的值一,并将其添加进去,产生正确的最终值。

06-03

图 6.3 成功避免竞争条件

如果在我们的虚拟竞争中出现平局怎么办?看看图 6.4。

06-04

图 6.4 竞争条件

在这个例子中,进程 1 和进程 2 都读取了初始值零。然后它们将该值增加至 1,并同时将其写回,从而产生了错误值。

你可能会问,“但我们的代码只有一行。为什么有两个操作!?”在底层,增加被写成了两个操作,这导致了这个问题。这使得它非原子性非线程安全。这并不容易理解。关于哪些操作是原子的以及哪些操作是非原子的解释可以在mng.bz/5Kj4找到。

这类错误很棘手,因为它们往往难以重现。它们与正常错误不同,因为它们依赖于我们的操作系统运行事物的顺序,当我们使用多进程时,这是我们无法控制的。那么我们如何修复这个讨厌的 bug 呢?

6.5.2 使用锁进行同步

我们可以通过同步访问任何我们想要修改的共享数据来避免竞争条件。同步访问意味着什么?回顾我们的竞争示例,这意味着我们控制对任何共享数据的访问,以便我们完成的任何操作都能以合理的顺序完成竞争。如果我们处于两个操作之间可能发生平局的情况,我们明确阻止第二个操作运行,直到第一个完成,从而保证操作以一致的方式完成竞争。你可以想象这就像终点线处的裁判员,看到即将发生平局,告诉跑者,“等一下。一次一个!”并选择一个跑者等待,直到另一个跑者越过终点线。

同步访问共享数据的一种机制是,也称为互斥锁(简称mutex)。这些结构允许单个进程“锁定”一段代码,防止其他进程运行该代码。被锁定的代码段通常称为临界区。这意味着如果一个进程正在执行被锁定的代码,而第二个进程试图访问该代码,第二个进程将需要等待(被裁判员阻塞)直到第一个进程完成对锁定区的操作。

锁支持两种主要操作:获取释放。当一个进程获取锁时,它保证它是唯一运行该代码段的进程。一旦需要同步访问的代码段完成,我们就释放锁。这允许其他进程获取锁并在临界区运行任何代码。如果一个进程试图运行被另一个进程锁定的代码,获取锁将阻塞,直到其他进程释放该锁。

重新审视我们的计数器竞态条件示例,并使用图 6.5,让我们可视化当两个进程几乎同时尝试获取锁时会发生什么。然后,让我们看看它是如何防止计数器得到错误值的。

06-05

图 6.5 显示,进程 2 在进程 1 释放锁之前无法读取共享数据。

在这个图中,进程 1 首先成功获取了锁,并读取并增加了共享数据。第二个进程尝试获取锁,但被阻塞,无法进一步执行,直到第一个进程释放锁。一旦第一个进程释放了锁,第二个进程就可以成功获取锁并增加共享数据。这防止了竞态条件,因为锁阻止了多个进程同时读取和写入共享数据。

那么,我们如何实现这种同步并共享我们的数据呢?多进程 API 实现者想到了这一点,并很好地包含了一个方法来获取值和数组的锁。要获取锁,我们调用get_lock().acquire(),要释放锁,我们调用get_lock().release()。使用列表 6.12,让我们将此应用于之前的示例以修复我们的错误。

列表 6.12 获取和释放锁

from multiprocessing import Process, Value

def increment_value(shared_int: Value):
    shared_int.get_lock().acquire()
    shared_int.value = shared_int.value + 1
    shared_int.get_lock().release()

if __name__ == '__main__':
    for _ in range(100):
        integer = Value('i', 0)
        procs = [Process(target=increment_value, args=(integer,)),
                 Process(target=increment_value, args=(integer,))]

        [p.start() for p in procs]
        [p.join() for p in procs]
        print(integer.value)
        assert (integer.value == 2)

当我们运行这段代码时,我们应该得到每个值都是2。我们解决了竞态条件!请注意,锁也是上下文管理器,为了清理我们的代码,我们可以使用with块来编写increment_value。这将自动为我们获取和释放锁:

def increment_value(shared_int: Value):
    with shared_int.get_lock():
        shared_int.value = shared_int.value + 1

注意,我们刚刚将并发代码强制变为顺序执行,这否定了并行运行的价值。这是一个重要的观察结果,也是并发中同步和共享数据的一般注意事项。为了避免竞态条件,我们必须在关键部分使我们的并行代码变为顺序执行。这可能会损害我们多进程代码的性能。必须小心只锁定绝对需要锁定的部分,以便应用程序的其他部分可以并发执行。当面对竞态条件错误时,很容易用锁保护所有代码。这将“修复”问题,但可能会降低应用程序的性能。

6.5.3 使用进程池共享数据

我们刚刚看到了如何在几个进程之间共享数据,那么我们如何将这个知识应用到进程池中呢?进程池的操作方式与手动创建进程略有不同,这给共享数据带来了挑战。为什么会有这样的问题?

当我们将任务提交给进程池时,它可能不会立即运行,因为池中的进程可能正忙于其他任务。进程池是如何处理这个问题的呢?在后台,进程池执行器保持一个任务队列来管理这个问题。当我们向进程池提交一个任务时,其参数会被序列化(序列化)并放入任务队列。然后,每个工作进程在准备好工作时从队列中请求一个任务。当一个工作进程从队列中拉取任务时,它会反序列化(反序列化)参数并开始执行任务。

根据定义,共享数据是在工作进程之间共享的。因此,将数据序列化和反序列化以在进程之间来回传递几乎没有意义。事实上,ValueArray 对象都不能被序列化,所以如果我们尝试像之前那样将共享数据作为函数的参数传递,我们会得到类似 can’t pickle Value objects 的错误。

为了处理这个问题,我们需要将我们的共享计数器放入一个全局变量中,并设法让我们的工作进程知道它。我们可以通过 进程池初始化器 来实现这一点。这些是在我们池中的每个进程启动时被调用的特殊函数。使用这个功能,我们可以创建对父进程创建的共享内存的引用。在创建进程池时,我们可以传递这个函数。为了了解这是如何工作的,让我们创建一个简单的例子,该例子增加一个计数器。

列表 6.13 初始化进程池

from concurrent.futures import ProcessPoolExecutor
import asyncio
from multiprocessing import Value

shared_counter: Value

def init(counter: Value):
    global shared_counter
    shared_counter = counter

def increment():
    with shared_counter.get_lock():
        shared_counter.value += 1

async def main():
    counter = Value('d', 0)
    with ProcessPoolExecutor(initializer=init,
                             initargs=(counter,)) as pool:                ❶
        await asyncio.get_running_loop().run_in_executor(pool, increment)
        print(counter.value)

if __name__ == "__main__":
    asyncio.run(main())

❶ 这告诉池为每个进程执行带有参数 counterinit 函数。

我们首先定义一个全局变量,shared_counter,它将包含我们创建的共享 Value 对象的引用。在我们的 init 函数中,我们接收一个 Value 并将 shared_counter 初始化为该值。然后,在主协程中,我们创建计数器并将其初始化为 0,接着在创建进程池时将我们的 init 函数和计数器传递给 initializerinitargs 参数。init 函数将为进程池创建的每个进程调用,正确地将我们的 shared_counter 初始化为主协程中创建的那个。

你可能会问,“为什么我们需要费这么大的力气?我们难道不能直接将全局变量 shared_counter: Value = Value('d', 0) 初始化,而不是让它为空吗?”我们不能这样做的原因是,每当创建一个进程时,创建它的脚本都会再次运行,针对每个进程。这意味着每个启动的进程都会执行 shared_counter: Value = Value('d', 0),这意味着如果我们有 100 个进程,我们会得到 100 个 shared_counter 值,每个都设置为 0,从而导致一些奇怪的行为。

现在我们知道了如何使用进程池正确地初始化共享数据,让我们看看如何将此应用于我们的 MapReduce 应用程序。我们将创建一个共享计数器,每次 map 操作完成时我们将增加它。我们还将创建一个progress reporter1任务,它将在后台运行,并且每秒将我们的进度输出到控制台。在这个例子中,我们将导入一些关于分区和减少的代码,以避免重复。

列表 6.14 跟踪 map 操作进度

from concurrent.futures import ProcessPoolExecutor
import functools
import asyncio
from multiprocessing import Value
from typing import List, Dict
from chapter_06.listing_6_8 import partition, merge_dictionaries

map_progress: Value

def init(progress: Value):
    global map_progress
    map_progress = progress

def map_frequencies(chunk: List[str]) -> Dict[str, int]:
    counter = {}
    for line in chunk:
        word, _, count, _ = line.split('\t')
        if counter.get(word):
            counter[word] = counter[word] + int(count)
        else:
            counter[word] = int(count)

    with map_progress.get_lock():
        map_progress.value += 1

    return counter

async def progress_reporter(total_partitions: int):
    while map_progress.value < total_partitions:
        print(f'Finished {map_progress.value}/{total_partitions} map operations')
        await asyncio.sleep(1)

async def main(partiton_size: int):
    global map_progress

    with open('googlebooks-eng-all-1gram-20120701-a', encoding='utf-8') as f:
        contents = f.readlines()
        loop = asyncio.get_running_loop()
        tasks = []
        map_progress = Value('i', 0)

        with ProcessPoolExecutor(initializer=init,
                                 initargs=(map_progress,)) as pool:
            total_partitions = len(contents) // partiton_size
            reporter = asyncio.create_task(progress_reporter(total_partitions))

            for chunk in partition(contents, partiton_size):
                tasks.append(loop.run_in_executor(pool, functools.partial(map_frequencies, chunk)))

            counters = await asyncio.gather(*tasks)

            await reporter

            final_result = functools.reduce(merge_dictionaries, counters)

            print(f"Aardvark has appeared {final_result['Aardvark']} times.")

if __name__ == "__main__":
    asyncio.run(main(partiton_size=60000))

与我们的原始 MapReduce 实现相比,主要的变化(除了初始化共享计数器之外)是在我们的map_frequencies函数内部。一旦我们完成了对那个块中所有单词的计数,我们就获取共享计数器的锁并增加它。我们还添加了一个progress_reporter协程,它将在后台运行,并且每秒报告我们完成了多少工作。运行此程序时,你应该会看到类似以下输出的内容:

Finished 17/1443 map operations
Finished 144/1443 map operations
Finished 281/1443 map operations
Finished 419/1443 map operations
Finished 560/1443 map operations
Finished 701/1443 map operations
Finished 839/1443 map operations
Finished 976/1443 map operations
Finished 1099/1443 map operations
Finished 1230/1443 map operations
Finished 1353/1443 map operations
Aardvark has appeared 15209 times.

我们现在知道如何使用异步 IO 来提高 CPU 密集型工作的性能。如果我们有一个同时包含大量 CPU 密集型和 I/O 密集型操作的工作负载会发生什么?我们可以使用多进程,但有没有一种方法可以结合多进程和单线程并发模型的思想来进一步提高性能?

6.6 多进程,多事件循环

虽然多进程主要用于 CPU 密集型任务,但它也可以为 I/O 密集型工作负载带来好处。让我们以在前一章列表 5.8 中运行多个 SQL 查询并发的例子为例。我们能否使用多进程进一步提高其性能?让我们看看它在单核上的 CPU 使用率图,如图 6.6 所示。

06-06

图 6.6 列表 5.8 中代码的 CPU 利用率图

虽然这段代码主要是在对数据库进行 I/O 密集型查询,但仍然有相当数量的 CPU 利用率。为什么会出现这种情况?在这个例子中,有工作正在进行以处理我们从 Postgres 获取的原始结果,这导致了更高的 CPU 利用率。由于我们是单线程的,当这个 CPU 密集型工作正在进行时,我们的事件循环无法处理其他查询的结果。这可能导致吞吐量问题。如果我们并发地发出 10,000 个 SQL 查询,但我们一次只能处理一个结果,我们可能会积累大量的查询结果等待处理。

我们能否通过使用多进程来提高我们的吞吐量?使用多进程,每个进程都有自己的线程和自己的 Python 解释器。这为我们提供了在每个进程池中的每个进程中创建一个事件循环的机会。在这种模型下,我们可以将我们的查询分布到多个进程中。如图 6.7 所示,这将分散多个进程的 CPU 负载。

06-07

图 6.7 父进程创建进程池。然后父进程创建具有各自事件循环的工作进程。

虽然这不会增加我们的 I/O 吞吐量,但它会增加我们一次可以处理的查询结果数量。这将提高我们应用程序的整体吞吐量。让我们以列表 5.7 的例子为例,使用它来创建这个架构,如列表 6.15 所示。

列表 6.15 每个进程一个事件循环

import asyncio
import asyncpg
from util import async_timed
from typing import List, Dict
from concurrent.futures.process import ProcessPoolExecutor

product_query = \
    """
SELECT
p.product_id,
p.product_name,
p.brand_id,
s.sku_id,
pc.product_color_name,
ps.product_size_name
FROM product as p
JOIN sku as s on s.product_id = p.product_id
JOIN product_color as pc on pc.product_color_id = s.product_color_id
JOIN product_size as ps on ps.product_size_id = s.product_size_id
WHERE p.product_id = 100"""

async def query_product(pool):
    async with pool.acquire() as connection:
        return await connection.fetchrow(product_query)

@async_timed()
async def query_products_concurrently(pool, queries):
    queries = [query_product(pool) for _ in range(queries)]
    return await asyncio.gather(*queries)

def run_in_new_loop(num_queries: int) -> List[Dict]:
    async def run_queries():
        async with asyncpg.create_pool(host='127.0.0.1',
                                       port=5432,
                                       user='postgres',
                                       password='password',
                                       database='products',
                                       min_size=6,
                                       max_size=6) as pool:
            return await query_products_concurrently(pool, num_queries)

    results = [dict(result) for result in asyncio.run(run_queries())]                  ❶
    return results

@async_timed()
async def main():
    loop = asyncio.get_running_loop()
    pool = ProcessPoolExecutor()
    tasks = [loop.run_in_executor(pool, run_in_new_loop, 10000) for _ in range(5)]     ❷
    all_results = await asyncio.gather(*tasks)                                         ❸
    total_queries = sum([len(result) for result in all_results])
    print(f'Retrieved {total_queries} products the product database.')

if __name__ == "__main__":
    asyncio.run(main())

❶ 在新的事件循环中运行查询,并将它们转换为字典。

❷ 创建五个进程,每个进程都有自己的事件循环来运行查询。

❸ 等待所有查询结果完成。

我们创建了一个新函数:run_in_new_loop。这个函数有一个内部的协程,run_queries,它创建一个连接池并并发运行我们指定的查询数量。然后我们使用 asyncio.run 调用 run_queries,它创建一个新的事件循环并运行协程。

这里需要注意的一点是我们将结果转换为字典,因为 asyncpg 记录对象无法被序列化。将它们转换为可序列化的数据结构确保我们可以将结果发送回父进程。

在我们的主协程中,我们创建了一个进程池并调用了五次 run_in_new_loop。这将并发启动 50,000 个查询——每个进程 10,000 个。当你运行这个程序时,你应该会看到快速启动五个进程,然后每个进程大约在同一时间完成。整个应用程序的运行时间应该略长于最慢的进程。在八核机器上运行此脚本时,它大约在 13 秒内完成。回到第五章的先前的例子,我们大约在 6 秒内进行了 10,000 个查询。这个输出意味着我们每秒可以处理大约 1,666 个查询。使用多进程和多事件循环方法,我们在 13 秒内完成了 50,000 个查询,或者大约每秒 3,800 个查询,吞吐量翻了一倍多。

摘要

  • 我们已经学会了如何在进程池中并行运行多个 Python 函数。

  • 我们已经学会了如何创建进程池执行器并在并行中运行 Python 函数。进程池执行器允许我们使用 asyncio API 方法,如 gather,来并发运行多个进程并等待结果。

  • 我们已经学会了如何使用进程池和 asyncio 解决 MapReduce 问题。这个工作流程不仅适用于 MapReduce,还可以用于任何我们可以将其拆分为多个更小块的 CPU 密集型工作。

  • 我们已经学会了如何在多个进程之间共享状态。这使我们能够跟踪与启动的子进程相关的数据,例如状态计数器。

  • 我们已经学会了如何通过使用锁来避免竞态条件。竞态条件发生在多个进程试图几乎同时访问数据时,可能导致难以复现的故障。

  • 我们已经学会了如何通过为每个进程创建一个事件循环来使用多进程来扩展 asyncio 的功能。这有可能提高具有 CPU 密集型和 I/O 密集型工作混合的工作负载的性能。

7 使用线程处理阻塞工作

本章涵盖了

  • 检查多线程库

  • 创建线程池以处理阻塞 I/O

  • 使用 asyncawait 管理线程

  • 使用线程池处理阻塞 I/O 库

  • 使用线程处理共享数据和锁定

  • 在线程中处理 CPU 密集型工作

当从头开始开发一个新的 I/O 密集型应用程序时,asyncio 可能是一个自然的技术选择。从一开始,你就可以使用与 asyncio 兼容的非阻塞库,例如 asyncpg 和 aiohttp,开始开发。然而,绿色字段(缺乏先前工作强加的约束的项目)开发是一种许多软件开发者没有的奢侈。我们的大部分工作可能是使用阻塞 I/O 库管理现有代码,例如 HTTP 请求的 requests,Postgres 数据库的 psycopg,或任何数量的阻塞库。我们可能还处于一个没有 asyncio 兼容库的情况。在这些情况下,有没有一种方法可以在使用 asyncio API 的同时获得并发性能的提升?

多线程 是这个问题的解决方案。由于阻塞 I/O 释放了全局解释器锁,这使我们在不同的线程中并发运行 I/O 成为可能。与 multiprocessing 库类似,asyncio 提供了一种让我们利用线程池的方法,这样我们就可以在仍然使用 asyncio API(如 gatherwait)的同时获得线程的优势。

在本章中,我们将学习如何使用 asyncio 进行多线程,以在线程中运行阻塞 API,如 requests。此外,我们还将学习如何同步共享数据,就像我们在上一章中所做的那样,并探讨更高级的锁定主题,如 可重入锁死锁。我们还将看到如何通过构建响应式 GUI 来结合 asyncio 和同步代码,以运行 HTTP 压力测试。最后,我们将探讨一些可以使用线程进行 CPU 密集型工作的例外情况。

7.1 介绍 threading 模块

Python 允许开发者通过 threading 模块创建和管理线程。此模块公开了 Thread 类,当实例化时,接受一个在单独线程中运行的函数。Python 解释器在进程内以单线程方式运行,这意味着即使我们在多个线程中运行代码,一次也只有一个 Python 字节码可以运行。全局解释器锁将只允许一次一个线程执行代码。

这似乎表明 Python 限制了我们对多线程的使用优势,但实际上有一些情况下会释放全局解释器锁,其中最主要的是在 I/O 操作期间。Python 在这种情况下可以释放 GIL,因为底层 Python 正在调用操作系统级别的调用以执行 I/O。这些系统调用在 Python 解释器之外,意味着在我们等待 I/O 完成时不需要运行任何 Python 字节码。

为了更好地了解如何在阻塞 I/O 的上下文中创建和运行线程,我们将回顾第三章中的回显服务器示例。回想一下,为了处理多个连接,我们需要将套接字切换到非阻塞模式,并使用select模块来监视套接字上的事件。如果我们正在处理一个不支持非阻塞套接字的遗留代码库,我们还能构建一个可以同时处理多个客户端的回显服务器吗?

由于套接字的recvsendall是 I/O 绑定方法,因此它们会释放 GIL,我们应该能够在不同的线程中并发运行它们。这意味着我们可以为每个已连接的客户端创建一个线程,并在该线程中读取和写入数据。这种模式是 Apache 等 Web 服务器中常见的范式,称为线程-连接模式。让我们通过在主线程中等待连接,然后为每个连接的客户端创建一个回显线程来尝试这个想法。

列表 7.1 多线程回显服务器

from threading import Thread
import socket

def echo(client: socket):
    while True:
        data = client.recv(2048)
        print(f'Received {data}, sending!')
        client.sendall(data)

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(('127.0.0.1', 8000))
    server.listen()
    while True:
        connection, _ = server.accept()                     ❶
        thread = Thread(target=echo, args=(connection,))    ❷
        thread.start()                                      ❸

❶ 阻塞等待客户端连接。

❷ 一旦客户端连接,创建一个线程来运行我们的回显函数。

❸ 启动线程。

在前面的列表中,我们进入一个无限循环,监听服务器套接字上的连接。一旦我们有一个客户端连接,我们创建一个新的线程来运行我们的echo函数。我们向线程提供一个target,即我们想要运行的echo函数,以及args,它是一个传递给echo的参数元组。这意味着我们将在我们的线程中调用echo(connection)。然后,我们启动线程并再次循环,等待第二个连接。同时,在我们创建的线程中,我们无限循环地监听来自客户端的数据,并且当我们有数据时,我们将它回显。

你应该能够同时连接任意数量的 telnet 客户端,并且消息能够正确地回显。由于每个recvsendall操作都在每个客户端的单独线程中运行,因此这些操作永远不会相互阻塞;它们只会阻塞它们运行的线程。

这解决了多个客户端无法同时使用阻塞套接字连接的问题,尽管这种方法有一些特定于线程的问题。如果我们尝试在客户端连接时使用 CTRL-C 来终止此进程,会发生什么?我们的应用程序是否会干净地关闭我们创建的线程?

结果表明,事情并没有那么干净地关闭。如果你终止应用程序,你应该在server.accept()上看到抛出的KeyboardInterrupt异常,但你的应用程序将挂起,因为后台线程将保持程序运行。此外,任何已连接的客户端仍然能够发送和接收消息!

不幸的是,Python 中用户创建的线程不会接收到KeyboardInterrupt异常;只有主线程会接收到它们。这意味着我们的线程将继续运行,愉快地读取客户端数据,并阻止我们的应用程序退出。

有几种处理这种问题的方法;具体来说,我们可以使用所谓的 守护线程(发音为 demon),或者我们可以想出我们自己的取消或“中断”正在运行的线程的方法。守护线程是用于长时间运行的后台任务的特殊线程。这些线程不会阻止应用程序关闭。事实上,当只有守护线程在运行时,应用程序将自动关闭。由于 Python 的主线程不是守护线程,这意味着如果我们使所有我们的连接线程成为守护线程,我们的应用程序将在 KeyboardInterrupt 时终止。将我们的代码从列表 7.1 修改为使用守护线程很容易;我们只需要在运行 thread.start() 之前将 thread.daemon 设置为 True。一旦我们做出这个改变,我们的应用程序将在 CTRL-C 时正确终止。

这种方法的缺点是我们没有方法在线程停止时运行任何清理或关闭逻辑,因为守护线程会突然终止。假设我们在关闭时想要向每个客户端写入服务器正在关闭的消息。我们有没有方法让某种类型的异常中断我们的线程并干净地关闭套接字?如果我们调用套接字的 shutdown 方法,任何现有的 recv 调用将返回 zero,而 sendall 将引发异常。如果我们从主线程调用 shutdown,这将具有中断正在阻塞 recvsendall 调用的客户端线程的效果。然后我们可以在客户端线程中处理这个异常并执行我们想要的任何清理逻辑。

要做到这一点,我们将以不同于之前的方式创建线程,通过子类化 Thread 类本身。这将使我们能够定义自己的线程,其中包含一个 cancel 方法,在其中我们可以关闭客户端套接字。然后,我们的 recvsendall 调用将被中断,允许我们退出 while 循环并关闭线程。

Thread 类有一个可以重写的 run 方法。当我们对 Thread 进行子类化时,我们使用我们想要线程在启动时运行的代码来实现这个方法。在我们的例子中,这是 recvsendall 的回声循环。

列表 7.2 通过子类化线程类实现干净的关闭

from threading import Thread
import socket

class ClientEchoThread(Thread):

    def __init__(self, client):
        super().__init__()
        self.client = client

    def run(self):
        try:
            while True:
                data = self.client.recv(2048)
                if not data:                                              ❶
                    raise BrokenPipeError('Connection closed!')
                print(f'Received {data}, sending!')
                self.client.sendall(data)
        except OSError as e:                                              ❷
            print(f'Thread interrupted by {e} exception, shutting down!')

    def close(self):
        if self.is_alive():                                               ❸
            self.client.sendall(bytes('Shutting down!', encoding='utf-8'))
            self.client.shutdown(socket.SHUT_RDWR)                        ❹

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(('127.0.0.1', 8000))
    server.listen()
    connection_threads = []
    try:
        while True:
            connection, addr = server.accept()
            thread = ClientEchoThread(connection)
            connection_threads.append(thread)
            thread.start()
    except KeyboardInterrupt:
        print('Shutting down!')
        [thread.close() for thread in connection_threads]                 ❺

❶ 如果没有数据,则引发异常。这发生在客户端关闭连接或连接被关闭时。

❷ 当我们遇到异常时,退出 run 方法。这将终止线程。

❸ 如果线程正在运行,则关闭连接;如果客户端关闭了连接,线程可能不会运行。

❹ 关闭客户端连接的读写。

❺ 在键盘中断时,调用我们线程的 close 方法来关闭每个客户端连接。

我们首先创建了一个新的类ClientEchoThread,该类从Thread继承。这个类用我们的原始echo函数中的代码覆盖了run方法,但做了一些修改。首先,我们将一切包裹在一个try catch块中,并拦截OSError异常。这种异常在关闭客户端套接字时由sendall等方法抛出。我们还检查recv的数据是否为0。这发生在两种情况下:如果客户端关闭了连接(例如,有人退出 telnet)或者当我们自己关闭客户端连接时。在这种情况下,我们自行抛出一个BrokenPipeErrorOSError的子类),执行异常块中的打印语句,并退出run方法,这将关闭线程。

我们还在我们的ClientEchoThread类上定义了一个close方法。该方法首先检查线程是否存活,然后再关闭客户端连接。线程“存活”意味着什么,为什么我们需要这样做?如果线程的run方法正在执行,则线程是存活的;在这种情况下,如果我们的run方法没有抛出任何异常,则这是真的。我们需要这个检查,因为客户端本身可能已经关闭了连接,在我们调用close之前,在run方法中引发了BrokenPipeError异常。这意味着调用sendall会导致异常,因为连接已不再有效。

最后,在我们的主循环中,该循环监听新的传入连接,我们拦截KeyboardInterrupt异常。一旦我们捕获到异常,我们就在我们创建的每个线程上调用close方法。这会向客户端发送消息,假设连接仍然活跃,并关闭连接。

总体来说,在 Python 中取消正在运行的线程,在一般情况下,是一个棘手的问题,并且取决于你试图处理的特定关闭情况。你需要特别注意确保你的线程不会阻止应用程序退出,并找出在哪里放置适当的中断点以退出线程。

我们现在已经看到了几种手动管理线程的方法,创建一个带有target函数的线程对象,以及通过继承Thread并覆盖run方法。现在我们了解了线程的基础知识,让我们看看如何使用它们与 asyncio 一起工作,以使用流行的阻塞库。

7.2 使用 asyncio 中的线程

我们现在知道了如何创建和管理多个线程来处理阻塞工作。这种方法的缺点是我们必须单独创建并跟踪线程。我们希望能够使用我们学到的所有基于 asyncio 的 API 来等待线程的结果,而无需自己管理它们。就像第六章中的进程池一样,我们可以使用线程池以这种方式管理线程。在本节中,我们将介绍一个流行的阻塞 HTTP 客户端库,并看看如何使用 asyncio 中的线程来并发运行 Web 请求。

7.2.1 介绍 requests 库

*requests 库* 是一个流行的 Python HTTP 客户端库,自称是“为人类设计的 HTTP”。您可以在 requests.readthedocs.io/en/master/ 查看该库的最新文档。使用它,您可以像使用 aiohttp 一样向 Web 服务器发送 HTTP 请求。我们将使用最新版本(截至本文撰写时,版本 2.24.0)。您可以通过运行以下 pip 命令来安装此库:

pip install -Iv requests==2.24.0

安装库后,我们就可以开始进行一些基本的 HTTP 请求了。让我们先向 example.com 发送几个请求,以获取状态码,就像我们之前使用 aiohttp 一样。

列表 7.3 requests 的基本用法

import requests

def get_status_code(url: str) -> int:
    response = requests.get(url)
    return response.status_code

url = 'https:// www .example .com'
print(get_status_code(url))
print(get_status_code(url))

上述列表执行了两个 HTTP GET 请求。运行此代码,你应该看到两个 200 输出。我们没有在这里创建 HTTP 会话,就像我们使用 aiohttp 一样,但该库根据需要支持此功能,以在不同请求之间保持 cookie 的持久性。

requests 库是阻塞的,这意味着每次调用 requests.get 都会阻止任何线程执行其他 Python 代码,直到请求完成。这对我们在 asyncio 中使用此库的方式有影响。如果我们尝试在协程或任务中使用此库,它将阻塞整个事件循环,直到请求完成。如果我们有一个需要 2 秒的 HTTP 请求,我们的应用程序除了等待这 2 秒之外,什么都不能做。为了正确地使用此库与 asyncio 一起,我们必须在线程中运行这些阻塞操作。

7.2.2 介绍线程池执行器

与进程池执行器类似,concurrent.futures 库提供了一个名为 ThreadPoolExecutorExecutor 抽象类的实现,用于与线程一起工作。与进程池不同,线程池执行器会创建并维护一个线程池,我们可以将工作提交给这个线程池。

虽然进程池默认会为机器上每个可用的 CPU 核心创建一个工作进程,但确定要创建多少个工作线程要复杂一些。内部,默认线程数的公式是min(32, os.cpu_count() + 4)。这导致工作线程的最大(上限)值为 32,最小(下限)值为 5。上限设置为 32 是为了避免在拥有大量 CPU 核心的机器上创建出人意料的线程数量(记住,线程的创建和维护成本较高)。下限设置为 5 是因为在较小的 1-2 核心机器上,仅启动几个线程不太可能显著提高性能。对于 I/O 密集型工作,通常有道理创建比可用 CPU 更多的线程。例如,在 8 核心机器上,上述公式意味着我们将创建 12 个线程。虽然只有 8 个线程可以并发运行,但我们可以有其他线程暂停等待 I/O 完成,让操作系统在 I/O 完成后恢复它们。

让我们将 7.3 列表中的示例修改为使用线程池并发运行 1,000 个 HTTP 请求。我们将计时结果以了解其好处。

列表 7.4 使用线程池运行请求

import time
import requests
from concurrent.futures import ThreadPoolExecutor

def get_status_code(url: str) -> int:
    response = requests.get(url)
    return response.status_code

start = time.time()

with ThreadPoolExecutor() as pool:
    urls = ['https:// www .example .com' for _ in range(1000)]
    results = pool.map(get_status_code, urls)
    for result in results:
        print(result)

end = time.time()

print(f'finished requests in {end - start:.4f} second(s)')

在具有快速互联网连接的 8 核心机器上,此代码可以在默认线程数的情况下以 8-9 秒的时间执行。很容易将此同步编写出来,通过做一些事情来理解线程的影响,如下所示:

start = time.time()

urls = ['https:// www .example .com' for _ in range(1000)]

for url in urls:
    print(get_status_code(url))

end = time.time()

print(f'finished requests in {end - start:.4f} second(s)')

运行此代码可能需要超过 100 秒!这使得我们的线程代码比同步代码快 10 多倍,给我们带来了相当大的性能提升。

虽然这显然是一个改进,但你可能还记得在第四章中关于 aiohttp 的内容,我们能够在不到 1 秒的时间内并发发送 1,000 个请求。为什么这比我们的线程版本慢这么多?记住,我们的最大工作线程数限制为 32(即 CPU 数量加 4),这意味着默认情况下我们只能并发运行最多 32 个请求。我们可以在创建线程池时传递max_workers=1000来尝试解决这个问题,如下所示:

with ThreadPoolExecutor(max_workers=1000) as pool:
    urls = ['https:// www .example .com' for _ in range(1000)]
    results = pool.map(get_status_code, urls)
    for result in results:
        print(result)

这种方法可以带来一些改进,因为我们现在为每个请求都分配了一个线程。然而,这仍然远远达不到我们基于协程的代码的效果。这是因为线程与资源开销相关。线程在操作系统级别创建,其创建成本比协程高。此外,线程在操作系统级别还有上下文切换的成本。当发生上下文切换时,保存和恢复线程状态会消耗一些使用线程获得的成绩。

当你确定要为特定问题使用多少线程时,最好从小规模开始(CPU 核心数加上几个作为起始点),进行测试,并对其进行基准测试,逐渐增加线程数。你通常会找到一个“最佳点”,在此之后,运行时间将趋于平稳,甚至可能下降,无论你添加多少线程。这个最佳点通常相对于你想要发起的请求数量来说是一个相当低的数字(为了说明,为 1,000 个请求创建 1,000 个线程可能并不是资源利用的最佳方式)。

7.2.3 使用 asyncio 的线程池执行器

使用线程池执行器与 asyncio 事件循环并没有太大的不同,这得益于我们有一个抽象的Executor基类,我们可以通过只更改一行代码来使用相同的代码运行线程或进程。让我们修改我们的示例,将运行 1,000 个 HTTP 请求的例子改为使用asyncio.gather而不是pool.map

列表 7.5 使用线程池执行器与 asyncio

import functools
import requests
import asyncio
from concurrent.futures import ThreadPoolExecutor
from util import async_timed

def get_status_code(url: str) -> int:
    response = requests.get(url)
    return response.status_code

@async_timed()
async def main():
    loop = asyncio.get_running_loop()
    with ThreadPoolExecutor() as pool:
        urls = ['https:// www .example .com' for _ in range(1000)]
        tasks = [loop.run_in_executor(pool, functools.partial(get_status_code, url)) for url in urls]
        results = await asyncio.gather(*tasks)
        print(results)

asyncio.run(main())

我们创建线程池的方式与之前相同,但不是使用map,而是通过调用loop.run_in_executor来调用我们的get_status_code函数,创建一个任务列表。一旦我们有了任务列表,我们就可以使用asyncio.gather或我们之前学到的其他 asyncio API 来等待它们完成。

在内部,loop.run_in_executor调用线程池执行器的submit方法。这将把每个我们传递进去的函数放入队列中。池中的工作线程然后从队列中取出,运行每个工作项直到完成。这种方法在使用没有 asyncio 的池时并不会带来任何性能上的好处,但当我们等待await asyncio.gather完成时,其他代码可以运行。

7.2.4 默认执行器

阅读 asyncio 文档时,你可能注意到run_in_executor方法的executor参数可以是None。在这种情况下,run_in_executor将使用事件循环的默认执行器。什么是默认执行器?可以将其视为适用于整个应用程序的可重用单例执行器。默认执行器将始终默认为ThreadPoolExecutor,除非我们使用loop.set_default_executor方法设置一个自定义的执行器。这意味着我们可以简化列表 7.5 中的代码,如下所示。

列表 7.6 使用默认执行器

import functools
import requests
import asyncio
from util import async_timed

def get_status_code(url: str) -> int:
    response = requests.get(url)
    return response.status_code

@async_timed()
async def main():
    loop = asyncio.get_running_loop()
    urls = ['https:// www .example .com' for _ in range(1000)]
    tasks = [loop.run_in_executor(None, functools.partial(get_status_code, url)) for url in urls]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())

在前面的列表中,我们消除了创建自己的ThreadPoolExecutor并在上下文管理器中使用它,就像我们之前做的那样,相反,我们将None作为执行器传入。第一次调用run_in_executor时,asyncio 为我们创建并缓存了一个默认的线程池执行器。随后的每次调用run_in_executor都会重用之前创建的默认执行器,这意味着执行器随后对事件循环全局有效。此池的关闭方式也与之前不同。之前,我们创建的线程池执行器在我们退出上下文管理器的with块时关闭。当使用默认执行器时,它不会关闭,直到事件循环关闭,这通常发生在我们的应用程序完成时。当我们想使用线程时使用默认线程池执行器简化了事情,但我们能否使这更加简单?

在 Python 3.9 中,引入了asyncio.to_thread协程,以进一步简化将工作放在默认线程池执行器上。它接受一个要在线程中运行的函数以及传递给该函数的参数集。之前,我们必须使用functools.partial来传递参数,这使得我们的代码更加简洁。然后,它在默认线程池执行器和当前运行的事件循环中运行该函数及其参数。这使得我们可以进一步简化我们的线程代码。使用to_thread协程消除了使用functools.partial和我们的asyncio.get_running_loop调用,减少了我们的总代码行数。

列表 7.7 使用 to_thread 协程

import requests
import asyncio
from util import async_timed

def get_status_code(url: str) -> int:
    response = requests.get(url)
    return response.status_code

@async_timed()
async def main():
    urls = ['https:// www .example .com' for _ in range(1000)]
    tasks = [asyncio.to_thread(get_status_code, url) for url in urls]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())

到目前为止,我们只看到了如何在线程中运行阻塞代码。结合线程与 asyncio 的力量在于,我们可以在等待线程完成时运行其他代码。为了了解如何在线程运行时运行其他代码,我们将回顾第六章中的示例,该示例定期输出长时间运行任务的状态。

7.3 锁、共享数据和死锁

与多进程代码类似,多线程代码在存在共享数据时也容易受到竞态条件的影响,因为我们无法控制执行顺序。任何时候,如果你有两个或多个线程或进程可能修改同一非线程安全数据的共享部分,你都需要利用锁来正确同步访问。从概念上讲,这与我们在多进程中采取的方法没有不同;然而,线程的内存模型略微改变了这种方法。

回想一下,在使用多进程时,我们创建的进程默认不共享内存。这意味着我们需要创建特殊的共享内存对象并正确初始化它们,以便每个进程都可以从这个对象中读取和写入。由于线程确实可以访问其父进程的相同内存,我们不再需要这样做,线程可以直接访问共享变量。

这简化了事情,但由于我们不会使用内置锁的共享 Value 对象,我们需要自己创建它们。为此,我们需要使用线程模块的 Lock 实现,这与我们在多进程中使用的不一样。这就像从线程模块导入 Lock 并在代码的关键部分调用其 acquirerelease 方法,或者在使用上下文管理器时使用它一样简单。

要了解如何使用锁进行线程操作,让我们回顾一下第六章中的任务,即跟踪和显示长时间任务的进度。我们将使用我们之前的例子,即发出数千个网络请求,并使用一个共享计数器来跟踪到目前为止我们已经完成了多少请求。

列表 7.8 打印请求状态

import functools
import requests
import asyncio
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
from util import async_timed

counter_lock = Lock()
counter: int = 0

def get_status_code(url: str) -> int:
    global counter
    response = requests.get(url)
    with counter_lock:
        counter = counter + 1
    return response.status_code

async def reporter(request_count: int):
    while counter < request_count:
        print(f'Finished {counter}/{request_count} requests')
        await asyncio.sleep(.5)

@async_timed()
async def main():
    loop = asyncio.get_running_loop()
    with ThreadPoolExecutor() as pool:
        request_count = 200
        urls = ['https:// www .example .com' for _ in range(request_count)]
        reporter_task = asyncio.create_task(reporter(request_count))
        tasks = [loop.run_in_executor(pool, functools.partial(get_status_code, url)) for url in urls]
        results = await asyncio.gather(*tasks)
        await reporter_task
        print(results)

asyncio.run(main())

这应该看起来很熟悉,因为它就像我们在第六章中编写以输出 map 操作进度的代码一样。我们创建了一个全局 counter 变量以及一个 counter_lock,以在关键部分同步对其的访问。在我们的 get_status_code 函数中,我们在增加计数器时获取锁。然后,在我们的主协程中,我们启动一个后台任务报告器,每 500 毫秒输出我们已经完成的请求数量。运行此代码,你应该会看到类似以下内容的输出:

Finished 0/200 requests
Finished 48/200 requests
Finished 97/200 requests
Finished 163/200 requests

我们现在已经了解了多线程和多进程中使用锁的基本知识,但关于锁还有很多东西要学习。接下来,我们将探讨 可重入性 的概念。

7.3.1 可重入锁

简单的锁对于协调多个线程对共享变量的访问很有效,但当线程尝试获取它已经获取的锁时会发生什么?这是否甚至安全?由于是同一线程获取锁,根据定义这是单线程的,因此应该是线程安全的。

虽然这种访问应该是没有问题的,但它确实会与迄今为止我们使用的锁产生问题。为了说明这一点,让我们想象我们有一个递归求和函数,它接受一个整数列表并产生该列表的总和。我们想要求和的列表可以从多个线程中修改,因此我们需要使用锁来确保我们在求和操作期间所求和的列表不会被修改。让我们尝试使用一个普通的锁来实现这一点,看看会发生什么。我们还会添加一些控制台输出,以查看我们的函数是如何执行的。

列表 7.9 使用锁的递归

from threading import Lock, Thread
from typing import List

list_lock = Lock()

def sum_list(int_list: List[int]) -> int:
    print('Waiting to acquire lock...')
    with list_lock:
        print('Acquired lock.')
        if len(int_list) == 0:
            print('Finished summing.')
            return 0
        else:
            head, *tail = int_list
            print('Summing rest of list.')
            return head + sum_list(tail)

thread = Thread(target=sum_list, args=([1, 2, 3, 4],))
thread.start()
thread.join()

如果你运行此代码,你会看到以下几条消息,然后应用程序将永远挂起:

Waiting to acquire lock...
Acquired lock.
Summing rest of list.
Waiting to acquire lock...

为什么会出现这种情况?如果我们逐步分析,第一次获取list_lock是完全没有问题的。然后我们解包列表,并递归地调用sum_list方法处理列表的剩余部分。这导致我们尝试第二次获取list_lock。这就是我们的代码挂起的地方,因为我们已经获取了锁,所以我们永远阻塞在尝试第二次获取锁。这也意味着我们永远不会退出第一个with块,无法释放锁;我们正在等待一个永远不会释放的锁!

由于这个递归是从发起它的同一个线程中来的,因此获取锁多次不应该有问题,因为这不会导致竞态条件。为了支持这些用例,线程库提供了可重入锁。可重入锁是一种特殊的锁,可以被同一个线程多次获取,允许该线程“重新进入”关键部分。线程模块在RLock类中提供了可重入锁。我们可以通过修改上述代码的两行来修复问题——导入语句和list_lock的创建:

from threading import Rlock

list_lock = RLock()

如果我们修改这些行,我们的代码将正常工作,并且单个线程将能够多次获取锁。内部,可重入锁通过保持递归计数来工作。每次我们从第一次获取锁的线程获取锁时,计数增加,每次我们释放锁时,计数减少。当计数为 0 时,锁最终被释放,以便其他线程获取它。

让我们考察一个更贴近现实世界的应用,以真正理解使用锁的递归概念。想象一下,我们正在尝试构建一个线程安全的整数列表类,该类包含一个方法,用于查找并替换列表中所有特定值的元素。这个类将包含一个普通的 Python 列表和一个我们用来防止竞态条件的锁。我们将假装现有的类已经有一个名为indices_of(to_find: int)的方法,该方法接受一个整数并返回列表中所有匹配to_find的索引。由于我们想要遵循 DRY(不要重复自己)原则,我们将在定义我们的查找和替换方法时重用这个方法(注意这实际上并不是最有效的方法,但我们这样做是为了说明概念)。这意味着我们的类和方法将类似于以下列表。

列表 7.10 线程安全的列表类

from threading import Lock
from typing import List

class IntListThreadsafe:

    def __init__(self, wrapped_list: List[int]):
        self._lock = Lock()
        self._inner_list = wrapped_list

    def indices_of(self, to_find: int) -> List[int]:
        with self._lock:
            enumerator = enumerate(self._inner_list)
            return [index for index, value in enumerator if value == to_find]

    def find_and_replace(self,
                         to_replace: int,
                         replace_with: int) -> None:
        with self._lock:
            indices = self.indices_of(to_replace)
            for index in indices:
                self._inner_list[index] = replace_with

threadsafe_list = IntListThreadsafe([1, 2, 1, 2, 1])
threadsafe_list.find_and_replace(1, 2)

如果在调用indices_of方法期间,来自另一个线程的人修改了列表,我们可能会得到一个错误的返回值,因此在我们搜索匹配索引之前,我们需要获取锁。我们的find_and_replace方法也必须出于相同的原因获取锁。然而,使用正常的锁,当我们调用find_and_replace时,我们最终会永远挂起。查找和替换方法首先获取锁,然后调用另一个方法,该方法尝试获取相同的锁。在这种情况下,切换到RLock将解决这个问题,因为find_and_replace的任何调用都将始终从同一线程获取任何锁。这说明了何时需要使用可重入锁的通用公式。如果你正在开发一个具有方法 A 的线程安全类,该方法获取锁,并且还有一个方法 B 也需要获取锁并且调用方法 A,你很可能需要使用可重入锁。

7.3.2 死锁

你可能从新闻中关于政治谈判的概念中熟悉了死锁,其中一方对另一方提出要求,而另一方则提出反要求。双方对下一步行动意见不一,谈判陷入僵局。在计算机科学中,这个概念类似,我们达到一个状态,其中对共享资源存在竞争但没有解决方案,我们的应用程序将永远挂起。

在上一节中我们看到的问题,即非可重入锁可能导致我们的程序永远挂起,是死锁的一个例子。在这种情况下,我们陷入了一种与自己陷入僵局的谈判状态,要求获取永远不会释放的锁。这种情况也可能发生在我们有两个线程使用多个锁时。图 7.1 阐述了这种情况:如果线程A请求线程B已获取的锁,而线程B正在请求线程A已获取的锁,我们将陷入僵局并发生死锁。在这种情况下,使用可重入锁不会有所帮助,因为我们有多个线程卡在等待其他线程持有的资源上。

07-01

图 7.1 线程 1 和 2 大约同时获取锁 A 和 B。然后,线程 1 等待获取锁 B,而该锁被线程 2 持有;同时,线程 2 正在等待获取 A,而该锁被线程 1 持有。这种循环依赖导致死锁,并将使应用程序挂起。

让我们看看如何在代码中创建这种类型的死锁。我们将创建两个锁,锁AB,以及两个需要获取这两个锁的方法。一个方法将首先获取A然后获取B,另一个方法将首先获取B然后获取A

列表 7.11 代码中的死锁

from threading import Lock, Thread
import time

lock_a = Lock()
lock_b = Lock()

def a():
    with lock_a:                                         ❶
        print('Acquired lock a from method a!')
        time.sleep(1)                                    ❷
        with lock_b:                                     ❸
            print('Acquired both locks from method a!')

def b():
    with lock_b:                                         ❸
        print('Acquired lock b from method b!')
        with lock_a:                                     ❶
            print('Acquired both locks from method b!')
thread_1 = Thread(target=a)
thread_2 = Thread(target=b)
thread_1.start()
thread_2.start()
thread_1.join()
thread_2.join()

❶ 获取锁 A。

❷ 睡眠 1 秒;这确保我们创建了产生死锁的正确条件。

❸ 获取锁 B。

当我们运行此代码时,我们将看到以下输出,并且我们的应用程序将永远挂起:

Acquired lock a from method a!
Acquired lock b from method b!

我们首先调用方法 A 并获取锁 A,然后引入一个人工延迟,给方法 B 获取锁 B 的机会。这使得我们处于一个状态,其中方法 A 持有锁 A,方法 B 持有锁 B。接下来,方法 A 尝试获取锁 B,但方法 B 正在持有那个锁。同时,方法 B 尝试获取锁 A,但方法 A 正在持有它,陷入等待 B 释放锁的状态。两种方法都陷入等待对方释放资源的状态,我们达到了僵局。

我们如何处理这种情况?一个解决方案是所谓的“鸵鸟算法”,这个名字来源于这种情况(尽管鸵鸟实际上并不这样行为)。在这种策略中,我们忽略问题,并制定一个策略,在遇到问题时重新启动我们的应用程序。这种方法的驱动思想是,如果问题发生的频率足够低,投资于修复是不值得的。如果你从上面的代码中移除 sleep,你很少会看到死锁发生,因为它依赖于一个非常特定的操作序列。这并不是真正的修复,也不是理想的解决方案,但是一种用于很少发生的死锁的策略。

然而,在我们的情况下有一个简单的解决方案,即我们改变两种方法中的锁,使其总是以相同的顺序获取。例如,方法 AB 都可以先获取锁 A,然后获取锁 B。这样就能解决问题,因为我们永远不会以可能导致死锁的顺序获取锁。另一种选择是将锁重构,以便我们只使用一个而不是两个。只有一个锁的情况下不可能发生死锁(不包括我们之前看到的可重入死锁)。总的来说,当处理需要获取的多个锁时,问问自己,“我是不是以一致的顺序获取这些锁?有没有一种方法可以重构它以只使用一个锁?”

我们现在已经看到了如何有效地使用线程与 asyncio 结合,并研究了更复杂的锁定场景。接下来,让我们看看如何使用线程将 asyncio 集成到可能无法与 asyncio 畅通合作的现有同步应用程序中。

7.4 在单独的线程中事件循环

我们主要关注构建完全从底层使用协程和 asyncio 实现的应用程序。当我们有任何不适合单线程并发模型的工作时,我们就在线程或进程中运行它。并非所有应用程序都适合这种范式。如果我们正在一个现有的同步应用程序中工作,并希望集成 asyncio 呢?

我们可能遇到这种情况的一种情况是构建桌面用户界面。构建 GUI 的框架通常运行它们自己的事件循环,并且事件循环会阻塞主线程。这意味着任何长时间运行的操作都可能使用户界面冻结。此外,这个 UI 事件循环会阻止我们创建 asyncio 事件循环。在本节中,我们将学习如何通过在 Tkinter 中构建一个响应式的 HTTP 压力测试用户界面来使用多线程同时运行多个事件循环。

7.4.1 Tkinter 简介

Tkinter 是默认 Python 安装中提供的一个平台无关的桌面图形用户界面(GUI)工具包。简称为“Tk 接口”,它是 tcl 语言编写的低级 Tk GUI 工具包的接口。随着 Tkinter Python 库的创建,Tk 已经成为 Python 开发者构建桌面用户界面的流行方式。

Tkinter 有一些“小部件”,例如标签、文本框和按钮,我们可以将它们放置在桌面窗口中。当我们与一个小部件交互,比如输入文本或按按钮时,我们可以触发一个函数来执行代码。响应用户操作运行的代码可能非常简单,比如更新另一个小部件或触发另一个操作。

Tkinter 以及许多其他 GUI 库,通过它们自己的事件循环绘制小部件和处理小部件交互。事件循环不断重绘应用程序,处理事件,并检查是否有代码应该在小部件事件响应时运行。为了熟悉 Tkinter 和其事件循环,让我们创建一个基本的 hello world 应用程序。我们将创建一个带有“说你好”按钮的应用程序,当我们点击它时,它将在控制台输出“Hello there!”

列表 7.12 使用 Tkinter 的“Hello world”

import tkinter
from tkinter import ttk

window = tkinter.Tk()
window.title('Hello world app')
window.geometry('200x100')

def say_hello():
    print('Hello there!')

hello_button = ttk.Button(window, text='Say hello', command=say_hello)
hello_button.pack()

window.mainloop()

此代码首先创建一个 Tkinter 窗口(见图 7.2),并设置应用程序标题和窗口大小。然后我们在窗口上放置一个按钮,并将其命令设置为 say_hello 函数。当用户按下此按钮时,say_hello 函数执行,打印出我们的消息。然后我们调用 window.mainloop(),这启动了 Tk 事件循环,运行我们的应用程序。

07-02

图 7.2 列表 7.12 的“Hello world”应用程序

在这里需要注意的是,我们的应用程序将在 window.mainloop() 上阻塞。内部,这个方法运行 Tk 事件循环。这是一个无限循环,它会检查窗口事件并不断重绘窗口,直到我们关闭它。Tk 事件循环与 asyncio 事件循环有有趣的相似之处。例如,如果我们尝试在我们的按钮的命令中运行阻塞工作会怎样?如果我们使用 time.sleep(10)say_hello 函数添加 10 秒的延迟,我们就会开始看到问题:我们的应用程序将冻结 10 秒!

与 asyncio 类似,Tkinter 在其事件循环中运行 所有 内容。这意味着如果我们有一个长时间运行的操作,比如发送网络请求或加载大文件,我们将阻塞 tk 事件循环,直到该操作完成。对用户的影响是 UI 挂起并且变得无响应。用户无法点击任何按钮,我们无法更新任何带有状态或进度的控件,操作系统可能会显示一个旋转器(如图 7.3 中的示例)来指示应用程序挂起。这显然是一个不希望看到的、无响应的用户界面。

07-03

图 7.3 当我们在 Mac 上阻塞事件循环时,会出现令人恐惧的“厄运沙滩球”。

这是一个异步编程理论上可以帮助我们的例子。如果我们可以发出不会阻塞 tk 事件循环的异步请求,我们可以避免这个问题。这比看起来要复杂,因为 Tkinter 并不了解 asyncio,你不能传递一个协程在按钮点击时运行。我们可以尝试在同一个线程中同时运行两个事件循环,但这不会起作用。Tkinter 和 asyncio 都是单线程的——这个想法与尝试在同一个线程中同时运行两个无限循环是一样的,这是不可能的。如果我们先启动 asyncio 事件循环,那么 asyncio 事件循环将阻止 Tkinter 循环运行,反之亦然。我们有没有办法在单线程应用程序旁边运行 asyncio 应用程序?

实际上,我们可以通过在一个单独的线程中运行 asyncio 事件循环来组合这两个事件循环,创建一个功能性的应用程序。让我们看看如何通过一个应用程序来实现这一点,该应用程序将使用进度条响应用户关于长时间运行任务状态的更新。

7.4.2 使用 asyncio 和线程构建响应式 UI

首先,让我们介绍我们的应用程序并绘制一个基本的用户界面。我们将构建一个 URL 压力测试应用程序。该应用程序将接受一个 URL 和许多要发送的请求作为输入。当我们按下提交按钮时,我们将使用 aiohttp 尽快发送网络请求,向所选的 web 服务器提供预定义的负载。由于这可能需要很长时间,我们将添加一个进度条来可视化测试的进度。我们将在完成总请求的每 1% 后更新进度条以显示进度。此外,如果用户想要的话,我们可以让他们取消请求。我们的用户界面将包含一些小部件,包括用于测试 URL 的文本输入框、用于输入我们希望发出的请求数量的文本输入框、一个开始按钮和一个进度条。我们将设计一个类似于图 7.4 中插图的用户界面。

07-04

图 7.4 URL 请求器 GUI

现在我们已经绘制了我们的 UI 草图,我们需要思考如何让两个事件循环并行运行。基本想法是,Tkinter 事件循环将在主线程中运行,而我们将异步事件循环在单独的线程中运行。然后,当用户点击“提交”时,我们将协程提交给异步事件循环以运行压力测试。当压力测试运行时,我们将从异步事件循环向 Tkinter 事件循环发出命令以更新我们的进度。这给我们提供了一个类似于图 7.5 所示的架构。

07-05

图 7.5 tk 事件循环将任务提交给异步事件循环,该循环在单独的线程中运行。

这种新的架构包括线程间的通信。在这种情况下,我们需要小心处理竞态条件,尤其是由于异步事件循环不是线程安全的!Tkinter 的设计考虑了线程安全,因此从单独的线程中调用它(至少在 Python 3+中)时,我们较少担心。

我们可能会倾向于使用asyncio.run从 Tkinter 提交协程,但这个函数会阻塞,直到我们传递的协程完成,这将导致 Tkinter 应用程序挂起。我们需要一个函数,可以将协程提交给事件循环而不产生任何阻塞。有几个新的异步函数需要学习,这些函数既是非阻塞的,又内置了线程安全性,以便正确地提交此类工作。第一个是在异步事件循环上的一个名为call_soon_threadsafe的方法。这个函数接受一个 Python 函数(不是一个协程),并安排它在异步事件循环的下一个迭代中以线程安全的方式执行。第二个函数是asyncio.run_coroutine_threadsafe。这个函数接受一个协程,并以线程安全的方式提交它,立即返回一个我们可以用来访问协程结果的 future。重要的是,并且令人困惑的是,这个 future不是异步 future,而是来自concurrent.futures模块。背后的逻辑是,异步 future 不是线程安全的,但concurrent.futures future 是。然而,这个future类具有与异步模块中的 future 相同的功能。

让我们开始定义和实现一些类,以构建基于上述描述的压力测试应用程序。我们将首先构建一个压力测试类。这个类将负责启动和停止一个压力测试,并跟踪完成请求的数量。它的构造函数将接受一个 URL、一个 asyncio 事件循环、要发起的请求数量以及一个进度更新回调函数。当我们想要触发进度条更新时,我们将调用这个回调函数。当我们实现 UI 时,这个回调函数将触发进度条的更新。内部,我们将计算一个刷新率,这是我们执行回调的速率。我们将默认这个速率为计划发送的总请求的 1%。

列表 7.13 压力测试类

import asyncio
from concurrent.futures import Future
from asyncio import AbstractEventLoop
from typing import Callable, Optional
from aiohttp import ClientSession

class StressTest:

    def __init__(self,
                 loop: AbstractEventLoop,
                 url: str,
                 total_requests: int,
                 callback: Callable[[int, int], None]):
        self._completed_requests: int = 0
        self._load_test_future: Optional[Future] = None
        self._loop = loop
        self._url = url
        self._total_requests = total_requests
        self._callback = callback
        self._refresh_rate = total_requests // 100

    def start(self):                                                       ❶
        future = asyncio.run_coroutine_threadsafe(self._make_requests(), self._loop)
        self._load_test_future = future

    def cancel(self):
        if self._load_test_future:
            self._loop.call_soon_threadsafe(self._load_test_future.cancel) ❷

    async def _get_url(self, session: ClientSession, url: str):
        try:
            await session.get(url)
        except Exception as e:
            print(e)
        self._completed_requests = self._completed_requests + 1 
        if self._completed_requests % self._refresh_rate == 0 \            ❸
                or self._completed_requests == self._total_requests:
            self._callback(self._completed_requests, self._total_requests)

    async def _make_requests(self):
        async with ClientSession() as session:
            reqs = [self._get_url(session, self._url) for _ in range(self._total_requests)]
            await asyncio.gather(*reqs)

❶ 开始发起请求,并存储未来,以便在需要时可以稍后取消。

❷ 如果我们要取消,请在负载测试未来上调用取消函数。

❸ 一旦完成 1% 的请求,就使用完成请求的数量和总请求数量调用回调函数。

在我们的 start 方法中,我们使用 _make_requests 调用 run_coroutine_threadsafe,这将开始在 asyncio 事件循环上发起请求。我们还在 _load_test_future 中跟踪这个返回的未来。跟踪这个未来让我们可以在 cancel 方法中取消负载测试。在我们的 _make_requests 方法中,我们创建一个协程列表来发起所有的网络请求,并将它们传递给 asyncio.gather 来运行。我们的 _get_url 协程发起请求,增加 _completed_requests 计数器,并在必要时调用回调函数以提供完成请求的总数。我们可以通过简单地实例化这个类并调用 start 方法来使用这个类,也可以通过调用 cancel 方法来取消。

值得注意的是,尽管多个协程会更新 _completed_requests 计数器,但我们没有在它周围使用任何锁定。记住,asyncio 是单线程的,并且 asyncio 事件循环在任何给定时间只运行一段 Python 代码。这导致在使用 asyncio 时,即使它在多个线程之间发生时是非原子的,增加计数器也是原子的。asyncio 保存我们免受许多在多线程中看到的竞争条件,但并非所有。我们将在后面的章节中进一步探讨这一点。

接下来,让我们实现我们的 Tkinter GUI 以使用这个负载测试类。为了代码的整洁性,我们将直接从TK类派生并初始化我们的小部件在构造函数中。当用户点击开始按钮时,我们将创建一个新的StressTest实例并启动它。现在的问题是我们将什么传递给StressTest实例作为回调?由于我们的回调将在工作线程中被调用,因此线程安全性成为一个问题。如果我们从工作线程修改共享数据,而主线程也可以修改这些数据,这可能会导致竞态条件。在我们的情况下,由于 Tkinter 内置了线程安全性,并且我们只是更新进度条,我们应该没问题。但如果我们需要处理共享数据呢?锁定是一种方法,但如果我们能在主线程中运行回调,我们将避免任何竞态条件。我们将使用一个通用模式来演示如何做到这一点,尽管直接更新进度条应该是安全的。

实现这一点的常见模式是使用queue模块中的共享线程安全队列。我们的 asyncio 线程可以将进度更新放入这个队列。然后,我们的 Tkinter 线程可以在其线程中检查这个队列以获取更新,并在正确的线程中更新进度条。我们需要告诉 Tkinter 在主线程中轮询队列以做到这一点。

Tkinter 有一个方法允许我们在主线程中按指定的时间增量排队运行一个函数,这个方法叫做after。我们将使用这个方法来运行一个询问队列是否有新的进度更新的方法(列表 7.14)。如果有,我们可以从主线程安全地更新进度条。我们将每 25 毫秒轮询队列一次,以确保我们以合理的延迟获得更新。

Tkinter 真的线程安全吗?

如果你搜索 Tkinter 和线程安全性,你会找到很多相互矛盾的信息。Tkinter 的线程情况相当复杂。这在一定程度上是因为,在过去的几年里,Tk 和 Tkinter 缺乏适当的线程支持。即使在添加了线程模式之后,它也有几个后来被修复的 bug。Tk 支持非线程模式和线程模式。在非线程模式下,没有线程安全性;并且从除了主线程之外的其他线程中使用 Tkinter 可能会导致崩溃。在 Python 的旧版本中,Tk 线程安全性没有被打开;然而,在 Python 3 及以后的版本中,线程安全性默认开启,我们有线程安全的保证。在线程模式下,如果从工作线程发出更新,Tkinter 会获取互斥锁并将更新事件写入主线程的队列以供稍后处理。发生这种情况的相关代码在 CPython 的Modules/_tkinter.c中的Tkapp_Call函数中。

列表 7.14 Tkinter GUI

from queue import Queue
from tkinter import Tk
from tkinter import Label
from tkinter import Entry
from tkinter import ttk
from typing import Optional
from chapter_07.listing_7_13 import StressTest

class LoadTester(Tk):

    def __init__(self, loop, *args, **kwargs):                             ❶
        Tk.__init__(self, *args, **kwargs)
        self._queue = Queue()
        self._refresh_ms = 25

        self._loop = loop
        self._load_test: Optional[StressTest] = None
        self.title('URL Requester')

        self._url_label = Label(self, text="URL:")
        self._url_label.grid(column=0, row=0)

        self._url_field = Entry(self, width=10)
        self._url_field.grid(column=1, row=0)

        self._request_label = Label(self, text="Number of requests:")
        self._request_label.grid(column=0, row=1)

        self._request_field = Entry(self, width=10)
        self._request_field.grid(column=1, row=1)

        self._submit = ttk.Button(self, text="Submit", command=self._start)❷
        self._submit.grid(column=2, row=1)

        self._pb_label = Label(self, text="Progress:")
        self._pb_label.grid(column=0, row=3)

        self._pb = ttk.Progressbar(self, orient="horizontal", length=200, mode="determinate")
        self._pb.grid(column=1, row=3, columnspan=2)
    def _update_bar(self, pct: int):                                       ❸
        if pct == 100:
            self._load_test = None
            self._submit['text'] = 'Submit'
        else:
            self._pb['value'] = pct
            self.after(self._refresh_ms, self._poll_queue)

    def _queue_update(self, completed_requests: int, total_requests: int): ❹
        self._queue.put(int(completed_requests / total_requests * 100))

    def _poll_queue(self):                                                 ❺
        if not self._queue.empty():
            percent_complete = self._queue.get()
            self._update_bar(percent_complete)
        else:
            if self._load_test:
                self.after(self._refresh_ms, self._poll_queue)

    def _start(self):                                                      ❻
        if self._load_test is None:
            self._submit['text'] = 'Cancel'
            test = StressTest(self._loop,
                              self._url_field.get(),
                              int(self._request_field.get()),
                              self._queue_update)
            self.after(self._refresh_ms, self._poll_queue)
            test.start()
            self._load_test = test
        else:
            self._load_test.cancel()
            self._load_test = None
            self._submit['text'] = 'Submit'

❶ 在我们的构造函数中,我们设置了文本输入、标签、提交按钮和进度条。

❷ 当点击时,我们的提交按钮将调用 _start 方法。

❸ 更新条方法将进度条设置为从 0 到 100 的完成百分比。此方法应仅在主线程中调用。

❹ 此方法是我们传递给压力测试的回调;它将进度更新添加到队列中。

❺ 尝试从队列中获取进度更新;如果我们有更新,则更新进度条。

❻ 启动负载测试,并开始每 25 毫秒轮询队列更新。

在我们应用程序的构造函数中,我们创建了用户界面所需的所有小部件。最值得注意的是,我们创建了用于测试 URL 和要运行的请求数的Entry小部件,一个提交按钮和一个水平进度条。我们还使用grid方法适当地排列这些小部件在窗口中。

当我们创建提交按钮小部件时,我们将命令指定为_start方法。此方法将创建一个StressTest对象并开始运行它,除非我们已经在运行负载测试,在这种情况下我们将取消它。当我们创建一个StressTest对象时,我们将_queue_update方法作为回调传递。StressTest对象将在有进度更新要发布时调用此方法。当此方法运行时,我们计算适当的百分比并将其放入队列。然后我们使用 Tkinter 的after方法安排每 25 毫秒运行一次_poll_queue方法。

使用队列作为共享通信机制而不是直接调用_update_bar将确保我们的_update_bar方法在 Tkinter 事件循环线程中运行。如果我们不这样做,进度条更新将在 asyncio 事件循环中发生,因为回调是在该线程中运行的。

现在我们已经实现了 UI 应用程序,我们可以将这些组件全部粘合在一起以创建一个完整工作的应用程序。我们将创建一个新的线程在后台运行事件循环,然后启动我们新创建的LoadTester应用程序。

列表 7.15 负载测试器应用程序

import asyncio
from asyncio import AbstractEventLoop
from threading import Thread
from chapter_07.listing_7_14 import LoadTester

class ThreadedEventLoop(Thread):                  ❶
    def __init__(self, loop: AbstractEventLoop):
        super().__init__()
        self._loop = loop
        self.daemon = True

    def run(self):
        self._loop.run_forever()

loop = asyncio.new_event_loop()

asyncio_thread = ThreadedEventLoop(loop)
asyncio_thread.start()                            ❷

app = LoadTester(loop)                            ❸
app.mainloop()

❶ 我们创建一个新的线程类来永久运行 asyncio 事件循环。

❷ 启动新的线程在后台运行 asyncio 事件循环。

❸ 创建负载测试器 Tkinter 应用程序,并启动其主事件循环。

我们首先定义一个继承自ThreadThreadedEventLoopClass来运行我们的事件循环。在这个类的构造函数中,我们接受一个事件循环并将线程设置为守护线程。我们将线程设置为守护线程,因为 asyncio 事件循环将在这个线程中阻塞并永久运行。这种无限循环将防止我们的 GUI 应用程序在非守护模式下运行时关闭。在线程的run方法中,我们调用事件循环的run_forever方法。这个方法的名字起得很好,因为它确实只是开始无限运行事件循环,直到我们停止事件循环。

一旦我们创建了这个类,我们就使用new_event_loop方法创建一个新的 asyncio 事件循环。然后我们创建一个ThreadedEventLoop实例,传入我们刚刚创建的循环并启动它。这创建了一个新的线程,我们的事件循环在其中运行。最后,我们创建LoadTester应用的实例并调用mainloop方法,启动 Tkinter 事件循环。

当我们运行这个应用的压力测试时,我们应该看到进度条平滑更新而不会冻结用户界面。我们的应用保持响应,并且我们可以随时点击取消来停止负载测试。在单独的线程中运行 asyncio 事件循环的技术对于构建响应式 GUI 非常有用,同时也适用于任何同步的遗留应用程序,在这些应用程序中协程和 asyncio 无法顺利运行。

我们已经看到了如何利用线程处理各种 I/O 密集型工作负载,但对于 CPU 密集型工作负载又该如何呢?回想一下,GIL 阻止我们在线程中并发运行 Python 字节码,但有一些值得注意的例外,这让我们可以在线程中执行一些 CPU 密集型工作。

7.5 使用线程处理 CPU 密集型工作

在 Python 中,全局解释器锁是一个复杂的话题。一般来说,多线程只适用于阻塞 I/O 工作,因为 I/O 操作会释放 GIL。这在大多数情况下是正确的,但并非所有情况。为了正确释放 GIL 并避免任何并发错误,正在运行的代码需要避免与 Python 对象(字典、列表、Python 整数等)交互。这可能会发生在我们库的大部分工作都在低级别的 C 代码中完成时。有一些值得注意的库,如 hashlib 和 NumPy,它们在纯 C 中执行 CPU 密集型工作并释放 GIL。这使得我们可以使用多线程来提高某些 CPU 密集型工作负载的性能。我们将检查两个这样的实例:为安全目的对敏感文本进行散列和用 NumPy 解决数据分析问题。

7.5.1 使用 hashlib 进行多线程

在当今世界,安全性从未如此重要。确保数据不被黑客读取是避免泄露敏感客户数据(如密码或其他可用于识别或伤害他们的信息)的关键。

散列算法通过取一段输入数据并创建一个新的数据块来解决此问题,该数据块对人类来说是不可读和不可恢复的(如果算法是安全的)。例如,密码“password”可能被散列成一个看起来更像是 'a12bc21df' 的字符串。虽然没有人可以读取或恢复输入数据,但我们仍然能够检查数据是否与散列匹配。这在验证用户登录时的密码或检查数据是否被篡改的场景中非常有用。

今天有许多不同的散列算法,例如 SHA512、BLAKE2 和 scrypt,尽管 SHA 不是存储密码的最佳选择,因为它容易受到暴力攻击。这些算法中的几个在 Python 的hashlib库中得到了实现。这个库中的许多函数在散列大于 2048 字节的 数据时会释放 GIL,因此多线程是一个提高此库性能的选项。此外,用于散列密码的scrypt函数始终释放 GIL。

让我们引入一个(希望是)假设的场景,看看在什么情况下多线程可能对hashlib有用。想象一下,你刚刚开始在一家成功组织担任首席软件架构师的新工作。你的经理分配给你第一个错误,让你开始学习公司的开发流程——登录系统的一个小问题。为了调试这个问题,你开始查看几个数据库表,令你惊讶的是,你注意到所有客户的密码都是以明文存储的!这意味着如果您的数据库被入侵,攻击者可以获取所有客户的密码并以他们的身份登录,可能暴露敏感数据,如保存的信用卡号码。你将这个问题带到经理的注意,他们要求你尽快找到解决问题的方案。

使用scrypt算法散列明文密码是解决这类问题的良好解决方案。它是安全的,原始密码无法恢复,因为它引入了。盐是一个随机数,确保我们得到的密码散列是唯一的。为了测试使用 scrypt,我们可以快速编写一个同步脚本来创建随机密码并散列它们,以了解事情将花费多长时间。在这个例子中,我们将测试 10,000 个随机密码。

列表 7.16 使用 scrypt 散列密码

import hashlib
import os
import string
import time
import random

def random_password(length: int) -> bytes:
    ascii_lowercase = string.ascii_lowercase.encode()
    return b''.join(bytes(random.choice(ascii_lowercase)) for _ in range(length))

passwords = [random_password(10) for _ in range(10000)]

def hash(password: bytes) -> str:
    salt = os.urandom(16)
    return str(hashlib.scrypt(password, salt=salt, n=2048, p=1, r=8))

start = time.time()

for password in passwords:
    hash(password)

end = time.time()
print(end - start)

我们首先编写一个函数来创建随机的小写密码,然后使用该函数创建 10,000 个 10 个字符的随机密码。然后我们使用scrypt函数散列每个密码。我们将省略细节(scrypt函数的 n、p 和 r 参数),但它们用于调整我们希望我们的散列有多安全以及内存/CPU 的使用情况。

在您拥有的服务器上运行此代码,这些服务器是 2.4 GHz 8 核心的机器,此代码只需超过 40 秒即可完成,这并不算太糟糕。问题是您拥有庞大的用户群,您需要散列 1,000,000,000 个密码。根据这个测试计算,散列整个数据库将需要超过 40 天!我们可以将数据集拆分并在多台机器上运行此程序,但考虑到速度如此之慢,我们需要很多机器来做这件事。我们能使用多线程来提高速度,从而减少所需的时间和机器吗?让我们应用我们关于多线程的知识来尝试一下。我们将创建一个线程池,并在多个线程中散列密码。

列表 7.17 使用多线程和 asyncio 进行散列

import asyncio
import functools
import hashlib
import os
from concurrent.futures.thread import ThreadPoolExecutor
import random
import string

from util import async_timed

def random_password(length: int) -> bytes:
    ascii_lowercase = string.ascii_lowercase.encode()
    return b''.join(bytes(random.choice(ascii_lowercase)) for _ in range(length))

passwords = [random_password(10) for _ in range(10000)]

def hash(password: bytes) -> str:
    salt = os.urandom(16)
    return str(hashlib.scrypt(password, salt=salt, n=2048, p=1, r=8))

@async_timed()
async def main():
    loop = asyncio.get_running_loop()
    tasks = []

    with ThreadPoolExecutor() as pool:
        for password in passwords:
            tasks.append(loop.run_in_executor(pool, functools.partial(hash, password)))

    await asyncio.gather(*tasks)

asyncio.run(main())

这种方法涉及我们创建一个线程池执行器,并为每个我们想要散列的密码创建一个任务。由于hashlib释放了 GIL,我们实现了相当不错的性能提升。这段代码大约运行了 5 秒,而之前我们得到了 40 秒。我们刚刚将运行时间从 47 天减少到略超过 5 天!作为下一步,我们可以将这个应用程序在不同的机器上并行运行,以进一步减少运行时间,或者我们可以获得一个具有更多 CPU 核心的机器。

7.5.2 使用 NumPy 进行多线程

NumPy 是一个非常流行的 Python 库,在数据科学和机器学习项目中广泛使用。它包含了许多常见的数学函数,这些函数适用于数组和矩阵,并且通常比纯 Python 数组表现更好。这种性能提升是因为底层库的大部分是用 C 和 Fortran 实现的,这两种语言都是底层语言,通常比 Python 更高效。

因为这个库的许多操作都在 Python 之外的底层代码中,这为 NumPy 释放 GIL 并允许我们多线程部分代码提供了机会。这里的限制是这种功能没有很好地记录,但通常可以假设矩阵操作可能被多线程以提高性能。话虽如此,根据numpy函数的实现方式,这种提升可能是大或小的。如果代码直接调用 C 函数并释放 GIL,那么可能获得更大的提升;如果围绕任何底层调用有很多支持 Python 代码,那么提升将较小。鉴于这一点没有很好地记录,你可能不得不尝试在你的应用程序中特定的瓶颈处添加多线程(你可以通过分析来确定瓶颈所在),并基准测试你获得的收益。然后你需要决定额外的复杂性是否值得你获得的任何潜在收益。

为了在实践中看到这一点,我们将创建一个包含 50 行、40 亿个数据点的大型矩阵。我们的任务将是获取每一行的平均值。NumPy 有一个高效的函数mean来计算这个值。这个函数有一个轴参数,它允许我们在一个轴上计算所有平均值,而无需编写循环。在我们的例子中,轴 1 将计算每一行的平均值。

列表 7.18 使用 NumPy 计算大型矩阵的平均值

import numpy as np
import time

data_points = 4000000000
rows = 50
columns = int(data_points / rows)

matrix = np.arange(data_points).reshape(rows, columns)

s = time.time()

res = np.mean(matrix, axis=1)

e = time.time()
print(e - s)

这个脚本首先创建一个包含 40 亿个整数数据点的数组,范围从 10 亿到 40 亿(请注意,这需要相当多的内存;如果你的应用程序因为内存不足而崩溃,请降低这个数字)。然后我们将数组“重塑”成一个有 50 行的矩阵。最后,我们调用 NumPy 的mean函数,轴为 1,计算每一行的平均值。总的来说,这个脚本在一个 8 核心 2.4 GHz CPU 上大约运行了 25-30 秒。让我们稍微修改一下这段代码,使其能够使用线程。我们将为每一行的中值运行一个单独的线程,并使用asyncio.gather等待所有行的中值。

列表 7.19 使用 NumPy 进行多线程

import functools
from concurrent.futures.thread import ThreadPoolExecutor
import numpy as np
import asyncio
from util import async_timed

def mean_for_row(arr, row):
    return np.mean(arr[row])

data_points = 4000000000
rows = 50
columns = int(data_points / rows)

matrix = np.arange(data_points).reshape(rows, columns)

@async_timed()
async def main():
    loop = asyncio.get_running_loop()
    with ThreadPoolExecutor() as pool:
        tasks = []
        for i in range(rows):
            mean = functools.partial(mean_for_row, matrix, i)
            tasks.append(loop.run_in_executor(pool, mean))

        results = asyncio.gather(*tasks)

asyncio.run(main())

首先,我们创建了一个 mean_for_row 函数,用于计算单行的平均值。由于我们的计划是在单独的线程中计算每一行的平均值,因此我们不能再像以前那样使用带有轴的 mean 函数。然后,我们使用线程池执行器创建一个主协程,并创建一个任务来计算每一行的平均值,使用 gather 等待所有计算完成。

在同一台机器上,此代码大约运行了 9-10 秒,性能提升了近 3 倍!在某些情况下,多线程可以帮助我们使用 NumPy,尽管在撰写本文时,关于哪些内容可以从线程中受益的文档是缺乏的。如果有疑问,如果线程可以帮助 CPU 密集型工作负载,最好的方法是测试它并对其进行基准测试。

此外,请注意,在尝试多线程或多进程以提升性能之前,你的 NumPy 代码应该尽可能地向量化。这意味着要避免像 Python 循环或 NumPy 的 apply_along_axis 函数这样的东西,因为它们只是隐藏了一个循环。使用 NumPy 时,你通常会看到通过尽可能地将计算推送到库的低级实现而获得更好的性能。

摘要

  • 我们已经学会了如何使用 threading 模块来运行 I/O 密集型工作。

  • 我们已经学会了如何在应用程序关闭时干净地终止线程。

  • 我们已经学会了如何使用线程池执行器将工作分配给线程池。这允许我们使用 asyncio API 方法,如 gather,来等待线程的结果。

  • 我们已经学会了如何使用线程池和 asyncio 来运行 I/O 密集型工作,从而提高性能。

  • 我们已经学会了如何使用 threading 模块中的锁来避免竞争条件。我们还学会了如何使用可重入锁来避免死锁。

  • 我们已经学会了如何在单独的线程中运行 asyncio 事件循环,并以线程安全的方式向其中发送协程。这使得我们可以使用 Tkinter 等框架构建响应式的用户界面。

  • 我们已经学会了如何使用 hashlibnumpy 进行多线程。底层库有时会释放 GIL,这让我们可以使用线程来执行 CPU 密集型工作。

8 流

本章涵盖

  • 传输和协议

  • 使用流进行网络连接

  • 异步处理命令行输入

  • 使用流创建客户端/服务器应用程序

在编写网络应用程序时,例如在前几章中的回声客户端,我们已经使用了套接字库来读取和写入我们的客户端。虽然直接使用套接字在构建低级网络库时很有用,但它们最终是复杂的生物,其细微之处超出了本书的范围。话虽如此,许多套接字用例依赖于一些概念上简单的操作,例如启动服务器、等待客户端连接以及向客户端发送数据。asyncio 的设计者意识到了这一点,并构建了网络流 API 来为我们抽象掉套接字的细微之处。这些高级 API 比套接字更容易使用,使得任何客户端/服务器应用程序的构建都更加容易,并且比我们自己使用套接字更加健壮。在 asyncio 中,使用流是构建基于网络的应用程序的首选方式。

在本章中,我们首先将通过构建一个简单的 HTTP 客户端来学习使用低级传输和协议 API。了解这些 API 将为我们理解高级流 API 在后台如何工作打下基础。然后,我们将利用这些知识来学习流读取器和写入器,并使用它们构建一个非阻塞的命令行 SQL 客户端。此应用程序将异步处理用户输入,使我们能够从命令行并发运行多个查询。最后,我们将学习如何使用 asyncio 的服务器 API 来创建客户端和服务器应用程序,构建一个功能性的聊天服务器和聊天客户端。

8.1 流的介绍

在 asyncio 中,是一组高级类和函数,用于创建和管理网络连接以及通用的数据流。使用它们,我们可以创建客户端连接以读取和写入服务器,甚至可以创建服务器并自行管理它们。这些 API 抽象了很多关于管理套接字的知识,例如处理 SSL 或丢失连接,使我们的开发生活变得稍微容易一些。

流 API 建立在称为传输协议的低级 API 之上。这些 API 直接封装了我们之前使用的套接字(通常,任何通用的数据流),为我们提供了一个干净的 API 来读取和写入套接字数据。

这些 API 的结构与其他 API 略有不同,因为它们使用回调式设计。与之前我们像等待套接字数据那样主动等待不同,当我们实现的一个类上的方法被调用时,数据就可用。然后,我们根据需要处理在这个方法中接收到的数据。为了开始学习这些基于回调的 API 是如何工作的,让我们首先通过构建一个基本的 HTTP 客户端来了解如何使用低级传输和协议 API。

8.2 传输和协议

从高层次来看,传输是与任意数据流进行通信的抽象。当我们与套接字或任何数据流(如标准输入)通信时,我们使用一组熟悉的操作。我们从源读取数据或向源写入数据,当我们完成与它的交互后,我们关闭它。套接字完美地符合我们定义的这个传输抽象;也就是说,我们向它读取和写入数据,一旦完成,我们就关闭它。简而言之,传输为从源发送和接收数据提供了定义。根据我们使用的源类型,传输有几种实现方式。我们主要关注ReadTransportWriteTransportTransport,尽管还有其他用于处理 UDP 连接和子进程通信的传输。图 8.1 展示了传输的类层次结构。

08-01

图 8.1 传输的类层次结构

将数据从套接字发送和接收只是方程的一部分。那么套接字的生命周期又是怎样的呢?我们建立连接;写入数据然后处理我们收到的任何响应。这些都是协议所拥有的操作集。请注意,这里的协议仅仅是指一个 Python 类,而不是像 HTTP 或 FTP 这样的协议。传输管理数据传输,并在事件发生时调用协议上的方法,例如连接建立或数据准备好处理,如图 8.2 所示。

08-02

图 8.2 当事件发生时,传输在协议上调用方法。协议可以向传输写入数据。

为了理解传输和协议是如何协同工作的,我们将构建一个基本的应用程序来运行单个 HTTP GET 请求。我们首先需要做的是定义一个扩展asyncio.Protocol的类。我们将实现基类中的几个方法来发送请求、接收请求的数据以及处理连接中的任何错误。

我们需要实现的第一个协议方法是connection_made。当底层套接字成功连接到 HTTP 服务器时,传输会调用这个方法。这个方法使用Transport作为参数,我们可以用它来与服务器通信。在这种情况下,我们将使用传输立即发送 HTTP 请求。

我们需要实现的第二个方法是data_received。每当传输接收到数据时,它都会调用这个方法,并将数据作为字节传递给我们。这个方法可以被多次调用,因此我们需要创建一个内部缓冲区来存储数据。

现在的问题变成了,我们如何知道我们的响应已经完成?为了回答这个问题,我们将实现一个名为 eof_received 的方法。当接收到 文件结束符 时,这个方法会被调用,对于套接字来说,这发生在服务器关闭连接的时候。一旦这个方法被调用,我们就可以保证 data_received 将不会被再次调用。eof_received 方法返回一个 Boolean 值,用于确定如何关闭传输(在这个例子中是关闭客户端套接字)。返回 False 确保传输会自行关闭,而返回 True 则意味着我们编写的协议实现将关闭连接。在这种情况下,由于我们不需要在关闭时执行任何特殊逻辑,我们的方法应该返回 False,因此我们不需要自己处理关闭传输。

根据我们描述的,我们只有一种方法可以将东西存储在内部缓冲区中。那么,我们的协议的消费者如何在请求完成后获取结果呢?为了做到这一点,我们可以在内部创建一个 Future 来保存完成时的结果。然后,在 eof_received 方法中,我们将 future 的结果设置为 HTTP 响应的结果。然后,我们将定义一个名为 get_response 的协程,它将等待这个 future

让我们将上面描述的内容实现为我们自己的协议。我们将称之为 HTTPGetClientProtocol

列表 8.1 使用传输和协议运行 HTTP 请求

import asyncio
from asyncio import Transport, Future, AbstractEventLoop
from typing import Optional

class HTTPGetClientProtocol(asyncio.Protocol):

    def __init__(self, host: str, loop: AbstractEventLoop):
        self._host: str = host
        self._future: Future = loop.create_future()
        self._transport: Optional[Transport] = None
        self._response_buffer: bytes = b''

    async def get_response(self):                                  ❶
        return await self._future

    def _get_request_bytes(self) -> bytes:                         ❷
        request = f"GET / HTTP/1.1\r\n" \
                  f"Connection: close\r\n" \
                  f"Host: {self._host}\r\n\r\n"
        return request.encode()

    def connection_made(self, transport: Transport):
        print(f'Connection made to {self._host}')
        self._transport = transport
        self._transport.write(self._get_request_bytes())           ❸

    def data_received(self, data):
        print(f'Data received!')
        self._response_buffer = self._response_buffer + data       ❹

    def eof_received(self) -> Optional[bool]:
        self._future.set_result(self._response_buffer.decode())    ❺
        return False

    def connection_lost(self, exc: Optional[Exception]) -> None:   ❻
        if exc is None:
            print('Connection closed without error.')
        else:
            self._future.set_exception(exc)

❶ 等待内部 future 直到从服务器收到响应。

❷ 创建 HTTP 请求。

❸ 一旦建立了连接,使用传输发送请求。

❹ 一旦我们有数据,将其保存到我们的内部缓冲区。

❺ 一旦连接关闭,使用缓冲区完成 future

❻ 如果连接在没有错误的情况下关闭,则不执行任何操作;否则,使用异常完成 future

现在我们已经实现了我们的协议,让我们用它来发送一个真实的请求。为此,我们需要在 asyncio 事件循环中学习一个新的协程方法,名为 create_connection。此方法将创建一个到指定主机的套接字连接,并将其包装在一个适当的传输中。除了主机和端口外,它还接受一个 协议工厂。协议工厂是一个创建协议实例的函数;在我们的例子中,是我们刚刚创建的 HTTPGetClientProtocol 类的实例。当我们调用这个协程时,我们得到了协程创建的传输以及工厂创建的协议实例。

列表 8.2 使用协议

import asyncio
from asyncio import AbstractEventLoop
from chapter_08.listing_8_1 import HTTPGetClientProtocol

async def make_request(host: str, port: int, loop: AbstractEventLoop) -> str:
    def protocol_factory():
        return HTTPGetClientProtocol(host, loop)

    _, protocol = await loop.create_connection(protocol_factory, host=host, port=port)

    return await protocol.get_response()

async def main():
    loop = asyncio.get_running_loop()
    result = await make_request('www .example .com', 80, loop)
    print(result)

asyncio.run(main())

我们首先定义了一个make_request方法,该方法接受我们想要请求的主机和端口,以及服务器的响应。在这个方法内部,我们为我们的协议工厂创建了一个内部方法,用于创建一个新的HTTPGetClientProtocol。然后,我们使用主机和端口调用create_connection,它返回我们的工厂创建的传输和协议。我们不需要传输,所以我们忽略它,但我们需要协议,因为我们想使用get_response协程;因此,我们将它在protocol变量中跟踪。最后,我们等待协议的get_response协程,该协程将等待 HTTP 服务器响应结果。在我们的主协程中,我们等待make_request并打印响应。执行此操作后,你应该看到如下 HTTP 响应(为了简洁,我们省略了 HTML 正文):

Connection made to www .example .com
Data received!
HTTP/1.1 200 OK
Age: 193241
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Connection closed without error.

我们已经学会了使用传输和协议。这些 API 是低级别的,因此不是在 asyncio 中处理流的推荐方式。让我们看看如何使用,这是一个高级抽象,它扩展了传输和协议。

8.3 流读取器和流写入器

传输和协议是低级别的 API,最适合我们需要直接控制发送和接收数据时的情况。例如,如果我们正在设计一个网络库或 Web 框架,我们可能会考虑使用传输和协议。对于大多数应用程序,我们不需要这种级别的控制,使用传输和协议将涉及我们编写大量重复的代码。

asyncio 的设计者意识到了这一点,并创建了高级的API。这个 API 将传输和协议的标准用例封装到两个易于理解和使用的类中:StreamReaderStreamWriter。正如你所猜到的,它们分别处理从流中读取和写入。使用这些类是推荐在 asyncio 中开发网络应用程序的方式。

为了理解如何使用这些 API,让我们以我们的 HTTP GET 请求示例为例,并将其转换为流。我们不是直接实例化StreamReaderStreamWriter实例,asyncio 提供了一个名为open_connection的库协程函数,它将为我们创建它们。这个协程接受我们将要连接的主机和端口,并返回一个包含StreamReaderStreamWriter的元组。我们的计划是使用StreamWriter发送 HTTP 请求,使用StreamReader读取响应。StreamReader的方法易于理解,我们有一个方便的readline协程,它等待我们有一行数据。或者,我们也可以使用StreamReaderread协程,它等待指定数量的字节到达。

StreamWriter 稍微复杂一些。它有一个我们预期的 write 方法,但它是一个普通方法,而不是 协程。内部,流写入器会立即尝试写入套接字的输出缓冲区,但这个缓冲区可能已满。如果套接字的写入缓冲区已满,数据将存储在内部队列中,稍后可以进入缓冲区。这可能导致潜在的问题,因为调用 write 并不一定立即发送数据。这可能会导致潜在的内存问题。想象一下,我们的网络连接变得缓慢,每秒只能发送 1 KB,但我们的应用程序每秒写入 1 MB。在这种情况下,我们的应用程序的写入缓冲区将比我们发送数据到套接字缓冲区的速度更快地填满,最终我们将开始达到机器的内存限制,从而引发崩溃。

我们如何等待所有数据都正确发送出去?为了解决这个问题,我们有一个名为 drain 的协程方法。这个协程将阻塞,直到所有排队的数据都发送到套接字,确保我们在继续之前已经写完了所有内容。我们希望在调用 write 之后使用函数的模式,我们总是会 await 一个对 drain 的调用。技术上讲,在每次 write 之后调用 drain 不是必需的,但这是一个好主意,可以帮助防止错误。

列表 8.3 使用流读取器和写入器的 HTTP 请求

import asyncio
from asyncio import StreamReader
from typing import AsyncGenerator

async def read_until_empty(stream_reader: StreamReader) -> AsyncGenerator[str, None]:
    while response := await stream_reader.readline():                                                  ❶
        yield response.decode()

async def main():
    host: str = 'www .example .com'
    request: str = f"GET / HTTP/1.1\r\n" \
                   f"Connection: close\r\n" \
                   f"Host: {host}\r\n\r\n"

    stream_reader, stream_writer = await asyncio.open_connection('www .example .com', 80)

    try:
        stream_writer.write(request.encode())                                                          ❷
        await stream_writer.drain()

        responses = [response async for response in read_until_empty(stream_reader)]                   ❸

        print(''.join(responses))
    finally:
        stream_writer.close()                                                                          ❹
        await stream_writer.wait_closed()

asyncio.run(main())

❶ 读取一行并解码,直到没有剩余的行。

❷ 写入 HTTP 请求,并排空写入器。

❸ 读取每一行,并将其存储在列表中。

❹ 关闭写入器,并等待其完成关闭。

在前面的列表中,我们首先创建了一个便利的异步生成器,用于从 StreamReader 读取所有行,将它们解码成字符串,直到没有剩余的行需要处理。然后,在我们的主协程中,我们打开到 example.com 的连接,在这个过程中创建了一个 StreamReaderStreamWriter 实例。然后我们写入请求并排空流写入器,分别使用 writedrain。一旦我们写完了请求,我们使用我们的异步生成器从响应中获取每一行,并将它们存储在 responses 列表中。最后,我们通过调用 close 关闭 StreamWriter 实例,然后等待 wait_closed 协程。为什么在这里需要调用一个方法和一个协程?原因是当我们调用 close 时,会发生一些事情,比如注销套接字和调用底层传输的 connection_lost 方法。这些都是在事件循环的后续迭代中异步发生的,这意味着在我们调用 close 之后,我们的连接不会立即关闭,而是在稍后某个时间。如果你需要在继续之前等待连接关闭,或者担心在关闭过程中可能发生的任何异常,调用 wait_closed 是最佳实践。

通过进行网络请求,我们已经学会了关于流 API 的基础知识。这些类的作用不仅限于基于 Web 和网络的应用程序。接下来,我们将看到如何利用流读取器创建非阻塞的命令行应用程序。

8.4 非阻塞命令行输入

在 Python 中,当我们需要获取用户输入时,我们通常使用input函数。这个函数会停止执行流程,直到用户提供了输入并按下 Enter 键。如果我们想在后台运行代码的同时保持对输入的响应呢?例如,我们可能希望让用户能够并发启动多个长时间运行的任务,比如长时间运行的 SQL 查询。在命令行聊天应用程序的情况下,我们可能希望用户能够在接收其他用户的消息的同时输入一条消息。

由于 asyncio 是单线程的,在 asyncio 应用程序中使用input意味着我们停止事件循环运行,直到用户提供输入,这会停止我们整个应用程序。即使使用任务在后台启动操作也不会起作用。为了演示这一点,让我们尝试创建一个应用程序,用户可以输入应用程序睡眠的时间。我们希望能够在接受用户输入的同时并发运行多个睡眠操作,所以我们将要求用户输入睡眠的秒数并在循环中创建一个delay任务。

列表 8.4 尝试后台任务

import asyncio
from util import delay

async def main():
    while True:
        delay_time = input('Enter a time to sleep:')
        asyncio.create_task(delay(int(delay_time)))

asyncio.run(main())

如果这段代码按我们的预期工作,在我们输入一个数字后,我们预计会看到sleeping for n second(s)打印出来,然后finished sleeping for n second(s) n 秒后。然而,情况并非如此,我们什么也没有看到,除了提示我们输入睡眠时间的提示符。这是因为我们的代码中没有await,因此任务从未有机会在事件循环上运行。我们可以通过在create_task行之后放置await asyncio.sleep(0)来解决这个问题,这将安排任务(这被称为“让出事件循环”,将在第十四章中介绍)。即使有了这个技巧,因为它停止了整个线程,input调用仍然会阻止我们创建的任何后台任务运行到完成。

我们真正想要的是让input函数成为一个协程,这样我们就可以编写类似delay_time = await input('Enter a time to sleep:').的东西。如果我们能这样做,我们的任务就会正确地安排,并在我们等待用户输入时继续运行。不幸的是,没有input的协程版本,所以我们需要做些其他的事情。

这就是协议和流读取器能帮我们解决问题的地方。回想一下,流读取器有一个readline协程,这正是我们正在寻找的协程类型。如果我们能将流读取器连接到标准输入,我们就可以使用这个协程来处理用户输入。

asyncio 在事件循环上有一个名为connect_read_pipe的协程方法,它将一个协议连接到一个文件对象,这几乎是我们想要的。这个协程方法接受一个协议工厂和一个管道。协议工厂只是一个创建协议实例的函数。管道是一个文件对象,它被定义为具有readwrite等方法的对象。然后connect_read_pipe协程将管道连接到工厂创建的协议,从管道获取数据并将其发送到协议。

在标准控制台输入方面,sys.stdin符合我们可以传递给connect_read_pipe的文件对象。一旦我们调用这个协程,我们将得到一个元组,包含我们的工厂函数创建的协议和一个ReadTransport。现在的问题是,我们应该在工厂中创建什么协议,以及我们如何将这个协议与具有我们想要使用的readline协程的StreamReader连接起来?

asyncio 提供了一个名为StreamReaderProtocol的实用类,用于将流读取器的实例连接到协议。当我们实例化这个类时,我们传递一个流读取器的实例。然后协议类将委托给我们所创建的流读取器,允许我们使用流读取器从标准输入读取数据。将这些部件组合在一起,我们可以创建一个在等待用户输入时不会阻塞事件循环的命令行应用程序。

对于 Windows 用户

不幸的是,在 Windows 上connect_read_pipe将无法与sys.stdin一起工作。这是由于 Windows 实现文件描述符的方式导致的未修复的 bug。为了在 Windows 上工作,您需要在一个单独的线程中调用sys.stdin.readline(),使用我们在第七章中探索的技术。您可以在bugs.python.org/issue26832了解更多关于此问题的信息。

由于我们将在本章的其余部分重复使用异步标准输入读取器,让我们将其创建在自己的文件listing_8_5.py中。然后我们将在本章的其余部分导入它。

列表 8.5 异步标准输入读取器

import asyncio
from asyncio import StreamReader
import sys

async def create_stdin_reader() -> StreamReader:
    stream_reader = asyncio.StreamReader()
    protocol = asyncio.StreamReaderProtocol(stream_reader)
    loop = asyncio.get_running_loop()
    await loop.connect_read_pipe(lambda: protocol, sys.stdin)
    return stream_reader

在前面的列表中,我们创建了一个可重用的协程create_stdin_reader,它创建一个StreamReader,我们将使用它来异步读取标准输入。我们首先创建一个流读取器实例,并将其传递给一个流读取器协议。然后我们调用connect_read_pipe,传入一个作为 lambda 函数的协议工厂。这个 lambda 函数返回我们之前创建的流读取器协议。我们还传递sys.stdin来将标准输入连接到我们的流读取器协议。由于我们不需要它们,我们忽略了connect_read_pipe返回的传输和协议。现在我们可以使用这个函数来异步地从标准输入读取并构建我们的应用程序。

列表 8.6 使用流读取器进行输入

import asyncio
from chapter_08.listing_8_5 import create_stdin_reader
from util import delay

async def main():
    stdin_reader = await create_stdin_reader()
    while True:
        delay_time = await stdin_reader.readline()
        asyncio.create_task(delay(int(delay_time)))

asyncio.run(main())

在我们的主协程中,我们调用 create_stdin_reader 并无限循环,等待用户通过 readline 协程输入。一旦用户在键盘上按下 Enter 键,这个协程将传递输入的文本。一旦我们收到用户的输入,我们将它转换为一个整数(注意在这里,对于实际的应用程序,我们应该添加代码来处理不良输入,因为我们现在传递一个字符串会导致崩溃)并创建一个 delay 任务。运行这个任务,你将能够同时运行多个 delay 任务,同时还能输入命令行输入。例如,分别输入 5 秒、4 秒和 3 秒的延迟,你应该看到以下输出:

5
sleeping for 5 second(s)
4
sleeping for 4 second(s)
3
sleeping for 3 second(s)
finished sleeping for 5 second(s)
finished sleeping for 4 second(s)
finished sleeping for 3 second(s)

这可行,但这种方法有一个关键的缺陷。如果我们输入输入延迟时间时控制台上出现消息,会发生什么?为了测试这个问题,我们将输入一个 3 秒的延迟时间,然后开始快速按下 1。这样做,我们会看到以下类似的情况:

3
sleeping for 3 second(s)
111111finished sleeping for 3 second(s)
11

当我们输入时,来自我们的延迟任务的消息打印出来,打断了我们的输入行,并迫使它继续在下一行。此外,输入缓冲区现在只有 11,这意味着如果我们按下 Enter,我们将创建一个持续该时间的 delay 任务,丢失了前几部分输入。这是因为默认情况下,终端以 cooked 模式运行。在这种模式下,终端将用户输入回显到标准输出,并处理特殊键,如 Enter 和 CTRL-C。这个问题出现是因为 delay 协程在终端回显输出时写入标准输出,导致竞争条件。

屏幕上还有一个位置,标准输出会写入。这被称为 光标,它就像在文字处理程序中看到的光标一样。当我们输入时,光标停留在我们的键盘输入打印出的行上。这意味着任何来自其他协程的输出消息都会打印在我们的输入所在的同一行,因为这是光标所在的位置,导致奇怪的行为。

为了解决这些问题,我们需要两种解决方案的组合。第一个是将终端的输入回显引入我们的 Python 应用程序。这将确保,当我们回显用户输入时,我们不会在单线程的情况下写入来自其他协程的任何输出消息。第二个是在我们写入输出消息时移动屏幕上的光标,确保我们不将输出消息写入我们的输入所在的同一行。我们可以通过操作终端设置和使用转义序列来实现这些操作。

8.4.1 终端 raw 模式和 read 协程

由于我们的终端以 cooked 模式运行,它会在我们的应用程序外部为我们处理 readline 上的用户输入。我们如何将这种处理引入我们的应用程序,以便我们避免之前看到的竞争条件?

答案是将终端切换到raw模式。在 raw 模式下,终端不再为我们进行缓冲、预处理和回显,每个按键都会发送到应用程序。然后,就由我们来决定如何回显和预处理。虽然这意味着我们必须做额外的工作,但它也意味着我们可以在写入标准输出方面拥有更精细的控制,从而给我们提供避免竞争条件所需的权力。

Python 允许我们更改终端到 raw 模式,但也允许cbreak模式。这种模式的行为类似于 raw 模式,不同之处在于像 CTRL-C 这样的按键仍然会被为我们解释,从而节省我们一些工作。我们可以通过使用tty模块和setcbreak函数来进入 raw 模式,如下所示:

import tty
import sys
tty.setcbreak(sys.stdin)

一旦我们进入cbreak模式,我们就需要重新思考我们的应用程序设计。readline协程将不再工作,因为在 raw 模式下,它不会为我们回显任何输入。相反,我们希望一次读取一个字符并将其存储在我们的内部缓冲区中,回显每个输入的字符。我们创建的标准输入流读取器有一个名为read的方法,该方法接受从流中读取的字节数。调用read(1)将一次读取一个字符,然后我们可以将其存储在缓冲区中并回显到标准输出。

现在我们有两块拼图要解决这个难题,进入cbreak模式和一次读取一个输入字符,将其回显到标准输出。我们需要思考如何显示delay协程的输出,以确保它不会干扰我们的输入。

让我们定义一些要求,使我们的应用程序更加用户友好,并解决输出写入与输入同一行的问题。然后,我们将让这些要求指导我们的实现:

  1. 用户输入字段应始终保持在屏幕底部。

  2. 协程输出应从屏幕顶部开始并向下移动。

  3. 当屏幕上的消息行数超过可用行数时,现有消息应向上滚动。

给定这些要求,我们如何显示delay协程的输出?鉴于我们希望在消息行数超过可用行数时向上滚动消息,直接使用 print 将标准输出会变得很棘手。为此,我们将采取的方法是保持一个要写入标准输出的消息的deque(双端队列)。我们将设置 deque 中元素的最大数量为终端屏幕上的行数。当 deque 满时,这将给我们想要的滚动行为,因为 deque 后面的项目将被丢弃。当新的消息被追加到 deque 中时,我们将移动到屏幕顶部并重新绘制每个消息。这将使我们得到所需的滚动行为,而无需保留太多关于标准输出状态的信息。这使得我们的应用程序流程看起来像图 8.3 中的插图。

08-03

图 8.3 延迟控制台应用程序

我们对该应用程序的计划如下:

  1. 将光标移动到屏幕底部,当按下键时,将其追加到我们的内部缓冲区,并将按键回显到标准输出。

  2. 当用户按下 Enter 键时,创建一个delay任务。我们不会将输出消息写入标准输出,而是将它们追加到一个双端队列中,其元素的最大数量等于控制台上的行数。

  3. 一旦消息进入双端队列,我们将在屏幕上重新绘制输出。我们首先将光标移动到屏幕的左上角。然后,我们将打印出双端队列中的所有消息。完成后,我们将光标返回到之前的输入行和列。

要以这种方式实现应用程序,我们首先需要学习如何移动光标在屏幕上。我们可以使用 ANSI 转义码来完成这项任务。这些是我们可以写入标准输出的特殊代码,可以执行诸如更改文本颜色、移动光标上下和删除行等操作。转义序列首先由一个转义码引入;在 Python 中,我们可以通过向控制台打印\033来实现。我们将需要使用的大多数转义序列都是由控制序列引入符引入的,它以打印\033[开始。为了更好地理解这一点,让我们看看如何将光标移动到当前光标下方五行的位置。

        sys.stdout.write('\033[5E')

这个转义序列以控制序列引入符开头,后跟5E。5 代表从当前光标行开始我们希望下移的行数,而 E 是“向下移动这么多行”的代码。转义序列简洁且稍难理解。在下一个列表中,我们将创建几个具有清晰名称的函数来解释每个转义码的作用,并在未来的列表中导入它们。如果您想了解更多关于 ANSI 转义序列及其工作原理的解释,Wikipedia 上关于该主题的文章提供了很好的信息,链接为en.wikipedia.org/wiki/ANSI_escape_code

让我们思考一下我们需要如何移动光标来屏幕周围,以确定我们需要实现哪些函数。首先,我们需要将光标移动到屏幕底部以接受用户输入。然后,一旦用户按下 Enter 键,我们需要清除他们输入的任何文本。为了从屏幕顶部打印协程输出消息,我们需要能够移动到屏幕的第一行。我们还需要保存和恢复光标当前的位置,因为在从协程中输入消息时,它可能会打印一条消息,这意味着我们需要将其移回正确的位置。我们可以使用以下转义码函数来完成这些操作:

列表 8.7 转义序列便利函数

import sys
import shutil

def save_cursor_position():
    sys.stdout.write('\0337')

def restore_cursor_position():
    sys.stdout.write('\0338')

def move_to_top_of_screen():
    sys.stdout.write('\033[H')

def delete_line():
    sys.stdout.write('\033[2K')

def clear_line():
    sys.stdout.write('\033[2K\033[0G')

def move_back_one_char():
    sys.stdout.write('\033[1D')

def move_to_bottom_of_screen() -> int:
    _, total_rows = shutil.get_terminal_size()
    input_row = total_rows - 1
    sys.stdout.write(f'\033[{input_row}E')
    return total_rows

现在我们有一组可重用的函数来在屏幕上移动光标,让我们实现一个可重用的协程,用于逐个读取标准输入。我们将使用 read 协程来完成此操作。一旦我们读取了一个字符,我们将将其写入标准输出,并将字符存储在内部缓冲区中。由于我们还想处理用户按下 Delete 的情况,我们将监视 Delete 键。当用户按下它时,我们将从缓冲区和标准输出中删除字符。

列表 8.8 逐个读取输入

import sys
from asyncio import StreamReader
from collections import deque
from chapter_08.listing_8_7 import move_back_one_char, clear_line

async def read_line(stdin_reader: StreamReader) -> str:
    def erase_last_char():                                     ❶
        move_back_one_char()
        sys.stdout.write(' ')
        move_back_one_char()

    delete_char = b'\x7f'
    input_buffer = deque()
    while (input_char := await stdin_reader.read(1)) != b'\n':
        if input_char == delete_char:                          ❷
            if len(input_buffer) > 0:
                input_buffer.pop()
                erase_last_char()
                sys.stdout.flush()
        else:
            input_buffer.append(input_char)                    ❸
            sys.stdout.write(input_char.decode())
            sys.stdout.flush()
    clear_line()
    return b''.join(input_buffer).decode()

❶ 删除标准输出中前一个字符的便利函数

❷ 如果输入字符是退格键,则删除最后一个字符。

❸ 如果输入字符不是退格键,将其追加到缓冲区并回显。

我们的协程接收一个流读取器,该读取器已连接到标准输入。然后我们定义一个便利函数来擦除标准输出的前一个字符,因为我们将在用户按下 Delete 键时需要这个功能。然后我们进入一个 while 循环,逐个读取字符,直到用户按下 Enter。如果用户按下 Delete,我们将从缓冲区和标准输出中删除最后一个字符。否则,我们将它追加到缓冲区并回显。一旦用户按下 Enter,我们将清除输入行并返回缓冲区的内容。

接下来,我们需要定义一个队列,我们将存储想要打印到标准输出的消息。由于我们希望在追加消息时重新绘制输出,我们将定义一个类,该类封装一个双端队列并接受一个可等待的回调。我们传递的回调将负责重新绘制输出。我们还将为我们的类添加一个 append 协程方法,该方法将项目追加到双端队列并使用当前双端队列中的项目集调用回调。

列表 8.9 消息存储

from collections import deque
from typing import Callable, Deque, Awaitable

class MessageStore:
    def __init__(self, callback: Callable[[Deque], Awaitable[None]], max_size: int):
        self._deque = deque(maxlen=max_size)
        self._callback = callback

    async def append(self, item):
        self._deque.append(item)
        await self._callback(self._deque)

现在,我们有了创建应用程序的所有部件。我们将重写我们的 delay 协程,以便向消息存储中添加消息。然后,在我们的主协程中,我们将创建一个辅助协程来重新绘制双端队列中的消息到标准输出。这是我们传递给 MessageStore 的回调。然后,我们将使用我们之前实现的 read_line 协程来接受用户输入,当用户按下 Enter 时创建一个延迟任务。

列表 8.10 异步延迟应用程序

import asyncio
import os
import tty
from collections import deque
from chapter_08.listing_8_5 import create_stdin_reader
from chapter_08.listing_8_7 import *
from chapter_08.listing_8_8 import read_line
from chapter_08.listing_8_9 import MessageStore

async def sleep(delay: int, message_store: MessageStore):
    await message_store.append(f'Starting delay {delay}')    ❶
    await asyncio.sleep(delay)
    await message_store.append(f'Finished delay {delay}')

async def main():
    tty.setcbreak(sys.stdin)
    os.system('clear')
    rows = move_to_bottom_of_screen()

    async def redraw_output(items: deque):                   ❷
        save_cursor_position()
        move_to_top_of_screen()
        for item in items:
            delete_line()
            print(item)
        restore_cursor_position()

    messages = MessageStore(redraw_output, rows - 1)

    stdin_reader = await create_stdin_reader()

    while True:
        line = await read_line(stdin_reader)
        delay_time = int(line)
        asyncio.create_task(sleep(delay_time, messages))

asyncio.run(main())

❶ 将输出消息追加到消息存储中。

❷ 回调用于将光标移动到屏幕顶部;重新绘制输出并将光标移回。

运行此程序,您将能够创建延迟并观察输入写入控制台,即使您在输入时也是如此。虽然它比我们的第一次尝试更复杂,但我们已经构建了一个避免我们之前在写入标准输出时遇到的问题的应用程序。

我们构建的适用于 delay 协程,但对于更实际的应用又如何呢?我们刚刚定义的组件足够健壮,我们可以通过重用它们来创建更有用的应用程序。例如,让我们思考一下如何创建一个命令行 SQL 客户端。某些查询可能需要很长时间才能执行,但在此期间我们可能想运行其他查询或取消正在运行的查询。使用我们刚刚构建的组件,我们可以创建这种类型的客户端。让我们使用第五章中创建的之前的电子商务产品数据库来构建一个,其中我们创建了一个包含一系列服装品牌、产品和 SKU 的模式。我们将创建一个连接池来连接到我们的数据库,并重用之前示例中的代码来接受和运行查询。我们将输出查询的基本信息到控制台——目前,只是返回的行数。

列表 8.11 一个异步命令行 SQL 客户端

import asyncio
import asyncpg
import os
import tty
from collections import deque
from asyncpg.pool import Pool
from chapter_08.listing_8_5 import create_stdin_reader
from chapter_08.listing_8_7 import *
from chapter_08.listing_8_8 import read_line
from chapter_08.listing_8_9 import MessageStore

async def run_query(query: str, pool: Pool, message_store: MessageStore):
    async with pool.acquire() as connection:
        try:
            result = await connection.fetchrow(query)
            await message_store.append(f'Fetched {len(result)} rows from: {query}')
        except Exception as e:
            await message_store.append(f'Got exception {e} from: {query}')

async def main():
    tty.setcbreak(0)
    os.system('clear')
    rows = move_to_bottom_of_screen()

    async def redraw_output(items: deque):
        save_cursor_position()
        move_to_top_of_screen()
        for item in items:
            delete_line()
            print(item)
        restore_cursor_position()

    messages = MessageStore(redraw_output, rows - 1)

    stdin_reader = await create_stdin_reader()

    async with asyncpg.create_pool(host='127.0.0.1',
                                   port=5432,
                                   user='postgres',
                                   password='password',
                                   database='products',
                                   min_size=6,
                                   max_size=6) as pool:

        while True:
            query = await read_line(stdin_reader)
            asyncio.create_task(run_query(query, pool, messages))

asyncio.run(main())

我们的代码几乎和之前一样,区别在于我们不是创建一个 delay 协程,而是创建一个 run_query 协程。它不是简单地等待任意时间,而是运行用户输入的查询,这可能需要任意时间。这使得我们可以在其他查询仍在运行时从命令行发出新的查询;它还允许我们在输入新查询的同时看到已完成查询的输出。

我们现在知道如何创建在执行其他代码并写入控制台的同时可以处理输入的命令行客户端。接下来,我们将学习如何使用更高层次的 asyncio API 创建服务器。

8.5 创建服务器

当我们构建服务器,例如我们的回声服务器时,我们已经创建了一个服务器套接字,将其绑定到一个端口并等待传入的连接。虽然这可行,但 asyncio 允许我们以更高层次的抽象创建服务器,这意味着我们可以创建服务器而无需担心管理套接字。以这种方式创建服务器简化了我们需要编写的套接字代码,因此,使用这些高级 API 是推荐的方式来创建和管理使用 asyncio 的服务器。

我们可以使用 asyncio.start_server 协程来创建一个服务器。这个协程接受几个可选参数来配置诸如 SSL 之类的设置,但我们最感兴趣的参数是 hostportclient_connected_cb。主机和端口就像我们之前看到的:服务器套接字将监听连接的地址。更有趣的部分是 client_connected_cb,它是一个回调函数或协程,每当客户端连接到服务器时都会运行。这个回调函数接受一个 StreamReaderStreamWriter 作为参数,这将使我们能够从连接到服务器的客户端读取和写入。

当我们等待 start_server 时,它将返回一个 AbstractServer 对象。除了 serve_forever 方法外,这个类缺少许多我们需要的有趣方法,serve_forever 方法将服务器无限期运行,直到我们终止它。这个类也是一个异步上下文管理器。这意味着我们可以使用它的一个实例与 async with 语法一起使用,以便在退出时正确关闭服务器。

为了掌握创建服务器的技巧,让我们再次创建一个回声服务器,但让它更高级一些。我们不仅会回显输出,还会显示有关连接了多少其他客户端的信息。当客户端从服务器断开连接时,我们也会显示信息。为了管理这些,我们将创建一个名为 ServerState 的类来管理连接的用户数量。一旦用户连接,我们将他们添加到服务器状态,并通知其他客户端他们已连接。

列表 8.12 使用服务器对象创建回声服务器

import asyncio
import logging
from asyncio import StreamReader, StreamWriter

class ServerState:

    def __init__(self):
        self._writers = []

    async def add_client(self, reader: StreamReader, writer: StreamWriter):  ❶
        self._writers.append(writer)
        await self._on_connect(writer)
        asyncio.create_task(self._echo(reader, writer))

    async def _on_connect(self, writer: StreamWriter):                       ❷
        writer.write(f'Welcome! {len(self._writers)} user(s) are online!\n'.encode())
        await writer.drain()
        await self._notify_all('New user connected!\n')

    async def _echo(self, reader: StreamReader, writer: StreamWriter):       ❸
        try:
            while (data := await reader.readline()) != b'':
                writer.write(data)
                await writer.drain()
            self._writers.remove(writer)
            await self._notify_all(f'Client disconnected. {len(self._writers)} user(s) are online!\n')
        except Exception as e:
            logging.exception('Error reading from client.', exc_info=e)
            self._writers.remove(writer)

    async def _notify_all(self, message: str):                               ❹
        for writer in self._writers:
            try:
                writer.write(message.encode())
                await writer.drain()
            except ConnectionError as e:
                logging.exception('Could not write to client.', exc_info=e)
                self._writers.remove(writer)

async def main():
    server_state = ServerState()

    async def client_connected(reader: StreamReader, writer:                 ❺
     StreamWriter) -> None:                                 
        await server_state.add_client(reader, writer)

    server = await asyncio.start_server(client_connected, '127.0.0.1', 8000) ❻

    async with server:
        await server.serve_forever()

asyncio.run(main())

❶ 将客户端添加到服务器状态,并创建一个回声任务。

❷ 在新的连接上,告诉客户端有多少用户在线,并通知其他新用户。

❸ 当客户端断开连接时,处理回声用户输入,并通知其他用户断开连接。

❹ 辅助方法,用于向所有其他用户发送消息。如果消息发送失败,则移除该用户。

❺ 当客户端连接时,将该客户端添加到服务器状态。

❻ 启动服务器,并永久提供服务。

当用户连接到我们的服务器时,我们的 client_connected 回调会返回该用户的读取器和写入器,这反过来又调用服务器状态的 add_client 协程。在 add_client 协程中,我们存储 StreamWriter,这样我们就可以向所有已连接客户端发送消息,并在客户端断开连接时移除它。然后我们调用 _on_connect,向客户端发送一条消息,告知他们有多少其他用户已连接。在 _on_connect 中,我们还通知任何其他已连接客户端有新用户已连接。

_echo 协程类似于我们过去所做,但有一个转折,即当用户断开连接时,我们通知任何其他已连接客户端有人断开连接。在运行此操作时,你应该有一个功能正常的回声服务器,让每个客户端都知道何时有新用户连接到或从服务器断开连接。

我们已经看到了如何创建一个比之前更高级的 asyncio 服务器。接下来,让我们在此基础上构建,创建一个聊天服务器和聊天客户端——这将是更高级的内容。

8.6 创建聊天服务器和客户端

我们现在知道如何创建服务器并处理异步命令行输入。我们可以将这两个领域的知识结合起来,创建两个应用程序。第一个是一个聊天服务器,可以同时接受多个聊天客户端,第二个是一个连接到服务器并发送接收聊天消息的聊天客户端。

在我们开始设计应用程序之前,让我们先从一些将帮助我们做出正确设计选择的需求开始。首先,对于我们的服务器:

  1. 一个聊天客户端应该能够在提供用户名时连接到服务器。

  2. 一旦用户连接上了,他们应该能够向服务器发送聊天消息,并且每条消息都应该发送给服务器上所有连接的用户。

  3. 为了防止空闲用户占用资源,如果一个用户空闲超过一分钟,服务器应该断开他们的连接。

第二,对于我们的客户端:

  1. 当用户启动应用程序时,客户端应该提示输入用户名并尝试连接到服务器。

  2. 一旦连接,用户将看到来自其他客户端的消息从屏幕顶部滚动下来。

  3. 用户应该在屏幕底部有一个输入字段。当用户按下 Enter 键时,输入框中的文本应该发送到服务器,然后发送给所有其他已连接的客户端。

给定这些需求,让我们首先思考一下客户端和服务器之间的通信应该是什么样的。首先,我们需要从客户端向服务器发送一条包含我们用户名的消息。我们需要区分使用用户名连接和发送消息,因此我们将引入一个简单的命令协议来指示我们正在发送用户名。为了简化,我们将只传递一个包含命令名称的字符串,命令名称为CONNECT,后跟用户提供的用户名。例如,CONNECT MissIslington将是我们将发送到服务器的消息,以将用户名MissIslington的用户连接到服务器。

一旦我们连接上了,我们只需直接向服务器发送消息,然后服务器会将消息发送给所有已连接的客户端(包括我们自己;如有需要,你可以优化这一点)。为了使应用程序更健壮,你可能想要考虑一个服务器发送回客户端的命令来确认消息已被接收,但为了简洁起见,我们将跳过这一点。

有了这个想法,我们就有足够的内容开始设计我们的服务器了。我们将创建一个类似于上一节所做的一个ChatServerState类。一旦客户端连接,我们将等待他们使用CONNECT命令提供用户名。假设他们提供了,我们将创建一个任务来监听来自客户端的消息并将它们写入所有其他已连接的客户端。为了跟踪已连接的客户端,我们将保持一个包含已连接用户名到其StreamWriter实例的字典。如果一个已连接的用户空闲超过一分钟,我们将断开他们的连接并将他们从字典中移除,同时向其他用户发送消息,告知他们有人离开了聊天。

列表 8.13 一个聊天服务器

import asyncio
import logging
from asyncio import StreamReader, StreamWriter

class ChatServer:

    def __init__(self):
        self._username_to_writer = {}

    async def start_chat_server(self, host: str, port: int):
        server = await asyncio.start_server(self.client_connected, host, port)

        async with server:
            await server.serve_forever()

    async def client_connected(self, reader: StreamReader, writer: StreamWriter):❶
        command = await reader.readline()
        print(f'CONNECTED {reader} {writer}')
        command, args = command.split(b' ')
        if command == b'CONNECT':
            username = args.replace(b'\n', b'').decode()
            self._add_user(username, reader, writer)
            await self._on_connect(username, writer)
        else:
            logging.error('Got invalid command from client, disconnecting.')
            writer.close()
            await writer.wait_closed()

    def _add_user(self, username: str, reader:                                   ❷
     StreamReader, writer: StreamWriter):                                        ❷
        self._username_to_writer[username] = writer
        asyncio.create_task(self._listen_for_messages(username, reader))

    async def _on_connect(self, username: str, writer: StreamWriter):            ❸
        writer.write(f'Welcome! {len(self._username_to_writer)} user(s) are online!\n'.encode())
        await writer.drain()
        await self._notify_all(f'{username} connected!\n')

    async def _remove_user(self, username: str):
        writer = self._username_to_writer[username]
        del self._username_to_writer[username]
        try:
            writer.close()
            await writer.wait_closed()
        except Exception as e:
            logging.exception('Error closing client writer, ignoring.', exc_info=e)

    async def _listen_for_messages(self,
                                   username: str,
                                   reader: StreamReader):                        ❹
        try:
            while (data := await asyncio.wait_for(reader.readline(), 60)) != b'':
                await self._notify_all(f'{username}: {data.decode()}')
            await self._notify_all(f'{username} has left the chat\n')
        except Exception as e:
            logging.exception('Error reading from client.', exc_info=e)
            await self._remove_user(username)

    async def _notify_all(self, message: str):                                   ❺
        inactive_users = []
        for username, writer in self._username_to_writer.items():
            try:
                writer.write(message.encode())
                await writer.drain()
            except ConnectionError as e:
                logging.exception('Could not write to client.', exc_info=e)
                inactive_users.append(username)

        [await self._remove_user(username) for username in inactive_users]

async def main():
    chat_server = ChatServer()
    await chat_server.start_chat_server('127.0.0.1', 8000)

asyncio.run(main())

❶ 等待客户端提供有效的用户名命令;否则,断开连接。

❷ 存储用户的流写入实例并创建一个任务来监听消息。

❸ 一旦用户连接,通知其他人他们也已连接。

❹ 监听来自客户端的消息并发送给所有其他客户端,等待最多一分钟接收消息。

❺ 向所有已连接的客户端发送消息,移除任何断开连接的用户。

我们的 ChatServer 类封装了我们聊天服务器的所有内容,提供了一个干净的接口。主要入口点是 start_chat_server 协程。这个协程在指定的主机和端口上启动服务器,并调用 serve_forever。对于我们的服务器客户端连接回调,我们使用我们的 client_connected 协程。这个协程等待客户端的第一行数据,如果它收到一个有效的 CONNECT 命令,它将调用 _add_user 然后调用 _on_connect;否则,它终止连接。

_add_user 函数将用户名和用户的流写入器存储在一个内部字典中,然后创建一个任务来监听来自用户的聊天消息。_on_connect 协程向客户端发送一条欢迎消息,然后通知所有其他已连接的客户端用户已连接。

当我们调用 _add_user 时,我们为 _listen_for_messages 协程创建了一个任务。这个协程是我们应用程序的核心。我们无限循环,从客户端读取消息,直到我们看到一个空行,这表示客户端断开连接。一旦我们收到一条消息,我们就调用 _notify_all 将聊天消息发送给所有已连接的客户端。为了满足客户端在空闲一分钟后被断开的要求,我们将 readline 协程包装在 wait_for 中。如果客户端空闲时间超过一分钟,这将抛出一个 TimeoutError。在这种情况下,我们有一个广泛的异常处理子句,它捕获 TimeoutError 和抛出的任何其他异常。我们通过从 _username_to_writer 字典中删除客户端来处理任何异常,这样我们就停止向他们发送消息。

现在我们有一个完整的服务器,但没有客户端连接,服务器就没有意义。我们将以我们之前编写的命令行 SQL 客户端类似的方式实现客户端。我们将创建一个协程来监听来自服务器的消息并将它们追加到消息存储中,当有新消息到来时重绘屏幕。我们还将输入放在屏幕底部,当用户按下 Enter 键时,我们将消息发送到聊天服务器。

列表 8.14 聊天客户端

import asyncio
import os
import logging
import tty
from asyncio import StreamReader, StreamWriter
from collections import deque
from chapter_08.listing_8_5 import create_stdin_reader
from chapter_08.listing_8_7 import *
from chapter_08.listing_8_8 import read_line
from chapter_08.listing_8_9 import MessageStore

async def send_message(message: str, writer: StreamWriter):
    writer.write((message + '\n').encode())
    await writer.drain()

async def listen_for_messages(reader: StreamReader,
                              message_store: MessageStore):                      ❶
    while (message := await reader.readline()) != b'':
        await message_store.append(message.decode())
    await message_store.append('Server closed connection.')

async def read_and_send(stdin_reader: StreamReader,
                        writer: StreamWriter):                                   ❷
    while True:
        message = await read_line(stdin_reader)
        await send_message(message, writer)

async def main():
    async def redraw_output(items: deque):
        save_cursor_position()
        move_to_top_of_screen()
        for item in items:
            delete_line()
            sys.stdout.write(item)
        restore_cursor_position()

    tty.setcbreak(0)
    os.system('clear')
    rows = move_to_bottom_of_screen()

    messages = MessageStore(redraw_output, rows - 1)

    stdin_reader = await create_stdin_reader()
    sys.stdout.write('Enter username: ')
    username = await read_line(stdin_reader)

    reader, writer = await asyncio.open_connection('127.0.0.1', 8000)            ❸

    writer.write(f'CONNECT {username}\n'.encode())
    await writer.drain()

    message_listener = asyncio.create_task(listen_for_messages(reader, messages))❹
    input_listener = asyncio.create_task(read_and_send(stdin_reader, writer))

    try:
        await asyncio.wait([message_listener, input_listener], return_when=asyncio.FIRST_COMPLETED)
    except Exception as e:
        logging.exception(e)
        writer.close()
        await writer.wait_closed()

asyncio.run(main())

❶ 监听来自服务器的消息,并将它们追加到消息存储中。

❷ 从用户读取输入,并将其发送到服务器。

❸ 打开到服务器的连接,并发送带有用户名的连接消息。

❹ 创建一个任务来监听消息,并监听输入;等待其中一个完成。

我们首先向用户请求他们的用户名,一旦我们得到一个,我们就向服务器发送我们的CONNECT消息。然后,我们创建两个任务:一个用于监听来自服务器的消息,另一个用于持续读取聊天消息并将它们发送到服务器。然后,我们将这两个任务包装在asyncio.wait中,等待任何一个先完成。我们这样做是因为服务器可能会断开我们的连接,或者输入监听器可能会抛出异常。如果我们独立地await每个任务,我们可能会发现自己陷入了困境。例如,如果服务器断开了我们的连接,如果我们首先await了那个任务,我们就无法停止输入监听器。使用wait协程可以防止这个问题,因为如果消息监听器或输入监听器完成,我们的应用程序将退出。如果我们想在这里有更健壮的逻辑,我们可以通过检查wait返回的donepending集合来实现。例如,如果输入监听器抛出了异常,我们可以取消消息监听器任务。

如果你首先运行服务器,然后运行几个聊天客户端,你将能够在客户端发送和接收消息,就像一个正常的聊天应用程序一样。例如,两个连接到聊天的用户可能会产生以下输出:

Welcome! 1 user(s) are online!
MissIslington connected!
SirBedevere connected!
SirBedevere: Is that your nose?
MissIslington: No, it's a false one!

我们构建了一个聊天服务器和客户端,它可以仅使用一个线程同时处理多个用户的连接。这个应用程序可以更加健壮。例如,你可能想要考虑在失败时重试消息发送或一个协议来确认客户端已收到消息。将这个应用程序变成一个生产级别的应用相当复杂,并且超出了本书的范围,尽管这可能是一个有趣的练习,因为有许多故障点需要考虑。通过使用我们在本例中探索的类似概念,你将能够创建满足你需求的健壮的客户端和服务器应用程序。

摘要

  • 我们已经学会了如何使用低级别的传输和协议 API 构建简单的 HTTP 客户端。这些 API 是高级流异步 io 流 API 的基础,通常不推荐用于通用用途。

  • 我们已经学会了如何使用StreamReaderStreamWriter类来构建网络应用程序。这些高级 API 是异步 io 中处理流的推荐方法。

  • 我们已经学会了如何使用流来创建非阻塞的命令行应用程序,这些应用程序可以在后台运行任务的同时保持对用户输入的响应。

  • 我们已经学会了如何使用start_server协程来创建服务器。这种方法是异步 io 中创建服务器的推荐方式,而不是直接使用套接字。

  • 我们已经学会了如何使用流和服务器创建响应式的客户端和服务器应用程序。利用这些知识,我们可以创建基于网络的程序,例如聊天服务器和客户端。

9 Web 应用程序

本章涵盖

  • 使用 aiohttp 创建 Web 应用程序

  • 异步服务器网关接口(ASGI)

  • 使用 Starlette 创建 ASGI Web 应用程序

  • 使用 Django 的异步视图

Web 应用程序为我们在互联网上使用的绝大多数网站提供动力。如果你曾作为拥有互联网存在感的公司的开发者工作,你可能在你的职业生涯中某个时刻参与过 Web 应用程序的开发。在同步 Python 的世界中,这意味着你使用过 Flask、Bottle 或极其流行的 Django 等框架。除了 Django 的较新版本之外,这些 Web 框架并不是为了与 asyncio 无缝工作而构建的。因此,当我们的 Web 应用程序执行可以并行化的工作,例如查询数据库或调用其他 API 时,我们除了多线程或多进程之外没有其他选择。这意味着我们需要探索与 asyncio 兼容的新框架。

在本章中,我们将了解一些流行的异步 Web 框架。我们首先将看到如何使用我们已经处理过的框架 aiohttp 来构建异步 RESTful API。然后,我们将了解异步服务器网关接口(ASGI),它是 WSGI(Web 服务器网关接口)的异步替代品,也是许多 Web 应用程序运行的方式。使用 ASGI 和 Starlette,我们将构建一个简单的支持 WebSocket 的 REST API。我们还将探讨使用 Django 的异步视图。在扩展时,Web 应用程序的性能始终是一个考虑因素,因此我们还将通过使用负载测试工具进行基准测试来查看性能数据。

9.1 使用 aiohttp 创建 REST API

之前,我们使用 aiohttp 作为 HTTP 客户端,向 Web 应用程序发送数千个并发 Web 请求。aiohttp 不仅支持作为 HTTP 客户端,还具有创建异步 Web 应用程序服务器的功能。

9.1.1 什么是 REST?

REST 是 表示状态转移 的缩写。它是现代 Web 应用程序开发中广泛使用的一种范式,尤其是在与 React 和 Vue 等框架的单页应用程序结合使用时。REST 为我们提供了一种无状态的、结构化的方式来设计我们的 Web API,而无需考虑客户端技术。REST API 应该能够与从手机到浏览器的任何数量的客户端进行交互,而且唯一需要改变的是数据在客户端的展示方式。

REST 中的关键概念是资源。资源通常是任何可以用名词表示的东西。例如,客户、产品或账户可以是 RESTful 资源。我们刚刚列出的资源引用单个客户或产品。资源也可以是集合,例如,“客户”或“产品”,我们可以通过某些唯一标识符访问这些集合的单例。单例也可能有子资源。例如,一个客户可能有一个喜欢的产品的列表。让我们看看几个 REST API,以更好地理解:

customers
customers/{id}
customers/{id}/favorites

我们这里有三个 REST API 端点。我们的第一个端点customers引用客户集合。作为此 API 的消费者,我们预计这将返回客户列表(这可能分页,因为它可能是一个大型集合)。我们的第二个端点引用单个客户,并接受一个id参数。如果我们用整数 ID 唯一标识客户,调用customers/1将给我们 ID 为 1 的客户的数据。我们的最后一个端点是一个子实体的例子。一个客户可能有一个喜欢的产品的列表,这使得喜欢列表成为客户的一个子实体。调用customers/1/favorites将返回 ID 为 1 的客户喜欢的列表。

我们将设计我们的 REST API,使其返回 JSON,因为这是典型的做法,尽管我们可以选择任何适合我们需求的格式。REST API 有时可以通过 HTTP 头部的内容协商支持多种数据表示。

尽管对 REST 的所有细节进行深入研究超出了本书的范围,但 REST 的博士论文的作者是一个了解这些概念的好地方。它可在mng.bz/1jAg找到。

9.1.2 aiohttp 服务器基础

让我们开始创建一个简单的“hello world”风格的 API,使用 aiohttp。我们将从创建一个简单的GET端点开始,该端点将以 JSON 格式提供关于时间和日期的一些基本数据。我们将我们的端点命名为/time,并期望它返回月份、日期和当前时间。

aiohttp 在web模块中提供了 Web 服务器功能。一旦我们导入这个模块,我们就可以使用RouteTableDef定义端点(在 aiohttp 中称为路由),RouteTableDef提供了一个装饰器,允许我们指定请求类型(GETPOST等)和表示端点名称的字符串。然后我们可以使用RouteTableDef装饰器来装饰当调用该端点时将执行的协程。在这些装饰的协程内部,我们可以执行我们想要的任何应用程序逻辑,然后向客户端返回数据。

仅凭创建这些端点本身并不能起到任何作用,我们仍然需要启动 Web 应用程序来提供路由服务。我们通过首先创建一个Application实例,添加来自我们的RouteTableDef的路由,然后运行应用程序来实现这一点。

列表 9.1 当前时间端点

from aiohttp import web
from datetime import datetime
from aiohttp.web_request import Request
from aiohttp.web_response import Response

routes = web.RouteTableDef()

@routes.get('/time')                    ❶
async def time(request: Request) -> Response:
    today = datetime.today()

    result = {
        'month': today.month,
        'day': today.day,
        'time': str(today.time())
    }

    return web.json_response(result)    ❷

app = web.Application()                 ❸
app.add_routes(routes)
web.run_app(app)

❶ 创建一个时间 GET 端点;当客户端调用此端点时,时间协程将运行。

❷ 将结果字典转换为 JSON 响应。

❸ 创建 Web 应用程序,注册路由,并运行应用程序。

在前面的列表中,我们首先创建了一个时间端点。@routes.get('/time') 指定当客户端对 /time URI 执行 HTTP GET 请求时,装饰的协程将执行。在我们的 time 协程中,我们获取月份、日期和时间并将其存储在字典中。然后我们调用 web.json_response,它将字典序列化为 JSON 格式。它还配置了我们发送回的 HTTP 响应。特别是,它将状态码设置为 200,将内容类型设置为 'application/json'

然后,我们创建 Web 应用程序并启动它。首先,我们创建一个 Application 实例并调用 add_routes。这注册了我们创建的所有装饰器与 Web 应用程序。然后我们调用 run_app,它启动了 Web 服务器。默认情况下,这将在 localhost 的 8080 端口启动 Web 服务器。

当我们运行这个程序时,我们可以通过在网页浏览器中访问 localhost:8080/time 或者使用命令行工具,例如 cURL 或 Wget 来测试它。让我们用 cURL 来测试一下,运行 curl -i localhost:8080/time,你应该会看到以下内容:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 51
Date: Mon, 23 Nov 2020 16:35:32 GMT
Server: Python/3.9 aiohttp/3.6.2

{"month": 11, "day": 23, "time": "11:35:32.033271"}

这表明我们已成功使用 aiohttp 创建了第一个端点!你可能已经注意到,从我们的代码列表中,我们的 time 协程有一个名为 request 的单个参数。虽然在这个例子中我们不需要使用它,但它很快就会变得很重要。这个数据结构包含了客户端发送的 Web 请求的信息,例如请求体、查询参数等。为了查看请求中的头部信息,在 time 协程中某处添加 print(request.headers),你应该会看到类似以下的内容:

<CIMultiDictProxy('Host': 'localhost:8080', 'User-Agent': 'curl/7.64.1', 'Accept': '*/*')>

9.1.3 连接到数据库并返回结果

虽然我们的时间端点展示了基本概念,但大多数 Web 应用程序并不这么简单。我们通常需要连接到数据库,如 Postgres 或 Redis,并且可能需要与其他 REST API 通信,例如,如果我们查询或更新我们使用的供应商 API。

要了解如何做这件事,我们将围绕第五章中的电子商务店面数据库构建一个 REST API。具体来说,我们将设计一个 REST API 来从数据库中获取现有产品以及创建新的产品。

我们首先需要做的是创建到数据库的连接。由于我们预计应用程序将有许多并发用户,使用连接池而不是单个连接更为合理。问题变成了:我们可以在哪里创建和存储连接池,以便我们的应用程序端点方便使用?

要回答我们可以在哪里存储连接池的问题,我们首先需要回答一个更广泛的问题:在哪里可以存储 aiohttp 应用程序中的共享应用数据。然后我们将使用这个机制来持有连接池的引用。

为了存储共享数据,aiohttp 的 Application 类充当字典。例如,如果我们有一些共享字典,我们希望所有路由都可以访问,我们可以在应用程序中按如下方式存储它:

app = web.Application()
app['shared_dict'] = {'key' : 'value'}

现在,我们可以通过执行 app['shared_dict'] 来访问共享字典。接下来,我们需要弄清楚如何在路由内部访问应用程序。我们可以使应用程序实例全局化,但 aiohttp 提供了一种更好的方法,即通过 Request 类。我们路由接收到的每个请求都将通过 app 字段有一个对应用程序实例的引用,这使得我们能够轻松访问任何共享数据。例如,获取共享字典并将其作为响应返回可能看起来像以下这样:

@routes.get('/')
async def get_data(request: Request) -> Response:
    shared_data = request.app['shared_dict']
    return web.json_response(shared_data)

一旦我们创建了它,我们将使用这种范式来存储和检索我们的数据库连接池。现在我们决定创建连接池的最佳位置。我们无法在创建应用程序实例时轻松地这样做,因为这发生在任何协程之外,我们无法使用所需的 await 表达式。

aiohttp 在应用程序实例上提供了一个名为 on_startup 的信号处理程序来处理此类设置任务。你可以将其视为在启动应用程序时将执行的协程列表。我们可以通过调用 app.on_startup.append(coroutine) 来将协程添加到启动时运行。我们添加到 on_startup 的每个协程都有一个单一参数:Application 实例。一旦我们实例化了它,我们就可以将我们的数据库池存储在这个传递给该协程的应用程序实例中。

我们还需要考虑当我们的 Web 应用程序关闭时会发生什么。我们希望在关闭时主动关闭和清理数据库连接;否则,我们可能会留下悬空连接,给我们的数据库带来不必要的压力。aiohttp 还提供了一个第二个信号处理程序:on_cleanup。在这个处理程序中的协程将在我们的应用程序关闭时运行,这为我们提供了一个关闭连接池的便捷位置。这就像 on_startup 处理程序一样,我们只需调用 append 并传入我们想要运行的协程。

将所有这些部分组合起来,我们可以创建一个 Web 应用程序,该应用程序创建一个连接池到我们的产品数据库。为了测试这一点,让我们创建一个获取我们数据库中所有品牌数据的端点。这将是一个名为 /brands 的 GET 端点。

列表 9.2 连接到产品数据库

import asyncpg
from aiohttp import web
from aiohttp.web_app import Application
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from asyncpg import Record
from asyncpg.pool import Pool
from typing import List, Dict

routes = web.RouteTableDef()
DB_KEY = 'database'

async def create_database_pool(app: Application):          ❶
    print('Creating database pool.')
    pool: Pool = await asyncpg.create_pool(host='127.0.0.1',
                                           port=5432,
                                           user='postgres',
                                           password='password',
                                           database='products',
                                           min_size=6,
                                           max_size=6)
    app[DB_KEY] = pool

async def destroy_database_pool(app: Application):         ❷
    print('Destroying database pool.')
    pool: Pool = app[DB_KEY]
    await pool.close()

@routes.get('/brands')
async def brands(request: Request) -> Response:            ❸
    connection: Pool = request.app[DB_KEY]
    brand_query = 'SELECT brand_id, brand_name FROM brand'
    results: List[Record] = await connection.fetch(brand_query)
    result_as_dict: List[Dict] = [dict(brand) for brand in results]
    return web.json_response(result_as_dict)

app = web.Application()
app.on_startup.append(create_database_pool)                ❹
app.on_cleanup.append(destroy_database_pool)

app.add_routes(routes)
web.run_app(app)

❶ 创建数据库池,并将其存储在应用程序实例中。

❷ 在应用程序实例中销毁池。

❸ 查询所有品牌并将结果返回给客户端。

❹ 将创建和销毁池的协程添加到启动和清理过程中。

我们首先定义两个协程来创建和销毁连接池。在create_database_pool中,我们创建一个池并将其存储在应用程序中的DB_KEY下。然后,在destroy_database_pool中,我们从应用程序实例中获取池并等待其关闭。当我们启动应用程序时,我们将这两个协程分别附加到on_startupon_cleanup信号处理程序。

接下来,我们定义我们的品牌路由。我们首先从请求中获取数据库池并运行一个查询以获取我们数据库中的所有品牌。然后,我们遍历每个品牌,将它们转换为字典。这是因为 aiohttp 不知道如何序列化 asyncpg Record 实例。当运行此应用程序时,你应该能够在浏览器中访问localhost:8080/brands并看到数据库中显示的所有品牌作为 JSON 列表,如下所示:

[{"brand_id": 1, "brand_name": "his"}, {"brand_id": 2, "brand_name": "he"}, {"brand_id": 3, "brand_name": "at"}]

我们现在已经创建了我们的第一个 RESTful 集合 API 端点。接下来,让我们看看如何创建创建和更新单例资源的端点。我们将实现两个端点:一个用于通过特定 ID 检索产品的 GET 端点和一个用于创建新产品的 POST 端点。

让我们从产品的 GET 端点开始。此端点将接受一个整数 ID 参数,这意味着要获取 ID 为 1 的产品,我们将调用/products/1。我们如何创建一个包含参数的路由?aiohttp 允许我们通过将任何参数包裹在花括号中来参数化我们的路由,因此我们的产品路由将是/products/{id}。当我们这样参数化时,我们将在请求的match_info字典中看到一个条目。在这种情况下,用户传递给id参数的任何内容都将作为字符串在request.match_info['id']中可用。

由于我们可能传递一个无效的字符串作为 ID,因此我们需要添加一些错误处理。客户端也可能请求一个不存在的 ID,因此我们需要适当地处理“未找到”的情况。对于这些错误情况,我们将返回 HTTP 400 状态码以指示客户端发出了一个错误请求。对于产品不存在的情况,我们将返回 HTTP 404 状态码。为了表示这些错误情况,aiohttp 为每个 HTTP 状态码提供了一套异常。在错误情况下,我们可以直接抛出它们,客户端将接收到适当的状态码。

列表 9.3 获取特定产品

import asyncpg
from aiohttp import web
from aiohttp.web_app import Application
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from asyncpg import Record
from asyncpg.pool import Pool

routes = web.RouteTableDef()
DB_KEY = 'database'

@routes.get('/products/{id}')
async def get_product(request: Request) -> Response:
    try:
        str_id = request.match_info['id']                               ❶
        product_id = int(str_id)

        query = \
            """
            SELECT
            product_id,
            product_name,
            brand_id
            FROM product
            WHERE product_id = $1
            """

        connection: Pool = request.app[DB_KEY]
        result: Record = await connection.fetchrow(query, product_id)   ❷

        if result is not None:                                          ❸
            return web.json_response(dict(result))
        else:
            raise web.HTTPNotFound()
    except ValueError:
        raise web.HTTPBadRequest()

async def create_database_pool(app: Application):
    print('Creating database pool.')
    pool: Pool = await asyncpg.create_pool(host='127.0.0.1',
                                           port=5432,
                                           user='postgres',
                                           password='password',
                                           database='products',
                                           min_size=6,
                                           max_size=6)
    app[DB_KEY] = pool

async def destroy_database_pool(app: Application):
    print('Destroying database pool.')
    pool: Pool = app[DB_KEY]
    await pool.close()

app = web.Application()
app.on_startup.append(create_database_pool)
app.on_cleanup.append(destroy_database_pool)

app.add_routes(routes)
web.run_app(app)

❶ 从 URL 中获取product_id参数。

❷ 对单个产品运行查询。

❸ 如果我们有结果,将其转换为 JSON 并发送给客户端;否则,发送“404 not found。”

接下来,让我们看看如何创建一个用于在数据库中创建新产品的 POST 端点。我们将以 JSON 字符串的形式在请求体中发送我们想要的数据,然后将其转换为插入查询。在这里,我们需要进行一些错误检查以查看 JSON 是否有效,如果不是,则向客户端发送一个错误请求。

列表 9.4 创建产品端点

import asyncpg
from aiohttp import web
from aiohttp.web_app import Application
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from chapter_09.listing_9_2 import create_database_pool, destroy_database_pool

routes = web.RouteTableDef()
DB_KEY = 'database'

@routes.post('/product')
async def create_product(request: Request) -> Response:
    PRODUCT_NAME = 'product_name'
    BRAND_ID = 'brand_id'

    if not request.can_read_body:
        raise web.HTTPBadRequest()

    body = await request.json()

    if PRODUCT_NAME in body and BRAND_ID in body:
        db = request.app[DB_KEY]
        await db.execute('''INSERT INTO product(product_id,
                                                product_name,
                                                brand_id)
                                                VALUES(DEFAULT, $1, $2)''',
                         body[PRODUCT_NAME],
                         int(body[BRAND_ID]))
        return web.Response(status=201)
    else:
        raise web.HTTPBadRequest()

app = web.Application()
app.on_startup.append(create_database_pool)
app.on_cleanup.append(destroy_database_pool)

app.add_routes(routes)
web.run_app(app)

我们首先检查是否有体request.can_read_body,如果没有,我们快速返回一个错误响应。然后,我们使用json协程将请求体作为字典获取。为什么这是一个协程而不是一个普通方法?如果我们有一个特别大的请求体,结果可能会被缓冲,并且可能需要一些时间来读取。我们不是阻塞我们的处理器等待所有数据到来,而是await直到所有数据都到达。然后我们将记录插入产品表,并将 HTTP 201 创建状态返回给客户端。

使用 cURL,你应该能够执行以下操作将产品插入到你的数据库中,并得到 HTTP 201 响应。

curl -i -d '{"product_name":"product_name", "brand_id":1}' localhost:8080/product
HTTP/1.1 201 Created
Content-Length: 0
Content-Type: application/octet-stream
Date: Tue, 24 Nov 2020 13:27:44 GMT
Server: Python/3.9 aiohttp/3.6.2

虽然这里的错误处理应该更加健壮(如果品牌 ID 是一个字符串而不是整数或 JSON 格式不正确会发生什么?),但这说明了如何处理postdata以将记录插入我们的数据库。

9.1.4 比较 aiohttp 与 Flask

使用 aiohttp 和异步就绪的 Web 框架,我们可以使用如 asyncpg 之类的库。除了使用 asyncio 库之外,与类似同步框架(如 Flask)相比,使用 aiohttp 框架有什么好处?

虽然它高度依赖于服务器配置、数据库硬件和其他因素,但基于 asyncio 的应用程序可以使用更少的资源实现更好的吞吐量。在同步框架中,每个请求处理器从开始到结束运行,不会中断。在异步框架中,当我们的await表达式挂起执行时,它们给框架一个处理其他工作的机会,从而提高效率。

为了测试这一点,让我们构建一个用于我们的品牌端点的 Flask 替代品。我们将假设对 Flask 和同步数据库驱动程序有基本的了解,尽管即使你不了解这些,你也应该能够理解代码。为了开始,我们将使用以下命令安装 Flask 和 psycopg2,一个同步的 Postgres 驱动程序:

pip install -Iv flask==2.0.1
pip install -Iv psycopg2==2.9.1

对于 psycopg,你可能会在安装时遇到编译错误。如果你遇到这种情况,你可能需要安装 Postgres 工具和 open SSL 或其他库。通过搜索你的错误,你应该能够找到答案。现在,让我们实现我们的端点。我们首先将创建到数据库的连接。然后,在我们的请求处理器中,我们将重用之前示例中的品牌查询,并将结果作为 JSON 数组返回。

列表 9.5 一个用于检索品牌的 Flask 应用程序

from flask import Flask, jsonify
import psycopg2

app = Flask(__name__)

conn_info = "dbname=products user=postgres password=password host=127.0.0.1"
db = psycopg2.connect(conn_info)

@app.route('/brands')
def brands():
    cur = db.cursor()
    cur.execute('SELECT brand_id, brand_name FROM brand')
    rows = cur.fetchall()
    cur.close()
    return jsonify([{'brand_id': row[0], 'brand_name': row[1]} for row in rows])

现在,我们需要运行我们的应用程序。Flask 自带一个开发服务器,但它不是生产就绪的,并且不会是一个公平的比较,特别是因为它只会运行一个进程,这意味着我们一次只能处理一个请求。我们需要使用一个生产 WSGI 服务器来测试这一点。我们将使用 Gunicorn 作为此示例,尽管你可以选择很多其他选项。让我们首先使用以下命令安装 Gunicorn:

pip install -Iv gunicorn==20.1.0

我们将在一个 8 核的机器上进行测试,所以我们将使用 Gunicorn 启动八个工作进程。运行 gunicorn -w 8 chapter_09.listing_9_5:app,你应该会看到八个工作进程启动:

[2020-11-24 09:53:39 -0500] [16454] [INFO] Starting gunicorn 20.0.4
[2020-11-24 09:53:39 -0500] [16454] [INFO] Listening at: http:/ /127.0.0.1:8000 (16454)
[2020-11-24 09:53:39 -0500] [16454] [INFO] Using worker: sync
[2020-11-24 09:53:39 -0500] [16458] [INFO] Booting worker with pid: 16458
[2020-11-24 09:53:39 -0500] [16459] [INFO] Booting worker with pid: 16459
[2020-11-24 09:53:39 -0500] [16460] [INFO] Booting worker with pid: 16460
[2020-11-24 09:53:39 -0500] [16461] [INFO] Booting worker with pid: 16461
[2020-11-24 09:53:40 -0500] [16463] [INFO] Booting worker with pid: 16463
[2020-11-24 09:53:40 -0500] [16464] [INFO] Booting worker with pid: 16464
[2020-11-24 09:53:40 -0500] [16465] [INFO] Booting worker with pid: 16465
[2020-11-24 09:53:40 -0500] [16468] [INFO] Booting worker with pid: 16468

这意味着我们已经为我们数据库创建了八个连接,并且可以并发处理八个请求。现在,我们需要一个工具来基准测试 Flask 和 aiohttp 的性能。命令行负载测试器适用于快速测试。虽然这不会是最准确的结果,但它会给我们一个性能的方向性想法。我们将使用一个名为 wrk 的负载测试器,尽管任何负载测试器,如 Apache Bench 或 Hey,都可以工作。你可以在 github.com/wg/wrk. 上查看 wrk 的安装说明。

让我们先对我们的 Flask 服务器运行一个 30 秒的负载测试。我们将使用一个线程和 200 个连接,模拟 200 个并发用户尽可能快地击打我们的应用。在一个 8 核 2.4 GHz 的机器上,你可能会看到以下类似的结果:

Running 30s test @ http:/ /localhost:8000/brands
  1 threads and 200 connections
  16534 requests in 30.02s, 61.32MB read
  Socket errors: connect 0, read 1533, write 276, timeout 0
Requests/sec:    550.82
Transfer/sec:    2.04MB

我们每秒处理了大约 550 个请求——这不是一个坏的结果。让我们用 aiohttp 重新运行相同的测试并比较结果:

Running 30s test @ http:/ /localhost:8080/brands
  1 threads and 200 connections
  46774 requests in 30.01s, 191.45MB read
Requests/sec:   1558.46
Transfer/sec:   6.38MB

使用 aiohttp,我们能够每秒处理超过 1,500 个请求,这大约是我们使用 Flask 能做到的三倍。更重要的是,我们只使用了一个进程,而 Flask 需要总共 八个进程 来处理 三分之一的请求!你还可以通过在 aiohttp 前面放置 NGINX 并启动更多工作进程来进一步提高 aiohttp 的性能。

我们现在已经了解了如何使用 aiohttp 来构建数据库支持的 Web 应用程序的基础。在 Web 应用程序的世界里,aiohttp 与大多数不同之处在于它本身就是一个 Web 服务器,并且它不遵循 WSGI,可以独立运行。正如我们通过 Flask 看到的,这种情况并不常见。接下来,让我们了解 ASGI 的工作原理,并看看如何使用一个名为 Starlette 的 ASGI 兼容框架。

9.2 异步服务器网关接口

在上一个示例中,我们使用 Flask 时,使用了 Gunicorn WSGI 服务器来提供服务。WSGI 是将网络请求转发到网络框架(如 Flask 或 Django)的一种标准化方式。虽然有许多 WSGI 服务器,但它们并不是为了支持异步工作负载而设计的,因为 WSGI 规范的出现时间远早于 asyncio。随着异步网络应用的日益普及,一种从框架中抽象出其服务器的方法变得必要。因此,创建了 异步服务器网关接口,或称 ASGI。ASGI 是互联网空间中的相对新来者,但已经有一些流行的实现和框架支持它,包括 Django。

9.2.1 ASGI 与 WSGI 的比较如何?

WSGI 是从破碎的 Web 应用框架景观中诞生的。在 WSGI 之前,选择一个框架可能会限制可用的 Web 服务器接口类型,因为两者之间没有标准化的接口。WSGI 通过为 Web 服务器提供与 Python 框架通信的简单 API 来解决这个问题。WSGI 在 2004 年随着 PEP-333(Python 增强提案;www.python.org/dev/peps/pep-0333/) 的接受正式进入 Python 生态系统,现在已成为 Web 应用部署的事实标准。

然而,当涉及到异步工作负载时,WSGI 就不再适用了。WSGI 规范的核心是一个简单的 Python 函数。例如,让我们看看我们可以构建的最简单的 WSGI 应用。

列表 9.6 一个 WSGI 应用

def application(env, start_response):
    start_response('200 OK', [('Content-Type','text/html')])
    return [b"WSGI hello!"]

我们可以通过运行 gunicorn chapter_09.listing_ 9_6 来运行此应用,并用 curl http:/ /127.0.0.1:8000 进行测试。如您所见,我们没有地方可以使用 await。此外,WSGI 只支持响应/请求生命周期,这意味着它不适用于长连接协议,如 WebSockets。ASGI 通过重新设计 API 以使用协程来解决这个问题。让我们将我们的 WSGI 示例转换为 ASGI。

列表 9.7 一个简单的 ASGI 应用

async def application(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [[b'content-type', b'text/html']]
    })
    await send({'type': 'http.response.body', 'body': b'ASGI hello!'})

ASGI 应用函数有三个参数:一个作用域字典、一个接收协程和一个发送协程,这允许我们分别发送和接收数据。在我们的例子中,我们发送 HTTP 响应的开始,然后是主体。

现在,我们该如何提供上述应用呢?有几种 ASGI 的实现方式,但我们将使用一个流行的名为 Uvicorn (www.uvicorn.org/) 的工具。Uvicorn 是基于 uvloop 和 httptools 构建的,它们是 asyncio 事件循环的快速 C 实现(我们实际上并不依赖于 asyncio 提供的事件循环,我们将在第十四章中了解更多)以及 HTTP 解析。我们可以通过运行以下命令来安装 Uvicorn:

pip install -Iv uvicorn==0.14.0

现在,我们可以使用以下命令运行我们的应用:

uvicorn chapter_09.listing_9_7:application

如果我们访问 http:/ /localhost:8000,我们应该能看到我们的“hello”消息被打印出来。虽然我们在这里直接使用 Uvicorn 来测试,但更好的做法是使用 Gunicorn 与 Uvicorn 结合,因为 Gunicorn 将具有在崩溃时重启工作进程的逻辑。我们将在第 9.4 节中看到如何使用 Django 来实现这一点。

我们应该记住,虽然 WSGI 是一个被接受的 PEP,但 ASGI 还没有被接受,并且截至本文撰写时,它仍然相对较新。预计 ASGI 的工作原理的细节会随着 asyncio 生态系统的变化而发展和变化。

现在,我们已经了解了 ASGI 的基础知识以及它与 WSGI 的比较。虽然我们学到的内容非常底层,但我们还是希望有一个框架来帮我们处理 ASGI!有几个符合 ASGI 规范的框架,让我们来看一个流行的例子。

9.3 使用 Starlette 的 ASGI

Starlette 是由 Uvicorn 和其他流行库(如 Django REST 框架)的创建者 Encode 创建的一个小型 ASGI 兼容框架。它提供了相当令人印象深刻的性能(在撰写本文时),WebSocket 支持以及更多。您可以在www.starlette.io/查看其文档。让我们看看如何使用它实现简单的 REST 和 WebSocket 端点。要开始,让我们首先使用以下命令安装它:

pip install -Iv starlette==0.15.0

9.3.1 使用 Starlette 的 REST 端点

让我们通过重新实现之前章节中的品牌端点来开始学习 Starlette。我们将通过创建Starlette类的一个实例来创建我们的应用程序。这个类有几个我们感兴趣的参数:一个route对象列表和一个在启动和关闭时运行的协程列表。Route对象是将字符串路径(在我们的例子中是品牌)映射到协程或另一个可调用对象的映射。与 aiohttp 类似,这些协程有一个表示请求的参数,并返回一个响应,所以我们的路由处理程序将非常类似于我们的 aiohttp 版本。略有不同的是我们处理共享数据库池的方式。我们仍然在我们的 Starlette 应用程序实例中存储它,但它位于一个状态对象内部。

列表 9.8 Starlette 品牌端点

import asyncpg
from asyncpg import Record
from asyncpg.pool import Pool
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from typing import List, Dict

async def create_database_pool():
    pool: Pool = await asyncpg.create_pool(host='127.0.0.1',
                                           port=5432,
                                           user='postgres',
                                           password='password',
                                           database='products',
                                           min_size=6,
                                           max_size=6)
    app.state.DB = pool

async def destroy_database_pool():
    pool = app.state.DB
    await pool.close()

async def brands(request: Request) -> Response:
    connection: Pool = request.app.state.DB
    brand_query = 'SELECT brand_id, brand_name FROM brand'
    results: List[Record] = await connection.fetch(brand_query)
    result_as_dict: List[Dict] = [dict(brand) for brand in results]
    return JSONResponse(result_as_dict)

app = Starlette(routes=[Route('/brands', brands)],
                on_startup=[create_database_pool],
                on_shutdown=[destroy_database_pool])

现在我们有了品牌端点,让我们使用 Uvicorn 来启动它。我们将启动八个工作进程,就像之前一样,使用以下命令:

uvicorn --workers 8 --log-level error chapter_09.listing_9_8:app

你应该能够在localhost:8000/brands这个端点上访问,并看到品牌表的内容,就像之前一样。现在我们的应用程序正在运行,让我们快速进行基准测试,看看它与 aiohttp 和 Flask 相比如何。我们将使用之前相同的 wrk 命令,200 个连接,持续 30 秒:

Running 30s test @ http:/ /localhost:8000/brands
  1 threads and 200 connections
Requests/sec:   4365.37
Transfer/sec:   16.07MB

我们每秒处理了超过 4,000 个请求,远远超过了 Flask 和 aiohttp!因为我们之前只运行了一个 aiohttp 工作进程,所以这并不是一个完全公平的比较(在 NGINX 后面有八个 aiohttp 工作进程时,我们会得到类似的数据),但这显示了异步框架提供的吞吐量能力。

9.3.2 使用 Starlette 的 WebSocket

在传统的 HTTP 请求中,客户端向服务器发送请求,服务器返回一个响应,这就是事务的结束。如果我们想构建一个用户无需刷新就能更新的网页呢?例如,我们可能有一个实时计数器,显示当前有多少用户在网站上。我们可以通过 HTTP 和一些 JavaScript 来实现这一点,JavaScript 会轮询一个端点,告诉我们网站上有多少用户。我们可以每隔几秒就访问一次端点,用最新的结果更新页面。

虽然这可以工作,但它有一些缺点。主要的缺点是我们给我们的 Web 服务器增加了额外的负载,每个请求和响应周期都会消耗时间和资源。这尤其令人反感,因为我们的用户数可能在请求之间没有变化,导致系统在没有新信息的情况下承受压力(我们可以通过缓存来缓解这个问题,但这个观点仍然成立,而且缓存引入了其他复杂性和开销)。HTTP 轮询在数字上等同于一个坐在车后座的孩子反复问,“我们到了吗?”

WebSocket 提供了 HTTP 轮询的替代方案。与 HTTP 的请求/响应周期不同,我们建立一个持久的套接字。然后,我们只需在这个套接字上自由发送数据。这个套接字是双向的,这意味着我们可以在不通过 HTTP 请求生命周期的情况下,向服务器发送数据并从服务器接收数据。要将此应用于显示最新用户数的示例,一旦我们连接到 WebSocket,服务器就可以直接“告诉我们”是否有新的用户数。如图 9.1 所示,我们不需要反复询问,这会创建额外的负载,并可能接收到不是新的数据。

09-01

图 9.1 与 WebSocket 相比,HTTP 轮询检索数据

Starlette 提供了一个易于理解的接口来支持 WebSocket。为了看到这个功能在行动中的样子,我们将构建一个简单的 WebSocket 端点,它会告诉我们有多少用户同时连接到 WebSocket 端点。要开始,我们首先需要安装 WebSocket 支持:

pip install -Iv websockets==9.1

接下来,我们需要实现我们的 WebSocket 端点。我们的计划是保持一个内存中所有已连接客户端 WebSocket 的列表。当新的客户端连接时,我们将它们添加到列表中,并将新用户数发送给列表中的所有客户端。当客户端断开连接时,我们将它们从列表中移除,并更新其他客户端关于用户数变化的信息。我们还将添加一些基本的错误处理。如果发送这些消息之一导致异常,我们将从列表中移除客户端。

在 Starlette 中,我们可以通过继承 WebSocketEndpoint 来创建一个端点来处理 WebSocket 连接。这个类有几个协程我们需要实现。第一个是 on_connect,当客户端连接到我们的套接字时会被触发。在 on_connect 中,我们将客户端的 WebSocket 存储在一个列表中,并将列表的长度发送给所有其他套接字。第二个协程是 on_receive;当客户端连接向服务器发送消息时会被触发。在我们的案例中,我们不需要实现这个,因为我们不期望客户端向我们发送任何数据。最后一个协程是 on_disconnect,当客户端断开连接时运行。在这种情况下,我们将客户端从已连接 WebSocket 列表中移除,并更新其他已连接客户端的最新用户数。

列表 9.9 Starlette WebSocket 端点

import asyncio
from starlette.applications import Starlette
from starlette.endpoints import WebSocketEndpoint
from starlette.routing import WebSocketRoute

class UserCounter(WebSocketEndpoint):
    encoding = 'text'
    sockets = []

    async def on_connect(self, websocket):                  ❶
        await websocket.accept()
        UserCounter.sockets.append(websocket)
        await self._send_count()

    async def on_disconnect(self, websocket, close_code):   ❷
        UserCounter.sockets.remove(websocket)
        await self._send_count()

    async def on_receive(self, websocket, data):
        pass

    async def _send_count(self):                            ❸
        if len(UserCounter.sockets) > 0:
            count_str = str(len(UserCounter.sockets))
            task_to_socket = {asyncio.create_task(websocket.send_text(count_str)): websocket
                              for websocket
                              in UserCounter.sockets}

            done, pending = await asyncio.wait(task_to_socket)

            for task in done:
                if task.exception() is not None:
                    if task_to_socket[task] in UserCounter.sockets:
                        UserCounter.sockets.remove(task_to_socket[task])

app = Starlette(routes=[WebSocketRoute('/counter', UserCounter)])

❶ 当客户端连接时,将其添加到套接字列表中,并通知其他用户新的计数。

❷ 当客户端断开连接时,将其从套接字列表中删除,并通知其他用户新的计数。

❸ 通知其他用户有多少用户已连接。如果在发送过程中发生异常,请将其从列表中删除。

现在,我们需要定义一个页面来与我们的 WebSocket 交互。我们将创建一个基本的脚本来连接到我们的 WebSocket 端点。当我们收到消息时,我们将使用最新的值更新页面上的计数器。

列表 9.10 使用 WebSocket 端点

<!DOCTYPE html>
<html lang="">
<head>
    <title>Starlette Web Sockets</title>
    <script>
        document.addEventListener("DOMContentLoaded", () => {
            let socket = new WebSocket("ws:/ /localhost:8000/counter");

            socket.onmessage = (event) => {
                const counter = document.querySelector("#counter");
                counter.textContent = event.data;
            };
        });
    </script>
</head>
<body>
    <span>Users online: </span>
    <span id="counter"></span>
</body>
</html>

在前面的列表中,脚本是最主要的工作部分。我们首先连接到我们的端点,然后定义一个 onmessage 回调。当服务器发送数据给我们时,这个回调就会运行。在这个回调中,我们从 DOM 中获取一个特殊元素并将其内容设置为接收到的数据。请注意,在我们的脚本中,我们不会在 DOMContentLoaded 事件之后执行此代码,如果没有这个事件,我们的计数器元素可能在脚本执行时不存在。

如果你使用 uvicorn —workers 1 chapter_09.listing_9_9:app 启动服务器并打开网页,你应该在页面上看到 1。如果你在单独的标签页中多次打开页面,你应该看到计数在所有标签页上增加。当你关闭一个标签页时,你应该看到所有其他打开标签页的计数减少。请注意,我们在这里只使用一个工作进程,因为我们共享内存中的状态(套接字列表);如果我们使用多个工作进程,每个工作进程将有自己的 socket 列表。为了正确部署,你需要一些持久存储,例如数据库。

我们现在可以使用 aiohttp 和 Starlette 创建基于 asyncio 的 Web 应用程序,用于 REST 和 WebSocket 端点。虽然这些框架很受欢迎,但它们的受欢迎程度并不接近 Django,这个 Python Web 框架中的“千磅巨兽”。

9.4 Django 异步视图

Django 是最受欢迎和广泛使用的 Python 框架之一。它自带丰富的功能,从处理数据库的 ORM(对象关系映射器)到可定制的管理控制台。直到 3.0 版本,Django 应用程序仅支持作为 WSGI 应用程序部署,并且除了 channels 库之外对 asyncio 的支持很少。3.0 版本引入了对 ASGI 的支持,并开始了使 Django 完全异步的过程。最近,3.1 版本增加了对异步视图的支持,允许你直接在你的 Django 视图中使用 asyncio 库。在撰写本文时,Django 的异步支持尚属新功能,整体功能集仍然有所欠缺(例如,ORM 完全是同步的,但支持异步是未来的发展方向)。预计随着 Django 对异步的感知能力增强,这种支持将会增长和演变。

让我们通过构建一个小型应用程序来学习如何使用异步视图,该应用程序在视图中使用 aiohttp。想象一下,我们正在与外部 REST API 集成,并希望构建一个工具来并发运行几个请求,以查看响应时间、正文长度以及失败(异常)的数量。我们将构建一个视图,该视图接受 URL 和请求计数作为查询参数,调用该 URL 并汇总结果,以表格格式返回。

让我们开始确保我们安装了适当的 Django 版本:

pip install -Iv django==3.2.8

现在,让我们使用 Django 管理工具来创建我们应用程序的框架。我们将我们的项目命名为 async_views

django-admin startproject async_views

一旦运行此命令,你应该会看到一个名为 async_views 的目录被创建,其结构如下:

async_views/
    manage.py
    async_views/
        __init__.py
        settings.py
        urls.py
        asgi.py
        wsgi.py

注意,我们既有 wsgi.py 文件也有 asgi.py 文件,这表明我们可以部署到这两种类型的网关接口。你现在应该能够使用 Uvicorn 来提供基本的 Django 欢迎页面。从顶级 async_views 目录运行以下命令:

gunicorn async_views.asgi:application -k uvicorn.workers.UvicornWorker

然后,当你访问 localhost:8000 时,你应该能看到 Django 欢迎页面(图 9.2)。

09-02

图 9.2 Django 欢迎页面

接下来,我们需要创建我们的应用程序,我们将称之为 async_api。在 async_ views 目录中,运行 python manage.py startapp async_api。这将为 async_api 应用程序构建 modelview 和其他文件。

现在,我们已经拥有了创建第一个异步视图所需的一切。在 async_api 目录中应该有一个 views.py 文件。在这个文件中,我们可以通过简单地将其声明为协程来指定一个异步视图。在这个文件中,我们将添加一个异步视图来并发地发出 HTTP 请求,并在 HTML 表格中显示它们的状态码和其他数据。

列表 9.11 Django 异步视图

import asyncio
from datetime import datetime
from aiohttp import ClientSession
from django.shortcuts import render
import aiohttp

async def get_url_details(session: ClientSession, url: str):
    start_time = datetime.now()
    response = await session.get(url)
    response_body = await response.text()
    end_time = datetime.now()
    return {'status': response.status,
            'time': (end_time - start_time).microseconds,
            'body_length': len(response_body)}

async def make_requests(url: str, request_num: int):
    async with aiohttp.ClientSession() as session:
        requests = [get_url_details(session, url) for _ in range(request_num)]
        results = await asyncio.gather(*requests, return_exceptions=True)
        failed_results = [str(result) for result in results if isinstance(result, Exception)]
        successful_results = [result for result in results if not isinstance(result, Exception)]
        return {'failed_results': failed_results, 'successful_results': successful_results}

async def requests_view(request):
    url: str = request.GET['url']
    request_num: int = int(request.GET['request_num'])
    context = await make_requests(url, request_num)
    return render(request, 'async_api/requests.html', context)

在前面的列表中,我们首先创建了一个用于发出请求并返回响应状态、请求总时间和响应正文长度的字典的协程。接下来,我们定义了一个名为 requests_view 的异步视图协程。这个视图从查询参数中获取 URL 和请求计数,然后使用 gather 并发地通过 get_url_details 发出请求。最后,我们从任何失败中过滤出成功的响应,并将结果放入上下文字典中,然后将其传递给 render 来构建响应。请注意,我们还没有构建响应的模板,现在只传递 async_views/requests.html。接下来,让我们构建模板,以便我们可以查看结果。

首先,我们需要在 async_api 目录下创建一个 templates 目录,然后在模板目录中我们需要创建一个 async_api 文件夹。一旦我们有了这个目录结构,我们就可以在 async_api/templates/async_api 中添加一个视图。我们将这个视图命名为 requests.html,并遍历来自我们视图的上下文字典,以表格格式放置结果。

列表 9.12 请求视图

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Request Summary</title>
</head>
<body>
<h1>Summary of requests:</h1>
<h2>Failures:</h2>
<table>
    {% for failure in failed_results %}
    <tr>
        <td>{{failure}}</td>
    </tr>
    {% endfor %}
</table>
<h2>Successful Results:</h2>
<table>
    <tr>
        <td>Status code</td>
        <td>Response time (microseconds)</td>
        <td>Response size</td>
    </tr>
    {% for result in successful_results %}
    <tr>
        <td>{{result.status}}</td>
        <td>{{result.time}}</td>
        <td>{{result.body_length}}</td>
    </tr>
    {% endfor %}
</table>
</body>
</html>

在我们的视图中,我们创建了两个表格:一个用于显示我们遇到的任何异常,另一个用于显示我们能够成功获取的结果。虽然这不会是创建过的最漂亮的网页,但它将包含我们想要的所有相关信息。

接下来,我们需要将我们的模板和视图连接到一个 URL,这样它就会在我们用浏览器访问它时运行。在async_api文件夹中,创建一个url.py文件,内容如下:

列表 9.13 async_api/url.py文件

from django.urls import path
from . import views

app_name = 'async_api'

urlpatterns = [
    path('', views.requests_view, name='requests'),
]

现在,我们需要在我们的 Django 应用中包含async_api应用的 URL。在async_views/async_views目录中,你应该已经有一个urls.py文件。在这个文件中,你需要修改urlpatterns列表以引用async_api,完成之后它应该看起来像下面这样:

from django.contrib import admin
from django.urls import path, include
urlpatterns = [
    path('admin/', admin.site.urls),
    path('requests/', include('async_api.urls'))
]

最后,我们需要将async_views应用添加到已安装的应用中。在async_views/async_views/settings.py中,修改INSTALLED_APPS列表以包含async_api;完成之后它应该看起来像这样:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'async_api'
]

现在,我们终于有了运行应用程序所需的一切。你可以使用我们首次创建 Django 应用时使用的相同gunicorn命令来启动应用程序。现在,你可以访问我们的端点并发出请求。例如,要并发访问 example.com 10 次并获取结果,请访问:

http:/ /localhost:8000/requests/?url=http:/ / example .com&request_num=10

虽然在您的机器上数字可能会有所不同,但你应该会看到一个像图 9.3 所示的页面。

09-03

图 9.3 请求异步视图

我们现在已经构建了一个 Django 视图,可以通过 ASGI 托管来并发地发出任意数量的 HTTP 请求,但如果你的情况是 ASGI 不可用呢?也许你正在使用一个依赖于它的旧应用;你还能托管异步视图吗?我们可以通过以下命令在 Gunicorn 下运行我们的应用程序来尝试这一点,使用wsgi.py中的 WSGI 应用和同步工作进程:

gunicorn async_views.wsgi:application

你仍然应该能够访问请求端点,并且一切都会正常工作。那么这是怎么工作的呢?当我们以 WSGI 应用运行时,每次我们访问一个异步视图,都会创建一个新的事件循环。我们可以通过在我们的视图中添加几行代码来证明这一点:

loop = asyncio.get_running_loop()
print(id(loop))

id函数将返回一个整数,该整数在整个对象的生命周期内保证是唯一的。当作为 WSGI 应用运行时,每次你访问请求端点,它都会打印一个不同的整数,这表明我们基于每个请求创建了一个新的事件循环。当以 ASGI 应用运行时保持相同的代码,你将看到每次都会打印相同的整数,因为 ASGI 将为整个应用程序只有一个事件循环。

这意味着我们可以在作为 WSGI 应用运行时获得异步视图和并发运行的好处。然而,除非你以 ASGI 应用的形式部署,否则任何需要事件循环跨多个请求运行的东西都不会工作。

9.4.1 在异步视图中运行阻塞工作

那么,在异步视图中进行阻塞工作会怎样呢?我们仍然处于许多库都是同步的世界,但这与单线程并发模型不兼容。ASGI 规范有一个函数可以处理这些情况,名为sync_to_async

在第七章中,我们了解到我们可以在线程池执行器中运行同步 API,并获取可以使用 asyncio 的 awaitables。sync_to_async函数本质上就是这样做的,但有一些值得注意的注意事项。

第一个注意事项是sync_to_async有一个线程敏感性的概念。在许多情况下,具有共享状态的同步 API 并没有设计成可以从多个线程中调用,这样做可能会导致竞态条件。为了处理这个问题,sync_to_async默认为“线程敏感”模式(具体来说,这个函数有一个默认为Truethread_sensitive标志)。这意味着我们传递给 Django 的任何同步代码都会在 Django 的主线程中运行。这意味着我们在这里所做的任何阻塞操作都会阻塞整个 Django 应用程序(如果我们正在运行多个 WSGI/ASGI 工作进程,至少会阻塞一个),通过这种方式,我们通过这样做而失去了异步堆栈的一些好处。

如果我们处于线程敏感性不是问题的情况(换句话说,当没有共享状态,或者共享状态不依赖于在特定线程中时),我们可以将thread_sensitive更改为False。这将使每个调用都在新线程中运行,给我们一个不会阻塞 Django 主线程的东西,并保留异步堆栈的更多好处。

为了看到这个功能在实际中的应用,让我们创建一个新的视图来测试sync_to_async的不同变体。我们将创建一个使用time.sleep使线程休眠的函数,并将其传递给sync_to_async。我们将向我们的端点添加一个查询参数,这样我们就可以轻松地在线程敏感性模式之间切换,以查看其影响。首先,将以下定义添加到async_views/async_api/views.py中:

列表 9.14 sync_to_async 视图

from functools import partial
from django.http import HttpResponse
from asgiref.sync import sync_to_async

def sleep(seconds: int):
    import time
    time.sleep(seconds)

async def sync_to_async_view(request):
    sleep_time: int = int(request.GET['sleep_time'])
    num_calls: int = int(request.GET['num_calls'])
    thread_sensitive: bool = request.GET['thread_sensitive'] == 'True'
    function = sync_to_async(partial(sleep, sleep_time), thread_sensitive=thread_sensitive)
    await asyncio.gather(*[function() for _ in range(num_calls)])
    return HttpResponse('')

接下来,将以下内容添加到async_views/async_api/urls.py中的urlpatterns列表中,以连接视图:

path('sync_to_async', views.sync_to_async_view)

现在,你将能够访问端点。为了测试这一点,让我们在线程不敏感模式下使用以下 URL 连续睡眠 5 秒钟五次:

http:/ /127.0.0.1:8000/requests/sync_to_async?sleep_time=5&num_calls=5&thread_sensitive=False

你会注意到,由于我们正在运行多个线程,这个过程只需要 5 秒就能完成。如果你多次点击这个 URL,你也会注意到每次请求仍然只需要 5 秒,这表明请求之间并没有互相阻塞。现在,让我们将 thread_sensitive url 参数更改为 True,你将看到相当不同的行为。首先,视图将需要 25 秒才能返回,因为它正在依次进行五个 5 秒的调用。其次,如果你多次点击 URL,每个请求都会阻塞,直到另一个请求完成,因为我们正在阻塞 Django 的主线程。sync_to_async 函数为我们提供了几个选项,让我们可以使用现有的代码与异步视图一起使用,但你需要意识到你正在运行的线程敏感性,以及这可能会对异步性能带来的限制。

9.4.2 在同步视图中使用异步代码

下一个合乎逻辑的问题是,“如果我有一个同步视图,但我想使用 asyncio 库怎么办?” ASGI 规范还有一个名为 async_to_sync 的特殊函数。这个函数接受一个协程并在事件循环中运行它,以同步方式返回结果。如果没有事件循环(如 WSGI 应用程序的情况),则会在每个请求上为我们创建一个新的事件循环;否则,它将在当前事件循环中运行(如我们作为 ASGI 应用程序运行时的情况)。为了尝试这个功能,让我们创建一个新的 requests 端点版本,作为一个同步视图,同时仍然使用我们的异步请求函数。

列表 9.15 在同步视图中调用异步代码

from asgiref.sync import async_to_sync

def requests_view_sync(request):
    url: str = request.GET['url']
    request_num: int = int(request.GET['request_num'])
    context = async_to_sync(partial(make_requests, url, request_num))()
    return render(request, 'async_api/requests.html', context)

接下来,将以下内容添加到 urls.py 中的 urlpatterns 列表中:

path('async_to_sync', views.requests_view_sync)

然后,你将能够点击以下 URL 并看到与我们第一次异步视图相同的结果:

http:/ /localhost:8000/requests/async_to_sync?url=http:/ / example.com&request_num=10

即使在同步 WSGI 世界中,sync_to_async 也让我们能够在不完全是异步的情况下获得异步堆栈的一些性能优势。

摘要

  • 我们已经学会了如何使用 aiohttp 和 asyncpg 将基本的 RESTful API 连接到数据库。

  • 我们已经学会了如何使用 Starlette 创建符合 ASGI 规范的 Web 应用程序。

  • 我们已经学会了如何使用 Starlette 和 WebSockets 来构建无需 HTTP 轮询即可提供最新信息的 Web 应用程序。

  • 我们已经学会了如何在 Django 中使用异步视图,并且也学习了如何在同步视图中使用异步代码,反之亦然。

10 微服务

本章涵盖

  • 微服务的基础

  • 后端-for-前端模式

  • 使用 asyncio 处理微服务通信

  • 使用 asyncio 处理失败和重试

许多网络应用程序都是作为单体构建的。一个“单体”通常指一个中等或大型的应用程序,包含多个模块,这些模块作为单个单元独立部署和管理。虽然这种模型本身并没有什么错误(对于大多数网络应用程序来说,单体是完全可以接受的,甚至更受欢迎,因为它们通常更简单),但它确实有其缺点。

例如,如果你对一个单体应用程序进行小的修改,你需要部署整个应用程序,即使可能不受你修改影响的部分。例如,一个单体电子商务应用程序可能在一个应用程序中包含订单管理和产品列表端点,这意味着对产品端点的调整将需要重新部署订单管理代码。微服务架构可以帮助解决这些痛点。我们可以为订单和产品创建单独的服务,然后一个服务的更改不会影响另一个服务。

在本章中,我们将更深入地了解微服务和其背后的动机。我们将学习一种称为“后端-for-前端”的模式,并将其应用于电子商务微服务架构。然后我们将使用 aiohttp 和 asyncpg 实现此 API,学习如何利用并发来提高我们应用程序的性能。我们还将学习如何使用断路器模式正确处理失败和重试,以构建更健壮的应用程序。

10.1 为什么需要微服务?

首先,让我们定义一下什么是微服务。这是一个相当棘手的问题,因为没有标准化的定义,而且你可能会根据你问的人得到不同的答案。一般来说,“微服务”遵循一些指导原则:

  • 它们是松散耦合且可独立部署的。

  • 它们有自己的独立堆栈,包括数据模型。

  • 它们通过 REST 或 gRPC 等协议进行通信。

  • 它们遵循“单一职责”原则;也就是说,微服务应该“只做一件事,做好这件事。”

让我们将这些原则应用到电子商务店面这个具体的例子中。这样的应用程序有用户向我们的假设组织提供运输和支付信息,然后购买我们的产品。在单体架构中,我们会有一个应用程序和一个数据库来管理用户数据、账户数据(如他们的订单和运输信息)以及我们的可用产品。在微服务架构中,我们将有多个服务,每个服务都有自己的数据库来处理不同的关注点。我们可能有一个产品 API 和其自己的数据库,它只处理与产品相关的数据。我们可能有一个用户 API 和其自己的数据库,它处理用户账户信息,等等。

我们为什么选择这种架构风格而不是单体架构?对于大多数应用来说,单体架构是完全可以接受的;它们更容易管理。进行代码更改,并运行所有测试套件以确保看似微小的更改不会影响系统的其他区域。一旦运行了测试,你就可以作为一个单元部署应用程序。如果你的应用程序在负载下表现不佳?在这种情况下,你可以水平或垂直扩展,要么部署更多应用程序实例,要么部署到更强大的机器上以处理额外的用户。虽然管理单体架构在操作上更简单,但这种简单性有其缺点,这些缺点可能非常重要,具体取决于你想要做出哪些权衡。

10.1.1 代码的复杂性

随着应用的扩展和新增功能,其复杂性也在增长。数据模型可能会变得更加耦合,导致不可预见且难以理解的依赖关系。技术债务越来越大,使得开发速度变慢且更加复杂。虽然这对于任何增长中的系统都是真实的,但一个具有多个关注点的庞大代码库可能会加剧这种情况。

10.1.2 可扩展性

在单体架构中,如果你需要扩展,你需要添加你整个应用的更多实例,这可能导致技术成本效率低下。在电子商务应用的背景下,你通常会收到比仅仅浏览产品的用户少得多的订单。在单体架构中,为了扩展以处理更多查看产品的用户,你还需要扩展你的订单处理能力。在微服务架构中,你只需扩展产品服务,如果订单服务没有问题,则无需对其进行扩展。

10.1.3 团队和堆栈独立性

随着开发团队的壮大,新的挑战也随之出现。想象一下,你有五个团队在同一个单体代码库上工作,每个团队每天提交代码几次。合并冲突将成为一个日益严重的问题,每个人都需要处理,跨团队协调部署也是如此。使用独立且松耦合的微服务,这个问题就会小得多。如果一个团队拥有一个服务,他们可以独立地工作并部署它。这也允许团队在需要时使用不同的技术堆栈,一个服务可以是 Java,另一个是 Python。

10.1.4 asyncio 如何帮助?

微服务通常需要通过 REST 或 gRPC 等协议相互通信。由于我们可能同时与多个微服务进行通信,这打开了并发运行请求的可能性,从而创建了一种在同步应用中不会拥有的效率。

除了从异步堆栈中获得的资源效率优势之外,我们还获得了 asyncio API 的错误处理优势,例如waitgather,这些 API 允许我们从一组协程或任务中聚合异常。如果某个特定组请求耗时过长或该组的一部分出现异常,我们可以优雅地处理它们。现在我们已经了解了微服务背后的基本动机,让我们学习一种常见的微服务架构模式,并看看如何实现它。

10.2 介绍后端-for-前端模式

当我们在微服务架构中构建 UI 时,我们通常需要从多个服务中获取数据以创建特定的 UI 视图。例如,如果我们正在构建用户订单历史 UI,我们可能需要从订单服务获取用户的订单历史,并将其与产品服务的产品数据合并。根据需求,我们可能还需要从其他服务获取数据。

这给我们的前端客户端带来了一些挑战。首先是用户体验问题。在独立服务中,我们的 UI 客户端将不得不通过互联网对每个服务进行一次调用。这会带来延迟和 UI 加载时间的问题。我们不能假设所有用户都有良好的互联网连接或快速的计算机;有些人可能在使用信号差的手机,有些人可能在使用较旧的计算机,还有些可能处于无法访问高速互联网的发展中国家。如果我们向五个服务发出五个缓慢的请求,可能会引起比发出一个缓慢请求更多的问题。

除了网络延迟的挑战之外,我们还有与良好的软件设计原则相关的挑战。想象一下,我们既有基于网络的 UI,也有 iOS 和 Android 移动 UI。如果我们直接调用每个服务并合并结果响应,我们需要在三个不同的客户端中复制执行此操作的逻辑,这既冗余又使我们面临客户端之间逻辑不一致的风险。

10-01

图 10.1 后端-for-前端模式

虽然有许多微服务设计模式,但其中一种可以帮助我们解决上述问题的是后端前端模式。在这个设计模式中,我们的 UI 不是直接与我们的服务通信,而是创建一个新的服务来执行这些调用并聚合响应。这解决了我们的问题,我们不再需要多次请求,而只需一次,从而减少了互联网上的往返次数。我们还可以在这个服务中嵌入任何与故障转移或重试相关的逻辑,从而节省我们的客户端重复相同逻辑的工作,并在我们需要更改逻辑时为我们提供一个更新逻辑的地方。这还使我们能够为不同类型的客户端提供多个后端前端服务。我们需要与之通信的服务可能需要根据我们是一个移动客户端还是基于 Web 的 UI 而有所不同。图 10.1 展示了这一点。现在我们理解了后端前端设计模式及其解决的问题,让我们将其应用于构建一个电子商务店面后端前端服务。

10.3 实现产品列表 API

让我们实现后端前端模式,为我们的电子商务店面桌面体验的“所有产品”页面。此页面显示我们网站上所有可用的产品,以及用户购物车和菜单栏中收藏项目的基本信息。为了增加销售额,当只有少量商品可用时,页面会有低库存警告。此页面顶部还有一个导航栏,包含有关用户收藏产品和购物车中数据的详细信息。图 10.2 展示了我们的 UI。

10-02

图 10.2 产品列表页面的原型

在一个包含多个独立服务的微服务架构中,我们需要从每个服务中请求适当的数据并将它们拼接起来以形成一个连贯的响应。让我们首先定义我们将需要的基服务和数据模型。

10.3.1 用户收藏服务

此服务跟踪用户到他们放入收藏夹的产品 ID 的映射。接下来,我们需要实现这些服务以支持我们的后端前端产品、库存、用户购物车和用户收藏。

用户购物车服务

这包含用户 ID 到他们放入购物车的产品 ID 的映射;数据模型与用户收藏服务相同。

库存服务

这包含了一个从产品 ID 到该产品可用库存的映射。

产品服务

这包含产品信息,如描述和 SKU。这与我们在第九章围绕我们的产品数据库实现的服务类似。

10.3.2 实现基服务

让我们从实现我们的库存服务的 aiohttp 应用程序开始,因为这将是我们最简单的服务。对于此服务,我们不会创建单独的数据模型;相反,我们只需返回 0 到 100 之间的随机数来模拟可用库存。我们还将添加随机延迟来模拟我们的服务间歇性缓慢,我们将使用此来演示如何在我们的产品列表服务中处理超时。出于开发目的,我们将此服务托管在端口 8001 上,以免干扰在第九章中运行的我们的产品服务,该服务在端口 8000 上运行。

列表 10.1 库存服务

import asyncio
import random
from aiohttp import web
from aiohttp.web_response import Response

routes = web.RouteTableDef()

@routes.get('/products/{id}/inventory')
async def get_inventory(request: Request) -> Response:
    delay: int = random.randint(0, 5)
    await asyncio.sleep(delay)
    inventory: int = random.randint(0, 100)
    return web.json_response({'inventory': inventory})

app = web.Application()
app.add_routes(routes)
web.run_app(app, port=8001)

接下来,让我们实现用户购物车和用户收藏服务。这两个服务的数据库模型是相同的,因此服务几乎相同,区别在于表名。让我们从这两个数据模型开始,即“用户购物车”和“用户收藏”。我们还将在这两个表中插入一些记录,以便我们有一些数据可以开始。首先,我们将从用户购物车表开始。

列表 10.2 用户购物车表

CREATE TABLE user_cart(
    user_id    INT NOT NULL,
    product_id INT NOT NULL
);

INSERT INTO user_cart VALUES (1, 1);
INSERT INTO user_cart VALUES (1, 2);
INSERT INTO user_cart VALUES (1, 3);
INSERT INTO user_cart VALUES (2, 1);
INSERT INTO user_cart VALUES (2, 2);
INSERT INTO user_cart VALUES (2, 5);

接下来,我们将创建用户收藏表并插入一些值;这将与之前的表非常相似。

列表 10.3 用户收藏表

CREATE TABLE user_favorite
(
    user_id    INT NOT NULL,
    product_id INT NOT NULL
);

INSERT INTO user_favorite VALUES (1, 1);
INSERT INTO user_favorite VALUES (1, 2);
INSERT INTO user_favorite VALUES (1, 3);
INSERT INTO user_favorite VALUES (3, 1);
INSERT INTO user_favorite VALUES (3, 2);
INSERT INTO user_favorite VALUES (3, 3);

为了模拟多个数据库,我们希望在每个 Postgres 数据库中创建这些表。回想一下第五章,我们可以使用 psql 命令行工具运行任意 SQL,这意味着我们可以使用以下两个命令为用户收藏和用户购物车创建两个数据库:

sudo -u postgres psql -c "CREATE DATABASE cart;"
sudo -u postgres psql -c "CREATE DATABASE favorites;"

由于我们现在需要设置和断开与多个不同数据库的连接,让我们在服务之间创建一些可重用的代码来创建 asyncpg 连接池。我们将在 aiohttp 的on_startupon_cleanup钩子中重用它。

列表 10.4 创建和销毁数据库池

import asyncpg
from aiohttp.web_app import Application
from asyncpg.pool import Pool

DB_KEY = 'database'

async def create_database_pool(app: Application,
                               host: str,
                               port: int,
                               user: str,
                               database: str,
                               password: str):
    pool: Pool = await asyncpg.create_pool(host=host,
                                           port=port,
                                           user=user,
                                           password=password,
                                           database=database,
                                           min_size=6,
                                           max_size=6)
    app[DB_KEY] = pool

async def destroy_database_pool(app: Application):
    pool: Pool = app[DB_KEY]
    await pool.close()

上述列表应类似于我们在第五章中编写的用于设置数据库连接的代码。在create_database_pool中,我们创建一个连接池并将其放入我们的Application实例中。在destroy_database_pool中,我们从应用程序实例中获取连接池并将其关闭。

接下来,让我们创建服务。在 REST 术语中,收藏和购物车都是特定用户的子实体。这意味着每个端点的根将是用户,并将接受一个用户 ID 作为输入。例如,/users/3/favorites将获取用户id3的收藏产品。首先,我们将创建用户收藏服务:

列表 10.5 用户收藏服务

import functools
from aiohttp import web
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from chapter_10.listing_10_4 import DB_KEY, create_database_pool, destroy_database_pool

routes = web.RouteTableDef()

@routes.get('/users/{id}/favorites')
async def favorites(request: Request) -> Response:
    try:
        str_id = request.match_info['id']
        user_id = int(str_id)
        db = request.app[DB_KEY]
        favorite_query = 'SELECT product_id from user_favorite where user_id = $1'
        result = await db.fetch(favorite_query, user_id)
        if result is not None:
            return web.json_response([dict(record) for record in result])
        else:
            raise web.HTTPNotFound()
    except ValueError:
        raise web.HTTPBadRequest()

app = web.Application()
app.on_startup.append(functools.partial(create_database_pool,
                                        host='127.0.0.1',
                                        port=5432,
                                        user='postgres',
                                        password='password',
                                        database='favorites'))
app.on_cleanup.append(destroy_database_pool)

app.add_routes(routes)
web.run_app(app, port=8002)

接下来,我们将创建用户购物车服务。此代码将主要与我们的前一个服务相似,主要区别在于我们将与user_cart表交互。

列表 10.6 用户购物车服务

import functools
from aiohttp import web
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from chapter_10.listing_10_4 import DB_KEY, create_database_pool, destroy_database_pool

routes = web.RouteTableDef()

@routes.get('/users/{id}/cart')
async def time(request: Request) -> Response:
    try:
        str_id = request.match_info['id']
        user_id = int(str_id)
        db = request.app[DB_KEY]
        favorite_query = 'SELECT product_id from user_cart where user_id = $1'
        result = await db.fetch(favorite_query, user_id)
        if result is not None:
            return web.json_response([dict(record) for record in result])
        else:
            raise web.HTTPNotFound()
    except ValueError:
        raise web.HTTPBadRequest()

app = web.Application()
app.on_startup.append(functools.partial(create_database_pool,
                                        host='127.0.0.1',
                                        port=5432,
                                        user='postgres',
                                        password='password',
                                        database='cart'))
app.on_cleanup.append(destroy_database_pool)

app.add_routes(routes)
web.run_app(app, port=8003)

最后,我们将实现产品服务。这将与我们在第九章中构建的 API 类似,不同之处在于我们将从数据库中获取所有产品而不是一个。通过以下列表,我们创建了四个服务来为我们的理论电子商务店面提供动力!

列表 10.7 产品服务

import functools
from aiohttp import web
from aiohttp.web_request import Request
from aiohttp.web_response import Response
from chapter_10.listing_10_4 import DB_KEY, create_database_pool, destroy_database_pool

routes = web.RouteTableDef()

@routes.get('/products')
async def products(request: Request) -> Response:
    db = request.app[DB_KEY]
    product_query = 'SELECT product_id, product_name FROM product'
    result = await db.fetch(product_query)
    return web.json_response([dict(record) for record in result])

app = web.Application()
app.on_startup.append(functools.partial(create_database_pool,
                                        host='127.0.0.1',
                                        port=5432,
                                        user='postgres',
                                        password='password',
                                        database='products'))
app.on_cleanup.append(destroy_database_pool)

app.add_routes(routes)
web.run_app(app, port=8000)

10.3.3 实现后端前端服务

接下来,让我们构建后端前端服务。我们首先从基于 UI 需求的一些 API 要求开始。产品加载时间对我们应用程序至关重要,因为我们的用户等待时间越长,他们继续浏览我们网站的可能性就越小,他们购买我们产品的可能性也就越小。这使得我们的要求集中在尽可能快地向用户提供最小可行数据:

  • API 不应该等待产品服务超过 1 秒。如果超过 1 秒,我们应该响应超时错误(HTTP 代码 504),这样 UI 就不会无限期地挂起。

  • 用户购物车和收藏夹数据是可选的。如果我们能在 1 秒内获取到,那就太好了!如果不行,我们只需返回我们拥有的产品数据。

  • 产品库存数据也是可选的。如果我们无法获取它,只需返回产品数据。

在这些要求的基础上,我们为自己提供了一些绕过缓慢服务或已崩溃服务或存在其他网络问题的方法。这使得我们的服务,以及因此使用它的用户界面,更加健壮。虽然它可能不会总是提供所有数据以提供完整的用户体验,但它有足够的数据来创建一个可用的体验。即使结果是产品服务的灾难性故障,我们也不会让用户无限期地挂起,使用忙碌的旋转器或其他糟糕的用户体验。

接下来,让我们定义我们希望我们的响应看起来像什么。导航栏只需要我们购物车和收藏夹中的项目数量,因此我们的响应将只表示这些为标量值。由于我们的购物车或收藏夹服务可能会超时或出现错误,我们将允许此值为null。对于我们的产品数据,我们只想在我们的正常产品数据中添加库存值,因此我们将添加此数据到产品数组中。这意味着我们的响应将类似于以下内容:

{
 "cart_items": 1,
 "favorite_items": null,
 "products": [{"product_id": 4, "inventory": 4},
              {"product_id": 3, "inventory": 65}]
}

在这种情况下,用户购物车中有一件商品。他们可能有收藏的商品,但结果为null,因为无法访问收藏夹服务。最后,我们有两种产品要显示,库存分别为 4 件和 65 件。

那么,我们应该如何开始实现这个功能呢?我们需要通过 HTTP 与我们的 REST 服务进行通信,因此 aiohttp 的网络客户端功能是这一点的自然选择,因为我们已经在使用框架的网络服务器。接下来,我们发送哪些请求,如何对它们进行分组和管理超时?首先,我们应该考虑我们可以并发运行的最大请求数。我们可以并发运行的请求数越多,理论上我们返回客户端响应的速度就越快。在我们的情况下,在我们获得产品 ID 之前,我们不能请求库存,因此我们不能并发运行,但我们的产品、购物车和收藏服务之间没有相互依赖。这意味着我们可以使用 wait 这样的 asyncio API 并发运行它们。使用 wait 并设置超时将给我们一个 done 集合,我们可以检查哪些请求因错误而完成,哪些在超时后仍在运行,这给了我们处理任何失败的机会。然后,一旦我们有了产品 ID 以及可能的用户收藏和购物车数据,我们就可以开始拼接我们的最终响应并将其发送回客户端。

我们将创建一个 /products/all 端点来完成此操作,该端点将返回这些数据。通常,我们希望在 URL、请求头或 cookie 中接受当前登录用户的 ID,以便我们可以在向我们的下游服务发出请求时使用它。在这个例子中,为了简单起见,我们将把这个 ID 硬编码到我们已经将数据插入我们数据库的用户身上。

列表 10.8 产品后端-前端

import asyncio
from asyncio import Task
import aiohttp
from aiohttp import web, ClientSession
from aiohttp.web_request import Request
from aiohttp.web_response import Response
import logging
from typing import Dict, Set, Awaitable, Optional, List

routes = web.RouteTableDef()

PRODUCT_BASE = 'http:/ /127.0.0.1:8000'
INVENTORY_BASE = 'http:/ /127.0.0.1:8001'
FAVORITE_BASE = 'http:/ /127.0.0.1:8002'
CART_BASE = 'http:/ /127.0.0.1:8003'

@routes.get('/products/all')
async def all_products(request: Request) -> Response:
    async with aiohttp.ClientSession() as session:
        products = asyncio.create_task(session.get(f'{PRODUCT_BASE}/products'))
        favorites = asyncio.create_task(session.get(f'{FAVORITE_BASE}/users/3/favorites'))
        cart = asyncio.create_task(session.get(f'{CART_BASE}/users/3/cart'))

        requests = [products, favorites, cart]
        done, pending = await asyncio.wait(requests, timeout=1.0)                     ❶

        if products in pending:                                                       ❷
            [request.cancel() for request in requests]
            return web.json_response({'error': 'Could not reach products service.'}, status=504)
        elif products in done and products.exception() is not None:
            [request.cancel() for request in requests]
            logging.exception('Server error reaching product service.', exc_info=products.exception())
            return web.json_response({'error': 'Server error reaching products service.'}, status=500)
        else:
            product_response = await products.result().json()                         ❸
            product_results: List[Dict] = await get_products_with_inventory(session, product_response)

            cart_item_count: Optional[int] = await get_response_item_count(cart,
                                                                           done,
                                                                           pending,
                                                                           'Error getting user cart.')
            favorite_item_count: Optional[int] = await get_response_item_count(favorites,
                                                                               done,
                                                                               pending,
                                                                               'Error getting user favorites.')
            return web.json_response({'cart_items': cart_item_count,
                                      'favorite_items': favorite_item_count,
                                      'products': product_results})

async def get_products_with_inventory(session: ClientSession,                         ❹
                      product_response) -> List[Dict]:                                ❹
    def get_inventory(session: ClientSession, product_id: str) -> Task:
        url = f"{INVENTORY_BASE}/products/{product_id}/inventory"
        return asyncio.create_task(session.get(url))

    def create_product_record(product_id: int, inventory: Optional[int]) -> Dict:
        return {'product_id': product_id, 'inventory': inventory}

    inventory_tasks_to_product_id = {
        get_inventory(session, product['product_id']): product['product_id'] for product in product_response
    }

    inventory_done, inventory_pending = await asyncio.wait(inventory_tasks_to_product_id.keys(), timeout=1.0)

    product_results = []

    for done_task in inventory_done:
        if done_task.exception() is None:
            product_id = inventory_tasks_to_product_id[done_task]
            inventory = await done_task.result().json()
            product_results.append(create_product_record(product_id, inventory['inventory']))
        else:
            product_id = inventory_tasks_to_product_id[done_task]
            product_results.append(create_product_record(product_id, None))
            logging.exception(f'Error getting inventory for id {product_id}',
                              exc_info=inventory_tasks_to_product_id[done_task].exception())

    for pending_task in inventory_pending:
        pending_task.cancel()
        product_id = inventory_tasks_to_product_id[pending_task]
        product_results.append(create_product_record(product_id, None))

    return product_results

async def get_response_item_count(task: Task,
                                  done: Set[Awaitable],
                                  pending: Set[Awaitable],
                                  error_msg: str) -> Optional[int]:                   ❺
    if task in done and task.exception() is None:
        return len(await task.result().json())
    elif task in pending:
        task.cancel()
    else:
        logging.exception(error_msg, exc_info=task.exception())
    return None

app = web.Application()
app.add_routes(routes)
web.run_app(app, port=9000)

❶ 创建任务以并发查询我们拥有的三个服务。

❷ 如果产品请求超时,则返回错误,因为我们无法继续。

❸ 从产品响应中提取数据,并使用它来获取库存数据。

❹ 给定一个产品响应,请求库存。

❺ 获取 JSON 数组响应中项目数量的便捷方法

在前面的列表中,我们首先定义了一个名为 all_products 的路由处理程序。在 all_products 中,我们并发地向我们的产品、购物车和收藏服务发送请求,并使用 wait 给这些请求 1 秒的时间来完成。一旦它们全部完成,或者我们等待了 1 秒,我们就开始处理结果。

由于产品响应至关重要,我们首先检查其状态。如果它仍然处于挂起状态或出现异常,我们将取消任何挂起的请求并向客户端返回错误。如果出现异常,我们将以 HTTP 500 错误响应,表示服务器问题。如果出现超时,我们将以 504 错误响应,表示我们无法访问服务。这种具体性为我们提供了关于他们是否应该再次尝试的线索,同时也为我们提供了更多有用的信息,这些信息可用于任何监控和调整(例如,我们可以设置专门用于监控 504 响应率的警报)。

如果我们从产品服务中获得成功的响应,我们现在可以开始处理它并请求库存编号。我们在一个名为 get_products_with_inventory 的辅助函数中做这项工作。在这个辅助函数中,我们从响应体中提取产品 ID,并使用这些 ID 向库存服务构造请求。由于我们的库存服务一次只接受一个产品 ID(理想情况下,你可能会将这些批量到一个请求中,但我们将假设管理库存服务的团队在这个方法上存在问题),我们将为每个产品创建一个请求库存的任务列表。我们将再次将这些任务传递给 wait 协程,给它们 1 秒钟的时间来完成。

由于库存编号是可选的,一旦我们的超时时间到达,我们就开始处理 donepending 两个库存请求集中的所有内容。如果我们从库存服务获得成功的响应,我们将创建一个包含产品信息和库存编号的字典。如果有异常或请求仍在 pending 集中,我们将创建一个库存为 None 的记录,表示我们无法检索它。使用 None 将在我们将响应转换为 JSON 时给我们一个 null 值。

最后,我们检查购物车和收藏夹的响应。对于这两个请求,我们只需要计算返回的项目数量。由于这种逻辑对两个服务几乎是相同的,我们创建了一个名为 get_response_item_count 的辅助方法来计数。在 get_response_item_count 中,如果我们从购物车或收藏夹服务获得成功的响应,它将是一个 JSON 数组,因此我们计算并返回该数组中的项目数量。如果有异常或请求超过 1 秒,我们将结果设置为 None,这样我们就可以在我们的 JSON 响应中获得一个 null 值。

这种实现为我们提供了一种合理稳健的方式来处理非关键服务的失败和超时,确保即使在下游问题的情况下,我们也能快速给出合理的响应。对下游服务的任何单个请求都不会超过 1 秒,这为我们服务的速度提供了一个大致的上限。然而,虽然我们已经创建了一些相当稳健的东西,但我们仍然有几种方法可以使它对问题更加有弹性。

10.3.4 重试失败的请求

我们第一次实现的一个问题是它悲观地假设如果我们从服务中获得异常,我们假设我们无法获取结果并继续前进。虽然这可能是合理的,但服务的问题可能是瞬时的。例如,可能有网络中断很快就会消失,可能有我们使用的任何负载均衡器的临时问题,或者可能有其他一系列的临时问题。

在这些情况下,在重试之间有短暂的延迟重试几次是有意义的。这给错误一个清除的机会,并且可以给我们用户提供比我们悲观地看待失败时更多的数据。当然,这也伴随着我们的用户需要等待更长的时间,可能只是为了看到他们本可以避免的相同失败。

为了实现这个功能,wait_for协程函数是一个完美的候选者。它将抛出我们遇到的任何异常,并允许我们指定一个超时。如果我们超过了那个超时,它将抛出一个TimeoutException并取消我们开始的任务。让我们尝试创建一个可重用的重试协程,为我们做这件事。我们将创建一个retry协程函数,它接受协程以及重试的次数。如果我们传递的协程失败或超时,我们将重试直到达到我们指定的次数。

列表 10.9 重试协程

import asyncio
import logging
from typing import Callable, Awaitable

class TooManyRetries(Exception):
    pass

async def retry(coro: Callable[[], Awaitable],
                max_retries: int,
                timeout: float,
                retry_interval: float):
    for retry_num in range(0, max_retries):
        try:
            return await asyncio.wait_for(coro(), timeout=timeout)                                           ❶
        except Exception as e:
            logging.exception(f'Exception while waiting (tried {retry_num} times), retrying.', exc_info=e)   ❷
            await asyncio.sleep(retry_interval)
    raise TooManyRetries()                                                                                   ❸

❶ 等待指定超时时间内的响应。

❷ 如果我们遇到异常,记录它并等待重试间隔。

❸ 如果我们失败次数过多,抛出异常以表示这一点。

在前面的列表中,我们首先创建了一个自定义异常类,当我们在最大重试次数后仍然失败时,我们将抛出这个异常。这将允许任何调用者捕获这个异常,并按他们的方式处理这个特定问题。retry协程接受几个参数。第一个参数是一个返回可等待对象的调用项;这是我们将会重试的协程。第二个参数是我们想要重试的次数,最后的参数是失败后等待重试的间隔和超时。我们创建了一个循环,将协程包装在wait_for中,如果这个循环成功完成,我们返回结果并退出函数。如果有错误、超时或其他情况,我们捕获异常,记录它,并在等待指定的时间间隔后再次重试。如果我们的循环在没有无错误调用我们的协程的情况下完成,我们将抛出一个TooManyRetries异常。

我们可以通过创建几个表现出我们想要处理的失败行为的协程来测试这一点。首先是一个总是抛出异常的协程,其次是总是超时的协程。

列表 10.10 测试重试协程

import asyncio
from chapter_10.listing_10_9 import retry, TooManyRetries

async def main():
    async def always_fail():
        raise Exception("I've failed!")

    async def always_timeout():
        await asyncio.sleep(1)

    try:
        await retry(always_fail,
                    max_retries=3,
                    timeout=.1,
                    retry_interval=.1)
    except TooManyRetries:
        print('Retried too many times!')

    try:
        await retry(always_timeout,
                    max_retries=3,
                    timeout=.1,
                    retry_interval=.1)
    except TooManyRetries:
        print('Retried too many times!')

asyncio.run(main())

对于两次重试,我们定义了 100 毫秒的超时和重试间隔,以及最大重试次数为三次。这意味着我们给协程 100 毫秒的时间来完成,如果它没有在规定时间内完成,或者失败了,我们将在尝试再次之前等待 100 毫秒。运行此列表,你应该看到每个协程尝试运行三次,最后打印出Retried too many times!,输出类似于以下内容(为了简洁省略了跟踪回溯):

ERROR:root:Exception while waiting (tried 1 times), retrying.
Exception: I've failed!
ERROR:root:Exception while waiting (tried 2 times), retrying.
Exception: I've failed!
ERROR:root:Exception while waiting (tried 3 times), retrying.
Exception: I've failed!
Retried too many times!
ERROR:root:Exception while waiting (tried 1 times), retrying.
ERROR:root:Exception while waiting (tried 2 times), retrying.
ERROR:root:Exception while waiting (tried 3 times), retrying.
Retried too many times!

使用这个方法,我们可以在我们的产品后端前端添加一些简单的重试逻辑。例如,假设我们想在将产品、购物车和收藏服务初始请求的重试次数达到几次之前,才认为它们的错误是不可恢复的。我们可以通过将每个请求包裹在重试协程中来实现这一点:

product_request = functools.partial(session.get, f'{PRODUCT_BASE}/products')
favorite_request = functools.partial(session.get, f'{FAVORITE_BASE}/users/5/favorites')
cart_request = functools.partial(session.get, f'{CART_BASE}/users/5/cart')

products = asyncio.create_task(retry(product_request,
                                     max_retries=3,
                                     timeout=.1,
                                     retry_interval=.1))

favorites = asyncio.create_task(retry(favorite_request,
                                      max_retries=3,
                                      timeout=.1,
                                      retry_interval=.1))

cart = asyncio.create_task(retry(cart_request,
                                 max_retries=3,
                                 timeout=.1,
                                 retry_interval=.1))

requests = [products, favorites, cart]
done, pending = await asyncio.wait(requests, timeout=1.0)

在这个例子中,我们尝试每个服务最多三次。这使我们能够从可能短暂的服务问题中恢复过来。虽然这是一个改进,但还有一个可能损害我们服务的潜在问题。例如,如果我们的产品服务总是超时怎么办?

10.3.5 断路器模式

我们实现中仍然存在的问题是,当一个服务持续缓慢到总是超时。这可能会发生在下游服务负载过高、存在其他网络问题或众多其他应用程序或网络错误的情况下。

你可能会想问,“嗯,我们的应用程序优雅地处理了超时;用户在看到错误或获取部分数据之前不会等待超过 1 秒钟,那问题在哪里?”你提出这个问题是正确的。然而,尽管我们已经设计我们的系统具有鲁棒性和弹性,但请考虑用户体验。例如,如果购物车服务遇到问题,总是需要 1 秒钟才能超时,这意味着所有用户都将被困在等待 1 秒钟以获取服务结果。

在这种情况下,由于购物车服务的问题可能会持续一段时间,任何撞击我们的后端前端的人都会被困在等待 1 秒钟,而我们知道这个问题极有可能发生。我们有没有一种方法可以短路一个可能失败的调用,这样我们就不必给用户造成不必要的延迟?

处理这种情况有一个恰如其分的模式,称为断路器模式。这个模式由 Michael Nygard 的书籍《Release It*》(The Pragmatic Bookshelf,2017 年)普及,它允许我们“断开电路”,当我们在每个时间段内遇到指定数量的错误时,我们可以使用这个模式绕过缓慢的服务,直到它的问题得到解决,确保我们对用户的响应尽可能快。

与电气断路器类似,基本的断路器模式有两个与其相关的状态,这与您电气面板上的正常断路器相同:开启状态和关闭状态。关闭状态是一个愉快的路径;我们向服务发出请求,它正常返回。开启状态发生在电路跳闸时。在这个状态下,我们不会麻烦去调用服务,因为我们知道它有问题;相反,我们立即返回一个错误。断路器模式阻止我们将电力发送到不良服务。除了这两个状态之外,还有一个“半开启”状态。这发生在我们在开启状态一段时间后。在这个状态下,我们发出单个请求来检查服务的问题是否已解决。如果是,我们关闭断路器;如果不是,我们保持它开启。为了使我们的示例简单,我们将跳过半开启状态,只关注关闭和开启状态,如图 10.3 所示。

10-03

图 10.3 在两次失败后打开的断路器。一旦打开,所有请求将立即失败。

让我们实现一个简单的断路器来理解它是如何工作的。我们将允许断路器的用户指定一个时间窗口和最大失败次数。如果在时间窗口内发生的错误超过最大次数,我们将打开断路器并使其他任何调用失败。我们将通过一个类来实现这一点,该类接受我们希望运行的协程并跟踪我们处于开启还是关闭状态。

列表 10.11 一个简单的断路器

import asyncio
from datetime import datetime, timedelta

class CircuitOpenException(Exception):
    pass

class CircuitBreaker:

    def __init__(self,
                 callback,
                 timeout: float,
                 time_window: float,
                 max_failures: int,
                 reset_interval: float):
        self.callback = callback
        self.timeout = timeout
        self.time_window = time_window
        self.max_failures = max_failures
        self.reset_interval = reset_interval
        self.last_request_time = None
        self.last_failure_time = None
        self.current_failures = 0

    async def request(self, *args, **kwargs):                   ❶
        if self.current_failures >= self.max_failures:
            if datetime.now() > self.last_request_time + timedelta(seconds=self.reset_interval):
                self._reset('Circuit is going from open to closed, resetting!')
                return await self._do_request(*args, **kwargs)
            else:
                print('Circuit is open, failing fast!')
                raise CircuitOpenException()
        else:
            if self.last_failure_time and datetime.now() > self.last_failure_time + timedelta(seconds=self.time_window):
                self._reset('Interval since first failure elapsed, resetting!')
            print('Circuit is closed, requesting!')
            return await self._do_request(*args, **kwargs)

    def _reset(self, msg: str):                                ❷
        print(msg)
        self.last_failure_time = None
        self.current_failures = 0

    async def _do_request(self, *args, **kwargs):              ❸
        try:
            print('Making request!')
            self.last_request_time = datetime.now()
            return await asyncio.wait_for(self.callback(*args, **kwargs), timeout=self.timeout)
        except Exception as e:
            self.current_failures = self.current_failures + 1
            if self.last_failure_time is None:
                self.last_failure_time = datetime.now()
            raise

❶ 发出请求,如果我们的失败计数超过,则快速失败。

❷ 重置我们的计数器和最后失败时间。

❸ 发出请求,同时跟踪我们发生的失败次数和它们最后一次发生的时间。

我们的断路器类接受五个构造函数参数。前两个是我们希望与断路器一起运行的回调和一个 timeout,它表示我们允许回调运行多长时间,如果超时则失败。接下来的三个参数与处理失败和重置有关。max_failure 参数是在 time_window 秒内我们将在打开断路器之前容忍的最大失败次数。reset_interval 参数是在发生 max_failure 次失败后,我们等待多少秒将断路器从开启状态重置为关闭状态。

然后,我们定义一个协程方法 request,它调用我们的回调并跟踪我们发生的失败次数。如果没有错误,它将返回回调的结果。当我们有失败时,我们在计数器 failure_count 中跟踪这一点。如果失败计数超过我们在指定时间间隔内设置的 max_failure 阈值,任何进一步的 request 调用将引发 CircuitOpenException。如果重置间隔已过,我们将 failure_count 重置为零并再次开始发出请求(如果我们的断路器是关闭的,这可能是未知的)。

现在,让我们通过一个简单的示例应用程序来看看我们的断路器是如何工作的。我们将创建一个slow_callback协程,它将暂停 2 秒钟。然后我们将在断路器中使用它,设置一个短暂的超时,这将使我们能够轻松地触发断路器。

列表 10.12 断路器的工作情况

import asyncio
from chapter_10.listing_10_11 import CircuitBreaker
async def main():
    async def slow_callback():
        await asyncio.sleep(2)

    cb = CircuitBreaker(slow_callback,
                        timeout=1.0,
                        time_window=5,
                        max_failures=2,
                        reset_interval=5)

    for _ in range(4):
        try:
            await cb.request()
        except Exception as e:
            pass

    print('Sleeping for 5 seconds so breaker closes...')
    await asyncio.sleep(5)

    for _ in range(4):
        try:
            await cb.request()
        except Exception as e:
            pass

asyncio.run(main())

在前面的列表中,我们创建了一个具有 1 秒超时的断路器,它在 5 秒间隔内容忍两次失败,并在断路器打开后 5 秒重置。然后我们尝试快速向断路器发送四个请求。前两个应该在 1 秒后因超时而失败,然后后续的每次调用都会立即失败,因为断路器是打开的。然后我们暂停 5 秒;这允许断路器的reset_interval过期,因此它应该回到关闭状态并开始再次调用我们的回调。运行此程序,您应该看到以下输出:

Circuit is closed, requesting!
Circuit is closed, requesting!
Circuit is open, failing fast!
Circuit is open, failing fast!
Sleeping for 5 seconds so breaker closes...
Circuit is going from open to closed, requesting!
Circuit is closed, requesting!
Circuit is open, failing fast!
Circuit is open, failing fast!

现在我们有一个简单的实现,我们可以将其与我们的重试逻辑结合起来,并在我们的后端前端中使用它。由于我们故意使我们的库存服务运行缓慢以模拟现实生活中的遗留服务,这是添加断路器的自然位置。我们将设置 500 毫秒的超时,并在 1 秒内容忍五次失败,之后我们将设置 30 秒的重置间隔。我们需要将我们的get_inventory函数重写为一个协程来完成此操作,如下所示:

async def get_inventory(session: ClientSession, product_id: str):
    url = f"{INVENTORY_BASE}/products/{product_id}/inventory"
    return await session.get(url)

inventory_circuit = CircuitBreaker(get_inventory, timeout=.5, time_window=5.0, max_failures=3, reset_interval=30)

然后,在我们的all_products协程中,我们需要更改我们创建库存服务请求的方式。我们将创建一个任务,调用我们的库存断路器而不是get_inventory协程:

inventory_tasks_to_pid = {
  asyncio.create_task(inventory_circuit.request(session, product['product_id'])): product['product_id']
  for product in product_response
}

inventory_done, inventory_pending = await asyncio.wait(inventory_tasks_to_pid.keys(), timeout=1.0)

一旦我们做出这些更改,您应该在几次调用后看到调用时间降低到产品的后端前端。由于我们正在模拟一个在负载下运行缓慢的库存服务,我们最终会因几个超时而触发断路器,然后任何后续的调用都不会再向库存服务发送请求,直到断路器重置。现在,我们的后端前端服务在面对缓慢且易出错的库存服务时更加健壮。如果需要,我们也可以将其应用于所有其他调用,以提高它们的稳定性。

在这个例子中,我们实现了一个非常简单的断路器来演示它是如何工作的以及如何使用 asyncio 来构建它。这个模式有几种现有的实现,具有许多其他可调整以适应您特定需求的功能。如果您正在考虑这个模式,请在自己实现之前花些时间研究可用的断路器库。

摘要

  • 与单体应用相比,微服务具有许多优势,包括但不限于独立可伸缩性和可部署性。

  • 后端前端模式是一种微服务模式,它聚合了来自多个下游服务的调用。我们已经学习了如何将微服务架构应用于电子商务用例,使用 aiohttp 创建了多个独立的服务。

  • 我们已经使用了 asyncio 实用函数,如 wait,以确保我们的前后端服务在面对下游服务失败时仍保持弹性和响应能力。

  • 我们创建了一个实用工具,用于管理使用 asyncioaiohttp 的 HTTP 请求的重试。

  • 我们实现了一个基本的断路器模式,以确保服务故障不会对其他服务产生负面影响。

11 同步

本章涵盖

  • 单线程并发问题

  • 使用锁来保护关键部分

  • 使用信号量来限制并发

  • 使用事件来通知任务

  • 使用条件来通知任务和获取资源

当我们使用多线程和多进程编写应用程序时,在使用非原子操作时必须担心竞态条件。像并发增加一个整型变量这样简单的事情都可能引起微妙且难以复现的错误。然而,当我们使用 asyncio 时,我们始终在单个线程上操作(除非我们与多线程和多进程交互),那么这难道不是意味着我们不需要担心竞态条件吗?实际上事情并没有这么简单。

虽然 asyncio 的单线程特性消除了多线程或多进程应用程序中可能发生的某些并发错误,但它们并没有完全消除。虽然你很可能不需要经常使用同步与 asyncio 一起工作,但仍然有一些情况下我们需要这些结构。asyncio 的同步原语可以帮助我们防止单线程并发模型特有的错误。

同步原语不仅限于防止并发错误,还有其他用途。例如,我们可能正在使用一个 API,根据我们与供应商的合同,我们只能并发地发出少量请求,或者我们可能担心某个 API 会被请求过载。我们可能还有一个工作流程,其中包含多个需要在有新数据可用时被通知的工作者。

在本章中,我们将探讨一些示例,展示如何在我们的 asyncio 代码中引入竞态条件,并学习如何使用锁和其他并发原语来解决这些问题。我们还将学习如何使用信号量来限制并发并控制对共享资源(如数据库连接池)的访问。最后,我们将探讨事件和条件,这些事件和条件可以用来在发生某些情况时通知任务,并在那时获取共享资源。

11.1 理解单线程并发错误

在早期关于多进程和多线程的章节中,回想一下,当我们处理在不同进程和线程之间共享的数据时,我们必须担心竞态条件。这是因为一个线程或进程可能在另一个线程或进程修改数据时读取数据,导致不一致的状态,从而损坏数据。

这种损坏部分是由于某些操作不是原子的,这意味着虽然它们看起来像是一个操作,但在底层实际上由多个单独的操作组成。我们在第六章中给出的例子是处理一个整型变量的增加;首先,我们读取当前值,然后增加它,然后将其重新赋值回变量。这给其他线程和进程提供了充足的机会在数据不一致的状态下获取数据。

在单线程并发模型中,我们避免了由非原子操作引起的竞态条件。在 asyncio 的单线程模型中,我们只有一个线程在任何给定时间执行一行 Python 代码。这意味着即使一个操作是非原子的,我们也会始终将其运行到完成,而不会让其他协程读取不一致的状态信息。

为了证明这一点,让我们尝试重新创建第七章中我们查看的竞态条件,其中多个线程试图实现一个共享计数器。我们不会让多个线程修改变量,而是会有多个任务。我们将重复这一过程 1,000 次,并断言我们得到正确的值。

列表 11.1 尝试创建竞态条件

import asyncio

counter: int = 0

async def increment():
    global counter
    await asyncio.sleep(0.01)
    counter = counter + 1
async def main():
    global counter
    for _ in range(1000):
        tasks = [asyncio.create_task(increment()) for _ in range(100)]
        await asyncio.gather(*tasks)
        print(f'Counter is {counter}')
        assert counter == 100
        counter = 0

asyncio.run(main())

在前面的列表中,我们创建了一个递增协程函数,该函数将 1 加到全局计数器上,并添加了 1 毫秒的延迟来模拟慢速操作。在我们的主协程中,我们创建了 100 个任务来递增计数器,然后使用gather并发运行它们。然后我们断言我们的计数器是预期的值,由于我们运行了 100 个递增任务,这个值应该始终是 100。运行这个程序,你应该看到我们得到的值始终是 100,即使整数递增是非原子的。如果我们用多个线程而不是协程来运行,我们应该在执行过程中看到我们的断言在某些时候失败。

这是否意味着在单线程并发模型中,我们已经找到了完全避免竞态条件的方法?不幸的是,情况并非如此。虽然我们避免了单个非原子操作可能引起错误的情况,但我们仍然存在多个操作执行顺序错误可能引起问题的状况。为了看到这一点,让我们让 asyncio 眼中的整数递增变得非原子。

为了做到这一点,我们将复制在递增全局计数器时底层发生的情况。我们读取全局值,递增它,然后写回。基本思想是,如果其他代码在协程在await上挂起时修改状态,一旦await完成,我们可能处于不一致的状态。

列表 11.2 单线程竞态条件

import asyncio

counter: int = 0

async def increment():
    global counter
    temp_counter = counter
    temp_counter = temp_counter + 1
    await asyncio.sleep(0.01)
    counter = temp_counter

async def main():
    global counter
    for _ in range(1000):
        tasks = [asyncio.create_task(increment()) for _ in range(100)]
        await asyncio.gather(*tasks)
        print(f'Counter is {counter}')
        assert counter == 100
        counter = 0

asyncio.run(main())

我们不是直接使用增量协程来增加计数器,而是首先将其读入一个临时变量,然后通过一个临时计数器增加一个。然后我们await asyncio.sleep来模拟慢速操作,挂起我们的协程,然后我们才将其重新赋值回全局计数器变量。运行此代码,你应该会立即看到此代码因断言错误而失败,并且我们的计数器始终被设置为1!每个协程首先读取计数器的值,该值为0,将其存储到临时值中,然后进入睡眠状态。由于我们单线程,每个对临时变量的读取都是顺序执行的,这意味着每个协程都将计数器的值存储为0,然后将其增加到1。然后,一旦睡眠结束,每个协程都将计数器的值设置为1,这意味着尽管我们运行了 100 个协程来增加我们的计数器,但我们的计数器始终是1。请注意,如果你删除await表达式,事情将按正确的顺序进行,因为在await点暂停时没有机会修改应用程序状态。

这确实是一个简单且有些不切实际的例子。为了更好地了解这种情况可能何时发生,让我们创建一个稍微复杂一点的竞争条件。想象一下,我们正在实现一个向已连接用户发送消息的服务器。在这个服务器中,我们保留一个用户名到套接字的字典,我们可以使用它向这些用户发送消息。当用户断开连接时,将运行一个回调,该回调将从字典中删除用户并关闭他们的套接字。由于我们在断开连接时关闭套接字,尝试发送任何其他消息将因异常而失败。如果我们正在发送消息的过程中用户断开连接会发生什么?让我们假设期望的行为是,如果我们在开始发送消息时用户是已连接的,则所有用户都应该收到消息。

为了测试这一点,让我们实现一个模拟套接字。这个模拟套接字将有一个send协程和一个close方法。我们的send协程将模拟通过慢速网络发送消息。这个协程还将检查一个标志以查看我们是否已经关闭了套接字,如果是的话,它将抛出一个异常。

然后,我们将创建一个包含一些已连接用户的字典,并为每个用户创建模拟套接字。我们将向每个用户发送消息,并在发送消息的同时手动触发单个用户的断开连接,以查看会发生什么。

列表 11.3 字典中的竞争条件

import asyncio

class MockSocket:
    def __init__(self):
        self.socket_closed = False

    async def send(self, msg: str):                ❶
        if self.socket_closed:
            raise Exception('Socket is closed!')
        print(f'Sending: {msg}')
        await asyncio.sleep(1)
        print(f'Sent: {msg}')

    def close(self):
        self.socket_closed = True

user_names_to_sockets = {'John': MockSocket(),
                         'Terry': MockSocket(),
                         'Graham': MockSocket(),
                         'Eric': MockSocket()}

async def user_disconnect(username: str):          ❷
    print(f'{username} disconnected!')
    socket = user_names_to_sockets.pop(username)
    socket.close()

async def message_all_users():                     ❸
    print('Creating message tasks')
    messages = [socket.send(f'Hello {user}')
                for user, socket in
                user_names_to_sockets.items()]
    await asyncio.gather(*messages)

async def main():
    await asyncio.gather(message_all_users(), user_disconnect('Eric'))

asyncio.run(main())

❶ 模拟向客户端发送消息的慢速发送。

❷ 断开一个用户并从应用程序内存中删除他们。

❸ 同时向所有用户发送消息。

如果你运行此代码,你将看到应用程序崩溃,并显示以下输出:

Creating message tasks
Eric disconnected!
Sending: Hello John
Sending: Hello Terry
Sending: Hello Graham
Traceback (most recent call last):
  File 'chapter_11/listing_11_3.py', line 45, in <module>
    asyncio.run(main())
  File "asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "python3.9/asyncio/base_events.py", line 642, in run_until_complete
    return future.result()
  File 'chapter_11/listing_11_3.py', line 42, in main
    await asyncio.gather(message_all_users(), user_disconnect('Eric'))
  File 'chapter_11/listing_11_3.py', line 37, in message_all_users
    await asyncio.gather(*messages)
  File 'chapter_11/listing_11_3.py', line 11, in send
    raise Exception('Socket is closed!')
Exception: Socket is closed!

在这个例子中,我们首先创建消息任务,然后我们 await,挂起我们的 message_all_users 协程。这给了 user_disconnect('Eric') 运行的机会,这将关闭 Eric 的套接字并将其从 user_names_to_sockets 字典中删除。一旦完成,message_all_users 将恢复;我们开始发送消息。由于 Eric 的套接字已关闭,我们看到一个异常,并且他不会收到我们打算发送的消息。请注意,我们还修改了 user_names_to_sockets 字典。如果我们需要使用这个字典并且依赖于 Eric 仍然在其中,我们可能会遇到异常或另一个错误。

这些是在单线程并发模型中你倾向于看到的错误类型。你遇到一个 await 暂停点,另一个协程运行并修改一些共享状态,一旦它恢复,就会以不期望的方式改变它。多线程并发错误和单线程并发错误的关键区别在于,在多线程应用程序中,任何修改可变状态的地方都可能发生竞态条件。在单线程并发模型中,你需要在 await 点修改可变状态。既然我们已经了解了单线程模型中的并发错误类型,让我们看看如何通过使用 asyncio 锁来避免它们。

11.2 锁

asyncio 的操作方式与 multiprocessingmultithreading 模块中的锁类似。我们获取一个锁,在临界区内部执行工作,完成后释放锁,让其他感兴趣的方获取它。主要区别是 asyncio 锁是可等待的对象,当它们被阻塞时,会挂起协程的执行。这意味着当一个协程被阻塞等待获取锁时,其他代码可以运行。此外,asyncio 锁也是异步上下文管理器,使用它们的推荐方式是 async with 语法。

为了熟悉锁的工作方式,让我们看看一个简单的例子,其中两个协程之间共享一个锁。我们将获取锁,这将防止其他协程在锁被释放之前在临界区运行代码。

列表 11.4 使用 asyncio 锁

import asyncio
from asyncio import Lock
from util import delay

async def a(lock: Lock):
    print('Coroutine a waiting to acquire the lock')
    async with lock:
        print('Coroutine a is in the critical section')
        await delay(2)
    print('Coroutine a released the lock')
async def b(lock: Lock):
    print('Coroutine b waiting to acquire the lock')
    async with lock:
        print('Coroutine b is in the critical section')
        await delay(2)
    print('Coroutine b released the lock')

async def main():
    lock = Lock()
    await asyncio.gather(a(lock), b(lock))

asyncio.run(main())

当我们运行前面的列表时,我们将看到协程 a 首先获取锁,而协程 b 将等待直到 a 释放锁。一旦 a 释放锁,b 就可以在临界区执行其工作,给我们以下输出:

Coroutine a waiting to acquire the lock
Coroutine a is in the critical section
sleeping for 2 second(s)
Coroutine b waiting to acquire the lock
finished sleeping for 2 second(s)
Coroutine a released the lock
Coroutine b is in the critical section
sleeping for 2 second(s)
finished sleeping for 2 second(s)
Coroutine b released the lock

在这里我们使用了 async with 语法。如果我们愿意,我们可以在锁上使用 acquirerelease 方法,如下所示:

await lock.acquire()
try:
    print('In critical section')
finally:
    lock.release()

话虽如此,最佳实践是在可能的情况下使用 async with 语法。

需要注意的一个重要事项是我们是在 main 协程内部创建了锁。由于锁是在我们创建的协程之间全局共享的,我们可能会想将其作为一个全局变量来避免每次都传递它,如下所示:

lock = Lock()

# coroutine definitions

async def main():
    await asyncio.gather(a(), b())

如果我们这样做,我们很快就会看到崩溃,并报告多个事件循环的错误:

Task <Task pending name='Task-3' coro=<b()> got Future <Future pending> attached to a different loop

为什么只是移动了锁的定义就会发生这种情况?这是 asyncio 库的一个令人困惑的怪癖,并不仅限于锁。大多数 asyncio 中的对象都提供了一个可选的 loop 参数,允许你指定要运行的特定事件循环。当这个参数未提供时,asyncio 会尝试获取当前正在运行的事件循环,但如果没有,它会创建一个新的。在上面的例子中,创建一个 Lock 会创建一个新的事件循环,因为当我们的脚本首次运行时,我们还没有创建一个。然后,asyncio.run(main()) 创建了第二个事件循环,当我们尝试使用我们的锁时,我们会混合这两个独立的事件循环,这会导致崩溃。

这种行为足够复杂,以至于在 Python 3.10 中,事件循环参数将被移除,这种令人困惑的行为将消失,但在此之前,你需要在使用全局 asyncio 变量时仔细考虑这些情况。

现在我们已经了解了基础知识,让我们看看如何使用锁来解决列表 11.3 中的错误,我们尝试向一个我们过早关闭套接字的用户发送消息。解决这个问题的想法是在两个地方使用锁:首先,当用户断开连接时,其次,当我们向用户发送消息时。这样,如果在我们发送消息时发生断开连接,我们将等待它们全部完成,然后再最终关闭任何套接字。

列表 11.5 使用锁来避免竞态条件

import asyncio
from asyncio import Lock

class MockSocket:
    def __init__(self):
        self.socket_closed = False

    async def send(self, msg: str):
        if self.socket_closed:
            raise Exception('Socket is closed!')
        print(f'Sending: {msg}')
        await asyncio.sleep(1)
        print(f'Sent: {msg}')

    def close(self):
        self.socket_closed = True

user_names_to_sockets = {'John': MockSocket(),
                         'Terry': MockSocket(),
                         'Graham': MockSocket(),
                         'Eric': MockSocket()}

async def user_disconnect(username: str, user_lock: Lock):
    print(f'{username} disconnected!')
    async with user_lock:                                ❶
        print(f'Removing {username} from dictionary')
        socket = user_names_to_sockets.pop(username)
        socket.close()

async def message_all_users(user_lock: Lock):
    print('Creating message tasks')
    async with user_lock:                                ❷
        messages = [socket.send(f'Hello {user}')
                    for user, socket in
                    user_names_to_sockets.items()]
        await asyncio.gather(*messages)

async def main():
    user_lock = Lock()
    await asyncio.gather(message_all_users(user_lock),
                         user_disconnect('Eric', user_lock))

asyncio.run(main())

❶ 在移除用户和关闭套接字之前获取锁。

❷ 在发送之前获取锁。

当我们运行以下列表时,我们不会再看到任何崩溃,并会得到以下输出:

Creating message tasks
Eric disconnected!
Sending: Hello John
Sending: Hello Terry
Sending: Hello Graham
Sending: Hello Eric
Sent: Hello John
Sent: Hello Terry
Sent: Hello Graham
Sent: Hello Eric
Removing Eric from dictionary

我们首先获取锁并创建消息任务。在这个过程中,埃里克断开了连接,断开连接中的代码试图获取锁。由于message_ all_users仍然持有锁,我们需要等待它完成才能运行断开连接中的代码。这样可以让所有消息在关闭套接字之前发送完毕,防止我们的错误。

你可能不会经常需要在 asyncio 代码中使用锁,因为许多并发问题可以通过其单线程特性避免。即使发生竞态条件,有时你也可以重构你的代码,使得状态在协程挂起时不会被修改(例如,使用不可变对象)。当你不能以这种方式重构时,锁可以强制以期望的同步顺序发生修改。现在我们了解了使用锁避免并发错误的原理,让我们看看如何使用同步在 asyncio 应用程序中实现新功能。

11.3 使用信号量限制并发

我们的应用程序需要使用的资源通常是有限的。我们可能只能同时使用有限数量的数据库连接;我们可能只有有限数量的 CPU,我们不想过载它们;或者我们可能正在使用一个 API,它只允许基于我们当前的订阅定价进行少数并发请求。我们也可能在使用我们自己的内部 API,并担心过载它,实际上是对我们自己发起分布式拒绝服务攻击。

信号量是一种可以帮助我们解决这些情况的构造。信号量的工作方式与锁非常相似,我们都可以获取它和释放它,主要区别在于我们可以多次获取它,直到达到我们指定的限制。内部,信号量跟踪这个限制;每次我们获取信号量时,我们都会减少限制,每次我们释放信号量时,我们都会增加它。如果计数达到零,任何进一步的获取信号量的尝试都将阻塞,直到有人调用释放并增加计数。为了将我们刚刚学到的与锁的平行,你可以将锁视为一个限制为一次的信号量的特殊情况。

要看到信号量的实际应用,让我们构建一个简单的例子,其中我们只想同时运行两个任务,但总共需要运行四个任务。为此,我们将创建一个限制为两个的信号量并在我们的协程中获取它。

列表 11.6 使用信号量

import asyncio
from asyncio import Semaphore

async def operation(semaphore: Semaphore):
    print('Waiting to acquire semaphore...')
    async with semaphore:
        print('Semaphore acquired!')
        await asyncio.sleep(2)
    print('Semaphore released!')

async def main():
    semaphore = Semaphore(2)
    await asyncio.gather(*[operation(semaphore) for _ in range(4)])

asyncio.run(main())

在我们的主协程中,我们创建了一个限制为两个的信号量,表示我们可以在额外的获取尝试开始阻塞之前获取它两次。然后我们创建了四个对operation的并发调用——这个协程使用async with块获取信号量,并通过 sleep 模拟一些阻塞工作。当我们运行这个程序时,我们将看到以下输出:

Waiting to acquire semaphore...
Semaphore acquired!
Waiting to acquire semaphore...
Semaphore acquired!
Waiting to acquire semaphore...
Waiting to acquire semaphore...
Semaphore released!
Semaphore released!
Semaphore acquired!
Semaphore acquired!
Semaphore released!
Semaphore released!

由于我们的信号量在阻塞之前只允许获取两次,所以我们的前两个任务成功获取了锁,而其他两个任务则在等待前两个任务释放信号量。一旦前两个任务的工作完成并且我们释放了信号量,其他两个任务就可以获取信号量并开始它们的工作。

让我们将这个模式应用到实际用例中。让我们想象你正在为一个资金紧张、现金短缺的初创公司工作,你刚刚与第三方 REST API 供应商建立了合作关系。他们的合同对于无限查询特别昂贵,但他们提供了一个允许只有 10 个并发请求的计划,这更加经济实惠。如果你同时发起超过 10 个请求,他们的 API 将返回状态码 429(请求过多)。你可以发送一组请求并在收到 429 时重试,但这效率低下,并且会给供应商的服务器带来额外的负载,这可能不会让他们的网站可靠性工程师感到高兴。更好的方法是创建一个限制为 10 的信号量,并在发起 API 请求时获取它。在请求时使用信号量将确保在任何给定时间你只有 10 个请求在运行。

让我们看看如何使用 aiohttp 库来做这件事。我们将向一个示例 API 发送 1,000 个请求,但使用信号量限制总并发请求为 10。请注意,aiohttp 有我们可以调整的连接限制,默认情况下它一次只允许 100 个连接。通过调整这个限制,我们可以实现以下相同的效果。

列表 11.7 使用信号量限制 API 请求

import asyncio
from asyncio import Semaphore
from aiohttp import ClientSession

async def get_url(url: str,
                  session: ClientSession,
                  semaphore: Semaphore):
    print('Waiting to acquire semaphore...')
    async with semaphore:
        print('Acquired semaphore, requesting...')
        response = await session.get(url)
        print('Finished requesting')
        return response.status

async def main():
    semaphore = Semaphore(10)
    async with ClientSession() as session:
        tasks = [get_url('https:/ / www .example .com', session, semaphore)
                 for _ in range(1000)]
        await asyncio.gather(*tasks)

asyncio.run(main())

虽然输出将取决于外部延迟因素而不确定,但你应该看到类似以下内容的输出:

Acquired semaphore, requesting...
Acquired semaphore, requesting...
Acquired semaphore, requesting...
Acquired semaphore, requesting...
Acquired semaphore, requesting...
Finished requesting
Finished requesting
Acquired semaphore, requesting...
Acquired semaphore, requesting...

每次请求完成时,信号量都会被释放,这意味着等待信号量的阻塞任务可以开始。这意味着我们任何时候最多只有 10 个请求在运行,当一个请求完成时,我们可以启动一个新的请求。

这解决了同时运行过多请求的问题,但上面的代码是突发性的,意味着它有可能在同一时刻突发 10 个请求,从而在流量上造成潜在的峰值。如果我们担心调用 API 时的负载峰值,这可能不是我们想要的。如果你需要在每个时间单位内只突发一定数量的请求,你需要使用这种实现方式配合流量整形算法,例如“漏桶”或“令牌桶”。

11.3.1 有界信号量

信号量的一个方面是,调用release的次数可以多于调用acquire的次数。如果我们总是使用带有async with块的信号量,这是不可能的,因为每个acquire都会自动配对一个release。然而,如果我们处于需要更精细控制释放和获取机制的情况(例如,可能有一些分支代码,其中一个分支允许我们比另一个分支更早释放),我们可能会遇到问题。作为一个例子,让我们看看当一个正常的协程使用async with块获取和释放信号量,同时该协程正在执行时,另一个协程调用release会发生什么。

列表 11.8 释放多于获取

import asyncio
from asyncio import Semaphore

async def acquire(semaphore: Semaphore):
    print('Waiting to acquire')
    async with semaphore:
        print('Acquired')
        await asyncio.sleep(5)
    print('Releasing')

async def release(semaphore: Semaphore):
    print('Releasing as a one off!')
    semaphore.release()
    print('Released as a one off!')

async def main():
    semaphore = Semaphore(2)

    print("Acquiring twice, releasing three times...")
    await asyncio.gather(acquire(semaphore),
                         acquire(semaphore),
                         release(semaphore))

    print("Acquiring three times...")
    await asyncio.gather(acquire(semaphore),
                         acquire(semaphore),
                         acquire(semaphore))

asyncio.run(main())

在前面的列表中,我们创建了一个有两个许可证的semaphore。然后我们运行了两次acquire调用和一次release调用,这意味着我们将调用release三次。我们的第一次acquire调用似乎运行正常,给出了以下输出:

Acquiring twice, releasing three times...
Waiting to acquire
Acquired
Waiting to acquire
Acquired
Releasing as a one off!
Released as a one off!
Releasing
Releasing

然而,我们第二次调用,我们获取信号量三次,遇到了问题,我们一次性获取了三次锁!我们无意中增加了信号量可用的许可证数量:

Acquiring three times...
Waiting to acquire
Acquired
Waiting to acquire
Acquired
Waiting to acquire
Acquired
Releasing
Releasing
Releasing

为了处理这些类型的情况,asyncio 提供了BoundedSemaphore。这个信号量的行为与我们一直在使用的信号量完全一样,关键区别在于,如果我们调用release使得它改变可用的许可证数量,释放将抛出一个ValueError异常:“BoundedSemaphore释放次数过多”。让我们在下面的列表中查看一个非常简单的例子。

列表 11.9 有限信号量

import asyncio
from asyncio import BoundedSemaphore

async def main():
    semaphore = BoundedSemaphore(1)

    await semaphore.acquire()
    semaphore.release()
    semaphore.release()

asyncio.run(main())

当我们运行前面的代码列表时,我们第二次调用release将抛出一个ValueError,表明我们释放了信号量太多次。如果你将列表 11.8 中的代码更改为使用BoundedSemaphore而不是Semaphore,你也会看到类似的结果。如果你手动调用acquirerelease,使得动态增加信号量可用的许可证数量将是一个错误,那么使用BoundedSemaphore是明智的,这样你将看到一个异常来警告你错误。

我们现在已经看到了如何使用信号量来限制并发,这在需要在我们应用程序中约束并发的情况下可能很有用。asyncio 同步原语不仅允许我们限制并发,还允许我们在发生某些事情时通知任务。接下来,让我们看看如何使用Event同步原语来完成这个操作。

11.4 使用事件通知任务

有时候,在我们继续之前,我们可能需要等待某些外部事件发生。我们可能需要等待缓冲区填满才能开始处理它,我们可能需要等待设备连接到我们的应用程序,或者我们可能需要等待某些初始化发生。我们可能还有多个任务等待处理可能尚未可用的数据。Event对象提供了一种机制,帮助我们处理我们想要空闲等待某个特定事件发生的情况。

在内部,Event类跟踪一个标志,表示事件是否已经发生。我们可以通过两个方法setclear来控制这个标志。set方法将这个内部标志设置为True,并通知任何等待的人事件已经发生。clear方法将这个内部标志设置为False,现在任何等待事件的人都将阻塞。

使用这两种方法,我们可以管理内部状态,但如何阻塞直到事件发生?Event 类有一个名为 wait 的协程方法。当我们 await 这个协程时,它将阻塞,直到有人调用事件对象的 set 方法。一旦发生这种情况,任何额外的 wait 调用都不会阻塞,将立即返回。如果我们调用 clear,那么在调用 set 之后,wait 的调用将再次开始阻塞,直到我们再次调用 set

让我们创建一个示例来观察事件的实际操作。我们将假装有两个任务依赖于某些事件的发生。我们将让这些任务等待并空闲,直到我们触发事件。

列表 11.10 事件基础

import asyncio
import functools
from asyncio import Event

def trigger_event(event: Event):
    event.set()

async def do_work_on_event(event: Event):
    print('Waiting for event...')
    await event.wait()                             ❶
    print('Performing work!')
    await asyncio.sleep(1)                         ❷
    print('Finished work!')
    event.clear()                                  ❸

async def main():
    event = asyncio.Event()
    asyncio.get_running_loop().call_later(5.0, functools.partial(trigger_event, event))      ❹
    await asyncio.gather(do_work_on_event(event), do_work_on_event(event))

asyncio.run(main())

❶ 等待事件发生。

❷ 一旦事件发生,wait 将不再阻塞,我们可以进行工作。

❸ 重置事件,以便未来对 wait 的调用将阻塞。

❹ 在未来 5 秒后触发事件。

在前面的列表中,我们创建了一个协程方法 do_work_on_event,这个协程接受一个事件,首先调用它的 wait 协程。这将阻塞,直到有人调用事件的 set 方法来指示事件已发生。我们还创建了一个简单的方法 trigger_event,它设置一个给定的事件。在我们的主协程中,我们创建一个事件对象,并使用 call_later 在未来 5 秒后触发事件。然后我们两次调用 do_work_on_event,使用 gather 创建两个并发任务。我们将看到这两个 do_work_on_event 任务空闲 5 秒,直到我们触发事件,之后我们将看到它们执行工作,输出如下:

Waiting for event...
Waiting for event...
Triggering event!
Performing work!
Performing work!
Finished work!
Finished work!

这展示了基本概念;在事件上等待将阻塞一个或多个协程,直到我们触发事件,之后它们可以继续执行工作。接下来,让我们看看一个更贴近现实世界的例子。想象我们正在构建一个 API 来接受来自客户端的文件上传。由于网络延迟和缓冲,文件上传可能需要一些时间才能完成。有了这个限制,我们希望我们的 API 有一个协程来阻塞,直到文件完全上传。这个协程的调用者可以等待所有数据到来,并对其进行任何操作。

我们可以使用一个事件来完成这个任务。我们将有一个协程来监听上传的数据并将其存储在内部缓冲区中。一旦我们到达文件的末尾,我们将触发一个事件来指示上传已完成。然后我们将有一个协程方法来获取文件内容,它将等待事件被设置。一旦事件被设置,我们就可以返回完整的上传数据。让我们在名为 FileUpload 的类中创建这个 API:

列表 11.11 文件上传 API

import asyncio
from asyncio import StreamReader, StreamWriter

class FileUpload:
    def __init__(self,
                 reader: StreamReader,
                 writer: StreamWriter):
        self._reader = reader
        self._writer = writer
        self._finished_event = asyncio.Event()
        self._buffer = b''
        self._upload_task = None

    def listen_for_uploads(self):
        self._upload_task = asyncio.create_task(self._accept_upload())   ❶

    async def _accept_upload(self):
        while data := await self._reader.read(1024):
            self._buffer = self._buffer + data
        self._finished_event.set()
        self._writer.close()
        await self._writer.wait_closed()

    async def get_contents(self):                                        ❷
        await self._finished_event.wait()
        return self._buffer

❶ 创建一个任务来监听上传并将它追加到缓冲区。

❷ 阻塞直到完成事件被设置,然后返回缓冲区的内容。

现在让我们创建一个文件上传服务器来测试这个 API。假设在每次成功上传后,我们想要将内容输出到标准输出。当客户端连接时,我们将创建一个 FileUpload 对象并调用 listen_for_uploads。然后,我们将创建一个单独的任务,等待 get_contents 的结果。

列表 11.12 在文件上传服务器中使用 API

import asyncio
from asyncio import StreamReader, StreamWriter
from chapter_11.listing_11_11 import FileUpload

class FileServer:

    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port
        self.upload_event = asyncio.Event()

    async def start_server(self):
        server = await asyncio.start_server(self._client_connected,
                                            self.host,
                                            self.port)
        await server.serve_forever()

    async def dump_contents_on_complete(self, upload: FileUpload):
        file_contents = await upload.get_contents()
        print(file_contents)

    def _client_connected(self, reader: StreamReader, writer: StreamWriter):
        upload = FileUpload(reader, writer)
        upload.listen_for_uploads()
        asyncio.create_task(self.dump_contents_on_complete(upload))

async def main():
    server = FileServer('127.0.0.1', 9000)
    await server.start_server()

asyncio.run(main())

在前面的列表中,我们创建了一个 FileServer 类。每当一个客户端连接到我们的服务器时,我们都会创建一个 FileUpload 类的实例,该实例是我们之前列表中创建的,它开始监听来自连接客户端的上传。我们还并发地创建了一个 dump_contents_on_complete 协程的任务。这个任务在文件上传上调用 get_contents 协程(它只在上传完成后返回)并将文件打印到标准输出。

我们可以通过使用 netcat 来测试这个服务器。在你的文件系统中选择一个文件,并运行以下命令,将 file 替换为你选择的文件:

cat file | nc localhost 9000

你应该会看到你上传的任何文件在所有内容完全上传后打印到标准输出。

需要注意的一个缺点是,事件可能比你的协程能够响应它们的频率要高。假设我们正在使用单个事件唤醒多个任务,这是一种生产者-消费者工作流程。如果我们的所有工作进程长时间忙碌,事件可能会在我们工作时运行,而我们永远不会看到它。让我们创建一个示例来演示这一点。我们将创建两个工作进程,每个工作进程执行 5 秒钟的工作。我们还将创建一个每秒触发一个事件的任务,其速率超过了消费者能够处理的速度。

列表 11.13 一个工作进程落后于事件

import asyncio
from asyncio import Event
from contextlib import suppress

async def trigger_event_periodically(event: Event):
    while True:
        print('Triggering event!')
        event.set()
        await asyncio.sleep(1)

async def do_work_on_event(event: Event):
    while True:
        print('Waiting for event...')
        await event.wait()
        event.clear()
        print('Performing work!')
        await asyncio.sleep(5)
        print('Finished work!')

async def main():
    event = asyncio.Event()
    trigger = asyncio.wait_for(trigger_event_periodically(event), 5.0)

    with suppress(asyncio.TimeoutError):
        await asyncio.gather(do_work_on_event(event), do_work_on_event(event), trigger)

asyncio.run(main())

当我们运行前面的列表时,我们会看到事件被触发,我们的两个工作进程并发开始工作。与此同时,我们继续触发事件。由于我们的工作进程很忙,他们直到完成工作并第二次调用 event.wait() 时才会看到我们的事件第二次被触发。如果你关心每次事件发生时都要做出响应,你需要使用排队机制,我们将在下一章中学习。

事件在需要当特定事件发生时发出警报时很有用,但如果我们需要等待事件的同时获得对共享资源(如数据库连接)的独占访问怎么办?条件可以帮助我们解决这些类型的流程。

11.5 条件

事件在发生某些简单通知时很有用,但更复杂的使用案例怎么办?想象一下,你需要访问一个需要事件锁的共享资源,在继续之前等待更复杂的事实集为真,或者只唤醒一定数量的任务而不是所有任务。在这些情况下,条件可能很有用。它们是我们迄今为止遇到的最复杂的同步原语,因此,你很可能不会经常使用它们。

条件 结合了锁和事件的方面,成为一个同步原语,有效地封装了两种行为。我们首先获取条件的锁,给我们的协程提供对任何共享资源的独占访问,允许我们安全地更改所需的任何状态。然后,我们使用 waitwait_for 协程等待特定事件的发生。这些协程释放锁并阻塞,直到事件发生,然后重新获取锁,给我们提供独占访问。

由于这有点令人困惑,让我们创建一个示例来理解如何使用条件。我们将创建两个工作任务,每个任务都尝试获取条件锁并等待事件通知。然后,经过几秒钟,我们将触发条件,这将唤醒两个工作任务,并允许它们进行工作。

列表 11.14 条件基础

import asyncio
from asyncio import Condition

async def do_work(condition: Condition):
    while True:
        print('Waiting for condition lock...')
        async with condition:                                              ❶
            print('Acquired lock, releasing and waiting for condition...')
            await condition.wait()                                         ❷
            print('Condition event fired, re-acquiring lock and doing work...')
            await asyncio.sleep(1)
        print('Work finished, lock released.')                             ❸

async def fire_event(condition: Condition):
    while True:
        await asyncio.sleep(5)
        print('About to notify, acquiring condition lock...')
        async with condition:
            print('Lock acquired, notifying all workers.')
            condition.notify_all()                                         ❹
        print('Notification finished, releasing lock.')

async def main():
    condition = Condition()

    asyncio.create_task(fire_event(condition))
    await asyncio.gather(do_work(condition), do_work(condition))

asyncio.run(main())

❶ 等待获取条件锁;一旦获取,释放锁。

❷ 等待事件触发;一旦触发,重新获取条件锁。

❸ 一旦退出 async with 块,释放条件锁。

❹ 通知所有任务事件已经发生。

在前面的代码示例中,我们创建了两个协程方法:do_workfire_eventdo_work 方法获取条件,这类似于获取锁,然后调用条件的 wait 协程方法。wait 协程方法将阻塞,直到有人调用条件的 notify_all 方法。

fire_event 协程方法稍微休眠一下,然后获取条件并调用 notify_all 方法,这将唤醒任何当前正在等待条件的任务。然后,在我们的主协程中创建一个 fire_event 任务和两个 do_work 任务,并发运行它们。运行此代码时,如果应用程序运行,你会看到以下重复:

Worker 1: waiting for condition lock...
Worker 1: acquired lock, releasing and waiting for condition...
Worker 2: waiting for condition lock...
Worker 2: acquired lock, releasing and waiting for condition...
fire_event: about to notify, acquiring condition lock...
fire_event: Lock acquired, notifying all workers.
fire_event: Notification finished, releasing lock.
Worker 1: condition event fired, re-acquiring lock and doing work...
Worker 1: Work finished, lock released.
Worker 1: waiting for condition lock...
Worker 2: condition event fired, re-acquiring lock and doing work...
Worker 2: Work finished, lock released.
Worker 2: waiting for condition lock...
Worker 1: acquired lock, releasing and waiting for condition...
Worker 2: acquired lock, releasing and waiting for condition...

你会注意到两个工作线程立即开始,并阻塞等待 fire_ event 协程调用 notify_all。一旦 fire_event 调用 notify_all,工作任务就会醒来,然后继续执行它们的工作。

条件有一个额外的协程方法称为 wait_for。与阻塞直到有人通知条件不同,wait_for 接受一个谓词(一个无参数的函数,返回一个 Boolean),并将阻塞直到该谓词返回 True。当有共享资源且某些协程依赖于某些状态变为真时,这非常有用。

例如,让我们假设我们正在创建一个包装数据库连接并运行查询的类。我们首先有一个底层连接,它不能同时运行多个查询,并且在有人尝试运行查询之前,数据库连接可能尚未初始化。共享资源和我们需要阻塞的事件的组合为我们提供了使用 Condition 的正确条件。让我们通过模拟一个模拟数据库连接类来做到这一点。这个类将运行查询,但只有在正确初始化连接后才会这样做。然后,我们将使用这个模拟连接类在完成初始化连接之前尝试并发运行两个查询。

列表 11.15 使用条件等待特定状态

import asyncio
from enum import Enum

class ConnectionState(Enum):
    WAIT_INIT = 0
    INITIALIZING = 1
    INITIALIZED = 2

class Connection:

    def __init__(self):
        self._state = ConnectionState.WAIT_INIT
        self._condition = asyncio.Condition()

    async def initialize(self):
        await self._change_state(ConnectionState.INITIALIZING)
        print('initialize: Initializing connection...')
        await asyncio.sleep(3)  # simulate connection startup time
        print('initialize: Finished initializing connection')
        await self._change_state(ConnectionState.INITIALIZED)

    async def execute(self, query: str):
        async with self._condition:
            print('execute: Waiting for connection to initialize')
            await self._condition.wait_for(self._is_initialized)
            print(f'execute: Running {query}!!!')
            await asyncio.sleep(3)  # simulate a long query

    async def _change_state(self, state: ConnectionState):
        async with self._condition:
            print(f'change_state: State changing from {self._state} to {state}')
            self._state = state
            self._condition.notify_all()

    def _is_initialized(self):
        if self._state is not ConnectionState.INITIALIZED:
            print(f'_is_initialized: Connection not finished initializing, state is {self._state}')
            return False
        print(f'_is_initialized: Connection is initialized!')
        return True
async def main():
    connection = Connection()
    query_one = asyncio.create_task(connection.execute('select * from table'))
    query_two = asyncio.create_task(connection.execute('select * from other_table'))
    asyncio.create_task(connection.initialize())
    await query_one
    await query_two

asyncio.run(main())

在前面的列表中,我们创建了一个包含条件对象并跟踪内部状态的连接类,我们将该状态初始化为 WAIT_INIT,表示我们正在等待初始化发生。我们还在 Connection 类上创建了一些方法。第一个是 initialize,它模拟创建数据库连接。该方法在首次调用时通过调用 _ change_state 方法将状态设置为 INITIALIZING,然后在连接初始化后,将状态设置为 INITIALIZED。在 _ change_state 方法内部,我们设置内部状态,然后调用条件的 notify_all 方法。这将唤醒任何正在等待条件的任务。

在我们的 execute 方法中,我们在 async with 块中获取条件对象,然后调用 wait_for 并带有检查状态是否为 INITIALIZED 的谓词。这将阻塞,直到我们的数据库连接完全初始化,从而防止我们在连接存在之前意外发出查询。然后,在我们的主协程中,我们创建一个连接类并创建两个运行查询的任务,然后是一个初始化连接的任务。运行此代码,您将看到以下输出,表明我们的查询在运行查询之前正确地等待初始化任务完成:

execute: Waiting for connection to initialize
_is_initialized: Connection not finished initializing, state is ConnectionState.WAIT_INIT
execute: Waiting for connection to initialize
_is_initialized: Connection not finished initializing, state is ConnectionState.WAIT_INIT
change_state: State changing from ConnectionState.WAIT_INIT to ConnectionState.INITIALIZING
initialize: Initializing connection...
_is_initialized: Connection not finished initializing, state is ConnectionState.INITIALIZING
_is_initialized: Connection not finished initializing, state is ConnectionState.INITIALIZING
initialize: Finished initializing connection
change_state: State changing from ConnectionState.INITIALIZING to ConnectionState.INITIALIZED
_is_initialized: Connection is initialized!
execute: Running select * from table!!!
_is_initialized: Connection is initialized!
execute: Running select * from other_table!!!

在我们需要访问共享资源并且需要在工作之前通知我们有关状态的场景中,条件非常有用。这是一个相对复杂的用例,因此您不太可能在 asyncio 代码中遇到或需要条件。

摘要

  • 我们已经了解了单线程并发错误以及它们与多线程和进程池中的并发错误的区别。

  • 我们知道如何使用 asyncio 锁来防止并发错误并同步协程。由于 asyncio 的单线程特性,这种情况较少发生,但在共享状态可能在 await 期间发生变化时,有时可能需要它们。

  • 我们已经学会了如何使用信号量来控制对有限资源的访问并限制并发性,这在流量整形中可能很有用。

  • 我们知道如何使用事件在发生某些事情时触发操作,例如初始化或唤醒工作任务。

  • 我们知道如何使用条件来等待一个动作,并且由于一个动作,我们可以访问共享资源。

12 异步队列

本章涵盖

  • 异步队列

  • 使用队列进行生产者-消费者工作流程

  • 在 Web 应用中使用队列

  • 异步优先队列

  • 异步 LIFO 队列

当设计用于处理事件或其他类型数据的应用程序时,我们通常需要一个机制来存储这些事件并将它们分配给一组工作者。这些工作者可以基于这些事件并发地执行我们需要的任何操作,从而节省时间,而不是按顺序处理事件。asyncio 提供了一个异步队列实现,使我们能够做到这一点。我们可以将数据块添加到队列中,并让多个工作者并发运行,从队列中提取数据并处理它,当数据可用时。

这些通常被称为 生产者-消费者工作流程。某物产生我们需要处理的数据或事件;处理这些工作项可能需要很长时间。队列还可以帮助我们传输长时间运行的任务,同时保持响应的用户界面。我们将项目放入队列以供以后处理,并通知用户我们在后台开始这项工作。异步队列还有一个额外的优点,即提供了一种限制并发的机制,因为每个队列通常允许有限数量的工作者任务。这可以用于我们需要以类似于第十一章中看到的信号量方式限制并发的情况。

在本章中,我们将学习如何使用 asyncio 队列来处理生产者-消费者工作流程。我们将首先通过构建一个以收银员作为消费者的示例杂货店队列来掌握基础知识。然后,我们将将其应用于订单管理 Web API,展示如何快速响应用户,同时让队列在后台处理工作。我们还将学习如何按优先级顺序处理任务,这在需要首先处理某个任务(尽管它是在队列中较晚放入的)时非常有用。最后,我们将探讨 LIFO(后进先出)队列,并了解异步队列的缺点。

12.1 异步队列基础

队列是一种 FIFO 数据结构。换句话说,当我们请求下一个元素时,队列中的第一个元素是第一个离开队列的元素。它们与你在杂货店结账时参与的队列没有太大区别。你在队伍的最后加入,等待收银员为你前面的人结账。一旦他们为某人结账,你就向上移动到队列中,而后来加入的人则在你后面等待。然后,当你成为队列中的第一个时,你结账并完全离开队列。

我们所描述的结账队列是一个同步工作流程。一次只有一个收银员为一位顾客结账。如果我们重新构想队列以更好地利用并发性,使其更像超市结账流程会怎样?不再是只有一个收银员,而是会有多个收银员和单个队列。每当有收银员空闲时,他们就可以示意下一位顾客到结账柜台。这意味着除了多个收银员同时结账顾客外,还有多个收银员同时引导顾客从队列中。

这就是异步队列让我们能够做到的核心。我们将多个等待处理的工作项添加到队列中。然后,当多个工作人员有空闲时间执行任务时,他们会从队列中拉取项目。

让我们通过构建我们的超市示例来探索这一点。我们将把工作人员任务视为收银员,而“工作项”将是需要结账的顾客。我们将实现具有收银员需要扫描的个别产品列表的顾客。一些项目扫描时间较长;例如,香蕉需要称重并输入其 SKU 代码。酒精饮料需要经理检查顾客的身份证。

对于我们的超市结账场景,我们将实现几个数据类来表示产品,使用整数表示收银员结账所需的时间(以秒为单位)。我们还将构建一个顾客类,其中包含他们想要购买的随机产品集合。然后,我们将这些顾客放入 asyncio 队列中以表示我们的结账队列。我们还将创建几个工作人员任务来表示我们的收银员。这些任务将从队列中拉取顾客,遍历他们的所有产品,并暂停所需时间以模拟结账过程。

列表 12.1 超市结账队列

import asyncio
from asyncio import Queue
from random import randrange
from typing import List

class Product:
    def __init__(self, name: str, checkout_time: float):
        self.name = name
        self.checkout_time = checkout_time

class Customer:
    def __init__(self, customer_id: int, products: List[Product]):
        self.customer_id = customer_id
        self.products = products

async def checkout_customer(queue: Queue, cashier_number: int):
    while not queue.empty():                                              ❶
        customer: Customer = queue.get_nowait()
        print(f'Cashier {cashier_number} '
              f'checking out customer '
              f'{customer.customer_id}')
        for product in customer.products:                                 ❷
            print(f"Cashier {cashier_number} "
                  f"checking out customer "
                  f"{customer.customer_id}'s {product.name}")
            await asyncio.sleep(product.checkout_time)
        print(f'Cashier {cashier_number} '
              f'finished checking out customer '
              f'{customer.customer_id}')
        queue.task_done()

async def main():
    customer_queue = Queue()

    all_products = [Product('beer', 2),
                    Product('bananas', .5),
                    Product('sausage', .2),
                    Product('diapers', .2)]

    for i in range(10):                                                   ❸
        products = [all_products[randrange(len(all_products))]
                    for _ in range(randrange(10))]
        customer_queue.put_nowait(Customer(i, products))
    cashiers = [asyncio.create_task(checkout_customer(customer_queue, i)) ❹
                for i in range(3)]                                        ❹
    await asyncio.gather(customer_queue.join(), *cashiers)

asyncio.run(main())

❶ 如果队列中有顾客,则持续检查顾客结账。

❷ 检查每位顾客的产品。

❸ 创建 10 个具有随机产品的顾客。

❹ 创建三个“收银员”或工作人员任务以检查顾客结账。

在前面的列表中,我们创建了两个数据类:一个用于产品,另一个用于超市顾客。一个产品由产品名称和收银员将该商品输入收银机所需的时间(以秒为单位)组成。顾客有一些产品要带给收银员购买。我们还定义了一个checkout_ customer 协程函数,该函数负责处理顾客结账的工作。当我们的队列中有顾客时,它使用queue.get_nowait()从队列前端拉取一个顾客,并使用asyncio.sleep模拟扫描商品所需的时间。一旦顾客结账完成,我们调用queue.task_done。这向队列发出信号,表示我们的工作线程已经完成了当前的工作项。在Queue类内部,当我们从队列中获取一个项目时,计数器会增加一个,以跟踪剩余未完成的任务数量。当我们调用task_done时,我们告诉队列我们已经完成,它会将这个计数器减一(为什么我们需要这样做将在我们讨论join时变得有意义)。

在我们的主协程函数中,我们创建了一个可用产品的列表,并生成了 10 位顾客,每位顾客都有随机生成的产品。我们还创建了三个用于checkout_customer协程的工作线程任务,这些任务存储在一个名为cashiers的列表中,这相当于我们想象中的超市中的三位收银员。最后,我们使用gather等待收银员checkout_customer任务完成,同时使用customer_queue.join()协程。我们使用gather是为了让收银员任务中的任何异常都能上升到我们的主协程函数。join协程会阻塞,直到队列为空且所有顾客都已结账。当内部待处理工作项的计数器达到零时,队列被认为是空的。因此,在您的工人中调用task_done非常重要。如果您不这样做,join协程可能会收到队列的错误视图,并且可能永远不会终止。

虽然顾客的商品是随机生成的,但你应该看到类似以下输出的结果,显示每个工作任务(收银员)正在并行地从队列中结账顾客:

Cashier 0 checking out customer 0
Cashier 0 checking out customer 0's sausage
Cashier 1 checking out customer 1
Cashier 1 checking out customer 1's beer
Cashier 2 checking out customer 2
Cashier 2 checking out customer 2's bananas
Cashier 0 checking out customer 0's bananas
Cashier 2 checking out customer 2's sausage
Cashier 0 checking out customer 0's sausage
Cashier 2 checking out customer 2's bananas
Cashier 0 finished checking out customer 0
Cashier 0 checking out customer 3

我们的三位收银员开始并行地从队列中结账顾客。一旦他们完成了一个顾客的结账,他们就会从队列中拉取另一个顾客,直到队列为空。

你可能会注意到,我们将项目放入队列和检索它们的函数名称很奇怪:get_nowaitput_nowait。为什么每个方法的末尾都有nowait?从队列中获取和检索项目有两种方式:一种是协程,它会阻塞,另一种是非阻塞的,是常规方法。get_nowaitput_nowait变体立即执行非阻塞方法调用并返回。为什么我们需要阻塞队列的插入或检索?

答案在于我们如何处理队列的上限和下限。这描述了当队列中有太多项目(上限)时会发生什么,以及当队列中没有项目(下限)时会发生什么。

回到我们的超市队列示例,让我们用getput的协程版本来解决两个不太符合现实的问题。

  • 很可能不会只有一条由 10 名顾客组成的队伍同时出现,一旦队伍空了,收银员就会完全停止工作。

  • 我们的客户队列可能不应该无限制扩展;比如说,最新款的理想游戏机刚刚上市,而你又是镇上唯一一家有售的商店。自然,大规模的恐慌随之而来,你的商店被顾客挤满了。我们可能无法在商店里容纳 5,000 名顾客,因此我们需要一种方法来拒绝他们或者让他们在外面等待。

对于第一个问题,假设我们想要重构我们的应用程序,以便每隔几秒钟随机生成一些顾客来模拟一个真实的超市队列。在我们的当前实现中,checkout_customer会循环直到队列不为空,并使用get_nowait获取一个顾客。由于我们的队列可能为空,我们不能在not queue.empty上循环,因为即使没有人排队,收银员也是可用的,所以我们需要在工作者协程中使用while True。那么当我们在队列空的时候调用get_nowait会发生什么呢?这可以通过几行代码轻松测试出来;我们只需创建一个空队列并调用相关的方法:

import asyncio
from asyncio import Queue

async def main():
    customer_queue = Queue()
    customer_queue.get_nowait()

asyncio.run(main())

我们的方法将抛出asyncio.queues.QueueEmpty异常。虽然我们可以用try catch来包装这个异常并忽略它,但这不会完全奏效,因为每次队列空的时候,我们都使工作者任务成为 CPU 密集型,不断旋转并捕获异常。在这种情况下,我们可以使用get协程方法。这将阻塞(以一种非 CPU 密集型的方式)直到队列中有项目可以处理,并且不会抛出异常。这相当于工作者任务空闲,等待某个顾客进入队列,在收银台处给他们分配工作。

为了解决成千上万的顾客试图同时排队的问题,我们需要考虑我们队列的界限。默认情况下,队列是无界的,它们可以增长以存储无限量的工作项。从理论上讲,这是可以接受的,但在现实世界中,系统有内存限制,因此给我们的队列设置一个上限以防止内存耗尽是一个好主意。在这种情况下,我们需要思考当我们的队列满了时我们希望的行为是什么。让我们看看当我们创建一个只能容纳一个项目并且尝试用put_nowait添加第二个项目时会发生什么:

import asyncio
from asyncio import Queue

async def main():
    queue = Queue(maxsize=1)

    queue.put_nowait(1)
    queue.put_nowait(2)

asyncio.run(main())

在这种情况下,与get_nowait类似,put_nowait会抛出一个类型为asyncio.queues.QueueFull的异常。与get一样,还有一个名为put的协程方法。此方法将阻塞,直到队列中有空间。考虑到这一点,让我们重构我们的顾客示例,使用getput的协程变体。

列表 12.2 使用协程队列方法

import asyncio
from asyncio import Queue
from random import randrange

class Product:
    def __init__(self, name: str, checkout_time: float):
        self.name = name
        self.checkout_time = checkout_time

class Customer:
    def __init__(self, customer_id, products):
        self.customer_id = customer_id
        self.products = products

async def checkout_customer(queue: Queue, cashier_number: int):
    while True:
        customer: Customer = await queue.get()
        print(f'Cashier {cashier_number} '
              f'checking out customer '
              f'{customer.customer_id}')
        for product in customer.products:
            print(f"Cashier {cashier_number} "
                  f"checking out customer "
                  f"{customer.customer_id}'s {product.name}")
            await asyncio.sleep(product.checkout_time)
        print(f'Cashier {cashier_number} '
              f'finished checking out customer '
              f'{customer.customer_id}')
        queue.task_done()

def generate_customer(customer_id: int) -> Customer:    ❶
    all_products = [Product('beer', 2),
                    Product('bananas', .5),
                    Product('sausage', .2),
                    Product('diapers', .2)]
    products = [all_products[randrange(len(all_products))]
                for _ in range(randrange(10))]
    return Customer(customer_id, products)

async def customer_generator(queue: Queue):             ❷
    customer_count = 0

    while True:
        customers = [generate_customer(i)
                     for i in range(customer_count,
                     customer_count + randrange(5))]
        for customer in customers:
            print('Waiting to put customer in line...')
            await queue.put(customer)
            print('Customer put in line!')
        customer_count = customer_count + len(customers)
        await asyncio.sleep(1)

async def main():
    customer_queue = Queue(5)

    customer_producer = asyncio.create_task(customer_generator(customer_queue))

    cashiers = [asyncio.create_task(checkout_customer(customer_queue, i))
                for i in range(3)]

    await asyncio.gather(customer_producer, *cashiers)

asyncio.run(main())

❶ 生成一个随机顾客。

❷ 每秒生成几个随机顾客。

在前面的列表中,我们创建了一个generate_customer协程,它使用随机产品列表创建一个顾客。与此同时,我们创建了一个customer_generator协程函数,它每秒生成一到五个随机顾客,并使用put将它们添加到队列中。因为我们使用了协程put,如果我们的队列已满,customer_generator将阻塞,直到队列中有空闲空间。具体来说,这意味着如果队列中有五个顾客,而生产者试图添加第六个顾客,队列将阻塞,直到有空间被收银员结账释放出来。我们可以将customer_generator视为我们的生产者,因为它为收银员生成顾客。

我们还将checkout_customer重构为无限运行,因为当队列空时,我们的收银员仍然处于待命状态。然后,我们将checkout_customer重构为使用队列get协程,如果队列中没有顾客,协程将阻塞。然后,在我们的主协程中,我们创建一个队列,一次允许五个顾客排队,并创建三个并发运行的checkout_customer任务。我们可以将收银员视为我们的消费者;他们从队列中消费顾客进行结账。

此代码随机生成顾客,但最终,队列应该填满到收银员处理顾客的速度赶不上生产者创建顾客的速度。因此,我们将看到类似以下输出的情况,其中生产者等待将顾客添加到队列中,直到有顾客完成结账:

Waiting to put customer in line...
Cashier 1 checking out customer 7's sausage
Cashier 1 checking out customer 7's diapers
Cashier 1 checking out customer 7's diapers
Cashier 2 finished checking out customer 5
Cashier 2 checking out customer 9
Cashier 2 checking out customer 9's bananas
Customer put in line!

我们现在已经了解了异步队列的基本工作原理,但由于我们在日常工作中通常不会构建超市模拟,让我们看看一些现实世界的场景,以了解我们如何在真正可能构建的应用程序中应用这些原理。

12.1.1 网络应用中的队列

当我们有一个可能耗时的操作可以在后台运行时,队列在 Web 应用中非常有用。如果我们在这个 Web 请求的主协程中运行这个操作,我们将阻塞对用户的响应,直到操作完成,这可能会导致最终用户得到一个缓慢、无响应的页面。

假设我们是电子商务组织的一部分,并且我们正在使用一个慢速订单管理系统进行操作。处理一个订单可能需要几秒钟,但我们不希望用户等待响应来确认他们的订单已被放置。此外,订单管理系统处理负载的能力不佳,因此我们希望限制并发请求的数量。在这种情况下,队列可以解决这两个问题。正如我们之前看到的,队列可以在添加更多元素之前有一个最大元素数,这意味着如果我们有一个上限的队列,我们最多会有我们创建的并发消费者任务的数量。这为并发提供了一个自然的限制。

队列还解决了用户等待响应时间过长的问题。将元素放入队列是瞬时的,这意味着我们可以立即通知用户他们的订单已被放置,从而提供快速的用户体验。当然,在现实世界中,这可能会打开后台任务失败而用户未得到通知的潜在可能性,因此你需要某种形式的数据持久性和逻辑来对抗这种情况。

为了尝试这个功能,让我们创建一个简单的使用 aiohttp 的 Web 应用程序,该程序使用队列来运行后台任务。我们将通过使用asyncio.sleep来模拟与慢速订单管理系统交互。在现实世界的微服务架构中,你可能会使用 aiohttp 或类似的库通过 REST 进行通信,但为了简单起见,我们将使用sleep

我们将创建一个 aiohttp 启动钩子来创建我们的队列以及一组将与慢速服务交互的工作任务。我们还将创建一个HTTP POST端点/订单,该端点将订单放入队列(在这里,我们将为我们的工作任务生成一个随机数来sleep以模拟慢速服务)。一旦订单被放入队列,我们将返回HTTP 200和一个消息,表明订单已被放置。

我们还会在 aiohttp 的关闭钩子中添加一些优雅的关闭逻辑,因为如果我们的应用程序关闭,我们可能仍然有一些订单正在处理。在关闭钩子中,我们将等待直到任何忙碌的工作者完成。

列表 12.3 使用 Web 应用程序的队列

import asyncio
from asyncio import Queue, Task
from typing import List
from random import randrange
from aiohttp import web
from aiohttp.web_app import Application
from aiohttp.web_request import Request
from aiohttp.web_response import Response

routes = web.RouteTableDef()

QUEUE_KEY = 'order_queue'
TASKS_KEY = 'order_tasks'

async def process_order_worker(worker_id: int, queue: Queue):       ❶
    while True:
        print(f'Worker {worker_id}: Waiting for an order...')
        order = await queue.get()
        print(f'Worker {worker_id}: Processing order {order}')
        await asyncio.sleep(order)
        print(f'Worker {worker_id}: Processed order {order}')
        queue.task_done()

@routes.post('/order')
async def place_order(request: Request) -> Response:
    order_queue = app[QUEUE_KEY]
    await order_queue.put(randrange(5))                            ❷
    return Response(body='Order placed!')
async def create_order_queue(app: Application):                    ❸
    print('Creating order queue and tasks.')
    queue: Queue = asyncio.Queue(10)
    app[QUEUE_KEY] = queue
    app[TASKS_KEY] = [asyncio.create_task(process_order_worker(i, queue))
                      for i in range(5)]

async def destroy_queue(app: Application):                         ❹
    order_tasks: List[Task] = app[TASKS_KEY]
    queue: Queue = app[QUEUE_KEY]
    print('Waiting for pending queue workers to finish....')
    try:
        await asyncio.wait_for(queue.join(), timeout=10)
    finally:
        print('Finished all pending items, canceling worker tasks...')
        [task.cancel() for task in order_tasks]

app = web.Application()
app.on_startup.append(create_order_queue)
app.on_shutdown.append(destroy_queue)

app.add_routes(routes)
web.run_app(app)

❶ 从队列中获取一个订单,并处理它。

❷ 将订单放入队列,并立即响应用户。

❸ 创建一个最大容量为 10 个元素的队列,并创建 5 个工作任务。

❹ 等待任何忙碌的任务完成。

在前面的列表中,我们首先创建了一个process_order_worker协程。这个协程从队列中拉取一个项目,在这个例子中是一个整数,并为此时间量暂停以模拟与慢速订单管理系统的交互。这个协程将永远循环,不断地从队列中拉取项目并处理它们。

然后,我们创建协程来设置和拆除队列,分别是 create_order_queuedestroy_order_queue。创建队列很简单,因为我们创建了一个最多包含 10 个元素的 asyncio 队列,并创建了五个工作任务,将它们存储在我们的 Application 实例中。

拆除队列稍微复杂一些。我们首先使用 Queue.join 等待队列完成所有元素的处理。由于我们的应用程序正在关闭,它将不再处理任何 HTTP 请求,因此不会有其他订单进入我们的队列。这意味着队列中已经存在的任何内容都将由工作进程处理,而工作进程当前正在处理的内容也将完成。我们还用超时为 10 秒的 wait_for 包装了 join。这是一个好主意,因为我们不希望一个失控的任务花费很长时间阻止我们的应用程序关闭。

最后,我们定义我们的应用程序 route。我们在 /order 路径创建了一个 POST 端点。此端点创建一个随机延迟并将其添加到队列中。一旦我们将订单添加到队列中,我们就向用户响应 HTTP 200 状态码和一条简短的消息。请注意,我们使用了 put 协程变体,这意味着如果我们的队列已满,请求将阻塞,直到消息进入队列,这可能需要一些时间。您可能想使用 put_nowait 变体,然后响应 HTTP 500 错误或其他错误代码,要求调用者稍后再试。在这里,我们权衡了请求可能需要一些时间,以便我们的订单始终进入队列。您的应用程序可能需要“快速失败”行为,因此在队列满时响应错误可能是您用例的正确行为。

使用这个队列,只要队列不满,我们的订单端点在订单提交后几乎会立即响应。这为最终用户提供了快速而流畅的订购体验——希望这能让他们回来购买更多商品。

在使用 asyncio 队列的 Web 应用程序时,需要记住队列的故障模式。如果我们的 API 实例由于某种原因崩溃,例如内存不足,或者我们需要重新启动服务器以重新部署我们的应用程序,那会怎样?在这种情况下,我们会在队列中丢失任何未处理的订单,因为它们只存储在内存中。有时,队列中丢失一个项目并不是什么大问题,但如果是客户订单,可能就不是这样了。

asyncio 队列不提供现成的任务持久性或队列持久性的概念。如果我们希望队列中的任务能够抵御这些类型的故障,我们需要在某个地方引入一个保存我们任务的方法,例如数据库。然而,更正确的方法是使用 asyncio 之外的单独队列,该队列支持任务持久性。Celery 和 RabbitMQ 是两个可以将任务持久化到磁盘的任务队列示例。

当然,使用独立的架构队列会带来额外的复杂性。在持久队列和持续任务的情况下,这也带来了性能挑战,需要将数据持久化到磁盘。为了确定最适合您应用程序的架构,您需要仔细权衡仅内存中的 asyncio 队列与独立架构组件之间的权衡。

12.1.2 网页爬虫队列

消费者任务也可以是生产者,如果我们的消费者生成更多工作以放入队列中。以一个访问特定页面所有链接的网页爬虫为例。你可以想象一个工作员下载并扫描页面以查找链接。一旦工作员找到链接,它可以将它们添加到队列中。这使得其他可用的工作员可以将链接拉入队列并并发访问它们,将它们遇到的任何链接重新添加到队列中。

让我们构建一个执行此操作的爬虫。我们将创建一个无界队列(如果您担心内存溢出,您可能希望将其限制),它将包含要下载的 URL。然后,我们的工作员将从队列中拉取 URL 并使用 aiohttp 下载它们。一旦我们下载了它们,我们将使用一个流行的 HTML 解析器 Beautiful Soup,以提取链接并将其放回队列中。

至少在这个应用程序中,我们不想扫描整个互联网,所以我们只会扫描从根页面出发的一定数量的页面。我们将称之为“最大深度”;如果我们的最大深度设置为三,这意味着我们只会跟随距离根页面三页的链接。

要开始,让我们使用以下命令安装 Beautiful Soup 版本 4.9.3:

pip install -Iv beautifulsoup4==4.9.3

我们假设您对 Beautiful Soup 有一定的了解。您可以在www.crummy.com/software/BeautifulSoup/bs4/doc的文档中了解更多信息。

我们的计划是创建一个工作协程,它会从队列中拉取一页并使用 aiohttp 下载它。一旦我们完成这个操作,我们将使用 Beautiful Soup 从页面中获取所有形式为 <a href="url"> 的链接,并将它们重新添加到队列中。

列表 12.4 基于队列的爬虫

import asyncio
import aiohttp
import logging
from asyncio import Queue
from aiohttp import ClientSession
from bs4 import BeautifulSoup

class WorkItem:
    def __init__(self, item_depth: int, url: str):
        self.item_depth = item_depth
        self.url = url

async def worker(worker_id: int, queue: Queue, session: ClientSession, max_depth: int):
    print(f'Worker {worker_id}')
    while True:                                                                                     ❶
        work_item: WorkItem = await queue.get()
        print(f'Worker {worker_id}: Processing {work_item.url}')
        await process_page(work_item, queue, session, max_depth)
        print(f'Worker {worker_id}: Finished {work_item.url}')
        queue.task_done()

async def process_page(work_item: WorkItem, queue: Queue, session: ClientSession, max_depth: int):  ❷
    try:
        response = await asyncio.wait_for(session.get(work_item.url), timeout=3)
        if work_item.item_depth == max_depth:
            print(f'Max depth reached, '
                  f'for {work_item.url}')
        else:
            body = await response.text()
            soup = BeautifulSoup(body, 'html.parser')
            links = soup.find_all('a', href=True)
            for link in links:
                queue.put_nowait(WorkItem(work_item.item_depth + 1,
                                          link['href']))
    except Exception as e:
        logging.exception(f'Error processing url {work_item.url}')

async def main():                                                                                   ❸
    start_url = 'http:/ /example.com'
    url_queue = Queue()
    url_queue.put_nowait(WorkItem(0, start_url))
    async with aiohttp.ClientSession() as session:
        workers = [asyncio.create_task(worker(i, url_queue, session, 3))
                   for i in range(100)]
        await url_queue.join()
        [w.cancel() for w in workers]

asyncio.run(main())

❶ 从队列中抓取一个 URL 以进行处理,然后开始下载它。

❷ 下载 URL 内容,并解析页面上的所有链接,将它们放回队列。

❸ 创建一个队列和 100 个工作任务来处理 URL。

在前面的列表中,我们首先定义了一个 WorkItem 类。这是一个简单的数据类,用于存储 URL 和该 URL 的深度。然后我们定义了我们的工作员,它会从队列中拉取一个 WorkItem 并调用 process_pageprocess_page 协程函数如果可以这样做(可能会发生超时或异常,我们只是记录并忽略)会下载 URL 的内容。然后它使用 Beautiful Soup 获取所有链接并将它们重新添加到队列中供其他工作员处理。

在我们的主协程中,我们创建队列并用第一个WorkItem启动它。在这个例子中,我们硬编码了 example.com,由于它是我们的根页面,其深度为 0。然后我们创建一个 aiohttp 会话并创建 100 个工作者,这意味着我们可以同时下载 100 个 URL,并将它的最大深度设置为 3。然后我们等待队列变空以及所有工作者通过Queue.join完成工作。一旦队列处理完毕,我们取消所有工作者任务。当你运行这段代码时,你应该会看到 100 个工作任务启动并开始从下载的每个 URL 中寻找链接,输出如下:

Found 1 links from http:/ / example .com
Worker 0: Finished http:/ / example .com
Worker 0: Processing https:/ /www .iana.org/domains/example
Found 68 links from https:/ /www .iana.org/domains/example
Worker 0: Finished https:/ /www .iana.org/domains/example
Worker 0: Processing /
Worker 2: Processing /domains
Worker 3: Processing /numbers
Worker 4: Processing /protocols
Worker 5: Processing /about
Worker 6: Processing /go/rfc2606
Worker 7: Processing /go/rfc6761
Worker 8: Processing http:/ /www .icann.org/topics/idn/
Worker 9: Processing http:/ /www .icann.org/

工作者将继续下载页面并处理链接,将它们添加到队列中,直到达到我们指定的最大深度。

通过构建一个假超市结账队列以及构建一个订单管理 API 和一个网络爬虫,我们已经看到了异步队列的基本原理。到目前为止,我们的工作者对队列中的每个元素都给予相同的权重,并且只是从队伍的前端取出一个来工作。如果我们希望某些任务即使排在队列的后面也能尽快执行,该怎么办呢?让我们看看优先队列,看看如何实现这一点。

12.2 优先队列

我们之前的队列示例按照 FIFO(先进先出)的顺序处理项目。排在队伍前面的人首先被处理。这在许多情况下都适用,无论是软件工程还是生活中。

然而,在某些应用中,所有任务都被视为平等的情况并不总是理想的。想象一下,我们正在构建一个数据处理管道,其中每个任务都是一个可能持续几分钟的长运行查询。假设两个任务几乎同时到达。第一个任务是一个低优先级的数据查询,但第二个是一个至关重要的数据更新,应该尽快处理。使用简单的队列,第一个任务将被处理,而更重要的第二个任务将等待第一个任务完成。想象一下,第一个任务可能需要数小时,或者如果所有工作者都很忙,第二个任务可能需要等待很长时间。

我们可以使用优先队列来解决这个问题,并让工作者首先处理最重要的任务。内部,优先队列由(使用heapq模块)支持,而不是像简单队列那样使用 Python 列表。要创建一个 asyncio 优先队列,我们创建一个asyncio.PriorityQueue的实例。

我们不会深入探讨数据结构的具体细节,但堆是一种具有每个父节点值都小于其所有子节点的属性的二叉树(参见图 12.1)。这与通常用于排序和搜索问题的二叉搜索树不同,二叉搜索树的唯一属性是节点的左子节点小于其父节点,而节点的右子节点大于其父节点。我们利用的堆属性是树中最顶端的节点总是树中最小元素。如果我们总是将最小节点作为最高优先级,那么高优先级节点将始终在队列中排在第一位。

12-01

图 12.1 在左侧,一个满足堆属性的二叉树;在右侧,一个不满足堆属性的二叉搜索树

我们放入队列中的工作项不太可能是普通的整数,因此我们需要某种方式来构建具有合理优先级规则的工作项。一种方法是将元组用作工作项,其中第一个元素是一个表示优先级的整数,第二个是任何任务数据。默认队列实现会查看元组的第一个值来决定优先级,最低的数字具有最高的优先级。让我们通过一个使用元组作为工作项的例子来看看优先队列的基本工作原理。

列表 12.5 使用元组的优先队列

import asyncio
from asyncio import Queue, PriorityQueue
from typing import Tuple

async def worker(queue: Queue):
    while not queue.empty():
        work_item: Tuple[int, str] = await queue.get()
        print(f'Processing work item {work_item}')
        queue.task_done()

async def main():
    priority_queue = PriorityQueue()

    work_items = [(3, 'Lowest priority'),
                  (2, 'Medium priority'),
                  (1, 'High priority')]

    worker_task = asyncio.create_task(worker(priority_queue))

    for work in work_items:
        priority_queue.put_nowait(work)

    await asyncio.gather(priority_queue.join(), worker_task)

asyncio.run(main())

在前面的列表中,我们创建了三个工作项:一个高优先级,一个中等优先级,一个低优先级。然后我们按照逆优先级顺序将它们添加到优先队列中,这意味着我们首先插入优先级最低的项,最后插入优先级最高的项。在一个普通队列中,这意味着我们会首先处理优先级最低的项,但如果我们运行这段代码,我们会看到以下输出:

Processing work item (1, 'High priority')
Processing work item (2, 'Medium priority')
Processing work item (3, 'Lowest priority')

这表明我们按照工作项的优先级顺序处理了工作项,而不是它们被插入队列的方式。元组适用于简单情况,但如果我们的工作项中有大量数据,元组可能会变得混乱且难以理解。我们是否有办法创建一种某种类型的类,使其以我们想要的方式与堆一起工作?实际上我们可以这样做,最简洁的方式是使用数据类(如果数据类不可用,我们也可以实现适当的dunder方法 __lt__, __le__, __gt__, 和 __ge__)。

列表 12.6 使用数据类的优先队列

import asyncio
from asyncio import Queue, PriorityQueue
from dataclasses import dataclass, field

@dataclass(order=True)
class WorkItem:
    priority: int
    data: str = field(compare=False)

async def worker(queue: Queue):
    while not queue.empty():
        work_item: WorkItem = await queue.get()
        print(f'Processing work item {work_item}')
        queue.task_done()

async def main():
    priority_queue = PriorityQueue()

    work_items = [WorkItem(3, 'Lowest priority'),
                  WorkItem(2, 'Medium priority'),
                  WorkItem(1, 'High priority')]

    worker_task = asyncio.create_task(worker(priority_queue))

    for work in work_items:
        priority_queue.put_nowait(work)

    await asyncio.gather(priority_queue.join(), worker_task)

asyncio.run(main())

在前面的列表中,我们创建了一个将 ordered 设置为 Truedataclass。然后我们添加了一个优先级整数和一个字符串数据字段,将其排除在比较之外。这意味着当我们将这些工作项添加到队列中时,它们将只按优先级字段排序。运行上面的代码,我们可以看到这是按正确的顺序处理的:

Processing work item WorkItem(priority=1, data='High priority')
Processing work item WorkItem(priority=2, data='Medium priority')
Processing work item WorkItem(priority=3, data='Lowest priority')

现在我们已经了解了优先队列的基本知识,让我们将其转换回我们之前示例中的订单管理 API。想象一下,我们有一些“高级用户”客户在我们的电子商务网站上花费了很多钱。我们想确保他们的订单总是首先被处理,以确保他们获得最佳体验。让我们调整我们之前的例子,为这些用户使用优先队列。

列表 12.7:Web 应用程序中的优先队列

import asyncio
from asyncio import Queue, Task
from dataclasses import field, dataclass
from enum import IntEnum
from typing import List
from random import randrange
from aiohttp import web
from aiohttp.web_app import Application
from aiohttp.web_request import Request
from aiohttp.web_response import Response

routes = web.RouteTableDef()

QUEUE_KEY = 'order_queue'
TASKS_KEY = 'order_tasks'

class UserType(IntEnum):
    POWER_USER = 1
    NORMAL_USER = 2

@dataclass(order=True)                                        ❶
class Order:
    user_type: UserType
    order_delay: int = field(compare=False)

async def process_order_worker(worker_id: int, queue: Queue):
    while True:
        print(f'Worker {worker_id}: Waiting for an order...')
        order = await queue.get()
        print(f'Worker {worker_id}: Processing order {order}')
        await asyncio.sleep(order.order_delay)
        print(f'Worker {worker_id}: Processed order {order}')
        queue.task_done()

@routes.post('/order')
async def place_order(request: Request) -> Response:
    body = await request.json()
    user_type = UserType.POWER_USER if body['power_user'] == 'True' else UserType.NORMAL_USER
    order_queue = app[QUEUE_KEY]
    await order_queue.put(Order(user_type, randrange(5)))     ❷
    return Response(body='Order placed!')

async def create_order_queue(app: Application): 
    print('Creating order queue and tasks.')
    queue: Queue = asyncio.PriorityQueue(10)
    app[QUEUE_KEY] = queue
    app[TASKS_KEY] = [asyncio.create_task(process_order_worker(i, queue))
                      for i in range(5)]

async def destroy_queue(app: Application):
    order_tasks: List[Task] = app[TASKS_KEY]
    queue: Queue = app[QUEUE_KEY]
    print('Waiting for pending queue workers to finish....')
    try:
        await asyncio.wait_for(queue.join(), timeout=10)
    finally:
        print('Finished all pending items, canceling worker tasks...')
        [task.cancel() for task in order_tasks]

app = web.Application()
app.on_startup.append(create_order_queue)
app.on_shutdown.append(destroy_queue)

app.add_routes(routes)
web.run_app(app)

❶ 一个表示我们的工作项的顺序类,其优先级基于用户类型。

❷ 将请求解析为订单。

前面的列表看起来与我们最初的 API 非常相似,区别在于我们使用了优先队列并创建了一个Order类来表示一个传入的订单。当我们收到一个订单时,我们现在期望它有一个带有“power user”标志的负载,对于 VIP 用户设置为True,对于其他用户设置为False。我们可以使用 cURL 像这样访问这个端点

curl -X POST -d '{"power_user":"False"}' localhost:8080/order

传入期望的高级用户值。如果用户是高级用户,他们的订单将总是由任何可用的工人优先于普通用户处理。

优先队列中可能出现的一个有趣的特殊情况是,当你连续添加两个具有相同优先级的工作项时会发生什么。它们是否按照它们被插入的顺序被工人处理?让我们做一个简单的例子来测试这一点。

列表 12.8:工作项优先级平局

import asyncio
from asyncio import Queue, PriorityQueue
from dataclasses import dataclass, field

@dataclass(order=True)
class WorkItem:
    priority: int
    data: str = field(compare=False)

async def worker(queue: Queue):
    while not queue.empty():
        work_item: WorkItem = await queue.get()
        print(f'Processing work item {work_item}')
        queue.task_done()

async def main():
    priority_queue = PriorityQueue()

    work_items = [WorkItem(3, 'Lowest priority'),
                  WorkItem(3, 'Lowest priority second'),
                  WorkItem(3, 'Lowest priority third'),
                  WorkItem(2, 'Medium priority'),
                  WorkItem(1, 'High priority')]

    worker_task = asyncio.create_task(worker(priority_queue))

    for work in work_items:
        priority_queue.put_nowait(work)

    await asyncio.gather(priority_queue.join(), worker_task)

asyncio.run(main())

在前面的列表中,我们首先将三个低优先级任务放入队列中。我们可能期望这些任务按照插入顺序进行处理,但当我们运行这个程序时,我们并没有得到预期的行为:

Processing work item WorkItem(priority=1, data='High priority')
Processing work item WorkItem(priority=2, data='Medium priority')
Processing work item WorkItem(priority=3, data='Lowest priority third')
Processing work item WorkItem(priority=3, data='Lowest priority second')
Processing work item WorkItem(priority=3, data='Lowest priority')

结果表明,我们以相反的顺序处理低优先级项,这是因为在底层heapsort算法不是一个稳定的排序算法,因为相等的项不保证在相同的插入顺序中。当优先级相同时,顺序可能不是问题,但如果你关心它,你需要添加一个平局解决键,以给你想要的排序。一种简单的方法是在工作项中添加一个项目计数,尽管你可以用很多种方法来做这件事。

列表 12.9:在优先队列中打破平局

import asyncio
from asyncio import Queue, PriorityQueue
from dataclasses import dataclass, field

@dataclass(order=True)
class WorkItem:
    priority: int
    order: int
    data: str = field(compare=False)

async def worker(queue: Queue):
    while not queue.empty():
        work_item: WorkItem = await queue.get()
        print(f'Processing work item {work_item}')
        queue.task_done()

async def main():
    priority_queue = PriorityQueue()

    work_items = [WorkItem(3, 1, 'Lowest priority'),
                  WorkItem(3, 2, 'Lowest priority second'),
                  WorkItem(3, 3, 'Lowest priority third'),
                  WorkItem(2, 4, 'Medium priority'),
                  WorkItem(1, 5, 'High priority')]

    worker_task = asyncio.create_task(worker(priority_queue))

    for work in work_items:
        priority_queue.put_nowait(work)

    await asyncio.gather(priority_queue.join(), worker_task)

asyncio.run(main())

在前面的列表中,我们向我们的WorkItem类添加了一个order字段。然后,当我们插入工作项时,我们添加一个整数来表示我们将它插入队列中的顺序。当优先级相同时,这将是我们排序的字段。在我们的例子中,这为我们提供了低优先级项所需的插入顺序:

Processing work item WorkItem(priority=1, order=5, data='High priority')
Processing work item WorkItem(priority=2, order=4, data='Medium priority')
Processing work item WorkItem(priority=3, order=1, data='Lowest priority')
Processing work item WorkItem(priority=3, order=2, data='Lowest priority second')
Processing work item WorkItem(priority=3, order=3, data='Lowest priority third')

我们现在已经看到了如何按照 FIFO 队列顺序和优先队列顺序处理工作项。如果我们想首先处理最近添加的工作项呢?接下来,让我们看看如何使用 LIFO 队列来实现这一点。

12.3 LIFO 队列

在计算机科学领域,LIFO 队列通常被称为。我们可以想象这些就像一副扑克筹码:当你下注时,你会从你的筹码堆顶部(或“弹出”)筹码,当你希望赢得牌时,你会将筹码放回筹码堆的顶部(或“推”上去)。这些在我们要让工作者首先处理最近添加的项目时非常有用。

我们不会构建比简单示例更多的内容来演示工作者处理元素顺序。至于何时使用 LIFO 队列,这取决于应用程序需要按什么顺序处理队列中的项目。你是否需要首先处理队列中最最近插入的项目?在这种情况下,你将想要使用 LIFO 队列。

列表 12.10 一个 LIFO 队列

import asyncio
from asyncio import Queue, LifoQueue
from dataclasses import dataclass, field

@dataclass(order=True)
class WorkItem:
    priority: int
    order: int
    data: str = field(compare=False)

async def worker(queue: Queue):
    while not queue.empty():
        work_item: WorkItem = await queue.get()           ❶
        print(f'Processing work item {work_item}')
        queue.task_done()

async def main():
    lifo_queue = LifoQueue()

    work_items = [WorkItem(3, 1, 'Lowest priority first'),
                  WorkItem(3, 2, 'Lowest priority second'),
                  WorkItem(3, 3, 'Lowest priority third'),
                  WorkItem(2, 4, 'Medium priority'),
                  WorkItem(1, 5, 'High priority')]

    worker_task = asyncio.create_task(worker(lifo_queue))

    for work in work_items:
        lifo_queue.put_nowait(work)                       ❷

    await asyncio.gather(lifo_queue.join(), worker_task)

asyncio.run(main())

❶ 从队列中获取一个项目,或者说是“弹出”它,从栈上。

❷ 将一个项目放入队列中,或者说是“推”到栈上。

在前面的列表中,我们创建了一个 LIFO 队列和一组工作项。然后我们依次将它们插入队列中,取出并处理它们。运行这个程序,你会看到以下输出:

Processing work item WorkItem(priority=1, order=5, data='High priority')
Processing work item WorkItem(priority=2, order=4, data='Medium priority')
Processing work item WorkItem(priority=3, order=3, data='Lowest priority third')
Processing work item WorkItem(priority=3, order=2, data='Lowest priority second')
Processing work item WorkItem(priority=3, order=1, data='Lowest priority first')

注意,我们在队列中处理项目的顺序与我们将它们插入队列的顺序相反。由于这是一个栈,这很有意义,因为我们首先处理队列中最最近添加的工作项。

我们现在已经看到了 asyncio 队列库所能提供的所有队列类型。使用这些队列有什么潜在的问题吗?我们是否可以在需要队列的应用程序中随时使用它们?我们将在第十三章中解决这个问题。

摘要

  • asyncio 队列是任务队列,在具有生成数据的协程和负责处理这些数据的协程的工作流程中非常有用。

  • 队列解耦了数据生成和数据处理,因为我们可以让生产者将项目放入队列,然后多个工作者可以独立和并发地处理这些项目。

  • 我们可以使用优先级队列来给某些任务赋予比其他任务更高的优先级。这在某些工作比其他工作更重要,并且应该首先处理的情况下非常有用。

  • asyncio 队列不是分布式的,不是持久的,也不是耐用的。如果你需要这些特性中的任何一个,你需要寻找一个单独的架构组件,比如 Celery 或 RabbitMQ。

13 管理子进程

本章涵盖了

  • 异步运行多个子进程

  • 处理子进程的标准输出

  • 使用标准输入与子进程通信

  • 避免子进程的死锁和其他陷阱

许多应用程序可能永远不会需要离开 Python 的世界。我们将从其他 Python 库和模块中调用代码,或者使用多进程或多线程来并发运行 Python 代码。然而,我们想要与之交互的并不都是用 Python 编写的。我们可能已经有一个用 C++、Go、Rust 或其他语言编写的应用程序,这些语言提供了更好的运行时特性,或者它已经存在,我们无需重新实现就可以使用。我们可能还希望使用操作系统提供的命令行工具,例如 GREP 用于搜索大文件,cURL 用于发送 HTTP 请求,或者我们拥有的任何数量的应用程序。

在标准 Python 中,我们可以使用 subprocess 模块在不同的进程中运行不同的应用程序。像大多数其他 Python 模块一样,标准的 subprocess API 是阻塞的,这使得它在没有多线程或多进程的情况下与 asyncio 不兼容。asyncio 提供了一个基于 subprocess 模块的模块,用于使用协程异步创建和管理子进程。

在本章中,我们将通过运行用不同语言编写的应用程序来学习使用 asyncio 创建和管理子进程的基础知识。我们还将学习如何处理输入和输出,读取标准输出,以及从我们的应用程序向我们的子进程发送输入。

13.1 创建子进程

假设你想扩展现有 Python Web API 的功能。你组织内的另一个团队已经为他们的批量处理机制构建了你想要的功能的命令行应用程序,但有一个主要问题,即该应用程序是用 Rust 编写的。鉴于应用程序已经存在,你不想通过在 Python 中重新实现它来重新发明轮子。我们还有没有其他方法可以在现有的 Python API 中使用这个应用程序的功能?

由于这个应用程序具有命令行界面,我们可以使用子进程来重用这个应用程序。我们将通过其命令行界面调用应用程序,并在单独的子进程中运行它。然后我们可以读取应用程序的结果,并在需要时将其用于我们现有的 API 中,从而避免重新实现应用程序的麻烦。

那么,我们如何创建一个子进程并执行它呢?asyncio库提供了两个现成的协程函数来创建子进程:asyncio.create_subprocess_shellasyncio.create_subprocess_exec。这些协程函数中的每一个都返回一个 Process 实例,该实例具有让我们等待进程完成和终止进程的方法,以及其他一些方法。为什么有两个协程来完成看似相同的工作?我们何时会想要使用其中一个而不是另一个?create_subprocess_shell 协程函数在系统上安装的 shell(如 zshbash)内部创建一个子进程。一般来说,除非你需要使用 shell 的功能,否则你会想要使用 create_subprocess_exec。使用 shell 可能会有一些陷阱,比如不同的机器可能安装了不同的 shell,或者同一个 shell 的配置可能不同。这可能会使得很难保证你的应用程序在不同的机器上表现一致。

要了解如何创建子进程的基本知识,让我们编写一个 asyncio 应用程序来运行一个简单的命令行程序。我们将从 ls 程序开始,该程序列出当前目录的内容以进行测试,尽管在现实世界中我们不太可能这样做。如果你在 Windows 机器上运行,请将 ls -l 替换为 cmd /c dir

列表 13.1 在子进程中运行简单命令

import asyncio
from asyncio.subprocess import Process

async def main():
    process: Process = await asyncio.create_subprocess_exec('ls', '-l')
    print(f'Process pid is: {process.pid}')
    status_code = await process.wait()
    print(f'Status code: {status_code}')

asyncio.run(main())

在前面的列表中,我们使用 create_subprocess_exec 创建了一个 Process 实例来运行 ls 命令。我们还可以通过添加其他参数来指定传递给程序的参数。这里我们传递了 -l,这会在目录中创建文件的创建者周围添加一些额外的信息。一旦我们创建了进程,我们就打印出进程 ID,然后调用 wait 协程。这个协程将等待进程完成,一旦完成,它将返回子进程的状态码;在这种情况下应该是零。默认情况下,子进程的标准输出将被管道传输到我们自己的应用程序的标准输出,所以当你运行这个程序时,你应该会看到以下类似的内容,具体取决于你的目录内容:

Process pid is: 54438
total 8
drwxr-xr-x   4 matthewfowler  staff  128 Dec 23 15:20 .
drwxr-xr-x  25 matthewfowler  staff  800 Dec 23 14:52 ..
-rw-r--r--   1 matthewfowler  staff    0 Dec 23 14:52 __init__.py
-rw-r--r--   1 matthewfowler  staff  293 Dec 23 15:20 basics.py
Status code: 0

注意,wait 协程将阻塞直到应用程序终止,而且没有任何保证关于进程将花费多长时间终止,更不用说是否真的会终止。如果你担心一个失控的进程,你需要使用 asyncio.wait_for 引入超时。然而,这里有一个注意事项。回想一下,如果超时,wait_for 将终止它正在运行的协程。你可能认为这将终止进程,但实际上它不会。它只终止等待进程完成的任务,而不是底层进程。

我们需要一个更好的方法来在超时时关闭进程。幸运的是,Process有两个方法可以帮助我们在这个情况下:terminatekillterminate方法会向子进程发送SIGTERM信号,而kill会发送SIGKILL信号。请注意,这两个方法都不是协程,并且是非阻塞的。它们只是发送信号。如果你想在终止子进程后尝试获取返回代码,或者你想等待任何清理,你需要再次调用wait

让我们测试一下使用sleep命令行应用程序(对于 Windows 用户,将'sleep', '3'替换为更复杂的'cmd', 'start', '/wait', 'timeout', '3')终止长时间运行的应用程序。我们将创建一个睡眠几秒钟的子进程,并尝试在它有机会完成之前终止它。

列表 13.2 终止子进程

import asyncio
from asyncio.subprocess import Process

async def main():
    process: Process = await asyncio.create_subprocess_exec('sleep', '3')
    print(f'Process pid is: {process.pid}')
    try:
        status_code = await asyncio.wait_for(process.wait(), timeout=1.0)
        print(status_code)
    except asyncio.TimeoutError:
        print('Timed out waiting to finish, terminating...')
        process.terminate()
        status_code = await process.wait()
        print(status_code)

asyncio.run(main())

在前面的列表中,我们创建了一个需要 3 秒钟才能完成的子进程,但用wait_for包装了它,并设置了 1 秒的超时。1 秒后,wait_for将抛出TimeoutError,在except块中我们将终止进程并等待它完成,打印出其状态码。这应该会给出类似于以下输出:

Process pid is: 54709
Timed out waiting to finish, terminating...
-15

在编写自己的代码时需要注意的一点是,except块中的wait仍然有可能花费很长时间,如果你对此有顾虑,你可能需要将其包装在wait_for中。

13.1.1 控制标准输出

在前面的例子中,我们的子进程的标准输出直接流向了应用程序的标准输出。如果我们不希望这种行为呢?也许我们想在输出上做额外的处理,或者也许输出并不重要,我们可以安全地忽略它。create_subprocess_exec协程有一个stdout参数,允许我们指定标准输出应该去哪里。这个参数接受一个enum,允许我们指定是否要将子进程的输出重定向到我们自己的标准输出,将其管道传输到StreamReader,或者通过将其重定向到/dev/null完全忽略它。

假设我们计划并发运行多个子进程并回显它们的输出。我们想知道哪个子进程生成了输出以避免混淆。为了使输出更容易阅读,我们将在将其写入应用程序的标准输出之前添加一些关于哪个子进程生成了输出的额外数据。我们将在打印之前将生成输出的命令添加到前面。

要做到这一点,我们首先需要将stdout参数设置为asyncio .subprocess.PIPE。这告诉子进程创建一个新的StreamReader实例,我们可以使用它来读取进程的输出。然后我们可以通过Proccess.stdout字段访问这个流读取器。让我们用我们的ls -la命令试一试。

列表 13.3 使用标准输出流读取器

import asyncio
from asyncio import StreamReader
from asyncio.subprocess import Process

async def write_output(prefix: str, stdout: StreamReader):
    while line := await stdout.readline():
        print(f'[{prefix}]: {line.rstrip().decode()}')

async def main():
    program = ['ls', '-la']
    process: Process = await asyncio.create_subprocess_exec(*program,
                                                            stdout=asyncio
                                                            .subprocess.PIPE)
    print(f'Process pid is: {process.pid}')
    stdout_task = asyncio.create_task(write_output(' '.join(program), process.stdout))

    return_code, _ = await asyncio.gather(process.wait(), stdout_task)
    print(f'Process returned: {return_code}')

asyncio.run(main())

在之前的列表中,我们首先创建了一个协程 write_output,它逐行将前缀添加到流读取器的输出。然后,在我们的主协程中,我们创建了一个子进程,指定我们想要管道 stdout。我们还创建了一个任务来运行 write_output,传入进程的标准输出流读取器,并与 wait 并发运行。当运行这个时,你会看到输出前添加了以下命令:

Process pid is: 56925
[ls -la]: total 32
[ls -la]: drwxr-xr-x   7 matthewfowler  staff  224 Dec 23 09:07 .
[ls -la]: drwxr-xr-x  25 matthewfowler  staff  800 Dec 23 14:52 ..
[ls -la]: -rw-r--r--   1 matthewfowler  staff    0 Dec 23 14:52 __init__.py
Process returned: 0

使用管道处理的一个关键方面,以及处理子进程的输入和输出,是它们容易发生死锁。如果我们的子进程生成大量输出,而我们没有正确地消耗它,wait 协程尤其容易受到这种影响。为了演示这一点,让我们通过生成一个将大量数据写入标准输出并一次性刷新的 Python 应用程序来查看一个简单的示例。

列表 13.4 生成大量输出

import sys

[sys.stdout.buffer.write(b'Hello there!!\n') for _ in range(1000000)]

sys.stdout.flush()

之前的列表将 Hello there!! 写入标准输出缓冲区 1,000,000 次,并一次性刷新它。让我们看看如果我们使用管道与这个应用程序,但不消耗数据会发生什么。

列表 13.5 管道导致的死锁

import asyncio
from asyncio.subprocess import Process

async def main():
    program = ['python3', 'listing_13_4.py']
    process: Process = await asyncio.create_subprocess_exec(*program,
                                                            stdout=asyncio
                                                            .subprocess.PIPE)
    print(f'Process pid is: {process.pid}')

    return_code = await process.wait()
    print(f'Process returned: {return_code}')

asyncio.run(main())

如果你运行前面的列表,你会看到打印出进程 pid,然后就没有更多内容了。应用程序将永远挂起,你需要强制终止它。如果你的系统没有发生这种情况,只需增加输出应用程序中输出数据的次数,你最终会遇到这个问题。

我们的应用程序看起来很简单,那么为什么我们会遇到这个死锁呢?问题在于流读取器的缓冲区是如何工作的。当流读取器的缓冲区填满时,任何更多的写入调用都会阻塞,直到缓冲区中有更多空间。当我们的流读取器缓冲区因为缓冲区已满而阻塞时,我们的进程仍在尝试将大量输出写入流读取器。这使得我们的进程依赖于流读取器变得未阻塞,但流读取器永远不会变得未阻塞,因为我们从未释放缓冲区中的任何空间。这是一个循环依赖,因此是一个死锁。

之前,我们通过在等待进程完成的同时从标准输出流读取器并发读取来完全避免这个问题。这意味着即使缓冲区满了,我们也会将其排空,这样进程就不会无限期地等待写入更多数据。在处理管道时,你需要小心处理流数据,以免遇到死锁。

你也可以通过避免使用 wait 协程来解决这个问题。此外,Process 类还有一个名为 communicate 的协程方法,它可以完全避免死锁。这个协程会阻塞,直到子进程完成,并并发地消耗标准输出和标准错误,一旦应用程序完成,就返回完整的输出。让我们修改之前的示例,使用 communicate 来解决这个问题。

列表 13.6 使用 communicate

import asyncio
from asyncio.subprocess import Process

async def main():
    program = ['python3', 'listing_13_4.py']
    process: Process = await asyncio.create_subprocess_exec(*program,
                                                            stdout=asyncio
                                                            .subprocess.PIPE)
    print(f'Process pid is: {process.pid}')

    stdout, stderr = await process.communicate()
    print(stdout)
    print(stderr)
    print(f'Process returned: {process.returncode}')

asyncio.run(main())

当你运行前面的列表时,你会看到所有应用程序的输出一次性打印到控制台(并且打印一次 None,因为我们没有写入任何内容到标准输出)。内部,communicate 创建了一些任务,这些任务会不断地从标准输出和标准错误读取输出到内部缓冲区,从而避免了任何死锁问题。虽然我们避免了潜在的死锁,但我们有一个严重的缺点,那就是我们无法交互式地处理标准输出的输出。如果你处于需要响应应用程序输出的情况(可能需要在你遇到某些消息时终止,或者启动另一个任务),你需要使用 wait,但要注意适当地从你的流读取器读取输出,以避免死锁。

另一个缺点是 communicate 会将标准输出和标准输入的所有数据缓冲在内存中。如果你正在处理一个可能会产生大量数据的子进程,你可能会面临内存不足的风险。我们将在下一节中看到如何解决这些缺点。

13.1.2 并发运行子进程

现在我们已经了解了创建、终止和从子进程中读取输出的基础知识,我们可以将这些知识添加到我们的现有知识中,以并发运行多个应用程序。让我们想象一下,我们需要加密内存中存储的多个文本片段,出于安全考虑,我们希望使用 Twofish 加密算法。这个算法不被 hashlib 模块支持,因此我们需要一个替代方案。我们可以使用 gpg(代表 GNU Privacy Guard,它是 PGP [相当好的隐私] 的免费软件替代品)命令行应用程序。您可以从 gnupg.org/download/ 下载 gpg。

首先,让我们定义我们将要使用的加密命令。我们可以通过定义一个密码并使用命令行参数设置一个算法来使用 gpg。然后,只需要将文本回显到应用程序即可。例如,为了加密文本“encrypt this!”,我们可以运行以下命令:

echo 'encrypt this!' | gpg -c --batch --passphrase 3ncryptm3 --cipher-algo TWOFISH

这应该在标准输出产生加密输出,类似于以下内容:

?
Q+??/??*??C??H`??`)R??u??7þ_{f{R;n?FE .?b5??(?i??????o\k?b<????`% 

这将在我们的命令行中工作,但如果我们在使用 create_subprocess_exec,则不会工作,因为我们不会有 | 管道操作符可用(如果你确实需要管道,create_subprocess_shell 将会工作)。那么我们如何传递我们想要加密的文本呢?除了允许我们将标准输出和标准错误管道化外,communicatewait 还允许我们将标准输入管道化。communicate 协程还允许我们在启动应用程序时指定输入字节。如果我们创建进程时已经管道化了标准输入,这些字节将被发送到应用程序。这对我们来说将非常合适;我们只需通过 communicate 协程传递我们想要加密的字符串即可。

让我们通过生成随机文本并对其进行并发加密来尝试一下。我们将创建一个包含 100 个随机文本字符串的列表,每个字符串有 1,000 个字符,并对它们中的每一个进行并发运行gpg

列表 13.7 并发加密文本

import asyncio
import random
import string
import time
from asyncio.subprocess import Process

async def encrypt(text: str) -> bytes:
    program = ['gpg', '-c', '--batch', '--passphrase', '3ncryptm3',
        '--cipher-algo', 'TWOFISH']

    process: Process = await asyncio.create_subprocess_exec(*program,
                                                            stdout=asyncio
                                                            .subprocess.PIPE,
                                                            stdin=asyncio
                                                            .subprocess.PIPE)
    stdout, stderr = await process.communicate(text.encode())
    return stdout

async def main():
    text_list = [''.join(random.choice(string.ascii_letters) for _ in range(1000)) for _ in range(100)]

    s = time.time()
    tasks = [asyncio.create_task(encrypt(text)) for text in text_list]
    encrypted_text = await asyncio.gather(*tasks)
    e = time.time()

    print(f'Total time: {e - s}')
    print(encrypted_text)

asyncio.run(main())

在前面的列表中,我们定义了一个名为encrypt的协程,它创建一个 gpg 进程,并通过communicate发送我们想要加密的文本。为了简单起见,我们只返回标准输出结果,并不进行任何错误处理;在实际应用中你可能会希望这里更健壮。然后,在我们的主协程中,我们创建一个随机文本列表并为每篇文本创建一个encrypt任务。然后我们使用gather并发运行它们,并打印出总运行时间和加密的文本位数。你可以通过在asyncio.create_task前放置await并移除gather来比较并发运行时间和同步运行时间,你应该会看到合理的加速。

在这个列表中,我们只有 100 篇文本。如果我们有数千篇甚至更多呢?我们当前的代码会处理 100 篇文本并尝试并发加密它们;这意味着我们同时创建 100 个进程。这提出了一个挑战,因为我们的机器资源有限,一个进程可能会消耗大量内存。此外,启动数百或数千个进程会创建非平凡的上下文切换开销。

在我们的情况下,由于 gpg 依赖于共享状态来加密数据,所以我们遇到了另一个问题。如果你将列表 13.7 中的代码中的文本数量增加到数千,你可能会开始看到以下内容打印到标准错误:

gpg: waiting for lock on `/Users/matthewfowler/.gnupg/random_seed'...

因此,我们不仅创建了许多进程以及与之相关的所有开销,而且还创建了实际上被 gpg 需要的共享状态所阻塞的进程。那么我们如何限制运行进程的数量以规避这个问题呢?这是一个当信号量派上用场时的完美例子。由于我们的工作是 CPU 密集型的,添加一个信号量来限制进程数量到我们可用的 CPU 核心数是有意义的。让我们通过使用限制在我们系统 CPU 核心数上的信号量来加密 1,000 篇文本,看看这能否提高我们的性能。

列表 13.8 使用信号量的子进程

import asyncio
import random
import string
import time
import os
from asyncio import Semaphore
from asyncio.subprocess import Process

async def encrypt(sem: Semaphore, text: str) -> bytes:
    program = ['gpg', '-c', '--batch', '--passphrase', '3ncryptm3', '--cipher-algo', 'TWOFISH']

    async with sem:
        process: Process = await asyncio.create_subprocess_exec(*program,
                                                                stdout=asyncio
                                                                .subprocess.PIPE,
                                                                stdin=asyncio
                                                                .subprocess.PIPE)
        stdout, stderr = await process.communicate(text.encode())
        return stdout

async def main():
    text_list = [''.join(random.choice(string.ascii_letters) for _ in range(1000)) for _ in range(1000)]
    semaphore = Semaphore(os.cpu_count())
    s = time.time()
    tasks = [asyncio.create_task(encrypt(semaphore, text)) for text in text_list]
    encrypted_text = await asyncio.gather(*tasks)
    e = time.time()

    print(f'Total time: {e - s}')

asyncio.run(main())

将此与具有无界子进程集的 1,000 篇文本的运行时间进行比较,你应该会看到一些性能提升,同时内存使用量也会减少。你可能会认为这与我们在第六章中看到的ProcessPoolExecutor的最大工作者概念类似,而且你是对的。内部,ProcessPoolExecutor使用信号量来管理同时运行多少个进程。

我们现在已经看到了创建、终止和并发运行多个子进程的基本知识。接下来,我们将探讨如何以更交互式的方式与子进程进行通信。

13.2 与子进程通信

到目前为止,我们一直在使用单向、非交互式通信与进程进行通信。但如果我们正在处理可能需要用户输入的应用程序怎么办?例如,我们可能被要求输入密码短语、用户名或其他多种输入。

在我们知道我们只需要处理一条输入的情况下,使用 communicate 是理想的。我们之前使用 gpg 发送要加密的文本时看到了这一点,但现在让我们尝试当子进程明确请求输入时。我们首先创建一个简单的 Python 程序来请求用户名并将其回显到标准输出。

列表 13.9 回显用户输入

username = input('Please enter a username: ')
print(f'Your username is {username}')

现在,我们可以使用 communicate 来输入用户名。

列表 13.10 使用 communicate 与标准输入

import asyncio
from asyncio.subprocess import Process

async def main():
    program = ['python3', 'listing_13_9.py']
    process: Process = await asyncio.create_subprocess_exec(*program,
                                                            stdout=asyncio
                                                            .subprocess.PIPE,
                                                            stdin=asyncio
                                                            .subprocess.PIPE)

    stdout, stderr = await process.communicate(b'Zoot')
    print(stdout)
    print(stderr)

asyncio.run(main())

当我们运行此代码时,我们将在控制台看到打印出 b'Please enter a username: Your username is Zoot\n',因为我们的应用程序在第一次用户输入后立即终止。如果我们有一个更交互式的应用程序,这将不起作用。例如,考虑这个应用程序,它反复请求用户输入并回显输入,直到用户输入 quit

列表 13.11 一个回显应用程序

user_input = ''

while user_input != 'quit':
    user_input = input('Enter text to echo: ')
    print(user_input)

由于 communicate 等待进程终止,我们需要使用 wait 并并发处理标准输出和标准输入。Process 类在 stdin 字段中公开 StreamWriter,我们可以在将标准输入设置为 PIPE 时使用它。我们可以与此标准输出 StreamReader 并发使用来处理这些类型的应用程序。让我们通过以下列表看看如何做到这一点,我们将创建一个应用程序向我们的子进程写入一些文本。

列表 13.12 使用回显应用程序与子进程

import asyncio
from asyncio import StreamWriter, StreamReader
from asyncio.subprocess import Process

async def consume_and_send(text_list, stdout: StreamReader, stdin: StreamWriter):
    for text in text_list:
        line = await stdout.read(2048)
        print(line)
        stdin.write(text.encode())
        await stdin.drain()

async def main():
    program = ['python3', 'listing_13_11.py']
    process: Process = await asyncio.create_subprocess_exec(*program,
                                                            stdout=asyncio
                                                            .subprocess.PIPE,
                                                            stdin=asyncio
                                                            .subprocess.PIPE)

    text_input = ['one\n', 'two\n', 'three\n', 'four\n', 'quit\n']

    await asyncio.gather(consume_and_send(text_input, process.stdout, process.stdin), process.wait())

asyncio.run(main())

在前面的列表中,我们定义了一个 consume_and_send 协程,它读取标准输出,直到我们收到用户指定输入的预期消息。一旦我们收到这个消息,我们就将数据转储到我们自己的应用程序的标准输出,并将 'text_list' 中的字符串写入标准输入。我们重复此操作,直到将所有数据发送到我们的子进程。当我们运行这个程序时,我们应该看到所有的输出都发送到了我们的子进程,并且得到了适当的回显:

b'Enter text to echo: '
b'one\nEnter text to echo: '
b'two\nEnter text to echo: '
b'three\nEnter text to echo: '
b'four\nEnter text to echo: '

我们目前正在运行的应用程序有产生确定性输出并在确定性点停止以请求输入的便利。这使得管理标准输出和标准输入相对简单。如果我们正在子进程中运行的应用程序有时才请求输入或可能在请求输入之前写入大量数据怎么办?让我们将我们的示例回显程序调整为更复杂一些。我们将使其随机回显用户输入 1 到 10 次,并在每次回显之间 sleep 半秒。

列表 13.13 一个更复杂的回显应用程序

from random import randrange
import time

user_input = ''

while user_input != 'quit':
    user_input = input('Enter text to echo: ')
    for i in range(randrange(10)):
        time.sleep(.5)
        print(user_input)

如果我们以与列表 13.12 类似的方式将此应用程序作为子进程运行,它将工作,因为我们仍然具有确定性,我们最终会以已知文本请求输入。然而,使用这种方法的一个缺点是我们的从标准输出读取和写入标准输入的代码紧密耦合。这加上我们输入/输出逻辑的日益复杂,可能会使代码难以理解和维护。

我们可以通过解耦读取标准输出与写入标准输入的数据,从而分离读取标准输出和写入标准输入的职责。我们将创建一个协程来读取标准输出,并创建另一个协程来将文本写入标准输入。我们的标准输出读取协程在接收到预期的输入提示后,将设置一个事件。我们的标准输入写入协程将等待该事件被设置,一旦设置,它将写入指定的文本。然后我们将这两个协程通过gather并发运行。

列表 13.14 解耦输出读取与输入写入

import asyncio
from asyncio import StreamWriter, StreamReader, Event
from asyncio.subprocess import Process

async def output_consumer(input_ready_event: Event, stdout: StreamReader):
    while (data := await stdout.read(1024)) != b'':
        print(data)
        if data.decode().endswith("Enter text to echo: "):
            input_ready_event.set()
async def input_writer(text_data, input_ready_event: Event, stdin: StreamWriter):
    for text in text_data:
        await input_ready_event.wait()
        stdin.write(text.encode())
        await stdin.drain()
        input_ready_event.clear()

async def main():
    program = ['python3', 'interactive_echo_random.py']
    process: Process = await asyncio.create_subprocess_exec(*program,
                                                            stdout=asyncio
                                                            .subprocess.PIPE,
                                                            stdin=asyncio
                                                            .subprocess.PIPE)

    input_ready_event = asyncio.Event()

    text_input = ['one\n', 'two\n', 'three\n', 'four\n', 'quit\n']

    await asyncio.gather(output_consumer(input_ready_event, process.stdout),
                         input_writer(text_input, input_ready_event,
                         process.stdin),
                         process.wait())

asyncio.run(main())

在前面的列表中,我们首先定义了一个output_consumer协程函数。这个函数接收一个input_ready事件以及一个将引用标准输出的StreamReader,并从标准输出读取,直到我们遇到文本Enter text to echo:。一旦我们看到这个文本,我们就知道我们的子进程的标准输入已经准备好接受输入,因此我们设置input_ready事件。

我们的input_writer协程函数遍历我们的输入列表,并等待我们的事件,直到标准输入准备好。一旦标准输入准备好,我们写入我们的输入并清除事件,这样在for循环的下一个迭代中,我们将阻塞,直到标准输入再次准备好。通过这种实现,我们现在有两个协程函数,每个函数都有一个明确的职责:一个用于写入标准输入,一个用于读取标准输出,这增加了我们代码的可读性和可维护性。

摘要

  • 我们可以使用asyncio的子进程模块通过create_subprocess_shellcreate_subprocess_exec异步启动子进程。尽可能使用create_subprocess_exec,因为它确保了跨机器的一致行为。

  • 默认情况下,子进程的输出将流向我们自己的应用程序的标准输出。如果我们需要读取和交互标准输入和标准输出,我们需要将它们配置为通过管道连接到StreamReaderStreamWriter实例。

  • 当我们管道标准输出或标准错误时,我们需要小心消费输出。如果我们不这样做,我们可能会使我们的应用程序陷入死锁。

  • 当我们同时运行大量子进程时,信号量可以合理地避免滥用系统资源和不必要的竞争。

  • 我们可以使用communicate协程方法向子进程的标准输入发送输入。

封底内页

图片

posted @ 2025-11-17 09:51  绝不原创的飞龙  阅读(32)  评论(0)    收藏  举报