asyncio 真的比多线程强?我用 100 个并发请求实测,差距也太大了

3 阅读1分钟

上个月我负责的那个数据中台,突然接到一个需求:要对下游 100+ 个服务做健康检查,每个接口平均耗时 200ms,要求 5 秒内出结果。我二话不说开了 100 个线程,结果线程切换开销直接把 CPU 打满,响应时间飙到 8 秒多。运维同事在群里连发三个“?”。

那一刻我才认真审视 asyncio。以前总觉得异步编程“门槛高、容易写出 bug”,但真正跑完一轮压测后,我只想说:在 IO 密集型场景下,asyncio 和多线程根本不是一个量级。下面是我把同一个任务用三种方案——同步、多线程、asyncio——放在一起硬刚的完整复盘。

实测场景:100 个 HTTP 请求,每个 200ms 延迟

我们用 FastAPI 搭了一个模拟下游服务,/health 接口固定 sleep 200ms 再返回 {"status": "ok"}。客户端分别用三种策略发起 100 个并发请求,统计总耗时和资源占用。

方案一:同步串行,慢得理所当然

# sync_demo.py — 同步请求,一个接一个
import time
import requests

URLS = [f"http://localhost:8000/health" for _ in range(100)]

def check_sync():
    results = []
    for url in URLS:
        resp = requests.get(url, timeout=5)
        results.append(resp.json())
    return results

if __name__ == "__main__":
    start = time.perf_counter()
    check_sync()
    elapsed = time.perf_counter() - start
    print(f"同步耗时: {elapsed:.2f}s")   # 20.3s 左右

毫无悬念,100 × 200ms = 20 秒,线程全程在等网络 IO,CPU 几乎空转。这是 100 个请求,如果是 1000 个,系统直接假死 3 分钟,任何并发的幻想都没有。

方案二:多线程,看似并发实则陷阱

# thread_demo.py — 100 个线程并发
import time
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

URLS = [f"http://localhost:8000/health" for _ in range(100)]

def fetch(url):
    return requests.get(url, timeout=5).json()

def check_thread():
    results = []
    with ThreadPoolExecutor(max_workers=100) as executor:
        futures = {executor.submit(fetch, url): url for url in URLS}
        for future in as_completed(futures):
            results.append(future.result())
    return results

if __name__ == "__main__":
    start = time.perf_counter()
    check_thread()
    elapsed = time.perf_counter() - start
    print(f"多线程耗时: {elapsed:.2f}s")  # 第一次 8.5s,后来波动在 3~6s

实测首次运行 8.5 秒,CPU 使用率瞬间冲上 90%。原因是 Python 的 GIL 在 IO 操作时虽然会释放,但 100 个线程的创建、上下文切换、锁争抢带来了巨大的额外开销。把 max_workers 降到 30,耗时降到 2.1 秒,CPU 也稳了——但这就变成了“经验调参”,而且线程数一多,系统就不稳定。

更隐蔽的坑是:requests 库本身不是线程安全的最佳选择,连接池复用也受限,偶尔还会抛出 ConnectionResetError,排查起来欲哭无泪。

方案三:asyncio + aiohttp,顺畅得让人不习惯

# async_demo.py — 使用 asyncio 和 aiohttp 并发请求
import asyncio
import time
import aiohttp

URLS = [f"http://localhost:8000/health" for _ in range(100)]

async def fetch(session, url):
    try:
        async with session.get(url, timeout=5) as resp:
            return await resp.json()
    except Exception as e:
        return {"error": str(e)}

async def check_async():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in URLS]
        results = await asyncio.gather(*tasks)
    return results

if __name__ == "__main__":
    start = time.perf_counter()
    asyncio.run(check_async())
    elapsed = time.perf_counter() - start
    print(f"asyncio 耗时: {elapsed:.2f}s")  # 稳定在 0.45~0.60s

100 个任务在同一个事件循环里调度,全部异步发出,实际耗时仅由最慢的一次 IO 决定,稳定在 0.6 秒以内。CPU 使用率全程不超过 15%,内存占用几乎是一条直线。老板在监控大屏上看到效果后,问我是不是偷偷加了服务器——其实只是把代码改写成了 async/await。

踩坑 & 注意事项:这 3 个坑我一个没落下

  1. 同步代码混进协程,性能直接打回原形。
    最早我把 requests.get() 直接放进 async def 里,心想“反正加了 async 就起飞了”。结果耗时纹丝不动,还报了 RuntimeWarning。asyncio 要求所有 IO 操作都使用支持非阻塞的库,比如 aiohttphttpx 的异步模式。如果非得用同步库,必须用 asyncio.to_thread() 把任务扔到线程池,否则事件循环会被堵塞死。

  2. 忘记 gather 的异常处理,一个任务挂了全盘崩溃。
    最初我直接 await asyncio.gather(*tasks),当某个接口超时抛出 asyncio.TimeoutError 时,所有未完成的任务一起被取消,返回的结果列表直接中断。正确姿势是设置 return_exceptions=True,让异常作为结果返回来,不中断其他任务,事后统一处理。

  3. ClientSession 要复用,不要每次请求都创建。
    一个 aiohttp.ClientSession 内部维护了连接池,重复创建会大量消耗端口和打开文件数。我习惯在 async with 上下文里复用同一个 session,所有请求共用一个连接池——这在高并发下是基本素养,否则会被运维追着打。

还有一个不是坑的领悟:asyncio 对 CPU 密集型任务没用,图像处理、数据计算还得乖乖上多进程或线程池。

总结

IO 密集型并发任务,请毫不犹豫选择 asyncio——它用单线程跑出了接近理论极限的吞吐量,把多线程按在地上摩擦。

#Python #异步编程 #asyncio #性能优化 #程序员踩坑