上周我接了个私活,需求不复杂:写个爬虫批量抓取几百个页面,然后做数据清洗入库。同步写法跑了一版,慢到我怀疑人生------500 个请求串行跑了将近 8 分钟。果断上 asyncio 改异步。
然后就开始了长达一周的踩坑之旅。
说实话,asyncio 这东西,文档看着挺简单,async def 加 await 就完了嘛。但真写起来,各种反直觉的行为能让你怀疑自己是不是学了假 Python。这篇文章就是我这一周踩过的真实坑位记录,都是血泪教训,希望能帮到正在入坑的朋友。
先说结论
| 坑位 | 症状 | 原因 | 解决耗时 |
|---|---|---|---|
| 协程没被 await | 函数调用了但没执行 | 忘了 await,只拿到协程对象 | 10 分钟 |
| 同步代码阻塞事件循环 | 整个程序卡住 | 在 async 函数里调了 time.sleep |
2 小时 |
asyncio.run() 套娃 |
报错 RuntimeError | 在已运行的事件循环里再调 asyncio.run() |
半天 |
gather 异常吞没 |
部分任务静默失败 | 默认不会抛出其他任务的异常 | 1 天 |
| aiohttp session 没关 | ResourceWarning 刷屏 | 没用 async with 管理生命周期 |
30 分钟 |
| 并发太猛被封 IP | 429 Too Many Requests | 没做并发控制 | 大半天 |
坑 1:协程没 await,代码静悄悄地不执行
新手第一个会踩的坑,我也没逃过。
python
import asyncio
async def fetch_data():
print("开始请求...")
await asyncio.sleep(1)
print("请求完成")
return {"data": "hello"}
async def main():
# ❌ 错误写法:忘了 await
result = fetch_data()
print(f"结果: {result}")
asyncio.run(main())
运行结果:
lua
结果: <coroutine object fetch_data at 0x...>
RuntimeWarning: coroutine 'fetch_data' was never awaited
fetch_data 里的两个 print 一个都没执行。因为 fetch_data() 只是创建了一个协程对象,并没有真正运行它。
python
async def main():
# ✅ 正确写法
result = await fetch_data()
print(f"结果: {result}")
这个坑虽然简单,但在复杂项目里特别隐蔽。比如你在某个回调里调用了协程函数但忘了 await,那段逻辑就直接被跳过了,还不报错(只有个 Warning),debug 的时候你会疯。
坑 2:同步阻塞炸掉整个事件循环
这个坑是真让我排查了两小时。
python
import asyncio
import time
async def task_a():
print(f"[{time.strftime('%H:%M:%S')}] Task A 开始")
# ❌ 用了同步的 time.sleep
time.sleep(3)
print(f"[{time.strftime('%H:%M:%S')}] Task A 完成")
async def task_b():
print(f"[{time.strftime('%H:%M:%S')}] Task B 开始")
await asyncio.sleep(1)
print(f"[{time.strftime('%H:%M:%S')}] Task B 完成")
async def main():
await asyncio.gather(task_a(), task_b())
asyncio.run(main())
你猜输出什么?
css
[14:00:00] Task A 开始
[14:00:03] Task A 完成 # 注意:B 在 A 完成后才开始!
[14:00:03] Task B 开始
[14:00:04] Task B 完成
time.sleep(3) 直接把事件循环阻塞了,Task B 根本没法并发。asyncio 是单线程的协作式并发,你用同步阻塞调用,就相当于一个人霸占了整条路,别人谁都过不去。
正确做法:
python
async def task_a():
print(f"[{time.strftime('%H:%M:%S')}] Task A 开始")
# ✅ 用异步的 sleep
await asyncio.sleep(3)
print(f"[{time.strftime('%H:%M:%S')}] Task A 完成")
但现实中不只是 sleep 的问题。你用了 requests 库发 HTTP 请求,用了 open() 读大文件,用了某个不支持异步的数据库驱动------这些全是同步阻塞操作,会把你的事件循环卡得死死的。
如果实在要用同步库,用 run_in_executor 扔到线程池:
python
import asyncio
import requests
async def fetch_sync_api(url):
loop = asyncio.get_event_loop()
# 把同步的 requests.get 扔到线程池执行
response = await loop.run_in_executor(None, requests.get, url)
return response.text
坑 3:asyncio.run() 嵌套调用直接炸
这个坑我是在 Jupyter Notebook 里踩的。
python
import asyncio
async def inner():
return "hello"
async def outer():
# ❌ 在协程里再调 asyncio.run()
result = asyncio.run(inner())
return result
asyncio.run(outer())
直接报:RuntimeError: asyncio.run() cannot be called from a running event loop
原因很简单:asyncio.run() 会创建一个新的事件循环,但你已经在一个事件循环里了。一山不容二虎,一个线程不容两个事件循环。
Jupyter Notebook 里更坑,因为 Notebook 自带一个运行中的事件循环,你在 cell 里直接写 asyncio.run() 就会炸。
解决方案:
python
# 在协程内部,直接 await 就行了,不要再 run
async def outer():
result = await inner() # ✅
return result
# 在 Jupyter Notebook 里:
# 方案一:直接 await(Jupyter 支持顶层 await)
result = await inner()
# 方案二:用 nest_asyncio(不太优雅但管用)
import nest_asyncio
nest_asyncio.apply()
asyncio.run(outer())
坑 4:asyncio.gather 的异常处理黑洞
这个坑害我丢了一天数据,真的痛。
python
import asyncio
async def task_ok():
await asyncio.sleep(0.5)
return "成功"
async def task_fail():
await asyncio.sleep(0.1)
raise ValueError("出错了!")
async def task_also_ok():
await asyncio.sleep(0.3)
return "也成功了"
async def main():
# ❌ 默认行为:一个任务抛异常,其他任务的结果就拿不到了
try:
results = await asyncio.gather(
task_ok(), task_fail(), task_also_ok()
)
except ValueError as e:
print(f"捕获到异常: {e}")
# 但是 task_ok 和 task_also_ok 的结果呢?没了。
asyncio.run(main())
gather 在默认行为下,遇到第一个异常就会把它抛出来,其他任务的结果你拿不到(虽然它们其实已经执行了或者还在执行)。
加上 return_exceptions=True:
python
async def main():
# ✅ 异常作为返回值,不会中断其他任务
results = await asyncio.gather(
task_ok(), task_fail(), task_also_ok(),
return_exceptions=True
)
for i, result in enumerate(results):
if isinstance(result, Exception):
print(f"任务 {i} 失败: {result}")
else:
print(f"任务 {i} 成功: {result}")
asyncio.run(main())
输出:
任务 0 成功: 成功
任务 1 失败: 出错了!
任务 2 成功: 也成功了
2026 年了,更推荐用 TaskGroup(Python 3.11+ 引入的):
python
async def main():
try:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(task_ok())
t2 = tg.create_task(task_fail())
t3 = tg.create_task(task_also_ok())
except* ValueError as eg:
for exc in eg.exceptions:
print(f"捕获: {exc}")
TaskGroup 配合 except*(ExceptionGroup 语法)用起来更清晰,异常处理逻辑更可控。
坑 5:并发数不控制,直接被封
最后一个大坑。我一开始写爬虫的时候,500 个请求直接 gather 一把梭:
python
# ❌ 500 个请求同时发出去
tasks = [fetch(url) for url in urls] # 500 个
results = await asyncio.gather(*tasks)
结果瞬间收到一堆 429,IP 还被临时封了。
用 asyncio.Semaphore 控制并发数:
python
import asyncio
import aiohttp
async def fetch(session, url, semaphore):
async with semaphore: # 信号量控制并发
async with session.get(url) as response:
return await response.text()
async def main():
semaphore = asyncio.Semaphore(10) # 最多同时 10 个请求
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url, semaphore) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
success = sum(1 for r in results if not isinstance(r, Exception))
failed = sum(1 for r in results if isinstance(r, Exception))
print(f"成功: {success}, 失败: {failed}")
asyncio.run(main())
顺带一提:aiohttp.ClientSession 一定要用 async with 来管理。手动创建但忘了 close,退出时会收到一堆 ResourceWarning: Unclosed client session,不影响功能,但看着烦。
我总结的 asyncio 心智模型
学了一周,我觉得理解 asyncio 的关键就一句话:它是单线程的协作式并发,所有协程共享一个线程,靠 await 主动让出执行权。
| 对比维度 | 多线程 threading | 异步 asyncio |
|---|---|---|
| 并发模型 | 抢占式 | 协作式 |
| 切换时机 | OS 随时切换 | 遇到 await 才切换 |
| 线程数 | 多个 | 1 个 |
| 竞态条件 | 容易出现 | 几乎不会 |
| 阻塞影响 | 只阻塞当前线程 | 阻塞整个事件循环 |
| 适用场景 | CPU 密集 + IO | 高并发 IO |
| 调试难度 | 高(竞态问题) | 中(异步思维) |
代码 IO 密集(网络请求、数据库查询、文件读写),asyncio 能给你显著的性能提升。我那个爬虫改完之后,500 个请求从 8 分钟降到了 40 秒,提升了 12 倍。
CPU 密集型的(大量计算、图像处理),asyncio 帮不了你,老老实实用 multiprocessing。
小结
回头看这一周,asyncio 的核心概念并不多,难的是那些反直觉的行为和隐蔽的 bug。几条实操建议:
- 先搞清楚事件循环的运行机制,不要急着写代码
- 检查所有库是否支持异步,不支持的用
run_in_executor包一层 gather一定要加return_exceptions=True,不然数据丢了你都不知道- 并发数必须控制,Semaphore 是你的好朋友
- Python 3.11+ 尽量用
TaskGroup替代gather,异常处理更优雅
踩完这些坑之后,asyncio 用起来其实挺顺手的。起码比 threading 那套锁来锁去的操作省心多了------毕竟单线程,不用操心竞态条件。
有问题欢迎评论区交流,asyncio + 数据库连接池的实践后面可能也会写一篇。