Python - 异步编程

一些定义

  • 原生协程: 使用async def 定义的协程函数。在原生协程内部可以使用await 关键字委托另一个协程,这类似于在经典协程中使用 yield from。async def 语句定义的始终是原生协程,即使主体中没有使用await 关键字。await 关键字不能在原生协程外部使用

  • 经典协程: 一种生成器函数,在表达式中使用yield 读取 my_coro.send(data) 调用发送的数据。经典协程可以使用yield from 委托其他经典协程。经典协程不能使用await 驱动,而且 asyncio 库不再支持

  • 基于生成器的协程: 一种使用@types.coroutine(Python 3.5引入) 装饰的生成器函数。使用这个装饰器的生成器与新增的await 关键字兼容

  • 异步生成器: 一种使用async def 定义,而且在主体中 使用 yield 的生成器函数。返回一个提供__aexit__方法(获取下一项)的异步生成器对象

一个asyncio 示例:探测域名

假设你想创建一个有关Python 的博客,准备使用一个Python 关键字注册后缀为.dev 的域名。例如await.dev。示例22-1 中的脚本使用asyncio 并发检查多个域名。

注意,域名不以特性的顺序出现。运行这个脚本,你会发现域名一个接一个地显示出来,延迟时间不等。+符号表示你的设备能通过DNS解析相应的域名。不带+符号的域名无法解析,这意味着该域名或许可以注册

blogdom.py 脚本通过远程协程对象探测DNS。由于异步操作是交叉执行的,因此检查18个域名所需的时间比依序检查少很多。其实,总用时基本与最慢的DNS响应时长相当,而不是所有响应时间之和

# 示例21-1:blogdom.py:为一个Python 博客搜索域名

import asyncio
import socket
from keyword import kwlist

MAX_KEYWORD_LEN = 4 # 1

async def probe(domain: str) -> tuple[str, bool]: # 2
    loop = asyncio.get_running_loop() # 3
    try:
        await loop.getaddrinfo(domain, None) # 4
    except socket.gaierror:
        return (domain, False)
    return (domain, True)


async def main() -> None: # 5
    names = (kw for kw in kwlist if len(kw) <= MAX_KEYWORD_LEN) # 6
    domains = (f'{name}.dev'.lower() for name in names) 
    coros = [probe(domain) for domain in domains] # 7

    for coro in asyncio.as_completed(coros): # 8
        domain, found = await coro # 9
        mark = '+' if found else ' '
        print(f'{mark} {domain}')

if __name__ == '__main__':
    asyncio.run(main()) # 10

# out:
'''
+ in.dev
+ try.dev
+ as.dev
+ def.dev
+ and.dev
+ from.dev
+ del.dev
+ not.dev
  is.dev
  else.dev
  if.dev
  true.dev
  none.dev
  with.dev
  for.dev
  elif.dev
  pass.dev
  or.dev
'''
  1. 设置关键字的最大长度,域名越短越好
  2. porbe 返回一个元组,包含一个域名和一个布尔值。True 表示可解析
  3. 获取asyncio 事件循环的引用,供后面使用
  4. 该方法返回一个五元组,例如:[(<AddressFamily.AF_INET: 2>, 0, 0, '', ('5.77.63.236', 0))],如果域名不可解析,则会返回socket.gaierror 错误
  5. main必定是一个协程,因此可以在主体中使用await
  6. 一个生成器,产出长度不超过MAX_KEYWORD_LEN 的Python 关键字
  7. 调用probe协程,传入各个domain,构建一个协程列表对象
  8. asyncio.as_completed 是一个生成器,产出协程,按照传入的协程完成的顺序(不是协程的提交顺序)返回结果
  9. 生成器产出协程后代表协程已结束,因为as_completed 就是这个作用。因此await 表达式不阻塞,但是我们需要,从coro 中获取结果。若coro 抛出的异常未处理,自然在这里重新抛出
  10. asyncio.run 启动事件循环,仅当事件循环退出后返回。使用asyncio的脚本经常这样做,即把main 实现为协程,在if _name_ == '_main_':块中使用asyncio.run 驱动

思考: asyncio.as_completed 写法和其他写法的不同?

https://www.jianshu.com/p/eed5da9965f2

新概念:可异步调用对象

for 关键字处理可迭代对象,await 关键字处理 可异步调用对象。

作为asyncio 库的终端用户,日常可见到以下两种可异步调用对象。

  • 原生协程对象,通过调用原生协程函数得到
  • asyncio.Task,通常由把协程对象传给asyncio.create_task() 得到。

然而,终端用户编写的代码不一定要使用await 处理Task,还可以使用asyncio.create_task(one_coro()) 调度 one_coro 并发执行,不等待它返回。我们在spinner_async.py 中的 spinner 协程内就是这么做的(见示例19-4)。如果不打算取消或等待任务,则无须保存create_task 返回的Task 对象。仅仅创建任务就能调度协程运行。

想比之下,使用 await other_coro() 立即运行 other_coro,等待协程运行完毕,因为继续向下执行之前需要协程返回的结果。在 spinner_asyncio.py 中,supervisor 协程使用 res = await slow() 执行slow 并获得结果。

实现异步库,或者为asyncio 库做贡献时,可能还要处理以下底层的可异步调用对象

  • 提供_await_ 方法,返回一个迭代器对象;例如,asyncio.Future 实例(asyncio.Task 是asyncio.Future的子类)

  • 以其他语言编写的对象,使用Python/C API. 提供tp_async.am_await 函数(类似于_await_ 方法),返回一个迭代器。

现存基准代码或许嗨哟一种可异步调用对象————基于生成器的协程对象————正在走弃用流程

PEP 492 指出,await 表达式"使用yield from实现,外加验证参数步骤", 而且"await 只接受一个可异步调用对象"。PEP 492 没有说明实现细节,不过引用了 引入 yield from 的 PEP 380.

使用asyncio 和 HTTPX 下载

flags_asyncio.py 会下载20个国家的国旗。20.2 节提到过这个脚本,现在运用新学的概念详细分析。

为了确保代码易于阅读,flags_asyncio.py 脚本没有处理错误。

# 示例21-2 flags_asyncio.py: 设置操作的函数

def download_many(cc_list: list[str]) -> int: # 1
    return asyncio.run(supervisor(cc_list)) # 2


async def supervisor(cc_list: list[str]) -> int:
    async with AsyncClient() as client: # 3
        to_do = [download_one(client, cc) 
                  for cc in cc_list] # 4

        res = await asyncio.gather(*to_do) # 5

    return len(res) # 6
  1. 这个函数为常规函数,以便传给flags.py 模块中的main 函数调用
  2. 执行时间循环,驱动supervisor(cc_list) 协程对象,直到协程返回。在事件循环运行期间,这行代码阻塞。这行代码的结果是supervisor 返回的内容
  3. httpx 中的异步HTTP客户端时AsyncClient 的方法。AsyncClinet 还是异步上下文管理器,即提供了异步设置和清理的上下文管理器
  4. 要下载的每个国旗调用一次download_one 协程,构建一个协程对象列表
  5. 等待asyncio.gather 协程。asyncio.gather 接受的参数是一个或多个可异步调用对象,等待全部执行完毕,以可异步调用对象的提交顺序返回结果列表
  6. supervisor 返回 asyncio.gather 返回的列表长度

现在来看flags_asyncio.py 脚本的前半部分。我调整了协程的顺序,使其与事件循环启动协程的顺序一致,方便阅读

# 示例21-3 flags_asyncio.py: 导入语句和负责下载的函数

import asyncio

from httpx import AsyncClient # 1
 
from flags import BASE_URL,save_flag,main # 2


async def download_one(clinet: AsyncClient, cc: str): # 3
    image = await  get_flag(clinet, cc)
    save_flag(image, f'{cc}.gif')
    print(cc, end=' ', flush=True)


async def get_flag(client: AsyncClient, cc: str) -> bytes: # 4
    url = f'{BASE_URL}/{cc}/{cc}.gif'.lower()
    resp = await client.get(url, timeout=6.1, 
                              follow_redirects=True) 
    return resp.read() # 5

if __name__ == '__main__':
    main(download_many)
  1. httpx 不在标准库中,必须自行安装
  2. 重用flags.py 中的代码(见示例20-2)
  3. download_one 必须是原生协程,这样才能使用await 处理get_flag(处理HTTP请求)。然后,显示下载的国旗对应的代码,并保存图像。
  4. get_flag 需要接收发起请求的AsyncClinet
  5. 网络I/O 操作以协程方法实现,可由 asyncio 事件循环异步驱动

为了提升性能,download_one 中的 save_flag 调用应该异步执行,以免阻塞事件循环。但是 asyncio 没有提供(类似Node.js 那种) 异步文件系统API。后面将说明如何把save_flag 委托给一个线程

原生协程的秘密

asyncio 事件循环在背后调用.send 驱动你的协程,而你的协程使用await 等待其他协程,包括标准库提供的协程。await 的实现大量借鉴yield from也调用.send 驱动协程

await 链最终到达一个底层可异步调用的对象,返回一个生成器,由事件循环驱动。对计时器或网络I/O等事件做出响应。位于await链末端的底层可异步调用对象深埋在库的实现中,不作为API开放,有可能是Python/C扩展

使用asyncio.gahter 和 asyncio.create_task 等函数可以启动多个并发await 通道,在单个线程内由单个事件循环驱动多个I/O操作并发执行

"不成功便成仁" 问题

注意,在示例 21-3 中不能重用 flags.py(见示例20-2) 中的get_flag 函数。为了使用HTTPX 提供的异步API,必须将其重写为协程。为了充分发挥asyncio 的性能,必须把执行I/O操作的每个函数替换为异步版本,使用await 或 asyncio.create_task 激活,这样在函数等待I/O 期间才能把 控制权交还给事件循环。如果无法把导致阻塞的函数重写为协程,那就应该在单独的线程或进程中运行那个函数,详见 21.8 节。

正是因为这样,我才选择在本章开头引用那句话。请记住这个建议:"你孤注一掷重写代码,要么彻底避免阻塞,要么纯属浪费时间"

出于同样的原因,也不能重用flags_threadpool.py (见示例20-3) 中的download_one 函数。示例 21-3 中的代码使用 await 驱动get_flag,所以download_one 也必须是一个协程。在supervisor中,每次请求创建一个download_one 协程对象,这些对象全由 asyncio.gater 协程驱动。

异步上下文管理器

在18.2 节,我们了解到,在with 块主体的前后可以使用一个对象运行代码,前提是那个对象所属的类提供了_enter_ 和 _exit_ 方法。

下面来看示例21-4. 这段代码摘自兼容 asyncio 的PostgreSQL 驱动 asyncpg 的事物文档

tr = connection.transaction()
awai tr.start()
try:
  await connection.execute("INSERT INTO mytable VALUES(1,2,3)")
except:
  await tr.rollback()
  raise
else:
  await tr.commit()

数据库事物特别适合使用上下文管理器协议:事物务必启动,数据由connection.execute 改动,然后根据改动的结果,必须回滚或提交。

在asyncpg 这种异步驱动中,设置和清理需要由协程执行,好让其他操作有机会并发执行。然而,传统的with 语句采用的实现方式不支持协程使用_enter_ 或 _exit_ 执行相关操作。

鉴于此,"PEP 492 - Coroutines with async and await syntax" 引入了 async wiht 语句,用于实现异步上下文管理器,即一种以协程实现_aenter_ 和 _aexit_ 方法的对象。

使用async wiht ,示例 21-4 可以写成如下形式(同样摘自 asyncpg文档)。

  async with connection.trancsaction():
    await connection.execute("INSERT INTO  mytable VALUES(1,2,3)")

在asynpg.Transaction 类中,协程方法 aenter 使用 await self.start(),协程方法_aexit_ 根据没有异常发生,异步等待私有协程方法_rollback_ 或 _commit。使用协程把Tranction 实现为异步上下文管理器,asyncpg 就能并发处理多个事物。

回到 flags_asyncio.py ,httpx 提供的AsyncClient 类是一个异步上下文管理器,因此可在特殊的协程方法_aenter_ 和 _aexit_ 中使用可异步调用对象

增强 asyncio 版下载脚本功能

使用asyncio.as_comolete 和 一个线程

在示例21-3中,我们把几个协程传给 asyncio.gather,按照协程的提交顺序返回协程的结果构成的列表。这意味着,只有所有可异步调用对象都执行完毕后,async.gather 才返回。然而,为了更新进度条,每有一个执行完毕就需要得到结果

幸好,asyncio 也提供了as_completed 生成器函数,作用与带进度条的线程池版示例(见示例20-16) 一样。

示例21-6 是flags2_asyncio.py 脚本的前半部分,定义get_flag 和 download_one 协程。示例21-7 是余下的代码,定义 supervisor 和 download_many。这个脚本比 flags_asyncio.py 长,因为增加了错误处理结构

# 示例21-6 flag2_asyncio.py:脚本的前半部分,余下的代码在示例21-7中

import asyncio
from collections import Counter
import httpx
from flags2_common import DownloadStatus,save_flag,main
from pathlib import Path
from http import HTTPStatus
import tqdm

# 默认的并发数较少,以免远程网站返回错误
# 例如503 - Service Temporarily Unavailable
DEFAULT_CONCUR_REQ = 5
MAX_CONCUR_REQ = 1000

async def get_flag(client:httpx.AsyncClient, # 1
                   base_url:str,cc:str) -> bytes:
    url = f'{base_url}/{cc}/{cc}.gif'.lower()
    resp = await client.get(url,timeout=3.1,follow_redirects=True) # 2 
    resp.raise_for_status()
    return resp.content

async def download_one(client:httpx.AsyncClient,cc:str,
                       base_url:str,semaphore:asyncio.Semaphore,
                       verbose:bool) -> DownloadStatus:
    try:
        async with semaphore: # 3
            image = await get_flag(client, base_url,cc)
    except httpx.HTTPStatusError as exc: # 4
        res = exc.response
        if res.status_code == HTTPStatus.NOT_FOUND:
            status  = DownloadStatus.NOT_FOUND
            msg = f'not found:{res.url}'
        else:
            raise
    else:
        # 保存图像是I/O操作,为避免阻塞事件循环,在一个线程中运行save_flag
        await asyncio.to_thread(save_flag,image,f'{cc}.gif')  # 5
        status = DownloadStatus.OK
        msg = 'OK'
    if verbose and msg :
        print(cc,msg)
    return status
  1. get_flag 与示例 20-14 中的顺序下载版本相差不大。第一个区别:这里需要client 参数。
  2. 第二个区别和第三个区别:.get 是AsyncClinet 提供的方法,而且是协程,因此需要await
  3. 把semaphore 当作异步上下文管理器使用,防止整个程序出现阻塞。当信号量计数器为零时,只有这个协程中止。
  4. 错误处理逻辑与示例20-14 中的download_one 一样
  5. 保存图像是I/O 操作。为免阻塞事件循环,在一个线程中运行save_flag

所有网络I/O 都使用asyncio 库提供的协程处理,而文件I/O不能这么做,因为文件I/O 也是阻塞操作 - 读写文件时比读写RAM 长上千倍,就算是网路附属存储(Network-Attached Storage) 背后可能也涉及网络I/O。

从Python 3.9 开始,asyncio.to_thread 协程可以轻松地把文件I/O委托给asyncio提供的一个线程池。如果需要支持Python 3.7 或 3.8 ,则需要多增加几行代码

信号量限制请求

像我们分析的这种网络客户端应该限流,以免对服务器发起过多的请求。

信号量是同步原语,比时钟灵活。信号量可以配置最大数量,而且一个信号量可由多个协程持有,因此特别适合于限制活动的并发协程数量。

asyncio.Semaphore 有一个内部计时器。每次使用await 处理协程方法.acquir(), 计数器递减;每次调用.release() 方法(不是协程,因为永久阻塞),计时器递减。计时器的初始值在实例化 Semaphore 时设定。

    semaphore = asyncio.Semaphore(concur_req)

若计时器大于零,则使用await 处理.acquire() 方法没有延迟;若计时器为零,则.acquire() 中止待处理的协程,直到其他协程在同一个Semaphore 实例上调用.release(),递增计时器。一般不直接调用这些方法,把semaphore 当作异步上下文管理器使用更安全。在示例21-6 中,download_one 函数就时这么做的

    async wiht semaphore:
      image = await get_flag(clinet,base_url,cc)

协程方法 Semaphore._aenter_ 异步等待.acquire(),协程方法__aexit__调用 .release()。上述代码片段可以确保 get_flags() 协程的数量在任意时刻都不超过concur_req.

# 示例21-7 flags2_asyncio.py:接续示例21-6


async def supervior(cc_list: list[str],base_url: str,
                    varbose:bool,concur_seq: int) -> Counter[DownloadStatus]: # 1
    counter: Counter[DownloadStatus] = Counter()
    semaphore = asyncio.Semaphore(concur_seq) # 2
    async with httpx.AsyncClient() as client:
        to_do = [download_one(client,cc,base_url,semaphore,varbose) 
                  for cc in sorted(cc_list)] # 3
        to_do_iter =  asyncio.as_completed(to_do) # 4

        if not varbose:
            to_do_iter = tqdm.tqdm(to_do_iter,total=len(cc_list))  # 5
        error: httpx.HTTPStatusError | None = None  # 6
        for coro in to_do_iter: # 7
            try:
                status = await coro # 8
            except httpx.HTTPStatusError as exc:
                error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
                error_msg = error_msg.format(resp=exc.response)
                error = exc # 9
            except httpx.RequestError as exc:
                error_msg = f'{exc} {type(exc)}'.strip()
                error = exc # 10
            except KeyboardInterrupt:
                break

            if error:
                status = DownloadStatus.ERROR # 11
                if varbose:
                    url = str(error.request.url) # 12
                    cc  = Path(url).stem.upper() # 13
                    print(f'{cc} error: {error_msg}')
            counter[status] += 1
        return counter



def download_many(cc_list:list[str],base_url:str,verbose:bool,concur_req: int) -> Counter[DownloadStatus]:
    coro = supervior(cc_list,base_url,verbose,concur_req)
    counts = asyncio.run(coro) # 14

if __name__ == '__main__':
    main(download_many,MAX_CONCUR_REQ,MAX_CONCUR_REQ)
  1. supervisor 接受的参数与 download_many 函数相同,但是不能由main 直接调用,因为download_many 是普通函数,而supervisor 是协程
  2. 创建一个asyncio.Semaphore 实例, 不允许使用这个信号量的活动协程数超过 concur_req。 concur_req 的值是由 flags2_common.py 中的main 函数根据命令行选择和各个示例中设置的常量计算。
  3. 创建一个协程对象列表,一个元素对应一次download_one 协程调用
  4. 获取一个迭代器,返回处理完毕的协程对象。我没有把这个as_completed 调用放在下方的for 循环内,因为我需要根据用户选择的详细级别确定是否外套一层tqdm 迭代器。
  5. 使用tqdm 生成器函数包装as_completed迭代器,显示进度
  6. 声明error,初始化为None。这个变量在try/except 语句外部存储可能抛出的异常。
  7. 迭代已完成的协程对象;这个循环示例与20-16中的download_many 内的循环类似
  8. 异步等待协程,获取结果。这一步不阻塞,因为as_completed 只生产已完成的协程
  9. 这个赋值有必要,因为exc 变量的作用域限定在这个except 子句中,而我需要存储它的值,供后面使用
  10. 同上
  11. 如果有错误,设置status
  12. 在详细模式下,从抛出的异常中提取URL
  13. 再提取文件名称,在下一行显示国家代码
  14. download_many 实例化 supervisor 协程对象,通过asyncio.run 传给事件循环,在事件循环结束后获得 supervisor 返回的计数器。

flags2_common.py

由于操作失败时不能以可异步调用对象为键从字典中获取国家代码,因此我不得不从异常中提取国家代码。为此,我把异常存储在try/except 语句外部的error 变量中。Python 语言不使用块级作用域,循环和 try/except 等语句不再它们管理的块中创建局部作用域。但是,如果except 子句把异常绑定到变量上(例如刚才见到的exc 变量) ,绑定的变量只存在于except 子句所在的块内。

我们使用asyncio 实现了 flags2_threadpool.py 同等的功能。对这个示例的讨论到此结束。

下一个示例演示一个简单的模式:使用协程逐个执行异步任务。我觉的这个模式值得关注,因为有JavaScript 经验得人都知道。为了在一个异步函数运行完毕后运行另一个异步函数,需要不断得嵌套回调,由此产生得编程模式称为 "死亡金字塔"。await 关键字可以根除这个模式。所以,Python 和 JavaScript 现在都提供了 await 关键字。

'''
Python 语言不使用块及作用域,循环和try/expect 等语句不在它们管理的块中创建局部作用域。
但是except 子句把异常绑定到变量上(例如刚才看到的exc变量),绑定的变量只存在于except子句
所在的块内
'''

def demo1():
    try:
        a = 1
        1 / 0
    except ZeroDivisionError as e:
        pass
    print(a)  # out: 1
    print(e)  # UnboundLocalError: local variable 'e' referenced before assignment

if __name__ == '__main__':
    demo1()

每次下载发起多个请求

# 示例21-8 flag3_asyncio.py get_country 协程

async def get_country(clinet: httpx.AsyncClient,
                      base_url: str,
                      cc: str) -> str: # 1
    url = f'{base_url}/{cc}metadata.json'.lower()
    resp = await  clinet.get(url, timeout=3.1, follow_redirects=True)
    resp.raise_for_status()
    metadata = resp.json() # 2
    return metadata['country'] # 3

  1. 如果一切顺利,那么这个协程返回一个字符串,即国家名称
  2. metadata 是一个 Python 字典,根据响应得JSON内容构建
  3. 返回国家名称

示例21-9 是修改后得download_one,与示例21-6 相比,只改动了几行。

async def download_one(clinet: httpx.AsyncClient,
                       cc: str,
                       base_url: str,
                       semaphore: asyncio.Semaphore,
                       verbose: bool) -> DownloadStatus:
    try:
        async with semaphore: # 1
            image = await get_flag(clinet, base_url,cc)
        async with semaphore: # 2
            country = await  get_country(clinet,base_url, cc)
    except httpx.HTTPStatusError as exc:
        res = exc.response
        if res.status_code == HTTPStatus.NOT_FOUND:
            status = DownloadStatus.NOT_FOUND
            msg = f'not found:{res.url}'
        else:
            raise
    else:
        filename = country.replace(' ', '_') # 3
        await asyncio.to_thread(save_flag, image, f'{filename}.gif')
        status = DownloadStatus.OK
        msg = 'OK'
    if verbose and msg:
        print(cc, msg)
    return status

  1. 持有semaphore ,异步等待 get_flag
  2. 同样,异步等待 get_country
  3. 使用国家名创建文件名。作为命令行用户,我不喜欢在文件名中看到空格

这比嵌套回调好多了!

我把get_flag 和 get_country 分开放在两个使用semaphore 控制得with 块中,因为持有信号量和锁得时间越短越好。

异步迭代 和 异步可迭代对象

async for 处理异步可迭代对象,即实现了_aiter_ 的对象。然而,_aiter_ 必须是常规方法(不是协程方法),而且必须返回一个异步迭代器

异步迭代器提供 _anext_ 协程方法,返回一个可异步调用对象,通常是一个协程对象。异步迭代器也应实现 _aiter_ ,往往返回 self。这与17.5.2 节所讲的可迭代对象与迭代器之间的区别是一样的。

PostgreSQL 异步驱动aiopg 的文档中有一个示例,演示了如何使用async for 迭代一个数据库游标的各行

async def go():
  pool = await aoppg.create_pool(dsn)
  async with pool.acquire() as conn:
    async with conn.cursor() as cur:
        await cur.execute("SELECT 1")
        ret = []
        async for row in cur:
            ret.append(row)
        assert ret = [(1,)]

这个示例中的查询只返回一行,而现实中,一个SELECT 查询可能返回上千行。对于大型响应,游标不一次性加载所有行。因此,async for row in cur: 一定不能阻塞事件循环,妨碍游标等待后续行。aiopg把游标实现为异步迭代器,每次调用 _anext_ 时可以把控制权交还给事件循环,当PostgreSQL 发送更多行时再重获控制权

异步生成器函数

若想实现异步迭代器,可以编写一个类,实现_anetx_ 和 _aiter_。不过,还有更简单的方法:以async def 声明一个函数,在主体中使用yield。这与利用经典迭代器模式的生成器函数是一样的。

下面分析一个简单的示例,这个示例用到了 async for ,还实现了一个异步生成器。示例21-1 中的blogdom.py 脚本用于探测域名。现在,假设我们发现那里定义的probe 协程还有其他用途,于是决定把它放在新模块domainlib.py 中。在这个模块中,我们还将定义一个异步生成器 multi_probe ,接受一组域名,产出相应的探测结果。

domainlib.py 的实现稍后再讲,先看如何在Python 新增的异步控制台中使用。

1. 在Python 异步控制台中实验

从 Python 3.8 开始,使用命令行选项 -m asyncio 运行解释器可以得到一个 "异步 REPL" ,这个Python 控制台导入 asyncio,提供一个运行中的事件循环,支持在顶层提示符下使用 await、async for 和 async wiht ———— 这些在原生协程外部使用会导致句法错误

PS E:\PyProject\study\流畅的python\chapter21> python -m asyncio
asyncio REPL 3.10.1 (tags/v3.10.1:2cd268a, Dec  6 2021, 19:10:37) [MSC v.1929 64 bit (AMD64)] on win32
Use "await" directly instead of "asyncio.run()".
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>>

头部信息指出,可以使用 await 代替 asyncio.run() ,驱动协程和其他可异步调用对象。另外,我没有输入 import asyncio、asyncio 模块是自动输入的,输出那一行是为了向用户明确表明该模块已导入。

现在导入 domainlib.py,使用其中的两个协程:probe 和 multi_probe(见示例 21-16)

>>> await asyncio.sleep(3,'Rise and shine!')  # 1
'Rise and shine!'
>>> await probe('python.org')  # 2
Result(domain='python.org', found=True) # 3
>>> from domainlib import *
>>> names = 'python.org rust-lang.org golang.org no-lang.invaild'.split() # 4
>>> async for result in multi_probe(names):  # 5                              
...     print(*result,sep='\t')
... 
golang.org      True # 6
python.org      True
rust-lang.org   True
no-lang.invaild False
>>> 

  1. 简单试用await,看看异步控制台的效果。提示:asyncio.sleep() 接受一个可选的参数,设置等待的秒数
  2. 驱动probe协程
  3. domainlib 模块中的probe 返回一个具名元组 Result
  4. 构建一个域名列表。.invaild 是保留顶级域名,用于测试。针对这种域名的DNS查询,DNS服务器始终返回 NXDOMAIN响应,表示"域名不存在"
  5. 使用async for 迭代异步生成器 multi_probe,显示结果。
  6. 注意,结果的显示顺序与把域名传给multi_probe 的顺序不同。返回一个DNS响应就显示一个域名
>>> probe('python.org')  # 1
<coroutine object probe at 0x00000244429C5770>
>>> multi_probe(names)  # 2 
<async_generator object multi_probe at 0x0000024442980B40>
>>> for r in  multi_probe(names): # 3
...     print(r)
...
Traceback (most recent call last):
  File "E:\code_tool\python\lib\concurrent\futures\_base.py", line 438, in result
    return self.__get_result()
  File "E:\code_tool\python\lib\concurrent\futures\_base.py", line 390, in __get_result
    raise self._exception
  File "E:\code_tool\python\lib\asyncio\__main__.py", line 34, in callback
    coro = func()
  File "<console>", line 1, in <module>
TypeError: 'async_generator' object is not iterable
>>>

  1. 调用原生协程得到一个协程对象
  2. 调用异步生成器得到一个async_generator 对象
  3. 不能使用常规的for 循环迭代异步生成器,因为异步生成器实现的是_aiter_,而不是 _iter_

异步生成器由 async for 驱动,可能导致阻塞(如示例21-16所示)

2. 实现一个异步生成器函数

# 示例21-18 domainlib.py: 探测域名得函数

import asyncio
import socket
from collections.abc import Iterable, AsyncIterator
from typing import NamedTuple, Optional


class Result(NamedTuple):  # 1
    domain: str
    found: bool


OptionalLoop = Optional[asyncio.AbstractEventLoop]  # 2


async def probe(domain: str, loop: OptionalLoop = None) -> Result:  # 3
    if loop is None:
        loop = asyncio.get_running_loop()
    try:
        await loop.getaddrinfo(domain, None)
    except socket.gaierror:
        return Result(domain, False)
    return Result(domain, True)


async def multi_probe(domains: Iterable[str]) -> AsyncIterator[Result]:  # 4
    loop = asyncio.get_event_loop()
    coros = [probe(doamin, loop) for doamin in domains]  # 5
    for coro in asyncio.as_completed(coros):  # 6
        result = await  coro  # 7
        yield result  # 8

  1. 使用NamtedTuple,probe 得结果更易于阅读和调试
  2. 这个类型别名是为了防止下一行太长,超出打印范围
  3. probe 现在接受一个可选得loop参数,免得在muti_probe 驱动这个协程得过程中不断调用 get_running_loop
  4. 异步生成器函数产生一个异步生成器对象,可以注解为AsyncIterator[SomeType]
  5. 构建一个probe 协程对象列表,对应各个域名
  6. 不能使用async for ,因为 asyncio.as_completed 是传统生成器
  7. 异步等待协程对象,获取结果
  8. 产出result。有这一行得存在,muti_probe 才是异步生成器

示例21-18 中得for 循环可以进一步精简:

 for coro in asyncio.as_completed(coros):
    yield await coro

Python 把主体解析为 yield (await coro),能达到预期结果。

有了 domainlib.py ,就可以演示在domaincheck.py 中如何使用异步生成器 multi_probe 了。domaincheck.py 脚本搜索Python 短关键字与指定后缀构成的域名。

在domainlib 模块的支持下,domaincheck.py 的代码简单直观,如示例21-19 所示。

# 示例 21-19 domaincheck.py:使用 domainlib 模块探测域名

import asyncio
import sys
from keyword import kwlist

from domainlib import multi_probe


async def main(tld: str) -> None:
    tld = tld.strip('.')
    names = (kw for kw in kwlist if len(kw) <= 4) # 1
    domains = (f'{name}.{tld}'.lower() for name in names) # 2
    print('FOUND\t\tNOT FOUND') # 3
    print('=====\t\t=====')
    async for domain, found in multi_probe(domains): # 4
        indent = '' if found else '\t\t' # 5
        print(f'{indent}{domain}')


if __name__ == '__main__':
    if len(sys.argv) == 2:
        asyncio.run(main(sys.argv[1])) # 6
    else:
        print('Please provide a TLD.', f'Example:{sys.argv[0]} COM.BR')
  1. 生成长度不超过4的关键字
  2. 使用指定的顶级域名生成一组域名
  3. 格式化表格式输出的表头
  4. 异步迭代 multi_probe(domains)
  5. 把indent 设为零个或两个制表符,把结果放入相应的列中
  6. 运行main 协程,传入命令行参数

输入: python domaincheck.py BR

输出:

FOUND           NOT FOUND
=====           =====
                not.br
                def.br
                is.br
                or.br
                for.br
                in.br
                in.br
                as.br
                pass.br
                true.br
                with.br
                del.br
                try.br
                none.br
                else.br
                elif.br
                if.br
                and.br
                from.br

异步生成器表达式和异步推导式

"PEP 530 Asyncchronous Comprehensions" 为推导式和生成器表达式引入 async for 和 await 句法,从Python 3.6 开始可以使用。

PEP 530 定义的结构只有异步生成器表达式可以出现在async def 主体外部。

对于示例 21-18 中的异步生成器multi_probe,我们可以再编写一个异步生成器,只返回找到的域名。下面在以 -m asyncio 选项启动的异步控制台中演示。

>>> from domainlib import *
>>> names = 'python.org rust-lang.org golang.org no-lang.invaild'.split()
>>> gen_found = (name async for name,found in multi_probe(names) if found)   # 1
>>> gen_found
<async_generator object <genexpr> at 0x0000016FC50536C0> # 2
>>> async for name in gen_found: # 3
...     print(name)
...
rust-lang.org
golang.org
python.org
  1. 使用async for ,表明这是一个异步生成器表达式。可在Python 模块的任意位置定义
  2. 异步生成器表达式构建一个async_generator 对象,与异步生成器函数(例如multi_probe) 返回的对象是一种类型
  3. 异步生成器对象由 async for 语句驱动,而该语句只能出现在async def 主体内,或者在异步控制台中使用。(像本例这样)
posted @ 2023-05-14 10:15  chuangzhou  阅读(78)  评论(0编辑  收藏  举报