作为一个写了6年Python的人,我曾经对异步编程嗤之以鼻。
直到上个月做爬虫项目被同步IO卡得怀疑人生,才老老实实啃了半个月asyncio。这半个月踩的坑比我过去一年写同步代码踩的都多,今天就把最容易踩的8个坑整理出来,帮你少走弯路。
背景:我为什么被逼着学异步
上个月接了个爬虫需求,要爬1000个商品页面的价格数据。用同步的requests写,跑一次要20多分钟,客户嫌太慢,让我优化到5分钟以内。
我第一反应是开多线程,试了之后发现开10个线程也只能跑到10分钟,再开线程反而因为切换开销变慢了。这时候有人跟我说,你试试asyncio啊,IO密集型场景用异步爽得很。
说实话我一开始是拒绝的,觉得异步代码写起来绕,debug还麻烦。但实在没办法,硬着头皮学了半个月,最后把爬虫优化到了2分钟跑完,真香。
不过学习过程中踩的坑是真的多,很多教程都没提这些细节,我一个个踩过来差点放弃。今天把最常见的8个坑整理出来,都是我实际踩过的血泪教训。
坑1:别用time.sleep(),要用asyncio.sleep()
这应该是新手最容易踩的第一个坑,我一开始就中招了。
写测试代码的时候,想模拟一下IO等待,顺手就写了个time.sleep(1),结果运行起来发现跟同步代码一样慢,完全没有异步的效果。
python
import asyncio
import time
async def test():
print("开始等待")
time.sleep(1) # 这里有坑!
print("等待结束")
async def main():
await asyncio.gather(test(), test(), test())
asyncio.run(main())
你猜上面代码运行要多久?3秒!因为time.sleep()是同步阻塞的,会把整个事件循环卡住,等于三个协程串行执行了。
解决方案: 所有的等待操作都要用asyncio对应的异步版本,把time.sleep()换成asyncio.sleep()就行:
python
async def test():
print("开始等待")
await asyncio.sleep(1) # 正确写法
print("等待结束")
改完之后运行只需要1秒,三个协程真正并行执行了。
血泪教训:只要是在async def的异步函数里,所有的IO操作都要找对应的异步版本,不要用同步的库,不然等于白写异步。
坑2:协程忘了加await,执行没反应还不报错
这个坑我踩了好多次,到现在有时候还会忘。
比如你写了个异步函数fetch_data(),调用的时候直接写fetch_data(),没加await,结果运行的时候啥反应都没有,也不报错,你还以为代码没执行。
python
import asyncio
async def fetch_data():
print("正在获取数据...")
await asyncio.sleep(1)
return {"code": 200, "data": "test"}
async def main():
fetch_data() # 这里没加await!
print("执行完成")
asyncio.run(main())
运行上面的代码,你会发现只输出了"执行完成",fetch_data()里的打印完全没出来,也没有任何报错,新手遇到这个直接懵。
原因是你直接调用异步函数返回的是一个协程对象,并没有真正执行,必须加await才会调度执行。
解决方案: 调用任何异步函数都要加await:
python
async def main():
await fetch_data() # 正确写法
print("执行完成")
如果不需要等待结果,可以用asyncio.create_task()把它包装成任务丢到后台执行:
python
async def main():
task = asyncio.create_task(fetch_data())
print("执行完成,后台会继续执行任务")
# 后面可以await task等待结果
坑3:run_until_complete()和run_forever()别混用
如果你看过老一点的asyncio教程,可能会见到loop.run_until_complete()和loop.run_forever()这两个写法。
Python 3.7之后官方推荐用asyncio.run()来启动程序,但有时候还是会遇到需要自己操作事件循环的场景,这两个方法很容易搞混。
我上次写一个定时任务,想让程序一直跑,就写了:
python
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.run_forever()
结果main()执行完之后程序直接退出了,run_forever()根本没执行。
原因是run_until_complete()会在传入的协程执行完成后自动停止事件循环,后面再调用run_forever()也没用了。
解决方案: 两个方法选一个就行,不要混用:
- 如果你要跑一个有明确结束点的程序,用asyncio.run(main())就够了
- 如果你要写一直运行的服务(比如爬虫调度器、API服务),用run_forever(),然后通过任务来控制逻辑
python
# 正确的永久运行写法
async def main():
while True:
# 你的业务逻辑
await asyncio.sleep(60) # 每分钟执行一次
loop = asyncio.get_event_loop()
loop.create_task(main())
loop.run_forever()
坑4:异步函数里调用同步函数,整个事件循环卡住
这个是异步性能上的大坑,很多人写了异步代码发现还没同步快,大概率是这个原因。
比如你在异步函数里调用了同步的requests.get(),或者pandas的读文件操作,这些同步函数会阻塞整个事件循环,导致所有其他协程都卡住。
我上次就是在异步爬虫里不小心用了同步的requests,结果1000个请求跑了15分钟,还不如同步快。
python
import asyncio
import requests
async def fetch(url):
# 这里用了同步的requests,坑!
resp = requests.get(url)
return resp.text
async def main():
urls = [f"https://example.com/page/{i}" for i in range(100)]
tasks = [asyncio.create_task(fetch(url)) for url in urls]
await asyncio.gather(*tasks)
上面的代码跟串行执行没区别,因为requests.get()是同步阻塞的,每次只能跑一个请求。
解决方案:
- 用对应的异步库替换同步库,比如requests换成aiohttp,同步文件操作换成aiofiles
- 如果没有异步版本,用asyncio.to_thread()把同步函数丢到线程里执行,不会阻塞事件循环:
python
async def fetch(url):
# 把同步requests丢到线程里执行
resp = await asyncio.to_thread(requests.get, url)
return resp.text
改完之后速度直接提升了10倍,这才是异步该有的效果。
坑5:协程里的异常没捕获,整个程序直接崩溃
异步代码的异常处理跟同步代码不太一样,新手很容易忽略。
比如你用asyncio.gather()跑了10个协程,其中一个抛了异常,如果你没捕获,整个程序会直接崩溃,剩下的9个协程也会被终止。
我上次爬100个页面,其中一个页面超时了,整个爬虫直接停了,剩下90个页面都没爬,白等了半天。
python
async def fetch(url):
await asyncio.sleep(1)
if url == "https://example.com/page/50":
raise Exception("请求超时")
return url
async def main():
urls = [f"https://example.com/page/{i}" for i in range(100)]
# 这里没处理异常,某个协程报错整个程序崩溃
results = await asyncio.gather(*[fetch(url) for url in urls])
解决方案:
- 给asyncio.gather()加return_exceptions=True参数,这样异常会作为结果返回,不会终止其他协程:
python
results = await asyncio.gather(*[fetch(url) for url in urls], return_exceptions=True)
# 然后自己遍历结果判断是否有异常
for result in results:
if isinstance(result, Exception):
print(f"请求出错:{result}")
- 或者在协程内部自己捕获异常:
python
async def fetch(url):
try:
await asyncio.sleep(1)
if url == "https://example.com/page/50":
raise Exception("请求超时")
return url
except Exception as e:
print(f"请求{url}出错:{e}")
return None
我更推荐第二种方式,在业务逻辑内部处理异常,更灵活。
坑6:asyncio.gather()返回结果顺序跟传入顺序一致,别搞错
这个坑不大,但很容易出逻辑bug。
很多人以为gather()的返回结果是按协程完成顺序排列的,其实不是,是按你传入协程的顺序排列的。
比如你传了三个协程A、B、C,哪怕C先完成,B其次,A最后完成,返回的结果列表还是[A的结果, B的结果, C的结果]。
我上次就是误以为是按完成顺序排列,把结果存到数据库的时候顺序错了,排查了好久才找到原因。
python
async def test(name, delay):
await asyncio.sleep(delay)
return name
async def main():
# 传入顺序:A(3秒), B(2秒), C(1秒)
results = await asyncio.gather(
test("A", 3),
test("B", 2),
test("C", 1)
)
print(results) # 输出:['A', 'B', 'C'],不是['C', 'B', 'A']
这个特性其实很方便,你不用自己对应顺序,直接按传入的顺序取结果就行,但前提是你要知道这个规则,不然会写出bug。
坑7:不要在协程里随便修改全局变量,要加锁
很多人觉得异步是单线程的,所以修改全局变量不用加锁,这是大错特错。
虽然asyncio是单线程跑的,但协程会在IO等待的时候切换,如果你在两个协程里修改同一个全局变量,还是会出现竞态条件。
我上次写计数功能,两个协程都给全局变量count加1,跑1000次结果总是少几十个,就是因为没加锁。
python
count = 0
async def add():
global count
# 这里分两步:读count,加1,写回count
# 协程可能在这两步之间切换,导致数据覆盖
tmp = count
await asyncio.sleep(0.001) # 模拟IO切换
count = tmp + 1
async def main():
tasks = [asyncio.create_task(add()) for _ in range(1000)]
await asyncio.gather(*tasks)
print(count) # 输出肯定小于1000
运行上面的代码,count的结果总是在900多,从来没到过1000,就是因为协程切换的时候出现了数据覆盖。
解决方案: 用asyncio.Lock()加锁,修改共享变量的时候要获取锁:
python
count = 0
lock = asyncio.Lock()
async def add():
global count
async with lock: # 获取锁,其他协程会等锁释放
tmp = count
await asyncio.sleep(0.001)
count = tmp + 1
改完之后count就会是1000了,不会少。
血泪教训:只要是多个协程修改同一个共享变量,不管是不是单线程,都要加锁。
坑8:Windows和Linux的事件循环差异,部署踩坑
这个坑是我部署到服务器的时候才遇到的,本地Windows跑的好好的,放到Linux上直接报错。
asyncio在不同平台的默认事件循环不一样:
- Windows上默认用的是SelectorEventLoop,不支持很多高级特性,而且性能差
- Linux上默认用的是EpollEventLoop,性能好,支持所有特性
我本地Windows写的代码里用了一些Windows循环支持的特性,放到Linux上就报错了。还有就是Windows上可以直接用asyncio.run(),Linux上有时候需要手动设置循环。
解决方案:
- 部署到Linux的时候,最好手动指定事件循环策略,确保用的是Epoll:
python
import asyncio
import sys
if sys.platform == "linux":
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) # 用uvloop性能更好
- 尽量不要用平台特有的API,写跨平台兼容的代码
- 推荐用uvloop替换默认的asyncio循环,性能能提升2-3倍,谁用谁知道。
写在最后
其实asyncio没那么难,核心就是记住几个原则:
- 异步函数里所有IO操作都要用异步版本,别用同步的
- 调用异步函数必须加await,或者包装成task
- 共享变量修改要加锁
- 异常要捕获,别让整个程序崩溃
我一开始学的时候觉得这玩意儿太绕了,写多了之后发现其实比多线程多进程简单多了,没有那么多锁和进程间通信的问题。
现在我写IO密集型的项目首先想到的就是asyncio,效率提升真的很明显。如果你也经常写爬虫、API服务、定时任务这类IO密集型的代码,真的推荐你好好学一下asyncio,绝对不亏。
如果你在使用过程中遇到什么问题,欢迎评论区交流,我会尽量帮你解答。