asyncio 真能比多线程更好吗?我测试了 100 个并发请求,差异巨大

发布: (2026年5月1日 GMT+8 09:45)
5 分钟阅读
原文: Dev.to

Source: Dev.to

请提供您希望翻译的文章正文内容,我将按照要求把它翻译成简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!

Introduction

上个月,我维护的数据平台突然出现了一个新需求:对 100 多个下游服务进行健康检查。每个端点平均耗时 200 ms,整个检查必须在 5 秒内完成。我毫不犹豫地启动了 100 条线程。线程切换的开销立刻把 CPU 用满,响应时间飙升至 8 秒以上。我的运维同事在群里发了三个问号。

那一刻让我不得不认真重新审视 asyncio。我以前一直认为异步编程学习曲线陡峭,且容易出 bug,但在做了详尽的基准测试后,我只能说:对于 I/O 密集型工作负载,asyncio 与多线程根本不是一个层次。下面是使用三种不同策略——同步、多线程和 asyncio——对同一任务进行对比的完整分析。

我们使用 FastAPI 搭建了一个模拟的下游服务。/health 接口故意睡 200 ms,然后返回 {"status": "ok"}。客户端使用这三种不同的方式并发发起 100 个请求,并测量总耗时和资源使用情况。

Synchronous Demo (sync_demo.py)

# 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 × 200 毫秒 = 20 秒。线程的全部时间都在等待网络 I/O,而 CPU 几乎处于空闲状态。这还是在只有 100 个请求的情况下;如果是 1 000 个请求,系统将几乎冻结三分钟,且没有任何并发。

Multithreaded Demo (thread_demo.py)

# 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 在 I/O 时会被释放,但创建 100 条线程、不断的上下文切换以及锁竞争会带来巨大的开销。当我把 max_workers 调低到 30 时,耗时降至 2.1 秒,CPU 也趋于平稳——但这就变成了“凭直觉调参”,一旦线程数再次上升,系统又会变得不稳定。

还有一个更隐蔽的陷阱:requests 库并不是最线程安全的选择;它的连接池复用有限,偶尔会抛出 ConnectionResetError,这类错误非常难以调试。

Asyncio Demo (async_demo.py)

# 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 个任务在单个事件循环中调度并异步分发。总耗时仅取决于最慢的 I/O 调用,始终保持在 0.6 秒以下。CPU 使用率从未超过 15 %,内存使用几乎保持完全平稳。当我的老板在监控仪表板上看到这些结果时,他问我是否暗中增加了更多服务器——结果是我只是用 async/await 重写了代码。

要点

混合同步代码到协程中会立刻导致性能下降。起初我把 requests.get() 直接放在 async def 里,随后…(后面的故事继续)。

0 浏览
Back to Blog

相关文章

阅读更多 »

让我损失3小时的 asyncio 错误

让我损失了3小时的错误 这件事发生在去年,我在为我们的 internal operations platform 添加 batch domain liveness check 功能时。该 require…