Python异步编程从入门到不懵:asyncio实战踩坑7连发
搞了两年Python异步编程,从一脸懵到勉强能用,踩过的坑能写本书。今天把最常见的7个坑整理出来,每个都是血泪教训,希望能帮你少走弯路。
我为什么非要学异步?
去年接了个需求:同时抓取200个API的数据,同步代码跑完要6分钟,老板说能不能快一点。我一查,每个请求等待时间就2秒,CPU全在发呆。
换成 asyncio + aiohttp,6分钟变15秒。当时觉得异步真香,结果后面踩的坑比省的时间还多。
所以这篇文章不是入门教程,而是一个"过来人"的踩坑清单。
坑1:在async函数里用了同步阻塞调用
这是新手100%会踩的坑,也是后果最严重的。
错误写法
python
import asyncio
import time
async def fetch_data():
# ❌ 用了 time.sleep,整个事件循环被卡住!
time.sleep(2)
return "数据到了"
async def main():
# 以为会并发执行,结果串行等了6秒
results = await asyncio.gather(
fetch_data(),
fetch_data(),
fetch_data(),
)
print(results)
asyncio.run(main()) # 实际耗时 6 秒,不是 2 秒
正确写法
python
async def fetch_data():
# ✅ 用 asyncio.sleep 代替 time.sleep
await asyncio.sleep(2)
return "数据到了"
# 现在并发执行,总耗时约 2 秒
什么算"阻塞调用"?
不仅仅是 time.sleep,以下都是异步代码中的毒药:
requests.get()→ 换aiohttp或httpxtime.sleep()→ 换asyncio.sleep()subprocess.run()→ 换asyncio.create_subprocess_exec()- 文件读写 → 换
aiofiles或asyncio.to_thread() - 任何CPU密集型计算 → 换
asyncio.to_thread()或进程池
划重点:async函数里只要出现同步阻塞,所有并发优势瞬间归零。
坑2:忘了await,协变变协程对象
这个坑太隐蔽了,Python不会报错,但结果完全不对。
错误写法
python
async def get_user(user_id):
await asyncio.sleep(0.5)
return {"id": user_id, "name": "张三"}
async def main():
# ❌ 忘了 await!result 是一个 coroutine 对象,不是字典
result = get_user(1)
print(result) # <coroutine object get_user at 0x7f8...>
print(result["name"]) # TypeError: 'coroutine' object is not subscriptable
正确写法
python
async def main():
# ✅ 加上 await
result = await get_user(1)
print(result["name"]) # 张三
怎么避免?
Python 3.11+ 会抛出 RuntimeWarning: coroutine was never awaited,一定要看警告!另外,装个 pylint 或 ruff,它们能静态检测出遗漏的 await。
说实话,我至今偶尔还会犯这个错。习惯成自然需要时间。
坑3:Gather不处理异常,一个炸全炸
asyncio.gather 默认行为是:一个任务抛异常,整个gather直接炸。
错误写法
python
async def fetch_url(url):
if "bad" in url:
raise ValueError(f"URL无效: {url}")
await asyncio.sleep(1)
return f"来自{url}的数据"
async def main():
# ❌ 一个失败,全部白费
results = await asyncio.gather(
fetch_url("https://good.com"),
fetch_url("https://bad.com"), # 这个会炸
fetch_url("https://ok.com"),
)
# 什么结果都拿不到
正确写法
python
async def main():
# ✅ return_exceptions=True,失败的返回异常对象,成功的正常返回
results = await asyncio.gather(
fetch_url("https://good.com"),
fetch_url("https://bad.com"),
fetch_url("https://ok.com"),
return_exceptions=True, # 关键参数!
)
for r in results:
if isinstance(r, Exception):
print(f"请求失败: {r}")
else:
print(f"请求成功: {r}")
或者用 TaskGroup(Python 3.11+),它的行为更清晰:
python
async def main():
results = {}
async with asyncio.TaskGroup() as tg:
tasks = []
for url in ["https://good.com", "https://bad.com", "https://ok.com"]:
task = tg.create_task(fetch_url(url))
tasks.append(task)
# TaskGroup 默认会在任何一个任务失败时取消其他任务
# 如果你需要"失败不影响其他",还是用 gather + return_exceptions
我的建议:90%的场景用 gather(return_exceptions=True),除非你明确需要"一个炸全炸"的语义。
坑4:全局变量引发的并发数据污染
异步代码里用全局变量/类属性,很容易踩到数据竞争的坑。
错误写法
python
class DataProcessor:
result = [] # ❌ 类变量,所有协程共享!
async def process(self, item):
await asyncio.sleep(0.1) # 模拟IO
self.result.append(item) # 并发append,可能丢数据
async def main():
processor = DataProcessor()
await asyncio.gather(*[
processor.process(i) for i in range(100)
])
print(len(processor.result)) # 可能不到100!
等等,你可能说Python有GIL啊,append不是原子操作吗?确实,list.append 在CPython里是原子的,但更复杂的操作(比如 result = result + [item])就不是了。
而且这不是重点。重点是逻辑上的数据污染------你以为是顺序处理,实际上协程随时会被切走,中间状态被其他协程读到。
正确写法
python
async def process(item):
await asyncio.sleep(0.1)
return item # 每个协程返回自己的结果
async def main():
results = await asyncio.gather(*[
process(i) for i in range(100)
])
print(len(results)) # 一定是100
原则:尽量用返回值传递数据,少用全局状态。异步编程里,共享状态就是万恶之源。
坑5:无限创建Task导致资源耗尽
并发是好东西,但不是越多越好。
错误写法
python
async def crawl(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.text()
async def main():
urls = [f"https://api.example.com/page/{i}" for i in range(10000)]
# ❌ 一次性创建10000个Task,连接池爆炸,内存爆炸
tasks = [crawl(url) for url in urls]
results = await asyncio.gather(*tasks)
10000个并发请求,要么被服务器限流返回429,要么本地连接池耗尽报错。
正确写法:用Semaphore控制并发数
python
CONCURRENCY = 50 # 最大并发数
semaphore = asyncio.Semaphore(CONCURRENCY)
async def crawl(url):
async with semaphore: # ✅ 控制并发
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.text()
async def main():
urls = [f"https://api.example.com/page/{i}" for i in range(10000)]
tasks = [crawl(url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
更优雅的方式是 asyncio.Semaphore 配合 aiohttp 的 TCPConnector 限制:
python
connector = aiohttp.TCPConnector(limit=50) # 连接池上限
async with aiohttp.ClientSession(connector=connector) as session:
# 现在最多50个并发连接
50-100的并发数通常是甜蜜点,具体看目标服务器的承受能力。
坑6:异步上下文管理器的正确姿势
aiohttp.ClientSession 这类资源必须正确关闭,否则会泄漏连接。
错误写法
python
async def fetch(url):
# ❌ 每次请求都创建新的session,从不关闭!
session = aiohttp.ClientSession()
async with session.get(url) as resp:
return await resp.text()
# session永远不会被关闭,连接泄漏
正确写法
python
async def fetch_all(urls):
# ✅ 复用同一个session,用完自动关闭
async with aiohttp.ClientSession() as session:
tasks = []
for url in urls:
# 注意:session.get() 本身也是上下文管理器
async def fetch_one(u):
async with session.get(u) as resp:
return await resp.text()
tasks.append(fetch_one(url))
return await asyncio.gather(*tasks, return_exceptions=True)
关键点:ClientSession 是重量级对象,一个应用只创建一个,所有请求复用。
还有个坑:在async函数里创建session但没在同一个函数里关闭,Python会报警告 Unclosed client session。千万别忽略这个警告。
坑7:事件循环嵌套------Jupyter/已有循环中的陷阱
在Jupyter Notebook里跑asyncio代码,经常遇到这个报错:
RuntimeError: This event loop is already running
原因是Jupyter本身就运行了一个事件循环,你再调用 asyncio.run() 就冲突了。
解决方案
python
# 方案1:Jupyter里直接 await(它本身就是异步环境)
result = await fetch_data() # 直接用,别 asyncio.run()
# 方案2:用 nest_asyncio(适用于必须在同步代码中跑异步的场景)
import nest_asyncio
nest_asyncio.apply() # 打补丁,允许嵌套事件循环
result = asyncio.run(fetch_data())
# 方案3:Python 3.10+,在已有事件循环中提交任务
import asyncio
loop = asyncio.get_event_loop()
task = loop.create_task(fetch_data())
result = await task
方案1最干净。如果你在Jupyter里,直接 await 就完事了。
一个完整的实战示例
把上面的坑都避开,写一个"正确"的异步爬虫:
python
import asyncio
import aiohttp
from typing import Optional
CONCURRENCY = 30
async def fetch_page(
session: aiohttp.ClientSession,
url: str,
semaphore: asyncio.Semaphore,
) -> Optional[str]:
"""抓取单个页面,带并发控制和错误处理"""
async with semaphore:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200:
return await resp.text()
else:
print(f"⚠️ {url} 返回状态码 {resp.status}")
return None
except asyncio.TimeoutError:
print(f"⏰ {url} 超时")
return None
except Exception as e:
print(f"❌ {url} 请求失败: {e}")
return None
async def crawl(urls: list[str]) -> list[str]:
"""并发爬取多个URL"""
semaphore = asyncio.Semaphore(CONCURRENCY)
# 复用session,带连接池限制
connector = aiohttp.TCPConnector(limit=CONCURRENCY)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [fetch_page(session, url, semaphore) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 过滤掉失败的结果
return [r for r in results if isinstance(r, str)]
# 跑起来
if __name__ == "__main__":
urls = [f"https://httpbin.org/delay/{i%3}" for i in range(100)]
results = asyncio.run(crawl(urls))
print(f"成功抓取 {len(results)} 个页面")
这个示例避开了前面提到的所有坑:
- ✅ 没有同步阻塞调用
- ✅ 所有
async操作都await了 - ✅ 用
gather(return_exceptions=True)处理异常 - ✅ 用返回值传递数据,不用全局变量
- ✅ Semaphore + TCPConnector 双重并发控制
- ✅ Session 复用并正确关闭
写在最后
Python异步编程的门槛不在语法------async/await 谁都会写------而在心智模型。你得时刻想着:这里是IO还是CPU?会不会阻塞事件循环?并发数要不要限制?资源有没有正确释放?
我的建议是:
- 先用同步代码跑通,确保逻辑没问题
- 只对IO密集型部分改异步,不要为了异步而异步
- 从
gather(return_exceptions=True)开始,别上来就玩花活 - 遇到
Unclosed session之类的警告,立刻修,别拖
异步编程就像骑自行车,开始会摔,但一旦上手,真的回不去了。
有问题欢迎评论区交流,踩了新坑也欢迎分享,咱们一起填 😄
参考文档:Python asyncio官方文档