Python-开发高级教程-三-

133 阅读1小时+

Python 开发高级教程(三)

原文:Advanced Python Development

协议:CC BY-NC-SA 4.0

六、聚集过程

既然我们已经有了从计算机收集数据并通过 HTTP 接口报告数据的健壮代码库,是时候开始记录和分析这些数据了。我们需要创建一个连接到每个传感器并提取数据的中央聚合流程。这样的过程将允许我们同时观察不同传感器之间的相关性以及随时间的趋势。

首先,我们需要创建一个新的 Python 包。对于我们来说,将聚合过程的所有代码与数据收集代码一起分发是没有意义的;我们期望部署更多的传感器,而不是聚合过程。

程序员很少从零开始一个新项目,并自己编写所有的样板文件。更常见的是使用模板,要么显式地使用,要么通过复制另一个项目并删除其功能来使用。从一段存在但什么都不做的代码开始比从一个空目录开始容易得多。

饼干成型切割刀

虽然您可以通过复制目录从模板创建新项目,但是有一些工具可以使这个过程变得更容易。尽管复制和修改一个模板目录看起来很简单,但它通常需要从“框架”或“示例”中重命名文件和目录,以匹配您正在创建的项目的名称。像 cookiecutter 这样的工具通过允许您创建使用首次创建项目时提供的变量的模板来自动化这个过程。

我推荐使用 cookiecutter 来创建新项目。对我们来说,这将是一个全球性的开发工具,而不是一个特定于项目的工具。我们应该将它安装到系统 Python 环境中, 1 ,就像我们对 Pipenv 所做的那样。

> pip install --user cookiecutter

有许多预先存在的 cookiecutter 模板;有些为一般的 Python 包提供模板,有些为更复杂的东西提供模板。对于各种各样的东西,如混合 Python/rust 包、基于 Python 的智能手机应用和 Python web 应用,都有专门的模板。

您不需要安装 cookiecutter 模板;事实上,你不能。一个模板只能作为本地模板副本的路径或者作为 git 的远程规范被引用(比如,你通常会传递给git clone 2 )。当您指定远程模板时,cookiecutter 会自动下载并使用该模板。如果您以前已经使用过该模板,系统会提示您用新下载的版本替换它。

Tip

如果你有一个经常使用的模板,我建议你在本地保存一个。不要忘记定期更新它,以防 git 存储库中已经应用了修复,但是除了速度上的小改进,这允许您在不连接到互联网的情况下生成代码。

如果您发现自己没有网络连接,但是没有维护本地签出,那么 cookiecutter 可能在~/.cookiecutter/有一个来自过去调用的缓存

创建新模板

我们可以使用这些模板作为聚合过程的基础,但是它们都不完全符合我们在前面章节中做出的决策。相反,我将创建一个新的模板,该模板收集了本书对最小 Python 包的建议。您可以根据自己的喜好进行调整,或者创建新的模板来自动创建特定于您的工作的样板代码。

Note

如果你想使用我在这里描述的模板,你没有必要做你自己的版本。我的模板可以和cookiecutter gh:MatthewWilkes/cookiecutter-simplepackage一起用。本节解释了创建您自己的自定义模板的过程。

我们将创建一个新的 git 存储库来保存模板。我们需要添加的第一件事是一个cookiecutter.json文件,如清单 6-1 所示。这个文件定义了我们将向用户询问的变量及其默认值。其中大多数都是简单的字符串,在这种情况下,会提示用户输入一个值或按 enter 键接受显示在括号中的默认值。通过将 Python 表达式括在大括号中,它们还可以包含来自早期条目的变量替换(这些条目可以是 Python 表达式),在这种情况下,这些替换的结果将用作默认值。最后,它们可以是一个列表,在这种情况下,用户会看到一个选项列表,并被要求选择一个,第一个项目是默认的。

{
    "full_name": "Advanced Python Development reader",
    "email": "example@advancedpython.dev",
    "project_name": "Example project",
    "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_').replace('-', '_') }}",
    "project_short_description": "An example project.",
    "version": "1.0.0",
    "open_source_license": ["BSD", "GPL", "Not open source"]
}

Listing 6-1cookiecutter.json

我们还需要创建一个目录,其中包含我们将要创建的模板。我们还可以使用大括号在文件名中包含用户提供的值,所以这个应该被称为{{ cookiecutter.project_slug }}来创建一个与project_slug值同名的目录。我们可以使用来自cookiecutter.json的任何值;但是,项目 slug 是最好的选择。这个目录将成为新项目的 git 存储库的根,因此它的名称应该与预期的存储库名称相匹配。

从这里,我们可以创建我们想要包含在这种类型的每个项目中的各种文件,比如构建文件(setup.pysetup.cfg)、文档(README.mdCHANGES.mdLICENCE)以及test/src/目录。

然而,有一个复杂的问题。该模板在src/中包含了一个{{ cookiecutter.project_slug }}/目录,这对于任何在其 slug 中不包含.的包都很好,但是如果我们正在创建apd.sensors,我们将会看到 cookiecutter 生成的内容与我们想要的内容之间的差异(图 6-1 )。

img/481001_1_En_6_Fig1_HTML.png

图 6-1

我们拥有的文件夹结构与我们需要的文件夹结构的比较

我们需要在目录结构中增加这一层,因为apd是一个名称空间包。当我们第一次创建apd.sensors时,我们决定apd将是一个名称空间,这允许我们在名称空间内创建多个包,条件是没有代码直接放在名称空间包中,只有它们包含的标准包。

我们在这里需要一些自定义行为,这超出了单独使用模板所能实现的范围。 3 我们需要识别 slug 中哪里有.,在这种情况下,拆分 slug 并为每个部分创建嵌套目录。Cookiecutter 通过使用后一代钩子来支持这个需求。在模板的根目录中,我们可以添加一个带有post_gen_project.py文件的 hooks 目录。预生成钩子,存储为钩子/ pre_gen_project.py,用于在生成开始前操作和验证用户输入;后生成钩子,存储为钩子/ post_gen_project.py,用于操作生成的输出。

钩子是 Python 文件,在适当的生成阶段直接执行。它们不需要提供任何重要的功能;代码可以是模块级的。Cookiecutter 首先将这个文件解释为一个模板,在它执行钩子代码之前,任何变量都会被替换。这种行为允许使用变量将数据直接插入到钩子的代码中(如清单 6-2 所示),而不是使用更常见的 API 来检索数据。

import os

package_name = "{{ cookiecutter.project_slug }}"
*namespaces, base_name = package_name.split(".")

if namespaces:
    # We need to create the namespace directories and rename the inner directory
    directory = "src"
    # Find the directory the template created: src/example.with.namespaces
    existing_inner_directory = os.path.join("src", package_name)

    # Create directories for namespaces: src/example/with/
    innermost_namespace_directory = os.path.join("src", *namespaces)
    os.mkdir(innermost_namespace_directory)

    # Rename the inner directory to the last component
    # and move it into the namespace directory
    os.rename(
        existing_inner_directory,
        os.path.join(innermost_namespace_directory, base_name)
    )

Listing 6-2hooks/post_gen_project.py

Note

*namespaces, base_name = package_name.split(".")行是扩展解包的一个例子。它与函数定义中的*args有相似的含义;base_name变量包含从package_name中分离出的最后一项,任何之前的项都存储为一个名为namespaces的列表。如果package_name中没有.字符,那么base_name将等于package_name,并且名称空间将是一个空列表。

使用我在这里创建的 cookiecutter 模板可以通过 GitHub helper 来完成,因为我已经将代码存储在 GitHub 中了。本章附带的代码中也提供了这一功能。cookiecutter 调用如下,其中gh:是 GitHub 助手前缀:

> cookiecutter gh:MatthewWilkes/cookiecutter-simplepackage

或者,您可以使用您的本地工作副本来测试调用

> cookiecutter ./cookiecutter-simplepackage

创建聚合包

我们现在可以使用 cookiecutter 模板为聚合过程创建一个包,名为apd.aggregation。切换到apd.code目录的父目录,但是不需要为聚合过程创建一个目录,因为我们的 cookiecutter 模板会这样做。我们调用 cookiecutter 生成器并填充我们想要的细节,然后可以用第一次提交中添加的生成文件在该目录中初始化一个新的 git 存储库。

生成 apd.aggregation 的控制台会话

> cookiecutter gh:MatthewWilkes/cookiecutter-simplepackage
full_name [Advanced Python Development reader]: Matthew Wilkes
email [example@advancedpython.dev]: matt@advancedpython.dev
project_name [Example project]: APD Sensor aggregator
project_slug [apd_sensor_aggregator]: apd.aggregation
project_short_description [An example project.]: A programme that queries apd.sensor endpoints and aggregates their results

.
version [1.0.0]:
Select license:
1 - BSD
2 - MIT
3 - Not open source
Choose from 1, 2, 3 (1, 2, 3) [1]:
> cd apd.aggregation
> git init
Initialized empty Git repository in /apd.aggregation/.git/
> git add .
> git commit -m "Generated from skeleton"

下一步是开始创建实用函数和附带的测试来收集数据。作为其中的一部分,我们必须对聚合过程的确切职责以及它所提供的特性做出一些决定。

我们希望从聚合过程中获得的特性的完整列表如下。在本书的过程中,我们不一定要构建所有这些特性,但是我们需要确保我们的设计不排除其中任何一个。

  • 按需从所有端点收集传感器的值

  • 以特定的时间间隔自动记录传感器的值

  • 调用在特定时间点为一个或多个端点记录的传感器数据

  • 调用一个或多个端点在某个时间范围内的传感器数据

  • 查找传感器值与某个条件(如某个范围内的最大值、最小值)相匹配的时间,可以是在所有时间内,也可以是在某个时间范围内

  • 支持所有传感器类型,无需修改服务器来存储数据

    • 要求传感器安装在服务器上进行分析是可以的,但不能检索数据。
  • 必须能够导出和导入兼容的数据,以实现数据可移植性和备份目的

  • 必须能够按时间或端点 4 删除数据

数据库类型

我们需要做的第一件事是决定数据应该如何存储在这个应用中。有许多数据库可用,涵盖了各种各样的特性集。开发人员经常根据当前的流行趋势选择特定的数据库,而不是对利弊进行冷静的分析。图 6-2 是一个决策树,它概括了我在决定使用什么类型的数据库时问自己的广泛问题。这只能帮助你找到一个广泛的数据库类别,而不是一个特定的软件,因为功能集变化很大。尽管如此,我相信在决定一种类型的数据库时问这些问题是有帮助的。

img/481001_1_En_6_Fig2_HTML.jpg

图 6-2

挑选一类数据库的决策树

我问自己的第一个问题是排除一些数据库技术的特例。这些都是有价值的技术,在它们特定的领域,它们是优秀的,但是它们相对来说是不经常需要的。这些是只附加的数据库——一旦写入,就不能(轻易)删除或编辑。这种数据库非常适合日志,比如事务日志或审计日志。区块链数据库和仅追加数据库之间的主要区别是信任;虽然在典型情况下两者都阻止编辑或删除数据,但是可以通过操作底层存储文件来编辑标准的仅追加数据库。区块链略有不同;它允许一组人共同充当维护者。只有在至少 50%的用户同意的情况下,才能编辑或删除数据。任何不同意的用户可以保留旧数据并离开该组。在撰写本文时,区块链是当今流行的数据库,但是它们不适用于几乎所有的应用。

图左侧的数据库类型更有用。它们是 SQL 和 NoSQL 数据库。NoSQL 数据库在 21 世纪初很流行。关系数据库后来采用了它们的一些特性作为扩展和额外的数据类型。是否使用 SQL 不是区分这些数据库类型的关键方法,而是它们是否是无模式的。这种区别类似于有和没有类型提示的 Python 无模式数据库允许用户添加任意形状的数据,而具有已定义模式的数据库验证数据以确保它符合数据库作者的期望。无模式数据库可能看起来更有吸引力,但它会使查询或迁移数据变得更加困难。如果不能保证存在哪些列以及它们的类型,就有可能存储看起来正确的数据,但在以后的开发中会出现问题。

例如,假设我们有一个温度日志表,其中存储了记录温度值的时间、记录该温度的传感器以及该值。该值很可能被声明为十进制数,但是如果传感器提供类似于"21.2c"而不是21.2的字符串,会发生什么呢?在实施模式的数据库中,这将引发错误,数据将无法插入。在无模式数据库中,如果检索到的数据集中存在这些格式不正确的条目之一,则插入会成功,但聚合数据的尝试(如计算平均值)会失败。与 Python 的类型提示一样,这并不能防止所有的错误,只是一种类型的错误。值70.2将被接受,因为它是一个有效的数字,尽管人类可以分辨出它是用华氏度而不是摄氏度来度量的。

我们需要考虑的最后一件事是如何查询数据。查询支持是这三个问题中最难概括的,因为数据库类别之间有很大的差异。人们通常认为关系数据库更适合查询,而 NoSQL 数据库更依赖自然键,比如对象存储中的路径或键/值存储中的键。然而,这过于简单化了。例如,SQLite 是一个关系数据库,但是与 PostgreSQL 等替代数据库相比,它的索引选项相对较少;Elasticsearch 是一个 NoSQL 数据库,设计用于索引和搜索的灵活性。

我们的例子

在我们的例子中,我们发现很难决定传感器值的单一类型,除了所有值都是 JSON 可序列化的这一事实。我们希望能够访问这种类型的内部,例如,温度值的大小或 IP 地址列表的长度。如果我们要用标准的关系数据库结构来构建它,我们将很难用一种面向未来的方式来表示这些选项。我们必须预先知道可能返回的不同类型的值来编写数据库结构。

更适合我们的是使用无模式数据库,让从 API 返回的传感器的 JSON 表示成为存储的数据。我们有一个保证,我们可以准确地恢复这些数据(假设我们有相同版本的传感器代码),并且找到一种表示它的方法没有任何困难。

这个问题把我们带到了决策树的最低决策点;我们现在需要考虑数据库中项目之间的关系。单个传感器值由于由相同的传感器类型生成、从相同的端点检索以及同时检索而与其他值相关。也就是说,传感器值通过传感器名称、端点 URL 和创建时间相关联。这些多维关系应该引导我们走向一个具有丰富索引和查询支持的数据库,因为它将帮助我们找到相关数据。我们还希望数据库具有良好的查询支持,因为我们希望能够从它们的值中找到记录,而不仅仅是传感器和时间。

这些需求引导我们使用关系数据库 和无模式支持选项。也就是说,我们应该强烈地考虑这样一个数据库,它的核心是关系型的,但支持实现无模式行为的类型。PostgreSQL 及其 JSONB 类型就是一个很好的例子。JSONB 用于以 JSON 格式 6 存储数据,并允许创建在其内部结构上工作的索引。

CREATE TABLE sensor_values(
    id SERIAL PRIMARY KEY,
    sensor_name TEXT NOT NULL,
    collected_at TIMESTAMP
    data JSONB
 )

这种格式平衡了固定模式数据库的一些优点,因为它是部分固定的。namecollected_at字段是固定列,但是剩余的数据字段是无模式字段。理论上,我们可以将 JSON 或任何其他序列化格式作为文本列存储在这个表中,但是使用 JSONB 字段允许我们编写查询和索引来检查这个值。

对象关系映射器

直接把 SQL 代码写成 Python 是完全可能的,但人们这样做的情况相对较少。数据库是复杂的野兽,SQL 因易受注入攻击而臭名昭著。完全抽象出单个数据库的特性是不可能的,但是确实有工具可以处理表创建、列映射和 SQL 生成。

Python 世界中最流行的是由 Michael Bayer 等人编写的 SQLAlchemy。SQLAlchemy 是一个非常灵活的对象关系映射器;它处理 SQL 语句和原生 Python 对象之间的转换,并且是以可扩展的方式完成的。另一个常用的 ORM 是 Django ORM,它不太灵活,但是提供了一个不太需要数据库工作原理的接口。一般来说,只有在 Django 项目中使用 Django ORM,否则 SQLAlchemy 是最合适的 ORM。

Note

SQLAlchemy 不附带类型提示;但是,有一个名为 sqlmypy 的 mypy 插件,它为 SQLAlchemy 提供提示,并教会 mypy 理解列定义所隐含的类型。我建议在使用类型检查的基于 SQLAlchemy 的项目中使用这种方法。本章附带的代码使用了这个插件。

首先,我们需要安装 SQLAlchemy 和一个数据库驱动程序。我们需要将SQLAlchemypsycopg2添加到setup.cfg中的install_requires部分,并使用命令行上的pipenv install -e .触发这些依赖关系进行重新评估。

用 SQLAlchemy 描述数据库结构有两种方式,经典和声明式。在经典风格中,实例化Table对象并将它们与现有的类相关联。在声明式风格中,您使用一个特定的基类(它引入了一个元类),然后您直接在面向用户的类上定义列。在大多数情况下,Python 风格的声明性方法使其成为自然的选择。

与前面相同的表,采用 SQLAlchemy 声明风格

import sqlalchemy
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP

Base = declarative_base()

class DataPoint(Base):
    __tablename__ = 'sensor_values'
    id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
    sensor_name = sqlalchemy.Column(sqlalchemy.String)
    collected_at = sqlalchemy.Column(TIMESTAMP)
    data = sqlalchemy.Column(JSONB)

然后,您可以使用 Python 代码编写查询,这会自动创建适当的 SQL。create_engine(...)函数用于从连接字符串创建数据库连接。设置echo=True可以通过,让你看到生成的 SQL。下一步是使用sessionmaker(...)创建一个函数,该函数允许您启动一个新的会话和事务,然后最终为数据库连接创建一个会话,如下所示:

>>> engine = sqlalchemy.create_engine("postgresql+psycopg2://apd@localhost/apd", echo=True)
>>> sm = sessionmaker(engine)
>>> Session = sm()
>>> Session.query(DataPoint).filter(DataPoint.sensor_name == "temperature").all()
INFO sqlalchemy.engine.base.Engine SELECT sensor_values.id AS sensor_values_id, sensor_values.sensor_name AS sensor_values_sensor_name, sensor_values.collected_at AS sensor_values_collected_at, sensor_values.data AS sensor_values_data
FROM sensor_values
WHERE sensor_values.sensor_name = %(sensor_name_1)s
INFO sqlalchemy.engine.base.Engine {'sensor_name_1': 'temperature'}
[]

Column Objects And Descriptors

我们在类中使用的列对象以一种不寻常的方式运行。当我们从类中访问一列时,比如DataPoint.sensor_name,我们得到一个特殊的对象来表示列本身。这些对象截取许多 Python 操作,并返回表示操作的占位符。如果没有这个拦截,DataPoint.sensor_name == "temperature"将被求值,filter(...)函数将等同于Session.query(DataPoint).filter(False).all()

DataPoint.sensor_name=="temperature"返回一个 BinaryExpression 对象。这个对象是不透明的,但是 SQL 模板(不包括常量值)可以用str(...)预览:

>>> str((DataPoint.sensor_name=="temperature"))                                 'sensor_values.sensor_name = :sensor_name_1'

表达式的隐含数据库类型存储在表达式结果的type属性中。在比较的情况下,总是Boolean

当对DataPoint类型的实例执行相同的表达式时,它不会保留任何特定于 SQL 的行为;该表达式正常计算对象的实际数据。SQLAlchemy 声明类的任何实例都像普通 Python 对象一样工作。

因此,开发人员可以使用相同的表达式来表示 Python 条件和 SQL 条件。

这是可能的,因为由DataPoint.sensor_name引用的对象是一个描述符。描述符是一个具有某种方法组合__get__(self, instance, owner)__set__(self, instance, value)__delete__(self, instance)的对象。

描述符允许实例属性的自定义行为,允许在类或实例上访问值时返回任意值,以及自定义设置或删除值时发生的事情。

下面是一个描述符示例,它在实例上的行为类似于普通的 Python 值,但在类上公开自己:

class ExampleDescriptor:

    def __set_name__(self, instance, name):
        self.name = name

    def __get__(self, instance, owner):
        print(f"{self}.__get__({instance}, {owner})")
        if not instance:
            # We were called on the class available as `owner`
            return self
        else:
            # We were called on the instance called `instance`
            if self.name in instance.__dict__:
                return instance.__dict__[self.name]
            else:
                raise AttributeError(self.name)

    def __set__(self, instance, value):
        print(f"{self}.__set__({instance}, {value})")
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        print(f"{self}.__delete__({instance}")
        del instance.__dict__[self.name]

class A:

    foo = ExampleDescriptor()

下面的控制台会话演示了前面的 get 方法的两个代码路径,以及设置和删除功能。

>>> A.foo
<ExampleDescriptor object at 0x03A93110>.__get__(None, <class 'A'>)
<ExampleDescriptor object at 0x03A93110>
>>> instance = A()
>>> instance.foo
<ExampleDescriptor object at 0x03A93110>.__get__(<A object at 0x01664090>, <class 'A'>)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".\exampledescriptor.py", line 16, in
    __get__raise AttributeError(self.name)
AttributeError: foo
>>> instance.foo = 1
<ExampleDescriptor object at 0x03A93110>.__set__(<A object at 0x01664090>, 1)
>>> instance.foo
<ExampleDescriptor object at 0x03A93110>.__get__(<A object at 0x01664090>, <class 'A'>)
1
>>> del instance.foo
<ExampleDescriptor object at 0x03A93110>.__delete__(<A object at 0x01664090>)

大多数时候,你需要一个描述符,这是为了产生一个计算结果的属性。这可以用@property装饰器更好地表达,它在幕后构造一个描述符。在只需要定制 get 功能的常见情况下,属性特别有用,但是它们也支持设置和删除的定制实现。

class A:

    @property
    def foo(self):
        return self._foo

    @foo.setter
    def foo(self, value):
        self._foo = value

    @foo.deleter
    def foo(self):
        del self._foo

一些核心 Python 特性是作为描述符实现的:它们是一种非常强大的方式,可以深入到核心对象逻辑中。在不了解它们的情况下,像@property@classmethoddecorator 这样的特性看起来就像是解释器专门寻找的魔法,而不是你可以自己编程的东西。

也就是说,虽然我经常使用@property装饰器,但我从来没有理由编写描述符。如果您发现自己在复制/粘贴属性定义,那么您可能需要考虑将它们的代码合并到一个描述符中。

版本化数据库

SQLAlchemy 中有一个函数可以创建数据库中定义的所有各种表、索引和约束。这将检查已经定义的表和列,并为它们生成匹配的数据库结构。

使用 SQLAlchemy 创建所有已定义的数据库表

engine = sqlalchemy.create_engine("postgresql+psycopg2://apd@localhost/apd", echo=True)
Base.metadata.create_all(engine)

这个功能一开始看起来很棒,但是非常有限。在完成一些性能测试后,您可能会在将来添加更多的表或列,或者至少添加更多的索引。create_all(...)函数创建了所有还不存在的东西,这意味着如果您重新运行create_all(...),任何被更改但之前存在的表都不会被更新。因此,依靠create_all(...)可能会导致数据库包含您期望的所有表,但不包含所有列。

为了解决这个问题,人们使用 SQL 迁移框架。Alembic 是 SQLAlchemy 最受欢迎的一个。它的工作方式是连接到数据库的一个实例,并生成使连接的数据库与代码中定义的数据库同步所需的操作。如果您使用的是 Django ORM,有一个内置的迁移框架,它通过分析所有过去的迁移并将分析的状态与代码的当前状态进行比较来工作。

这些框架允许我们对数据库进行更改,并相信这些更改会传播到实际部署中,而不管他们过去使用的是什么版本的软件。如果用户跳过一个或三个版本,这些版本之间的任何迁移也将运行。

为此,我们将 Alembic 添加到setup.cfg依赖项列表中,然后重新运行pipenv install -e .来刷新这些依赖项并安装 Alembic。然后我们使用alembic命令行工具来生成在我们的包中使用 Alembic 所需的文件。

> pipenv run alembic init src\apd\aggregation\alembic
Creating directory src\apd\aggregation\alembic ...  done
Creating directory src\apd\aggregation\alembic\versions ...  done
Generating alembic.ini ...  done
Generating src\apd\aggregation\alembic\env.py ...  done
Generating src\apd\aggregation\alembic\README ...  done
Generating src\apd\aggregation\alembic\script.py.mako ...  done
Please edit configuration/connection/logging settings in 'alembic.ini' before proceeding.

大多数文件都是在包内的alembic/目录中创建的。我们需要将文件放在这里,以便安装软件包的人可以访问它们;该层次结构之外的文件不会分发给最终用户。例外是alembic.ini,它提供日志和数据库连接配置。这些因最终用户而异,因此不能包含在软件包中。

我们需要修改生成的alembic.ini文件,主要是改变数据库 URI 以匹配我们正在使用的连接字符串。如果愿意,我们可以保留script_location=src/apd/aggregation/alembic的值,因为在这个开发环境中,我们使用的是apd.aggregation的可编辑安装,但是这个路径对于最终用户来说是无效的,所以我们应该将它改为引用一个已安装的包,并且我们应该在 readme 文件中包含一个最小的alembic.ini示例。

Caution

Alembic 脚本一般只适用于用户模型(依赖项有自己的配置和 ini 文件来迁移它们的模型)。用户从来没有一个有效的理由为他们的依赖关系中包含的模型生成新的迁移。另一方面,Django 的 ORM 同时处理用户模型和依赖关系,所以如果一个维护者发布了一个包的不完整版本,最终用户在生成他们自己的迁移时可能会无意中为它创建新的迁移。因此,检查迁移文件是否被正确提交和发布是非常重要的。当作为最终用户生成新的迁移时,您应该全面检查为您的代码创建的文件,而不是依赖项。

面向最终用户的最小 alembic.ini】

[alembic]
script_location = apd.aggregation:alembic
sqlalchemy.url = postgresql+psycopg2://apd@localhost/apd

我们还需要在包内定制生成的代码,从env.py文件开始。这个文件需要一个对我们之前在使用create_all(...)函数时看到的元数据对象的引用,因此它可以确定代码中模型的状态。它还包含连接数据库和生成代表迁移的 SQL 文件的函数。这些可以编辑,以允许定制数据库连接选项,以满足我们的项目需求。

我们需要更改target_metadata行,以使用模型使用的声明性Base类的元数据,如下所示:

from apd.aggregation.database import Base
target_metadata = Base.metadata

现在我们可以生成一个迁移来表示数据库的初始状态, 7 这个迁移创建了我们为支持 DataPoint 类而创建的datapoints表。

> pipenv run alembic revision --autogenerate -m "Create datapoints table"

修订命令在alembic/versions/目录中创建一个文件。名称的第一部分是随机生成的不透明标识符,但第二部分基于上面给出的消息。标志的存在意味着生成的文件不会是空的;它包含匹配代码当前状态所需的迁移操作。该文件基于一个模板,alembic/目录中的script.py.mako。这个模板是由 Alembic 自动添加的。虽然我们可以修改它,如果我们想,默认的一般是好的。改变这一点的主要原因是修改注释,也许是生成迁移时要检查的事情的清单。

在对该文件运行 black 并删除包含说明的注释后,它看起来像这样:

alem BIC/versions/6 D2 ea CD 5 da 3f _ create _ sensor _ values _ table . py

"""Create datapoints table

Revision ID: 6d2eacd5da3f
Revises: N/A
Create Date: 2019-09-29 13:43:21.242706

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "6d2eacd5da3f"
down_revision = None
branch_labels = None
depends_on = None

def upgrade():
    op.create_table(
        "datapoints",
        sa.Column("id", sa.Integer(), nullable=False),
        sa.Column("sensor_name", sa.String(), nullable=True),
        sa.Column("collected_at", postgresql.TIMESTAMP(), nullable=True),
        sa.Column("data", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
        sa.PrimaryKeyConstraint("id"),
    )

def downgrade():
    op.drop_table("datapoints")

Alembic 使用四个模块范围变量来确定迁移的运行顺序。这些不应该被改变。upgrade()downgrade()函数的主体是我们需要检查的,以确保它们做了我们期望的所有更改,并且只做了我们期望的更改。需要进行的最常见的更改是如果检测到不正确的更改,例如迁移更改了一个列,但是目标状态等于起始状态。例如,如果数据库备份恢复不正确,就会发生这种情况。

一个不太常见(但仍然常见)的问题是,有时 alembic 迁移包括从依赖项或用户代码中的其他地方引入代码的 import 语句,通常是在开发人员使用自定义列类型时。在这种情况下,迁移必须改变,因为迁移代码完全独立是很重要的。出于同样的原因,任何常量也应该复制到迁移文件中。

如果迁移导入外部代码,那么它的效果可能会随着外部代码的改变而改变。任何影响不完全确定的迁移都可能导致现实世界的数据库具有不一致的状态,这取决于迁移时哪个版本的依赖关系代码是可用的。

Example of a Migration Repeatability Issue

例如,考虑以下用于将用户表添加到软件中的部分迁移代码:

from example.database import UserStates

def upgrade():
    op.create_table(
        "user",
        sa.Column("id", sa.Integer(), nullable=False),
        sa.Column("username", sa.String(), nullable=False),
        sa.Column("status", sa.Enum(UserStates), nullable=False),
        ...
        sa.PrimaryKeyConstraint("id"),
    )

有一个状态字段,作为枚举字段,只能包含预选值。如果 1.0.0 版本的代码定义了UserStates = ["valid", "deleted"],那么 Enum 将被创建为有效选项。然而,1.1.0 版本可能会添加另一个状态,使UserStates = ["new", "valid", "deleted"]表示用户在登录之前必须验证他们的帐户。1.1.0 版还需要添加一个迁移,以将“new”作为有效类型添加到该枚举中。

如果用户安装了 1.0.0 版并运行了迁移,然后安装了 1.1.0 版并重新运行了迁移,则数据库将是正确的。但是,如果用户只是在 1.1.0 发布后才了解该软件,并且在安装了 1.1.0 的情况下运行了两次迁移,那么初始迁移将添加所有三个用户状态,而第二个迁移将无法添加已经存在的值。

作为开发人员,我们习惯了不应该重复代码的想法,因为这会导致可维护性问题,但是数据库迁移是个例外。您应该复制您需要的任何代码,以确保迁移的行为不会随着时间的推移而改变。

最后,有些变化模棱两可。如果我们要更改我们在这里创建的datapoints表的名称,Alembic 不清楚这是名称更改还是删除一个表并创建另一个恰好具有相同结构的表。Alembic 总是在删除和重新创建方面出错,所以如果想要重命名,但迁移没有改变,就会发生数据丢失。

Alembic 文档中提供了可用操作的详细信息,它提供了您可能需要的所有日常操作。操作插件可以提供新的操作类型,尤其是特定于数据库的操作。

Tip

当您对升级操作进行更改时,也应该对降级操作进行等效的更改。如果您不想支持从特定版本降级,您应该引发一个异常,而不是保留不正确的自动生成的迁移代码。对于非破坏性迁移,允许降级非常有用,因为它允许开发人员在特性分支之间切换时恢复数据库。

随着这个迁移的生成并提交到源代码控制中,我们可以运行迁移了,它为我们生成了这个数据点表。运行迁移是通过 alembic 命令行完成的,如下所示:

> alembic upgrade head

其他有用的 alembic 命令

有一些 Alembic 用户日常需要的子命令。这些因素如下:

  • alembic current

    • 显示连接的数据库的版本号。
  • alembic heads

    • 显示迁移集中的最新版本号。如果列出了多个版本,则需要合并迁移。
  • alembic merge heads

    • 创建一个新的迁移,它依赖于 alembic heads 列出的所有修订,确保它们都被执行。
  • alembic history

    • 显示了 Alembic 已知的所有迁移的列表。
  • alembic stamp <revisionid>

    • 用字母数字版本标识符替换<revisionid>,将现有数据库标记为该版本,而不运行任何迁移。
  • alembic upgrade <revisionid>

    • <revisionid>替换为要升级到的字母数字版本标识符。这可以对头部 8 进行最近一次修改。Alembic 跟踪修订历史,运行尚未执行的任何迁移的升级方法。
  • alembic downgrade <revisionid>

    • upgrade,但是目标修改更早,使用降级方式。根据我的经验,这在合并迁移中不如在直接迁移中有效,您应该知道降级并不等同于撤销。它不能还原已删除的列中的数据。

加载数据

现在我们已经定义了数据模型,可以开始从传感器加载数据了。我们将使用优秀的请求库通过 HTTP 来完成这项工作。支持将 HTTP 请求内置到 Python 中,但是 requests 库有更好的用户界面。我建议在所有情况下都使用基于标准库 HTTP 支持的请求。只有在使用依赖关系不切实际的情况下,才应该使用标准库的 HTTP 请求支持。

我们从传感器中提取数据所需的最底层构建块是一个函数,该函数给定端点的 API 细节,向 API 发出 HTTP 请求,解析结果,并为每个传感器创建DataPoint类实例。

从服务器添加数据点的功能

def get_data_points(server: str, api_key: t.Optional[str]) -> t.Iterable[DataPoint]:
    if not server.endswith("/"):
        server += "/"
    url = server + "v/2.0/sensors/"
    headers = {}
    if api_key:
        headers["X-API-KEY"] = api_key
    try:
        result = requests.get(url, headers=headers)
    except requests.ConnectionError as e:
        raise ValueError(f"Error connecting to {server}")
    now = datetime.datetime.now()
    if result.ok:
        for value in result.json()["sensors"]:
            yield DataPoint(
                sensor_name=value["id"], collected_at=now, data=value["value"]
            )
    else:
        raise ValueError(
            f"Error loading data from {server}: "
            + result.json().get("error", "Unknown")
        )

该函数连接到远程服务器,并返回每个传感器值的 DataPoint 对象。它还可以引发一个表示在试图读取数据时遇到错误的ValueError,并对所提供的 URL 执行一些基本的检查。

Yield and Return

我只是将get_data_points()函数描述为返回数据点对象的*,但这并不完全正确。它使用 yield 关键字,而不是 return。我们在第五章中简要地看到了这一点,当时编写了一个 WSGI 应用,它返回部分响应,中间有一个延迟。*

yield语句使它成为一个生成器函数。生成器是一个延迟求值的可迭代值。它可以产生零个或多个值,甚至无穷多个值。生成器只生成调用者请求的项,不像普通函数在第一个项对调用者可用之前计算完整的返回值。

构建简单生成器的最简单方法是使用生成器表达式,如果您熟悉列表、集合和字典理解,它看起来就像您想象的元组理解。

>>> [item for item in range(10)]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> (item for item in range(10))
<generator object <genexpr> at 0x01B58EB0>

这些生成器表达式不能像列表一样进行索引,您只能从中请求下一项:

>>> a=(item for item in range(10))
>>> a[0]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'generator' object is not subscriptable
>>> next(a)
0
>>> next(a)
1
...
>>> next(a)
8
>>> next(a)
9
>>> next(a)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration

也可以使用list(a)语法将它们转换成列表或元组(只要它们不包含无限多的条目);然而,这考虑到了他们的状态。如果您已经从生成器中提取了一些或所有的项目,那么list(a)的结果将只包含那些剩余的项目。

发电机功能

前面的例子是生成器表达式,但是get_data_points()是生成器函数。它们使用yield关键字来指定下一个值应该是什么,然后暂停执行,直到用户请求下一个值。Python 记住了函数的状态;当请求下一项时,它从 yield 语句的点继续。

这非常有用,因为有些函数需要很长时间来生成每个后续值。另一种方法是创建一个函数,您需要指定想要生成的项目数量,但是生成器模型允许您在决定是否需要更多项目之前检查返回的项目。

考虑以下发生器函数:

def generator() -> t.Iterable[int]:
    print("Stating")
    yield 1
    print("Part way")
    yield 2
    print("Done")

这里,print(...)代表更复杂的代码,可能连接到外部服务或复杂的算法。如果我们将这个生成器强制转换为一个元组,那么在我们得到结果之前,所有的打印都会发生:

>>> tuple(generator())
Stating
Part way
Done
(1, 2)

但是,如果我们逐个使用这些项目,我们可以看到在返回的值之间执行了yield语句之间的代码:

>>> for num in generator():
...   print(num)
...
Stating
1
Part way
2
Done

何时使用它们

有时可能不清楚最好使用生成器还是普通函数。任何只是单独生成数据的函数都可以是生成器函数或标准函数,但是对数据执行操作(比如向数据库添加数据点)的函数必须确保消耗迭代器。

通常所说的经验法则是,这样的函数应该返回值,而不是产生值,但是任何导致整个迭代器被求值的模式都可以。另一种方法是循环所有项目:

def add_to_session(session)
    for item in generator:
        session.add(item)

或者通过将生成器转换成具体的列表或元组类型:

def add_to_session(session)
    session.add_all(tuple(generator))

然而,如果在前面的函数中有一个yield语句,那么它们就不会像预期的那样工作。前面的两个函数都可以用add_to_session(generator)调用,生成器生成的所有项目都将被添加到会话中。如果以同样的方式调用以下内容,将不会向会话中添加任何项目:

def add_to_session(session)
    for item in generator:
        session.add(item)
        yield item

如果有疑问,请使用标准函数,而不是生成器函数。无论哪种方式,请确保您测试了您的函数的行为是否符合预期。

Exercise 6-1: Practice With Generators

编写一个生成器函数,从单个传感器提供无限量的数据点。您应该在您构建的DataPoint实例上使用yield,并在使用time.sleep(...)函数的采样之间等待一秒钟。

一旦编写了这个函数,就应该循环遍历它的值,以查看传感器被查询时数据的突发情况。您还应该尝试使用标准库的filter(function, iterable)函数来查找特定传感器的值。

本章附带的代码中提供了一个实现示例。

这个函数是一个很好的开始:它提供了我们可以迭代的包含DataPoint对象的东西,但是我们需要创建一个数据库连接,将它们添加到一个会话中,然后提交那个会话。为此,我定义了两个助手函数(如清单 6-3 所示),一个函数给定一个数据库会话和服务器信息,从每个服务器获取所有数据点,并调用session.add(point)将它们添加到当前数据库事务中。第二个是作为一个独立的数据收集功能。它建立会话,调用add_data_from_sensors(...),然后将会话提交给数据库。我还创建了另一个基于单击的命令行工具来执行这些操作,允许在命令行上传递参数。

def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.Iterable[DataPoint]:
    points: t.List[DataPoint] = []
    for server in servers:
        for point in get_data_points(server, api_key):
            session.add(point)
            points.append(point)
    return points

def standalone(
    db_uri: str, servers: t.Tuple[str], api_key: t.Optional[str], echo: bool = False
) -> None:
    engine = sqlalchemy.create_engine(db_uri, echo=echo)
    sm = sessionmaker(engine)
    Session = sm()
    add_data_from_sensors(Session, servers, api_key)
    Session.commit()

Listing 6-3Helper functions in collect.py

点击 cli.py 中的 input point

@click.command()
@click.argument("server", nargs=-1)
@click.option(
    "--db",
    metavar="<CONNECTION_STRING>",
    default="postgresql+psycopg2://localhost/apd",
    help="The connection string to a PostgreSQL database",
    envvar="APD_DB_URI",
)
@click.option("--api-key", metavar="<KEY>", envvar="APD_API_KEY")
@click.option(
    "--tolerate-failures",
    "-f",
    help="If provided, failure to retrieve some sensors' data will not " "abort the collection process",
    is_flag=True,
)
@click.option("-v", "--verbose", is_flag=True, help="Enables verbose mode")
def collect_sensor_data(
    db: str, server: t.Tuple[str], api_key: str, tolerate_failures: bool, verbose: bool
):
    """This loads data from one or more sensors into the specified database.

    Only PostgreSQL databases are supported, as the column definitions use
    multiple pg specific features. The database must already exist and be
    populated with the required tables.

    The --api-key option is used to specify the access token for the sensors
    being queried.

    You may specify any number of servers, the variable should be the full URL
    to the sensor's HTTP interface, not including the /v/2.0 portion. Multiple
    URLs should be separated with a space.
    """
    if tolerate_failures:
        attempts = [(s,) for s in server]
    else:
        attempts = [server]
    success = True
    for attempt in attempts:
        try:
            standalone(db, attempt, api_key, echo=verbose)
        except ValueError as e:
            click.secho(str(e), err=True, fg="red")
            success = False
    return success

这个示例使用了 click 的更多特性,包括 click 命令上的文档字符串作为命令的帮助向最终用户公开。帮助文本大大增加了函数的长度,但是在带有语法突出显示的代码编辑器中,帮助文本就不那么冗长了。这在用户使用--help标志时公开,如下所示:

> pipenv run collect_sensor_data --help
Usage: collect_sensor_data [OPTIONS] [SERVER]...

  This loads data from one or more sensors into the specified database

.

  Only PostgreSQL databases are supported, as the column definitions use
  multiple pg specific features. The database must already exist and be
  populated with the required tables.

  The --api-key option is used to specify the access token for the sensors
  being queried.

  You may specify any number of servers, the variable should be the full URL
  to the sensor's HTTP interface, not including the /v/2.0 portion. Multiple
  URLs should be separated with a space.

Options:
  --db <CONNECTION_STRING>  The connection string to a PostgreSQL database
  --api-key <KEY>
  -f, --tolerate-failures   If provided, failure to retrieve some sensors'
                            data will not abort the collection process

  -v, --verbose             Enables verbose mode
  --help                    Show this message and exit.

然后,我们第一次使用@click.argument。我们用它来收集函数的参数,而不是带有相关值的选项。这个参数的nargs=-1选项声明我们接受任意数量的参数,而不是特定的数量(通常是1)。因此,该命令可以作为collect_sensor_data http://localhost:8000/(仅从本地主机收集数据)、作为collect_sensor_data http://one:8000/ http://two:8000/(从两个服务器收集数据),甚至作为collect_sensor_data(不收集数据,但会隐式测试数据库连接)来调用。

--api-key--verbose选项可能不需要任何解释,但是--tolerate-failures选项是我们可能没有考虑到的。如果没有这个选项及其支持代码,我们将对所有传感器位置运行standalone(...)函数,但是如果其中一个失败,整个脚本都会失败。此选项允许用户指定在指定了多个服务器的情况下,任何成功的服务器都会保存其数据,而失败的传感器会被忽略。代码通过使用该选项来决定是否应该从[(" http://one:8000/ ", " http://two:8000/ ")][(" http://one:8000/ ", ), (" http://two:8000/ ", )]下载数据来实现这一点。这个命令的代码在正常情况下将所有服务器传递给standalone(...),但是如果添加了--tolerate-failures,那么对于每个服务器 URL,将有一个对standalone(...)的调用。这是一个非常方便的特性,但是如果我自己使用这个命令的话,我会喜欢这个特性。

最后,支持功能相对简单。add_data_from_sensors(...)函数包装现有的get_data_points(...)函数,并在其返回的每个数据点上调用session.add(...)。然后它将这些作为返回值传递给调用者,但作为一个列表而不是一个生成器。当我们遍历生成器时,它确保了迭代器被完全消耗。对add_data_from_sensors(...)的调用可以访问DataPoint对象,但是它们没有义务遍历这些对象来使用生成器。

Caution

喜欢函数式编码风格的开发人员有时会陷入一个陷阱。他们可能想用类似于map(Session.add, items)的东西来代替这个功能。map 函数创建了一个生成器,因此需要消耗它才能产生效果。这样做可能会引入一些微妙的错误,比如只有在启用了 verbose 标志的情况下代码才起作用,这会导致 iterable 被日志记录语句消耗掉。

Do not use

map(...) 如果你对项目调用的函数有任何副作用,比如用数据库会话注册对象。总是使用循环来代替;它更加清晰,并且没有对后续代码施加任何义务来确保生成器被消耗掉。

新技术

我们已经略微谈到了一些非常常用的技术。我建议花点时间去理解我们在这一章中对它们的使用所做的所有决定。为此,我在下文中简要概述了我的建议。

数据库

选择一个与你需要处理的数据相匹配的数据库,而不是当前流行的数据库。有些数据库,比如 PostgreSQL,是很好的默认选择,因为它们提供了很大的灵活性,但是灵活性是以复杂性为代价的。

如果您使用基于 SQL 的数据库,请使用 ORM 和迁移框架。除了极端的情况,它们比编写自己的定制 SQL 更好地为您服务。但是,不要误以为 ORM 会保护你不了解数据库。它简化了界面,但是如果您试图在不了解数据库需求的情况下与数据库进行交互,您将会遇到困难。

自定义属性行为

如果您需要一个行为类似于计算属性的东西,也就是说,一个行为类似于对象上的属性但实际上从其他来源构建其值的东西,@property是最好的方法。对于一次性的值包装器来说也是如此,其中数据被修改或重新格式化。在这种情况下,应该使用带有 setter 的属性。

如果您正在编写一个要在代码库中多次使用的行为(尤其是如果您正在构建一个供他人使用的框架),描述符通常是一个更好的选择。您可以用属性做的任何事情都可以用自定义描述符来完成,但是您应该更喜欢属性,因为它们看起来更容易理解。如果你创建了一个行为,你应该小心确保它不会偏离其他开发者从 Python 代码中期望的行为太远。

发电机

生成器适用于希望提供无限(或非常长)的值流进行循环的情况。如果生成器的用户不需要保存所有先前值的记录,则可以使用它们来减少内存消耗。这种优势也可能是它们最大的缺点:除非消耗掉整个生成器,否则不能保证生成器函数中的代码能够执行。

不要使用生成器,除非是在需要生成一个只读取一次的项目列表的函数中,在生成过程预计会很慢的情况下,以及在您不确定调用者是否需要处理所有项目的情况下。

摘要

在这一章中我们已经做了很多:我们已经创建了一个新的包,引入了 ORM 和迁移框架,并且在幕后窥视了一些 Python 解释器用来确定当你访问一个对象的属性时会发生什么的深层魔法。我们还有一个有效的聚合流程,可以从每个传感器中提取数据并存储起来以备后用。

在下一章中,当我们看到如何在 Python 中实现异步编程以及何时异步编程是解决问题的合适方法时,我们将更深入地研究yield功能的复杂用法。

额外资源

我建议查看以下资源,以了解更多关于我们在本章中使用的技术。像往常一样,请随意阅读那些你感兴趣的或者与你的工作相关的内容。

Footnotes 1

Cookiecutter 有相当多的依赖项。到目前为止,在我们安装的所有系统工具中,这是我最想隔离的一个。您可以使用 pipenv 为 cookiecutter 创建一个环境,并将与该环境相关联的bin/(或者,在 Windows 上,Scripts/)目录添加到您的系统路径中(运行pipenv --venv来找到它)。如果您的系统 Python 环境是一个非常旧的版本,您可能也需要这样做。

  2

还有流行的 Git 托管平台的帮助者,比如 GitHub。例如,gh:MatthewWilkes/cookiecutter-simplepackage将引用我的 GitHub 帐户上的cookiecutter-simplepackage存储库。

  3

仅仅使用一个模板是有可能实现这一点的,但前提是该模板特定于所使用的嵌套名称空间包的数量。

  4

导出和删除选项对于传感器与公众共处一地的任何部署都特别重要,例如 2004 年在阿姆斯特丹周围的家庭中使用噪声传感器来监控飞机噪声。我们以尊重用户和公众隐私的方式开发软件,这一点很重要。

  5

当人们谈论数据的形状时,他们指的是数据类型的结构。例如,{“foo”: 2}{“bar”: 99}形状相同,但与[“foo”, 2]和{ ”foo”: “2”}不同。

  6

JSON 有两种格式,JSONJSONBJSONB在加载数据时解析 JSON,但是JSON稍微宽容一些。如果您需要存储包含重复键、有意义的空白或有意义的键排序的 JSON,您应该使用JSON类型而不是JSONB。如果您不打算在 JSON 数据中搜索,那么使用JSONB的开销可能不值得。

  7

我们可以用本章开头提到的Base.metadata.create_all(engine)命令生成初始状态,但这只是因为当前状态也是初始状态。如果我们做了任何改变,那么create_all(...)将不再产生初始状态。将它放在初始迁移中意味着用户总是可以通过升级到数据库的最新版本来设置数据库。

  8

使用heads也将升级到最新版本,但是它将遵循任何分支路径,而不是要求它们被合并。我建议不要使用这个功能,而是确保合并任何分叉的迁移。

 

七、并行和异步

开发人员发现自己面临的一个常见问题是,他们有一个花费大量时间等待某事发生的操作,以及其他不依赖于第一个操作结果的操作。当程序正在做其他事情时,等待缓慢的操作完成可能会令人沮丧。这是异步编程试图解决的基本问题。

这个问题在 IO 操作(如网络请求)期间变得最为明显。在我们的聚合过程中,我们有一个循环,它向各个端点发出 HTTP 请求,然后处理结果。这些 HTTP 请求可能需要一些时间来完成,因为它们通常涉及检查外部传感器并在几秒钟内查看值。如果每个请求需要 3 秒钟才能完成,那么检查 100 个传感器将意味着在所有处理时间之外还要等待 5 分钟。

另一种方法是我们将程序的某些方面并行化。并行化最自然的功能是涉及等待某个外部系统的步骤。如图 7-2 所示,如果图 7-1 中的三个等待步骤能够并行化,将会节省大量时间。

img/481001_1_En_7_Fig2_HTML.png

图 7-2

并行等待的逐步过程,解析不一定按顺序进行

img/481001_1_En_7_Fig1_HTML.png

图 7-1

连接到三台传感器服务器并下载其数据的分步过程

当然,计算机对一次可以处理多少网络请求有实际的限制。任何将文件复制到外部硬盘的人都知道,一些存储介质比并行存储介质更适合处理多个顺序访问。最适合并行编程的情况是在需要执行的 IO 绑定和 CPU 绑定操作之间达到平衡的时候。如果强调 CPU 限制,唯一可能的速度提升就是提交更多的资源。另一方面,如果有太多的 IO 发生,我们可能不得不限制并发任务的数量,以避免处理任务的积压。

非阻塞 IO

用 Python 编写异步函数的最简单的方法,也是长久以来一直可行的方法,是编写使用非阻塞 IO 操作的函数。非阻塞 IO 操作是标准 IO 操作的变体,它在操作开始时立即返回,而不是在操作完成时返回的正常行为。

一些库可能将这些用于底层操作,比如从套接字读取,但是它们很少用于更复杂的设置或者被大多数 Python 开发人员使用。没有广泛使用的库允许开发人员利用 HTTP 请求的非阻塞 IO,所以我不能推荐它作为管理 web 服务器同时连接问题的实用解决方案。不过,这是一种在 Python 2 时代更常用的技术,看起来很有趣,因为它有助于我们理解更现代的解决方案的优点和缺点。

我们将在这里查看一个示例实现,以便我们可以看到代码必须如何构造才能利用这一点的差异。实现依赖于标准库的select.select(...)函数,它是select(2)系统调用的包装器。当给定一个类似文件的对象列表(包括套接字和子进程调用)时,select返回那些准备好读取数据的对象, 1 或阻塞,直到至少有一个准备好。

select代表异步代码的关键思想,即我们可以并行等待多件事情,但有一个处理阻塞的函数,直到一些数据准备好。阻塞行为从依次等待每个任务变为等待多个并发请求中的第一个。非阻塞 IO 进程的关键是一个阻塞的函数,这似乎有悖常理,但其目的并不是完全消除阻塞,而是将阻塞转移到我们没有其他事情可做的时候。

堵不是坏事;这使得我们的代码有一个易于理解的执行流程。如果没有连接就绪时select(...)没有阻塞,我们就必须引入一个循环来重复调用select(...),直到连接就绪。立即阻塞的代码更容易理解,因为它不必处理变量是尚未准备好的未来结果的占位符的情况。select 方法通过将阻塞推迟到稍后的时间点来牺牲程序流中的一些天真的清晰性,但是它允许我们利用并行等待。

Caution

以下示例函数非常乐观;它们不是符合标准的 HTTP 函数,并且它们对服务器的行为做了许多假设。这是故意的;它们在这里是为了说明一种方法,而不是推荐在现实世界中使用的代码。对于教学和比较目的来说,它足够好,仅此而已。

清单 7-1 显示了一个程序的例子,它发出一些非阻塞 IO HTTP 请求。我们代码的 HTTP 处理和这个示例最显著的区别是增加了两个额外的函数——执行 HTTP 请求和响应动作的函数。像这样分割逻辑使得这种方法没有吸引力,但是重要的是要记住在请求包中有这些函数的等价物;我们在这里看到它们只是因为我们在寻找一个没有库可以依靠的方法。

import datetime
import io
import json
import select
import socket
import typing as t
import urllib.parse

import h11

def get_http(uri: str, headers: t.Dict[str, str]) -> socket.socket:
    """Given a URI and a set of headers, make a HTTP request and return the
    underlying socket. If there were a production-quality implementation of
    nonblocking HTTP this function would be replaced with the relevant one
    from that library."""
    parsed = urllib.parse.urlparse(uri)
    if parsed.port:
        port = parsed.port
    else:
        port = 80
    headers["Host"] = parsed.netloc
    sock = socket.socket()
    sock.connect((parsed.hostname, port))
    sock.setblocking(False)

    connection = h11.Connection(h11.CLIENT)
    request = h11.Request(method="GET", target=parsed.path, headers=headers.items())

    sock.send(connection.send(request))
    sock.send(connection.send(h11.EndOfMessage()))
    return sock

def read_from_socket(sock: socket.socket) -> str:
    """ If there were a production-quality implementation of nonblocking HTTP
    this function would be replaced with the relevant one to get the body of
    the response if it was a success or error otherwise. """
    data = sock.recv(1000000)
    connection = h11.Connection(h11.CLIENT)
    connection.receive_data(data)

    response = connection.next_event()
    headers = dict(response.headers)
    body = connection.next_event()
    eom = connection.next_event()

    try:
        if response.status_code == 200:
            return body.data.decode("utf-8")
        else

:
            raise ValueError("Bad response")
    finally:
        sock.close()

def show_responses(uris: t.Tuple[str]) -> None:
    sockets = []
    for uri in uris:
        print(f"Making request to {uri}")
        sockets.append(get_http(uri, {}))
    while sockets:
        readable, writable, exceptional = select.select(sockets, [], [])
        print(f"{ len(readable) } socket(s) ready")
        for request in readable:
            print(f"Reading from socket")
            response = read_from_socket(request)
            print(f"Got { len(response) } bytes")
            sockets.remove(request)

if __name__ == "__main__":
    show_responses([
        "http://jsonplaceholder.typicode.com/posts?userId=1",
        "http://jsonplaceholder.typicode.com/posts?userId=5",
        "http://jsonplaceholder.typicode.com/posts?userId=8",
    ])

Listing 7-1Optimistic nonblocking HTTP functions – nbioexample.py

使用 Python 解释器运行该文件的结果将是获取这三个 URL,然后在它们的数据可用时读取它们,如下所示:

> pipenv run python .\nbioexample.py
Making request to http://jsonplaceholder.typicode.com/posts?userId=1
Making request to http://jsonplaceholder.typicode.com/posts?userId=5
Making request to http://jsonplaceholder.typicode.com/posts?userId=8
1 socket(s) ready
Reading from socket
Got 27520 bytes
1 socket(s) ready
Reading from socket
Got 3707 bytes
1 socket(s) ready
Reading from socket
Got 2255 bytes

get_http(...)函数是创建套接字的函数。它解析提供给它的 URL,并设置一个 TCP/IP 套接字来连接到该服务器。这确实涉及到一些阻塞 IO,特别是任何 DNS 查找和套接字设置操作,但是与等待主体的时间相比,这些相对较短,所以我没有试图使它们成为非阻塞的。

然后,该函数将这个套接字设置为非阻塞,并使用h11库生成一个 HTTP 请求。仅仅通过字符串操作生成 HTTP 请求 2 是完全可能的,但是这个库极大地简化了我们的代码。

一旦套接字上有可用的数据,我们就调用read_from_socket(...)函数。它假设数据少于 1000000 字节,并且表示一个完整的响应, 3 然后使用h11库将其解析为表示响应的头和主体的对象。我们用它来确定请求是否成功,并返回响应的主体或引发一个ValueError。数据被解码为 UTF-8,因为那是 Flask 在另一端为我们生成的。用正确的字符集解码是很重要的;这可以通过提供一个定义了字符集的头来实现,也可以通过其他一些关于字符集的保证来实现。由于我们还编写了服务器代码,我们知道我们正在使用 Flask 的内置 JSON 支持,它使用 Flask 的默认编码,即 UTF-8。

Tip

在某些情况下,您可能不确定使用的是哪种字符编码。chardet 库分析文本以建议最可能的编码,但这并不是万无一失的。该库或类似具有多种编码的 try/except 块的后备库仅适用于从不一致且不报告其编码的源加载数据的情况。在大多数情况下,您应该能够指定准确的编码,并且您必须这样做以避免细微的错误。

使我们的代码不阻塞

为了将前面的函数集成到我们的代码库中,我们代码中的其他函数需要一些更改,如清单 7-2 所示。现有的get_data_points(...)功能将需要分成connect_to_server(...)prepare_datapoints_from_response(...)功能。因此,我们将 socket 对象暴露给add_data_from_sensors(...)函数,允许它使用select,而不仅仅是在每个服务器上循环。

def connect_to_server(server: str, api_key: t.Optional[str]) -> socket.socket:
    if not server.endswith("/"):
        server += "/"
    url = server + "v/2.0/sensors/"
    headers = {}
    if api_key:
        headers["X-API-KEY"] = api_key

    return get_http(url, headers=headers)

def prepare_datapoints_from_response(response: str) -> t.Iterator[DataPoint]:
    now = datetime.datetime.now()
    json_result = json.loads(response)
    if "sensors" in json_result:
        for value in json_result["sensors"]:
            yield DataPoint(
                sensor_name=value["id"], collected_at=now, data=value["value"]
            )
    else:
        raise ValueError(
            f"Error loading data from stream: " + json_result.get("error", "Unknown")
        )

def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.Iterable[DataPoint]:
    points: t.List[DataPoint] = []
    sockets = [connect_to_server(server, api_key) for server in servers]
    while sockets:
        readable, writable, exceptional = select.select(sockets, [], [])
        for request in readable:
            # In a production quality implementation there would be
            # handling here for responses that have only partially been
            # received.
            value = read_from_socket(request)
            for point in prepare_datapoints_from_response(value):
                session.add(point)
                points.append(point)
            sockets.remove(request)
    return points

Listing 7-2Additional glue functions

这听起来可能微不足道,但这是决定不在生产代码中使用这种 HTTP 请求方法的充分理由。在我看来,如果没有一个库来简化 API,那么使用非阻塞套接字所增加的认知负荷是过多的。理想的方法是不对程序流引入任何更改,但是最小化更改有助于保持代码的可维护性。这种实现将原始套接字泄漏到应用函数中的事实是不可接受的。

总的来说,虽然这种方法确实减少了等待时间,但是它要求我们对代码进行重大的重构,并且它只在等待步骤中提供了节省,而不是在解析阶段。非阻塞 IO 是一种有趣的技术,但是它只适用于例外情况,并且需要对程序流进行重大修改,以及放弃所有公共库来实现最基本的结果。我不推荐这种做法。

多线程和多重处理

更常见的方法是将工作负载分成多个线程或进程。线程允许同时处理逻辑子问题。它们可能是 CPU 受限的,也可能是 IO 受限的。在这个模型中,一组结果的解析可能发生在等待另一组结果之前,因为整个检索过程被分成一个新的线程。每个任务都是并行运行的,但是在一个线程中,所有的事情都是顺序运行的(如图 7-3 所示),函数照常阻塞。

img/481001_1_En_7_Fig3_HTML.png

图 7-3

使用线程或多个进程时的并行任务

一个线程中的代码总是按顺序执行,但是当多个线程同时运行时,不能保证它们的执行以任何有意义的方式同步。更糟糕的是,不能保证不同线程中的代码执行与语句边界对齐。当两个线程访问同一个变量时,不能保证先执行动作:它们可能会重叠。Python 用来执行用户函数的内部低级“字节码”是 Python 中并行性的构建块,而不是语句。

低级线程

Python 中线程的最底层接口是threading.Thread对象,它有效地将函数调用包装到一个新线程中。线程的动作可以通过传递一个函数作为target=参数或者通过子类化threading.Thread并定义一个run()方法来定制,如表 7-1 所示。

表 7-1

为线程执行提供代码的两种方法

| `import threading``def helloworld():``print("Hello world!")``thread = threading.Thread(``target=helloworld,``name="helloworld"``)``thread.start()``thread.join()` | `import threading``class HelloWorldThread(threading.Thread):``def run(self):``print("Hello world!")``thread = HelloWorldThread(name="helloworld")``thread.start()``thread.join()` |

start()方法开始执行线程;join()方法阻塞执行,直到该线程完成。name参数主要用于调试性能问题,但是如果您曾经手动创建过线程,那么总是设置一个名称是一个好习惯。

线程没有返回值,所以如果它们需要返回一个计算出的值,这可能会很棘手。传回值的一种方式是使用一个可变对象,它可以在适当的位置改变,或者,如果使用子类方法,在线程对象上设置一个属性。

当只有一个简单的返回类型时,线程对象上的属性是一个很好的方法,比如一个布尔成功值,或者一个计算的结果。当线程在做一件不连续的工作时,这是一个很好的选择。

当您有多个线程时,可变对象是最合适的,每个线程处理一个常见问题的一部分,例如,从一组 URL 收集传感器数据,每个线程负责一个 URL。这个对象非常适合这个目的。

Exercise 7-1: Write a Wrapper To Return via a Queue

与其直接调整函数,不如编写一些代码来包装任意函数,并将其结果存储在一个队列中,而不是直接返回,以允许函数像线程一样干净地运行。如果你卡住了,回头看看第五章和如何写一个接受参数的装饰器。

函数return_via_queue(...)应该如下所示:

from __future__ import annotations
...

def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.Iterable[DataPoint]:
    points: t.List[DataPoint] = []
    q: queue.Queue[t.List[DataPoint]] = queue.Queue()
    wrap = return_via_queue(q)
    threads = [
        threading.Thread(target=wrap(get_data_points), args=(server, api_key))
        for server in servers
    ]
    for thread in threads:
        # Start all threads
        thread.start()
    for thread in threads:
        # Wait for all threads to finish
        thread.join()
    while not q.empty():
        # So long as there's a return value in the queue, process one # thread's results
        found = q.get_nowait()
        for point in found:
            session.add(point)
            points.append(point)
    return points

您还必须调整get_data_points(...)函数来返回一列DataPoint对象,而不是它们的迭代器,或者在包装器函数中进行等效的转换。这是为了确保在线程将数据返回给主线程之前,所有数据都在线程中得到处理。因为生成器直到值被请求时才产生它们的值,所以我们需要确保请求发生在线程内。

本章的代码示例中提供了包装器方法的示例实现和该程序的简单线程版本。

关于 __ 未来 _ _ 进口的说明

from __future__ import example这样的语句是启用特性的方式,这些特性将成为 Python 未来版本的一部分。它们必须在 Python 文件的最顶端,前面没有其他语句。

在这种情况下,行q: queue.Queue[t.List[DataPoint]] = queue.Queue()就是问题。标准库中的queue.Queue对象不是 Python 3.8 中的泛型类型,所以它不能接受它所包含的对象类型的类型定义。这一遗漏在 Python 中被跟踪为 bug 33315,这里有理由不愿意添加新的typing.Queue类型或调整内置类型。

尽管如此,mypy 将queue.Queue视为泛型类型;只是 Python 解释器没有。有两种方法可以解决这个问题,一种是使用基于字符串的类型提示,这样 Python 解释器就不会试图计算queue.Queue[...]而失败

    q: "queue.Queue[t.List[DataPoint]]" = queue.Queue()

或者通过使用来自__future__annotations选项,启用为 Python 4 计划的类型注释解析逻辑。这个逻辑阻止 Python 在运行时解析注释,并且是前面的示例中采用的方法。

这种低级别的线程对用户来说一点也不友好。正如我们在前面的练习中看到的,可以编写一个包装器代码,使函数在线程环境中不变地工作。也可以为threading.Thread对象编写一个包装器,自动包装被调用的函数,并自动从内部队列中检索结果,然后无缝地返回给程序员。

幸运的是,我们不必在生产代码中编写这样的特性;Python 标准库中内置了一个助手:concurrent.futures.ThreadPoolExecutorThreadPoolExecutor管理使用中的线程数量,允许程序员限制一次执行的线程数量。

使用ThreadPoolExecutor对一个 hello world 线程的等效调用是

from concurrent.futures import ThreadPoolExecutor

def helloworld():
    print("Hello world!")

with ThreadPoolExecutor() as pool:
    pool.submit(helloworld)

这里,我们看到一个上下文管理器,它定义了线程池活动的时间段。由于没有将max_threads参数传递给执行器,Python 根据运行程序的计算机上可用的 CPU 数量来选择线程数量。

一旦进入上下文管理器,程序就向线程池提交函数调用。可以多次调用pool.submit(...)函数来调度额外的任务,其结果是一个表示该任务的Future对象。使用过现代 JavaScript 的开发人员对未来会非常熟悉;它们是代表将来某个时刻会出现的值(或错误)的对象。result()方法返回提交的函数返回的任何值。如果该函数引发了一个异常,那么当调用result()方法时也会引发相同的异常。

from concurrent.futures import ThreadPoolExecutor

def calculate():
    return 2**16

with ThreadPoolExecutor() as pool:
    task = pool.submit(calculate)

>>> print(task.result())
65536

Caution

如果不访问未来的 result()方法,那么它引发的任何异常都不会传播到主线程。这可能会使调试变得困难,所以最好确保您总是能够访问结果,即使您从未将它赋给变量。

如果在with块中调用了result(),执行将会阻塞,直到相关任务完成。当with块结束时,执行会一直阻塞,直到所有的预定任务都完成,所以在with块结束后对 result 方法的调用总是立即返回。

字节码

为了理解 Python 中线程化的一些限制,我们需要看看解释器如何加载和运行代码的幕后。在这一节中,Python 代码可能用解释器使用的底层字节码进行了注释。这个字节码是一个实现细节,存储在.pyc文件中。它在最底层编码程序的行为。解释像 Python 这样复杂的语言并不是一项简单的任务,因此 Python 解释器将其对代码的解释缓存为一系列简单的操作。

当人们谈论 Python 时,他们通常谈论的是 C 编程语言中 Python 的实现 CPython。CPython 是引用的实现,因为它旨在成为人们在了解 Python 如何工作时的参考。还有其他实现,其中最流行的是 PyPy,这是一种用专门设计的类似 Python 的语言而不是 c 语言编写的 Python 实现。4CPython 和 pypypy 都将它们对 Python 代码的解释缓存为 Python 字节码。

Python 的另外两个实现值得一提:Jython 和 IronPython。这两者都将它们的解释缓存为字节码,但关键是它们使用了不同的字节码。Jython 使用与 Java 相同的字节码格式,IronPython 使用与. NET 相同的字节码格式。对于本章,当我们谈论字节码时,我们谈论的是 Python 字节码,因为我们是在 CPython 中如何实现线程的背景下看待它的。

一般来说,您不必担心字节码,但是了解它的作用对于编写多线程代码是很有用的。以下给出的样本是使用标准库中的dis模块 5 生成的。函数dis.dis(func)显示了给定函数的字节码,假设它是用 Python 而不是 C 扩展编写的。例如,sorted(...)函数是用 C 实现的,因此没有字节码可以显示。

为了演示这一点,让我们看一个函数及其反汇编(清单 7-3 )。该函数已经用来自dis.dis(increment)的反汇编结果进行了注释,该结果显示了文件中的行号、函数中指令的字节码偏移量、指令名和任何指令参数作为它们的原始值,括号中是 Python 表示。

num = 0

def increment():
    global num
    num += 1          # 5  0    LOAD_GLOBAL              0 (num)
                      #    2    LOAD_CONST               1 (1)
                      #    4    INPLACE_ADD
                      #    6    STORE_GLOBAL             0 (num)

    return None       # 10 8    LOAD_CONST               0 (None)
                      #    10   RETURN_VALUE

Listing 7-3A simple function to increment a global variable

num += 1看起来像一个原子操作, 6 但是字节码显示底层解释器运行四个操作来完成它。我们不关心这四个指令是什么,只是我们不能相信我们的直觉,哪些操作是原子的,哪些不是。

如果我们连续运行这个增量函数 100 次,存储到num的结果将是100,这在逻辑上是有意义的。如果这个函数在一对线程中执行,就不能保证最后的结果是100。在这种情况下,只有当另一个线程正在运行LOAD_CONSTIN_PLACE_ADDSTORE_GLOBAL步骤时,没有线程执行LOAD_GLOBAL字节码步骤,才能找到正确的结果。Python 不能保证这一点,所以前面的代码不是线程安全的。

启动一个线程是有开销的,而且计算机会同时运行多个进程。尽管有两个线程可用,但这两个线程可能碰巧按顺序运行,或者它们可能同时启动,或者启动时间之间可能存在偏移。执行重叠的方式如图 7-4 所示。

img/481001_1_En_7_Fig4_HTML.png

图 7-4

两个线程同时执行 num += 1 的可能安排。只有最左边和最右边的例子产生正确的结果

那个女孩

然而,这有些简化了。CPython 有一个称为 GIL(Global Interpreter Lock)的特性,用于简化线程安全。 7 这个锁意味着一次只能有一个线程在执行 Python 代码。然而,这不足以解决我们的问题,因为 GIL 的粒度是字节码级别的,所以尽管没有两条字节码指令同时执行,解释器仍然可以在每条路径之间切换,从而导致重叠。因此,图 7-5 显示了螺纹如何重叠的更准确的表示。

img/481001_1_En_7_Fig5_HTML.png

图 7-5

GIL 激活时 num += 1 执行的可能安排。只有最左边和最右边产生正确的结果

看起来 GIL 在不保证正确结果的情况下消除了线程的优势,但它并不像看起来那么糟糕。我们将很快讨论它的好处,但是首先,我们应该解决这个否定线程优势的问题。严格来说,没有两条字节码指令可以同时运行。

字节码指令比 Python 的行要简单得多,允许解释器在任何给定的点上推理它正在采取什么动作。因此,它可以在安全的情况下允许多线程执行,比如在网络连接期间或等待从文件中读取数据时。

具体来说,并不是 Python 解释器做的所有事情都需要持有 GIL。它必须保存在字节码指令的开头和结尾,但是可以在内部释放。等待套接字有数据可供读取是不需要持有 GIL 就可以完成的事情之一。在发生 IO 操作的字节码指令中,GIL 可以被释放,解释器可以同时执行任何不需要持有 GIL 的代码,只要它在不同的线程中。一旦 IO 操作完成,它必须等待从获取它的线程那里重新获得 GIL,然后才能继续执行。

在这种情况下,代码永远不必等待 IO 函数完成,Python 会以设定的时间间隔中断线程,以公平地调度其他线程。默认情况下,这大约是每 0.005 秒一次,这是一个足够长的时间,我们的示例可以在我的计算机上正常运行。如果我们使用sys.setswitchinterval(...)函数手动告诉解释器更频繁地切换线程,我们会开始看到失败。

不同切换间隔的线程安全测试代码

if __name__ == "__main__":
    import concurrent.futures
    import sys
    for si in [0.005, 0.0000005, 0.0000000005]:
        sys.setswitchinterval(si)
        results = []
        for attempt in range(100):
            with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
                for i in range(100):
                    pool.submit(increment)
            results.append(num)
            num = 0
        correct = [a for a in results if a == 100]
        pct = len(correct) / len(results)
        print(f"{pct:.1%} correct at sys.setswitchinterval({si:.10f})")

在我的电脑上,运行这个的结果是

100.0% correct at sys.setswitchinterval(0.0050000000)
71.0% correct at sys.setswitchinterval(0.0000005000)
84.0% correct at sys.setswitchinterval(0.0000000005)

在我的测试中,默认行为 100%正确并不意味着它解决了问题。0.005 是一个精心选择的区间,对于大多数人来说,这个区间产生错误的几率较低。当你测试一个函数时,它碰巧能工作,这并不意味着它能保证在每台机器上都能工作。引入线程的代价是获得了相对简单的并发性,但没有关于共享状态的强有力的保证。

锁和死锁

通过强制执行字节码指令不重叠的规则,可以保证它们是原子的。对于相同的值,没有两个STORE字节码指令同时发生的风险,因为没有两个字节码指令可以真正同时运行。指令的执行可能会自动释放 GIL,并等待在其执行的部分重新获得它,但这与并行发生的任意两个指令不同。Python 使用这种原子性来构建线程安全的类型和同步工具。

如果您需要在线程之间共享状态,您必须用锁手动保护这个状态。锁是允许您防止您的代码与其他可能干扰的代码同时运行的对象。如果两个并发线程都试图获取锁,只有一个会成功。任何试图获取锁的其他线程都将被阻塞,直到第一个线程释放它。这是可能的,因为锁是用 C 代码实现的,这意味着它们的执行是作为一个字节码步骤发生的。等待锁变得可用并获得锁的所有工作都是响应单个字节码指令而执行的,这使它成为原子性的。

受锁保护的代码仍然可以被中断,但是在这些中断期间不会运行冲突的代码。当线程持有锁时,它们仍然可以被中断。如果线程被中断而试图获取同一个锁,那么它将无法这样做,并将暂停执行。在有两个线程的环境中,这意味着执行将直接返回到第一个函数。如果有一个以上的线程是活动的,它可能会首先传递给其他线程,但同样无法获取第一个线程的锁。

带锁定的增量功能

import threading

numlock = threading.Lock()
num = 0

def increment():
    global num
    with numlock:
        num += 1
    return None

在这个版本的函数中,名为numlock的锁用于保护读/写 num 值的操作。这个上下文管理器使得锁在执行传递到主体之前被获取,并且在主体之后的第一行之前被释放。尽管我们在这里增加了一些开销,但这是最小的,并且它保证了代码的结果是正确的,不管任何用户设置或不同的 Python 解释器版本。

带锁测试结果 num += 1

100.0% correct at sys.setswitchinterval(0.0050000000)
100.0% correct at sys.setswitchinterval(0.0000005000)
100.0% correct at sys.setswitchinterval(0.0000000005)

无论切换间隔是多长,这段代码都能找到正确的结果,因为组成num += 1的四个字节码指令保证作为一个块执行。在每四个块的前后都有一个额外的锁定字节码指令,如图 7-6 所示。

img/481001_1_En_7_Fig6_HTML.png

图 7-6

num += 1 在两个线程上的可能安排,在开始和结束时显示显式锁定

从线程池中正在使用的两个线程来看,with numlock :行可能会阻塞执行,也可能不会。两个线程都不需要做任何特殊的事情来处理这两种情况(立即获得锁或等待轮到),因此这是对控制流相对最小的更改。

困难在于确保所需的锁到位并且不存在矛盾。如果一个程序员定义了两个锁并同时使用它们,就有可能造成程序陷入死锁的情况。

僵局

考虑这样一种情况,我们在一个线程中递增两个数字,在另一个线程中递减它们,从而产生以下函数:

num = 0
other = 0

def increment():
    global num
    global other
    num += 1
    other += 1
    return None

def decrement():
    global num
    global other
    other -= 1
    num -= 1
    return None

这个程序遭遇了我们以前遇到的同样的问题;如果我们在ThreadPoolExecutor中安排这些功能,那么结果可能是不正确的。我们可能会考虑应用之前解决这个问题的相同的锁定模式,添加一个otherlock锁来补充我们已经创建的numlock锁,但是死锁的可能性潜伏在这段代码中。我们有三种方法来安排这些函数中的锁(如表 7-2 所示),其中一种方法会导致死锁。

表 7-2

同时更新两个变量的三种锁定方法

| *最小化锁定代码(防止死锁)*`num = 0``other = 0``numlock = \threading.Lock()``otherlock = \threading.Lock()``def increment():``global num``global other``with numlock:``num += 1``with otherlock:``other += 1``return None``def decrement():``global num``global other``with otherlock:``other -= 1``with numlock:``num -= 1``return None` | *以一致的顺序使用锁(防止死锁)*`num = 0``other = 0``numlock = threading.Lock()``otherlock = \threading.Lock()``def increment():``global num``global other``with numlock, otherlock:``num += 1``cother += 1``return None``def decrement():``global num``global other``with numlock, otherlock:``other -= 1``num -= 1``return None` | *以不一致的顺序使用锁(* ***导致死锁*** *)*`num = 0``other = 0``numlock = \threading.Lock()``otherlock = \threading.Lock()``def increment():``global num``global other``with numlock, otherlock:``num += 1``other += 1``return None``def decrement():``global num``global other``with otherlock, numlock:``other -= 1``num -= 1``return None` |

最好的选择是确保我们永远不会同时持有两把锁。这使得它们真正独立,因此没有死锁的风险。在这种模式下,线程永远不会等待获取锁,直到它们已经释放了之前持有的锁。

中间的实现同时使用两个锁。这不太好,因为它持有锁的时间超过了需要的时间,但有时代码不可避免地需要锁定两个变量。虽然前面的两个函数都可以编写为一次只使用一个锁,但是请考虑交换值的函数的情况:

def switch():
    global num
    global other
    with numlock, otherlock:
        num, other = other, num
    return None

这个函数要求numother在执行时都没有被另一个线程使用,所以它需要保持两个数字都被锁定。在increment()decrement()(和switch())函数中获取锁的顺序相同,所以每个函数都试图在otherlock之前获取numlock。如果两个线程在执行过程中是同步的,它们会同时尝试获取numlock,其中一个会阻塞。不会出现死锁。

最后一个例子展示了一个实现,其中decrement()函数中的锁的顺序被颠倒了。这确实很难注意到,但是会导致死锁。运行第三版increment()的线程有可能在运行减量的线程获得otherlock锁的同时获得numlock锁。现在,两个线程都在等待获取它们没有的锁,并且在它们获得丢失的锁之前都不能释放它们的锁。这会导致程序无限期挂起。

有几种方法可以避免这个问题。由于这是关于代码结构的逻辑断言,自然的工具是静态检查器,以确保您的代码不会颠倒获取锁的顺序。不幸的是,我不知道任何现有的 Python 代码检查的实现。

最直接的替代方法是使用一个锁来覆盖这两个变量,而不是单独锁定它们。尽管表面上看这很吸引人,但随着需要保护的对象数量的增加,它并不能很好地扩展。当另一个线程处理other变量时,一个锁对象会阻止对num变量的任何操作。跨独立函数共享锁会大大增加代码中的阻塞量,这会抵消线程带来的优势。

您可能会放弃获取锁的with numlock:方法,而直接调用锁的acquire()方法。虽然这允许您指定一个超时时间和一个错误处理程序,以防在超时时间内没有获得锁,但我不推荐这样做。随着错误处理程序的引入,这种变化使得代码的逻辑更难理解,以这种方式检测死锁的唯一适当的反应是引发异常。这会因为超时而减慢程序的速度,并且不能解决问题。这种方法在本地调试时可能很有用,允许您检查死锁期间的状态,但不应该考虑用于生产代码。

我的建议是,您应该使用所有这些方法来防止死锁。首先,你应该使用最小数量的锁来保证你的程序线程安全。如果您确实需要多个锁,您应该最小化它们被持有的时间,一旦共享状态被操纵,就释放它们。最后,您应该定义锁的顺序,并在获取锁时始终使用这个顺序。最简单的方法是总是按字母顺序获取锁。确保锁的固定顺序仍然需要手动检查您的代码,但是锁的每次使用都可以根据您的规则独立检查,而不是根据所有其他使用检查。

避免全局状态

避免全局状态并不总是可能的,但是在许多情况下,这是可能的。一般来说,如果两个函数都不依赖于共享变量的值,那么可以安排两个函数并行运行。 8 假设不是 100 个对increment()的调用和 100 个对decrement()的调用,而是 100 个对increment()的调用和 1 个对函数save_number_to_database()的调用。无法保证在调用save_number_to_database()之前increment()会完成多少次。保存的数字可能在 0 到 100 之间,这显然是没有用的。这些函数并行运行没有意义,因为它们都依赖于共享变量的值。

共享数据有两种主要的关联方式。共享数据可以用于跨多个线程整理数据,也可以用于在多个线程之间传递数据。

整理数据

我们的两个increment()decrement()函数只是简单的演示。它们通过加 1 或减 1 来操纵它们的共享状态,但是通常并行运行的函数会进行更复杂的操纵。例如,在apd.aggregation中,共享状态是我们拥有的传感器结果的集合,每个线程向该集合添加更多的结果。

有了这两个例子,我们可以将决定操作应该是什么和应用操作的工作分开。由于这只是我们应用需要访问共享状态的操作的阶段,这允许我们并行地进行任何计算或 IO 操作。然后每个线程返回结果,最后将结果合并在一起,如清单 7-4 所示。

import concurrent.futures
import threading

def increment():
    return 1

def decrement():
    return -1

def onehundred():
    tasks = []
    with concurrent.futures.ThreadPoolExecutor() as pool:
        for i in range(100):
            tasks.append(pool.submit(increment))
            tasks.append(pool.submit(decrement))
    number = 0
    for task in tasks:
        number += task.result()
    return number

if __name__ == "__main__":

    print(onehundred())

Listing 7-4Example of using task result to store intended changes

传递数据

到目前为止,我们讨论的例子都涉及到将工作委托给子线程的主线程,但是在处理来自早期任务的数据的过程中发现新任务是很常见的。例如,大多数 API 对数据进行分页,因此如果我们有一个获取 URL 的线程和一个解析响应的线程,我们需要能够将初始 URL 从主线程传递到获取线程,还需要将新发现的 URL 从解析线程传递到获取线程。

当在两个(或更多)线程之间传递数据时,我们需要使用队列,或者是queue.Queue或者是变体queue.LifoQueue。这些分别实现 FIFO和 LIFO9 和 队列。虽然我们以前只将Queue用作一个方便的、线程安全的数据容器,但现在我们将按预期使用它。

队列有四种主要方法。10get()put()方法是不言自明的,除了说如果队列是空的,那么get()方法阻塞,如果队列设置了最大长度并且是满的,那么put()方法阻塞。此外,还有一个task_done()方法,用于告诉队列一个项目已经被成功处理,还有一个join()方法,阻塞直到所有项目都被成功处理。join()方法通常由向队列添加项目的线程调用,以允许它等待直到所有工作完成。

因为如果队列当前是空的,那么get()方法就会阻塞,所以不可能在非线程代码中使用这个方法。但是,这确实使它们非常适合线程代码,在线程代码中,需要等到产生数据的线程使数据可用。

Tip

事先并不总是清楚一个队列中将存储多少项。如果在检索到最后一项后调用get(),那么它将无限期阻塞。这可以通过为 get 提供一个超时参数来避免,在这种情况下,它将在引发queue.Empty异常之前阻塞给定的秒数。更好的方法是发送一个 sentinel 值,比如 None。然后,代码可以检测这个值,并知道它不再需要检索新值。

如果我们构建一个线程程序来从 GitHub 公共 API 获取信息,我们需要能够检索 URL 并解析它们的结果。如果能够在获取 URL 的同时进行解析就好了,所以我们将在获取和解析函数之间拆分代码。

清单 7-5 展示了这样一个程序的例子,其中多个 GitHub repos 可以并行检索它们的提交。它使用三个队列,一个用于 fetch 线程的输入,一个用于 fetch 的输出和 parse 的输入,一个用于 parse 的输出。

from concurrent.futures import ThreadPoolExecutor
import queue
import requests
import textwrap

def print_column(text, column):
    wrapped = textwrap.fill(text, 45)
    indent_level = 50 * column
    indented = textwrap.indent(wrapped, " " * indent_level)
    print(indented)

def fetch(urls, responses, parsed):
    while True:
        url = urls.get()
        if url is None:
            print_column("Got instruction to finish", 0)
            return
        print_column(f"Getting {url}", 0)
        response = requests.get(url)
        print_column(f"Storing {response} from {url}", 0)
        responses.put(response)
        urls.task_done()

def parse(urls, responses, parsed):
    # Wait for the initial URLs to be processed
    print_column("Waiting for url fetch thread", 1)
    urls.join()

    while not responses.empty():
        response = responses.get()
        print_column(f"Starting processing of {response}", 1)

        if response.ok:
            data = response.json()
            for commit in data:
                parsed.put(commit)

            links = response.headers["link"].split(",")
            for link in links:
                if "next" in link:
                    url = link.split(";")[0].strip("<>")
                    print_column(f"Discovered new url: {url}", 1)
                    urls.put(url)

        responses.task_done()
        if responses.empty():
            # We have no responses left, so the loop will
            # end. Wait for all queued urls to be fetched
            # before continuing
            print_column("Waiting for url fetch thread", 1)
            urls.join()

    # We reach this point if there are no responses to process
    # after waiting for the fetch thread to catch up. Tell the
    # fetch thread that it can stop now, then exit this thread.
    print_column("Sending instruction to finish", 1)
    urls.put(None)

def get_commit_info(repos):
    urls = queue.Queue()
    responses = queue.Queue()
    parsed = queue.Queue()

    for (username, repo) in repos:
        urls.put(f"https://api.github.com/repos/{username}/{repo}/commits")

    with ThreadPoolExecutor() as pool:
        fetcher = pool.submit(fetch, urls, responses, parsed)
        parser = pool.submit(parse, urls, responses, parsed)
    print(f"{parsed.qsize()} commits found")

if __name__ == "__main__":
    get_commit_info(
        [("MatthewWilkes", "apd.sensors"), ("MatthewWilkes", "apd.aggregation")]
    )

Listing 7-5Threaded API client

运行这段代码会产生两列输出,由来自每个线程的消息组成。完整的输出太长,无法在此包含,但下面给出了一小部分作为演示:

Getting https://api.github.com/repos/MatthewW
ilkes/apd.aggregation/commits
Storing <Response [200]> from https://api.git
hub.com/repos/MatthewWilkes/apd.aggregation/c
ommits
                                                  Starting processing of <Response [200]>
                                                  Discovered new url: https://api.github.com/
                                                  repositories/188280485/commits?page=2
                                                  Starting processing of <Response [200]>
Getting https://api.github.com/repositories/1
88280485/commits?page=2
                                                  Discovered new url: https://api.github.com/
                                                  repositories/222268232/commits?page=2

通过检查来自每个线程的日志消息,我们可以查看它们的工作是如何并行调度的。首先,主线程设置必要的队列和子线程,然后等待所有线程完成。两个子线程一启动,fetch 线程就开始处理主线程传递的 URL,而 parse 线程在等待解析响应时会迅速暂停。

解析线程在没有工作时使用urls.join(),所以每当它用完工作时,它就等待,直到获取线程完成它发送的所有工作。这在图 7-7 中可见,因为解析行总是在提取行完成后恢复。

img/481001_1_En_7_Fig7_HTML.png

图 7-7

清单 7-5 中三个线程的时序图

fetch 线程不使用任何队列的join()方法,它使用get()来阻塞,直到有一些工作要做。这样,可以看到获取线程在解析线程仍在执行时恢复。最后,解析线程向获取线程发送一个 sentinel 值以结束,当两者都退出时,主线程中的线程池上下文管理器退出,执行返回到主线程。

其他同步原语

我们在前面的例子中使用的队列同步比我们前面使用的锁行为更复杂。事实上,在标准库中还有许多其他的同步原语。这些允许您构建更复杂的线程安全协调行为。

重入锁

Lock对象非常方便,但它不是唯一用于跨线程同步代码的系统。也许其他最重要的是可重入锁,它可以作为threading.RLock获得。可重入锁是可以被多次获取的锁,只要这些获取是嵌套的。

from concurrent.futures import ThreadPoolExecutor
import threading

num = 0

numlock = threading.RLock()

def fiddle_with_num():
    global num
    with numlock:
        if num == 4:
            num = -50

def increment():
    global num
    with numlock:
        num += 1
        fiddle_with_num()

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:
        for i in range(8):
            pool.submit(increment)
    print(num)

Listing 7-6An example of nested locking using RLocks

这样做的好处是,依赖于一个锁的函数可以调用其他依赖于同一个锁的函数,在第一个锁释放它之前,第二个函数不会阻塞。这大大简化了使用锁的 API 的创建。

清单输出示例 7-6

> python .\listing7-06-reentrantlocks.py
-46

情况

与我们目前使用的锁不同,条件声明变量已经准备好,而不是它正忙。队列在内部使用条件来实现get()put(...)join()的阻塞行为。条件允许比获取锁更复杂的行为。

条件是一种告诉其他线程该检查数据的方式,这些数据必须独立存储。等待数据的线程调用上下文管理器中条件的wait_for(...)函数,而提供数据的线程调用notify()方法。没有规定一个线程不能在不同的时间同时做这两件事;但是,如果所有线程都在等待数据,而没有一个线程在发送数据,那么就有可能引入死锁。

例如,当调用队列的get(...)方法时,代码立即通过其内部not_empty条件获取队列的单锁,然后检查队列的内部存储是否有任何可用数据。如果是,则返回一个项并释放锁。此时保持锁定可以确保没有其他用户可以同时检索该项,因此没有重复的风险。然而,如果内存中没有数据,那么就调用not_empty.wait()方法。这释放了单个锁,允许其他线程操作队列,并且直到条件通知已经添加了一个新项目时才重新获取锁并返回。

有一种叫做notify_all()notify()方法。标准的notify()方法只唤醒一个正在等待的线程,而notify_all()唤醒所有正在等待的线程。使用notify_all()代替notify()总是安全的,但是当预计只有一个线程将被解除阻塞时,notify()避免了唤醒多个线程。

一个条件本身仅足以发送一个信息位:该数据已经可用。为了实际检索数据,我们必须以某种方式存储它,比如队列的内部存储。

清单 7-7 中的例子创建了两个线程,每个线程从一个共享的data列表中取出一个数字,然后将这个数字以 2 为模推送到一个共享的results列表中。该代码使用两个条件来实现这一点,一个条件是确保有数据可供处理,另一个条件是确定何时应该关闭线程。

from concurrent.futures import ThreadPoolExecutor
import sys
import time
import threading

data = []
results = []
running = True

data_available = threading.Condition()
work_complete = threading.Condition()

def has_data():
    """ Return true if there is data in the data list """
    return bool(data)

def num_complete(n):
    """Return a function that checks if the results list has the length specified by n"""

    def finished():
        return len(results) >= n

    return finished

def calculate():
    while running:
        with data_available:
            # Acquire the data_available lock and wait for has_data
            print("Waiting for data")
            data_available.wait_for(has_data)
            time.sleep(1)
            i = data.pop()
        with work_complete:
            if i % 2:
                results.append(1)
            else:
                results.append(0)
            # Acquire the work_complete lock and wake listeners
            work_complete.notify_all()

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:
        # Schedule two worker functions
        workers = [pool.submit(calculate), pool.submit(calculate)]

        for i in range(200):
            with data_available:
                data.append(i)
                # After adding each piece of data wake the data_available lock
                data_available.notify()
        print("200 items submitted")

        with work_complete:
            # Wait for at least 5 items to be complete through the work_complete lock
            work_complete.wait_for(num_complete(5))

        for worker in workers:
            # Set a shared variable causing the threads to end their work
            running = False
        print("Stopping workers")

    print(f"{len(results)} items processed")

Listing 7-7An example program using conditions

清单输出示例 7-7

> python .\listing7-07-conditions.py
Waiting for data
Waiting for data
200 items submitted
Waiting for data
Waiting for data
Waiting for data
Stopping workers
Waiting for data
Waiting for data
7 items processed

障碍

屏障是 Python 中概念上最简单的同步对象。用已知数量的创建屏障。当一个线程调用wait()时,它会一直阻塞,直到等待的线程数量与关卡的参与方数量相同。也就是说,threading.Barrier(2)在第一次调用wait()时阻塞,但是第二次调用立即返回并释放第一次阻塞的调用。

当多个线程处理一个问题的多个方面时,障碍是有用的,因为它们可以防止工作积压。屏障允许您确保一组线程只与该组中最慢的成员运行得一样快。

在屏障的初始创建或任何wait()调用中可以包含超时。如果任何等待调用花费的时间超过其超时时间,那么所有等待线程都会引发一个BrokenBarrierException,任何试图等待该障碍的后续线程也会如此。

清单 7-8 中的例子演示了同步一组五个线程,每个线程都等待一段随机的时间,这样一旦最后一个线程准备好了,它们都会继续执行。

from concurrent.futures import ThreadPoolExecutor
import random
import time
import threading

barrier = threading.Barrier(5)

def wait_random():
    thread_id = threading.get_ident()
    to_wait = random.randint(1, 10)
    print(f"Thread {thread_id:5d}: Waiting {to_wait:2d} seconds")
    start_time = time.time()
    time.sleep(to_wait)
    i = barrier.wait()
    end_time = time.time()
    elapsed = end_time - start_time

    print(
        f"Thread {thread_id:5d}: Resumed in position {i} after {elapsed:3.3f} seconds"
    )

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:
        # Schedule two worker functions
        for i in range(5):
            pool.submit(wait_random)

Listing 7-8Example of using a barrier

清单输出示例 7-8

> python .\listing7-08-barriers.py
Thread 21812: Waiting  8 seconds
Thread 17744: Waiting  2 seconds
Thread 13064: Waiting  4 seconds
Thread 14064: Waiting  6 seconds
Thread 22444: Waiting  4 seconds
Thread 21812: Resumed in position 4 after 8.008 seconds
Thread 17744: Resumed in position 0 after 8.006 seconds
Thread 22444: Resumed in position 2 after 7.999 seconds
Thread 13064: Resumed in position 1 after 8.000 seconds
Thread 14064: Resumed in position 3 after 7.999 seconds

事件

事件是另一种简单的同步方法。任何数量的线程都可以调用事件上的wait()方法,该方法会一直阻塞,直到事件被触发。通过调用set()方法可以在任何时候触发事件,这将唤醒所有等待事件的线程。对wait()方法的任何后续调用都会立即返回。

与屏障一样,事件对于确保多线程保持同步非常有用,而不是一些线程抢在前面。事件的不同之处在于,它们只有一个线程来决定何时该组可以继续,因此它们非常适合于一个线程专用于管理其他线程的程序。

event 方法也可以使用clear()方法重置,因此将来对wait()的任何调用都将被阻塞。可以用is_set()方法来检查事件的当前状态。清单 7-9 中的例子使用一个事件将一组线程与一个主线程同步,这样它们等待的时间至少与主线程一样长,但不会更长。

from concurrent.futures import ThreadPoolExecutor
import random

import time
import threading

event = threading.Event()

def wait_random(master):
    thread_id = threading.get_ident()
    to_wait = random.randint(1, 10)
    print(f"Thread {thread_id:5d}: Waiting {to_wait:2d} seconds " f"(Master: {master})")
    start_time = time.time()
    time.sleep(to_wait)
    if master:
        event.set()
    else:
        event.wait()
    end_time = time.time()
    elapsed = end_time - start_time
    print(
        f"Thread {thread_id:5d}: Resumed after {elapsed:3.3f} seconds"
    )

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:

        # Schedule two worker functions
        for i in range(4):
            pool.submit(wait_random, False)
        pool.submit(wait_random, True)

Listing 7-9Example of using events to set a minimum wait time

清单示例控制台输出 7-9

> python .\listing7-09-events.py
Thread 19624: Waiting  9 seconds (Master: False)
Thread  1036: Waiting  1 seconds (Master: False)
Thread  6372: Waiting 10 seconds (Master: False)
Thread 16992: Waiting  1 seconds (Master: False)
Thread 22100: Waiting  6 seconds (Master: True)
Thread 22100: Resumed after 6.003 seconds
Thread 16992: Resumed after 6.005 seconds
Thread  1036: Resumed after 6.013 seconds
Thread 19624: Resumed after 9.002 seconds
Thread  6372: Resumed after 10.012 seconds

旗语

最后,信号量在概念上更复杂,但却是一个非常古老的概念,因此在许多语言中都很常见。信号量类似于锁,但是它可以被多个线程同时获取。创建信号量时,必须给它一个值。该值是可以同时获取的次数。

信号量对于确保依赖稀缺资源的操作(例如使用大量内存或开放网络连接的操作)不会在超过某个阈值的情况下并行运行非常有用。例如,清单 7-10 展示了等待随机时间的五个线程,但是一次只能有三个线程等待。

from concurrent.futures import ThreadPoolExecutor
import random
import time
import threading

semaphore = threading.Semaphore(3)

def wait_random():

    thread_id = threading.get_ident()
    to_wait = random.randint(1, 10)
    with semaphore:
        print(f"Thread {thread_id:5d}: Waiting {to_wait:2d} seconds")
        start_time = time.time()
        time.sleep(to_wait)

        end_time = time.time()
        elapsed = end_time - start_time
        print(
            f"Thread {thread_id:5d}: Resumed after {elapsed:3.3f} seconds"
        )

if __name__ == "__main__":
    with ThreadPoolExecutor() as pool:
        # Schedule two worker functions
        for i in range(5):
            pool.submit(wait_random)

Listing 7-10Example of using semaphores to ensure only one thread waits at once

清单示例控制台输出 7-10

> python .\listing7-10-semaphore.py
Thread 10000: Waiting 10 seconds
Thread 24556: Waiting  1 seconds
Thread 15032: Waiting  6 seconds

Thread 24556: Resumed after 1.019 seconds
Thread 11352: Waiting  8 seconds
Thread 15032: Resumed after 6.001 seconds
Thread  6268: Waiting  4 seconds
Thread 11352: Resumed after 8.001 seconds
Thread 10000: Resumed after 10.014 seconds
Thread  6268: Resumed after 4.015 seconds

ProcessPoolExecutors

正如我们已经看到使用ThreadPoolExecutor将代码的执行委托给不同的线程,这导致我们违反了 GIL 的限制,如果我们愿意放弃所有共享状态,我们可以使用ProcessPoolExecutor在多个进程中运行代码。

当在进程池中执行代码时,开始时可用的任何状态都可用于子进程。但是,两者之间没有协调。数据只能作为提交给池的任务的返回值传递回控制流程。对全局变量的更改不会以任何方式反映出来。

尽管多个独立的 Python 进程不受 GIL 规定的同一种一次一个的执行方法的约束,但它们也有很大的开销。对于 IO 绑定的任务(即,大部分时间都在等待,因此不持有 GIL 的任务),进程池通常比线程池慢。

另一方面,涉及大量计算的任务非常适合委托给子流程,尤其是那些长时间运行的任务,与并行执行的节省相比,设置的开销更小。

让我们的代码多线程化

我们要并行化的函数是get_data_points(...);当处理 1 个或 500 个传感器时,实现命令行和数据库连接的函数没有显著变化;没有特别的理由将它的工作分成线程。将这项工作放在主线程中可以更容易地处理错误和报告进度,所以我们只重写了add_data_from_sensors(...)函数。

使用 ThreadPoolExecutor 的 add_data_from_sensors 的实现

def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.List[DataPoint]:

    threads: t.List[Future] = []
    points: t.List[DataPoint] = []
    with ThreadPoolExecutor() as pool:
        for server in servers:
            points_future = pool.submit(get_data_points, server, api_key)
            threads.append(points_future)
        for points_future in threads:
            points += handle_result(points_future, session)
    return points

def handle_result(execution: Future, session: Session) -> t.List[DataPoint]:
    points: t.List[DataPoint] = []
    result = execution.result()
    for point in result:
        session.add(point)
        points.append(point)
    return points

因为我们会在第一次调用result()方法之前将所有的任务提交给ThreadPoolExecutor,所以它们会被排队等待在线程中同时执行。触发阻塞的是result()方法和with阻塞的结尾;提交作业不会导致程序阻塞,即使您提交的作业多于可以同时处理的数量。

与原始线程方法或非阻塞 IO 方法相比,这种方法对程序流的干扰要小得多,但它仍然涉及到更改执行流,以处理这些函数现在处理Future对象而不是直接处理数据的事实。

异步

当谈到 Python 并发性时,AsyncIO 是房间里的大象,这主要是因为它是 Python 3 的旗舰特性之一。这是一种语言特性,它允许像非阻塞 IO 示例那样工作,但使用与ThreadPoolExecutor有点类似的 API。API 并不完全相同,但是提交任务和能够阻塞以等待结果的基本概念是两者共有的。

异步代码是协同多任务的。也就是说,代码永远不会被中断以允许另一个函数执行;只有当某个功能阻塞时,才会发生切换。这一改变使得推断代码将如何运行变得更加容易,因为像num += 1这样简单的语句不会被中断。

在使用 asyncio 时,您经常会看到两个新的关键字,即asyncawait关键字。async关键字将某些控制流块(特别是defforwith)标记为使用 asyncio 流,而不是标准流。这些块的含义仍然与标准同步 Python 中的相同,但是底层代码路径可能会有很大不同。

相当于ThreadPoolExecutor本身的是事件循环。当执行异步代码时,事件循环对象负责跟踪所有要执行的任务,并协调将它们的返回值传递回调用代码。

打算从同步上下文和异步上下文中调用的代码之间有严格的区别。如果您意外地从同步上下文中调用异步代码,您会发现自己使用的是协程对象而不是您期望的数据类型,如果您从异步上下文中调用同步代码,您可能会无意中引入阻塞 IO,从而导致性能问题。

为了加强这种分离,并允许 API 作者有选择地支持同步和异步使用他们的对象,async修饰符被添加到forwith中,以指定您正在使用异步兼容的实现。这些变量不能用于同步上下文或者没有异步实现的对象(比如元组或者列表,在async for的情况下)。

异步定义

我们可以像定义函数一样定义新的协程。但是,def关键字变成了async def。这些协程像其他协程一样返回值。因此,我们可以在一个 asyncio 方法中实现清单 7-3 中的相同行为,如清单 7-11 所示。

import asyncio

async def increment():
    return 1

async def decrement():
    return -1

async def onehundred():
    num = 0
    for i in range(100):
        num += await increment()
        num += await decrement()
    return num

if __name__ == "__main__":
    asyncio.run(onehundred())

Listing 7-11Example of concurrent increment and decrement coroutines

其行为方式相同:运行两个协程,检索它们的值,并根据这些函数的结果调整 num 变量。主要区别在于,这些协同程序不是提交给线程池,而是将onehundred()异步函数传递给事件循环来运行,该函数负责调用完成工作的其他协同程序。

当我们调用一个被定义为异步的函数时,我们会收到一个协程对象作为结果,而不是让函数执行。

async def hello_world():
    return "hello world"

>>> hello_world()
<coroutine object hello_world at 0x03DEDED0>

asyncio.run(...)函数是异步代码的主要入口点。它会一直阻塞,直到传递的函数以及该函数调度的所有其他函数都完成为止。结果是同步代码一次只能启动一个协同程序。

等待

关键字await是阻塞的触发器,直到异步函数完成。但是,这只会阻塞当前的异步调用堆栈。您可以同时执行多个异步函数,在这种情况下,在等待结果的同时执行另一个函数。

await关键字相当于ThreadPoolExecutor示例中的Future.result()方法:它将一个可获得的对象转换成它的结果。它可以出现在任何使用异步函数调用的地方;编写打印图 7-8 所示函数结果的三种变体中的任何一种都同样有效。

img/481001_1_En_7_Fig8_HTML.png

图 7-8

await 关键字的三种等效用法

一旦使用了 await,底层的 await 就会被使用。这是不可能的

data = get_data()
if await data:
    print(await data)

可应用对象是实现__await__()方法的对象。这是一个实现细节;你不需要写一个__await__()方法。相反,您将使用为您提供的各种不同的内置对象。例如,任何使用async def定义的协程都有一个__await__()方法。

除了协程之外,另一个常见的 awawait 是Task,它可以用asyncio.create_task(...)函数从协程中创建。通常的用法是用asyncio.run(...)调用一个函数,然后用asyncio.create_task(...)调度下一个函数。

async def example():
    task = asyncio.create_task(hello_world())
    print(task)
    print(hasattr(task, "__await__"))
    return await task

>>> asyncio.run(example())

<Task pending coro=<hello_world() running at <stdin>:1>>
True
'hello world'

任务是已经被调度用于并行执行的协程。当您await一个协程时,您让它被调度执行,然后立即阻塞等待它的结果。create_task(...)功能允许您在需要任务结果之前安排任务*。如果您有多个操作要执行,每个操作都执行一些阻塞 IO,但是您直接await了协程,那么在前一个操作完成之前,一个操作不会被调度。将协程调度为任务首先允许它们并行运行,如表 7-3 所示。*

表 7-3

用于并行等待的任务和裸协同程序的比较

| *直接等待协程*`import asyncio``import time``async def slow():``start = time.time()``await asyncio.sleep(1)``await asyncio.sleep(1)``await asyncio.sleep(1)``end = time.time()``print(end - start)``>>> asyncio.run(slow())``3.0392887592315674` | *首先转换为任务*`import asyncio``import time``async def slow():``start = time.time()``first = asyncio.create_task(asyncio.sleep(1))``second = asyncio.create_task(asyncio.sleep(1))``third = asyncio.create_task(asyncio.sleep(1))``await first``await second``await third``end = time.time()``print(end - start)``>>> asyncio.run(slow())``1.0060641765594482` |

有一些有用的便利函数来处理基于协程的调度任务,最著名的是asyncio.gather(...)。该方法接受任意数量的可应用对象,将它们调度为任务,等待它们,并按照它们的协同程序/任务最初给出的顺序返回它们的返回值元组的可应用对象。

当多个 awaitables 应该并行运行时,这非常有用:

async def slow():
    start = time.time()
    await asyncio.gather(
        asyncio.sleep(1),
        asyncio.sleep(1),
        asyncio.sleep(1)
    )
    end = time.time()
    print(end - start)

>>> asyncio.run(slow())
1.0132906436920166

异步用于

async for构造允许迭代一个对象,其中迭代器本身由异步代码定义。在同步迭代器上使用async for是不正确的,因为同步迭代器仅仅是在异步上下文中使用,或者恰好包含 awaitables。

我们使用的常见数据类型都不是异步迭代器。如果你有一个元组或者一个列表,那么你就使用标准的 for 循环,不管它们包含什么,也不管它们是用在同步还是异步代码中。

本节包含了异步函数中三种不同的循环方法的例子。类型提示在这里特别有用,因为这里的数据类型略有不同,它清楚地表明每个函数需要哪些类型。

清单 7-12 展示了一个可迭代的。它包含两个异步函数:一个协程返回一个数字 11 ,另一个将一个可迭代表的内容相加。也就是说,add_all(...)函数期望来自number(...)的协程(或任务)的标准迭代。numbers()功能是同步的;它返回一个包含两次调用number(...)的标准列表。

import asyncio
import typing as t

async def number(num: int) -> int:
    return num

def numbers() -> t.Iterable[t.Awaitable[int]]:
    return [number(2), number(3)]

async def add_all(numbers: t.Iterable[t.Awaitable[int]]) -> int:
    total = 0
    for num in numbers:
        total += await num
    return total

if __name__ == "__main__":
    to_add = numbers()
    result = asyncio.run(add_all(to_add))
    print(result)

Listing 7-12Looping over a list of awaitables

add_all(...)函数中,循环是标准的 for 循环,因为它在遍历一个列表。列表的内容是number(2)number(3)的结果,所以需要等待这两个调用来检索它们各自的结果。

另一种写法是颠倒 iterable 和 awaitable 之间的关系。也就是说,不是传递一个整型变量列表,而是传递一个整型变量列表的**。这里,numbers()被定义为一个协程,它返回一个整数列表。**

import asyncio
import typing as t

async def number(num: int) -> int:
    return num

async def numbers() -> t.Iterable[int]:
    return [await number(2), await number(3)]

async def add_all(nums: t.Awaitable[t.Iterable[int]]) -> int:
    total = 0
    for num in await nums:
        total += num
    return total

if __name__ == "__main__":
    to_add = numbers()
    result = asyncio.run(add_all(to_add))
    print(result)

Listing 7-13Awaiting a list of integers

numbers()协程现在负责等待单个number(...)协程。我们仍然使用标准的 for 循环,但是现在我们不是等待 for 循环的内容,而是等待我们正在循环的值。

对于这两种方法,第一个number(...)调用在第二个调用之前被等待,但是对于第一种方法,控制传递回两者之间的add_all(...)函数。在第二种情况下,只有在所有的数字都被单独等待并组合成一个列表后,控制权才会被传递回来。使用第一种方法,每个number(...)协程在需要时被处理,但是使用第二种方法,number(...)调用的所有处理都发生在第一个值被使用之前。

第三种方法是使用async for。为此,我们将清单 7-13 中的numbers()协程转换成一个生成器函数,从而得到清单 7-14 中的代码。这与在同步 Python 代码中使用的避免高内存使用率的方法相同,同样的代价是值只能迭代一次。

import asyncio
import typing as t

async def number(num: int) -> int:
    return num

async def numbers() -> t.AsyncIterator[int]:
    yield await number(2)
    yield await number(3)

async def add_all(nums: t.AsyncIterator[int]) -> int:
    total = 0
    async for num in nums:
        total += num
    return total

if __name__ == "__main__":
    to_add = numbers()
    result = asyncio.run(add_all(to_add))
    print(result)

Listing 7-14Asynchronous generator

我们仍然需要在numbers()方法中使用await关键字,因为我们想要迭代number(...)方法的结果,而不是结果的占位符。像第二个版本一样,这隐藏了等待来自sum(...)函数的单个number(...)调用的细节,而不是信任迭代器来管理它。然而,它也保留了第一个属性,即每个number(...)调用只在需要时才被评估:它们并不都被提前处理。

对于一个支持被 For 迭代的对象,它必须实现一个返回迭代器的__iter__方法。迭代器是一个对象,它实现了一个__iter__方法(返回自身)和一个__next__方法来推进迭代器。实现了__iter__但没有实现__next__的对象不是迭代器而是可迭代。Iterables 可以被迭代;迭代器也知道它们的当前状态。

同样,实现异步方法__aiter__的对象是一个AsyncIterable。如果__aiter__返回self并且还提供了一个__anext__异步方法,那么它就是一个AsyncIterator

一个对象可以实现所有四种方法,以支持同步和异步迭代。这只有在你实现一个行为类似于 iterable 的类时才有意义,无论是同步的还是异步的。创建异步 iterable 最简单的方法是使用异步函数的yield构造,这对于大多数用例来说已经足够了。

在前面所有的例子中,我们直接使用了协程。当函数指定它们在typing.Awaitable上工作时,我们可以确定如果我们传递任务而不是协程,相同的代码将会工作。第二个例子,我们在等待一个列表,相当于使用内置的asyncio.gather(...)函数。两者都返回一系列结果。因此,这可能是你最常看到的方法,尽管如清单 7-15 所示。

import asyncio
import typing as t

async def number(num: int) -> int:
    return num

async def numbers() -> t.Iterable[int]:
    return await asyncio.gather(
        number(2),
        number(3)
    )

async def add_all(nums: t.Awaitable[t.Iterable[int]]) -> int:
    total = 0
    for num in await nums:
        total += num
    return total

if __name__ == "__main__":
    to_add = numbers()
    result = asyncio.run(add_all(to_add))
    print(result)

Listing 7-15Using gather to process tasks in parallel

异步方式

with语句还有一个异步对应物async with,用于帮助编写依赖异步代码的上下文管理器。这在异步代码中很常见,因为许多 IO 操作都涉及安装和拆卸阶段。

就像async for使用__aiter__而不是__iter__一样,异步上下文管理器定义了__aenter____aexit__方法来替换__enter____exit__.,如果合适的话,对象可以再次选择实现所有四个方法来在两个上下文中工作。

在异步函数中使用同步上下文管理器时,可能会在正文的第一行之前和最后一行之后阻塞 IO。使用async with和兼容的上下文管理器允许事件循环在阻塞 IO 期间调度一些其他的异步代码。

我们将在接下来的两章中更详细地介绍使用和创建上下文管理器,但两者都等同于 try/finally 构造,但标准上下文管理器在其进入和退出方法中使用同步代码,而异步上下文管理器使用异步代码。

异步锁定原语

尽管异步代码不像线程那样容易受到并发安全问题的影响,但是仍然有可能编写出存在并发错误的异步代码。基于等待结果而不是线程被中断的交换模型可以防止大多数意外错误,但不能保证正确性。

例如,在清单 7-16 中,我们有一个我们查看线程时的增量示例的 asyncio 版本。在num +=行中有一个await,并引入了一个offset()协程来返回将加到 num 中的 1。这个offset()函数也使用asyncio.sleep(0)来阻塞几分之一秒,这模拟了阻塞 IO 请求的行为。

import asyncio
import random

num = 0

async def offset():
    await asyncio.sleep(0)
    return 1

async def increment():
    global num
    num += await offset()

async def onehundred():
    tasks = []
    for i in range(100):
        tasks.append(increment())
    await asyncio.gather(*tasks)
    return num

if __name__ == "__main__":
    print(asyncio.run(onehundred()))

Listing 7-16Example of an unsafe asynchronous program

尽管这个程序应该打印 100,但它也可以打印任何低至 1 的数字,这取决于事件循环对调度任务做出的决定。为了防止这种情况,我们需要将 await offset()调用移到不属于+=构造的部分,或者锁定num变量。

AsyncIO 提供线程库中LockEven t、ConditionSemaphore的直接等价物。这些变体使用相同 API 的异步版本,因此我们可以修复清单 7-17 中所示的事件函数。

import asyncio
import random

num = 0

async def offset():
    await asyncio.sleep(0)
    return 1

async def increment(numlock):
    global num
    async with numlock:
        num += await offset()

async def onehundred():
    tasks = []
    numlock = asyncio.Lock()

    for i in range(100):
        tasks.append(increment(numlock))
    await asyncio.gather(*tasks)
    return num

if __name__ == "__main__":
    print(asyncio.run(onehundred()))

Listing 7-17Example of asynchronous locking

也许同步原语的线程版本和异步版本的最大区别在于异步原语不能在全局范围内定义。更准确地说,它们只能从正在运行的协程中实例化,因为它们必须向当前事件循环注册自己。

使用同步库

到目前为止,我们编写的代码依赖于我们拥有一个完全异步的库和函数堆栈,以便从我们的异步代码中调用。如果我们引入一些同步代码,那么我们在执行它的时候会阻塞所有的任务。我们可以通过使用time.sleep(...)方法阻塞一段时间来演示这一点。早先我们使用asyncio.sleep(...)来建模一个长期运行的异步感知任务;混合这些让我们看看这样一个混合系统的性能:

import asyncio
import time

async def synchronous_task():
    time.sleep(1)

async def slow():
    start = time.time()
    await asyncio.gather(
        asyncio.sleep(1),
        asyncio.sleep(1),
        synchronous_task(),
        asyncio.sleep(1)
    )
    end = time.time()
    print(end - start)

>>> asyncio.run(slow())

2.006387243270874

在这种情况下,我们的三个异步任务都需要 1 秒钟,并且是并行处理的。阻塞任务也需要 1 秒,但它是串行处理的,这意味着总时间是 2 秒。为了确保所有四个函数并行运行,我们可以使用loop.run_in_executor(...)函数。这会分配一个ThreadPoolExecutor(或者您选择的另一个执行器)并在该上下文中运行指定的任务,而不是在主线程中。

import asyncio
import time

async def synchronous_task():
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, time.sleep, 1)

async def slow():
    start = time.time()
    await asyncio.gather(
        asyncio.sleep(1),
        asyncio.sleep(1),
        synchronous_task(),
        asyncio.sleep(1)
    )
    end = time.time()
    print(end - start)

>>> asyncio.run(slow())
1.0059468746185303

run_in_executor(...)函数的工作原理是将问题切换到一个易于异步处理的问题上。它使用一个线程(或进程)来执行代码,而不是试图将任意 Python 函数从同步转换为异步,找到正确的位置将控制权交还给事件循环,在正确的时间被唤醒等等。由于线程和进程是一种操作系统结构,因此它们天生适合异步控制。这将需要与 asyncio 系统兼容的范围缩小到启动一个线程并等待它完成。

让我们的代码异步

让我们的代码在异步上下文中工作的第一步是选择一个函数作为异步函数链中的第一个。我们希望将同步和异步代码分开,所以我们需要在调用栈中选择一个足够高的位置,使得所有需要异步的东西都可以(可能是间接地)被这个函数调用。

在我们的代码中,get_data_points(...)函数是我们唯一想要在异步上下文中运行的函数。由add_data_from_sensors(...)调用,?? 本身由standalone(...)调用,依次由collect_sensor_data(...)调用。这四个函数中的任何一个都可以成为asyncio.run(...)的自变量。

collect_sensor_data(...)函数是 click 入口点,所以不能是异步函数。get_data_points(...)函数需要被多次调用,因此它比异步流的主入口点更适合协程。这就剩下standalone(...)add_data_from_sensors(...)

standalone(...)函数已经完成了数据库的设置;这也是设置事件循环的好地方。因此,我们需要让add_data_from_sensors(...)成为一个异步函数,并调整如何从standalone(...)调用它。

def standalone(
    db_uri: str, servers: t.Tuple[str], api_key: t.Optional[str], echo: bool = False
) -> None:
    engine = create_engine(db_uri, echo=echo)
    sm = sessionmaker(engine)
    Session = sm()
    asyncio.run(add_data_from_sensors(Session, servers, api_key))
    Session.commit()

我们现在需要改变底层函数的实现,使之不调用任何阻塞同步代码。目前,我们使用请求库进行 HTTP 调用,这是一个阻塞的同步库。

作为替代,我们将切换到aiohttp模块来发出 HTTP 请求。Aiohttp 是一个本地异步 http 库,支持客户端和服务器应用。该接口不像 requests 那样精致,但是非常有用。

API 中最大的区别是 HTTP 请求涉及许多上下文管理器,如下所示:

    async with aiohttp.ClientSession() as http:
        async with http.get(url) as request:
            result = await request.json()

顾名思义,ClientSession代表了具有共享 cookie 状态和 HTTP 头配置的会话的思想。在这种情况下,请求是通过 get 这样的异步上下文管理器发出的。上下文管理器的结果是一个对象,该对象具有可以等待以检索响应内容的方法。

不可否认,前面的结构比等效的 using requests 要冗长得多,它允许在许多地方让出执行流来绕过阻塞 IO。最明显的是await行,它在等待响应被检索并解析为 JSON 时放弃控制权。不太明显的是http.get(...)上下文管理器的入口和出口,它可以建立套接字连接,允许像 DNS 解析这样的事情不阻塞执行。当进入和退出一个ClientSession时,执行流也有可能被产出。

所有这些就是说,虽然前面的构造比使用请求的相同代码更冗长,但它确实允许透明地设置和拆除与 HTTP 会话相关的共享资源,并且是以不会显著减慢该过程的方式这样做的。

在我们的add_data_from_sensors(...)函数中,我们需要处理现在需要这个会话对象的事实,最好是在我们的多个请求之间共享客户端会话。我们还需要保留请求协程调用的记录,这样我们就可以并行调度它们并检索它们的数据。

async def add_data_from_sensors(
    session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.List[DataPoint]:
    todo: t.List[t.Awaitable[t.List[DataPoint]]] = []
    points: t.List[DataPoint] = []
    async with aiohttp.ClientSession() as http:
        for server in servers:
            todo.append(get_data_points(server, api_key, http))
        for a in await asyncio.gather(*todo):
            points += await handle_result(a, session)
    return points

在这个函数中,我们定义了两个变量,一个 awaitables 列表,每个变量返回一个DataPoint对象列表,以及一个在处理 awaitables 时填充的DataPoint对象列表。然后,我们设置ClientSession并遍历服务器,为每个服务器添加一个get_data_points(...)调用。在这个阶段,这些是协程,因为它们没有被调度为任务。我们可以依次等待它们,但是这样会导致每个请求按顺序发生。相反,我们使用asyncio.gather(...)将它们调度为任务,并允许我们迭代结果,每个结果都是一个DataPoint对象的列表。

接下来,我们需要将数据添加到数据库中。我们在这里使用 SQLAlchemy,它是一个同步库。对于生产质量的代码,我们需要确保这里没有阻塞的机会。下面的实现不能保证session.add(...)方法会因为数据与数据库会话同步而阻塞。

不应在生产代码中使用的 handle_result 占位符

async def handle_result(result: t.List[DataPoint], session: Session) -> t.List[DataPoint]:
    for point in result:
        session.add(point)
    return result

我们将在下一章探讨在并行执行环境中处理数据库集成的方法,但这对于原型来说已经足够好了。

最后,我们需要做获取数据的实际工作。该方法与同步版本有很大的不同,除了它也需要传入ClientSession,并且必须做一些小的更改以适应 HTTP 请求 API 中的差异。

使用 aiohttp 实现 get _ data _ points

async def get_data_points(server: str, api_key: t.Optional[str], http: aiohttp.ClientSession) -> t.List[DataPoint]:
    if not server.endswith("/"):
        server += "/"
    url = server + "v/2.0/sensors/"
    headers = {}
    if api_key:
        headers["X-API-KEY"] = api_key
    async with http.get(url) as request:
        result = await request.json()
        ok = request.status == 200
    now = datetime.datetime.now()
    if ok:
        points = []
        for value in result["sensors"]:
            points.append(
                DataPoint(
                    sensor_name=value["id"], collected_at=now, data=value["value"]
                )
            )
        return points
    else:
        raise ValueError(
            f"Error loading data from {server}: "
            + result.json().get("error", "Unknown")
        )

与多线程或多进程模型相比,这种方法有许多不同的选择。多进程模型允许真正的并发处理,多线程方法可以获得一些非常小的性能增益,这要归功于较少限制的切换保证,但在我看来,异步代码具有更自然的接口。

asyncio 方法的主要缺点是,只有使用异步库才能真正实现这些优点。通过结合使用 asyncio 和 threaded 方法,仍然可以使用其他库,这两种方法之间的良好集成使这变得很容易,但是将现有代码转换为异步方法有很大的重构需求,同样,首先要习惯于编写异步代码也有很大的学习曲线。

比较

本章附带的代码中有所有四种方法的实现,因此我们可以运行一个简单的基准来比较它们的速度。以这种方式对提议的优化进行基准测试总是很困难;除了真实世界的测试之外,很难从任何东西得到真实的数字,所以下面的内容应该有所保留。

这些数字是通过在一次调用中多次从同一个传感器提取数据而生成的。除了对这些调用进行计时的机器上的其他负载之外,这些数字是不现实的,因为它们不涉及查找许多不同目标的连接信息,并且因为返回所请求数据的服务器在它能够服务的同时请求的数量上是有限的。

从图 7-9 中可以看出,线程化和 asyncio 方法在耗时方面几乎没有区别。我们因其复杂性而拒绝的非阻塞 IO 方法也是可比较的。多进程方法明显较慢,但与其他三种方法相似。标准的同步方法的行为类似于仅从一个或两个传感器收集数据,但是较大的结果集很快变得病态,比并行方法花费更长的数量级。

我们应该从中获得的信息是,这种工作负载非常适合并行化。asyncio 在我们的基准测试中快了 20%,这一事实并不一定等同于它是一种更快的技术,只是在这个特定的测试中更快而已。未来对代码库的改变,以及不同的测试条件,很容易改变技术之间的关系。

img/481001_1_En_7_Fig9_HTML.png

图 7-9

使用不同的并行化方法从 1、2、5、10、20 或 50 个 HTTP APIs 加载数据所花费的时间

做出选择

在撰写本文时,Python 社区中流传着两个关于 asyncio 的恶意谎言。首先,asyncio 在并发性方面取得了“胜利”。第二是它不好,不应该使用。毫不奇怪,真相就在中间。Asyncio 对于很大程度上受限于 io 的网络客户端来说非常出色,但不是万能的。

在选择不同的方法时,首先要问自己的问题是,您的代码是将大部分时间用于等待 IO,还是将大部分时间用于处理数据。等待一会儿然后进行大量计算的任务不太适合 asyncio,因为它可以并行化等待,但不能并行化执行,从而留下大量 CPU 受限的任务需要执行。同样,它也不是线程池的天然选择,因为 GIL 会阻止各种线程真正并行运行。多进程部署有更高的开销,但是能够利用 CPU 绑定代码中的真正并行化。

如果任务等待的时间确实比执行代码的时间长,那么 asyncio 或基于线程的并行化方法可能是最佳选择。根据经验,我建议对调用服务器但自己不等待网络请求的应用使用 asyncio,对接受入站连接的应用使用进程池和线程池的组合。 12 表示这一点的决策树如图 7-10 所示。

img/481001_1_En_7_Fig10_HTML.jpg

图 7-10

客户机/服务器应用中并行化方法的决策树

这不是一个硬性规定;有太多的例外可以列出来,您应该考虑您的应用的细节并测试您的假设,但总的来说,我更喜欢针对服务器应用的抢占式多任务处理 13 的健壮且可预测的行为。

我们的传感器 API 端点完全是标准的 Python,但是通过 waste WSGI 服务器运行。WSGI 服务器为我们做出并发决策,用waitress-serve实例化一个四线程线程池来处理入站请求。

收集器进程在每次调用中都需要大量的等待,并且完全是客户端的,因此使用 asyncio 来实现其并发行为是一个很好的选择。

摘要

在本章中,我们已经了解了两种最常见的并行化类型,线程化和异步化,以及其他不太常用的方法。并发性是一个困难的话题,我们还没有讨论完使用 asyncio 可以实现的事情,但是我们现在将线程放在后面。

异步编程是一个非常强大的工具,所有 Python 程序员都应该知道,但线程和 asyncio 的权衡非常不同,一般来说,在任何给定的程序中,只有一个是有用的。

如果您需要用 Python 编写一个依赖于并发性的程序,我强烈建议您尝试不同的方法,找出最适合您的问题的方法。我也鼓励你确保理解我们在本章中使用的所有同步原语的用法,因为锁的适当使用可以决定一个缓慢而难以理解的程序和一个快速而直观编写的程序之间的差别。

额外资源

下面的链接包含了一些有用的背景信息,这些信息与我在本章中讨论的主题以及一些其他不太常见的方法有关:

Footnotes 1

它还可以做其他事情,比如检测文件何时可以写入,但这与我们的需求无关。

  2

假设您使用的是 HTTP 0.9、1.0 或 1.1。HTTP 2.0 及更高版本是二进制协议。

  3

这是一个可怕的假设,会导致大量间歇性的错误。要真正做到这一点,我们必须逐块构建响应。

  4

使用 PyPy 的一个原因是它有一个 JIT 编译器,使得一些代码运行得更快。与 CPython 的兼容性不是 100%,这主要是由于编译后的扩展是如何处理的,但是在 PyPy 下,程序的性能可能会有很大的不同。也许值得一试,看看你的程序是否工作,它们是快了还是慢了。

  5

拆解的简称。

  6

一步完成的不能分割的操作。

  7

Python 的一些实现,尤其是那些运行在底层虚拟机(如 Jython)上的实现,不实现 GIL,因为虚拟机提供了相同的保证。总体效果是一样的,只是字节码和切换的具体细节不一样。

  8

除了为线程安全而设计的变量类型,如队列。

  9

后进先出先进先出

  10

他们也有一些内省队列状态的方法,比如empty()full()qsize()。不过,底层队列可能会在检查状态和下一条指令之间发生变化。只有当你对程序的状态有额外的保证时,这些方法才真正有用,所以你知道队列不会改变。

  11

这是一个虚构的例子,因为没有理由编写一个只返回自己的参数的函数,特别是以异步方式,但是如果我们设想这不是返回输入,而是调用返回数据的 web 服务,这就更有道理了。这是一个有用的功能和更容易理解的东西之间的权衡。

  12

我向维护 Guillotina 项目的朋友 Nathan 和 Ramon 道歉,Guillotina 项目是 Python 中高性能 REST APIs 的专家框架,他们将大力宣传 asyncio 在服务器代码中的好处。

  13

抢占式多任务处理是指一个中央机构可以中断任务,给另一个任务一个执行的机会。在这种情况下,Python GIL 通过其切换间隔强制执行此操作。另一种选择,即合作多任务,是任务必须自愿放弃对同伴的控制。这是协程这个名字的基础,也是 Windows 3.1 应用中的错误如此容易地在无意中冻结系统的原因。