Python异步编程从入门到不懵:asyncio实战踩坑指南
作为一个写了6年Python的人,我之前一直对异步编程敬而远之。直到上周要写个爬虫,并发量要求上千,同步写法根本扛不住,硬着头皮啃了三天asyncio,踩了大大小小8个坑,搞到凌晨两点才跑通。今天把这些坑整理出来,看完你至少能少走3天弯路。
为什么你学asyncio总懵
说实话,很多人学asyncio学不明白,真不是你笨,是教程都讲得太飘了。
上来就给你讲协程、事件循环、Future、Task这些概念,听得人云里雾里,看完还是不知道怎么写能用的代码。
我这次也是被逼到份上了,边踩坑边学,才发现asyncio其实没那么复杂,核心就是解决IO密集型任务的并发问题,不用搞那么多虚头巴脑的概念。
踩坑1:别在异步代码里写同步IO
血泪教训:这是我踩的第一个坑,也是最蠢的一个。
我一开始写的时候,直接把之前同步的requests请求放到了async函数里,跑起来发现速度一点没变快,跟同步没区别。
查了半天才搞明白,异步代码里如果有同步IO操作(比如requests、time.sleep、普通的文件读写),会把整个事件循环都卡住,其他任务根本没法执行。
# ❌ 错误写法:requests是同步库,会卡住整个事件循环importasyncioimportrequestsasyncdeffetch_url(url):resp=requests.get(url)# 这里会卡住!returnresp.status_codeasyncdefmain():tasks=[fetch_url(f"https://example.com/page/{i}")foriinrange(10)]awaitasyncio.gather(*tasks)asyncio.run(main())✅ 正确写法:要用对应的异步库,比如aiohttp代替requests,asyncio.sleep代替time.sleep,aiofiles代替普通文件读写。
# ✅ 正确写法:用异步IO库importasyncioimportaiohttpasyncdeffetch_url(url,session):asyncwithsession.get(url)asresp:returnresp.statusasyncdefmain():asyncwithaiohttp.ClientSession()assession:tasks=[fetch_url(f"https://example.com/page/{i}",session)foriinrange(10)]awaitasyncio.gather(*tasks)asyncio.run(main())如果你实在找不到对应的异步库,非要用同步代码怎么办?用asyncio.to_thread把同步代码扔到单独的线程里跑,别卡住事件循环就行。
踩坑2:不要随便用run_until_complete
很多老教程里都会教你用loop.run_until_complete来运行异步任务,其实Python 3.7+之后官方已经推荐用asyncio.run()了。
run_until_complete有个大坑:如果你手动创建了事件循环,用完没关闭,下次再用的时候可能会报各种各样的奇怪错误,比如事件循环已经关闭,或者任务已经被销毁之类的。
asyncio.run()会自动帮你管理事件循环的生命周期,用完自动关闭,省了很多麻烦。
除非你有特别的需求要手动管理事件循环,否则直接用asyncio.run()就对了。
踩坑3:Task不是越多越好
我一开始写爬虫的时候,想着并发越高越好,一口气创建了1000个Task,结果跑了两分钟就崩了,报连接被重置的错误。
后来才知道,虽然异步并发开销小,但也不是无限的。你创建太多Task的话,首先服务器扛不住这么多并发请求,会给你限流或者直接封IP,其次本地的TCP连接数也有上限,开太多会占满端口。
一般来说,爬虫类的任务并发控制在50-200之间就差不多了,具体要看目标网站的抗压能力。
怎么控制并发?用asyncio.Semaphore信号量,简单好用:
importasyncioimportaiohttp# 最多同时跑50个任务semaphore=asyncio.Semaphore(50)asyncdeffetch_url(url,session):asyncwithsemaphore:# 用信号量控制并发asyncwithsession.get(url)asresp:returnawaitresp.text()asyncdefmain():asyncwithaiohttp.ClientSession()assession:tasks=[fetch_url(f"https://example.com/page/{i}",session)foriinrange(1000)]awaitasyncio.gather(*tasks)asyncio.run(main())踩坑4:异常处理不到位导致程序崩溃
异步代码的异常处理跟同步代码有点不一样,如果你不处理Task里的异常,整个程序可能会直接崩溃,而且你还不知道哪里出了问题。
我之前写的时候,有个请求超时了没处理,直接导致整个爬虫程序挂了,跑了半小时的数据差点丢了。
两种处理异常的方式:
第一种是在async函数内部用try/except捕获:
asyncdeffetch_url(url,session):try:asyncwithsession.get(url,timeout=10)asresp:returnawaitresp.text()exceptExceptionase:print(f"请求{url}失败:{e}")returnNone第二种是在gather的时候加return_exceptions=True,这样某个任务出错不会影响其他任务,异常会作为结果返回:
# 某个任务出错不会导致整个gather失败results=awaitasyncio.gather(*tasks,return_exceptions=True)# 然后遍历results处理异常forresultinresults:ifisinstance(result,Exception):print(f"任务出错:{result}")推荐两种方式结合用,双重保险,避免程序莫名其妙崩掉。
踩坑5:别在异步代码里用多线程/多进程
我之前想当然觉得,异步+多线程肯定更快啊,结果搞出来的代码比纯同步还慢。
其实asyncio本身就是单线程跑的,你如果在异步代码里乱开多线程,反而会因为线程切换开销拖慢速度,还可能会出现线程安全问题。
如果你真的需要用多进程处理CPU密集型任务,可以用asyncio的run_in_executor把CPU任务扔到进程池里跑,但是别自己瞎开线程/进程。
还有啊,别在不同的线程里调用同一个事件循环的方法,会出各种奇奇怪怪的问题,asyncio的事件循环不是线程安全的。
踩坑6:忘了await异步函数
这个坑看起来很蠢,但真的很多人踩,包括我自己。
有时候写代码写嗨了,调用async函数的时候忘了加await,结果代码跑起来什么反应都没有,还找半天bug。
# ❌ 错误:没有await,这个函数不会执行,只会返回一个coroutine对象fetch_url(url,session)# ✅ 正确:加await才会执行awaitfetch_url(url,session)如果你看到控制台报RuntimeWarning: coroutine ‘xxx’ was never awaited的警告,别犹豫,肯定是哪里漏了await。
踩坑7:asyncio.sleep(0)不是没用的
很多人不知道asyncio.sleep(0)是干嘛的,觉得睡0秒有啥用?
其实这个是手动让出CPU时间片的操作,如果你有一个计算量很大的异步任务,会一直占着事件循环,导致其他任务得不到执行。
这个时候在循环里加个await asyncio.sleep(0),就能主动让出控制权,让其他任务也有机会跑。
asyncdefheavy_compute_task():foriinrange(100000):# 做一些计算compute(i)# 每循环一次让出一次CPUawaitasyncio.sleep(0)这个技巧在做CPU密集型异步任务的时候特别好用,不会让整个程序卡住。
踩坑8:关闭事件循环前要等所有任务完成
如果你的程序要退出的时候,还有没跑完的Task,直接关闭事件循环的话,会报Task was destroyed but it is pending!的错误。
正确的做法是在退出前,先获取所有未完成的Task,等它们跑完或者手动取消掉:
asyncdefmain():# 你的业务代码...if__name__=="__main__":loop=asyncio.get_event_loop()try:loop.run_until_complete(main())finally:# 取消所有未完成的任务pending=asyncio.all_tasks(loop)fortaskinpending:task.cancel()# 等待任务取消完成loop.run_until_complete(asyncio.gather(*pending,return_exceptions=True))# 关闭事件循环loop.close()不过如果你用asyncio.run()的话,它会自动帮你处理这些事情,不用自己写这么多代码。
写在最后
说实话,asyncio真的没有很多教程说的那么难,大部分人学不会都是被那些复杂的概念吓退了。
你完全可以先不管什么协程、事件循环这些底层概念,先照着例子写能用的代码,写多了自然就理解了。
我之前也是觉得异步编程特别高深,这次踩了一遍坑下来,发现其实常用的功能就那么几个,gather、Semaphore、异常处理,掌握这几个大部分场景都够用了。
如果你也在学asyncio的过程中遇到什么问题,欢迎评论区交流,我看到都会回复。
