Python 多线程为什么跑不快?asyncio 入门指南:异步并发从零上手
你有没有写过这样的代码:给 10 个请求各开一个线程,结果耗时和单线程差不多,CPU 还飙到 100%?或者线程开到几百个,程序直接卡死不动。
这不是你代码写错了,而是用错了并发模型。Python 的多线程在 IO 密集场景下,远没有你想象中那么"多线程"。
这篇文章解决三件事:Python 多线程为什么跑不快、asyncio 凭什么做到高并发、以及我踩过的 5 个异步坑。看完你能直接把爬虫或批量接口调用改写成异步版本。
一、为什么 Python 多线程"跑不快"
1.1 GIL:同一时刻只有一个线程在跑字节码
CPython 有一个全局解释器锁(GIL)。它保证同一时刻只有一个线程在执行 Python 字节码:
- CPU 密集任务(计算、图像处理、加密):多线程不能利用多核,还会因线程切换增加开销,甚至比单线程更慢;
- IO 密集任务 (网络请求、文件读写、数据库查询):线程等待 IO 时会释放 GIL,所以多线程有一定作用------但每个线程占用独立的栈内存,开到几百个时调度开销会拖垮程序。
python
import threading
import time
def cpu_task():
s = 0
for i in range(10_000_000):
s += i
return s
start = time.perf_counter()
for _ in range(4):
cpu_task()
print("单线程:", round(time.perf_counter() - start, 2), "s")
start = time.perf_counter()
ts = [threading.Thread(target=cpu_task) for _ in range(4)]
[t.start() for t in ts]
[t.join() for t in ts]
print("4 线程:", round(time.perf_counter() - start, 2), "s")
在多核机器上跑,你会发现4 线程并没有快 4 倍,通常只是持平甚至更慢。这就是 GIL 的直接结果。
1.2 asyncio 用更低的成本解决"等待"
asyncio 的本质是:单线程 + 事件循环,在等待 IO 的时候不干等,转头去执行别的协程。
它不是并行(parallel),而是并发(concurrency):
| 维度 | 线程 | 协程 |
|---|---|---|
| 内存占用 | ~8MB / 个 | 几 KB / 个 |
| 切换成本 | 操作系统内核态切换 | 用户态,纯 Python 调度 |
| 并发规模 | 几百个就吃力 | 几千上万个轻松 |
| CPU 密集 | 受 GIL 限制 | 同样受 GIL 限制 |
关键认知:asyncio 买的是"等待时的效率",不是"计算时的速度"。 这决定了它的适用边界(第五节)。
二、环境准备:Python 3.7+ 自带,无需安装
asyncio 是 Python 标准库 ,3.7 及以上已随解释器安装,不需要 pip install asyncio(装这个包反而会报错)。
bash
# 查看 Python 版本,建议 3.7+
python --version
# 实战部分用到异步 HTTP 客户端 aiohttp,单独装它即可
pip install aiohttp
经验提示:3.7 引入
asyncio.run()让入口极简;3.11 加入更安全的asyncio.TaskGroup。本文示例以 3.8+ 为准,均可直接运行。
三、四个必须分清的核心概念
- 协程(Coroutine) :
async def定义的函数,调用它不会立即执行 ,只返回一个协程对象;必须被await或交给事件循环才真正运行。 - 事件循环(Event Loop) :异步程序的"调度中枢",不断检查哪个协程可以继续执行。
asyncio.run()帮你隐式创建并管理它。 - await :只能在
async函数内使用,作用是"暂停当前协程,等后面的可等待对象完成",不会阻塞整个线程。 - 任务(Task) :把协程包装成可由事件循环调度的对象,
asyncio.create_task()创建,让多个协程并发跑。
一句话记忆:协程是剧本,事件循环是导演,await 是"轮到别人先演",Task 是同时开拍的多条线。
python
import asyncio
async def hello():
print("① 开始执行")
await asyncio.sleep(1) # 主动让出控制权,模拟 IO 等待
print("② 1 秒后继续执行")
coro = hello() # 注意:这里什么都没打印!只返回协程对象
print(type(coro)) # <class 'coroutine'>
asyncio.run(coro) # 这一行才真正驱动它执行
await 后面只能跟三类 awaitable 对象:协程、asyncio.Task、asyncio.Future。
python
async def main():
# ❌ 错误:time.sleep 是阻塞的,不是 awaitable
# await time.sleep(1)
# ✅ 正确:asyncio.sleep 是可等待的
await asyncio.sleep(1)
asyncio.run(main())
四、并发实战:让多个任务真正同时跑
create_task 把协程挂到事件循环上立即调度,gather 并发收集结果,总耗时取决于最慢的那个,而不是累加。
python
import asyncio
async def job(name, seconds):
print(f"{name} 开始,需要 {seconds}s")
await asyncio.sleep(seconds)
print(f"{name} 完成")
return name
async def main():
t1 = asyncio.create_task(job("任务A", 2))
t2 = asyncio.create_task(job("任务B", 1))
t3 = asyncio.create_task(job("任务C", 3))
# 总耗时 ≈ 3s,而非 2+1+3=6s
results = await asyncio.gather(t1, t2, t3)
print("全部完成:", results)
asyncio.run(main())
高频 API 速查:
python
# 并发执行,按传入顺序返回结果
results = await asyncio.gather(coro1(), coro2(), coro3())
# 立即调度,不阻塞当前协程(发射后不管)
task = asyncio.create_task(coro1())
result = await task
# 超时控制:超过 3 秒抛 TimeoutError
try:
await asyncio.wait_for(slow_coro(), timeout=3)
except asyncio.TimeoutError:
print("超时了")
# 限制最大并发数,防止把对方服务器打挂
sem = asyncio.Semaphore(10)
async def limited_fetch(session, url):
async with sem:
return await fetch(session, url)
进阶建议:Python 3.11+ 推荐用
async with asyncio.TaskGroup() as tg:替代裸gather,某个任务失败时能自动取消其他任务,更安全。
4.1 真实场景:并发抓取多个网站状态码
python
import asyncio
import aiohttp
async def fetch_status(session, url):
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
return url, resp.status
except Exception as e:
return url, f"错误: {type(e).__name__}"
async def main():
urls = [
"https://www.baidu.com",
"https://www.python.org",
"https://www.github.com",
]
async with aiohttp.ClientSession() as session:
tasks = [fetch_status(session, u) for u in urls]
results = await asyncio.gather(*tasks)
for url, status in results:
print(f"{url} -> {status}")
if __name__ == "__main__":
asyncio.run(main())
aiohttp.ClientSession 是异步上下文管理器,必须用 async with 管理,避免连接泄漏。
五、适用边界:什么时候该用,什么时候别用
这是我最想强调的一节------用错场景,asyncio 不仅没收益,还会把代码变复杂。
✅ 该用 asyncio
- 网络 IO 密集:爬虫、批量调用 HTTP API、WebSocket 长连接;
- 高并发长连接:聊天服务、实时推送,单线程扛几千连接;
- 需要精细控制并发度 :配合
Semaphore做限流。
❌ 别用 asyncio
- CPU 密集计算 :GIL 仍在,asyncio 帮不上忙,改用
ProcessPoolExecutor或多进程; - 简单脚本:只发 2、3 个请求,同步写法更清晰,别为了"显得高级"上异步;
- 生态全是同步库 :重度依赖不支持异步的数据库驱动 / SDK 时,强行异步会到处
run_in_executor,得不偿失。
CPU 密集任务的正确做法------把计算丢进进程池,绕开 GIL:
python
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
return sum(i * i for i in range(n))
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, cpu_heavy, 10_000_000)
print(result)
六、我踩过的 5 个坑
坑 1:在协程里用 requests,整条事件循环被卡死
requests 是同步库。在协程里调用会阻塞整个事件循环,并发瞬间退化成串行。
python
# ❌ 灾难写法:requests 会阻塞事件循环
async def bad_fetch(url):
return requests.get(url).status_code
# ✅ 方案A:换异步库 aiohttp
async def good_fetch(session, url):
async with session.get(url) as r:
return r.status
# ✅ 方案B:不得不用同步库时,丢进线程池(3.9+ 可用 asyncio.to_thread)
async def legacy_fetch(url):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: requests.get(url).status_code)
坑 2:忘了 await,协程静默跳过
python
async def main():
fetch(session, url) # ❌ 没有 await:不报错、不执行、静默跳过
await fetch(session, url) # ✅
这是最隐蔽的坑。开启 python -W error::RuntimeWarning 可以让"协程从未被等待"直接抛出警告。
坑 3:在 Jupyter 里调 asyncio.run() 报"循环已在运行"
Jupyter 底层(IPython)已经在跑事件循环了。在 cell 里直接 await 即可 ,不要再 run():
python
result = await main_async() # ✅ Jupyter 里直接 await
坑 4:create_task 后没保存引用,任务被垃圾回收
python
# ❌ 任务可能在完成前被 GC 回收
asyncio.create_task(some_coro())
# ✅ 保留强引用
_background_tasks = set()
task = asyncio.create_task(some_coro())
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
坑 5:把 CPU 密集函数放进协程,以为能加速
asyncio 不解决计算问题。把计算函数丢进协程,结果只会和单线程一样慢,还多一层复杂度------用进程池。
七、实战:异步批量抓取 + 限流 + 超时
把上面的知识串成一个能直接用的小工具(50 个请求、并发上限 10):
python
import asyncio
import aiohttp
from typing import Iterable
async def fetch_one(session, url, sem, timeout=10):
async with sem:
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=timeout)
) as r:
return url, r.status
except Exception as e:
return url, f"ERROR: {type(e).__name__}"
async def fetch_all(urls: Iterable[str], limit: int = 10):
sem = asyncio.Semaphore(limit)
async with aiohttp.ClientSession() as session:
tasks = [fetch_one(session, u, sem) for u in urls]
return await asyncio.gather(*tasks)
if __name__ == "__main__":
urls = ["https://httpbin.org/delay/1" for _ in range(50)]
results = asyncio.run(fetch_all(urls, limit=10))
ok = sum(1 for _, s in results if s == 200)
print(f"完成 {len(results)} 个,成功 {ok} 个")
50 个请求、每个延迟 1 秒、并发上限 10:理论耗时约 5 秒;同步写法需要 50 秒。
八、一页纸总结
- 多线程跑不快的根因:GIL 限制 CPU 并行 + 线程内存/调度成本高;
- asyncio 的本质:单线程 + 事件循环,等待 IO 时切换任务,开销极低;
- 四个概念 :协程(
async def)、事件循环(调度中枢)、await(让出)、Task(并发执行); - 记住三件事 :
async def定义了不会立即执行、await才是驱动、asyncio.run()是入口; - 适用边界:IO 密集用它,CPU 密集用进程池,简单脚本别上异步;
- 最大坑 :协程里调同步库(
requests、time.sleep)会阻塞整个循环,要么换异步库,要么丢线程池。
如果你只记住一句:asyncio 买的是"等待时的效率",不是"计算时的速度"。
你第一次用 asyncio 时踩的是哪个坑?是忘了 await,还是被 requests 卡死过整个循环?评论区聊聊,我看到都会回。
如果这篇帮你把异步的概念理顺了,点个赞收藏一下------下一期打算写「asyncio 和多线程到底怎么选」的实测对比,想看的话留言告诉我。