本文面向已经会写基础 Python、但想真正理解异步编程的开发者。示例基于 Python 3.11+,并优先采用 Python 3.14 中仍然推荐的高层 API。
一、为什么需要异步编程
程序执行时,CPU 往往不是一直在计算。它经常要等待:
- 网络响应;
- 数据库查询;
- 文件读写;
- 定时器;
- 其他进程或服务。
这类任务称为 I/O 密集型任务。如果使用同步代码,程序会在等待期间停在那里:
import time
def read_data():
time.sleep(2) # 模拟等待外部资源
return "data"
def main():
print(read_data())
print("继续执行")
main()
同步代码的特点是:调用者必须等当前函数返回,才能继续下一行。
异步代码的思路是:当当前任务遇到可等待的 I/O 时,把执行权交还给事件循环,让事件循环去运行其他任务;等 I/O 完成后,再回来继续执行当前任务。
因此,异步编程解决的主要是 等待期间的并发利用率,不是让单个 CPU 计算自动变快。CPU 密集型任务通常需要进程、原生扩展或其他专门方案。
二、异步编程的核心概念
2.1 协程
用 async def 定义的函数,调用后不会立即执行函数体,而是返回一个协程对象:
import inspect
async def hello():
# 这里只展示:调用 async def 会得到协程对象。
return "hello"
coroutine = hello()
print(inspect.iscoroutine(coroutine)) # True
coroutine.close() # 示例结束后手动关闭,避免未等待协程警告
协程必须被事件循环调度,常见入口是 asyncio.run():
import asyncio
async def main():
message = await hello()
print(message)
asyncio.run(main())
await 的含义不是“创建一个后台线程”,而是:等待一个可等待对象完成,并在等待期间允许事件循环运行其他任务。await 只能出现在 async def 函数内部。
2.2 顺序等待与并发等待
下面的代码虽然使用了异步函数,但仍然是顺序执行:
import asyncio
async def work(name: str, seconds: float):
await asyncio.sleep(seconds)
return name
async def sequential():
first = await work("first", 2)
second = await work("second", 2)
return first, second
两个任务总耗时接近 4 秒。若任务之间没有依赖关系,可以并发启动:
import asyncio
async def concurrent():
first_task = asyncio.create_task(work("first", 2))
second_task = asyncio.create_task(work("second", 2))
first = await first_task
second = await second_task
return first, second
两个任务总耗时通常接近 2 秒。
2.3 asyncio.TaskGroup:结构化管理一组任务
在 Python 3.11+ 中,TaskGroup 提供了一种结构化并发写法。任务在 async with 代码块中创建,并且在离开代码块之前一定会被等待:
import asyncio
async def with_task_group():
async with asyncio.TaskGroup() as group:
first_task = group.create_task(work("first", 2))
second_task = group.create_task(work("second", 2))
# 离开上下文时,两个任务都已经完成。
return first_task.result(), second_task.result()
TaskGroup 的 API
TaskGroup 本身的构造函数没有业务参数:
asyncio.TaskGroup()
它是一个异步上下文管理器,主要通过 create_task() 创建任务。为了兼容 Python 3.11 和 3.12,最常用的签名可以写成:
group.create_task(coro, *, name=None, context=None)
参数含义如下:
| 参数 | 含义 |
|---|---|
coro | 要执行的协程对象,例如 fetch(url)。 |
name | 任务名称,便于调试、日志和监控,可通过 task.get_name() 获取。 |
context | 任务使用的 contextvars.Context。不传时,任务会复制当前上下文。 |
eager_start | Python 3.14+ 支持。是否在创建任务时立即开始执行协程;具体行为依赖当前 Python 版本和事件循环实现。 |
**kwargs | Python 3.14+ 支持。会继续传给事件循环的任务创建函数,适合使用自定义事件循环支持的参数。 |
因此,如果文章声明兼容 Python 3.11+,不应在通用签名中把 eager_start 和 **kwargs 写成所有版本都可用的参数。需要使用新参数时,应明确标注最低 Python 版本。
大多数业务代码只需要传 coro 和可选的 name:
async def load_dashboard():
async with asyncio.TaskGroup() as group:
user_task = group.create_task(
load_user(),
name="load-user",
)
orders_task = group.create_task(
load_orders(),
name="load-orders",
)
return {
"user": user_task.result(),
"orders": orders_task.result(),
}
TaskGroup 的异常行为
如果组内某个任务抛出非取消异常,TaskGroup 通常会:
- 取消同一组中尚未完成的其他任务;
- 如果
async with的代码体仍在执行,取消包含该任务组的父任务; - 等待所有任务结束;
- 将组内异常以
ExceptionGroup或BaseExceptionGroup的形式抛出。
async def fail_fast():
try:
async with asyncio.TaskGroup() as group:
group.create_task(work("ok", 5))
group.create_task(raise_error())
group.create_task(work("also-cancelled", 5))
except* ValueError as error_group:
print("捕获到一组 ValueError:", error_group.exceptions)
这里的 except* 是 Python 3.11+ 用于匹配异常组的语法。TaskGroup 的设计目标是让相关任务形成一个生命周期整体:一个任务失败时,其他任务通常也不应继续无条件运行。
CancelledError 有特殊处理:一个任务被取消,不等同于该任务组发生普通业务失败。编写清理逻辑时,应在 finally 中释放资源,并且通常不要吞掉取消异常:
async def worker():
try:
await asyncio.sleep(10)
finally:
await close_resource()
TaskGroup 的结果
TaskGroup 不会像 gather() 一样直接返回结果列表。create_task() 会返回 asyncio.Task 对象,需要在任务组退出后通过 task.result() 读取结果:
async with asyncio.TaskGroup() as group:
tasks = [
group.create_task(work("a", 1)),
group.create_task(work("b", 1)),
]
results = [task.result() for task in tasks]
任务结果的顺序由 tasks 列表决定,而不是由任务完成的先后决定。
2.4 asyncio.gather():批量等待并收集结果
gather() 更适合“同时执行几项独立工作,然后按输入顺序拿到结果”的场景:
asyncio.gather(*aws, return_exceptions=False)
参数含义如下:
| 参数 | 含义 |
|---|---|
*aws | 一个或多个可等待对象,可以是协程、任务或其他 awaitable。协程会被自动包装成任务。 |
return_exceptions=False | 默认值。第一个发生的异常会立即传播给调用者。 |
return_exceptions=True | 不直接抛出子任务异常,而是把异常对象放进结果列表;调用者必须检查每一项结果。 |
最基本的用法:
async def with_gather():
results = await asyncio.gather(
work("first", 1),
work("second", 2),
work("third", 1),
)
return results
# 结果顺序与传入顺序一致:
# ["first", "second", "third"]
虽然 second 可能最后完成,但它仍然会出现在结果列表的第二个位置。
return_exceptions=False
默认情况下,第一个异常会被传播:
async def gather_fail_fast():
results = await asyncio.gather(
load_user(),
load_orders(),
load_recommendations(),
)
return results
需要注意:gather() 传播一个子任务的异常时,默认不会自动取消其他已经开始的可等待对象。其他任务可能仍在后台继续执行。因此,如果任务之间有明确的“一个失败,全部停止”关系,TaskGroup 往往更符合预期。
return_exceptions=True
当每个任务都应独立完成,即使某些任务失败也要保留其他结果时,可以启用它:
async def gather_all_outcomes():
outcomes = await asyncio.gather(
load_user(),
load_orders(),
load_recommendations(),
return_exceptions=True,
)
for outcome in outcomes:
if isinstance(outcome, BaseException):
print("一个任务失败:", outcome)
else:
print("一个任务成功:", outcome)
return outcomes
此时返回列表中的元素可能是正常返回值,也可能是异常对象。不要直接把这些结果当成业务数据使用。
gather() 的取消行为
如果等待 gather() 的外层任务被取消,gather() 会取消尚未完成的子任务:
async def request_handler():
return await asyncio.gather(
slow_operation_a(),
slow_operation_b(),
)
如果只是其中一个子任务被取消,gather() 会把它视为一个 CancelledError 来处理,而不会因此自动取消其他子任务;在默认的 return_exceptions=False 下,调用方仍可能收到这个取消异常。设置 return_exceptions=True 时,它才会作为结果列表中的异常对象返回。这个行为与“外层等待 gather() 被取消”不同,实际编写取消逻辑时需要区分两种情况。
2.5 TaskGroup 与 gather() 的对比
| 对比项 | TaskGroup | gather() |
|---|---|---|
| 引入版本 | Python 3.11+ | Python 3.5+ |
| 主要用途 | 管理有共同生命周期的一组任务 | 批量等待并收集结果 |
| 返回值 | 不直接返回结果列表,需要读取 Task.result() | 返回按输入顺序排列的结果列表 |
| 默认异常 | 任务失败时取消同组任务,退出时抛出异常组 | 传播第一个异常,其他任务默认不自动取消 |
| 异常汇总 | 支持 ExceptionGroup / except* | 使用 return_exceptions=True 把异常放入结果列表 |
| 取消语义 | 组内任务具有整体生命周期 | 外层 gather() 被取消时取消未完成子任务 |
| 并发限制 | 不负责限制并发数量 | 不负责限制并发数量 |
可以使用下面的经验规则:
- 需要“一个任务失败,其他任务也停止”,优先使用
TaskGroup。 - 需要“所有任务都跑完,并按传入顺序收集结果”,使用
gather()。 - 需要“成功和失败结果都保留”,使用
gather(return_exceptions=True),并显式检查异常。 - 需要限制同时运行的任务数时,额外使用
Semaphore、连接池或任务队列。
例如,下面的写法将 TaskGroup 与结果字典结合起来,既有结构化生命周期,又保留了清晰的业务字段:
async def load_page():
async with asyncio.TaskGroup() as group:
tasks = {
"user": group.create_task(load_user()),
"orders": group.create_task(load_orders()),
"recommendations": group.create_task(load_recommendations()),
}
return {
name: task.result()
for name, task in tasks.items()
}
2.6 取消、超时和异常
异步任务必须认真处理取消。服务停止、客户端断开连接、请求超时,都可能导致任务被取消:
import asyncio
async def cancellable():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
# 释放资源、记录状态,然后继续向上抛出取消异常
print("任务被取消")
raise
为外部调用设置超时:
import asyncio
async def call_with_timeout():
try:
async with asyncio.timeout(3):
return await work("remote-result", 10)
except TimeoutError:
return "timeout"
不要用无限等待掩盖网络或下游服务故障。超时、取消和资源关闭应当成为异步函数设计的一部分。
三、FastAPI 异步编程
FastAPI 的路径操作函数可以使用 async def 或普通的 def。选择的关键不是“异步看起来更先进”,而是函数内部调用的依赖是否支持异步。
3.1 使用异步依赖
from fastapi import FastAPI
import httpx
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.example.com/users/{user_id}",
timeout=5.0,
)
response.raise_for_status()
return response.json()
这里的调用链是异步的:FastAPI 调用异步路由,路由 await HTTPX 的异步请求,网络等待期间事件循环可以处理其他请求。
3.2 同步路由也可以使用
如果依赖的库只有同步 API:
from fastapi import FastAPI
import legacy_database
app = FastAPI()
@app.get("/reports/{report_id}")
def get_report(report_id: int):
return legacy_database.fetch_report(report_id)
FastAPI 会把普通的路径操作函数放到外部线程池中执行。这样可以避免同步函数直接占住事件循环,但线程池容量、数据库连接池和下游服务吞吐量仍然需要评估。
3.3 生命周期内复用 HTTP 客户端
不要在高频请求的每次调用中都创建一个新的客户端。更合理的方式是在应用生命周期内创建一个共享客户端:
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http_client = httpx.AsyncClient(
base_url="https://api.example.com",
timeout=httpx.Timeout(5.0),
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
),
)
try:
yield
finally:
await app.state.http_client.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/items/{item_id}")
async def get_item(item_id: str, request: Request):
client: httpx.AsyncClient = request.app.state.http_client
response = await client.get(f"/items/{item_id}")
response.raise_for_status()
return response.json()
共享客户端可以复用连接池,减少连接建立成本。关闭应用时要显式关闭客户端,避免连接泄漏。
3.4 异步依赖注入
数据库、鉴权和远程配置等依赖也可以使用异步生成器:
from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI
async def get_connection() -> AsyncIterator[object]:
connection = await create_async_connection()
try:
yield connection
finally:
await connection.close()
app = FastAPI()
@app.get("/orders/{order_id}")
async def get_order(
order_id: int,
connection: object = Depends(get_connection),
):
return await connection.fetch_one(
"SELECT * FROM orders WHERE id = :id",
{"id": order_id},
)
实际项目中,数据库驱动、ORM 和连接池的具体 API 会有所不同,但原则一致:谁创建资源,谁负责在生命周期结束时释放资源。
四、异步网络请求
4.1 使用 HTTPX AsyncClient
HTTPX 同时提供同步和异步 API。异步代码中应使用 AsyncClient:
import asyncio
import httpx
async def fetch(client: httpx.AsyncClient, url: str) -> dict:
response = await client.get(url)
response.raise_for_status()
return response.json()
async def main():
urls = [
"https://api.example.com/a",
"https://api.example.com/b",
"https://api.example.com/c",
]
async with httpx.AsyncClient(
timeout=httpx.Timeout(5.0),
limits=httpx.Limits(max_connections=20),
) as client:
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(fetch(client, url)) for url in urls]
results = [task.result() for task in tasks]
print(results)
asyncio.run(main())
注意几个实践点:
- 一个批次尽量共享同一个
AsyncClient,以复用连接池。 - 必须设置合理的连接、读取、写入和连接池等待超时。
- 使用
raise_for_status(),不要把 HTTP 错误当成正常业务数据。 - 对并发量设置上限,避免瞬间打满连接池或压垮下游服务。
4.2 限制并发量
任务很多时,不应无条件创建成千上万个网络请求。可以使用 asyncio.Semaphore:
import asyncio
import httpx
async def fetch_limited(
client: httpx.AsyncClient,
semaphore: asyncio.Semaphore,
url: str,
) -> dict:
async with semaphore:
response = await client.get(url)
response.raise_for_status()
return response.json()
async def fetch_many(urls: list[str]) -> list[dict]:
semaphore = asyncio.Semaphore(20)
async with httpx.AsyncClient(timeout=5.0) as client:
return await asyncio.gather(
*(
fetch_limited(client, semaphore, url)
for url in urls
)
)
并发上限应结合下游限流策略、连接池大小、服务端延迟和机器资源进行压测,而不是凭感觉设置。
4.3 流式响应
对于大响应或服务端推送,可以使用流式接口,避免一次性把全部内容读进内存:
async with httpx.AsyncClient() as client:
async with client.stream(
"GET",
"https://example.com/large-file",
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
process_chunk(chunk)
如果使用手动流式模式,就必须确保最终调用 response.aclose()。
五、异步阻塞问题
5.1 什么是阻塞事件循环
典型的错误写法:
import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/bad")
async def bad_endpoint():
time.sleep(3) # 阻塞当前事件循环
return {"ok": True}
虽然路由写成了 async def,但 time.sleep() 是同步阻塞调用。事件循环在这 3 秒内无法调度同一线程上的其他异步任务,其他请求也可能被拖慢。
常见阻塞源包括:
time.sleep();requests、同步版数据库驱动、同步版 Redis 客户端;- 大文件的同步读写;
- 大量 JSON 编解码;
- 图片处理、压缩、加密等 CPU 计算;
- 会长时间等待的同步 SDK。
5.2 怎样定位阻塞
可以先做三件事:
- 检查
async def函数内部是否调用了同步库。 - 开启 asyncio 调试模式,关注慢回调。
- 通过压测观察事件循环延迟,而不只看平均响应时间。
开发阶段可以使用:
PYTHONASYNCIODEBUG=1 python app.py
异步代码的性能问题经常表现为“偶尔很慢”。这通常需要结合日志、超时、事件循环延迟和下游依赖耗时一起观察。
六、同步转异步
“同步转异步”有两种完全不同的情况,必须先区分。
6.1 把阻塞同步函数移到线程
对于 I/O 阻塞但暂时没有异步版本的同步库,可以使用 asyncio.to_thread():
import asyncio
import legacy_sdk
async def load_from_legacy_sdk(key: str):
return await asyncio.to_thread(
legacy_sdk.load,
key,
)
在 FastAPI 中:
import asyncio
from fastapi import FastAPI
import legacy_sdk
app = FastAPI()
@app.get("/legacy/{key}")
async def legacy_endpoint(key: str):
data = await asyncio.to_thread(legacy_sdk.load, key)
return {"data": data}
这不会把同步库“变成真正的异步库”,而是把阻塞工作放到线程中,使事件循环线程可以继续调度其他任务。线程池仍然有限,因此要控制并发量,也要注意线程安全。
6.2 CPU 密集型工作不要简单丢给线程
async def bad_cpu_offload(data: bytes):
return await asyncio.to_thread(expensive_cpu_function, data)
这可以避免事件循环线程被直接阻塞,但在常规 CPython 环境中,纯 Python CPU 计算通常仍会受到 GIL 影响,吞吐提升有限。更适合的选择可能是:
ProcessPoolExecutor或独立进程;- 使用释放 GIL 的原生库;
- 将任务交给任务队列和专门的 worker;
- 使用适合该计算的外部服务。
6.3 从同步入口调用异步函数
在普通同步脚本中,使用:
import asyncio
def main():
result = asyncio.run(async_operation())
print(result)
if __name__ == "__main__":
main()
但不要在已经运行事件循环的环境中再次调用 asyncio.run(),例如某些 Notebook、异步测试框架或异步 Web 处理函数。此时应该直接 await,或者把调用边界设计得更清楚。
6.4 不要把 run_in_executor() 当成默认答案
旧代码中经常能看到:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_function)
它仍然有用,尤其是在需要自定义线程池或进程池时。但对于简单的同步阻塞函数,asyncio.to_thread() 更直接,也会传播当前上下文变量。
七、一个可落地的判断清单
写一个异步函数前,可以按下面的顺序判断:
- 这段工作主要是在等待网络、数据库或文件,还是在进行 CPU 计算?
- 使用的第三方库是否提供
await版本? - 如果没有,能否使用
asyncio.to_thread(),或者改用真正的异步库? - 并发任务是否有数量上限?
- 是否设置了连接、读取、写入和连接池等待超时?
- 任务取消时,连接、文件和锁是否会被释放?
- 是否在应用生命周期内复用客户端、连接池等资源?
- 是否用压测验证了事件循环没有被同步调用拖住?
八、总结
Python 异步编程的核心不是把所有函数都改成 async def,而是建立一条连续的非阻塞调用链:
异步入口
-> await 异步业务函数
-> await 异步数据库 / HTTP 客户端
-> 及时处理取消、超时和资源释放
在 FastAPI 中,异步路由适合调用异步数据库驱动、HTTPX AsyncClient 等异步依赖;面对遗留同步库,可以用 asyncio.to_thread() 把阻塞操作移到线程;面对 CPU 密集型任务,则应考虑进程或专用 worker。
最后,异步带来的收益依赖正确的边界管理。一个内部调用了 time.sleep() 或同步网络客户端的 async def,形式上是异步的,行为上仍可能阻塞整个事件循环。