前几章讨论的是并行,即一次做多件事,对应的就是多线程或多进程。而本章讨论的内容是并发,即一次处理多件事,也就是协程。
本章介绍 asyncio
包,这个包使用事件循环驱动的协程实现并发。主要话题有:
- 对比一个简单的多线程程序和对应的
asyncio
版,说明多线程和异步任务之间的关系 asyncio.Future
类与concurrent.futures.Future
类之间的区别。- 下载国旗那些示例的异步版。
- 摒弃线程或进程,如何使用异步编程管理网络应用中的高并发。
- 在异步编程中,与回调相比,协程显著提升性能的方式。
- 如何把阻塞的操作交给线程池处理,从而避免阻塞事件循环。
- 使用
asyncio
编写服务器,重新审视 Web 应用对高并发的处理方式。 - 为什么
asyncio
已经准备好对 Python 生态系统产生重大影响。
首先,本章通过简单的示例来对比 threading
模块和 asyncio
包。
线程与协程对比
首先来看一个简单但有趣的示例:在长时间计算的过程中,使用 multiprocessing
包在控制台中显示一个由 ASCII 字符 ”|/-\“ 构成的动画旋转指针。
现在有两种实现,一个借由 threading
模块使用线程实现,一个借由 asyncio
包使用协程实现。
import threading
import itertools
import time
import sys
class Signal:
go = True
def spin(msg, signal):
write, flush = sys.stdout.write, sys.stdout.flush
for char in itertools.cycle('|/-\\'):
status = char + ' ' + msg
write(status)
flush()
write('\x08' * len(status))
time.sleep(.1)
if not signal.go:
break
write(' ' * len(status) + '\x08' * len(status))
flush()
def slow_function():
time.sleep(3)
return 42
def supervisor():
signal = Signal()
spinner = threading.Thread(target=spin, args=('thinking!', signal))
print('spinner object:', spinner)
spinner.start()
result = slow_function()
signal.go = False
spinner.join()
return result
def main():
result = supervisor()
print('Answer: ', result)
if __name__ == '__main__':
main()
Python 没有提供终止线程的API,这是有意为之的。若想关闭线程,必须给线程发送消息。这里,我使用的是 signal.go
属性:在主线程中把它设为 False
后,spinner
线程最终会注意到,然后干净地退出。
下面来看如何使用 @asyncio.coroutine
装饰器替代线程,实现相同的行为。
import asyncio
import itertools
import sys
@asyncio.coroutine
def spin(msg):
write, flush = sys.stdout.write, sys.stdout.flush
for char in itertools.cycle('|/-\\'):
status = char + ' ' + msg
write(status)
flush()
write('\x08' * len(status))
try:
yield from asyncio.sleep(.1)
except asyncio.CancelledError:
break
write(' ' * len(status) + '\x08' * len(status))
@asyncio.coroutine
def slow_function():
yield from asyncio.sleep(3)
return 42
@asyncio.coroutine
def supervisor():
spinner = asyncio.async(spin('thinking!'))
print('spinner object:', spinner)
result = yield from slow_function()
spinner.cancel()
return result
def main():
loop = asyncio.get_event_loop()
result = loop.run_until_complete(supervisor())
loop.close()
print('Answer:', result)
if __name__ == '__main__':
main()
这两种 supervisor
实现之间的主要区别概述如下:
asyncio.Task
对象差不多与threading.Thread
对象等效。Task
对象用于驱动协程,Thread
对象用于调用可调用的对象。Task
对象不由自己动手实例化,而是通过把协程传给asyncio.async(...)
函数或loop.create_task(...)
方法获取。- 获取的
Task
对象已经排定了运行时间(例如,由asyncio.async
函数排定);Thread
实例则必须调用start
方法,明确告知让它运行。 - 在线程版
supervisor
函数中,slow_function
函数是普通的函数,直接由线程调用。在异步版supervisor
函数中,slow_function
函数是协程,由yield from
驱动。 - 没有 API 能从外部终止线程,因为线程随时可能被中断,导致系统处于无效状态。如果想终止任务,可以使用
Task.cancel()
实例方法,在协程内部抛出CancelledError
异常。协程可以在暂停的yield
处捕获这个异常,处理终止请求。 supervisor
协程必须在 main 函数中由loop.run_until_complete
方法执行。
线程与协程之间的比较还有最后一点要说明:如果使用线程做过重要的编程,你就知道写出程序有多么困难,因为调度程序任何时候都能终端线程。必须记住保留锁,去保护程序中重要部分,防止多步操作在执行的过程中中断。
而协程默认会做好全方位保护,以防止中断。我们必须显式产出才能让程序的余下部分运行。对协程来说,无需保留锁,在多个线程之间同步操作,协程自身就会同步,因为在任意时刻只有一个协程运行。想交出控制权时,可以使用 yield
或 yield from
把控制权交还调度程序。这就是能够安全地取消协程的原因:按照定义,协程只能在暂停的 yield
处取消,因此可以处理 CancelledError
异常,执行清理操作。
使用 asyncio
和 aiohttp
包下载
import asyncio
import sys
import os
import aiohttp
import time
BASE_URL = 'http://flupy.org/data/flags'
DEST_DIR = './downloads/'
POP20_CC = ('CN IN US ID BR PK NG BD RU JP '
'MX PH VN ET EG DE IR TR CD FR').split()
def show(text):
print(text, end=' ')
sys.stdout.flush()
@asyncio.coroutine
def get_flag(cc):
url = '{}/{cc}/{cc}.gif'.format(BASE_URL, cc=cc.lower())
print(url)
resp = yield from aiohttp.request('GET', url)
image = yield from resp.read()
return image
def save_flag(img, filename):
path = os.path.join(DEST_DIR, filename)
with open(path, 'wb') as fp:
fp.write(img)
@asyncio.coroutine
def download_one(cc):
image = yield from get_flag(cc)
show(cc)
save_flag(image, cc.lower() + '.gif')
return cc
def download_many(cc_list):
loop = asyncio.get_event_loop()
to_do = [download_one(cc) for cc in sorted(cc_list)]
wait_coro = asyncio.wait(to_do)
res, _ = loop.run_until_complete(wait_coro)
loop.close()
return len(res)
def main(download_many):
t0 = time.time()
count = download_many(POP20_CC)
elapsed = time.time() - t0
msg = '\n{} flags downloaded in {:.2f}s'
print(msg.format(count, elapsed))
if __name__ == '__main__':
main(download_many)
在最后,需要额外说明 yield from
两点。
- 使用
yield from
链接的多个协程最终必须由不是协程的调用方驱动,调用方显式或隐式(例如,在 for 循环中) 在最外层委派生成器上调用next(...)
或.send(...)
- 链条中最内层的子生成器必须是简单的生成器(只使用
yield
) 或可迭代的对象。
在 asyncio
包的 API 中使用 yield from
时,这两点都成立,不过要注意下述细节。
- 我们编写的协程链条始终通过把最外层委派生成器传给
asyncio
包 API中的某个函数(如loop.run_until_complete(...)
) 驱动。也就是说,使用asyncio
包时,我们编写的代码不通过调用next(...)
或.send(...)
方法驱动协程—这一点由 asyncio` 包实现的事件循环去做。 - 我们编写的协程链条最终通过
yield from
把职责委托给asyncio
包中的某个协程函数或协程方法,也就是说,最内层的子生成器是库中真正执行 I/O 操作的函数,而不是我们自己编写的函数。
改进 asyncio
下载脚本
这个版本想要添加一些功能:显示进度条以及处理各种异常。
使用 asyncio.as_completed
函数
为了更新进度条,各个协程运行结束后就要立即获取结果。loop.run_until_complete
方法不满足我们的需要,此时,为了集成进度条,我们使用的是 as_completed
生成器函数。
部分代码如下:
@asyncio.coroutine
def downloader_coro(cc_list, base_url, verbose, concur_req):
counter = collections.Counter()
semaphore = asyncio.Semaphore(concur_req)
to_do = [download_one(cc, base_url, semaphore, verbose) for cc in sorted(cc_list)]
to_do_iter = asyncio.as_completed(to_do)
if not verbose:
to_do_iter = tqdm.tqdm(to_do_iter, total=len(cc_list))
for future in to_do_iter:
res = yield from future
status = res.status
counter[status] += 1
return counter
使用 Executor
对象,防止阻塞事件循环
Python 社区往往会忽略一个事实—访问本地文件系统会阻塞。在上述示例中,阻塞型函数是 save_flag
。解决这个问题的方法是,使用事件循环对象的 run_in_executor
方法。
asyncio
的事件循环在背后维护着一个 ThreadPoolExecutor
对象,我们可以调用 run_in_executor
方法,把可调用的对象发给它执行。
@asyncio.coroutine
def download_one(cc, base_url, semaphore, verbose):
with (yield from semaphore):
image = yield from get_flag(base_url, cc)
loop = asyncio.get_event_loop()
loop.run_in_executor(None, save_flag, iamge, cc.lower() + '.gif')
status = HTTPStatus.ok
msg = 'OK'
return Result(status, cc)