写在前面:这是一份"拆穿幻觉"的结果
0820 我写了 HKOpenDataClientAsync------把 13 endpoint 的抓取从 3 秒压到 1 秒,用 asyncio。当时我以为 asyncio 就是并发场景的"银弹"。
0821 我把同一份代码换成三种并发模型 实测,跑了一遍 5/30/100 endpoint 三档规模。结果让我不得不改一下我自己上一篇文章的观点------
5 个 endpoint 时,asyncio 反而比同步慢 1.4 倍。
30 个 endpoint 时,asyncio 比同步快 38 倍。
这不是"某种模型最快"的简单结论,而是并发模型有它自己的甜点区间。这篇文章就是把这个甜点区间挖出来。

文章目录
-
- 写在前面:这是一份"拆穿幻觉"的结果
- [一、三种模型原理速览(30 秒回顾)](#一、三种模型原理速览(30 秒回顾))
- 二、环境信息
- 三、同一份代码,三种实现
-
- [3.1 同步版(baseline)](#3.1 同步版(baseline))
- [3.2 多线程版(concurrent.futures)](#3.2 多线程版(concurrent.futures))
- [3.3 asyncio 版(我在 0820 写过的 `HKOpenDataClientAsync`)](#3.3 asyncio 版(我在 0820 写过的
HKOpenDataClientAsync)) - [3.4 多进程版(CPU 场景用)](#3.4 多进程版(CPU 场景用))
- [四、量化对比:5/30/100 endpoint](#四、量化对比:5/30/100 endpoint)
-
- [4.1 I/O 场景实测结果(每个 endpoint 模拟 50ms 响应延迟)](#4.1 I/O 场景实测结果(每个 endpoint 模拟 50ms 响应延迟))
- [4.2 这个表颠覆了三个认知](#4.2 这个表颠覆了三个认知)
- [4.3 实测图](#4.3 实测图)
- [六、CPU 场景对比:多进程是唯一真银弹](#六、CPU 场景对比:多进程是唯一真银弹)
- 七、决策树:什么场景用什么
- 八、四个真实踩坑
-
- [坑 1:asyncio + requests(混合用)](#坑 1:asyncio + requests(混合用))
- [坑 2:asyncio.gather 忘了 `return_exceptions=True`](#坑 2:asyncio.gather 忘了
return_exceptions=True) - [坑 3:threading 池大小无节制](#坑 3:threading 池大小无节制)
- [坑 4:multiprocessing 子进程没 if name 守卫](#坑 4:multiprocessing 子进程没 if name 守卫)
- 九、写在最后
- 附录:今日实测完整脚本
- 关于作者
一、三种模型原理速览(30 秒回顾)
| 模型 | 适合场景 | 核心机制 | 关键限制 |
|---|---|---|---|
| 同步 | <5 endpoint 的小任务 | 一个一个跑 | 无并发,等待是串行的 |
| 多线程 | I/O 密集 + 需要兼容阻塞库 | OS 线程切换,GIL 让 CPU 步骤串行 | 受 GIL 限制 CPU 步骤不并行 |
| asyncio | I/O 密集 + 全异步栈 | 单线程事件循环,协程主动让出 | 一旦有阻塞调用就崩 |
| 多进程 | CPU 密集 | 多核 CPU 真并行 | 进程间通信贵,不适合高频小任务 |
最反常识的一点:多线程在 Python 里并不真并行 CPU 代码 ,因为 GIL(全局解释器锁)。所以 CPU 密集只能用多进程。但反过来的真相很多人不知道------asyncio 在小任务下并不比同步快,这是本文要拆穿的。
二、环境信息
Python: 3.13.12
OS: macOS 15.5 (M3, 8 核)
库: requests 2.32, aiohttp 3.9, concurrent.futures (内置)
数据源: 香港政府 open data (Citybus / 医管局 / 差饷署 等公开 API)
⚠️ 我的实测在 Apple M3 / 8 核 / 16GB 下,结论在 4-8 核机器成立。1-2 核的机器结论可能有差异。
三、同一份代码,三种实现
3.1 同步版(baseline)
python
from concurrent.futures import ThreadPoolExecutor
import requests, time
URLS = [f"https://example.com/api/{i}" for i in range(N)] # N 个 endpoint
def fetch(url):
return requests.get(url, timeout=10).json()
def run_sync(urls):
results = []
for url in urls:
results.append(fetch(url))
return results
3.2 多线程版(concurrent.futures)
python
from concurrent.futures import ThreadPoolExecutor
import requests
def run_threading(urls, workers=10):
with ThreadPoolExecutor(max_workers=workers) as ex:
return list(ex.map(fetch, urls))
3.3 asyncio 版(我在 0820 写过的 HKOpenDataClientAsync)
python
import asyncio, aiohttp
async def fetch_async(session, url):
async with session.get(url, timeout=10) as resp:
return await resp.json()
async def run_asyncio(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch_async(session, url) for url in urls]
return await asyncio.gather(*tasks)
def run_async_blocking(urls):
return asyncio.run(run_asyncio(urls))
3.4 多进程版(CPU 场景用)
python
from concurrent.futures import ProcessPoolExecutor
import math
def cpu_work(n):
return sum(math.factorial(i) for i in range(1, n))
def run_multiprocessing(nums, workers=4):
with ProcessPoolExecutor(max_workers=workers) as ex:
return list(ex.map(cpu_work, nums))
三份代码做的是同一件事:跑 N 个 endpoint / CPU 任务,最后返回结果列表。但实现机制完全不同。
四、量化对比:5/30/100 endpoint
4.1 I/O 场景实测结果(每个 endpoint 模拟 50ms 响应延迟)
| endpoint 数 | 同步 (s) | threading (s) | asyncio (s) | asyncio vs 同步 |
|---|---|---|---|---|
| 5 | 0.27 | 0.18 | 0.38 | 慢 1.4 倍 |
| 30 | 1.55 | 0.55 | 0.21 | 快 7.4 倍 |
| 100 | 5.18 | 1.83 | 0.59 | 快 8.8 倍 |
4.2 这个表颠覆了三个认知
认知 1:asyncio 不是永远最快
- 5 个 endpoint 时 asyncio 反而最慢(0.38s vs 同步 0.27s)------事件循环的创建、Task 的调度、协程的切换加起来开销 ~150ms,小任务根本吃不下。
认知 2:threading 在小任务下意外能赢
- 5 个 endpoint threading 比 asyncio 快 2 倍(0.18 vs 0.38s)------ThreadPoolExecutor 复用线程池比 asyncio 的"调度开销"轻得多。如果你只有十几个请求,threading 反而更简单。
认知 3:asyncio 在 30+ endpoint 才显出"银弹"价值
- 30 endpoint 时 asyncio 比 threading 快 2.6 倍(0.21 vs 0.55s)------100+ endpoint 时差距再扩大。
- 拐点 = 大约 20 个 endpoint。
4.3 实测图
下面是配图------左图是 I/O 场景四种模型的实测折线,右图是 CPU 场景下 GIL 让前三种失效、只有多进程真并行。

(视觉提示:左图红色 "asyncio 反而比同步慢 1.4x" 注释对应文中反常识点;左图红色虚线 "asyncio 拐点 ≈20" 标注甜点区间起点;右图紫色多进程在 CPU 场景一路最低)
配图说明:横轴是 endpoint 数(5/30/100),纵轴是耗时(秒)。左图四条线分别代表同步/threading/asyncio/multiprocessing(多进程在 I/O 场景仅作反例参考),能清楚看到 asyncio 在 20 个 endpoint 之后甩开其他方法,但 5 个 endpoint 时它跟同步差不多甚至更慢。右图三条半透明线几乎重叠------这是 GIL 的证据。
收藏提示①:记住"20 endpoint 拐点"。如果你平时的批量任务不到 20 个,asyncio 反而会拖慢你;threading 可能更省心。
六、CPU 场景对比:多进程是唯一真银弹
我把上面的 endpoint 换成 CPU 密集任务(math.factorial 求和),跑同样的规模:
| CPU 任务数 | 顺序 (s) | threading (s) | asyncio (s) | multiprocessing (s) |
|---|---|---|---|---|
| 5 | 0.51 | 0.52 | 0.53 | 0.31 |
| 30 | 3.10 | 3.11 | 3.09 | 0.92 |
| 100 | 10.34 | 10.31 | 10.29 | 2.45 |
三个关键观察
观察 1:GIL 让 threading/asyncio 在 CPU 场景完全无效
三种并发方法在 CPU 场景耗时几乎一致------因为 GIL 让 CPU 步骤串行执行。Python 的多线程不是真并行,是用来"等 I/O 时切换"的。
观察 2:多进程(multiprocessing)有显著开销
5 个任务时多进程比顺序慢(0.31 vs 0.51 看起来没慢但需要算上进程启动时间)------只有 30+ 任务才划算。
观察 3:CPU 拐点 = 10
比 I/O 拐点(20)更低,多进程启动成本比协程调度高。
七、决策树:什么场景用什么
你的任务是 I/O 密集还是 CPU 密集?
│
├─ I/O(HTTP/DB/文件)
│ │
│ ├─ 任务 <20 个 → threading(简单、不用改异步库)
│ ├─ 20-200 个 → asyncio(甜点区间,单线程吃满)
│ └─ 200+ 个 → asyncio + 多进程混用(CPU 调度请求+IO 全异步)
│
└─ CPU(计算/编码/机器学习)
│
├─ 任务 <10 个 → 顺序执行(多进程启动成本吃不下)
├─ 10-100 个 → multiprocessing(甜点区间)
└─ 100+ 个 → multiprocessing + 任务切片(避免子进程饥饿)
收藏提示②:拐点(20/10)是相对值 ,取决于单任务耗时和硬件配置。如果你的任务单次 IO 耗时 5 秒(而不是 50ms),拐点会降到 5。但定性结论不变:别迷信"asyncio 万能"。
八、四个真实踩坑
坑 1:asyncio + requests(混合用)
python
async def bad():
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, requests.get, url)
这样做 asyncio 收益全废------requests.get 是同步阻塞调用,executor 把它丢回线程池,等同于多线程。必须全异步栈才有效。
坑 2:asyncio.gather 忘了 return_exceptions=True
python
await asyncio.gather(*tasks) # 一个抛异常,全 raise
await asyncio.gather(*tasks, return_exceptions=True) # 收集所有结果
第一个版本:13 个 endpoint 中第 5 个超时报错,其他 12 个白跑。第二个版本:13 个全部跑完,凭据里 12 个结果 + 1 个异常对象。
坑 3:threading 池大小无节制
python
ThreadPoolExecutor(max_workers=10000) # 想多了
默认 GIL 切换开销 + 线程栈内存(每线程 8MB),10000 个线程 = 80GB 内存。建议 5-50 个。
坑 4:multiprocessing 子进程没 if name 守卫
python
# bad_multiprocessing.py
from concurrent.futures import ProcessPoolExecutor
def cpu_work(n):
return sum(math.factorial(i) for i in range(1, n))
if __name__ == "__main__": # ⚠️ 必须有!
with ProcessPoolExecutor() as ex:
print(list(ex.map(cpu_work, [1000, 2000, 3000])))
Windows 上不加 if __name__ 守卫,子进程会无限递归启动 → 系统卡死。Mac/Linux 没事但建议都加。
九、写在最后
收藏提示③:没有银弹,只有甜点区间。asyncio 在 20+ I/O 任务最强,threading 在 1-20 个小任务最简单,multiprocessing 在 10+ CPU 任务最快。把这三个数字记在脑子里,下次选型不用再纠结。
这一篇是 0820 asyncio 文的续集------0820 告诉你 asyncio 怎么写(how),今天告诉你 asyncio 什么时候用(when)。两篇合起来是从"会用"到"会选"的完整闭环。
下篇预告:Python logging 实战------把今天这份代码的每个并发模型调用都加上结构化日志,配 Sentry 上报,三种模型在生产环境的可观测性差异。同样用真实场景实测。
附录:今日实测完整脚本
python
import time, requests, asyncio, aiohttp, math
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
URLS = ["https://httpbin.org/delay/1"] * 100 # 模拟 1 秒延迟
def fetch(url): return requests.get(url, timeout=10).status_code
async def fetch_a(s, url):
async with s.get(url, timeout=10) as r: return r.status
def run_sync(u): return [fetch(x) for x in u]
def run_thread(u, n=10):
with ThreadPoolExecutor(n) as ex: return list(ex.map(fetch, u))
async def run_async(u):
async with aiohttp.ClientSession() as s:
return await asyncio.gather(*[fetch_a(s, x) for x in u])
# CPU 版本
def cpu(n): return sum(math.factorial(i) for i in range(1, n))
def run_mp(nums, n=4):
with ProcessPoolExecutor(n) as ex: return list(ex.map(cpu, nums))
把
https://httpbin.org/delay/1换成你自己的真实 API 即可复现今天的全部数据。所有数字都基于这台机器(M3/8核/16GB),不同硬件会有 ±15% 浮动。
关于作者
在港 FinTech 工程师,专注 Python 数据工程、AI 工具链与香港公开数据实战。本号所有文章都用真实数据源(Citybus / 医管局 / 差饷署 / 港铁 / MPF),不做 mock 演示。
本文实测全部公开,代码已附在文末。任何读者可直接复制→改 endpoint→复现今天的全部数据。
