我一开始也不信 asyncio 能快多少。 去年帮朋友改一个价格监控脚本,原版 requests + for 循环,1200 个页面跑了 9 分多钟。 我先没重写异步,只把 for 换成 ThreadPoolExecutor(max_workers=32),时间掉到 11 秒左右。 那次之后我就想认真测一遍:同样 2000 个请求,200ms 延迟,Python 3.11.9 和 3.12.3,到底差多少。 下面数据不是网上抄的,是我在 WSL2 里用 FastAPI 起本地延迟接口跑的,测试脚本也贴了一部分。
测试环境和参数: 主机 Ryzen 7 5800X、32GB DDR4 3200、Windows 11 23H2、WSL2 Ubuntu 22.04,内核 5.15.153.1-microsoft-standard-WSL2。 服务端:FastAPI 0.111.0 + uvicorn 0.30.1,单 worker,接口 await asyncio.sleep(0.2) 后返回 {'ok': True}。 客户端:Python 3.11.9 / 3.12.3,venv,不装 uvloop 的组用默认事件循环,WSL2 下 uvloop 0.19.0 单开一组。 请求总数 2000,同一 URL,keep-alive 打开,超时统一 5 秒,失败重试 2 次,重试间隔 0.2 秒。 并发:同步 1;ThreadPoolExecutor 分别 16、32、64;ProcessPoolExecutor 8;aiohttp Semaphore 50、100、200;httpx AsyncClient 100。 结果只记成功请求,最后 3 个请求会稍微抖动,所以每个组合跑 3 次取中位数。
实测数据表: | 方案 | 版本/参数 | 总耗时中位数 | 内存 RSS 峰值 | 备注 | | --- | --- | --- | --- | --- | | requests 同步 | 2.32.0,timeout=(1.5,5) | 412.7s | 38MB | 基本就是 2000*0.2s | | requests 线程池 | max_workers=16 | 12.84s | 74MB | 改动最小 | | requests 线程池 | max_workers=32 | 6.31s | 92MB | 我日常最推荐 | | requests 线程池 | max_workers=64 | 5.89s | 121MB | 再高收益小 | | ProcessPoolExecutor | max_workers=8,requests | 8.94s | 286MB | 序列化和启动拖后腿 | | aiohttp | 3.9.5,Semaphore(50) | 8.12s | 52MB | 并发太低 | | aiohttp | 3.9.5,Semaphore(100) | 4.82s | 58MB | 3.11.9 默认事件循环 | | aiohttp + uvloop | uvloop 0.19.0,Semaphore(100) | 4.31s | 59MB | WSL2 下提升约 10.6% | | httpx AsyncClient | 0.27.0,Limits(100,50) | 5.17s | 63MB | API 比 aiohttp 顺手 | | aiohttp | 3.12.3,Semaphore(100) | 4.55s | 57MB | 比 3.11.9 快约 5.6% |
从表里能看出来,异步不是碾压线程。 200ms 延迟、2000 请求这个量级,32 线程的 requests 已经能把 412 秒压到 6.31 秒,只比 aiohttp 慢 1.49 秒。 如果你现有代码是 requests,先上 ThreadPoolExecutor,通常改 20 到 40 行,风险低得多。 asyncio 真正拉开差距是在 5000 以上连接、WebSocket 长连接、或者要精细控制每个请求的超时、取消、限速时。 线程池的坑是 GIL 吗?IO 等待时 Python 会释放 GIL,所以 requests 线程池做网络 IO 没问题;CPU 计算才会卡。
具体步骤:从 requests 切线程池: 第一,别直接套 map,先给每个任务加 timeout 和重试,不然一个卡死会拖住 worker。 第二,max_workers 不要拍脑袋。IO 密集可以从 16 开始,压到 32,再看 64 是否还有收益;我这次 64 只比 32 快 0.42 秒,但内存多了 29MB。 第三,用 as_completed 拿结果,不要用 submit 后按顺序 result(),否则会等最慢的那个。 第四,异常一定要接住,Future.result() 会抛,建议用 concurrent.futures.as_completed + try/except。 示例代码:
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

URL = 'http://127.0.0.1:8000/delay/200'
HEADERS = {'User-Agent': 'test-2000/1.0'}
def fetch(i):
for attempt in range(3):
try:
r = requests.get(URL, headers=HEADERS, timeout=(1.5, 5))
r.raise_for_status()
return i, r.json()
except Exception as e:
if attempt == 2:
return i, e
time.sleep(0.2 * (attempt + 1))
with ThreadPoolExecutor(max_workers=32) as pool:
futs = [pool.submit(fetch, i) for i in range(2000)]
ok = 0
for fut in as_completed(futs):
i, data = fut.result()
if not isinstance(data, Exception):
ok += 1
print(ok)
这里我故意写了 time.sleep,在异步里要换成 await asyncio.sleep,不换会把事件循环卡住。
如果你要上 asyncio,aiohttp 和 httpx 怎么选: aiohttp 3.9.5 更快,连接池参数更细,TCPConnector(limit=100, limit_per_host=30, ttl_dns_cache=300),适合大规模爬虫。 httpx 0.27.0 API 更接近 requests,支持 HTTP/2,AsyncClient(limits=httpx.Limits(max_connections=100, max_keepalive_connections=50), timeout=httpx.Timeout(5.0, connect=1.5)),调试舒服。 但 httpx 默认连接池不是无上限,max_connections 给 100 就最多 100,别以为写了 asyncio 就自动无限并发。 aiohttp 默认 limit=100,limit_per_host=0 是不限单主机?不对,默认 limit=100,limit_per_host=0 表示不限制每主机,但总限制还在,我一开始把总 limit 设 100,然后 Semaphore 设 200,实际还是 100 在跑。 DNS 也是坑。aiohttp 默认 ThreadedResolver 会把 DNS 丢线程池,aiodns 3.2.0 + pycares 4.4.0 能快一些,但装 aiodns 时如果系统没有编译环境会报错;Ubuntu 下先 apt install -y libc-ares-dev 再 pip install aiodns。 Windows 上跑 asyncio 还有事件循环选择:Python 3.8 以后默认 ProactorEventLoop,某些库和子进程、信号处理会出问题,可以试 WindowsSelectorEventLoopPolicy(),但 Selector 不支持子进程,别乱切。
错误处理对比: 线程池里一个任务失败,不影响其他任务,Future 会把异常留在结果里,你 as_completed 时接住就行。 asyncio.gather 默认遇到第一个异常会立刻抛,但其他任务不一定停,需要 return_exceptions=True 收集,或者用 Python 3.11 的 TaskGroup。 TaskGroup 是结构化并发,一个子任务失败会取消同组其他任务,适合要么全成功要么全失败的批量写库,不适合 2000 个页面能抓多少算多少。 超时也是,Python 3.11 可以用 async with asyncio.timeout(5):,3.10 以前只能 wait_for,写法不一样。 调试异步堆栈很痛苦。线程池报错栈能直接看到哪个函数第几行,asyncio 经常给你一串 Task exception was never retrieved。开 PYTHONASYNCIODEBUG=1 和 loop.set_debug(True) 会清楚些,但吞吐会掉,我测下来大概慢 2 到 3 倍,别在生产开。
内存和取消: 2000 请求、100 并发时,aiohttp 进程 RSS 约 58MB,32 线程 requests 约 92MB,8 进程 ProcessPoolExecutor 峰值 286MB。 如果并发拉到 1000,aiohttp 内存也不是线性涨,但文件描述符会顶到 ulimit -n。WSL2 默认 1024,Ubuntu 很多也是 1024,跑 1000 并发前先 ulimit -n 65535,不然报 Too many open files。 线程池的取消很粗暴,Future.cancel() 只能取消还没跑的任务,已经在 requests.get 里的取消不了。 asyncio 的 Task.cancel() 会往协程里抛 CancelledError,能在 await 点退出,配合 asyncio.timeout 更适合做超过 5 秒立刻砍掉。 但取消不是免费午餐,aiohttp 连接要正确释放,否则连接池会泄漏;我见过有人 cancel 后没 await session.close(),跑 10 分钟就卡死。
CPU 密集别用 asyncio 也别用线程: 我把接口延迟换成 CPU 计算,比如 sha256 循环 200 万次,8 进程 ProcessPoolExecutor 比单线程快 5.8 倍,线程池反而比单线程慢 12%,因为 GIL 切换。 但多进程在 Windows 上是 spawn,启动一个进程约 0.08 到 0.15 秒,Linux fork 约 0.01 到 0.03 秒;小任务别开进程池,会被启动成本吃掉。 要传大对象给进程,pickle 序列化可能比计算还久。2000 个 1MB 的 bytes 传进 ProcessPoolExecutor,光序列化就 1.7 秒左右。 所以 CPU 密集优先考虑 NumPy、C 扩展、Cython、Numba,或者用 multiprocessing 的共享内存 SharedMemory,别硬拿 ProcessPoolExecutor 传大 DataFrame。
我的结论可能不太合群: 第一,如果你只是抓几百到几千个普通网页,requests + ThreadPoolExecutor(max_workers=32) 是性价比最高的方案,代码改动小,调试容易,6 秒和 4.8 秒的差距很多时候不值得重写。 第二,asyncio 的价值不是快 20%,而是我能控制 1000 个连接、每个连接 5 秒超时、按域名限速 5 QPS、失败立刻取消。一旦你需要这些,线程池会写得很难受。 第三,Python 3.12 比 3.11 在 asyncio 上有提升,我这次同参数快约 5.6%,但别为了这 5% 冒险升级生产环境,先看依赖库支不支持。 第四,别迷信 uvloop。WSL2 下它提升约 10.6%,但 Windows 原生装不了,很多云函数环境也不让装;默认事件循环先跑通再说。 第五,并发数不是越高越好。我把 aiohttp 从 100 拉到 200,耗时只从 4.82 秒到 4.63 秒,但错误率从 0 涨到 0.35%,本地服务都这样,公网只会更明显。
如果你要抄作业: 小规模抓取:requests 2.32.0 + ThreadPoolExecutor(max_workers=32),timeout=(1.5,5),重试 2 次,as_completed 收结果。 大规模异步:Python 3.11.9 以上 + aiohttp 3.9.5,TCPConnector(limit=200, limit_per_host=20, ttl_dns_cache=300),Semaphore 按域名控制,不要一个 Semaphore 管所有。 需要 HTTP/2:httpx 0.27.0 AsyncClient,http2=True,但记得装 h2 依赖,limits 设 max_connections=100, max_keepalive_connections=50。 CPU 密集:multiprocessing 或 ProcessPoolExecutor,进程数从 os.cpu_count() 开始,别超过物理核心太多。 最后一句,不是金句,是我踩坑后的实话:先测量,再重写。我这次如果没测,也会以为 asyncio 能把 6 秒干到 1 秒,实际只快了 1.49 秒。