codecamp

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 放到明确的线程池边界中。

来源与相邻小节

阅读相邻小节时,请在教程目录中选择对应标题。

2.1 选择同步路由还是异步路由
2.3 安全接入只能同步调用的SDK
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }