使用asyncio 包进行并发
抨击线程的往往是系统程序员,他们考虑的使用场景对一般的应用程序员来说,也许
一生都不会遇到……应用程序员遇到的使用场景,99% 的情况下只需知道如何派生
一堆独立的线程,然后用队列收集结果
——Michele Simionato 深度思考 Python 的人
在别的博客中讲了使用concurrent.futures 模块处理并发, 里面也有将阻塞函数变为非阻塞函数。
这里使用asyncio 包来处理并发, 两个中都有期物, future的概念。 期物是基础。
相比较concurrent.futures 而言, asyncio 用于协程实现并发
协程替代多线程实现动画
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():
# 假装等待I/O一段时间
# yield from asyncio.sleep(3)
yield from asyncio.sleep(3) # 把控制权交给主循环,在休眠结束之后恢复这个协程
return 5 # 协程中获取的值都是return 中的值
@asyncio.coroutine
def supervisor():
# asyncio.async() 函数排定spin协程的运行时间, 使用一个task对象包装spin协程并立即返回
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()
@asyncio.coroutine 这个装饰器不会预激协程。
线程函数
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
异步版本
@asyncio.coroutine
def supervisor():
spinner = asyncio.async(spin('thinking!'))
print('spinner object:', spinner)
result = yield from slow_function()
spinner.cancel()
return result
比较线程和异步代码之间的区别
- asyncio.Task 对象差不多与threading.Thread对象等效, Task对象是实现协作式多任务的库
- Task 对象用于驱动协程, Thread 对象用于调用可调用的对象,
- Task 对象不由自己动手实例化,而是通过而是通过把协程传给asyncio.async()函数或者loop.create_task() 方法获取
- 获取到的Task对象已经排定好了运行时间(例如asyncio.async函数排定)。Thread实例必须要调用start方法明确告诉它执行。
- slow_function() 方法是协程由yield from 驱动。
- 没有API 可以从外部终止线程,因为线程会随时中断,导致系统会处于无效状态。如果想终止任务, 可以使用Task.cancel()实例方法,在协程内部抛出CancelledError 异常。协程可以在暂停的yield 处捕获这个异常, 处理终止请求
- supervisor 协程必须在main函数中由 loop.run_until_complete 方法执行
多线程编程中 很困难, 必须记住保留锁, 调度程序的时候随时都能中断线程。 必须记住保留锁, 去保护程序中的重要部分。
而协程会默认做好全方位的保护, 防止中断。我们必须显示的产出才能让程序的剩余部分运行。对于协程来说无需保留锁,在多个线程之间同步操作, 协程自身就会同步, 因为在任意时刻只有一个协程在运行。 想交出控制权的时候, 可以使用yield 或者yield from 把控制权交还调度程序。 这就是能安全取消协程的原因: 按照定义, 协程只能在暂停的yield 处取消, 因此可以处理CancelledError异常, 执行清理工作。
asyncio.Future 类和concurrent.futures.Future类之间区别
asyncio.Future 和concurrent.futures.Future 类的接口基本一致, 不过实现方式不一样,不可以互换。
期物只是调度执行某物的结果。 在asyncio 中中, baseEventLoop.create_task() 方法接受一个协程, 排定他的运行时间, 然后返回一个asyncio.Task实例, 也是asyncio.Future类的实例。 因为Task 是 Future的子类, 用于包装协程。这与调用Executor.submit() 方法创建concurrent.futures.Future实例是一个道理。
与concurrent.futures.Future类似, asyncion.Future 类也提供了.done() .add_done_callback(), .result() 方法,前两个方法两者差不多, 但是.result 差别比较大。
asyncio.Future 类的 .result()方法没有参数,因此不能指定超时时间。 如果调用.result() 方法时候期物还没有运行完毕, 那么.resutl() 不会阻塞去等待结果, 而是抛出异常 asyncio.InvalidStateError
但是不用害怕, 获取asyncio.Future对象的结果通常使用yield from, 从中产出结果。
使用yield from 处理期物, 等待期物运行完毕这一步不需要我们操心,而且不会阻塞事件循环, 因为在asyncio包中 yield from 的作用把控制权交给事件循环。
使用yield from 和使用add_done_callback() 方法处理协程的作用一样:
延迟操作结束之后, 事件循环不会触发回调对象, 而是设置期物的返回值;而yield from 的表达式则在暂停的协程中生成返回值, 恢复执行执行协程。
因为asyncio.Future 类的目的是与yield from 一起使用, 所以不需要使用一下方法:
- 无需调用my_future.add_done_callback(…), 因为可以直接把想在期物运行结束后执行的操作放在协程中 yield from 表达式的后面, 这是协程的一大优势: 协程是可以暂停和恢复的函数
- 无需调用.result() 方法, 因为yield from 可以从期物中产出的值就是结果。(result = yield from my_future)
当然,有时也需要使用 .done()、.add_done_callback(…) 和 .result() 方法。但
是一般情况下,asyncio.Future 对象由 yield from 驱动,而不是靠调用这些方法驱
动。
从期物任务和协程中产出
在 asyncio 包中,期物和协程关系紧密,因为可以使用 yield from 从
asyncio.Future 对象中产出结果。这意味着,如果 foo 是协程函数(调用后返回协程
对象),抑或是返回 Future 或 Task 实例的普通函数,那么可以这样写:res = yield
from foo()。这是 asyncio 包的 API 中很多地方可以互换协程与期物的原因之一。
为了执行这些操作, 必须排定协程的运行时间, 然后使用asyncio.Task对象包装协程。 对于协程来说, 获Task对象有两种主要的方式。
asyncio.async(coro_future, *, loop=None)
这个函数统一了协程和期物, 第一个参数可以是两者中的任一一个。 如果是future 或者Task对象, 那就原封不动的返回。 如果是协程, 那么async 函数会调用loop.create_task() 方法创建Task对象, loop=关键字是可选的,用于传入事件循环。如果没有传入, 那么async 函数会通过调用asyncio.get_event_loop() 函数获取循环对象。
BaseEventLoop.create_task(coro)
这个方法排定协程的执行时间, 返回一个asyncio.Task 对象,如果在自定义的BaseEventLoop 子类, 返回的结果可能是外部库(tornado )中与Task 类兼容的某个类的实例
asyncio 包中有多个函数会自动(内部使用的是 asyncio.async 函数)把参数指定的协
程包装在 asyncio.Task 对象中,例如 BaseEventLoop.run_until_complete(…) 方
法
asyncio 包下载
import asyncio
import aiohttp
from flags import BASE_URL, save_flag, show, main
@asyncio.coroutine
def get_flag(cc):
url = '{}/{cc}/{cc}.gif'.format(BASE_URL, cc=cc.lower())
resp = yield from aiohttp.request('GET', url)
image = yield from resp.read()
return image
@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)
if __name__ == '__main__':
main(download_many)
可以看出这里是手动关闭的, 这个时候可能想到了 with 代码块确保循环被关闭。 但是实际的情况是很复杂的,客户代码绝对不会直接穿件事件循环, 而是直接调用asyncio.get_event_loop()函数, 获取事件循环的引用。 而且有时候我们得代码不拥有时间循环。 因此关闭事件循环 会出错。
asyncio.wait() 协程的参数是一个由期物或者协程构成的可迭代对象;wait 会分别把各个协程包装进一个task 对象。 最终的结果是, wait处理的所有对象都通过某种方式变成future 的实例。 wait 协程函数, 因此返回的是一个协程或者生成器对象。wait_coro 变量中存储的正式这种对象。 为了驱动协程我们把协程传给loop.run_until_complete() 方法。
loop.run_until_complete 方法的参数是一个期物或者一个协程。如果是协程, run_until_complete 方法和wait() 方法一样, 把协程包裹进一个Task对象中。协程, 期物和任务都能由yield from 驱动。这正是 run_until_complete 方法对 wait
函数返回的 wait_coro 对象所做的事
wait_coro 运行结束后返回一个元组,第一个元
素是一系列结束的期物,第二个元素是一系列未结束的期物。在示例 18-5 中,第二个元
素始终为空,因此我们把它赋值给 _,将其忽略。但是 wait 函数有两个关键字参数,如
果设定了可能会返回未结束的期物;这两个参数是 timeout 和 return_when。详情参见
asyncio.wait 函数的文档(https://docs.python.org/3/library/asyncio-
task.html#asyncio.wait)
yield from
在协程中 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 包中的某个协程
函数或协程方法(例如示例 18-2 中的 yield from asyncio.sleep(…)),或者
其他库中实现高层协议的协程(例如示例 18-5 中 get_flag 协程里的 resp =
yield from aiohttp. request(‘GET’, url))。也就是说,最内层的子生成器是库中真正执行 I/O 操作的函数,而不是我们自己编写
的函数。
概括起来就是:使用 asyncio 包时,我们编写的异步代码中包含由 asyncio 本身驱动的
协程(即委派生成器),而生成器最终把职责委托给 asyncio 包或第三方库(如
aiohttp)中的协程。这种处理方式相当于架起了管道,让 asyncio 事件循环(通过我
们编写的协程)驱动执行低层异步 I/O 操作的库函数。
避免阻塞型调用
可以理解阻塞函数 : 把执行硬盘 或者网络I/O 操作的函数定义为 阻塞型函数。
有两种方法可以避免阻塞型调用终止整个应用程序的进程
- 把单独的线程中运行各个阻塞型操作
- 把每个阻塞型操作转换成非阻塞的异步调用使用
多个线程是可以的, 但是各个操作系统线程(Python使用的线程)消耗的内存达到兆字节(具体取决操作系统种类)。 如果要处理几千个连接, 而每个连接需要处理一个线程的话, 我们负担不起
为了降低内存的消耗, 通常使用异步来实现异步调用。使用回调时候,不等待回应, 而是注册一个函数, 在发生某件事时候调用。 这样所有调用都是非阻塞的。
只有异步应用程序底层的事件循环能依靠基础设置的中断, 线程, 轮询和后台进程等, 确保多个并发请求能取得进展并最终完成, 这样才能使用回调。
事件循环获得响应后, 会回过头来调用我们指定的回调。 不过这种做法确实正确, 事件循环和应用代码共用主线程绝不会阻塞。
把生成器当成协程来使用也是异步编程的另外一种方式。对于事件循环来说, 调用回调与在暂停的协程上调用send()方法的效果差不多。 各个暂停的协程是要消耗内存, 但是比线程消耗的内存的数量级小。而且协程能够避免可怕的回调地狱。
但是asyncio包目前没有提供异步文件系统API(Node有)
asyncio.as_completed函数
上面时间循环忽地结果用的是asyncio.wait函数, loop.run_until_complete方法驱动, 注意这个方法驱动的是, 全部协程运行完毕后, 这个函数才会返回所有下载结果。 但是有时候的需求是, 各个协程运行完毕后就要立即获取结果。asyncio.as_comoleted() 生成器函数就是这样的函数。
import asyncio
import collections
import aiohttp
from aiohttp import web
import tqdm
from flags2_common import main, HTTPStatus, Result, save_flag
# 默认设为较小的值,防止远程网站出错
# 例如503 - Service Temporarily Unavailable
DEFAULT_CONCUR_REQ = 5
MAX_CONCUR_REQ = 1000
class FetchError(Exception):
def __init__(self, country_code):
self.country_code = country_code
@asyncio.coroutine
def get_flag(base_url, cc):
url = '{}/{cc}/{cc}.gif'.format(base_url, cc=cc.lower())
resp = yield from aiohttp.request('GET', url)
if resp.status == 200:
image = yield from resp.read()
return image
elif resp.status == 404:
raise web.HTTPNotFound()
else:
raise aiohttp.HttpProcessingError(
code=resp.status, message=resp.reason,
headers=resp.headers)
@asyncio.coroutine
def download_one(cc, base_url, semaphore, verbose):
"""
:param cc:
:param base_url:
:param semaphore: 这个参数是asyncio.Semphore 类的实例。Semaphore类是同步装置, 用于限制并发请求数量
:param verbose:
:return:
"""
try:
# 把semaphore 当成上下文管理器使用, 防止阻塞整个系统, 如果semaphore计数器是所允许的最大值, 只有这个协程会阻塞
with (yield from semaphore):
# 退出这个with 语句之后semaphore计数器的值会递减, 解除阻塞可能在等待同一个semaphore对象的其他协程实例
image = yield from get_flag(base_url, cc)
except web.HTTPNotFound:
status = HTTPStatus.not_found
msg = 'not found'
except Exception as exc:
raise FetchError(cc) from exc
else:
save_flag(image, cc.lower() + '.gif')
status = HTTPStatus.ok
msg = 'OK'
if verbose and msg:
print(cc, msg)
return Result(status, cc)
@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:
try:
res = yield from future
except FetchError as exc:
country_code = exc.country_code
try:
error_msg = exc.__cause__.args[0]
except IndexError:
error_msg = exc.__cause__.__class__.__name__
if verbose and error_msg:
msg = '*** Error for {}: {}'
print(msg.format(country_code, error_msg))
status = HTTPStatus.error
else:
status = res.status
counter[status] += 1
return counter
def download_many(cc_list, base_url, verbose, concur_req):
loop = asyncio.get_event_loop()
coro = downloader_coro(cc_list, base_url, verbose, concur_req)
counts = loop.run_until_complete(coro)
loop.close()
return counts
if __name__ == '__main__':
main(download_many, DEFAULT_CONCUR_REQ, MAX_CONCUR_REQ)
使用executor对象, 防止阻塞事件循环
我们不应该忽略, 访问本地文件会阻塞