返回介绍

18.2 使用 asyncio 和 aiohttp 包下载

发布于 2024-02-05 21:59:47 字数 5692 浏览 0 评论 0 收藏 0

从 Python 3.4 起,asyncio 包只直接支持 TCP 和 UDP。如果想使用 HTTP 或其他协议,那么要借助第三方包。当下,使用 asyncio 实现 HTTP 客户端和服务器时,使用的似乎都是 aiohttp 包。

示例 18-5 是下载国旗的 flags_asyncio.py 脚本的完整代码清单。运作方式简述如下。

(1) 首先,在 download_many 函数中获取一个事件循环,处理调用 download_one 函数生成的几个协程对象。

(2) asyncio 事件循环依次激活各个协程。

(3) 客户代码中的协程(如 get_flag)使用 yield from 把职责委托给库里的协程(如 aiohttp.request)时,控制权交还事件循环,执行之前排定的协程。

(4) 事件循环通过基于回调的低层 API,在阻塞的操作执行完毕后获得通知。

(5) 获得通知后,主循环把结果发给暂停的协程。

(6) 协程向前执行到下一个 yield from 表达式,例如 get_flag 函数中的 yield from resp.read()。事件循环再次得到控制权,重复第 4~6 步,直到事件循环终止。

这与 16.9.2 节所见的示例类似。在那个示例中,主循环依次启动多个出租车进程;各个出租车进程产出结果后,主循环调度各个出租车的下一个事件(未来发生的事),然后激活队列中的下一个出租车进程。那个出租车仿真简单得多,主循环易于理解。不过,在 asyncio 中,基本的流程是一样的:在一个单线程程序中使用主循环依次激活队列里的协程。各个协程向前执行几步,然后把控制权让给主循环,主循环再激活队列里的下一个协程。

下面详细分析示例 18-5。

示例 18-5 flags_asyncio.py:使用 asyncio 和 aiohttp 包实现的异步下载脚本

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)

❶ 必须安装 aiohttp 包,它不在标准库中。4

4可以使用 pip install aiohttp 命令安装 aiohttp 包。——编者注

❷ 重用 flags 模块(见示例 17-2)中的一些函数。

❸ 协程应该使用 @asyncio.coroutine 装饰。

❹ 阻塞的操作通过协程实现,客户代码通过 yield from 把职责委托给协程,以便异步运行协程。

❺ 读取响应内容是一项单独的异步操作。

❻ download_one 函数也必须是协程,因为用到了 yield from。

❼ 与依序下载版 download_one 函数唯一的区别是这一行中的 yield from;函数定义体中的其他代码与之前完全一样。

❽ 获取事件循环底层实现的引用。

❾ 调用 download_one 函数获取各个国旗,然后构建一个生成器对象列表。

❿ 虽然函数的名称是 wait,但它不是阻塞型函数。wait 是一个协程,等传给它的所有协程运行完毕后结束(这是 wait 函数的默认行为;参见这个示例后面的说明)。

⓫ 执行事件循环,直到 wait_coro 运行结束;事件循环运行的过程中,这个脚本会在这里阻塞。我们忽略 run_until_complete 方法返回的第二个元素。下文说明原因。

⓬关闭事件循环。

 如果事件循环是上下文管理器就好了,这样我们就可以使用 with 块确保循环会被关闭。然而,实际情况是复杂的,客户代码绝不会直接创建事件循环,而是调用 asyncio.get_event_loop() 函数,获取事件循环的引用。而且有时我们的代码不“拥有”事件循环,因此关闭事件循环会出错。例如,使用 Quamash 这种包实现的外部 GUI 事件循环时,Qt 库负责在退出应用时关闭事件循环。

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 函数的文档

注意,在示例 18-5 中不能重用 flags.py 脚本(见示例 17-2)中的 get_flag 函数,因为那个函数用到了 requests 库,执行的是阻塞型 I/O 操作。为了使用 asyncio 包,我们必须把每个访问网络的函数改成异步版,使用 yield from 处理网络操作,这样才能把控制权交还给事件循环。在 get_flag 函数中使用 yield from,意味着它必须像协程那样驱动。

因此,不能重用 flags_threadpool.py 脚本(见示例 17-3)中的 download_one 函数。示例 18-5 中的代码使用 yield from 驱动 get_flag 函数,因此 download_one 函数本身也得是协程。每次请求时,download_many 函数会创建一个 download_one 协程对象;这些协程对象先使用 asyncio.wait 协程包装,然后由 loop.run_until_complete 方法驱动。

asyncio 包中有很多新概念要掌握,不过,如果你采用 Guido van Rossum 建议的一个技巧,就能轻松地理解示例 18-5 的总体逻辑:眯着眼,假装没有 yield from 关键字。这样做之后,你会发现示例 18-5 中的代码与纯粹依序下载的代码一样易于阅读。

比如说,以这个协程为例:

@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

我们可以假设它与下述函数的作用相同,只不过协程版从不阻塞:

def get_flag(cc):
  url = '{}/{cc}/{cc}.gif'.format(BASE_URL, cc=cc.lower())
  resp = aiohttp.request('GET', url)
  image = resp.read()
  return image

yield from foo 句法能防止阻塞,是因为当前协程(即包含 yield from 代码的委派生成器)暂停后,控制权回到事件循环手中,再去驱动其他协程;foo 期物或协程运行完毕后,把结果返回给暂停的协程,将其恢复。

在 16.7 节的末尾,我对 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 操作的库函数。

现在可以回答第 17 章提出的那个问题了。

flags_asyncio.py 脚本和 flags.py 脚本都在单个线程中运行,前者怎么会比后者快 5 倍?

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文