第 5 章 并发与并行(下)
第 39 条:用 Queue 来协调各线程之间的工作
如果 Python 程序同时要执行许多事务,那么开发者经常需要协调这些事务。而在各种协调方式中,较为高效的一种,则是采用函数管线。
管线的工作原理,与制造业中的组装生产线(assembly line)相似。管线分为许多首尾相连的阶段(phase,环节),每个阶段都由一种具体的函数来负责。程序总是把待处理的新部件添加到管线的开端。每一种函数都可以在它所负责的那个阶段内,并发地处理位于该阶段的部件。等负责本阶段的那个函数,把某个部件处理好之后,该部件就会传送到管线中的下一个阶段,以此类推,直到全部阶段都经历一遍。涉及阻塞式I/O操作或子进程的工作任务,尤其适合用此办法处理,因为这样的任务,很容易分配到多个 Python 线程或进程之中。
例如,要构建一个照片处理系统,该系统从数码相机里面持续获取照片、调整其尺寸,并将其添加到网络相册中。这样的程序,可以采用三阶段的管线来做。第一阶段获取新图片。第二阶段把下载好的图片传给缩放函数。第三阶段把缩放后的图片交给上传(upload,上载)函数。
假设已经用 Python 代码,把负责这三个阶段的 download、resize 和 upload 函数都写好了。那么,如何将其拼接为一条可以并发处理照片的管线呢?
首先要做的,是设计一种任务传递方式,以便在管线的不同阶段之间传递工作任务。这种方式,可以用线程安全的生产者—消费者队列来建模。
class MyQueue(object):
def __init__(self):
self.items = deque()
self.lock = Lock()
数码相机在程序中扮演生产者的角色,它会把新的图片添加到 items 列表的末端,这个 items 列表,用来存放待处理的条目。
def put(self, item):
with self.lock:
self.items.append(item)
图片处理管线的第一阶段,在程序中扮演消费者的角色,它会从待处理的条目清单顶部移除图片。
def get(self):
with self.lock:
return self.items.popleft()
用 Python 线程来表示管线的各个阶段,这种 Worker 线程,会从 MyQucue 这样的队列中取出待处理的任务,并针对该任务运行相关函数,然后把运行结果放到另一个 MyQueue 队列里。此外,Worker 线程还会记录查询新任务的次数,以及处理完的任务数量。
class Worker(Thread):
def __init__(self, func, in_queue, out_queue):
super().__init__()
self.func = func
self.in_queue = in_queue
self.out_queue = out_queue
self.polled_count = 0
self.work_done = 0
对于 Worker 线程来说,最棘手的部分,就是如何应对输人队列为空的情况。如果上一个阶段没有及时地把相关任务处理完,那就会引发此问题。在下面的范例代码中,通过捕获 IndexError 异常来处理这种状况。你可以将其想象为生产线上的某个环节发生了阻滞。
def run(self):
while True:
self.polled_count += 1
try:
item = self.in_queue.get()
except IndexError:
sleep(0.01) # No work to do
else:
result = self.func(item)
self.out_queue. put(result)
self.work_done += 1
现在,创建相关的队列,然后根据队列与工作线程之间的对应关系,把整条管线的三个阶段拼接好。
download_queue = MyQueue()
resize_queue = MyQueue()
upload_queue = MyQueue()
done_queue = MyQueue()
threads = [
Worker(download, download_queue, resize_queue),
Worker(resize, resize_queue, upload_queue),
Worker(upload, upload_queue, done_queue),
]
启动这些线程,并将大量任务添加到管线的第一个阶段。在范例代码中,用简单的 object 对象,来模拟 download 函数所需下载的真实数据:
for thread in threads:
thread.start()
for _ in range(1000):
download_queue.put(object())
最后,等待管线将所有条目都处理完毕。完全处理好的任务,会出现在 done_queue 队列里面。
while len(done_queue.items) < 1000:
# Do something useful while waiting
# ...
这个范例程序可以正常运行,但是线程在查询其输入队列并获取新的任务时,可能会产生一种副作用,这是值得注意的。run 方法中有一段微妙的代码,用来捕获 IndexError 异常的,而通过下面的输出信息,可以得知:这段代码运行了很多次。
processed = len(done_queue.items)
polled = sum(t.polled_count for t in threads)
print('Processed', processed, 'items after polling', polled, 'times')
>>>
Processed 1000 items after polling 3030 times
在管线中,每个阶段的工作函数,其执行速度可能会有所差别,这就使得前一阶段可能会拖慢后一阶段的进度,从而令整条管线迟滞。后一个阶段会在其循环语句中,反复查询输入队列,以求获取新的任务,而前一个阶段又迟迟不能把任务交过来,于是就令后一个阶段陷入了饥饿(starve)。这样做的结果是:工作线程会白白地浪费 CPU 时间,去执行一些没有用的操作,也就是说,它们会持续地抛出并捕获 IndexError 异常。
上面那种实现方式有很多缺陷,刚才说的那个问题只是其中的一小部分而已。除此之外,还有三个较大的问题,也应该设法避免。首先,为了判断所有的任务是否都彻底处理完毕,必须再编写一个循环,持续判断 done_queue 队列中的任务数量。其次,Worker 线程的 run 方法,会一直执行其循环。即便到了应该退出的时候,也没有办法通知 Worker 线程停止这一循环。
第三个问题更严重:如果管线的某个阶段发生迟滞,那么随时都可能导致程序崩溃。若第一阶段的处理速度很快,而第二阶段的处理速度较慢,则连接这两个阶段的那个队列的容量就会不断增大。第二阶段始终没有办法跟上第一阶段的节奏。这种现象持续一段时间之后,程序就会因为收到大量的输入数据而耗尽内存,进而崩溃。
这些问题并不能证明管线是一种糟糕的设计方式,它们只是在提醒大家:想要自己打造一种良好的生产者—消费者队列,是非常困难的。
用 Queue 类来弥补自编队列的缺陷
内置的 queue 模块中,有个名叫 Queue 的类,该类能够彻底解决上面提出的那些问题。
Queue 类使得工作线程无需再频繁地查询输入队列的状态,因为它的 get 方法会持续阻塞,直到有新的数据加入。例如,启动一条线程,并令该线程等待 Queue 队列中的输入数据:
from queue import Queue
queue = Queue()
def consumer():
print('Consumer waiting')
queue.get() # Runs after put() below
print( 'Consumer done')
thread = Thread(target=consumer)
thread.start()
线程虽然已经启动了,但它却并不会立刻就执行完毕,而是会卡在 queue.get() 那里,必须调用 Queue 实例的 put 方法,给队列中放入一项任务,方能使 queue.get() 方法得以返回。
print('Producer putting')
queue.put(object()) # Runs before get() above
thread.join()
print('Producer done')
>>>
Consumer waiting
Producer putting
Consumer done
Producer done
为了解决管线的迟滞问题,用 Queue 类来限定队列中待处理的最大任务数量,使得相邻的两个阶段,可以通过该队列平滑地衔接起来。构造 Queue 时,可以指定缓冲区的容量,如果队列已满,那么后续的 put 方法就会阻塞。例如,定义一条线程,令该线程先等待片刻,然后再去消费 queue 队列中的任务:
queue = Queue(1) # Buffer size of 1
def consumer():
time.sleep(0.1) # wait
queue.get() # Runs second
print('Consumer got 1')
queue.get() # Runs fourth
print('Consumer got 2')
thread = Thread(target=consumer)
thread.start()
之所以要令消费线程等待片刻,是想给生产线程留出一定的时间,使其可以在 consumer() 方法调用 get 之前,率先通过 put 方法,把两个对象放到队列里面。然而,刚才在构建 Queue 的时候,把缓冲区的大小设成了 1,这就意味着,生产线程在放入第一个对象之后,会卡在第二个 put 方法那里,它必须等待消费线程通过 get 方法将第一个对象消费掉,然后才能放入第二个对象。
queue.put(object()) # Runs first
print('Producer put 1')
queue.put(object()) # Runs third
print('Producer put 2')
thread .join()
print('Producer done')
>>>
Producer put 1
Consumer got 1
Producer put 2
Consumer got 2
Producer done
还可以通过 Queue 类的 task_done 方法来追踪工作进度。有了这个方法,就不用再像原来那样,在管线末端的 done_queue 处进行轮询,而是可以直接判断:管线中的某个阶段,是否已将输入队列中的任务,全都处理完毕。
in_queue = Queue()
def consumero:
print('Consumer waiting')
work = in_queue.get() # Done second
print('Consumer working')
# Doing work
# ...
print('Consumer done')
in_queue.task_done() # Done third
Thread(target=consumer).start()
现在,生产者线程的代码,既不需要在消费者线程上面调用 join 方法,也不需要轮询消费者线程。生产者只需在 Queue 实例上面调用 join,并等待 in_queue 结束即可。即便调用 in_queue.join() 时队列为空,join 也不会立刻返回,必须等消费者线程为队列中的每个条目都调用 task_done() 之后,生产者线程才可以从 join 处继续向下执行。
in_queue.put(objecto) # Done first
print('Producer waiting')
in_queue.join() # Done fourth
print('Producer done')
>>>
Consumer waiting
Producer waiting
Consumer working
Consumer done
Producer done
把这些行为都封装到 Queue 的子类里面,并且令工作线程可以通过这个 ClosableQueue 类,判断出自己何时应该停止处理。这个子类定义了 close 方法,此方法会给队列中添加一个特殊的对象,用以表明该对象之后再也没有其他任务需要处理了:
class ClosableQueue(Queue):
SENTINEL = object()
def close(self):
self.put(self.SENTINEL)
然后,为该类定义迭代器,此迭代器在发现特殊对象时,会停止迭代。__iter__
方法也会在适当的时机调用 task_done,使得开发者可以追踪队列的工作进度。
def __iter__(self):
while True:
item = self.get()
try:
if item is self.SENTINEL:
return # Cause the thread to exit
yield item
finally:
self.task_done()
现在,根据 ClosableQueue 类的行为,来重新定义工作线程。这一次,只要 for 循环耗尽,线程就会退出。
class Stoppableworker(Thread):
def __init__(self, func, in_queue, out_queue):
# ...
def run(self):
for item in self.in_queue:
result = self.func(item)
self.out_queue.put(result)
接下来,用新的工作线程类,来重新创建线程列表。
download_queue = ClosableQueue()
# ...
threads = [
Stoppableworker(download, download_queue, resize_queue),
# ...
]
然后,还是像从前那样,运行工作线程。把所有待处理的任务,都添加到管线第一阶段的输入队列之后,则给该队列发出终止信号。
for thread in threads:
thread.start()
for _ in range(1000):
download_queue.put(object())
download_queue.close()
最后,针对管线中相邻两个阶段连接处的那些队列,分别调用 join 方法。也就是说,只要当前阶段处理完毕,就给下一个阶段的输入队列里面放入终止信号。等到这些队列全部完工之后,所有的产品都会输出到 done_queue 之中。
download_queue.join()
resize_queue.close()
resize_queue.join()
upload_queue.close()
upload_queue.join()
print(done_queue.qsize(), 'items finished')
>>>
1000 items finished
总结
- 管线是一种优秀的任务处理方式,它可以把处理流程划分为若干阶段,并使用多条 Python 线程来同时执行这些任务。
- 构建并发式的管线时,要注意许多问题,其中包括:如何防止某个阶段陷入持续等待的状态之中、如何停止工作线程,以及如何防止内存膨胀等。
- Queue 类所提供的机制,可以彻底解决上述问题,它具备阻塞式的队列操作、能够指定缓冲区尺寸,而且还支持 join 方法,这使得开发者可以构建出健壮的管线。
第 40 条:考虑用协程来并发地运行多个函数
Python 程序员可以用线程来运行多个函数,使这些函数看上去好像是在同一时间得到执行的。然而,线程有三个显著的缺点:
- 为了确保数据安全,我们必须使用特殊的工具来协调这些线程。这使得多线程的代码,要比单线程的过程式代码更加难懂。这种复杂的多线程代码,会逐渐令程序变得难于扩展和维护。
- 线程需要占用大量内存,每个正在执行的线程,大约占据 8MB 内存。如果只开十几个线程,多数计算机还是可以承受的。但是,如果要在程序中运行成千上万个函数,并且想用线程来模拟出同时运行的效果,那就会出现问题。在这些函数中,有的函数与用户发送给服务器的请求相对应,有的函数与屏幕上面的像素相对应,还有的函数与仿真程序中的粒子相对应。如果每调用一次函数,就要开一个线程,那么计算机显然无法承受。
- 线程启动时的开销比较大。如果程序不停地依靠创建新线程来同时执行多个函数,并等待这些线程结束,那么使用线程所引发的开销,就会拖慢整个程序的速度。
Python 的协程(coroutine)可以避免上述问题,它使得 Python 程序看上去好像是在同时运行多个函数。协程的实现方式,实际上是对生成器的一种扩展。启动生成器协程所需的开销,与调用函数的开销相仿。处于活跃状态的协程,在其耗尽之前,只会占用不到 1KB 的内存。
协程的工作原理是这样的:每当生成器函数执行到 yield 表达式的时候,消耗生成器的那段代码,就通过 send 方法给生成器回传一个值。而生成器在收到了经由 send 函数所传进来的这个值之后,会将其视为 yield 表达式的执行结果。
def my_coroutine():
while True:
received = yield
print('Received:', received)
it = my_coroutine()
next(it) # Prime the coroutine
it.send('First')
it.send('Second')
>>>
Received: First
Received: Second
在生成器上面调用 send 方法之前,要先调用一次 next 函数,以便将生成器推进到第一条 yield 表达式那里。此后,可以把 yield 操作与 send 操作结合起来,令生成器能够根据外界所输入的数据,用一套标准的流程来产生对应的输出值。
例如,要编写一个生成器协程,并给它依次发送许多数值,而该协程每收到一个数值,就会给出当前所统计到的最小值。在下面这段范例代码中,第一条 yield 语句中的 yield 关键字,后面没有跟随其他内容,这条语句的意思是,把外界传进来的首个值,当成目前的最小值。此后,生成器会屡次执行 while 循环中的那条 yield 语句,以便将当前统计到的最小值告诉外界,同时等候外界传入下一个待考察的值。
def minimize():
current = yield
while True:
value = yield current
current = min(value, current)
外界的代码在消耗该生成器时,可以每次将其推进一步,而生成器在收到外界发过来的值之后,就会给出当前所统计到的最小值。
it = minimize()
next(it) # Prime the generator
print(it.send(10))
print(it.send(4))
print(it.send(22))
print(it.send(-1))
>>>
10
4
4
-1
生成器函数似乎会一直运行下去,每次在它上面调用 send 之后,都会产生新的值。与线程类似,协程也是独立的函数,它可以消耗由外部环境所传进来的输入数据,并产生相应的输出结果。但与线程不同的是,协程会在生成器函数中的每个 yield 表达式那里暂停,等到外界再次调用 send 方法之后,它才会继续执行到下一个 yield 表达式。这就是协程的奇妙之处。
这种奇妙的机制,使得消耗生成器的那段代码,可以在每执行完协程中的一条 yield 表达式之后,就进行相应的处理。例如,那段代码可以用生成器所产生的输出值,来调用其他函数,并更新程序的数据结构。更为重要的是,它可以通过这个输出值,来推进其他的生成器函数,使得那些生成器函数也执行到它们各自的下一条 yield 表达式处。接连推进多个独立的生成器,即可模拟出 Python 线程的并发行为,令程序看上去好像是在同时运行多个函数。
1.生命游戏
现在用一个例子,来演示协程的协同运作效果。用协程实现康威(John HortonConway)的生命游戏(The Game of Life)。游戏规则很简单。在任意尺寸的二维网格中,每个细胞都处在生存(alive)或空白(empty)状态。
ALIVE = '*'
EMPTY = '_'
时钟每走一步,生命游戏就前进一步。向前推进时,要点算每个细胞周边的那八个单元格,看看该细胞附近有多少个存活的细胞。本细胞需要根据相邻细胞的存活量,来判断自己在下一轮是继续存活、死亡,还是再生(regenerate)。下面从左至右列出五张 5×5 的生命游戏网格,它们演示了这些细胞在历经四个世代(generation)的变化之后,所呈现的情况。稍后会解释具体的规则。
0 | 1 | 2 | 3 | 4
----- | ----- | ----- | ----- | -----
-*--- | --*-- | --**- | --*-- | -----
--**- | --**- | -*--- | -*--- | -**--
-―-*- | --**- | --**- | --*-- | -----
----- | ----- | ----- | ----- | -----
用生成器协程来建模,把每个细胞都表示为一个协程,并令这些协程步调一致地向前推进。
为了实现这套模型,首先要定义一种方式,来获取相邻细胞的生存状态。用名为 count_neighbors 的协程制作该功能,这个协程会产生 Query 对象。而这个 Query 类,则是自己定义的。该类的作用,是向生成器协程提供一种手段,使得协程能够借此向外围环境查询相关的信息。
Query = namedtuple('Query', ('y', 'x'))
下面这个协程,会针对本细胞的每一个相邻细胞,来产生与之对应的 Query 对象。每个 yield 表达式的结果,要么是 ALIVE,要么是 EMPTY。这就是协程与消费代码之间的接口契约(interface contract)。其后,count_neighbors 生成器会根据相邻细胞的状态,来返回本细胞周边的存活细胞个数。
def count_neighbors(y, x):
n_ = yield Query(y + 1, x + 0) # North
ne = yield Query(y + 1, × + 1) # Northeast
# Define e_, se, s_, sw, w_, nw ...
# ...
neighbor_states = [n_, ne, e_, se, s_, sw, w_, nw]
count = 0
for state in neighbor_states:
if state == ALIVE:
count += 1
return count
接下来,用虚构的数据测试这个 count_neighbors 协程。下面这段代码,会针对本细胞的每个相邻细胞,向生成器索要一个 Query 对象,并根据该对象,给出那个相邻细胞的存活状态。然后,通过 send 方法把状态发给协程,使 count_neighbors 协程可以收到上一个 Query 对象所对应的状态。最后,由于协程中的 return 语句会把生成器耗竭,所以程序届时将抛出 StopIteration 异常,可以在处理该异常的过程中,得知本细胞周边的存活细胞总量。
it = count_neighbors(10, 5)
ql = next(it) # Get the first query
print('First yie1d: ', q1)
q2 = it.send(ALIVE) # Send q1 state,get q2
print('Second yield:', q2)
q3 = it.send(ALIVE) # Send q2 state,get q3
# ...
try:
count = it.send(EMPTY) # Send q8 state,retrieve count
except StopIteration as e:
print('Count:', e.value) # Value from return statement
>>>
First yield: Query(y=11, x=5)
Second yield: Query(y=11, x=6)
...
Count: 2
count_neighbors 协程把相邻的存活细胞数量统计出来之后,必须根据这个数量来更新本细胞的状态,于是,就需要用一种方式来表示状态的迁移。为此,又定义了另一个名叫 step_cell 的协程。这个生成器会产生 Transition 对象,用以表示本细胞的状态迁移。这个 Transition 类,与 Query 一样,也是自己定义的。
Transition = namedtuple('Transition, ('y', 'x', 'state'))
step_cell 协程会通过参数来接收当前细胞的网格坐标。它会针对此坐标产生 Query 对象,以查询本细胞的初始状态。接下来,它运行 count_neighbors 协程,以检视本细胞周边的其他细胞。此后,它运行 game_logic 函数,以判断本细胞在下一轮应该处于何种状态。最后,它生成 Transition 对象,把本细胞在下一轮所应有的状态,告诉外部代码。
def game_logic(state, neighbors):
# ...
def step_cell(y, x):
state = yield Query(y, ×)
neighbors = yield from count_neighbors(y, x)
next_state = game_logic(state, neighbors)
yield Transition(y, x, next_state)
请注意,step_cell 协程用 yield from 表达式来调用 count_neighbors。在 Python 程序中,这种表达式可以把生成器协程组合起来,使开发者能够更加方便地复用小段的功能代码,并通过简单的协程来构建复杂的协程。count_neighbors 协程耗竭之后,其最终的返回值(也就是 return 语句的返回值)会作为 yield from 表达式的结果,传给 step_cell。
现在,终于可以来定义游戏的逻辑函数了,康威生命游戏的规则很简单,只有下面三条。
def game_logic(state, neighbors):
if state == ALIVE:
if neighbors < 2:
return EMPTY # Die: Too few
elif neighbors > 3:
return EMPTY # Die: Too many
else:
if neighbors == 3:
return ALIVE # Regenerate
return state
现在用虚拟的数据来测试 step_cell 协程。
it = step_ce11 (10, 5)
q0 = next(it) # Initial location query
print('Me: ', q0)
q1 = it.send(ALIVE) # Send my status,get neighbor query
print('Q1: ', q1)
# ...
t1 = it.send(EMPTY) # Send for q8, get game decision
print('Outcome: ', t1)
>>>
Me: Query(y=10, x=5)
Q1: Query(y=11, x=5)
...
Outcome: Transition(y=10, x=5, state='-')
生命游戏的目标,是要同时在网格中的每个细胞上面,运行刚才编写的那套游戏逻辑。为此,把 step_cell 协程组合到新的 simulate 协程之中。新的协程,会多次通过 yield from 表达式,来推进网格中的每一个细胞。把每个坐标点中的细胞都处理完之后,simulate 协程会产生 TICK 对象,用以表示当前这代的细胞已经全部迁移完毕。
TICK = object()
def simulate(height, width):
while True:
for y in range(height):
for x in range(width):
yield from step_cell(y, x)
yield TICK
simulate 的好处在于,它和外界环境完全脱离了关联。目前还没有定义如何用 Python 对象来表示网格,也没有定义外部代码应该如何处理 Query、Transition、TICK 值并设置游戏的初始状态。尽管如此,游戏的逻辑依然是清晰的。每个细胞都可以通过运行 step_cell 来迁移到下一个状态。待所有细胞都迁移好之后,游戏的时钟就会向前走一步。只要 simulate 协程述在推进,这个过程就会一直持续下去。
协程的优势正在于此。它令开发者可以把精力放在当前所要完成的逻辑上面。协程会把程序对环境所下的指令,与发令时所用的实现代码相互解耦。这使得程序好像能够平行地运行多个协程,也使得开发者能够在不修改协程的前提下,逐渐改进发布指令时所用的实现代码。
现在,要在真实环境中运行 simulate。为此,需要把网格中每个细胞的状态表示出来。下面定义的这个 Grid 类,代表整张网格:
class Grid(object):
def __init__(self, height, width):
self.height = height
self.width = width
self.rows = []
for _ in range(self.height):
self.rows.append([EMPTY] * self.width)
def __str__(self):
# ...
在查询或设置该网格中的细胞时,调用者可以把任意值当成坐标。如果传入的坐标值越界,那就自动折回,这使得网格看上去好像是—种无限循环的空间。
def query(self, y, x):
return self.rows[y % self.height][x % self.width]
def assign(self, y, x, state):
self.rows[y % self.height][x % self.width] = state
最后,定义下面这个函数,它可以对 simulate 及其内部的协程所产生的各种值进行解释。该函数会把协程所产生的指令,转化为与外部环境相关的交互操作。这个函数会把网格内的所有细胞都向前推进一步,待各细胞的状态迁移操作完成之后,这些细胞就构成了一张新的网格,而 live_a _generation 函数会把这张新网格返回给调用者。
def live_a_generation(grid, sim):
progeny = Grid(grid.height, grid.width)
item = next(sim)
while item is not TICK:
if isinstance(item, Query):
state = grid.query(item.y, item.x)
item = sim.send(state)
else: # Must be a Transition
progeny.assign(item.y, item.x, item.state)
item = next(sim)
return progeny
为了验证这个函数的效果,需要创建网格并设置其初始状态。下面这段代码,会制作一种名叫 glider(滑翔机)的经典形状。
grid = Grid(5, 9)
grid.assign(0, 3, ALIVE)
# ...
print(grid)
>>>
---*-----
----*----
--***----
---------
---------
现在,可以逐代推进这张网格,每推进一次,它就变迁到下一代。刚才绘制的那个滑翔机形状,会逐渐朝网格的右下方移动,而这种移动效果,正是由 game_logic 函数里那些简单的规则所确立的。
class ColumnPrinter(object):
#...
columns = ColumnPrinter()
sim = simulate(grid.height, grid.width)
for i in range(5):
columns.append(str(grid))
grid = live_a_generation(grid, sim)
print(columns)
>>>
0 | 1 | 2 | 3 | 4
---*----- | --------- | --------- | --------- | ---------
----*---- | --*-*---- | ----*---- | ---*----- | ----*----
---***--- | ---**---- | --*-*---- | ----**--- | -----*---
--------- | ---*----- | ---**---- | ---**---- | ---***---
--------- | --------- | --------- | --------- | ---------
上面这套实现方式,其最大的优势在于:开发者能够在不修改 game_logic 函数的前提下,更新该函数外围的那些代码。可以在现有的 Query、Transition 和 TICK 机制的基础之上修改相关的规则,或施加影响范围更为广泛的效果。上面这套范例代码,演示了如何用协程来分离程序中的各个关注点,而关注点的分离(the separation ofconcerns),正是一条重要的设计原则。
2.Python 2的协程
协程在 Python 2 中的两项限制。首先,Python 2 没有 yield from 表达式。第二个限制是:Python 2 的生成器不支持 return 语句。
总结
- 协程提供了一种有效的方式,令程序看上去好像能够同时运行大量函数。
- 对于生成器内的 yield 表达式来说,外部代码通过 send 方法传给生成器的那个值,就是该表达式所要具备的值。
- 协程是一种强大的工具,它可以把程序的核心逻辑,与程序同外部环境交互时所用的代码相分离。
第 41 条:考虑用 concurrent.futures 来实现真正的平行计算
编写 Python 程序时,可能会遭遇性能问题。即使优化了代码,程序也依然有可能运行得很慢,从而无法满足对执行速度的要求。目前的计算机,其 CPU 核心数越来越多,于是,可以考虑通过平行计算(parallelism)来提升性能。能不能把代码的总计算量分配到多个独立的任务之中,并在多个 CPU 核心上面同时运行这些任务呢?
很遗憾,Python 的全局解释器锁(GIL)使得没有办法用线程实现真正的平行计算,因此,上面那个想法行不通。另一种常见的建议,是用 C 语言把程序中对性能要求较高的那部分代码,改写为扩展模块。由于 C 语言更贴近硬件,所以运行得比 Python 快,一旦运行速度达到要求,自然就不用再考虑平行计算了。C 语言扩展也可以启动并平行地运行多条原生线程(native thread),从而充分利用 CPU 的多个内核。Python 中的 C 语言扩展 API,有完备的文档可供查阅,这使得它成为解决性能问题的一个好办法。
但是,用 C 语言重写代码,是有很大代价的。短小而易读的 Python 代码,会变成冗长而费解的 C 代码。在进行这样的移植时,必须进行大量的测试,以确保移植之后的 C 程序,在功能上与原来的 Python 程序等效,而且还要确保移植过程中没有引入 bug。有的时候,这些努力是值得的。例如,Python 开发社区中的各种 C 语言扩展模块,就构成了一套庞大的生态系统,这些模块,能够提升文本解析、图像合成和矩阵运算等操作的执行速度。此外,还有如 Cython(http://cython.org/)和 Numba(http://numba.pydata.org/)等开源工具,可以帮助开发者把 Python 代码更加顺畅地迁移到 C 语言。
然而问题在于:只把程序中的一小部分迁移到 C,通常是不够的。一般来说, Python 程序之所以执行得比较慢,并不是某个主要因素单独造成的,而是多个因素联合导致的。所以,要想充分利用 C 语言的硬件和线程优势,就必须把程序中的大量代码移植到 C,而这样做,又大幅增加了测试量和风险。于是,应该思考一下:有没有一种更好的方式,只需使用较少的 Python 代码,即可有效提升执行速度,并迅速解决复杂的计算问题。
可以试着通过内置的 concurrent.futures 模块,来利用另外一个名叫 multiprocessing 的内置模块,从而实现这种需求。该做法会以子进程的形式,平行地运行多个解释器,从而令 Python 程序能够利用多核心 CPU 来提升执行速度。由于子进程与主解释器相分离,所以它们的全局解释器锁也是互相独立的。每个子进程都可以完整地利用一个 CPU 内核,而且这些子进程,都与主进程之间有着联系,通过这条联系渠道,子进程可以接收主进程发过来的指令,并把计算结果返回给主进程。
例如,现在要编写一个运算量很大的 Python 程序,并且要在该程序中充分利用 CPU 的多个内核。采用查找两数最大公约数的算法,来演示这种编程方式。在实际工作中,这样的程序可能要执行运算量更为庞大的算法,例如,它可能要通过纳维–斯托克斯方程(Navier-Stokes equation)来模拟流体的运动。
def gcd(pair):
a, b = pair
low = min(a, b)
for i in range(low, 0, -1):
if a % i == 0 and b % i == 0:
return i
由于没有做平行计算,所以程序会依次用 gcd 函数来求各组数字的最大公约数,这将导致程序的运行时间随着数据量的增多而变长。
numbers = [(1963309, 2265973), (2030677, 3814172),
(1551645, 22296203), (2039045, 2020802)]
start = time()
results = list(map(gcd, numbers))
end = time()
print('Took %.3f seconds' % (end - start))
>>>
Took 1.170 seconds
用多条 Python 线程来改善上述程序,是没有效果的,因为全局解释器锁(GIL)使得 Python 无法在多个 CPU 核心上面平行地运行这些线程。下面这个程序,借助 concurrent.futures 模块来执行与刚才相同的运算,它使用 ThreadPooLExccutor 类及两个工作线程来实现(max_workers 表示工作线程的数量,此参数应该与 CPU 的核心数同)(记得上述代码放在 if __name__ == '__main__':
语句下,下同):
start = time()
pool = ThreadPoolExecutor(max_workers=2)
results = list(pool.map(gcd, numbers))
end = time()
print('Took %.3f seconds' % (end - start))
>>>
Took 1.199 seconds
线程启动的时候,是有一定开销的,与线程池进行通信,也会有开销,所以上面这个程序运行得比单线程版本还要慢。
然而神奇的是:只需改动一行代码,就可以提升整个程序的速度。只要把 ThreadPoolExecutor 换成 concurrent.futures 模块里的 ProcessPoolExecutor,程序的速度就上去了。
start = time()
pool = ProcessPoolExecutor(max_workers=2) # The one change
results = list(pool.map(gcd, numbers))
end = time()
print('Took %.3f seconds' % (end - start))
>>>
Took 0.663 seconds
在双核电脑上运行这段程序,发现它果然比前两个版本快很多。这是什么原因呢?这是因为 ProcessPoolExecutor 类会利用由 multiprocessing 模块所提供的底层机制,来逐步完成下列操作:
- 1)把 numbers 列表中的每一项输入数据都传给 map。
- 2)用 pickle 模块对数据进行序列化,将其变成二进制形式。
- 3)通过本地套接字(local socket),将序列化之后的数据从主解释器所在的进程,发送到子解释器所在的进程。
- 4)接下来,在子进程中,用 pickle 对二进制数据进行反序列化操作,将其还原为 Python 对象。
- 5)引入包含 ged 函数的那个 Python 模块。
- 6)各条子进程平行地针对各自的输入数据,来运行 gcd 函数。
- 7)对运行结果进行序列化操作,将其转变为字节。
- 8)将这些字节通过 socket 复制到主进程之中。
- 9)主进程对这些字节执行反序列化操作,将其还原为 Python 对象。
- 10)最后,把每条子进程所求出的计算结果合并到一份列表之中,并返回给调用者。
从编程者的角度看,上面这些步骤,似乎是比较简单的,但实际上,为了实现平行计算,multiprocessing 模块和 ProcessPoolExecutor 类在幕后做了大量的工作。如果改用其他编程语言来写,那么开发者只需用一把同步锁或一项原子操作,就可以把线程之间的通信过程协调好,而在 Python 语言中,却必须使用开销较高的 multiprocessing 模块。multiprocessing 的开销之所以比较大,原因就在于:主进程和子进程之间,必须进行序列化和反序列化操作,而程序中的大量开销,正是由这些操作所引发的。
对于某些较为孤立,且数据利用率较高的任务来说,这套方案非常合适。所谓孤立,是指待运行的函数不需要与程序中的其他部分共享状态。所谓利用率高,是指只需要在主进程与子进程之间传递一小部分数据,就能完成大量的运算。本例中的最大公约数算法,满足这两个条件,其他一些类似的数学算法,也可以通过这套方案实现平行计算。
如果待执行的运算不符合上述特征,那么 multiprocessing 所产生的开销,可能使我们无法通过平行化(parallelization,并行化)来提升程序速度。在那种情况下,可以求助 multiprocessing 所提供的一些高级机制,如共享内存(shared memory)、跨进程锁定(cross-process lock)、队列( queue)和代理(proxy)等。不过,那些特性用起来非常复杂。想通过那些工具令多条 Python 线程共享同一个进程的内存空间,本身已经相当困难,若还想经由 socket 把它们套用到其他进程,则会使代码变得更加难懂。
建议大家多使用简单的 concurrent.futures 模块,并且尽量避开 multiprocessing 里的那些复杂特性。对于较为孤立且数据利用率较高的函数来说,一开始可以试着用多线程的 ThreadPoolExecutor 类来运行。稍后,可以将其迁移到 ProcessPoolExecutor 类,看看能不能提升程序的执行速度。如果试遍了各种方案,还是无法达到理想的执行速度,那我们再考虑直接使用 multiprocessing 模块中的那些复杂特性。
总结
- 把引发 CPU 性能瓶颈的那部分代码,用 C 语言扩展模块来改写,即可在尽量发挥 Python 特性的前提下,有效提升程序的执行速度。但是,这样做的工作量比较大,而且可能会引入 bug。
- multiprocessing 模块提供了一些强大的工具。对于某些类型的任务来说,开发者只需编写少量代码,即可实现平行计算。
- 若想利用强大的 multiprocessing 模块,最恰当的做法,就是通过内置的 concurrent.futures 模块及其 ProcessPoolExecutor 类来使用它。
- multiprocessing 模块所提供的那些高级功能,都特别复杂,所以开发者尽量不要直接使用它们。