Python的异步把我坑惨了,原来async/await和多线程的区别这么大

📅 2026/7/23 15:33:10
Python的异步把我坑惨了,原来async/await和多线程的区别这么大
一个让我怀疑人生的爬虫项目前年接了一个数据采集项目。每天要爬一万多个商品页面的价格和库存信息每个页面请求大概等1秒钟。我心想“一万个请求每个一秒也就两个多小时嘛跑着呗。”结果跑起来发现根本不是这么回事。同步请求一个接一个加上网络波动和重试跑完一轮要四个多小时。客户那边每天等着数据更新四个多小时黄花菜都凉了。我说“上多线程”开了50个线程并发请求速度确实快了跑完一轮大概十几分钟。但新问题来了——线程开太多服务器连接数爆了被目标网站封了IP。降低到20个线程速度又慢回去了。同事看了一眼我的代码“你这个场景用异步啊。”我“异步async/await那套跟多线程有什么区别”那天下午我把async/await和多线程的区别彻底研究了一遍。今天把这些东西讲清楚希望你下次遇到类似问题别像我一样纠结。第一步先搞清楚并发到底在解决什么问题不管是多线程还是异步解决的都是同一个问题别让程序傻等着。看一段最朴素的同步代码import time import requests def fetch(url): print(f开始请求 {url}) response requests.get(url) # 这里傻等着 print(f完成请求 {url}) return response start time.time() fetch(网站A) fetch(网站B) fetch(网站C) print(f总耗时: {time.time() - start})三个请求每个等1秒总共3秒。问题出在哪requests.get(url)这行代码执行的时候程序啥事也没干就在那儿干等着网络返回数据。CPU其实是空闲的但同步代码不允许它去做别的事——没执行完当前函数绝不跳到下一行。并发要解决的就是这个“等待浪费”。当一个操作需要等待时让程序暂时离开去做别的事等那个操作准备好了再回来继续。第二步多线程——增加人手多线程的思路很直接既然一个人等的时候干不了活那就多招几个人。每个线程就像一个独立的员工。员工A在等网络响应的时候员工B可以继续发请求员工C可以继续发请求。这样CPU就不会闲着了。import threading import requests def fetch(url): response requests.get(url) print(f完成 {url}) urls [网站A, 网站B, 网站C] threads [] for url in urls: t threading.Thread(targetfetch, args(url,)) t.start() threads.append(t) for t in threads: t.join()三个线程同时干活总耗时约1秒。但多线程有个代价线程切换由操作系统控制。操作系统会时不时打断当前线程切换到另一个线程。这种切换叫做“抢占式”——你控制不了什么时候切、切给谁。就像开一个多窗口的银行每个窗口一个柜员。如果某个客户填表填得慢那个窗口就得等着但其他窗口还能继续服务别人。问题是柜员数量有限招太多人成本也高——线程太多操作系统切换开销大还可能把服务器资源耗尽。第三步异步——一个超级柜员异步的思路完全不同不增加人手而是让一个人变得更高效。import asyncio import aiohttp async def fetch(session, url): print(f开始请求 {url}) async with session.get(url) as response: data await response.text() # 主动让出控制权 print(f完成请求 {url}) return data async def main(): async with aiohttp.ClientSession() as session: tasks [ fetch(session, 网站A), fetch(session, 网站B), fetch(session, 网站C), ] await asyncio.gather(*tasks) asyncio.run(main())异步的核心是一个叫做“事件循环”的东西。你可以把它理解成一个超级调度员管理着所有待执行的任务。当一个异步任务遇到需要等待的操作时比如网络请求它不会傻等而是主动告诉事件循环“我要等一会儿你先去忙别的好了叫我”。事件循环收到消息后立刻切换到下一个可以执行的任务。等网络数据回来了事件循环再把之前暂停的任务唤醒继续执行。用银行的比喻异步就像一个超级王牌柜员只开一个窗口。客户A来办业务发现需要回去拿身份证。这个柜员不会傻等而是立刻跟客户A说“你先去拿拿好了再回来我先给下一个人办。”然后马上开始处理客户B的业务。等客户A拿着身份证回来了柜员再找个空隙把剩下的业务办完。这个柜员从来没有闲着通过在任务的等待时间里来回切换一个人就搞定了多个客户的活儿。关键区别在于多线程是操作系统帮你切换抢占式你控制不了异步是任务自己主动让出控制权协作式你自己说了算。第四步到底有什么区别说了这么多用一张表总结一下维度多线程async/await调度者操作系统抢占式事件循环协作式资源消耗每个线程约1-8MB内存每个任务几乎不占额外内存并发上限几十到几百个线程几千甚至上万个任务切换开销操作系统上下文切换较重函数调用级别很轻适用场景I/O密集型少量并发I/O密集型海量并发CPU密集型受GIL限制基本没用没用单线程学习曲线相对平缓相对陡峭需要理解事件循环用我那个爬虫项目来实测同步版本4个多小时20线程版本约20分钟但容易被封异步版本用aiohttp跑一万个请求不到10分钟跑完而且单线程连接数可控不容易被封。有实测数据显示在4核8G服务器上aiohttp可以维持3000并发连接而传统的同步方式超过500连接就会出现性能断崖式下跌。异步爬虫比同步快10到100倍。第五步还有一些坑异步虽然强大但坑也不少。我踩过的几个分享给你。坑一逐个await等于没用异步这是最常见也最隐蔽的错误。# 错误写法——还是串行 async def main(): await fetch(网站A) # 等A完成 await fetch(网站B) # 再等B完成 await fetch(网站C) # 再等C完成这样写跟同步没区别总耗时还是3秒。正确做法是用asyncio.gather()并发执行# 正确写法——真正并发 async def main(): await asyncio.gather( fetch(网站A), fetch(网站B), fetch(网站C), )坑二在异步里调用同步阻塞函数事件循环是单线程的。如果你在协程里调用了time.sleep()或者同步的requests.get()整个事件循环都会被卡住。async def bad_example(): time.sleep(5) # 卡死整个事件循环5秒 # 应该用 await asyncio.sleep(5)如果是必须用的同步库可以用asyncio.to_thread()把它丢到线程池里执行。坑三忘了awaitasync def main(): fetch(网站A) # 忘了await这行什么都没干 await fetch(网站B)不写await协程根本不会执行。Python 3.11会给出警告但最好还是养成习惯——写了async def调用的时候一定加await。总结一句话记住区别多线程是操作系统帮你切换任务异步是你自己主动让出控制权。用餐厅来总结多线程招了10个厨师每个厨师负责一道菜。等菜的时候厨师闲着但其他厨师还在忙异步只有1个超级厨师但他从来不等——菜在锅里炖的时候他立刻去切下一道菜的食材现在我遇到并发需求会先问自己两个问题任务是等I/O还是算CPU等的话才考虑并发并发量多大几十个用多线程几百上千个用异步想清楚再动手少让程序卡住几次。