Python 专家级编程第二版(六)
原文:
zh.annas-archive.org/md5/4CC2EF9A4469C814CC3EEBD966D2E707译者:飞龙
第十二章:优化-一些强大的技术
优化程序并不是一个神奇的过程。它是通过遵循一个简单的算法完成的,由 Stefan Schwarzer 在 Europython 2006 中合成的原始伪代码示例:
def optimize():
"""Recommended optimization"""
assert got_architecture_right(), "fix architecture"
assert made_code_work(bugs=None), "fix bugs"
while code_is_too_slow():
wbn = find_worst_bottleneck(just_guess=False,
profile=True)
is_faster = try_to_optimize(wbn,
run_unit_tests=True,
new_bugs=None)
if not is_faster:
undo_last_code_change()
# By Stefan Schwarzer, Europython 2006
这个例子可能不是最整洁和最清晰的例子,但基本上涵盖了组织优化过程的所有重要方面。我们从中学到的主要内容是:
-
优化是一个迭代过程,不是每一次迭代都会带来更好的结果
-
主要的前提是经过测试验证的代码能够正常工作
-
您应该始终专注于优化当前的应用程序瓶颈
使您的代码运行更快并不是一件容易的事情。在抽象数学问题的情况下,解决方案当然在于选择正确的算法和适当的数据结构。但在这种情况下,很难提供一些通用的提示和技巧,可以用于解决算法问题的任何代码。当然,有一些通用的方法论用于设计新算法,甚至可以应用于各种问题的元启发式算法,但它们是相当与语言无关的,因此超出了本书的范围。
无论如何,一些性能问题只是由特定的代码质量缺陷或应用程序使用上下文引起的。例如,应用程序的速度可能会因为:
-
基本内置类型的错误使用
-
过于复杂
-
硬件资源使用模式与执行环境不匹配
-
等待第三方 API 或后台服务的响应时间过长
-
在应用程序的时间关键部分做得太多
更多时候,解决这些性能问题并不需要高级的学术知识,而只需要良好的软件工艺。而工艺的一大部分就是知道何时使用适当的工具。幸运的是,有一些处理性能问题的众所周知的模式和解决方案。
在本章中,我们将讨论一些流行且可重复使用的解决方案,使您能够通过非算法优化程序:
-
降低复杂性
-
使用架构权衡
-
缓存
降低复杂性
在我们进一步探讨优化技术之前,让我们明确定义我们要处理的内容。从本章的介绍中,我们知道专注于改进应用程序瓶颈对于成功的优化至关重要。瓶颈是严重限制程序或计算机系统容量的单个组件。每个具有性能问题的代码的一个重要特征是它通常只有一个瓶颈。我们在上一章中讨论了一些分析技术,所以您应该已经熟悉了定位和隔离这些地方所需的工具。如果您的分析结果显示有一些地方需要立即改进,那么您应该首先尝试将每个地方视为一个独立的组件并进行独立优化。
当然,如果没有明显的瓶颈,但您的应用程序仍然表现不符合您的期望,那么您真的处于一个糟糕的位置。优化过程的收益与优化瓶颈的性能影响成正比。优化每个不会对整体执行时间或资源消耗产生实质性贡献的小组件,只会让您在分析和优化上花费的时间获益微薄。如果您的应用程序似乎没有真正的瓶颈,有可能是您遗漏了某些东西。尝试使用不同的分析策略或工具,或者从不同的角度(内存、I/O 操作或网络吞吐量)来看待它。如果这并没有帮助,您应该真正考虑修改您的软件架构。
但是,如果您成功找到了限制应用程序性能的单个完整组件,那么您真的很幸运。很有可能,只需进行最小的代码改进,您就能真正提高代码执行时间和/或资源使用率。而优化的收益将再次与瓶颈的大小成正比。
在尝试提高应用程序性能时,首要和最明显的方面是复杂性。关于程序复杂性有很多定义,也有很多表达方式。一些复杂度度量标准可以提供关于代码行为的客观信息,有时这些信息可以推断出性能期望。有经验的程序员甚至可以可靠地猜测两种不同的实现在实践中的性能,知道它们的复杂性和现实的执行环境。
定义应用程序复杂性的两种流行方法是:
-
圈复杂度经常与应用程序性能相关联
-
Landau 符号,也称为大 O 符号,是一种非常有用的算法分类方法,可以客观地评判性能。
从那里,优化过程有时可以理解为降低复杂性的过程。本节提供了简化循环的简单技巧。但首先,让我们学习如何测量复杂性。
圈复杂度
圈复杂度是由 Thomas J. McCabe 在 1976 年开发的一个度量标准。因为它的作者,它经常被称为 McCabe 的复杂度。它衡量了代码中的线性路径数量。所有的 if,for 和 while 循环都被计算出一个度量。
然后可以将代码分类如下:
| 圈复杂度 | 它的含义 |
|---|---|
| 1 到 10 | 不复杂 |
| 11 到 20 | 中等复杂 |
| 21 到 50 | 真的很复杂 |
| 大于 50 | 太复杂 |
圈复杂度更多是代码质量评分,而不是客观评判其性能的度量标准。它不能取代寻找性能瓶颈的代码性能分析的需要。无论如何,具有较高圈复杂度的代码往往倾向于使用相当复杂的算法,这些算法在输入数据较大时可能表现不佳。
尽管圈复杂度不是判断应用程序性能的可靠方法,但它有一个非常好的优势。它是一个源代码度量标准,因此可以用适当的工具来测量。这不能说是关于表达复杂性的其他方式——大 O 符号。由于可测量性,圈复杂度可能是对性能分析的有用补充,它可以为您提供有关软件问题部分的更多信息。在考虑根本性的代码架构重设计时,复杂的代码部分是首先要审查的。
在 Python 中测量 McCabe 的复杂度相对简单,因为它可以从其抽象语法树中推导出来。当然,你不需要自己做这个。一个为 Python 提供圈复杂度测量的流行工具是 flake8(带有 mccabe 插件),它已经在第四章“选择良好的名称”中介绍过。
大 O 符号
定义函数复杂性的最经典方法是大 O 符号。这个度量标准定义了算法如何受输入数据大小的影响。例如,算法是否与输入数据的大小成线性关系还是二次关系?
手动计算算法的大 O 符号是获得算法性能与输入数据大小关系概览的最佳方法。了解应用程序组件的复杂度使您能够检测并专注于真正减慢代码的部分。
为了衡量大 O 符号,所有常数和低阶项都被移除,以便专注于当输入数据增长时真正起作用的部分。这个想法是尝试将算法归类为这些类别中的一个,即使它是一个近似值:
| 符号 | 类型 |
|---|---|
| O(1) | 常数。不依赖于输入数据。 |
| O(n) | 线性。随着“n”的增长而增长。 |
| O(n log n) | 准线性。 |
| O(n²) | 二次复杂度。 |
| O(n³) | 立方复杂度。 |
| O(n!) | 阶乘复杂度。 |
例如,我们已经从第二章中知道,dict查找的平均复杂度是O(1)。无论dict中有多少元素,它都被认为是常数,而查找特定项的列表中的元素是O(n)。
让我们来看另一个例子:
>>> def function(n):
... for i in range(n):
... print(i)
...
在这种情况下,打印语句将被执行n次。循环速度将取决于n,因此它的复杂度使用大 O 符号表示将是O(n)。
如果函数有条件,保留的正确符号是最高的:
>>> def function(n):
... if some_test:
... print('something')
... else:
... for i in range(n):
... print(i)
...**
在这个例子中,函数可能是O(1)或O(n),取决于测试。但最坏情况是O(n),所以整个函数的复杂度是O(n)。
在讨论用大 O 符号表示的复杂度时,我们通常会考虑最坏情况。虽然这是在比较两个独立算法的复杂度时最好的方法,但在每种实际情况下可能不是最佳方法。许多算法会根据输入数据的统计特征改变运行时性能,或者通过巧妙的技巧摊销最坏情况操作的成本。这就是为什么在许多情况下,最好以平均复杂度或摊销复杂度来审查你的实现。
例如,看一下将单个元素附加到 Python 的list类型实例的操作。我们知道 CPython 中的list使用具有内部存储的过度分配的数组,而不是链表。如果数组已满,附加新元素需要分配新数组,并将所有现有元素(引用)复制到内存中的新区域。如果从最坏情况复杂度的角度来看,很明显list.append()方法的复杂度是O(n)。与链表结构的典型实现相比,这有点昂贵。
但我们也知道 CPython 的list类型实现使用过度分配来减轻这种偶尔重新分配的复杂性。如果我们评估一系列操作的复杂性,我们会发现list.append()的平均复杂度是O(1),这实际上是一个很好的结果。
在解决问题时,我们通常对输入数据的许多细节有很多了解,比如它的大小或统计分布。在优化应用程序时,始终值得利用关于输入数据的每一个知识点。在这里,最坏情况复杂度的另一个问题开始显现出来。它旨在显示函数在输入趋向于大值或无穷大时的极限行为,而不是为真实数据提供可靠的性能近似值。渐近符号在定义函数的增长率时非常有用,但它不会对一个简单的问题给出可靠的答案:哪种实现会花费更少的时间?最坏情况复杂度会忽略关于你的实现和数据特征的所有细节,以显示你的程序在渐近上的行为。它适用于可能根本不需要考虑的任意大的输入。
例如,假设您有一个关于由n个独立元素组成的数据的问题要解决。再假设您知道两种不同的解决这个问题的方法——程序 A和程序 B。您知道程序 A需要 100n²次操作才能完成,而程序 B需要 5n³次操作才能给出问题的解决方案。您会选择哪一个?当谈论非常大的输入时,程序 A当然是更好的选择,因为它在渐近上表现更好。它的复杂度是O(n²),而程序 B的复杂度是O(n³)。
但是通过解决一个简单的 100 n² > 5 n³不等式,我们可以发现当n小于 20 时,程序 B将需要更少的操作。如果我们对输入范围有更多了解,我们可以做出稍微更好的决策。
简化
为了减少代码的复杂性,数据存储的方式是基础性的。您应该仔细选择数据结构。本节提供了一些简单代码片段的性能如何通过适当的数据类型来提高的示例。
在列表中搜索
由于 Python 中list类型的实现细节,搜索列表中特定值不是一个廉价的操作。list.index()方法的复杂度是O(n),其中n是列表元素的数量。如果不需要执行许多元素索引查找,这种线性复杂度并不特别糟糕,但如果需要执行许多这样的操作,它可能会产生负面的性能影响。
如果您需要在列表上进行快速搜索,可以尝试 Python 标准库中的bisect模块。该模块中的函数主要设计用于以保持已排序序列顺序的方式插入或查找给定值的插入索引。无论如何,它们可以用于使用二分算法有效地查找元素索引。以下是官方文档中使用二分搜索查找元素索引的函数的配方:
def index(a, x):
'Locate the leftmost value exactly equal to x'
i = bisect_left(a, x)
if i != len(a) and a[i] == x:
return i
raise ValueError
请注意,bisect模块中的每个函数都需要一个排序好的序列才能工作。如果您的列表没有按正确的顺序排列,那么对其进行排序至少需要*O(n log n)的复杂度。这是比O(n)*更糟糕的类别,因此对整个列表进行排序仅进行单个搜索肯定不划算。但是,如果您需要在一个不经常改变的大列表中执行大量索引搜索,那么使用单个排序操作的bisect可能是一个非常好的折衷方案。
另外,如果您已经有一个排序好的列表,您可以使用bisect插入新的项目到该列表中,而无需重新排序。
使用set而不是列表
当您需要从给定序列中构建一系列不同值时,可能首先想到的算法是:
>>> sequence = ['a', 'a', 'b', 'c', 'c', 'd']
>>> result = []
>>> for element in sequence:
... if element not in result:
... result.append(element)
...**
>>> result
['a', 'b', 'c', 'd']
复杂度是由在result列表中使用in运算符引入的,它的时间复杂度是O(n)。然后它在循环中使用,这将花费O(n)。因此,总体复杂度是二次的—O(n²)。
对于相同的工作使用set类型将更快,因为存储的值使用哈希查找,就像dict类型一样。此外,set确保元素的唯一性,因此我们不需要做任何额外的工作,只需从我们的sequence对象创建一个新的集合。换句话说,对于sequence中的每个值,查看它是否已经在set中所花费的时间将是恒定的:
>>> sequence = ['a', 'a', 'b', 'c', 'c', 'd']
>>> result = set(sequence)
>>> result
set(['a', 'c', 'b', 'd'])
这将复杂度降低到O(n),这是set对象创建的复杂度。额外的优势是代码更短更明确。
注意
当您尝试降低算法的复杂度时,要仔细考虑您的数据结构。有各种内置类型,所以要选择合适的类型。
减少外部调用,减轻工作量
复杂性的一部分是由于调用其他函数、方法和类引入的。一般来说,尽可能多地将代码从循环中移出。对于嵌套循环来说,这一点尤为重要。不要一遍又一遍地重新计算那些在循环开始之前就可以计算出来的东西。内部循环应该是紧凑的。
使用 collections
collections 模块提供了高性能的替代内置容器类型。该模块中提供的主要类型有:
-
deque:带有额外功能的类似列表的类型 -
defaultdict:带有内置默认工厂功能的类似字典的类型 -
namedtuple:类似元组的类型,为成员分配键
deque
deque 是列表的另一种实现方式。列表基于数组,而 deque 基于双向链表。因此,当需要在中间或头部插入时,deque 要快得多,但是当需要访问任意索引时,deque 要慢得多。
当然,由于 Python list 类型中内部数组的过度分配,不是每次 list.append() 调用都需要内存重新分配,而这种方法的平均复杂度是 O(1)。但是,pops 和 appends 在链表上执行时通常比在数组上执行要快。当元素需要添加到序列的任意点时,情况会发生戏剧性的变化。因为数组中新元素右侧的所有元素都需要移动,所以 list.insert() 的复杂度是 O(n)。如果需要执行大量的 pops、appends 和 inserts,那么使用 deque 而不是列表可能会提供显著的性能改进。但是在从 list 切换到 deque 之前,一定要对代码进行分析,因为在数组中快速的一些操作(例如访问任意索引)在链表中非常低效。
例如,如果我们使用 timeit 测量向序列添加一个元素并从中删除的时间,list 和 deque 之间的差异甚至可能不会被注意到:
$ python3 -m timeit \
> -s 'sequence=list(range(10))' \
> 'sequence.append(0); sequence.pop();'
1000000 loops, best of 3: 0.168 usec per loop
$ python3 -m timeit \**
> -s 'from collections import deque; sequence=deque(range(10))' \
> 'sequence.append(0); sequence.pop();'
1000000 loops, best of 3: 0.168 usec per loop
但是,如果我们对想要添加和移除序列的第一个元素的情况进行类似的比较,性能差异是显著的:
$ python3 -m timeit \
> -s 'sequence=list(range(10))' \
> 'sequence.insert(0, 0); sequence.pop(0)'
1000000 loops, best of 3: 0.392 usec per loop
$ python3 -m timeit \
> -s 'from collections import deque; sequence=deque(range(10))' \
> 'sequence.appendleft(0); sequence.popleft()'
10000000 loops, best of 3: 0.172 usec per loop
而且,当序列的大小增长时,这种差异会变得更大。以下是对包含 10,000 个元素的列表执行相同测试的示例:
$ python3 -m timeit \
> -s 'sequence=list(range(10000))' \
> 'sequence.insert(0, 0); sequence.pop(0)'
100000 loops, best of 3: 14 usec per loop
$ python3 -m timeit \
> -s 'from collections import deque; sequence=deque(range(10000))' \**
> 'sequence.appendleft(0); sequence.popleft()'
10000000 loops, best of 3: 0.168 usec per loop
由于高效的 append() 和 pop() 方法可以同时从序列的两端以相同的速度工作,deque 是实现队列的完美类型。例如,使用 deque 而不是 list 来实现 FIFO(先进先出)队列将会更加高效。
注意
deque 在实现队列时效果很好。不过,从 Python 2.6 开始,Python 标准库中有一个单独的 queue 模块,提供了 FIFO、LIFO 和优先级队列的基本实现。如果要将队列用作线程间通信的机制,应该使用 queue 模块中的类,而不是 collections.deque。这是因为这些类提供了所有必要的锁定语义。如果不使用线程和不使用队列作为通信机制,那么 deque 应该足够提供队列实现的基础。
defaultdict
defaultdict 类型类似于 dict 类型,但为新键添加了一个默认工厂。这避免了编写额外的测试来初始化映射条目,并且比 dict.setdefault 方法更高效。
defaultdict 看起来只是 dict 上的语法糖,简单地允许您编写更短的代码。实际上,在失败的键查找时返回预定义值也比 dict.setdefault() 方法稍微快一些:
$ python3 -m timeit \
> -s 'd = {}'**
> 'd.setdefault("x", None)'
10000000 loops, best of 3: 0.153 usec per loop
$ python3 -m timeit \**
> -s 'from collections import defaultdict; d=defaultdict(lambda: None)' \
> 'd["x"]'
10000000 loops, best of 3: 0.0447 usec per loop
差异并不大,因为计算复杂度并没有改变。dict.setdefault方法包括两个步骤(键查找和键设置),这两个步骤的复杂度都是O(1),正如我们在第二章的字典部分中所看到的,语法最佳实践-类级别以下。没有办法使复杂度低于O(1)。但在某些情况下,它无疑更快,值得知道,因为在优化关键代码部分时,每一个小的速度提升都很重要。
defaultdict类型接受一个工厂作为参数,因此可以与不需要参数的内置类型或类一起使用其构造函数。以下是官方文档中的一个示例,展示了如何使用defaultdict进行计数:
>>> s = 'mississippi'
>>> d = defaultdict(int)
>>> for k in s:
... d[k] += 1
...
>>> list(d.items())
[('i', 4), ('p', 2), ('s', 4), ('m', 1)]
namedtuple
namedtuple是一个类工厂,它接受一个类型名称和一个属性列表,并创建一个类。然后可以用这个类来实例化一个类似元组的对象,并为其元素提供访问器:
>>> from collections import namedtuple**
>>> Customer = namedtuple(
... 'Customer',
... 'firstname lastname'
... )
>>> c = Customer('Tarek', 'Ziadé')
>>> c.firstname
'Tarek'
它可以用来创建比需要一些样板代码来初始化值的自定义类更容易编写的记录。另一方面,它基于元组,因此通过索引访问其元素非常快。生成的类可以被子类化以添加更多操作。
使用namedtuple而不是其他数据类型的收益一开始可能并不明显。主要优点是它比普通元组更容易使用、理解和解释。元组索引不携带任何语义,因此通过属性访问元组元素也很好。但是,你也可以从具有*O(1)*获取/设置操作平均复杂度的字典中获得相同的好处。
就性能而言,namedtuple的第一个优势是它仍然是tuple的一种。这意味着它是不可变的,因此底层数组存储被分配到了所需的大小。另一方面,字典需要使用内部哈希表的过度分配来确保获取/设置操作的平均复杂度较低。因此,namedtuple在内存效率方面胜过dict。
namedtuple基于元组的事实也可能对性能有益。它的元素可以通过整数索引访问,就像另外两个简单的序列对象-列表和元组一样。这个操作既简单又快速。在dict或自定义类实例(也使用字典来存储属性)的情况下,元素访问需要哈希表查找。它经过高度优化,以确保不管集合大小如何,性能都很好,但提到的O(1)复杂度实际上只是平均复杂度。dict在设置/获取操作的实际摊销最坏情况复杂度是O(n)。在对性能关键的代码部分,有时使用列表或元组而不是字典可能是明智的。这仅仅是因为它们在性能方面更可预测。
在这种情况下,namedtuple是一种很好的类型,它结合了字典和元组的优点:
-
在更重视可读性的部分,可能更喜欢属性访问
-
在性能关键的部分,元素可以通过它们的索引访问
注意
通过将数据存储在与算法使用方式良好匹配的高效数据结构中,可以实现降低复杂性。
也就是说,当解决方案不明显时,你应该考虑放弃并重写被指责的部分,而不是为了性能而破坏代码的可读性。
通常情况下,Python 代码既可以可读又可以快速。因此,尝试找到一种执行工作的好方法,而不是试图绕过有缺陷的设计。
使用架构权衡
当您的代码无法通过减少复杂性或选择适当的数据结构来进一步改进时,一个很好的方法可能是考虑做一些权衡。如果我们审查用户问题并定义对他们来说真正重要的是什么,我们可以放松一些应用要求。性能通常可以通过以下方式改进:
-
用启发式和近似算法替换确切解算法
-
将一些工作推迟到延迟任务队列
-
使用概率数据结构
使用启发式和近似算法
有些算法问题根本没有可以在用户可接受的时间内运行的最先进解决方案。例如,考虑一个处理一些复杂优化问题的程序,如旅行商问题(TSP)或车辆路径问题(VRP)。这两个问题都是组合优化中的NP 难问题。这些问题的确切算法的复杂度较低是未知的。这意味着可以实际解决的问题规模受到极大限制。对于非常大的输入,很可能无法在用户可接受的时间内提供确切的解决方案。
幸运的是,用户很可能对最佳解决方案不感兴趣,而是对足够好且及时获得的解决方案感兴趣。因此,当启发式或近似算法提供可接受的结果质量时,使用它们确实是有意义的:
-
启发式通过在速度上进行权衡优化给定问题,而不是完整性、准确性或精度。它们专注于速度,但可能很难证明它们的解决方案质量与确切算法的结果相比。
-
近似算法与启发式类似,但与启发式不同的是,它们具有可证明的解决方案质量和运行时间界限。
例如,已知一些良好的启发式和近似问题可以在合理的时间内解决极大的 TSP 问题。它们还有很高的概率产生距最优解仅 2-5%的结果。
启发式的另一个好处是,它们并不总是需要针对您需要解决的每个新问题从头开始构建。它们的高级版本,称为元启发式,提供了解决数学优化问题的策略,这些策略不是特定于问题的,因此可以应用于许多情况。一些流行的元启发式算法包括:
-
模拟退火
-
遗传算法
-
禁忌搜索
-
蚁群优化
-
进化计算
使用任务队列和延迟处理
有时并不是做很多事情,而是在正确的时间做事情。一个很好的例子是在网页应用中发送电子邮件。在这种情况下,增加的响应时间可能并不一定是您实现的结果。响应时间可能被某些第三方服务所主导,例如电子邮件服务器。如果您的应用程序大部分时间都在等待其他服务的回复,您能优化您的应用程序吗?
答案既是肯定的也是否定的。如果您无法控制服务,这是处理时间的主要贡献者,并且没有其他更快的解决方案可用,那么您当然无法进一步加快速度。您不能简单地跳过时间以获取您正在等待的回复。下图(图 1)展示了处理 HTTP 请求并导致发送电子邮件的简单示例。您无法减少等待时间,但可以改变用户的感知方式!
图 1 网页应用中同步发送电子邮件的示例
这种类型问题的通常模式是使用消息/任务队列。当您需要做一些可能需要不确定时间的事情时,只需将其添加到需要完成的工作队列中,并立即响应接受请求的用户。这里,我们来到为什么发送电子邮件是一个很好的例子的原因。电子邮件已经是任务队列!如果您使用 SMTP 协议向电子邮件服务器提交新消息,成功的响应并不意味着您的电子邮件已经传递给收件人。这意味着电子邮件已经传递给了电子邮件服务器,并且它将稍后尝试进一步传递。
因此,如果服务器的响应并不保证电子邮件是否已经传递,您无需等待它以生成用户的 HTTP 响应。使用任务队列处理请求的更新流程如下图所示:
图 2 Web 应用程序中异步电子邮件传递的示例
当然,您的电子邮件服务器可能响应非常快,但您需要更多时间来生成需要发送的消息。也许您正在生成 XLS 格式的年度报告,或者在 PDF 文件中交付发票。如果您使用的是已经是异步的电子邮件传输,那么也将整个消息生成任务放到消息处理系统中。如果无法保证准确的交付时间,那么您不应该打扰同步生成您的交付物。
在应用程序的关键部分正确使用任务/消息队列还可以给您带来其他好处:
-
为服务 HTTP 请求的 Web 工作者将从额外的工作中解脱出来,处理请求更快。这意味着您将能够使用相同的资源处理更多的请求,从而处理更大的负载。
-
消息队列通常更不容易受到外部服务的瞬态故障的影响。例如,如果您的数据库或电子邮件服务器不时超时,您可以始终重新排队当前处理的任务并稍后重试。
-
通过良好的消息队列实现,您可以轻松地将工作分布在多台机器上。这种方法可能提高应用程序某些组件的可扩展性。
如您在图 2中所见,将异步任务处理添加到应用程序中不可避免地增加了整个系统架构的复杂性。您将需要设置一些新的后备服务(例如 RabbitMQ 这样的消息队列)并创建能够处理这些异步作业的工作者。幸运的是,有一些流行的工具用于构建分布式任务队列。在 Python 开发人员中最受欢迎的是Celery(www.celeryproject.org/)。它是一个完整的任务队列框架,支持多个消息代理,还允许定期执行任务(可以替代您的cron作业)。如果您需要更简单的东西,那么 RQ(python-rq.org/)可能是一个不错的选择。它比 Celery 简单得多,并使用 Redis 键/值存储作为其消息代理(RQ实际上代表Redis Queue)。
尽管有一些经过良好测试的工具,您应该始终仔细考虑您对任务队列的方法。绝对不是每种工作都应该在队列中处理。它们擅长解决一些问题,但也引入了一大堆新问题:
-
系统架构的复杂性增加
-
处理“多次”交付
-
更多需要维护和监控的服务
-
更长的处理延迟
-
更困难的日志记录
使用概率数据结构
概率数据结构是设计为以一种允许您在时间或资源约束内回答一些特定问题的方式存储值集合的结构,这是其他数据结构无法实现的。最重要的事实是答案只有可能是真实的或是真实值的近似。然而,可以很容易地估计正确答案的概率或准确性。因此,尽管不总是给出正确答案,如果我们接受一定程度的误差,它仍然可以是有用的。
有许多具有这种概率特性的数据结构。它们中的每一个都解决了一些特定的问题,并且由于它们的随机性质,不能在每种情况下使用。但是,为了举一个实际的例子,让我们谈谈其中一个特别受欢迎的——HyperLogLog。
HyperLogLog(参见en.wikipedia.org/wiki/HyperLogLog)是一种近似估计多重集中不同元素数量的算法。对于普通集合,您需要存储每个元素,这对于非常大的数据集可能非常不切实际。HLL 与实现集合的经典方式不同。不深入实现细节,我们可以说它只专注于提供集合基数的近似值。因此,实际值从不存储。它们不能被检索、迭代和测试成员资格。HyperLogLog 以时间复杂度和内存大小交换准确性和正确性。例如,Redis 实现的 HLL 只需要 12k 字节,标准误差为 0.81%,集合大小没有实际限制。
使用概率数据结构是解决性能问题的一种非常有趣的方式。在大多数情况下,这是在速度更快的处理或更好的资源使用之间进行一些准确性或正确性的权衡。但并不总是需要这样。概率数据结构在键/值存储系统中经常用于加速键查找。在这类系统中使用的一种流行技术称为近似成员查询(AMQ)。可以用于此目的的一个有趣的数据结构是 Bloom 过滤器(参见en.wikipedia.org/wiki/Bloom_filter)。
缓存
当您的应用程序函数计算时间过长时,可以考虑的有用技术是缓存。缓存无非是保存返回值以供将来参考。运行成本高昂的函数或方法的结果可以被缓存,只要:
-
函数是确定性的,给定相同的输入,结果每次都是相同的值
-
函数的返回值在一段时间内仍然有用且有效(非确定性)
换句话说,确定性函数对于相同的参数集始终返回相同的结果,而非确定性函数返回可能随时间变化的结果。这种方法通常大大减少了计算时间,并允许您节省大量计算资源。
任何缓存解决方案最重要的要求是具有允许您检索保存的值的存储,其速度明显快于计算它们所需的时间。通常适合缓存的是:
-
可查询数据库的可调用结果
-
来自呈现静态值的可调用的结果,例如文件内容、Web 请求或 PDF 呈现
-
来自执行复杂计算的确定性可调用的结果
-
全局映射,跟踪具有过期时间的值,例如 Web 会话对象
-
需要经常快速访问的结果
缓存的另一个重要用例是保存通过 Web 提供的第三方 API 的结果。这可能通过减少网络延迟大大提高应用程序性能,但也可以让您节省金钱,如果您被要求对此类 API 的每个请求进行计费。
根据您的应用架构,缓存可以以许多种方式和各种复杂程度实现。提供缓存的方式有很多种,复杂的应用程序可以在应用程序架构堆栈的不同级别上使用不同的方法。有时,缓存可能只是一个保留在进程内存中的单个全局数据结构(通常是dict)。在其他情况下,您可能希望设置一个专用的缓存服务,该服务将在精心定制的硬件上运行。本节将为您提供有关最流行的缓存方法的基本信息,并指导您通过常见的用例和常见的陷阱。
确定性缓存
确定性函数是缓存的最简单和最安全的用例。确定性函数如果给定完全相同的输入,总是返回相同的值,因此通常可以无限期地存储它们的结果。唯一的限制是用于缓存的存储大小。缓存这些结果的最简单方法是将它们放入进程内存中,因为这通常是从中检索数据的最快地方。这样的技术通常被称为记忆化。
在优化可能多次评估相同输入的递归函数时,记忆化非常有用。我们已经在第七章中讨论了斐波那契数列的递归实现,其他语言中的 Python 扩展。当时,我们尝试用 C 和 Cython 来改进我们的程序的性能。现在我们将尝试通过更简单的方法来实现相同的目标——借助缓存的帮助。但在这样做之前,让我们回顾一下fibonacci()函数的代码:
def fibonacci(n):
""" Return nth Fibonacci sequence number computed recursively
"""
if n < 2:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
正如我们所见,fibonacci()是一个递归函数,如果输入值大于两,它会调用自身两次。这使得它非常低效。运行时间复杂度为O(2^n),执行会创建一个非常深和广的调用树。对于大的值,这个函数将需要非常长的时间来执行,并且很有可能很快就会超过 Python 解释器的最大递归限制。
如果您仔细观察图 3,它展示了一个示例调用树,您会发现它多次评估许多中间结果。如果我们能够重用其中一些值,就可以节省大量时间和资源。
图 3 fibonacci(5)执行的调用树
一个简单的记忆化尝试是将先前运行的结果存储在字典中,并在可用时检索它们。fibonacci()函数中的递归调用都包含在一行代码中:
return fibonacci(n - 1) + fibonacci(n - 2)
我们知道 Python 从左到右评估指令。这意味着,在这种情况下,具有更高参数值的函数调用将在具有较低参数的函数调用之前执行。由于这个原因,我们可以通过构建一个非常简单的装饰器来提供记忆化:
def memoize(function):
""" Memoize the call to single-argument function
"""
call_cache = {}
def memoized(argument):
try:
return call_cache[argument]
except KeyError:
return call_cache.setdefault(argument, function(argument))
return memoized
@memoize
def fibonacci(n):
""" Return nth Fibonacci sequence number computed recursively
"""
if n < 2:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
我们在memoize()装饰器的闭包上使用了字典作为缓存值的简单存储。将值保存和检索到这个数据结构的平均O(1)复杂度,因此这大大降低了记忆化函数的总体复杂度。每个唯一的函数调用将只被评估一次。这样更新的函数的调用树如图 4所示。在不进行数学证明的情况下,我们可以直观地推断,在不改变fibonacci()函数的核心的情况下,我们将复杂度从非常昂贵的O(2n)降低到线性的O(n)。
图 4 使用记忆化执行 fibonacci(5)的调用树
当然,我们的memoize()装饰器的实现并不完美。它在那个简单的例子中表现良好,但绝对不是可重用的软件。如果您需要记忆化具有多个参数的函数或想要限制缓存的大小,您需要更通用的东西。幸运的是,Python 标准库提供了一个非常简单和可重用的实用程序,它在大多数情况下都可以用于在内存中缓存确定性函数的结果。这就是functools模块中的lru_cache(maxsize, typed)装饰器。名称来自 LRU 缓存,代表最近最少使用。附加参数允许更精细地控制记忆化行为:
-
maxsize:这设置了缓存的最大大小。None值表示没有限制。 -
typed:这定义了不同类型的值是否应该被缓存为给出相同结果。
在我们的斐波那契数列示例中使用lru_cache的方法如下:
@lru_cache(None)
def fibonacci(n):
""" Return nth Fibonacci sequence number computed recursively
"""
if n < 2:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
非确定性缓存
对于非确定性函数的缓存比记忆化更加棘手。由于这样一个函数的每次执行可能会产生不同的结果,通常不可能在任意长的时间内使用先前的值。你需要做的是决定缓存值可以被视为有效的时间有多长。在经过一段时间后,存储的结果被视为过时,缓存需要通过新值进行刷新。
通常需要缓存的非确定性函数往往依赖于很难在应用程序代码内部跟踪的某些外部状态。典型的组件示例包括:
-
关系数据库和通常任何类型的结构化数据存储引擎
-
通过网络连接访问的第三方服务(Web API)
-
文件系统
换句话说,非确定性缓存在任何情况下都可以使用,当您临时使用预先计算的结果时,而不确定它们是否代表与其他系统组件的状态一致的状态(通常是后备服务)。
请注意,这种缓存的实现显然是一种权衡。因此,它与我们在使用架构权衡部分中介绍的技术有一定关系。如果您放弃每次运行代码的一部分,而是使用过去保存的结果,您就有可能使用变得过时或代表系统不一致状态的数据。这样,您就在以速度和性能换取正确性和/或完整性。
当然,只要与缓存交互所花费的时间少于函数所花费的时间,这种缓存就是有效的。如果重新计算值更快,那就尽管这样做!这就是为什么只有在值得的情况下才需要设置缓存;正确设置缓存是有成本的。
通常缓存的实际内容通常是与系统的其他组件交互的整个结果。如果您想在与数据库通信时节省时间和资源,值得缓存昂贵的查询。如果您想减少 I/O 操作的数量,您可能希望缓存经常访问的文件的内容(例如配置文件)。
缓存非确定性函数的技术实际上与缓存确定性函数的技术非常相似。最显著的区别是它们通常需要通过其年龄来使缓存值失效的选项。这意味着functools模块中的lru_cache()装饰器在这种情况下的用途非常有限。扩展此功能以提供过期功能应该不是很难,但我会把它留给你作为一个练习。
缓存服务
我们说过,非确定性缓存可以使用本地进程内存来实现,但实际上很少这样做。这是因为本地进程内存在大型应用程序中作为缓存存储的效用非常有限。
如果你遇到非确定性缓存是你首选的解决性能问题的方案,通常你需要更多。通常,当你需要同时为多个用户提供数据或服务时,非确定性缓存是你必须要的解决方案。如果是这样,那么迟早你需要确保用户可以同时被服务。虽然本地内存提供了一种在多个线程之间共享数据的方式,但它可能不是每个应用程序的最佳并发模型。它的扩展性不好,所以最终你将需要将你的应用程序作为多个进程运行。
如果你足够幸运,你可能需要在数百甚至数千台机器上运行你的应用程序。如果你想要将缓存值存储在本地内存中,这意味着你的缓存需要在每个需要它的进程上进行复制。这不仅仅是对资源的浪费。如果每个进程都有自己的缓存,那就已经是速度和一致性之间的权衡,你如何保证所有的缓存与彼此一致呢?
在后续请求之间保持一致性是一个严重的问题(尤其是)对于具有分布式后端的 Web 应用程序。在复杂的分布式系统中,确保用户始终由托管在同一台机器上的同一进程一致地提供服务是非常困难的。当然,在一定程度上是可以做到的,但一旦解决了这个问题,就会出现十个其他问题。
如果你正在开发一个需要为多个并发用户提供服务的应用程序,那么处理非确定性缓存的最佳方式是使用专门的服务。通过使用 Redis 或 Memcached 等工具,你可以让所有的应用程序进程共享相同的缓存结果。这既减少了宝贵的计算资源的使用,也避免了由多个独立和不一致的缓存引起的问题。
Memcached
如果你想认真对待缓存,Memcached是一个非常流行且经过实战验证的解决方案。这个缓存服务器被像 Facebook 或 Wikipedia 这样的大型应用程序用来扩展他们的网站。除了简单的缓存功能,它还具有集群功能,可以在很短的时间内建立一个高效的分布式缓存系统。
这个工具是基于 Unix 的,但可以从任何平台和许多语言驱动。有许多略有不同的 Python 客户端,但基本用法通常是相同的。与 Memcached 的最简单交互几乎总是由三种方法组成:
-
set(key, value): 保存给定键的值 -
get(key): 如果存在,获取给定键的值 -
delete(key): 如果存在,删除给定键下的值
这里有一个与 Memcached 集成的示例,使用了流行的 Python 包之一——pymemcached:
from pymemcache.client.base import Client
# setup Memcached client running under 11211 port on localhost
client = Client(('localhost', 11211))
# cache some value under some key and expire it after 10 seconds
client.set('some_key', 'some_value', expire=10)
# retrieve value for the same key
result = client.get('some_key')
Memcached 的一个缺点是它设计用于将值存储为字符串或二进制数据块,这与每种本地 Python 类型都不兼容。实际上,它只与一种类型兼容——字符串。这意味着更复杂的类型需要被序列化才能成功存储在 Memcached 中。对于简单数据结构来说,常见的序列化选择是 JSON。这里有一个使用 JSON 序列化与pymemcached的示例:
import json
from pymemcache.client.base import Client
def json_serializer(key, value):
if type(value) == str:
return value, 1
return json.dumps(value), 2
def json_deserializer(key, value, flags):
if flags == 1:
return value
if flags == 2:
return json.loads(value)
raise Exception("Unknown serialization format")
client = Client(('localhost', 11211), serializer=json_serializer,
deserializer=json_deserializer)
client.set('key', {'a':'b', 'c':'d'})
result = client.get('key')
与每个基于键/值存储原则的缓存服务一起工作时,非常常见的另一个问题是如何选择键名。
对于缓存简单函数调用的情况,通常问题比较简单。您可以将函数名和其参数转换为字符串并将它们连接在一起。您唯一需要关心的是确保为应用程序的许多部分使用缓存时,为不同函数创建的键之间没有冲突。
更棘手的情况是当缓存函数具有由字典或自定义类组成的复杂参数时。在这种情况下,您需要找到一种方法以一致的方式将这样的调用签名转换为缓存键。
最后一个问题是,像许多其他缓存服务一样,Memcached 不太喜欢非常长的键字符串。通常,越短越好。长键可能会降低性能,或者根本不适合硬编码的服务限制。例如,如果你缓存整个 SQL 查询,查询字符串本身通常是可以用作键的良好唯一标识符。但另一方面,复杂的查询通常太长,无法存储在诸如 Memcached 之类的典型缓存服务中。一个常见的做法是计算MD5、SHA或任何其他哈希函数,并将其用作缓存键。Python 标准库有一个hashlib模块,提供了几种流行的哈希算法的实现。
请记住,计算哈希是有代价的。然而,有时这是唯一可行的解决方案。在处理需要用于创建缓存键的复杂类型时,这也是一种非常有用的技术。在使用哈希函数时需要注意的一件重要事情是哈希冲突。没有哈希函数能保证冲突永远不会发生,所以一定要知道概率并注意这样的风险。
总结
在本章中,您已经学到了:
-
如何定义代码的复杂性以及一些减少复杂性的方法
-
如何利用一些架构上的权衡来提高性能
-
缓存是什么以及如何使用它来提高应用程序性能
前面的方法集中了我们在单个进程内的优化工作。我们试图减少代码复杂性,选择更好的数据类型,或者重用旧的函数结果。如果这些都没有帮助,我们尝试做一些权衡,使用近似值,做得更少,或者留下工作以后再做。
在下一章中,我们将讨论一些 Python 中的并发和并行处理技术。
第十三章:并发
并发及其表现之一——并行处理——是软件工程领域中最广泛的主题之一。本书中的大部分章节也涵盖了广泛的领域,几乎所有这些章节都可以成为一本独立的书的大主题。但并发这个主题本身是如此庞大,以至于它可能需要数十个职位,我们仍然无法讨论其所有重要方面和模型。
这就是为什么我不会试图愚弄你,并且从一开始就声明我们几乎不会深入讨论这个话题。本章的目的是展示为什么你的应用程序可能需要并发,何时使用它,以及你可以在 Python 中使用的最重要的并发模型:
-
多线程
-
多处理
-
异步编程
我们还将讨论一些语言特性、内置模块和第三方包,这些都可以让你在代码中实现这些模型。但我们不会详细讨论它们。把本章的内容当作你进一步研究和阅读的起点。它在这里是为了引导你了解基本的想法,并帮助你决定是否真的需要并发,以及哪种方法最适合你的需求。
为什么要并发?
在回答“为什么要并发”之前,我们需要问“并发到底是什么?”
对第二个问题的答案可能会让一些人感到惊讶,他们曾经认为这是并行处理的同义词。但并发不同于并行。并发不是应用程序实现的问题,而只是程序、算法或问题的属性。并行只是处理并发问题的可能方法之一。
1976 年,Leslie Lamport 在他的《分布式系统中的时间、时钟和事件排序》一文中说:
"如果两个事件互不影响,则它们是并发的。"
通过将事件推广到程序、算法或问题,我们可以说如果某事物可以被完全或部分分解为无序的组件(单元),那么它就是并发的。这些单元可以相互独立地进行处理,处理的顺序不会影响最终结果。这意味着它们也可以同时或并行处理。如果我们以这种方式处理信息,那么我们确实在处理并行处理。但这并非强制性的。
以分布式方式进行工作,最好利用多核处理器或计算集群的能力,是并发问题的自然结果。但这并不意味着这是处理并发的唯一有效方式。有很多用例,可以以非同步的方式处理并发问题,但不需要并行执行。
因此,一旦我们知道了并发到底是什么,就是时候解释这到底是怎么回事了。当问题是并发的时候,它给了你处理它的机会,以一种特殊的、更有效的方式。
我们经常习惯用经典的方式处理问题,通过一系列步骤来解决问题。这是我们大多数人思考和处理信息的方式——使用同步算法逐步进行。但这种信息处理方式并不适合解决大规模问题或需要同时满足多个用户或软件代理的需求:
-
处理工作的时间受单个处理单元(单台机器、CPU 核心等)性能的限制
-
在程序完成处理前,无法接受和处理新的输入
因此,通常处理并发问题的最佳方法是同时处理:
-
问题的规模如此之大,以至于在可接受的时间范围内或在可用资源范围内处理它们的唯一方法是将执行分配给能够并行处理工作的多个处理单元。
-
你的应用程序需要保持响应性(接受新输入),即使它还没有完成处理旧的输入
这涵盖了大多数情况下并发处理是一个合理选择的情况。第一组问题明显需要并行处理解决方案,因此通常使用多线程和多处理模型来解决。第二组问题不一定需要并行处理,因此实际解决方案取决于问题的细节。请注意,这组问题还涵盖了应用程序需要独立为多个客户(用户或软件代理)提供服务,而无需等待其他成功服务的情况。
另一件值得一提的事情是,前面两组并不是互斥的。很多时候,你需要保持应用程序的响应性,同时又无法在单个处理单元上处理输入。这就是为什么在并发性方面,不同的看似替代或冲突的方法经常同时使用的原因。这在开发 Web 服务器时尤其常见,可能需要使用异步事件循环,或者线程与多个进程的结合,以利用所有可用资源并在高负载下保持低延迟。
多线程
线程通常被开发人员认为是一个复杂的话题。虽然这种说法完全正确,但 Python 提供了高级类和函数,简化了线程的使用。CPython 对线程的实现带来了一些不便的细节,使它们比其他语言中的线程更少用。它们对于一些你可能想要解决的问题仍然完全合适,但不像在 C 或 Java 中那样多。在本节中,我们将讨论 CPython 中多线程的限制,以及 Python 线程是可行解决方案的常见并发问题。
什么是多线程?
线程是执行的线程的缩写。程序员可以将他或她的工作分成同时运行并共享相同内存上下文的线程。除非你的代码依赖于第三方资源,多线程在单核处理器上不会加快速度,甚至会增加一些线程管理的开销。多线程将受益于多处理器或多核机器,并将在每个 CPU 核心上并行执行每个线程,从而使程序更快。请注意,这是一个通用规则,对大多数编程语言都应该成立。在 Python 中,多核 CPU 上的多线程性能收益有一些限制,但我们将在后面讨论。为简单起见,现在假设这个说法是正确的。
相同上下文被线程共享的事实意味着你必须保护数据免受并发访问。如果两个线程在没有任何保护的情况下更新相同的数据,就会发生竞争条件。这被称为竞争危害,因为每个线程运行的代码对数据状态做出了错误的假设,可能会导致意外的结果发生。
锁机制有助于保护数据,线程编程一直是确保资源以安全方式被线程访问的问题。这可能非常困难,线程编程经常会导致难以调试的错误,因为它们很难重现。最糟糕的问题发生在由于糟糕的代码设计,两个线程锁定一个资源并尝试获取另一个线程已锁定的资源。它们将永远等待对方。这被称为死锁,非常难以调试。可重入锁通过确保线程不会尝试两次锁定资源来在一定程度上帮助解决这个问题。
然而,当线程用于专门为它们构建的工具的孤立需求时,它们可能会提高程序的速度。
多线程通常在系统内核级别得到支持。当计算机只有一个处理器和一个核心时,系统使用时间片机制。在这里,CPU 从一个线程快速切换到另一个线程,以至于产生线程同时运行的错觉。这也是在处理级别上完成的。在没有多个处理单元的情况下,并行性显然是虚拟的,并且在这样的硬件上运行多个线程并不会带来性能提升。无论如何,有时即使必须在单个核心上执行代码,实现代码的多线程仍然是有用的,我们稍后将看到一个可能的用例。
当执行环境具有多个处理器或多个 CPU 核心时,一切都会发生变化。即使使用时间片,进程和线程也会分布在 CPU 之间,从而提供更快地运行程序的能力。
Python 如何处理线程
与其他一些语言不同,Python 使用多个内核级别的线程,每个线程都可以运行解释器级别的任何线程。但是,语言的标准实现——CPython——存在重大限制,使得在许多情况下线程的可用性降低。所有访问 Python 对象的线程都由一个全局锁串行化。这是因为解释器的许多内部结构以及第三方 C 代码都不是线程安全的,需要受到保护。
这种机制称为全局解释器锁(GIL),其在 Python/C API 级别的实现细节已经在第七章的释放 GIL部分中讨论过,其他语言中的 Python 扩展。GIL 的移除是 python-dev 电子邮件列表上偶尔出现的一个话题,并且被开发人员多次提出。遗憾的是,直到现在,没有人成功提供一个合理简单的解决方案,使我们能够摆脱这个限制。高度不可能在这个领域看到任何进展。更安全的假设是 GIL 将永远存在于 CPython 中。因此,我们需要学会如何与之共存。
那么在 Python 中使用多线程有什么意义呢?
当线程只包含纯 Python 代码时,使用线程加速程序几乎没有意义,因为 GIL 会串行化它。但请记住,GIL 只是强制只有一个线程可以在任何时候执行 Python 代码。在实践中,全局解释器锁会在许多阻塞系统调用上被释放,并且可以在不使用任何 Python/C API 函数的 C 扩展的部分中被释放。这意味着多个线程可以并行执行 I/O 操作或在某些第三方扩展中执行 C 代码。
对于使用外部资源或涉及 C 代码的非纯代码块,多线程对等待第三方资源返回结果是有用的。这是因为一个明确释放了 GIL 的休眠线程可以等待并在结果返回时唤醒。最后,每当程序需要提供响应式界面时,多线程都是答案,即使它使用时间片。程序可以在进行一些繁重的计算的同时与用户交互,所谓的后台。
请注意,GIL 并不是 Python 语言的每个实现都存在。这是 CPython、Stackless Python 和 PyPy 的限制,但在 Jython 和 IronPython 中并不存在(参见第一章,“Python 的当前状态”)。尽管 PyPy 也在开发无 GIL 版本,但在撰写本书时,它仍处于实验阶段,文档不完善。它基于软件事务内存,称为 PyPy-STM。很难说它何时(或是否)会正式发布为生产就绪的解释器。一切似乎表明这不会很快发生。
何时应该使用线程?
尽管有 GIL 的限制,但线程在某些情况下确实非常有用。它们可以帮助:
-
构建响应式界面
-
委托工作
-
构建多用户应用程序
构建响应式界面
假设您要求系统通过图形用户界面将文件从一个文件夹复制到另一个文件夹。任务可能会被推送到后台,并且界面窗口将由主线程不断刷新。这样您就可以实时了解整个过程的进展。您还可以取消操作。这比原始的cp或copy shell 命令少了一些烦恼,因为它在所有工作完成之前不提供任何反馈。
响应式界面还允许用户同时处理多个任务。例如,Gimp 可以让您在处理一张图片的同时处理另一张图片,因为这两个任务是独立的。
在尝试实现这样的响应界面时,一个很好的方法是将长时间运行的任务推送到后台,或者至少尝试为用户提供持续的反馈。实现这一点的最简单方法是使用线程。在这种情况下,它们的目的不是为了提高性能,而只是确保用户即使需要处理一些数据较长时间,也可以继续操作界面。
如果这样的后台任务执行大量 I/O 操作,您仍然可以从多核 CPU 中获得一些好处。这是一个双赢的局面。
委托工作
如果您的进程依赖于第三方资源,线程可能会真正加快一切。
让我们考虑一个函数的情况,该函数索引文件夹中的文件并将构建的索引推送到数据库中。根据文件的类型,该函数调用不同的外部程序。例如,一个专门用于 PDF,另一个专门用于 OpenOffice 文件。
您的函数可以为每个转换器设置一个线程,并通过队列将要完成的工作推送给它们中的每一个,而不是按顺序处理每个文件,执行正确的程序,然后将结果存储到数据库中。函数所花费的总时间将更接近最慢转换器的处理时间,而不是所有工作的总和。
转换器线程可以从一开始就初始化,并且负责将结果推送到数据库的代码也可以是一个消耗队列中可用结果的线程。
请注意,这种方法在某种程度上是多线程和多进程的混合。如果您将工作委托给外部进程(例如,使用subprocess模块的run()函数),实际上是在多个进程中进行工作,因此具有多进程的特征。但在我们的情况下,我们在单独的线程中等待处理结果,因此从 Python 代码的角度来看,这仍然主要是多线程。
线程的另一个常见用例是执行对外部服务的多个 HTTP 请求。例如,如果您想从远程 Web API 获取多个结果,同步执行可能需要很长时间。如果您在进行新请求之前等待每个先前的响应,您将花费大量时间等待外部服务的响应,并且每个请求都会增加额外的往返时间延迟。如果您正在与一个高效的服务(例如 Google Maps API)通信,很可能它可以同时处理大部分请求而不影响单独请求的响应时间。因此,合理的做法是在单独的线程中执行多个查询。请记住,在进行 HTTP 请求时,大部分时间都花在从 TCP 套接字中读取数据上。这是一个阻塞的 I/O 操作,因此在执行recv() C 函数时,CPython 会释放 GIL。这可以极大地提高应用程序的性能。
多用户应用程序
线程也被用作多用户应用程序的并发基础。例如,Web 服务器将用户请求推送到一个新线程中,然后变为空闲状态,等待新的请求。每个请求都有一个专用的线程简化了很多工作,但需要开发人员注意锁定资源。但是,当所有共享数据都被推送到处理并发事项的关系型数据库中时,这就不是问题了。因此,在多用户应用程序中,线程几乎像独立的进程一样运行。它们在同一个进程下只是为了简化在应用程序级别的管理。
例如,Web 服务器可以将所有请求放入队列,并等待线程可用以将工作发送到线程。此外,它允许内存共享,可以提高一些工作并减少内存负载。两个非常流行的 Python 符合 WSGI 标准的 Web 服务器:Gunicorn(参考gunicorn.org/)和uWSGI(参考uwsgi-docs.readthedocs.org),允许您以符合这一原则的方式使用带有线程工作进程的 HTTP 请求。
在多用户应用程序中使用多线程实现并发性比使用多进程要便宜。单独的进程会消耗更多资源,因为每个进程都需要加载一个新的解释器。另一方面,拥有太多线程也是昂贵的。我们知道 GIL 对 I/O 密集型应用程序并不是问题,但总有一个时刻,您需要执行 Python 代码。由于无法仅使用裸线程并行化应用程序的所有部分,因此在具有多核 CPU 和单个 Python 进程的机器上,您永远无法利用所有资源。这就是为什么通常最佳解决方案是多进程和多线程的混合——多个工作进程(进程)与多个线程同时运行。幸运的是,许多符合 WSGI 标准的 Web 服务器都允许这样的设置。
但在将多线程与多进程结合之前,要考虑这种方法是否真的值得所有的成本。这种方法使用多进程来更好地利用资源,另外使用多线程来实现更多的并发,应该比运行多个进程更轻。但这并不一定是真的。也许摆脱线程,增加进程的数量并不像你想象的那么昂贵?在选择最佳设置时,你总是需要对应用程序进行负载测试(参见第十章中的负载和性能测试部分,测试驱动开发)。另外,使用多线程的副作用是,你会得到一个不太安全的环境,共享内存会导致数据损坏或可怕的死锁。也许更好的选择是使用一些异步的方法,比如事件循环、绿色线程或协程。我们将在异步编程部分后面介绍这些解决方案。同样,如果没有合理的负载测试和实验,你无法真正知道哪种方法在你的情况下效果最好。
一个多线程应用的示例
为了了解 Python 线程在实践中是如何工作的,让我们构建一个示例应用程序,可以从实现多线程中获益。我们将讨论一个简单的问题,你可能在职业实践中不时遇到——进行多个并行的 HTTP 查询。这个问题已经被提到作为多线程的常见用例。
假设我们需要使用多个查询从某个网络服务获取数据,这些查询不能被批量处理成一个大的 HTTP 请求。作为一个现实的例子,我们将使用 Google Maps API 的地理编码端点。选择这个服务的原因如下:
-
它非常受欢迎,而且有很好的文档
-
这个 API 有一个免费的层,不需要任何身份验证密钥
-
在 PyPI 上有一个
python-gmaps包,允许你与各种 Google Maps API 端点进行交互,非常容易使用
地理编码简单地意味着将地址或地点转换为坐标。我们将尝试将预定义的各种城市列表转换为纬度/经度元组,并在标准输出上显示结果与python-gmaps。就像下面的代码所示一样简单:
>>> from gmaps import Geocoding
>>> api = Geocoding()
>>> geocoded = api.geocode('Warsaw')[0]
>>> print("{:>25s}, {:6.2f}, {:6.2f}".format(
... geocoded['formatted_address'],
... geocoded['geometry']['location']['lat'],
... geocoded['geometry']['location']['lng'],
... ))
Warsaw, Poland, 52.23, 21.01
由于我们的目标是展示多线程解决并发问题与标准同步解决方案相比的效果,我们将从一个完全不使用线程的实现开始。下面是一个循环遍历城市列表、查询 Google Maps API 并以文本格式表格显示有关它们地址和坐标的信息的程序代码:
import time
from gmaps import Geocoding
api = Geocoding()
PLACES = (
'Reykjavik', 'Vien', 'Zadar', 'Venice',
'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
'New York', 'Dehli',
)
def fetch_place(place):
geocoded = api.geocode(place)[0]
print("{:>25s}, {:6.2f}, {:6.2f}".format(
geocoded['formatted_address'],
geocoded['geometry']['location']['lat'],
geocoded['geometry']['location']['lng'],
))
def main():
for place in PLACES:
fetch_place(place)
if __name__ == "__main__":
started = time.time()
main()
elapsed = time.time() - started
print()
print("time elapsed: {:.2f}s".format(elapsed))
在main()函数的执行周围,我们添加了一些语句,用于测量完成工作所花费的时间。在我的电脑上,这个程序通常需要大约 2 到 3 秒才能完成任务:
$ python3 synchronous.py
**Reykjavík, Iceland, 64.13, -21.82
**Vienna, Austria, 48.21, 16.37
**Zadar, Croatia, 44.12, 15.23
**Venice, Italy, 45.44, 12.32
**Wrocław, Poland, 51.11, 17.04
**Bologna, Italy, 44.49, 11.34
**Berlin, Germany, 52.52, 13.40
**Slubice, Poland, 52.35, 14.56
**New York, NY, USA, 40.71, -74.01
**Dehli, Gujarat, India, 21.57, 73.22
time elapsed: 2.79s
注意
我们的脚本每次运行都会花费不同的时间,因为它主要取决于通过网络连接访问的远程服务。所以有很多不确定因素影响最终结果。最好的方法是进行更长时间的测试,多次重复,还要从测量中计算一些平均值。但为了简单起见,我们不会这样做。你将会看到,这种简化的方法对于说明目的来说已经足够了。
每个项目使用一个线程
现在是时候改进了。我们在 Python 中没有进行太多的处理,长时间执行是由与外部服务的通信引起的。我们向服务器发送 HTTP 请求,它计算答案,然后我们等待直到响应被传送回来。涉及了大量的 I/O,因此多线程似乎是一个可行的选择。我们可以在单独的线程中同时启动所有请求,然后等待它们接收数据。如果我们正在通信的服务能够并发处理我们的请求,我们应该肯定会看到性能的提升。
那么让我们从最简单的方法开始。Python 提供了清晰且易于使用的抽象,通过threading模块可以轻松地操作系统线程。这个标准库的核心是Thread类,代表一个单独的线程实例。下面是main()函数的修改版本,它为每个地点创建并启动一个新线程,然后等待直到所有线程都完成:
from threading import Thread
def main():
threads = []
for place in PLACES:
thread = Thread(target=fetch_place, args=[place])
thread.start()
threads.append(thread)
while threads:
threads.pop().join()
这是一个快速而肮脏的改变,它有一些严重的问题,我们稍后会试图解决。它以一种有点轻率的方式解决问题,并不是编写可为成千上万甚至百万用户提供服务的可靠软件的方式。但嘿,它起作用:
$ python3 threaded.py
**Wrocław, Poland, 51.11, 17.04
**Vienna, Austria, 48.21, 16.37
**Dehli, Gujarat, India, 21.57, 73.22
**New York, NY, USA, 40.71, -74.01
**Bologna, Italy, 44.49, 11.34
**Reykjavík, Iceland, 64.13, -21.82
**Zadar, Croatia, 44.12, 15.23
**Berlin, Germany, 52.52, 13.40
**Slubice, Poland, 52.35, 14.56
**Venice, Italy, 45.44, 12.32
time elapsed: 1.05s
所以当我们知道线程对我们的应用有益时,是时候以稍微理智的方式使用它们了。首先我们需要找出前面代码中的问题:
-
我们为每个参数启动一个新线程。线程初始化也需要一些时间,但这种小的开销并不是唯一的问题。线程还会消耗其他资源,比如内存和文件描述符。我们的示例输入有一个严格定义的项目数量,如果没有呢?你肯定不希望运行数量不受限制的线程,这取决于输入数据的任意大小。
-
在线程中执行的
fetch_place()函数调用了内置的print()函数,实际上,你很少会想在主应用程序线程之外这样做。首先,这是因为 Python 中标准输出的缓冲方式。当多个线程之间交错调用这个函数时,你可能会遇到格式不正确的输出。另外,print()函数被认为是慢的。如果在多个线程中滥用使用,它可能导致串行化,这将抵消多线程的所有好处。 -
最后但同样重要的是,通过将每个函数调用委托给单独的线程,我们使得控制输入处理速率变得极其困难。是的,我们希望尽快完成工作,但很多时候外部服务会对单个客户端的请求速率设置严格限制。有时,合理设计程序以使其能够控制处理速率是很有必要的,这样你的应用就不会因滥用外部 API 的使用限制而被列入黑名单。
使用线程池
我们要解决的第一个问题是程序运行的线程数量没有限制。一个好的解决方案是建立一个具有严格定义大小的线程工作池,它将处理所有并行工作,并通过一些线程安全的数据结构与工作线程进行通信。通过使用这种线程池方法,我们也将更容易解决刚才提到的另外两个问题。
因此,一般的想法是启动一些预定义数量的线程,这些线程将从队列中消耗工作项,直到完成。当没有其他工作要做时,线程将返回,我们将能够退出程序。用于与工作线程通信的结构的一个很好的候选是内置queue模块中的Queue类。它是一个先进先出(FIFO)队列实现,非常类似于collections模块中的deque集合,并且专门设计用于处理线程间通信。以下是一个修改后的main()函数的版本,它只启动了有限数量的工作线程,并使用一个新的worker()函数作为目标,并使用线程安全的队列与它们进行通信:
from queue import Queue, Empty
from threading import Thread
THREAD_POOL_SIZE = 4
def worker(work_queue):
while not work_queue.empty():
try:
item = work_queue.get(block=False)
except Empty:
break
else:
fetch_place(item)
work_queue.task_done()
def main():
work_queue = Queue()
for place in PLACES:
work_queue.put(place)
threads = [
Thread(target=worker, args=(work_queue,))
for _ in range(THREAD_POOL_SIZE)
]
for thread in threads:
thread.start()
work_queue.join()
while threads:
threads.pop().join()
运行修改后的程序的结果与之前的类似:
$ python threadpool.py**
**Reykjavík, Iceland, 64.13, -21.82
**Venice, Italy, 45.44, 12.32
**Vienna, Austria, 48.21, 16.37
**Zadar, Croatia, 44.12, 15.23
**Wrocław, Poland, 51.11, 17.04
**Bologna, Italy, 44.49, 11.34
**Slubice, Poland, 52.35, 14.56
**Berlin, Germany, 52.52, 13.40
**New York, NY, USA, 40.71, -74.01
**Dehli, Gujarat, India, 21.57, 73.22
time elapsed: 1.20s
运行时间将比每个参数一个线程的情况慢,但至少现在不可能用任意长的输入耗尽所有的计算资源。此外,我们可以调整THREAD_POOL_SIZE参数以获得更好的资源/时间平衡。
使用双向队列
我们现在能够解决的另一个问题是线程中输出的潜在问题。最好将这样的责任留给启动其他线程的主线程。我们可以通过提供另一个队列来处理这个问题,该队列将负责从我们的工作线程中收集结果。以下是将所有内容与主要更改放在一起的完整代码:
import time
from queue import Queue, Empty
from threading import Thread
from gmaps import Geocoding
api = Geocoding()
PLACES = (
'Reykjavik', 'Vien', 'Zadar', 'Venice',
'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
'New York', 'Dehli',
)
THREAD_POOL_SIZE = 4
def fetch_place(place):
return api.geocode(place)[0]
def present_result(geocoded):
**print("{:>25s}, {:6.2f}, {:6.2f}".format(
**geocoded['formatted_address'],
**geocoded['geometry']['location']['lat'],
**geocoded['geometry']['location']['lng'],
**))
def worker(work_queue, results_queue):
while not work_queue.empty():
try:
item = work_queue.get(block=False)
except Empty:
break
else:
**results_queue.put(
**fetch_place(item)
**)
work_queue.task_done()
def main():
work_queue = Queue()
**results_queue = Queue()
for place in PLACES:
work_queue.put(place)
threads = [
**Thread(target=worker, args=(work_queue, results_queue))
for _ in range(THREAD_POOL_SIZE)
]
for thread in threads:
thread.start()
work_queue.join()
while threads:
threads.pop().join()
**while not results_queue.empty():
**present_result(results_queue.get())
if __name__ == "__main__":
started = time.time()
main()
elapsed = time.time() - started
print()
print("time elapsed: {:.2f}s".format(elapsed))
这消除了输出格式不正确的风险,如果present_result()函数执行更多的print()语句或执行一些额外的计算,我们可能会遇到这种情况。我们不希望从这种方法中获得任何性能改进,但实际上,由于print()执行缓慢,我们还减少了线程串行化的风险。这是我们的最终输出:
$ python threadpool_with_results.py**
**Vienna, Austria, 48.21, 16.37
**Reykjavík, Iceland, 64.13, -21.82
**Zadar, Croatia, 44.12, 15.23
**Venice, Italy, 45.44, 12.32
**Wrocław, Poland, 51.11, 17.04
**Bologna, Italy, 44.49, 11.34
**Slubice, Poland, 52.35, 14.56
**Berlin, Germany, 52.52, 13.40
**New York, NY, USA, 40.71, -74.01
**Dehli, Gujarat, India, 21.57, 73.22
time elapsed: 1.30s
处理错误和速率限制
之前提到的您在处理这些问题时可能遇到的最后一个问题是外部服务提供商施加的速率限制。在编写本书时,谷歌地图 API 的官方速率限制为每秒 10 次请求和每天 2500 次免费和非身份验证请求。使用多个线程很容易耗尽这样的限制。问题更加严重,因为我们尚未涵盖任何故障场景,并且在多线程 Python 代码中处理异常比通常要复杂一些。
api.geocode() 函数在客户端超过谷歌速率时会引发异常,这是个好消息。但是这个异常会单独引发,并不会使整个程序崩溃。工作线程当然会立即退出,但主线程会等待所有存储在work_queue上的任务完成(使用work_queue.join()调用)。这意味着我们的工作线程应该优雅地处理可能的异常,并确保队列中的所有项目都被处理。如果没有进一步的改进,我们可能会陷入一种情况,其中一些工作线程崩溃,程序将永远不会退出。
让我们对我们的代码进行一些微小的更改,以便为可能发生的任何问题做好准备。在工作线程中出现异常的情况下,我们可以将错误实例放入results_queue队列,并将当前任务标记为已完成,就像没有错误时一样。这样我们可以确保主线程在work_queue.join()中等待时不会无限期地锁定。然后主线程可能检查结果并重新引发在结果队列中找到的任何异常。以下是可以更安全地处理异常的worker()和main()函数的改进版本:
def worker(work_queue, results_queue):
while True:
try:
item = work_queue.get(block=False)
except Empty:
break
else:
**try:
**result = fetch_place(item)
**except Exception as err:
**results_queue.put(err)
**else:
**results_queue.put(result)
**finally:
**work_queue.task_done()
def main():
work_queue = Queue()
results_queue = Queue()
for place in PLACES:
work_queue.put(place)
threads = [
Thread(target=worker, args=(work_queue, results_queue))
for _ in range(THREAD_POOL_SIZE)
]
for thread in threads:
thread.start()
work_queue.join()
while threads:
threads.pop().join()
**while not results_queue.empty():
**result = results_queue.get()
**if isinstance(result, Exception):
**raise result
present_result(result)
当我们准备处理异常时,就是我们的代码中断并超过速率限制的时候了。我们可以通过修改一些初始条件来轻松实现这一点。让我们增加地理编码的位置数量和线程池的大小:
PLACES = (
'Reykjavik', 'Vien', 'Zadar', 'Venice',
'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
'New York', 'Dehli',
) * 10
THREAD_POOL_SIZE = 10
如果您的执行环境足够快,您应该很快就会收到类似的错误:
$ python3 threadpool_with_errors.py
**New York, NY, USA, 40.71, -74.01
**Berlin, Germany, 52.52, 13.40
**Wrocław, Poland, 51.11, 17.04
**Zadar, Croatia, 44.12, 15.23
**Vienna, Austria, 48.21, 16.37
**Bologna, Italy, 44.49, 11.34
**Reykjavík, Iceland, 64.13, -21.82
**Venice, Italy, 45.44, 12.32
**Dehli, Gujarat, India, 21.57, 73.22
**Slubice, Poland, 52.35, 14.56
**Vienna, Austria, 48.21, 16.37
**Zadar, Croatia, 44.12, 15.23
**Venice, Italy, 45.44, 12.32
**Reykjavík, Iceland, 64.13, -21.82
Traceback (most recent call last):
**File "threadpool_with_errors.py", line 83, in <module>
**main()
**File "threadpool_with_errors.py", line 76, in main
**raise result
**File "threadpool_with_errors.py", line 43, in worker
**result = fetch_place(item)
**File "threadpool_with_errors.py", line 23, in fetch_place
**return api.geocode(place)[0]
**File "...\site-packages\gmaps\geocoding.py", line 37, in geocode
**return self._make_request(self.GEOCODE_URL, parameters, "results")
**File "...\site-packages\gmaps\client.py", line 89, in _make_request
**)(response)
gmaps.errors.RateLimitExceeded: {'status': 'OVER_QUERY_LIMIT', 'results': [], 'error_message': 'You have exceeded your rate-limit for this API.', 'url': 'https://maps.googleapis.com/maps/api/geocode/json?address=Wroc%C5%82aw&sensor=false'}
前面的异常当然不是由于错误的代码造成的。这个程序对于这个免费服务来说太快了。它发出了太多的并发请求,为了正确工作,我们需要有一种限制它们速率的方法。
限制工作的速度通常被称为节流。PyPI 上有一些包可以让您限制任何类型工作的速率,并且非常容易使用。但是我们不会在这里使用任何外部代码。节流是一个很好的机会,可以引入一些用于线程的锁原语,因此我们将尝试从头开始构建一个解决方案。
我们将使用的算法有时被称为令牌桶,非常简单:
-
有一个预定义数量的令牌的桶。
-
每个令牌对应于处理一个工作项的单个权限。
-
每次工作线程请求单个或多个令牌(权限)时:
-
我们测量了从上次我们重新填充桶以来花费了多少时间
-
如果时间差允许,我们将用与此时间差相应的令牌数量重新填充桶
-
如果存储的令牌数量大于或等于请求的数量,我们会减少存储的令牌数量并返回该值
-
如果存储的令牌数量少于请求的数量,我们返回零
两个重要的事情是始终用零令牌初始化令牌桶,并且永远不允许它填充的令牌数量超过其速率可用的令牌数量,按照我们标准的时间量表达。如果我们不遵循这些预防措施,我们可能会以超过速率限制的突发方式释放令牌。因为在我们的情况下,速率限制以每秒请求的数量来表示,所以我们不需要处理任意的时间量。我们假设我们的测量基准是一秒,因此我们永远不会存储比该时间量允许的请求数量更多的令牌。以下是一个使用令牌桶算法进行节流的类的示例实现:
From threading import Lock
class Throttle:
def __init__(self, rate):
self._consume_lock = Lock()
self.rate = rate
self.tokens = 0
self.last = 0
def consume(self, amount=1):
with self._consume_lock:
now = time.time()
# time measument is initialized on first
# token request to avoid initial bursts
if self.last == 0:
self.last = now
elapsed = now - self.last
# make sure that quant of passed time is big
# enough to add new tokens
if int(elapsed * self.rate):
self.tokens += int(elapsed * self.rate)
self.last = now
# never over-fill the bucket
self.tokens = (
self.rate
if self.tokens > self.rate
else self.tokens
)
# finally dispatch tokens if available
if self.tokens >= amount:
self.tokens -= amount
else:
amount = 0
return amount
使用这个类非常简单。假设我们在主线程中只创建了一个Throttle实例(例如Throttle(10)),并将其作为位置参数传递给每个工作线程。在不同的线程中使用相同的数据结构是安全的,因为我们使用threading模块中的Lock类的实例来保护其内部状态的操作。现在我们可以更新worker()函数的实现,以便在每个项目之前等待节流释放一个新的令牌:
def worker(work_queue, results_queue, throttle):
while True:
try:
item = work_queue.get(block=False)
except Empty:
break
else:
**while not throttle.consume():
**pass
try:
result = fetch_place(item)
except Exception as err:
results_queue.put(err)
else:
results_queue.put(result)
finally:
work_queue.task_done()
多进程
坦率地说,多线程是具有挑战性的——我们在前一节已经看到了。最简单的方法只需要最少的工作。但是以明智和安全的方式处理线程需要大量的代码。
我们必须设置线程池和通信队列,优雅地处理来自线程的异常,并且在尝试提供速率限制功能时也要关心线程安全。只需十行代码就可以并行执行外部库中的一个函数!我们只是假设这是可以投入生产的,因为外部包的创建者承诺他的库是线程安全的。对于一个实际上只适用于执行 I/O 绑定任务的解决方案来说,这听起来像是一个很高的代价。
允许你实现并行的另一种方法是多进程。不受 GIL 约束的独立 Python 进程可以更好地利用资源。这对于在执行真正消耗 CPU 的任务的多核处理器上运行的应用程序尤为重要。目前,这是 Python 开发人员(使用 CPython 解释器)唯一可用的内置并发解决方案,可以让你利用多个处理器核心。
使用多个进程的另一个优势是它们不共享内存上下文。因此,更难破坏数据并引入死锁到你的应用程序中。不共享内存上下文意味着你需要额外的工作来在独立的进程之间传递数据,但幸运的是有许多很好的方法来实现可靠的进程间通信。事实上,Python 提供了一些原语,使进程间通信尽可能简单,就像线程之间一样。
在任何编程语言中启动新进程的最基本的方法通常是在某个时候fork程序。在 POSIX 系统(Unix、Mac OS 和 Linux)上,fork 是一个系统调用,在 Python 中通过os.fork()函数暴露出来,它将创建一个新的子进程。然后这两个进程在分叉后继续程序。下面是一个自我分叉一次的示例脚本:
import os
pid_list = []
def main():
pid_list.append(os.getpid())
child_pid = os.fork()
if child_pid == 0:
pid_list.append(os.getpid())
print()
print("CHLD: hey, I am the child process")
print("CHLD: all the pids i know %s" % pid_list)
else:
pid_list.append(os.getpid())
print()
print("PRNT: hey, I am the parent")
print("PRNT: the child is pid %d" % child_pid)
print("PRNT: all the pids i know %s" % pid_list)
if __name__ == "__main__":
main()
以下是在终端中运行它的示例:
$ python3 forks.py
PRNT: hey, I am the parent
PRNT: the child is pid 21916
PRNT: all the pids i know [21915, 21915]
CHLD: hey, I am the child process
CHLD: all the pids i know [21915, 21916]
请注意,在os.fork()调用之前,这两个进程的数据状态完全相同。它们都有相同的 PID 号(进程标识符)作为pid_list集合的第一个值。后来,两个状态分歧,我们可以看到子进程添加了21916的值,而父进程复制了它的21915 PID。这是因为这两个进程的内存上下文是不共享的。它们有相同的初始条件,但在os.fork()调用后不能相互影响。
在分叉内存上下文被复制到子进程后,每个进程都处理自己的地址空间。为了通信,进程需要使用系统范围的资源或使用低级工具,比如信号。
不幸的是,在 Windows 下os.fork不可用,需要在新的解释器中生成一个新的进程来模拟 fork 功能。因此,它需要根据平台的不同而有所不同。os模块还公开了在 Windows 下生成新进程的函数,但最终你很少会使用它们。这对于os.fork()也是如此。Python 提供了一个很棒的multiprocessing模块,它为多进程提供了一个高级接口。这个模块的巨大优势在于它提供了一些我们在一个多线程应用程序示例部分中不得不从头编写的抽象。它允许你限制样板代码的数量,因此提高了应用程序的可维护性并减少了其复杂性。令人惊讶的是,尽管它的名字是multiprocessing模块,但它也为线程暴露了类似的接口,因此你可能希望对两种方法使用相同的接口。
内置的 multiprocessing 模块
multiprocessing提供了一种可移植的方式来处理进程,就像它们是线程一样。
这个模块包含一个Process类,它与Thread类非常相似,可以在任何平台上使用:
from multiprocessing import Process
import os
def work(identifier):
print(
'hey, i am a process {}, pid: {}'
''.format(identifier, os.getpid())
)
def main():
processes = [
Process(target=work, args=(number,))
for number in range(5)
]
for process in processes:
process.start()
while processes:
processes.pop().join()
if __name__ == "__main__":
main()
执行前述脚本将得到以下结果:
$ python3 processing.py
hey, i am a process 1, pid: 9196
hey, i am a process 0, pid: 8356
hey, i am a process 3, pid: 9524
hey, i am a process 2, pid: 3456
hey, i am a process 4, pid: 6576
当进程被创建时,内存被分叉(在 POSIX 系统上)。进程的最有效使用方式是让它们在创建后独立工作,以避免开销,并从主线程检查它们的状态。除了复制的内存状态,Process类还在其构造函数中提供了额外的args参数,以便传递数据。
进程模块之间的通信需要一些额外的工作,因为它们的本地内存默认情况下不是共享的。为了简化这一点,多进程模块提供了一些进程之间通信的方式:
-
使用
multiprocessing.Queue类,它几乎与queue.Queue相同,之前用于线程之间通信 -
使用
multiprocessing.Pipe,这是一个类似套接字的双向通信通道 -
使用
multiprocessing.sharedctypes模块,允许您在进程之间共享的专用内存池中创建任意 C 类型(来自ctypes模块)
multiprocessing.Queue和queue.Queue类具有相同的接口。唯一的区别是,第一个是设计用于多进程环境,而不是多线程环境,因此它使用不同的内部传输和锁定原语。我们已经看到如何在一个多线程应用程序的示例部分中使用 Queue,因此我们不会对多进程做同样的事情。使用方式完全相同,因此这样的例子不会带来任何新东西。
现在提供的更有趣的模式是Pipe类。它是一个双工(双向)通信通道,概念上与 Unix 管道非常相似。Pipe 的接口也非常类似于内置socket模块中的简单套接字。与原始系统管道和套接字的区别在于它允许您发送任何可挑选的对象(使用pickle模块)而不仅仅是原始字节。这使得进程之间的通信变得更加容易,因为您可以发送几乎任何基本的 Python 类型:
from multiprocessing import Process, Pipe
class CustomClass:
pass
def work(connection):
while True:
instance = connection.recv()
if instance:
print("CHLD: {}".format(instance))
else:
return
def main():
parent_conn, child_conn = Pipe()
child = Process(target=work, args=(child_conn,))
for item in (
42,
'some string',
{'one': 1},
CustomClass(),
None,
):
print("PRNT: send {}:".format(item))
parent_conn.send(item)
child.start()
child.join()
if __name__ == "__main__":
main()
当查看前面脚本的示例输出时,您会发现您可以轻松传递自定义类实例,并且它们根据进程具有不同的地址:
PRNT: send: 42
PRNT: send: some string
PRNT: send: {'one': 1}
PRNT: send: <__main__.CustomClass object at 0x101cb5b00>
PRNT: send: None
CHLD: recv: 42
CHLD: recv: some string
CHLD: recv: {'one': 1}
CHLD: recv: <__main__.CustomClass object at 0x101cba400>
在进程之间共享状态的另一种方法是使用multiprocessing.sharedctypes中提供的类在共享内存池中使用原始类型。最基本的是Value和Array。以下是multiprocessing模块官方文档中的示例代码:
from multiprocessing import Process, Value, Array
def f(n, a):
n.value = 3.1415927
for i in range(len(a)):
a[i] = -a[i]
if __name__ == '__main__':
num = Value('d', 0.0)
arr = Array('i', range(10))
p = Process(target=f, args=(num, arr))
p.start()
p.join()
print(num.value)
print(arr[:])
这个例子将打印以下输出:
3.1415927
[0, -1, -2, -3, -4, -5, -6, -7, -8, -9]
在使用multiprocessing.sharedctypes时,您需要记住您正在处理共享内存,因此为了避免数据损坏的风险,您需要使用锁定原语。多进程提供了一些可用于线程的类,例如Lock、RLock和Semaphore,来做到这一点。sharedctypes类的缺点是它们只允许您共享ctypes模块中的基本 C 类型。如果您需要传递更复杂的结构或类实例,则需要使用 Queue、Pipe 或其他进程间通信通道。在大多数情况下,理应避免使用sharedctypes中的类型,因为它们会增加代码复杂性,并带来来自多线程的所有已知危险。
使用进程池
使用多进程而不是线程会增加一些实质性的开销。主要是因为它增加了内存占用,因为每个进程都有自己独立的内存上下文。这意味着允许无限数量的子进程甚至比在多线程应用程序中更加棘手。
在依赖多进程进行更好资源利用的应用程序中控制资源使用的最佳模式是以类似于使用线程池部分描述的方式构建进程池。
multiprocessing模块最好的地方是它提供了一个现成的Pool类,可以为你处理管理多个进程工作者的所有复杂性。这个池实现大大减少了所需的样板代码量和与双向通信相关的问题数量。你也不需要手动使用join()方法,因为Pool可以作为上下文管理器使用(使用with语句)。以下是我们以前的一个线程示例,重写为使用multiprocessing模块中的Pool类:
from multiprocessing import Pool
from gmaps import Geocoding
api = Geocoding()
PLACES = (
'Reykjavik', 'Vien', 'Zadar', 'Venice',
'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
'New York', 'Dehli',
)
POOL_SIZE = 4
def fetch_place(place):
return api.geocode(place)[0]
def present_result(geocoded):
print("{:>25s}, {:6.2f}, {:6.2f}".format(
geocoded['formatted_address'],
geocoded['geometry']['location']['lat'],
geocoded['geometry']['location']['lng'],
))
def main():
with Pool(POOL_SIZE) as pool:
results = pool.map(fetch_place, PLACES)
for result in results:
present_result(result)
if __name__ == "__main__":
main()
正如你所看到的,现在代码要短得多。这意味着在出现问题时,现在更容易维护和调试。实际上,现在只有两行代码明确处理多进程。这是一个很大的改进,因为我们以前必须从头开始构建处理池。现在我们甚至不需要关心通信通道,因为它们是在Pool实现内部隐式创建的。
使用multiprocessing.dummy作为多线程接口
multiprocessing模块中的高级抽象,如Pool类,是比threading模块提供的简单工具更大的优势。但是,并不意味着多进程始终比多线程更好的方法。有很多情况下,线程可能是比进程更好的解决方案。特别是在需要低延迟和/或高资源效率的情况下。
但这并不意味着每当你想要使用线程而不是进程时,你就需要牺牲multiprocessing模块中的所有有用抽象。有multiprocessing.dummy模块,它复制了multiprocessing的 API,但使用多线程而不是 forking/spawning 新进程。
这使你可以减少代码中的样板,并且使接口更加可插拔。例如,让我们再次看一下我们以前示例中的main()函数。如果我们想要让用户控制他想要使用哪种处理后端(进程或线程),我们可以简单地替换Pool类:
from multiprocessing import Pool as ProcessPool
from multiprocessing.dummy import Pool as ThreadPool
def main(use_threads=False):
if use_threads:
pool_cls = ThreadPool
else:
pool_cls = ProcessPool
with pool_cls(POOL_SIZE) as pool:
results = pool.map(fetch_place, PLACES)
for result in results:
present_result(result)
异步编程
近年来,异步编程已经获得了很大的关注。在 Python 3.5 中,它最终获得了一些语法特性,巩固了异步执行的概念。但这并不意味着异步编程只能从 Python 3.5 开始。很多库和框架早在很久以前就提供了,大部分都起源于 Python 2 的旧版本。甚至有一个名为 Stackless 的 Python 的整个替代实现(见第一章,“Python 的当前状态”),它专注于这种单一的编程方法。其中一些解决方案,如 Twisted、Tornado 或 Eventlet,仍然拥有庞大和活跃的社区,并且真的值得了解。无论如何,从 Python 3.5 开始,异步编程比以往任何时候都更容易。因此,预计其内置的异步特性将取代较旧工具的大部分部分,或者外部项目将逐渐转变为基于 Python 内置的高级框架。
当试图解释什么是异步编程时,最简单的方法是将这种方法视为类似于线程但不涉及系统调度。这意味着异步程序可以并发处理问题,但其上下文在内部切换,而不是由系统调度程序切换。
但是,当然,我们不使用线程来同时处理异步程序中的工作。大多数解决方案使用一种不同的概念,根据实现的不同,它被命名为不同的名称。用来描述这种并发程序实体的一些示例名称是:
-
绿色线程或 greenlets(greenlet、gevent 或 eventlet 项目)
-
协程(Python 3.5 原生异步编程)
-
任务(Stackless Python)
这些主要是相同的概念,但通常以稍微不同的方式实现。出于明显的原因,在本节中,我们将只集中讨论 Python 从版本 3.5 开始原生支持的协程。
合作式多任务处理和异步 I/O
合作式多任务处理是异步编程的核心。在这种计算机多任务处理风格中,操作系统不负责启动上下文切换(到另一个进程或线程),而是每个进程在空闲时自愿释放控制,以实现多个程序的同时执行。这就是为什么它被称为合作式。所有进程都需要合作才能实现平稳的多任务处理。
这种多任务处理模型有时在操作系统中使用,但现在几乎不再作为系统级解决方案。这是因为一个设计不良的服务很容易破坏整个系统的稳定性。现在,线程和进程调度以及由操作系统直接管理的上下文切换是系统级并发的主要方法。但在应用程序级别,合作式多任务处理仍然是一个很好的并发工具。
在应用程序级别讨论合作式多任务处理时,我们不需要处理需要释放控制的线程或进程,因为所有执行都包含在一个单一的进程和线程中。相反,我们有多个任务(协程、任务和绿色线程),它们释放控制给处理任务协调的单个函数。这个函数通常是某种事件循环。
为了避免以后混淆(由于 Python 术语),从现在开始我们将把这样的并发任务称为协程。合作式多任务处理中最重要的问题是何时释放控制。在大多数异步应用程序中,控制权在 I/O 操作时释放给调度器或事件循环。无论程序是从文件系统读取数据还是通过套接字进行通信,这样的 I/O 操作总是与进程变得空闲的等待时间相关。等待时间取决于外部资源,因此释放控制是一个很好的机会,这样其他协程就可以做他们的工作,直到它们也需要等待。
这使得这种方法在行为上与 Python 中的多线程实现方式有些相似。我们知道 GIL 会对 Python 线程进行串行化,但在每次 I/O 操作时会释放。主要区别在于 Python 中的线程是作为系统级线程实现的,因此操作系统可以在任何时间点抢占当前运行的线程,并将控制权交给另一个线程。在异步编程中,任务永远不会被主事件循环抢占。这就是为什么这种多任务处理风格也被称为非抢占式多任务处理。
当然,每个 Python 应用程序都在一个操作系统上运行,那里有其他进程竞争资源。这意味着操作系统始终有权剥夺整个进程的控制权,并将控制权交给另一个进程。但当我们的异步应用程序恢复运行时,它会从系统调度器介入时暂停的地方继续运行。这就是为什么协程仍然被认为是非抢占式的。
Python 的 async 和 await 关键字
async和await关键字是 Python 异步编程的主要构建模块。
在def语句之前使用的async关键字定义了一个新的协程。协程函数的执行可能在严格定义的情况下被暂停和恢复。它的语法和行为与生成器非常相似(参见第二章,“语法最佳实践-类级别下面”)。实际上,生成器需要在 Python 的旧版本中使用以实现协程。这是一个使用async关键字的函数声明的示例:
async def async_hello():
print("hello, world!")
使用async关键字定义的函数是特殊的。当调用时,它们不执行内部的代码,而是返回一个协程对象:
>>> async def async_hello():
... print("hello, world!")
...**
>>> async_hello()
<coroutine object async_hello at 0x1014129e8>
协程对象在其执行被安排在事件循环中之前不会执行任何操作。asyncio模块可用于提供基本的事件循环实现,以及许多其他异步实用程序:
>>> import asyncio
>>> async def async_hello():
... print("hello, world!")
...**
>>> loop = asyncio.get_event_loop()
>>> loop.run_until_complete(async_hello())
hello, world!
>>> loop.close()
显然,由于我们只创建了一个简单的协程,所以在我们的程序中没有涉及并发。为了真正看到一些并发,我们需要创建更多的任务,这些任务将由事件循环执行。
可以通过调用loop.create_task()方法或使用asyncio.wait()函数提供另一个对象来等待来添加新任务到循环中。我们将使用后一种方法,并尝试异步打印使用range()函数生成的一系列数字:
import asyncio
async def print_number(number):
print(number)
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(
asyncio.wait([
print_number(number)
for number in range(10)
])
)
loop.close()
asyncio.wait()函数接受一个协程对象的列表并立即返回。结果是一个生成器,产生表示未来结果(futures)的对象。正如其名称所示,它用于等待所有提供的协程完成。它返回生成器而不是协程对象的原因是为了与 Python 的先前版本向后兼容,这将在后面解释。运行此脚本的结果可能如下:
$ python asyncprint.py**
0
7
8
3
9
4
1
5
2
6
正如我们所看到的,数字的打印顺序与我们创建协程的顺序不同。但这正是我们想要实现的。
Python 3.5 中添加的第二个重要关键字是await。它用于等待协程或未来结果(稍后解释)的结果,并将执行控制权释放给事件循环。为了更好地理解它的工作原理,我们需要回顾一个更复杂的代码示例。
假设我们想创建两个协程,它们将在循环中执行一些简单的任务:
-
等待随机秒数
-
打印一些作为参数提供的文本和在睡眠中花费的时间
让我们从一个简单的实现开始,它存在一些并发问题,我们稍后将尝试使用额外的await使用来改进它:
import time
import random
import asyncio
async def waiter(name):
for _ in range(4):
time_to_sleep = random.randint(1, 3) / 4
time.sleep(time_to_sleep)
print(
"{} waited {} seconds"
"".format(name, time_to_sleep)
)
async def main():
await asyncio.wait([waiter("foo"), waiter("bar")])
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
在终端中执行(使用time命令来测量时间),可能会得到以下输出:
$ time python corowait.py**
bar waited 0.25 seconds
bar waited 0.25 seconds
bar waited 0.5 seconds
bar waited 0.5 seconds
foo waited 0.75 seconds
foo waited 0.75 seconds
foo waited 0.25 seconds
foo waited 0.25 seconds
real 0m3.734s
user 0m0.153s
sys 0m0.028s
正如我们所看到的,这两个协程都完成了它们的执行,但不是以异步的方式。原因是它们都使用了time.sleep()函数,这是阻塞的,但没有释放控制给事件循环。这在多线程设置中可能效果更好,但我们现在不想使用线程。那么我们该如何解决这个问题呢?
答案是使用asyncio.sleep(),这是time.sleep()的异步版本,并使用await关键字等待其结果。我们已经在main()函数的第一个版本中使用了这个语句,但这只是为了提高代码的清晰度。显然,这并没有使我们的实现更加并发。让我们看一个改进的waiter()协程的版本,它使用await asyncio.sleep():
async def waiter(name):
for _ in range(4):
time_to_sleep = random.randint(1, 3) / 4
await asyncio.sleep(time_to_sleep)
print(
"{} waited {} seconds"
"".format(name, time_to_sleep)
)
如果我们运行更新后的脚本,我们可以看到两个函数的输出如何交错:
$ time python corowait_improved.py**
bar waited 0.25 seconds
foo waited 0.25 seconds
bar waited 0.25 seconds
foo waited 0.5 seconds
foo waited 0.25 seconds
bar waited 0.75 seconds
foo waited 0.25 seconds
bar waited 0.5 seconds
real 0m1.953s
user 0m0.149s
sys 0m0.026s
这个简单改进的额外优势是代码运行得更快。总体执行时间小于所有睡眠时间的总和,因为协程合作地释放控制。
旧版本 Python 中的 asyncio
asyncio模块出现在 Python 3.4 中。因此,它是在 Python 3.5 之前唯一支持异步编程的版本。不幸的是,看起来这两个后续版本刚好足够引入兼容性问题。
无论喜欢与否,Python 中的异步编程核心早于支持此模式的语法元素。迟做总比不做好,但这造成了一种情况,即有两种语法可用于处理协程。
从 Python 3.5 开始,你可以使用async和await:
async def main():
await asyncio.sleep(0)
但对于 Python 3.4,你需要使用asyncio.coroutine装饰器和yield from语句:
@asyncio.couroutine
def main():
yield from asyncio.sleep(0)
另一个有用的事实是,yield from语句是在 Python 3.3 中引入的,并且在 PyPI 上有一个asyncio的后备。这意味着你也可以在 Python 3.3 中使用这个协作式多任务处理的实现。
异步编程的实际示例
正如本章中已经多次提到的那样,异步编程是处理 I/O 绑定操作的强大工具。所以现在是时候构建比简单打印序列或异步等待更实际的东西了。
为了保持一致,我们将尝试处理与多线程和多进程帮助解决的相同问题。因此,我们将尝试通过网络连接异步获取一些来自外部资源的数据。如果我们可以像在前面的部分中那样使用相同的python-gmaps包,那就太好了。不幸的是,我们不能。
python-gmaps的创建者有点懒,走了捷径。为了简化开发,他选择了requests包作为他的首选 HTTP 客户端库。不幸的是,requests不支持async和await的异步 I/O。还有一些其他项目旨在为requests项目提供一些并发性,但它们要么依赖于 Gevent(grequests,参见github.com/kennethreitz/grequests),要么依赖于线程/进程池执行(requests-futures,参见github.com/ross/requests-futures)。这两者都不能解决我们的问题。
注意
在你因为我在责备一个无辜的开源开发者而生气之前,冷静下来。python-gmaps包背后的人就是我。依赖项的选择不当是这个项目的问题之一。我只是喜欢偶尔公开批评自己。这对我来说应该是一个痛苦的教训,因为在我写这本书的时候,python-gmaps在其最新版本(0.3.1)中不能轻松地与 Python 的异步 I/O 集成。无论如何,这可能会在未来发生变化,所以一切都没有丢失。
知道在前面的示例中很容易使用的库的限制,我们需要构建一些填补这一空白的东西。Google Maps API 非常容易使用,所以我们将构建一个快速而简陋的异步实用程序,仅用于说明目的。Python 3.5 版本的标准库仍然缺少一个使异步 HTTP 请求像调用urllib.urlopen()一样简单的库。我们绝对不想从头开始构建整个协议支持,所以我们将从 PyPI 上可用的aiohttp包中得到一点帮助。这是一个非常有前途的库,为异步 HTTP 添加了客户端和服务器实现。这是一个建立在aiohttp之上的小模块,它创建了一个名为geocode()的辅助函数,用于向 Google Maps API 服务发出地理编码请求:
import aiohttp
session = aiohttp.ClientSession()
async def geocode(place):
params = {
'sensor': 'false',
'address': place
}
async with session.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params=params
) as response:
result = await response.json()
return result['results']
假设这段代码存储在名为asyncgmaps的模块中,我们稍后会用到它。现在我们准备重写在讨论多线程和多进程时使用的示例。以前,我们习惯将整个操作分为两个独立的步骤:
-
使用
fetch_place()函数并行执行对外部服务的所有请求。 -
使用
present_result()函数在循环中显示所有结果。
但是,因为协作式多任务处理与使用多个进程或线程完全不同,我们可以稍微修改我们的方法。在“使用一个线程处理一个项目”部分提出的大部分问题不再是我们的关注点。协程是非抢占式的,因此我们可以在等待 HTTP 响应后立即显示结果。这将简化我们的代码并使其更清晰。
import asyncio
# note: local module introduced earlier
from asyncgmaps import geocode, session
PLACES = (
'Reykjavik', 'Vien', 'Zadar', 'Venice',
'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
'New York', 'Dehli',
)
async def fetch_place(place):
return (await geocode(place))[0]
async def present_result(result):
geocoded = await result
print("{:>25s}, {:6.2f}, {:6.2f}".format(
geocoded['formatted_address'],
geocoded['geometry']['location']['lat'],
geocoded['geometry']['location']['lng'],
))
async def main():
await asyncio.wait([
present_result(fetch_place(place))
for place in PLACES
])
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
# aiohttp will raise issue about unclosed
# ClientSession so we perform cleanup manually
loop.run_until_complete(session.close())
loop.close()
使用期货将非异步代码与异步集成
异步编程很棒,特别是对于对构建可扩展应用程序感兴趣的后端开发人员。实际上,这是构建高度并发服务器的最重要工具之一。
但现实是痛苦的。许多处理 I/O 绑定问题的流行软件包并不适用于异步代码。主要原因是:
-
Python 3 及其一些高级功能的采用率仍然较低
-
Python 初学者对各种并发概念的理解较低
这意味着现有的同步多线程应用程序和软件包的迁移通常是不可能的(由于架构约束)或成本太高。许多项目可以从合并异步多任务处理方式中受益,但最终只有少数项目会这样做。
这意味着现在,当尝试从头开始构建异步应用程序时,您将遇到许多困难。在大多数情况下,这将类似于“异步编程的实际示例”部分中提到的问题 - 接口不兼容和 I/O 操作的非异步阻塞。
当您遇到这种不兼容性时,您有时可以放弃await并同步获取所需的资源。但这将在等待结果时阻止其他协程执行其代码。从技术上讲,这是有效的,但也破坏了异步编程的所有收益。因此,最终,将异步 I/O 与同步 I/O 结合起来不是一个选择。这是一种“全有或全无”的游戏。
另一个问题是长时间运行的 CPU 绑定操作。当您执行 I/O 操作时,释放控制权不是一个问题。当从文件系统或套接字中读取/写入时,您最终会等待,因此使用await是您能做的最好的事情。但是当您需要实际计算某些东西并且知道这将需要一段时间时该怎么办?当然,您可以将问题切分成几部分,并在每次推进工作时释放控制权。但很快您会发现这不是一个好的模式。这样做可能会使代码混乱,也不能保证良好的结果。时间切片应该是解释器或操作系统的责任。
那么,如果您有一些使长时间同步 I/O 操作的代码,而您无法或不愿意重写。或者当您需要在主要设计为异步 I/O 的应用程序中进行一些重型 CPU 绑定操作时该怎么办?嗯...您需要使用一种变通方法。我所说的变通方法是多线程或多进程。
这可能听起来不好,但有时最好的解决方案可能是我们试图逃避的解决方案。在 Python 中,对 CPU 密集型任务的并行处理总是使用多进程更好。如果设置正确并小心处理,多线程可以同样好地处理 I/O 操作(快速且没有太多资源开销)如async和await。
所以有时当你不知道该怎么办,当某些东西简单地不适合你的异步应用程序时,使用一段代码将它推迟到单独的线程或进程。你可以假装这是一个协程,释放控制权给事件循环,最终在结果准备好时处理结果。幸运的是,Python 标准库提供了concurrent.futures模块,它也与asyncio模块集成。这两个模块一起允许你安排在线程或额外进程中执行的阻塞函数,就像它们是异步非阻塞的协程一样。
执行者和未来
在我们看到如何将线程或进程注入异步事件循环之前,我们将更仔细地看一下concurrent.futures模块,这将成为我们所谓的变通方法的主要组成部分。
concurrent.futures模块中最重要的类是Executor和Future。
Executor代表一个可以并行处理工作项的资源池。这在目的上似乎与multiprocessing模块的Pool和dummy.Pool类非常相似,但它有完全不同的接口和语义。它是一个不打算实例化的基类,并且有两个具体的实现:
-
ThreadPoolExecutor:这个代表一个线程池 -
ProcessPoolExecutor:这个代表一个进程池
每个执行者提供三种方法:
-
submit(fn, *args, **kwargs):这个方法安排fn函数在资源池上执行,并返回代表可调用执行的Future对象 -
map(func, *iterables, timeout=None, chunksize=1):这个方法以类似于multiprocessing.Pool.map()方法的方式在可迭代对象上执行 func 函数 -
shutdown(wait=True):这个方法关闭执行者并释放它的所有资源
最有趣的方法是submit(),因为它返回一个Future对象。它代表一个可调用的异步执行,间接代表它的结果。为了获得提交的可调用的实际返回值,你需要调用Future.result()方法。如果可调用已经完成,result()方法不会阻塞它,只会返回函数的输出。如果不是这样,它会阻塞直到结果准备好。把它当作一个结果的承诺(实际上它和 JavaScript 中的 promise 概念是一样的)。你不需要立即在接收到它后解包它(用result()方法),但如果你试图这样做,它保证最终会返回一些东西:
>>> def loudy_return():
... print("processing")
... return 42
...**
>>> from concurrent.futures import ThreadPoolExecutor
>>> with ThreadPoolExecutor(1) as executor:
... future = executor.submit(loudy_return)
...**
processing
>>> future
<Future at 0x33cbf98 state=finished returned int>
>>> future.result()
42
如果你想使用Executor.map()方法,它在用法上与multiprocessing模块的Pool类的map()方法没有区别:
def main():
with ThreadPoolExecutor(POOL_SIZE) as pool:
results = pool.map(fetch_place, PLACES)
for result in results:
present_result(result)
在事件循环中使用执行者
Executor.submit()方法返回的Future类实例在概念上与异步编程中使用的协程非常接近。这就是为什么我们可以使用执行者来实现协作式多任务和多进程或多线程的混合。
这个变通方法的核心是事件循环类的BaseEventLoop.run_in_executor(executor, func, *args)方法。它允许你在由executor参数表示的进程或线程池中安排func函数的执行。这个方法最重要的一点是它返回一个新的awaitable(一个可以用await语句await的对象)。因此,由于这个方法,你可以执行一个阻塞函数,它不是一个协程,就像它是一个协程一样,无论它需要多长时间来完成,它都不会阻塞。它只会阻止等待这样一个调用结果的函数,但整个事件循环仍然会继续运转。
一个有用的事实是,您甚至不需要创建自己的执行器实例。如果将None作为执行器参数传递,将使用ThreadPoolExecutor类以默认线程数(对于 Python 3.5,它是处理器数量乘以 5)。
因此,让我们假设我们不想重写导致我们头疼的python-gmaps包的有问题的部分。我们可以通过loop.run_in_executor()调用轻松地将阻塞调用推迟到单独的线程,同时将fetch_place()函数保留为可等待的协程:
async def fetch_place(place):
coro = loop.run_in_executor(None, api.geocode, place)
result = await coro
return result[0]
这样的解决方案并不像拥有完全异步库来完成工作那样好,但您知道半瓶水总比没有水好。
总结
这是一段漫长的旅程,但我们成功地克服了 Python 程序员可用的并发编程的最基本方法。
在解释并发到底是什么之后,我们迅速行动起来,通过多线程的帮助解剖了典型的并发问题之一。在确定了我们代码的基本缺陷并加以修复后,我们转向了多进程,看看它在我们的情况下会如何运作。
我们发现,使用multiprocessing模块比使用threading的基本线程要容易得多。但就在那之后,我们意识到我们也可以使用相同的 API 来处理线程,多亏了multiprocessing.dummy。因此,现在在多进程和多线程之间的选择只是更适合问题的解决方案,而不是哪种解决方案具有更好的接口。
说到问题的适应性,我们最终尝试了异步编程,这应该是 I/O 密集型应用程序的最佳解决方案,只是意识到我们不能完全忘记线程和进程。所以我们又回到了起点!
这就引出了本章的最终结论。并没有银弹。有一些方法可能更受您喜欢。有一些方法可能更适合特定的问题集,但您需要了解它们,以便取得成功。在现实场景中,您可能会发现自己在单个应用程序中使用整套并发工具和风格,这并不罕见。
上述结论是下一章第十四章有用的设计模式主题的绝佳引言。这是因为没有单一的模式可以解决您所有的问题。您应该尽可能了解尽可能多的模式,因为最终您将每天都使用它们。
第十四章:有用的设计模式
设计模式是软件设计中常见问题的可重用的、有些特定于语言的解决方案。关于这个主题最流行的书是设计模式:可复用面向对象软件的元素,Addison-Wesley Professional,由 Gamma、Helm、Johnson 和 Vlissides 编写,也被称为四人帮或GoF。它被认为是这一领域的重要著作,并提供了 23 种设计模式的目录,其中包括 SmallTalk 和 C++的示例。
在设计应用程序代码时,这些模式有助于解决常见问题。它们向所有开发人员发出警报,因为它们描述了经过验证的开发范例。但是应该根据使用的语言来学习它们,因为其中一些在某些语言中没有意义或者已经内置。
本章描述了 Python 中最有用的模式或者有趣讨论的模式,并提供了实现示例。以下是三个部分,对应于 GoF 定义的设计模式类别:
-
创建模式:这些模式用于生成具有特定行为的对象
-
结构模式:这些模式有助于为特定用例构建代码结构
-
行为模式:这些模式有助于分配责任和封装行为
创建模式
创建模式处理对象实例化机制。这样的模式可能定义了对象实例的创建方式,甚至类的构造方式。
这些模式在编译语言(如 C 或 C++)中非常重要,因为在运行时更难以按需生成类型。
但是在 Python 中,运行时创建新类型非常简单。内置的type函数允许您通过代码定义一个新的类型对象:
>>> MyType = type('MyType', (object,), {'a': 1})
>>> ob = MyType()
>>> type(ob)
<class '__main__.MyType'>
>>> ob.a
1
>>> isinstance(ob, object)
True
类和类型是内置的工厂。我们已经处理了新类对象的创建,您可以使用元类与类和对象生成进行交互。这些功能是实现工厂设计模式的基础,但我们不会在本节进一步描述它,因为我们已经在第三章中广泛涵盖了类和对象创建的主题,语法最佳实践 - 类级别以上。
除了工厂,GoF 中另一个有趣的创建设计模式是单例。
单例
单例将类的实例化限制为仅一个对象实例。
单例模式确保给定的类在应用程序中始终只有一个活动实例。例如,当您希望将资源访问限制为进程中仅有一个内存上下文时,可以使用此模式。例如,数据库连接器类可以是一个单例,它处理同步并在内存中管理数据。它假设与数据库交互的同时没有其他实例。
这种模式可以简化应用程序中处理并发的方式。提供应用程序范围功能的实用程序通常被声明为单例。例如,在 Web 应用程序中,负责保留唯一文档 ID 的类将受益于单例模式。应该有一个且仅有一个执行此工作的实用程序。
在 Python 中,有一种流行的半成语是通过覆盖类的__new__()方法来创建单例:
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls, *args, **kwargs)
return cls._instance
如果您尝试创建该类的多个实例并比较它们的 ID,您会发现它们都代表同一个对象:
>>> instance_a = Singleton()
>>> instance_b = Singleton()
>>> id(instance_a) == id(instance_b)
True
>>> instance_a == instance_b
True
我将其称为半成语,因为这是一个非常危险的模式。问题在于当您尝试对基本单例类进行子类化并创建此新子类的实例时,如果您已经创建了基类的实例,则问题就开始了。
>>> class ConcreteClass(Singleton): pass
>>> Singleton()
<Singleton object at 0x000000000306B470>
>>> ConcreteClass()
<Singleton object at 0x000000000306B470>
这可能会变得更加棘手,当你注意到这种行为受到实例创建顺序的影响时。根据你的类使用顺序,你可能会得到相同的结果,也可能不会。让我们看看如果你首先创建子类实例,然后创建基类的实例,结果会是什么样的:
>>> class ConcreteClass(Singleton): pass
>>> ConcreteClass()
<ConcreteClass object at 0x00000000030615F8>
>>> Singleton()
<Singleton object at 0x000000000304BCF8>
正如你所看到的,行为完全不同,非常难以预测。在大型应用程序中,这可能导致非常危险且难以调试的问题。根据运行时上下文,您可能会或者不会使用您本来打算使用的类。由于这种行为真的很难预测和控制,应用程序可能会因为改变的导入顺序甚至用户输入而崩溃。如果您的单例不打算被子类化,那么以这种方式实现可能相对安全。无论如何,这是一个定时炸弹。如果将来有人忽视风险并决定从您的单例对象创建一个子类,一切都可能会爆炸。避免使用这种特定的实现,使用另一种替代方案会更安全。
使用更高级的技术——元类是更安全的。通过重写元类的__call__()方法,您可以影响自定义类的创建。这允许创建可重用的单例代码:
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
通过将Singleton用作自定义类的元类,您可以获得安全的可子类化的单例,并且不受实例创建顺序的影响:
>>> ConcreteClass() == ConcreteClass()
True
>>> ConcreteSubclass() == ConcreteSubclass()
True
>>> ConcreteClass()
<ConcreteClass object at 0x000000000307AF98>
>>> ConcreteSubclass()
<ConcreteSubclass object at 0x000000000307A3C8>
克服单例实现问题的另一种方法是使用 Alex Martelli 提出的方法。他提出了一种与单例类似但在结构上完全不同的方法。这不是来自 GoF 书籍的经典设计模式,但似乎在 Python 开发人员中很常见。它被称为Borg或Monostate。
这个想法非常简单。单例模式中真正重要的不是一个类有多少个实例,而是它们始终共享相同的状态。因此,Alex Martelli 提出了一个使类的所有实例共享相同__dict__的类:
class Borg(object):
_state = {}
def __new__(cls, *args, **kwargs):
ob = super().__new__(cls, *args, **kwargs)
ob.__dict__ = cls._state
return ob
这解决了子类化问题,但仍取决于子类代码的工作方式。例如,如果重写了__getattr__,则可能会破坏模式。
然而,单例不应该有多层继承。标记为单例的类已经是特定的。
也就是说,许多开发人员认为这种模式是处理应用程序中的唯一性的一种繁重方式。如果需要单例,为什么不使用具有函数的模块,因为 Python 模块已经是单例了呢?最常见的模式是将模块级变量定义为需要是单例的类的实例。这样,你也不会限制开发人员对你的初始设计。
注意
单例工厂是处理应用程序唯一性的隐式方式。你可以不用它。除非你在类似 Java 的框架中工作,这种模式是必需的,否则请使用模块而不是类。
结构模式
结构模式在大型应用程序中非常重要。它们决定了代码的组织方式,并为开发人员提供了如何与应用程序的每个部分进行交互的指南。
长期以来,Python 世界中许多结构模式的最著名实现是 Zope 项目的Zope 组件架构(ZCA)。它实现了本节中描述的大多数模式,并提供了一套丰富的工具来处理它们。ZCA 旨在不仅在 Zope 框架中运行,还在其他框架中运行,如 Twisted。它提供了接口和适配器的实现,以及其他功能。
不幸的是(或者不是),Zope 几乎失去了所有的动力,不再像以前那样受欢迎。但是它的 ZCA 可能仍然是 Python 中实现结构模式的一个很好的参考。Baiju Muthukadan 创建了Zope 组件架构综合指南。它可以打印和免费在线获取(参考muthukadan.net/docs/zca.html)。它是在 2009 年写的,所以它没有涵盖 Python 的最新版本,但应该是一个很好的阅读,因为它为一些提到的模式提供了很多合理性。
Python 已经通过其语法提供了一些流行的结构模式。例如,类和函数装饰器可以被认为是装饰器模式的一种变体。此外,创建和导入模块的支持是模块模式的一种表现。
常见结构模式的列表实际上相当长。原始的设计模式书中有多达七种,后来的文献中还扩展了这个列表。我们不会讨论所有这些模式,而只会专注于最受欢迎和公认的三种模式,它们是:
-
适配器
-
代理
-
外观
适配器
适配器模式允许使用现有类的接口从另一个接口中使用。换句话说,适配器包装了一个类或对象A,使其在预期用于类或对象B的上下文中工作。
在 Python 中创建适配器实际上非常简单,因为这种语言的类型系统是如何工作的。Python 中的类型哲学通常被称为鸭子类型:
“如果它走起来像鸭子,说起来像鸭子,那么它就是鸭子!”
根据这个规则,如果一个函数或方法接受一个值,决定不应该基于它的类型,而应该基于它的接口。因此,只要对象的行为符合预期,即具有适当的方法签名和属性,它的类型就被认为是兼容的。这与许多静态类型的语言完全不同,在这些语言中很少有这样的事情。
在实践中,当一些代码打算与给定类一起工作时,只要它们提供了代码使用的方法和属性,就可以用另一个类的对象来提供它。当然,这假设代码不会调用instance来验证实例是否属于特定类。
适配器模式基于这种哲学,定义了一种包装机制,其中一个类或对象被包装以使其在最初不打算用于它的上下文中工作。StringIO就是一个典型的例子,因为它适应了str类型,所以它可以被用作file类型:
>>> from io import StringIO
>>> my_file = StringIO('some content')
>>> my_file.read()
'some content'
>>> my_file.seek(0)
>>> my_f
ile.read(1)
's'
让我们举另一个例子。DublinCoreInfos类知道如何显示给定文档的一些 Dublin Core 信息子集的摘要(参见dublincore.org/),并提供为dict提供。它读取一些字段,比如作者的名字或标题,并打印它们。为了能够显示文件的 Dublin Core,它必须以与StringIO相同的方式进行适配。下图显示了这种适配器模式实现的类似 UML 的图。
图 2 简单适配器模式示例的 UML 图
DublinCoreAdapter包装了一个文件实例,并提供了对其元数据的访问:
from os.path import split, splitext
class DublinCoreAdapter:
def __init__(self, filename):
self._filename = filename
@property
def title(self):
return splitext(split(self._filename)[-1])[0]
@property
def languages(self):
return ('en',)
def __getitem__(self, item):
return getattr(self, item, 'Unknown')
class DublinCoreInfo(object):
def summary(self, dc_dict):
print('Title: %s' % dc_dict['title'])
print('Creator: %s' % dc_dict['creator'])
print('Languages: %s' % ', '.join(dc_dict['languages']))
以下是示例用法:
>>> adapted = DublinCoreAdapter('example.txt')
>>> infos = DublinCoreInfo()
>>> infos.summary(adapted)
Title: example
Creator: Unknown
Languages: en
除了允许替换的事实之外,适配器模式还可以改变开发人员的工作方式。将对象适应特定上下文的假设是对象的类根本不重要。重要的是这个类实现了DublinCoreInfo等待的内容,并且这种行为由适配器固定或完成。因此,代码可以简单地告诉它是否与实现特定行为的对象兼容。这可以通过接口来表达。
接口
接口是 API 的定义。它描述了一个类应该具有的方法和属性列表,以实现所需的行为。这个描述不实现任何代码,只是为希望实现接口的任何类定义了一个明确的合同。然后任何类都可以以任何方式实现一个或多个接口。
虽然 Python 更喜欢鸭子类型而不是明确的接口定义,但有时使用它们可能更好。例如,明确的接口定义使框架更容易定义接口上的功能。
好处在于类之间松散耦合,这被认为是一种良好的实践。例如,要执行给定的过程,类A不依赖于类B,而是依赖于接口I。类B实现了I,但它可以是任何其他类。
许多静态类型语言(如 Java 或 Go)内置了对这种技术的支持。接口允许函数或方法限制实现给定接口的可接受参数对象的范围,无论它来自哪种类。这比将参数限制为给定类型或其子类更灵活。这就像鸭子类型行为的显式版本:Java 使用接口在编译时验证类型安全,而不是在运行时使用鸭子类型将事物绑在一起。
Python 对接口的类型哲学与 Java 完全不同,因此它没有原生支持接口。无论如何,如果您想对应用程序接口有更明确的控制,通常有两种选择:
-
使用一些第三方框架添加接口的概念
-
使用一些高级语言特性来构建处理接口的方法论。
使用 zope.interface
有一些框架允许您在 Python 中构建明确的接口。最值得注意的是 Zope 项目的一部分。它是zope.interface包。尽管如今 Zope 不像以前那样受欢迎,但zope.interface包仍然是 Twisted 框架的主要组件之一。
zope.interface包的核心类是Interface类。它允许您通过子类化来明确定义一个新的接口。假设我们想为矩形的每个实现定义一个强制性接口:
from zope.interface import Interface, Attribute
class IRectangle(Interface):
width = Attribute("The width of rectangle")
height = Attribute("The height of rectangle")
def area():
""" Return area of rectangle
"""
def perimeter():
""" Return perimeter of rectangle
"""
使用zope.interface定义接口时需要记住的一些重要事项如下:
-
接口的常见命名约定是使用
I作为名称后缀。 -
接口的方法不得带有
self参数。 -
由于接口不提供具体实现,因此它应该只包含空方法。您可以使用
pass语句,引发NotImplementedError,或提供文档字符串(首选)。 -
接口还可以使用
Attribute类指定所需的属性。
当您定义了这样的合同后,您可以定义提供IRectangle接口实现的新具体类。为此,您需要使用implementer()类装饰器并实现所有定义的方法和属性:
@implementer(IRectangle)
class Square:
""" Concrete implementation of square with rectangle interface
"""
def __init__(self, size):
self.size = size
@property
def width(self):
return self.size
@property
def height(self):
return self.size
def area(self):
return self.size ** 2
def perimeter(self):
return 4 * self.size
@implementer(IRectangle)
class Rectangle:
""" Concrete implementation of rectangle
"""
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return self.width * 2 + self.height * 2
通常说接口定义了具体实现需要满足的合同。这种设计模式的主要好处是能够在对象被使用之前验证合同和实现之间的一致性。使用普通的鸭子类型方法,只有在运行时缺少属性或方法时才会发现不一致性。使用zope.interface,您可以使用zope.interface.verify模块的两种方法来提前检查实际实现中的不一致性:
-
verifyClass(interface, class_object): 这会验证类对象是否存在方法,并检查其签名的正确性,而不会查找属性 -
verifyObject(interface, instance): 这验证实际对象实例的方法、它们的签名和属性
由于我们已经定义了我们的接口和两个具体的实现,让我们在交互式会话中验证它们的契约:
>>> from zope.interface.verify import verifyClass, verifyObject
>>> verifyObject(IRectangle, Square(2))
True
>>> verifyClass(IRectangle, Square)
True
>>> verifyObject(IRectangle, Rectangle(2, 2))
True
>>> verifyClass(IRectangle, Rectangle)
True
没有什么令人印象深刻的。Rectangle和Square类仔细遵循了定义的契约,因此除了成功的验证外,没有更多的东西可见。但是当我们犯错时会发生什么?让我们看一个未能提供完整IRectangle接口实现的两个类的示例:
@implementer(IRectangle)
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
@implementer(IRectangle)
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
Point类没有提供IRectangle接口的任何方法或属性,因此它的验证将在类级别上显示不一致性:
>>> verifyClass(IRectangle, Point)
Traceback (most recent call last):
**File "<stdin>", line 1, in <module>
**File "zope/interface/verify.py", line 102, in verifyClass
**return _verify(iface, candidate, tentative, vtype='c')
**File "zope/interface/verify.py", line 62, in _verify
**raise BrokenImplementation(iface, name)
zope.interface.exceptions.BrokenImplementation: An object has failed to implement interface <InterfaceClass __main__.IRectangle>
**The perimeter attribute was not provided.
Circle类有点棘手。它定义了所有接口方法,但在实例属性级别上违反了契约。这就是为什么在大多数情况下,您需要使用verifyObject()函数来完全验证接口实现的原因:
>>> verifyObject(IRectangle, Circle(2))
Traceback (most recent call last):
**File "<stdin>", line 1, in <module>
**File "zope/interface/verify.py", line 105, in verifyObject
**return _verify(iface, candidate, tentative, vtype='o')
**File "zope/interface/verify.py", line 62, in _verify
**raise BrokenImplementation(iface, name)
zope.interface.exceptions.BrokenImplementation: An object has failed to implement interface <InterfaceClass __main__.IRectangle>
**The width attribute was not provided.
使用zope.inteface是一种有趣的解耦应用程序的方式。它允许您强制执行正确的对象接口,而无需多重继承的过度复杂性,并且还可以及早捕获不一致性。然而,这种方法最大的缺点是要求您明确定义给定类遵循某个接口才能进行验证。如果您需要验证来自内置库的外部类的实例,这将特别麻烦。zope.interface为该问题提供了一些解决方案,当然您也可以使用适配器模式或甚至猴子补丁来处理这些问题。无论如何,这些解决方案的简单性至少是值得商榷的。
使用函数注释和抽象基类
设计模式的目的是使问题解决变得更容易,而不是为您提供更多的复杂层次。zope.interface是一个很好的概念,可能非常适合某些项目,但它并不是万能解决方案。使用它,您可能很快就会发现自己花费更多时间修复与第三方类的不兼容接口的问题,并提供无休止的适配器层,而不是编写实际的实现。如果您有这种感觉,那么这是某种问题出现的迹象。幸运的是,Python 支持构建轻量级的接口替代方案。它不像zope.interface或其替代方案那样是一个成熟的解决方案,但通常提供更灵活的应用程序。您可能需要编写更多的代码,但最终您将拥有更具可扩展性,更好地处理外部类型,并且可能更具未来性的东西。
请注意,Python 在其核心中没有接口的明确概念,可能永远不会有,但具有一些功能,允许您构建类似接口功能的东西。这些功能包括:
-
抽象基类(ABCs)
-
函数注释
-
类型注释
我们解决方案的核心是抽象基类,所以我们将首先介绍它们。
如您可能知道的那样,直接的类型比较被认为是有害的,而且不是pythonic。您应该始终避免以下比较:
assert type(instance) == list
在函数或方法中比较类型的方式完全破坏了将类子类型作为参数传递给函数的能力。稍微更好的方法是使用isinstance()函数,它会考虑继承关系:
assert isinstance(instance, list)
isinstance()的额外优势是您可以使用更广泛的类型来检查类型兼容性。例如,如果您的函数期望接收某种序列作为参数,您可以与基本类型的列表进行比较:
assert isinstance(instance, (list, tuple, range))
这种类型兼容性检查的方式在某些情况下是可以的,但仍然不完美。它将适用于list、tuple或range的任何子类,但如果用户传递的是与这些序列类型完全相同但不继承自任何一个的东西,它将失败。例如,让我们放宽要求,说你想接受任何类型的可迭代对象作为参数。你会怎么做?可迭代的基本类型列表实际上相当长。你需要涵盖 list、tuple、range、str、bytes、dict、set、生成器等等。适用的内置类型列表很长,即使你覆盖了所有这些类型,它仍然不允许你检查是否与定义了__iter__()方法的自定义类兼容,而是直接继承自object。
这是抽象基类(ABC)是适当解决方案的情况。ABC 是一个类,不需要提供具体的实现,而是定义了一个类的蓝图,可以用来检查类型的兼容性。这个概念与 C++语言中的抽象类和虚方法的概念非常相似。
抽象基类用于两个目的:
-
检查实现的完整性
-
检查隐式接口兼容性
因此,让我们假设我们想定义一个接口,确保一个类具有push()方法。我们需要使用特殊的ABCMeta元类和标准abc模块中的abstractmethod()装饰器创建一个新的抽象基类:
from abc import ABCMeta, abstractmethod
class Pushable(metaclass=ABCMeta):
@abstractmethod
def push(self, x):
""" Push argument no matter what it means
"""
abc模块还提供了一个可以用来代替元类语法的 ABC 基类:
from abc import ABCMeta, abstractmethod
class Pushable(metaclass=ABCMeta):
@abstractmethod
def push(self, x):
""" Push argument no matter what it means
"""
一旦完成,我们可以将Pushable类用作具体实现的基类,并且它将阻止我们实例化具有不完整实现的对象。让我们定义DummyPushable,它实现了所有接口方法和IncompletePushable,它违反了预期的合同:
class DummyPushable(Pushable):
def push(self, x):
return
class IncompletePushable(Pushable):
pass
如果你想获得DummyPushable实例,那就没有问题,因为它实现了唯一需要的push()方法:
>>> DummyPushable()
<__main__.DummyPushable object at 0x10142bef0>
但是,如果你尝试实例化IncompletePushable,你会得到TypeError,因为缺少interface()方法的实现:
>>> IncompletePushable()
Traceback (most recent call last):
**File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class IncompletePushable with abstract methods push
前面的方法是确保基类实现完整性的好方法,但与zope.interface替代方案一样明确。DummyPushable实例当然也是Pushable的实例,因为 Dummy 是Pushable的子类。但是其他具有相同方法但不是Pushable的后代的类呢?让我们创建一个并看看:
>>> class SomethingWithPush:
... def push(self, x):
... pass
...**
>>> isinstance(SomethingWithPush(), Pushable)
False
还有一些东西缺失。SomethingWithPush类明确具有兼容的接口,但尚未被视为Pushable的实例。那么,缺少什么?答案是__subclasshook__(subclass)方法,它允许你将自己的逻辑注入到确定对象是否是给定类的实例的过程中。不幸的是,你需要自己提供它,因为abc的创建者不希望限制开发人员覆盖整个isinstance()机制。我们对它有完全的控制权,但我们被迫写一些样板代码。
虽然你可以做任何你想做的事情,但通常在__subclasshook__()方法中唯一合理的事情是遵循常见的模式。标准程序是检查定义的方法集是否在给定类的 MRO 中的某个地方可用:
from abc import ABCMeta, abstractmethod
class Pushable(metaclass=ABCMeta):
@abstractmethod
def push(self, x):
""" Push argument no matter what it means
"""
@classmethod
def __subclasshook__(cls, C):
if cls is Pushable:
if any("push" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented
通过这种方式定义__subclasshook__()方法,现在可以确认隐式实现接口的实例也被视为接口的实例:
>>> class SomethingWithPush:
... def push(self, x):
... pass
...**
>>> isinstance(SomethingWithPush(), Pushable)
True
不幸的是,这种验证类型兼容性和实现完整性的方法并未考虑类方法的签名。因此,如果实现中预期的参数数量不同,它仍将被视为兼容。在大多数情况下,这不是问题,但如果您需要对接口进行如此精细的控制,zope.interface包允许这样做。正如前面所说,__subclasshook__()方法不会限制您在isinstance()函数的逻辑中添加更多复杂性,以实现类似的控制水平。
补充抽象基类的另外两个特性是函数注释和类型提示。函数注释是在第二章中简要描述的语法元素,语法最佳实践-类级别以下。它允许您使用任意表达式对函数及其参数进行注释。正如第二章中所解释的,语法最佳实践-类级别以下,这只是一个不提供任何语法意义的功能存根。标准库中没有使用此功能来强制执行任何行为。无论如何,您可以将其用作通知开发人员预期参数接口的便捷且轻量级的方式。例如,考虑从zope.interface重写的IRectangle接口以抽象基类的形式:
from abc import (
ABCMeta,
abstractmethod,
abstractproperty
)
class IRectangle(metaclass=ABCMeta):
@abstractproperty
def width(self):
return
@abstractproperty
def height(self):
return
@abstractmethod
def area(self):
""" Return rectangle area
"""
@abstractmethod
def perimeter(self):
""" Return rectangle perimeter
"""
@classmethod
def __subclasshook__(cls, C):
if cls is IRectangle:
if all([
any("area" in B.__dict__ for B in C.__mro__),
any("perimeter" in B.__dict__ for B in C.__mro__),
any("width" in B.__dict__ for B in C.__mro__),
any("height" in B.__dict__ for B in C.__mro__),
]):
return True
return NotImplemented
如果您有一个仅适用于矩形的函数,比如draw_rectangle(),您可以将预期参数的接口注释如下:
def draw_rectangle(rectangle: IRectange):
...
这只是为开发人员提供有关预期信息的信息。即使这是通过非正式合同完成的,因为正如我们所知,裸注释不包含任何语法意义。但是,它们在运行时是可访问的,因此我们可以做更多的事情。以下是一个通用装饰器的示例实现,它能够验证函数注释中提供的接口是否使用抽象基类:
def ensure_interface(function):
signature = inspect.signature(function)
parameters = signature.parameters
@wraps(function)
def wrapped(*args, **kwargs):
bound = signature.bind(*args, **kwargs)
for name, value in bound.arguments.items():
annotation = parameters[name].annotation
if not isinstance(annotation, ABCMeta):
continue
if not isinstance(value, annotation):
raise TypeError(
"{} does not implement {} interface"
"".format(value, annotation)
)
function(*args, **kwargs)
return wrapped
一旦完成,我们可以创建一些具体的类,它们隐式地实现了IRectangle接口(而不是继承自IRectangle),并更新draw_rectangle()函数的实现,以查看整个解决方案的工作原理:
class ImplicitRectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
return self._width
@property
def height(self):
return self._height
def area(self):
return self.width * self.height
def perimeter(self):
return self.width * 2 + self.height * 2
@ensure_interface
def draw_rectangle(rectangle: IRectangle):
print(
"{} x {} rectangle drawing"
"".format(rectangle.width, rectangle.height)
)
如果我们使用不兼容的对象来调用draw_rectangle()函数,它现在将引发TypeError并提供有意义的解释:
>>> draw_rectangle('foo')
Traceback (most recent call last):
**File "<input>", line 1, in <module>
**File "<input>", line 101, in wrapped
TypeError: foo does not implement <class 'IRectangle'> interface
但是,如果我们使用ImplicitRectangle或任何其他类似IRectangle接口的对象,该函数将按预期执行:
>>> draw_rectangle(ImplicitRectangle(2, 10))
2 x 10 rectangle drawing
我们的ensure_interface()的示例实现是基于typeannotations项目中的typechecked()装饰器,该项目试图提供运行时检查功能(请参阅github.com/ceronman/typeannotations)。它的源代码可能会给您一些有趣的想法,关于如何处理类型注释以确保运行时接口检查。
可以用来补充这种接口模式的最后一个特性是类型提示。类型提示在 PEP 484 中有详细描述,并且是最近添加到语言中的。它们在新的typing模块中公开,并且从 Python 3.5 开始可用。类型提示建立在函数注释的基础上,并重用了 Python 3 中略微被遗忘的语法特性。它们旨在指导类型提示并检查各种尚未出现的 Python 类型检查器。typing模块和 PEP 484 文档旨在提供一种用于描述类型注释的标准类型和类的层次结构。
然而,类型提示似乎并不是什么革命性的东西,因为这个特性并没有内置任何类型检查器到标准库中。如果你想在你的代码中使用类型检查或者强制严格的接口兼容性,你需要创建自己的工具,因为目前还没有值得推荐的工具。这就是为什么我们不会深入研究 PEP 484 的细节。无论如何,类型提示和描述它们的文档是值得一提的,因为如果在 Python 的类型检查领域出现了一些非凡的解决方案,它很可能是基于 PEP 484 的。
使用 collections.abc
抽象基类就像创建更高级抽象的小积木。它们允许你实现真正可用的接口,但非常通用,设计用于处理远远超出这个单一设计模式的东西。你可以释放你的创造力,做出神奇的事情,但构建一些通用的、真正可用的东西可能需要大量的工作。这可能永远得不到回报。
这就是为什么自定义抽象基类并不经常使用。尽管如此,collections.abc模块提供了许多预定义的 ABCs,允许验证许多基本 Python 类型的接口兼容性。使用这个模块提供的基类,你可以检查一个给定的对象是否可调用、映射,或者是否支持迭代。使用它们与isinstance()函数比较要比与基本的 Python 类型比较要好得多。即使你不想使用ABCMeta定义自己的自定义接口,你也应该知道如何使用这些基类。
你会时不时地使用collections.abc中最常见的抽象基类:
-
Container:这个接口意味着对象支持in操作符,并实现了__contains__()方法 -
Iterable:这个接口意味着对象支持迭代,并实现了__iter__()方法 -
Callable:这个接口意味着它可以像函数一样被调用,并实现了__call__()方法 -
Hashable:这个接口意味着对象是可散列的(可以包含在集合中并作为字典中的键),并实现了__hash__方法 -
Sized:这个接口意味着对象有大小(可以使用len()函数)并实现了__len__()方法
collections.abc模块中可用的抽象基类的完整列表可以在官方 Python 文档中找到(参见docs.python.org/3/library/collections.abc.html)。
代理
代理提供了对昂贵或远程资源的间接访问。代理位于客户端和主体之间,如下图所示:
如果 Subject 的访问是昂贵的,这是为了优化 Subject 的访问。例如,在第十二章中描述的memoize()和lru_cache()装饰器,优化-一些强大的技术,可以被视为代理。
代理也可以用来提供对主体的智能访问。例如,大型视频文件可以被包装成代理,以避免在用户只要求它们的标题时将它们加载到内存中。
urllib.request模块提供了一个例子。urlopen是一个代理,用于访问远程 URL 上的内容。当它被创建时,可以独立于内容本身检索头部,而无需读取响应的其余部分:
>>> class Url(object):
... def __init__(self, location):
... self._url = urlopen(location)
... def headers(self):
... return dict(self._url.headers.items())
... def get(self):
... return self._url.read()
...**
>>> python_org = Url('http://python.org')
>>> python_org.headers().keys()
dict_keys(['Accept-Ranges', 'Via', 'Age', 'Public-Key-Pins', 'X-Clacks-Overhead', 'X-Cache-Hits', 'X-Cache', 'Content-Type', 'Content-Length', 'Vary', 'X-Served-By', 'Strict-Transport-Security', 'Server', 'Date', 'Connection', 'X-Frame-Options'])
这可以用来决定在获取页面主体之前是否已经更改了页面,通过查看last-modified头部。让我们用一个大文件举个例子:
>>> ubuntu_iso = Url('http://ubuntu.mirrors.proxad.net/hardy/ubuntu-8.04-desktop-i386.iso')
>>> ubuntu_iso.headers()['Last-Modified']
'Wed, 23 Apr 2008 01:03:34 GMT'
代理的另一个用例是数据唯一性。
例如,让我们考虑一个网站,在几个位置上呈现相同的文档。特定于每个位置的额外字段被附加到文档中,例如点击计数器和一些权限设置。在这种情况下,可以使用代理来处理特定于位置的问题,并指向原始文档,而不是复制它。因此,给定的文档可以有许多代理,如果其内容发生变化,所有位置都将受益,而无需处理版本同步。
一般来说,代理模式对于实现可能存在于其他地方的某些东西的本地处理很有用:
-
加快流程
-
避免外部资源访问
-
减少内存负载
-
确保数据的唯一性
Facade
Facade提供了对子系统的高级、简单的访问。
Facade 只是一个快捷方式,用于使用应用程序的功能,而不必处理子系统的底层复杂性。例如,可以通过在包级别提供高级功能来实现这一点。
Facade 通常是在现有系统上完成的,其中包的频繁使用被合成为高级功能。通常,不需要类来提供这样的模式,__init__.py模块中的简单函数就足够了。
一个提供了一个大的外观覆盖复杂和复杂接口的项目的很好的例子是requests包(参考docs.python-requests.org/)。它通过提供一个清晰的 API,使得在 Python 中处理 HTTP 请求和响应的疯狂变得简单,这对开发人员来说非常容易阅读。它实际上甚至被宣传为“人类的 HTTP”。这种易用性总是以一定的代价为代价,但最终的权衡和额外的开销并不会吓倒大多数人使用 Requests 项目作为他们选择的 HTTP 工具。最终,它使我们能够更快地完成项目,而开发人员的时间通常比硬件更昂贵。
注意
Facade 简化了您的包的使用。在几次迭代后,通常会添加 Facade 以获得使用反馈。
行为模式
行为模式旨在通过结构化它们的交互过程来简化类之间的交互。
本节提供了三个流行的行为模式的示例,您在编写 Python 代码时可能需要考虑:
-
观察者
-
访问者
-
模板
观察者
观察者模式用于通知一系列对象观察组件的状态变化。
观察者允许以可插拔的方式向应用程序添加功能,通过将新功能与现有代码库解耦。事件框架是观察者模式的典型实现,并在接下来的图中描述。每当发生事件时,所有观察者都会收到触发此事件的主题的通知。
事件是发生某事时创建的。在图形用户界面应用程序中,事件驱动编程(参见en.wikipedia.org/wiki/Event-driven_programming)通常用于将代码与用户操作链接起来。例如,可以将函数链接到MouseMove事件,以便在鼠标在窗口上移动时调用它。
在 GUI 应用程序的情况下,将代码与窗口管理内部解耦会大大简化工作。函数是分开编写的,然后注册为事件观察者。这种方法存在于微软的 MFC 框架的最早版本中(参见en.wikipedia.org/wiki/Microsoft_Foundation_Class_Library),以及 Qt 或 GTK 等所有 GUI 开发工具中。许多框架使用信号的概念,但它们只是观察者模式的另一种表现。
代码也可以生成事件。例如,在一个将文档存储在数据库中的应用程序中,DocumentCreated、DocumentModified和DocumentDeleted可以是代码提供的三个事件。一个在文档上工作的新功能可以注册自己作为观察者,每当文档被创建、修改或删除时得到通知,并进行适当的工作。这样就可以在应用程序中添加一个文档索引器。当然,这要求负责创建、修改或删除文档的所有代码都触发事件。但这比在整个应用程序代码库中添加索引挂钩要容易得多!一个遵循这种模式的流行 Web 框架是 Django,它具有信号机制。
可以通过在类级别上工作来实现 Python 中观察者的注册的Event类:
class Event:
_observers = []
def __init__(self, subject):
self.subject = subject
@classmethod
def register(cls, observer):
if observer not in cls._observers:
cls._observers.append(observer)
@classmethod
def unregister(cls, observer):
if observer in cls._observers:
cls._observers.remove(observer)
@classmethod
def notify(cls, subject):
event = cls(subject)
for observer in cls._observers:
observer(event)
观察者使用Event类方法注册自己,并通过携带触发它们的主题的Event实例得到通知。以下是一个具体的Event子类的示例,其中一些观察者订阅了它的通知:
class WriteEvent(Event):
def __repr__(self):
return 'WriteEvent'
def log(event):
print(
'{!r} was fired with subject "{}"'
''.format(event, event.subject)
)
class AnotherObserver(object):
def __call__(self, event):
print(
"{!r} trigerred {}'s action"
"".format(event, self.__class__.__name__)
)
WriteEvent.register(log)
WriteEvent.register(AnotherObserver())
这里是使用WriteEvent.notify()方法触发事件的示例结果:
>>> WriteEvent.notify("something happened")
WriteEvent was fired with subject "something happened"
WriteEvent trigerred AnotherObserver's action
这个实现很简单,只是作为说明目的。要使其完全功能,可以通过以下方式加以增强:
-
允许开发人员更改事件的顺序
-
使事件对象携带的信息不仅仅是主题
解耦你的代码是有趣的,观察者是正确的模式。它将你的应用程序组件化,并使其更具可扩展性。如果你想使用现有的工具,可以尝试Blinker(参见pythonhosted.org/blinker/)。它为 Python 对象提供快速简单的对象到对象和广播信号。
访问者
访问者有助于将算法与数据结构分离,其目标与观察者模式类似。它允许扩展给定类的功能,而不改变其代码。但是访问者通过定义一个负责保存数据并将算法推送到其他类(称为Visitors)的类,更进一步。每个访问者专门负责一个算法,并可以在数据上应用它。
这种行为与 MVC 范式非常相似(参见en.wikipedia.org/wiki/Model-view-controller),其中文档是被动容器,通过控制器推送到视图,或者模型包含被控制器改变的数据。
访问者模式是通过在数据类中提供一个入口点来实现的,所有类型的访问者都可以访问。一个通用的描述是一个接受Visitor实例并调用它们的Visitable类,如下图所示:
Visitable类决定如何调用Visitor类,例如,决定调用哪个方法。例如,负责打印内置类型内容的访问者可以实现visit_TYPENAME()方法,每个这些类型可以在其accept()方法中调用给定的方法:
class VisitableList(list):
def accept(self, visitor):
visitor.visit_list(self)
class VisitableDict(dict):
def accept(self, visitor):
visitor.visit_dict(self)
class Printer(object):
def visit_list(self, instance):
print('list content: {}'.format(instance))
def visit_dict(self, instance):
print('dict keys: {}'.format(
', '.join(instance.keys()))
)
这是在下面的例子中所做的:
>>> visitable_list = VisitableList([1, 2, 5])
>>> visitable_list.accept(Printer())
list content: [1, 2, 5]
>>> visitable_dict = VisitableDict({'one': 1, 'two': 2, 'three': 3})
>>> visitable_dict.accept(Printer())
dict keys: two, one, three
但这种模式意味着每个被访问的类都需要有一个accept方法来被访问,这是相当痛苦的。
由于 Python 允许代码内省,一个更好的主意是自动链接访问者和被访问的类:
>>> def visit(visited, visitor):
... cls = visited.__class__.__name__
... method_name = 'visit_%s' % cls
... method = getattr(visitor, method_name, None)
... if isinstance(method, Callable):
... method(visited)
... else:
... raise AttributeError(
... "No suitable '{}' method in visitor"
... "".format(method_name)
... )
...**
>>> visit([1,2,3], Printer())
list content: [1, 2, 3]
>>> visit({'one': 1, 'two': 2, 'three': 3}, Printer())
dict keys: two, one, three
>>> visit((1, 2, 3), Printer())
Traceback (most recent call last):
**File "<input>", line 1, in <module>
**File "<input>", line 10, in visit
AttributeError: No suitable 'visit_tuple' method in visitor
这种模式在ast模块中以这种方式使用,例如,通过NodeVisitor类调用编译代码树的每个节点的访问者。这是因为 Python 没有像 Haskell 那样的匹配操作符。
另一个例子是一个目录遍历器,根据文件扩展名调用访问者方法:
>>> def visit(directory, visitor):
... for root, dirs, files in os.walk(directory):
... for file in files:
... # foo.txt → .txt
... ext = os.path.splitext(file)[-1][1:]
... if hasattr(visitor, ext):
... getattr(visitor, ext)(file)
...
>>> class FileReader(object):
... def pdf(self, filename):
... print('processing: {}'.format(filename))
...
>>> walker = visit('/Users/tarek/Desktop', FileReader())
processing slides.pdf
processing sholl23.pdf
如果您的应用程序具有多个算法访问的数据结构,则访问者模式将有助于分离关注点。数据容器最好只专注于提供对数据的访问和保存,而不做其他事情。
模板
模板通过定义在子类中实现的抽象步骤来设计通用算法。这种模式使用Liskov 替换原则,由维基百科定义为:
“如果 S 是 T 的子类型,则程序中类型 T 的对象可以替换为类型 S 的对象,而不会改变该程序的任何理想属性。”
换句话说,抽象类可以通过在具体类中实现的步骤来定义算法的工作方式。抽象类还可以为算法提供基本或部分实现,并让开发人员覆盖其部分。例如,queue模块中的Queue类的一些方法可以被覆盖以使其行为变化。
让我们实现一个示例,如下图所示。
Indexer是一个索引器类,它在五个步骤中处理文本,无论使用何种索引技术,这些步骤都是常见的:
-
文本规范化
-
文本拆分
-
停用词去除
-
词干词
-
频率
Indexer为处理算法提供了部分实现,但需要在子类中实现_remove_stop_words和_stem_words。 BasicIndexer实现了严格的最小值,而LocalIndex使用了停用词文件和词干词数据库。 FastIndexer实现了所有步骤,并可以基于快速索引器(如 Xapian 或 Lucene)。
一个玩具实现可以是:
from collections import Counter
class Indexer:
def process(self, text):
text = self._normalize_text(text)
words = self._split_text(text)
words = self._remove_stop_words(words)
stemmed_words = self._stem_words(words)
return self._frequency(stemmed_words)
def _normalize_text(self, text):
return text.lower().strip()
def _split_text(self, text):
return text.split()
def _remove_stop_words(self, words):
raise NotImplementedError
def _stem_words(self, words):
raise NotImplementedError
def _frequency(self, words):
return Counter(words)
从那里,BasicIndexer实现可以是:
class BasicIndexer(Indexer):
_stop_words = {'he', 'she', 'is', 'and', 'or', 'the'}
def _remove_stop_words(self, words):
return (
word for word in words
if word not in self._stop_words
)
def _stem_words(self, words):
return (
(
len(word) > 3 and
word.rstrip('aeiouy') or
word
)
for word in words
)
而且,像往常一样,这是前面示例代码的一个使用示例:
>>> indexer = BasicIndexer()
>>> indexer.process("Just like Johnny Flynn said\nThe breath I've taken and the one I must to go on")
Counter({"i'v": 1, 'johnn': 1, 'breath': 1, 'to': 1, 'said': 1, 'go': 1, 'flynn': 1, 'taken': 1, 'on': 1, 'must': 1, 'just': 1, 'one': 1, 'i': 1, 'lik': 1})
应该考虑模板,以便设计可能变化并可以表达为孤立子步骤的算法。这可能是 Python 中最常用的模式,并且不总是需要通过子类实现。例如,许多内置的 Python 函数处理算法问题,接受允许您将部分实现委托给外部实现的参数。例如,“sorted()”函数允许使用后续由排序算法使用的可选key关键字参数。对于在给定集合中查找最小值和最大值的“min()”和“max()”函数也是如此。
总结
设计模式是可重用的,与语言有关的解决方案,用于软件设计中的常见问题。无论使用何种语言,它们都是所有开发人员文化的一部分。
因此,使用给定语言中最常用模式的实现示例是记录的好方法。在网络和其他书籍中,您将很容易找到 GoF 书籍中提到的每个设计模式的实现。这就是为什么我们只集中在 Python 语言上下文中最常见和流行的模式上。