asyncio 真能比多线程更好吗?我测试了 100 个并发请求,差异巨大
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 里,随后…(后面的故事继续)。