做了三年Python开发,干过爬虫、写过脚本、搭过服务,但我真正理解Asyncio,是在一次重写批量下载工具的时候。那会儿用requests循环抓几百个文件,跑一趟得几分钟,后来换成asyncio,一分钟不到就完事。从那以后我就明白,异步编程不是锦上添花,是Python开发者迟早要跨过去的一道坎。
这篇不是从官方文档翻译过来的“教程”,是我在实际项目里摸出来的经验总结。我会用最直白的话讲清异步编程的核心原理、关键API和完整的落地案例,最后整理一份高频报错的排查手册。不管你是刚学Python的新手,还是写过同步代码但没玩过并发的进阶选手,照着这篇文章的思路走一遍,至少能在自己项目里用起来。
1. 为什么非学不可:异步编程到底解决什么问题
1.1 同步代码的痛点,从一次慢速请求说起
先看一段很常见的同步代码:
import time import requests def fetch(url): print(f"开始请求: {url}") resp = requests.get(url) print(f"完成: {resp.status_code}") return resp def main(): url = "https://httpbin.org/delay/2" # 这个接口会故意延迟2秒 start = time.time() for i in range(3): fetch(url) print(f"总耗时: {time.time() - start:.2f}s") main()跑一下,耗时约6秒。问题很明显:每次requests.get()发出后,程序就卡在那里等服务器响应。等待期间CPU在干什么?什么都没干,就是空转等网络数据回来。换句话说,这段时间你的程序是被网络IO阻塞住的。
网络请求只是IO阻塞的一种,文件读写、数据库查询、外部接口调用都是同一类问题。如果一个程序里有很多这样的操作,同步写法就是眼睁睁地看着宝贵的时间一秒秒流走。
1.2 asyncio的优势在哪里,什么场景适用
要解决等待浪费问题,传统的做法是多线程。用线程池开几个线程,让它们并行等。但Python有个绕不开的东西叫GIL,同一时刻只能有一个线程执行Python字节码。好在对于网络IO这类操作,在线程等待时GIL会释放,所以多线程确实能提速。
但线程也有代价:线程的创建和切换有系统级开销,线程多了还容易出竞态问题,管理起来麻烦。
asyncio走的是另一条路:单线程,靠协作式调度来切换任务。程序里只有一个事件循环在跑,但它能记住“现在这个任务在等IO,先让它歇着,去执行另一个任务”,等IO回来了再回来继续。这种切换开销极小,轻轻松松管理成千上万个并发任务。
不过也要说清楚适用边界。asyncio擅长的是IO密集型任务——网络请求、文件读写、数据库操作这类。如果是CPU密集型任务,比如大量计算、图像处理,asyncio帮不上忙,那是多进程该干的活。常用三角形类比:进程适合CPU密集,线程适合阻塞型IO,协程适合高并发IO,各有各的相对优势。我个人的选择标准很简单——如果任务是等外部资源返回,就先想想能不能用asyncio。
提示:Python 3.7+才能完整使用我下面讲的
asyncio.run()等新API,老版本的话建议升级,别拿兼容老版本当借口让自己痛苦。
2. 事件循环与协程:asyncio的两个核心概念
2.1 事件循环到底怎么“转”起来的
很多新手一上来就背概念,说事件循环是负责调度协程的东西,但没真正理解。我常用一个咖啡馆的比喻解释:
想象你是咖啡馆里唯一的服务员。同步模式下,你接一个客人的单,就非得等咖啡做出来端上去,才去服务下一个客人。高峰期客人一多,后面的客人全部干瞪眼。
事件循环模式则是:接到A客人的单,记下来,告诉后厨去做,然后转头问B客人要喝什么;B点完单,你又去看看后厨的咖啡好了没有,好了就端给A。你一个人,但同一时间里服务了很多客人。
这里的关键是“IO等待时切换到别的任务”。asyncio的事件循环本质上就是一个无限循环,维护着两个数据结构——一个是”现在就执行“的任务队列,一个是”在等某个事件回来“的等待队列。每次循环都检查:有任务准备好继续了吗?有的话就推进它的代码。所有准备好的任务都在这次循环的迭代里推进一小段。
这种调度是协作式的,意味着协程必须主动让出控制权。谁负责让?就是你写的await。只有遇到await时,当前协程才会把执行权交回事件循环,事件循环才能去调度别的协程。如果不写await或者写了阻塞调用的代码,那就等于在整个事件循环里插了一根铁棍,谁也转不动。
2.2 协程、Task、Future的关系
这块我当年绕了很久,现在用一张关系图给你理清楚(不是绘图工具,就是概念拓扑):
- 协程(Coroutine):
async def定义的函数不是协程,函数被调用时返回的那个对象才是。它是一个待执行的计算流程,但自己不跑,得有人拉着它跑。 - Task(任务):把协程“注册”到事件循环上得到的对象。它才是事件循环真正调度的单元。你可以同时创建很多Task,事件循环按状态推进它们。Task本质上有一个指向Future底层的机制,任务完成的结果也会存在Future里。
- Future(未来对象):一个底层概念,表示一个“将来才会有结果”的操作。你会见到它更多是因为第三方库,自己写业务代码时一般只跟Task和协程打交道。
一段代码看清楚三者的关系:
import asyncio async def say_hello(): await asyncio.sleep(1) return "hello" # 调用async函数 -> 得到协程对象 coro = say_hello() print(type(coro)) # <class 'coroutine'> # 把协程包装成Task(需在事件循环内) async def main(): task = asyncio.create_task(say_hello()) print(type(task)) # <class '_asyncio.Task'> result = await task print(result) # hello asyncio.run(main())新手最容易犯的错:定义了async函数,调用后以为它会自动执行。实际上你有三种方式让它真的跑起来——第一种,直接await coro();第二种,包成Task由事件循环调度;第三种,用asyncio.run()包一个最外层的入口协程。理解了这个区别,你就能看懂下面所有的代码了。
3. 入门必会的7个API与实操要点
3.1 入口:asyncio.run()的隐藏细节
你写的异步程序从哪进来?就是asyncio.run():
asyncio.run(main())它做三件事:创建新事件循环、把传入的协程跑完、最后关闭事件循环。我在第一次用的时候以为只要写个main()就能自动跑,结果发现要手动调run()。它的一个特性是每次调用都会新起一个事件循环,所以你不能在已有事件循环的环境里再调asyncio.run()。
这点在Jupyter Notebook这种交互式环境里特别明显。你跑了一次asyncio.run(some_coro()),再跑第二次时可能就报错asyncio.run() cannot be called from a running event loop——因为Notebook的kernel本身已经有一个事件循环在跑了。这时候我更建议你在Jupyter里用await some_coro()直接等待,或者在脚本里用asyncio.run()保证整段入口干净。
3.2 关键组合:async/await
async def把一个普通函数变成协程函数。注意:协程函数里的代码并不会像普通函数那样从上到下一次性执行,碰到await就会暂停并让出控制权:
async def demo(): print("第一行") await asyncio.sleep(1) # 就在这里暂停,让别的任务先跑 print("第二行")await后面必须跟一个“可等待对象”(awaitable),也就是协程、Task、Future三类之一。如果你await的不是这些东西,会直接报TypeError: object int can't be used in 'await' expression。
另一个高频坑是:在非async函数(普通同步函数)里使用await,解释器会直接报SyntaxError: 'await' outside async function。想要这个能力,就把那个同步函数也改成async def。
3.3 任务编排:create_task、gather、wait怎么选
如果只是单协程里加点延时,用不上asyncio。真正的威力是并发创建一大堆任务。三个最常用API的差别:
asyncio.create_task(coro):把协程包装成Task并立即排入事件循环。它不等待结果,返回Task对象。适合你要“发任务但不立刻等它”的场景。asyncio.gather(*coros, return_exceptions=False):同时执行多个可等待对象,等全部完成后把结果按顺序返回。return_exceptions=True时,某个协程抛异常不会影响其他任务继续执行,异常会作为结果返回。这是最推荐直接用的方式。asyncio.wait(tasks, timeout=None, return_when=...):接收的是Task的集合,返回(done, pending)两个集合。适合需要手动管理“某些任务还没完成”的场景,比如设置统一超时。
直观对比一下gather和create_task组合await的区别,很多人困惑“结果顺序”。用gather你拿到的结果列表顺序,永远和你传入协程的顺序一致,不管谁先完成。用create_task分别await,则谁先拿到结果由实际完成时间决定。如果业务需要“按请求顺序取结果”,gather更省心;如果需求是“有一个完成了就立刻处理”,那建议用asyncio.as_completed迭代。配一个例子:
import asyncio async def fetch(num, delay): await asyncio.sleep(delay) return f"第{num}个任务" async def main(): tasks = [ fetch(1, 2), # 最慢的放前面 fetch(2, 0.5), fetch(3, 0.1) ] results = await asyncio.gather(*tasks) print(results) # ["第1个任务", "第2个任务", "第3个任务"], 顺序不乱 asyncio.run(main())3.4 超时与取消:别让卡死的请求拖垮程序
异步任务最大的隐患就是“无限等待”。网络请求可能卡死,数据库连接可能不返回,没有超时控制的任务会占着坑不挪窝。
asyncio.wait_for(awaitable, timeout)就是干这个的:
import asyncio async def slow_operation(): await asyncio.sleep(10) return "成功" async def main(): try: result = await asyncio.wait_for(slow_operation(), timeout=3) print(result) except asyncio.TimeoutError: print("3秒没完成,超时了") asyncio.run(main())wait_for在超时时会自动取消内部的任务。而如果任务本身拒绝响应取消(比如它正在执行一段无法被打断的同步阻塞代码),超时也不会立刻生效——这个我们在第3.6节讲怎么处理。
还有一种主动取消的方式,直接调用task.cancel(),让协程在下一个await点上抛出CancelledError。实际项目中,我习惯给所有网络请求都套一层wait_for,避免某个下游服务把整个程序拖死。这是我从一次线上故障里学到的极端经验:一个没有超时的脚本等了一个第三方API 40分钟没有返回,日志一片空白,始作俑者就是没设置超时。
3.5 限流:Semaphore控制并发上限
并发不是越大越好。你有1000个任务,一口气全发出去,目标服务器可能直接拒连接,甚至把你的IP封掉。Semaphore(信号量)就是用来给并发加塞子的。
import asyncio sem = asyncio.Semaphore(5) # 最多同时5个 async def safe_fetch(url): async with sem: # 进来占一个名额 print(f"抓取: {url}") await asyncio.sleep(1) return f"结果: {url}" async def main(): tasks = [safe_fetch(f"https://api.example.com/{i}") for i in range(20)] await asyncio.gather(*tasks) asyncio.run(main())注意它的写法是**async with sem:**,不是普通的with sem。如果写错了会报AttributeError: __aenter__。限流参数怎么定?我的经验是:先看目标服务的承载能力,不确定就测试性地从5开始逐步往上调。爬虫场景下,这个参数同时是礼貌与生存的边界。
3.6 阻塞调用怎么办:run_in_executor把同步函数交给线程池
这是asyncio的“兜底手段”。上面强调过,协程里如果出现同步阻塞调用(比如requests.get()、time.sleep()、普通的open()文件IO),整个事件循环会被卡死,所有并发任务全部停摆。
然而实际项目中总免不了要用些同步的库,比如还没出异步版本的SDK。解决办法是loop.run_in_executor(),它把这些阻塞调用丢给一个线程池去执行,返回的Future可以被await:
import asyncio import requests def sync_fetch(url): # 这是一个普通的同步函数 resp = requests.get(url, timeout=5) return resp.status_code async def async_wrapper(url): loop = asyncio.get_running_loop() # 丢进默认线程池执行,不会阻塞事件循环 result = await loop.run_in_executor(None, sync_fetch, url) return result async def main(): urls = ["https://httpbin.org/delay/1", "https://httpbin.org/delay/2"] results = await asyncio.gather(*(async_wrapper(u) for u in urls)) print(results) asyncio.run(main())也可以显式传一个ThreadPoolExecutor,控制线程池大小。注意:不要滥用run_in_executor,它的原理是线程,用多了又回到线程的老路上去。主要用在你无法替换同步库的场景,而像requests这种完全可以换成aiohttp就换成aiohttp。
4. 从零能跑:一个完整的并发爬虫案例
4.1 需求与代码骨架
光讲API不落地没有意义,我们来做一个真实场景。假设要抓取50个网页的标题,用同步方式挨个抓,每个页面延迟1秒,就要50秒。换个异步思路,事件循环同时发起50个请求,50个请求的总耗时逼近最慢的单请求,也就是约1秒。
先装上需要用到的库(环境是Python 3.7+,建议装到虚拟环境里):
pip install aiohttp核心代码结构是这样的:
import asyncio import aiohttp import time async def fetch_title(session, url): try: async with session.get(url, timeout=10) as resp: html = await resp.text() # 简单提取<title>标签内容 start = html.find("<title>") + len("<title>") end = html.find("</title>") title = html[start:end].strip() if start != -1 and end != -1 else "无标题" return url, title except Exception as e: return url, f"错误: {e}" async def main(): urls = [f"https://httpbin.org/delay/1" for _ in range(50)] # 模拟50个耗时网页 async with aiohttp.ClientSession() as session: tasks = [fetch_title(session, url) for url in urls] start = time.time() results = await asyncio.gather(*tasks) print(f"总耗时: {time.time() - start:.2f}s") for url, title in results[:3]: print(f"{url} -> {title}") asyncio.run(main())重点在async with aiohttp.ClientSession()。整个程序共享一个Session,连接会被复用,效率远高于每次请求都建新连接。你的所有请求都包成Task交给gather去调度。
4.2 同步版本 vs 异步版本效果对比
我拿上面的例子在本地跑过,50个请求、每个延迟1秒:
- 同步版(
requests逐个请求):约50.3秒 - 异步版(
aiohttp并发请求):约1.8秒
差了接近28倍。这还只是在本地模拟环境下,真实网络场景的抖动会更明显。需要说明的是,很多新手拿这个对比后兴奋过头,把asyncio当成万能加速器——不是的。你换成CPU密集型的计算试试?比如大量循环运算,或者数据处理——那种任务用asyncio不会快,因为计算本身占据CPU时间片,协程切换反而有损耗。asyncio的快,来自“等待IO时不占资源”,这个原理要真正刻在心里。
4.3 进阶:加超时、限流、异常兜底
真实环境里不能就这么裸奔,必须做三重防护:
第一重:请求超时。session.get(url, timeout=10)不能省,避免某个服务器挂掉后疯狂占用连接。
**第二重:并发限流。**前面爬相同网站的接口,很容易被反爬。给fetch_title加一个信号量:
SEM_LIMIT = asyncio.Semaphore(10) # 最多10个并发 async def fetch_title(session, url): async with SEM_LIMIT: try: async with session.get(url, timeout=10) as resp: html = await resp.text() return url, html except asyncio.TimeoutError: return url, "超时" except aiohttp.ClientError as e: return url, f"网络错误: {e}"第三重:结果容错。gather里某个任务失败不能让整个程序崩溃。上面的代码已经在函数内部try/except了,所以安全。如果你不想在函数内部处理,也可以:
results = await asyncio.gather(*tasks, return_exceptions=True) # 拿到结果后筛一遍,except Exception类型的结果就是失败任务这三重防护加完后,再去看全量代码,你会发现整体结构没有变得更复杂——这就是asyncio的魅力,代码看起来是同步顺序写的(顺着纸面从上往下读),实际执行时是并发的。
5. 常见问题与排查技巧实录
5.1 三个高频报错的定位与解决
我整理了一张自己在实际项目中踩过的坑排查表,基本都是排在最前面的高频报错:
| 报错信息 | 出现场景 | 原因 | 解决方法 |
|---|---|---|---|
SyntaxError: 'await' outside async function | 在普通def函数里写了await | await只能在async def函数体内使用 | 把外层函数改为async def,或者把相关逻辑移到协程函数里 |
RuntimeError: asyncio.run() cannot be called from a running event loop | 在Jupyter Notebook等交互环境,或在一个协程内部调用asyncio.run() | 当前线程已经有事件循环在跑了,而asyncio.run()要求新建一个 | 交互环境直接用await;协程内要启动子任务就用asyncio.create_task(),不要用run() |
RuntimeWarning: coroutine 'xxx' was never awaited | 调用了async函数但没有await它 | 协程对象没有执行机会,被垃圾回收时提示 | 确认调用处没有漏写await;如果是要“后台溜任务”,用create_task()包起来 |
RuntimeError: Event loop is closed | 循环内资源未清理,或多次使用asyncio.run()后对象还引用旧循环 | 事件循环已被关闭,但某些Task或回调还挂着 | 用asyncio.run()统一管理生命周期;需长期运行的任务考虑asyncio.new_event_loop()并显式set_event_loop() |
TimeoutError(asyncio.TimeoutError) | wait_for到了指定时间还没完成 | 超时后任务被取消或抛出异常 | 捕获后做降级处理,给下游服务提示;排查目标服务可能已假死 |
最后这个Event loop is closed是最容易在复杂的项目里抽风的。之前我在一个用Quart(asyncio版Flask)写的服务里,数据库连接池在应用关闭时没释放干净,重启就报这个错,排查半天才找到是连接池的引用没销毁。建议排查时先看“哪些资源还在使用旧事件循环”,连接、子进程、信号处理都会触发。
5.2 调试与测试的独家心得
调试异步代码比同步代码难在“时序不可控”。断点停在某个协程里时,其他协程还在跑,日志交错在一起,非常容易看晕。分享几个我长期在用的技巧:
**第一:日志里带上任务ID。**给每个协程传一个唯一标识(比如URL、编号),输出时始终带上,否则日志里全是交错行,没法判断哪一步对哪一步。
**第二:利用loop.slow_callback_duration或者代码内自己测量任务耗时。**我在包协程时习惯用装饰器记录每个任务耗时,方便对比哪个阶段是性能瓶颈。
**第三:测试异步代码用pytest-asyncio,别自己搞asyncio.run套浏览器。**这个插件支持给测试函数加标记,写起来还很简洁:
import pytest @pytest.mark.asyncio async def test_fetch_title(): async with aiohttp.ClientSession() as session: url, title = await fetch_title(session, "https://example.com") assert title第四:调试时把asyncio.get_event_loop()换成asyncio.get_running_loop()。这是Python 3.10以后被反复敲打的一个变化,老代码里到处是get_event_loop(),在新版本下会收到DeprecationWarning。
5.3 三个容易犯的“新手级”逻辑误区
除了报错,我更想多唠叨几个“代码能跑但设计错误”的误区。这些在项目评审里我可没少批评小朋友。
误区一:在协程里用time.sleep()。这个说到天荒地老也要强调,它会让当前线程睡过去,事件循环整个停摆,所有并发任务全部冻结。必须用await asyncio.sleep()。
误区二:把asyncio.open_connection()或aiohttp等库用于CPU密集任务。比如你会写“我用async爬了1000个网页,然后用BeautifulSoup解析”,但解析是CPU计算,这段会阻塞事件循环。数据量一大,该切出去还是得切出去(比如用run_in_executor)。
误区三:用gather开10000个任务不设限。异步开销小归小,但任务对象本身也有资源成本,一次性创建上万个任务会导致内存飙升。配合Semaphore限流才是工程级用法。
结束语:我的实战经验总结
如果你完整看完上面这些内容,现在应该有能力把一段同步的IO密集代码改写成asyncio版本了。我给自己的项目做代码评审时,碰到IO密集场景会下意识问一句——这里能不能异步化?但也会立刻反问——值得不值得?有时候任务量很小,一个同步requests.get()就搞定,强行引入asyncio徒增复杂度,那种场景“同步到底,顺手清晰”反而是最好的方案。
最后分享一个我在线上项目里常用的可靠小技巧:对异步任务统一封装一个“限时+重试+熔断”的函数,不要每个业务各写各的。脚手架搭好后,后续接任何一个第三方接口,都只需要写业务逻辑,超时和重试不用再重复考虑。具体而言,重试逻辑用一个简单的循环包在wait_for外面,重试间隔用asyncio.sleep,这样整个重试过程不会阻塞主流程。
异步编程的上手曲线确实比普通Python语法陡一点。但一旦理解了事件循环、协程、Task这三个概念的协作关系,你写出来的程序在IO密集场景下的提升是肉眼可见的。如果这篇文章帮你减少了一点点试错时间,那它就是有价值的——接下来,拿自己手头最耗时的那个脚本开刀,改成异步试试。