2.2 复现并定位事件循环阻塞
复现并定位事件循环阻塞
上一节说明了为什么同一个 time.sleep() 会因路由声明不同而产生不同影响。本节把这个差异放进一个真正监听端口的单 worker 进程中,用三条短延迟路由做对照:异步路由里直接阻塞、同步路由交给线程执行、异步等待交还事件循环。实验的目的不是测量某台机器的固定 QPS,而是建立一个能重复观察的因果关系。
实验应该固定什么
实验只使用本地回环地址和短暂延迟,不连接已有业务服务,也不制造大量请求。服务端固定一个 worker,客户端同时发出三条慢请求;否则多个 worker 可能把阻塞分散到不同进程,掩盖单个事件循环被卡住的现象。客户端的连接池至少容纳并发请求,避免把客户端排队误认为服务端排队。
三条路由的含义如下:
| 路由 | 函数体的等待方式 | 单 worker 下的预期现象 |
|---|---|---|
/terrible-ping |
async def 中执行 time.sleep() |
当前事件循环被占住,慢请求倾向于串行 |
/good-ping |
普通 def 中执行 time.sleep() |
整个同步路由在线程中运行,事件循环仍能处理其他请求 |
/perfect-ping |
async def 中执行 await asyncio.sleep() |
等待点允许其他协程继续推进 |
“good”只是本实验中的对照名称,不代表所有同步函数都适合无限并发。线程池仍有容量,数据库连接、外部服务限流和 CPU 负载也会成为新的边界。
服务端:固定单 worker
将下面文件保存为 lab.py。延迟常量只用于让差异容易观察;真实系统应依据自己的超时、依赖和监控设计实验。
<!-- file: ch02_blocking/lab.py -->
from __future__ import annotations
import asyncio
import time
import uvicorn
from fastapi import FastAPI
DELAY_SECONDS = 0.25
app = FastAPI(title="事件循环阻塞实验")
@app.get("/terrible-ping")
async def terrible_ping() -> dict[str, object]:
# 这是实验中的故意错误:阻塞当前 worker 的事件循环。
time.sleep(DELAY_SECONDS)
return {"route": "terrible", "pong": True}
@app.get("/good-ping")
def good_ping() -> dict[str, object]:
# 普通同步路由由 FastAPI/Starlette 放入同步线程执行。
time.sleep(DELAY_SECONDS)
return {"route": "good", "pong": True}
@app.get("/perfect-ping")
async def perfect_ping() -> dict[str, object]:
await asyncio.sleep(DELAY_SECONDS)
return {"route": "perfect", "pong": True}
@app.get("/health")
async def health() -> dict[str, object]:
return {"ok": True, "delay_seconds": DELAY_SECONDS}
if __name__ == "__main__":
# 直接 python lab.py 时仍固定单 worker,且明确关闭 reload。
uvicorn.run(
"lab:app",
host="127.0.0.1",
port=18765,
workers=1,
reload=False,
)
从包含 lab.py 的目录启动,保持端口和 worker 设置不变:
python lab.py
也可以使用命令行方式,但不要加 --reload,并明确保留 --workers 1:
uvicorn lab:app --host 127.0.0.1 --port 18765 --workers 1
另开终端,在同一工作目录运行探测程序。服务启动后先手动确认健康检查:
curl http://127.0.0.1:18765/health
客户端:并发请求而不是逐个请求
下面的探测程序使用 HTTPX 的异步客户端,在同一轮内创建三条请求任务。它只断言状态码和相对关系,不把某次运行的具体毫秒数写成结论。max_connections=10 是为了让三条请求能够同时使用连接;它不是服务端线程数的设置。
<!-- file: ch02_blocking/probe.py -->
from __future__ import annotations
import asyncio
import time
import httpx
BASE_URL = "http://127.0.0.1:18765"
DELAY_SECONDS = 0.25
PATHS = ("/terrible-ping", "/good-ping", "/perfect-ping")
async def measure(
client: httpx.AsyncClient,
path: str,
request_count: int = 3,
) -> tuple[float, list[int]]:
started = time.perf_counter()
responses = await asyncio.gather(
*(client.get(path) for _ in range(request_count))
)
elapsed = time.perf_counter() - started
return elapsed, [response.status_code for response in responses]
async def main() -> None:
limits = httpx.Limits(
max_connections=10,
max_keepalive_connections=10,
)
timeout = httpx.Timeout(5.0, connect=1.0)
async with httpx.AsyncClient(
base_url=BASE_URL,
limits=limits,
timeout=timeout,
trust_env=False,
) as client:
health = await client.get("/health")
assert health.status_code == 200
assert health.json()["ok"] is True
results: dict[str, float] = {}
for path in PATHS:
elapsed, statuses = await measure(client, path)
assert statuses == [200, 200, 200]
results[path] = elapsed
print(f"{path}: {elapsed:.3f}s for 3 concurrent requests")
# 三个阻塞请求在单 worker 中应明显比一次异步等待更久。
# 阈值相对于实验延迟计算,避免依赖某台机器的固定毫秒数。
assert results["/terrible-ping"] >= DELAY_SECONDS * 2.0
assert results["/terrible-ping"] > results["/perfect-ping"] * 1.2
assert results["/good-ping"] < results["/terrible-ping"]
print("blocking experiment self-check passed")
if __name__ == "__main__":
asyncio.run(main())
在服务端运行时再开一个终端:
python probe.py
探测器打印的秒数属于当前机器的一次运行,受到操作系统调度、解释器负载、网络栈和其他进程影响。本节不把某个具体数值当成通用结论。如果断言失败,先检查是否误启动了多个 worker、端口上是否是旧进程、是否把 probe.py 从错误目录运行,随后再检查机器是否繁忙。
如何用健康检查定位阻塞点
探测慢请求期间,在另一终端重复访问 /health。如果访问 /terrible-ping 时健康检查也明显等待,而访问 /good-ping 或 /perfect-ping 时仍能较快返回,这说明延迟发生在当前 worker 的事件循环路径上;它不是“FastAPI 所有请求都会变慢”的结论。如果服务以多个 worker 运行,某个 worker 被卡住时其他 worker 仍可能响应健康检查,因此实验必须先固定为一个 worker。
在真实项目中,定位过程应继续向下追踪:路由调用的 service 是否使用同步 SDK,依赖项是否包含阻塞查询,中间件是否做了同步日志或文件操作,序列化和大对象处理是否消耗 CPU。只把函数改名为 async 不会消除这些调用。2.3 节会将同步 SDK 放到明确的线程池边界中。
来源与相邻小节
- 主要参考:fastapi-best-practices 中文 README 的异步路由三种 ping 对照。本节基于原文三类 ping 示例,缩短延迟并补充独立客户端与验收方法。
- 官方说明:FastAPI 并发与 async/await、HTTPX AsyncClient。
- 相邻小节:2.1 选择同步路由还是异步路由、2.3 安全接入只能同步调用的 SDK、2.4 区分 CPU 任务与后台任务。
阅读相邻小节时,请在教程目录中选择对应标题。