《Python编程入门》13.3 异步生态与常见陷阱

同步库直接写进 async 会阻塞整个事件循环。本节用本地服务实测 requests 串行、to_thread 与 httpx.AsyncClient 的耗时差,讲连接池、contextvars、async for 与 async with,并盘点假异步:忘记 await、循环里串行 await、create_task 丢引用,最后说明何时不该用异步。

本节目标:学会在异步代码里安全地使用同步库,认清几种「假异步」写法,并知道什么时候不该用 asyncio。
适用版本:Python 3.12+(实测 3.14.6)

13.3 异步生态与常见陷阱

前两节我们一直在「纯异步」的世界里写代码,但真实项目里到处都是同步库:requests、老旧的数据库驱动、某个只提供同步接口的内部 SDK。本节要解决的就是异步代码与同步世界的摩擦,以及由此衍生的一堆「看起来是异步、其实是串行」的陷阱。

13.3.1 同步库怎么用:to_thread 与 run_in_executor

13.1 节讲过:在协程里直接调同步阻塞函数,会卡死整个循环。安全的做法是把同步函数丢到线程池里执行,循环本身则继续调度别的任务。最简洁的入口是 asyncio.to_thread():

import asyncio, time

def blocking(n):
    time.sleep(0.3)
    return n

async def main():
    t0 = time.perf_counter()
    results = await asyncio.gather(*[asyncio.to_thread(blocking, i) for i in range(3)])
    print("to_thread 结果:", results, f"耗时 {time.perf_counter()-t0:.3f}s")

asyncio.run(main())
to_thread 结果: [0, 1, 2] 耗时 0.314s

三个各阻塞 0.3 秒的函数并发执行,总耗时约 0.31 秒——说明它们确实跑在多个线程里,没有互相拖累。to_thread 是 3.9+ 的语法糖,等价于 loop.run_in_executor(None, func, *args)。要控制线程池大小、或改用进程池时,才需要 run_in_executor 的完整形式(13.2.9 节)。

一句话准则:异步框架用异步库;没有异步版本时,把同步调用包进 to_thread。

13.3.2 实测:requests 在协程里会阻塞循环

「包进 to_thread」不是教条,而是有实测依据的。我们起一个本地慢接口(每个请求固定耗时 0.3 秒),用三种方式各发 5 个请求:

import time, asyncio, requests, httpx

URL = "http://127.0.0.1:8799/"     # 本地服务,每个请求 0.3s
N = 5

def sync_requests():
    t0 = time.perf_counter()
    for _ in range(N):
        requests.get(URL)                       # 串行阻塞
    return time.perf_counter() - t0

async def requests_in_threads():
    t0 = time.perf_counter()
    await asyncio.gather(*[asyncio.to_thread(requests.get, URL) for _ in range(N)])
    return time.perf_counter() - t0

async def async_httpx():
    t0 = time.perf_counter()
    async with httpx.AsyncClient(trust_env=False) as client:
        await asyncio.gather(*[client.get(URL) for _ in range(N)])
    return time.perf_counter() - t0
requests 串行        : 1.586s
requests + to_thread : 0.319s
httpx.AsyncClient    : 0.453s

三个数字讲清了全部故事:

方式耗时原因
requests 串行1.586s5 × 0.3s,一个接一个
requests + to_thread0.319s丢进线程池,5 个并发
httpx.AsyncClient0.453s原生异步,5 个并发(含建连开销)

trust_env=False 是为了忽略系统级代理设置(有些机器的代理会拦截 127.0.0.1)。生产环境若不需要可省略。

再看一个更直观的对照:在协程里跑一个「心跳任务」每 0.05 秒记一次数,然后分别用 requests.get 和 httpx 请求同一个 0.3 秒的接口:

requests 直接调用: 阻塞 0.415s,期间心跳 1 次
httpx.AsyncClient: 等待 0.328s,期间心跳 8 次

requests 期间心跳只跳了 1 次——因为整条循环被冻住了;httpx 期间心跳跳了 8 次,循环照常运转。在异步代码里裸调同步 HTTP 库,等于把并发的意义全部抹掉。

13.3.3 httpx.AsyncClient:连接池与 async with

httpx 是既能同步又能异步的 HTTP 客户端。它的异步接口是 httpx.AsyncClient,有两个必须知道的点:

import httpx, asyncio

async def main():
    # 用 async with 管理生命周期(trust_env=False 见 13.3.2 的说明)
    async with httpx.AsyncClient(trust_env=False) as client:
        r1 = await client.get("http://127.0.0.1:8799/")
        r2 = await client.get("http://127.0.0.1:8799/")   # 复用连接
        print(r1.status_code, r2.status_code)

asyncio.run(main())
200 200
  • 必须用 async with:客户端内部维护一个连接池,async with 退出时统一关闭连接、释放资源。写成 client = httpx.AsyncClient() 而不关闭,连接会泄漏。
  • 一个客户端要复用:同一个 AsyncClient 里的多个请求共享连接池,第二次请求同一主机能复用已建立的 TCP 连接。这也解释了 13.3.2 里 httpx 那 0.45 秒里包含了首次建连的开销——预热后再发 5 个并发请求,总耗时能降到约 0.3 秒(即单个请求的时间)。

不要把 AsyncClient 写成「每次请求都新建一个」,那等于放弃了连接池。

13.3.4 异步上下文传播:contextvars 优于 threading.local

线程里保存「当前请求的上下文」常用 threading.local——每个线程一份独立的变量。但协程不是线程:成百上千个协程跑在同一个线程里,threading.local 会让它们互相覆盖。正确工具是 contextvars:

import asyncio, contextvars

request_id = contextvars.ContextVar("request_id", default="-")

async def handle(name):
    request_id.set(name)
    await asyncio.sleep(0.05)          # 切换点:别的协程会插进来
    return f"{name} -> {request_id.get()}"   # 切回来仍是自己的值

async def main():
    return await asyncio.gather(handle("A"), handle("B"), handle("C"))

print("contextvars:", asyncio.run(main()))
contextvars: ['A -> A', 'B -> B', 'C -> C']

三个协程在 await 处互相穿插,但每个协程读到的 request_id 都是自己设的那个值——因为 contextvars 的每个 Task 拥有独立的上下文副本,上下文在 await 切换时会自动保存与恢复。这正是日志追踪(trace id)、请求级配置需要的语义。threading.local 在同一线程的多个协程间会串味,别在异步代码里用它。

13.3.5 假异步之一:忘记 await

回到 13.1 节那个坑,它也是「假异步」的头号来源:

async def value():
    return 42

async def forget():
    r = value()               # 忘了 await
    print("忘了 await:", type(r).__name__)

asyncio.run(forget())
RuntimeWarning: coroutine 'value' was never awaited
忘了 await: coroutine

代码不报错,r 却是一个协程对象而不是 42。凡是调用 async def 函数,几乎都必须 await;唯一的例外是你故意想创建后台任务,那就该用 create_task 而不是裸调用。

13.3.6 假异步之二:循环里串行 await

即使每个操作都正确地 await 了,如果把它们顺序写出来,依然是串行:

import asyncio, time

async def task(i):
    await asyncio.sleep(0.2)
    return i

async def serial():
    t0 = time.perf_counter()
    for i in range(5):
        await task(i)          # 一个一个来
    return time.perf_counter() - t0

async def concurrent():
    t0 = time.perf_counter()
    await asyncio.gather(*[task(i) for i in range(5)])
    return time.perf_counter() - t0
串行 await : 1.007s
gather     : 0.202s

同样的 5 个任务,串行 await 花了 1 秒,gather 只花 0.2 秒。写了 async/await 不等于并发;只要任务是彼此独立的,就应该用 gather 或 TaskGroup 把它们并发起来。判断标准:这些 await 之间有没有数据依赖?没有就该并发。

13.3.7 假异步之三:create_task 丢引用

create_task 创建的是「发射后不管」的后台任务,但如果没人持有它的引用,官方文档明确警告:任务可能在完成前被垃圾回收,从此无声消失。安全写法是把任务放进一个集合,完成时再移除:

import asyncio

background_tasks = set()

async def background(i):
    await asyncio.sleep(0.3)
    print(f"  任务 {i} 完成")

async def main():
    for i in range(3):
        t = asyncio.create_task(background(i))
        background_tasks.add(t)                 # 持有强引用
        t.add_done_callback(background_tasks.discard)   # 完成后释放
    await asyncio.sleep(0.5)
    print("  存活引用数:", len(background_tasks))

asyncio.run(main())
  任务 0 完成
  任务 1 完成
  任务 2 完成
  存活引用数: 0

在 3.14.6 的实测里,未保存引用的任务没有在运行中被立刻回收,而是在 asyncio.run() 结束时被统一取消——这同样是个隐患:任务「活着出去了,却没等到结果就被取消了」。无论哪种情况,结论一致:后台任务务必用集合(或变量)持有引用,并配 add_done_callback(discard) 清理。 更推荐直接用 TaskGroup,它在作用域结束前强制等待所有任务,从根上杜绝孤儿任务。

13.3.8 async for / async with / 异步生成器

异步世界里有两套特殊协议。异步上下文管理器实现 __aenter__ / __aexit__,配 async with;异步生成器用 async def + yield,配 async for:

import asyncio

class Conn:
    async def __aenter__(self):
        print("  打开连接")
        return self
    async def __aexit__(self, *exc):
        print("  关闭连接")

async def countdown(n):
    for i in range(n, 0, -1):
        await asyncio.sleep(0.01)
        yield i

async def main():
    async with Conn():
        print("  使用连接")
    print("async for:", [x async for x in countdown(3)])

asyncio.run(main())
  打开连接
  使用连接
  关闭连接
async for: [3, 2, 1]

httpx.AsyncClient、asyncpg 的连接、各种异步文件对象都实现了 __aenter__/__aexit__,所以它们都要求 async with。异步生成器则适合「分页拉取」这类需要边等边产出的场景。

13.3.9 anyio 与 trio:另一条路线

asyncio 是标准库方案,但并非唯一。trio 从零设计,主打结构化并发(用 nursery 严格管理任务树);anyio 是兼容层,同一份代码可跑在 asyncio 或 trio 后端上:

import anyio

async def task(name):
    await anyio.sleep(0.1)
    print(f"  {name} 完成")

async def main():
    async with anyio.create_task_group() as tg:
        tg.start_soon(task, "A")
        tg.start_soon(task, "B")

anyio.run(main, backend="asyncio")     # 换成 backend="trio" 即可切后端
  A 完成
  B 完成

anyio(实测 4.15.1)的 create_task_group() 在 asyncio 后端上也提供「一个失败、全体取消」的语义。何时选哪条路线,以及 trio 的取消作用域、httpx 对双后端的支持,可以参考专题 Python 高级异步编程 。初学阶段先用标准库 asyncio 打牢基础即可。

13.3.10 什么时候不该用异步

异步不是万能药,用错场景反而更慢更复杂:

场景该不该用异步说明
大量网络 / 磁盘 I/O 并发该用等待期间能调度别的任务
CPU 密集计算(图像、数值)不该协程无法绕过 GIL,用多进程(12.2 节)
只有一两个串行请求不该异步的调度开销不值当,同步代码更简单
全站都是同步库且无法替换慎重到处 to_thread 不如直接用线程池
需要极低延迟的少量连接看情况简单同步 + 线程池往往够用

一句话:异步解决的是「大量 I/O 等待」,不是「算得快」。 如果瓶颈是 CPU,回到第 12 章的 multiprocessing;如果根本没有并发需求,同步代码永远是最省心的选择。想更系统地看 asyncio 的实战用法,可以读专题 asyncio 完全指南 。

小结

  • 同步库在协程里会卡死循环(实测 requests 期间心跳仅 1 次);用 asyncio.to_thread 或 run_in_executor 把它送进线程池(实测 5 个请求从 1.586s 降到 0.319s)。
  • httpx.AsyncClient 要用 async with 管理生命周期、并复用同一个客户端以利用连接池。
  • 异步上下文用 contextvars 而非 threading.local;Task 之间互不串味。
  • 三种「假异步」:忘记 await、循环里串行 await(1.007s vs 0.202s)、create_task 丢引用。
  • async with / async for / 异步生成器是异步生态的通用协议;anyio/trio 是另一条结构化并发路线。
  • CPU 密集、无并发需求时不该用异步——异步只解决 I/O 等待。

第 12 章的线程、进程与本节的协程,构成了 Python 并发的完整版图:进程绕开 GIL 做并行计算,线程处理少量阻塞调用,协程撑起海量 I/O 并发。接下来我们要给这些代码上「保险」——下一章进入测试,学习如何用 pytest 验证你写的一切。

阅读导航:上一节:asyncio 任务、并发与超时取消 · 下一节:pytest 基础与断言 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时