☰
Python异步编程实战:async/await、事件循环与协程并发优化
2026/10/15 2:21:05 网站建设 项目流程

因为我平时给人改代码改得多,发现一个很有意思的现象:很多人写Python爬虫或者调接口,代码逻辑没问题,就是慢得要命。尤其是一次要处理几百个网络请求、几十个数据库查询的时候,跑起来能从早等到晚。我最早也是这么过来的,直到我把项目里那些同步I/O改成了async/await,同一个任务从二十几分钟缩到了两分钟以内,那会儿才真正意识到——非阻塞I/O调用不是锦上添花的技巧,而是Python进阶路上绕不开的一道坎。

这篇文章想聊的就是这件事:async/await到底解决了什么问题,底层怎么跑起来的,以及你在真实项目里改造成异步时最容易踩的那些坑。适合学完Python基础语法、但一直没搞懂协程是怎么回事的读者。我会尽量把事件循环、协程调度、阻塞和非阻塞的差别用大白话讲清楚,同时给出能直接抄走的改造思路和排查方法。

1. 一次同步请求卡死全场的教训:为什么要重新理解I/O

先说个我今天还经常拿来当反面教材的例子。某开发者写过一个批量查询库存的系统,逻辑很简单:遍历一千个商品代码,逐个调第三方接口,拿到数据后写入数据库。第一版跑起来,程序界面卡住不动,进度条能一小时走不完。他一开始以为是接口限流,后来抓包一看,大部分时间都耗在等待响应上——每个请求发出去之后,程序就死等在那里,什么活儿都不干。

这里的根本问题不在网络,而在于同步I/O模型。

1.1 I/O密集和CPU密集的差别,直接决定你用不用异步

写Python的人常听到“I/O密集型任务适合用异步”,但很多人没细想过为什么。区分方法其实很简单:如果你的程序大部分时间在等外部资源——等网络包回来、等磁盘读写完成、等数据库返回结果——那就是I/O密集型;如果它大部分时间在占用CPU做运算、循环、处理数据,那就是CPU密集型。

同步代码处理I/O时,线程会进入阻塞状态,CPU时间片直接闲置。一次网络请求可能几十毫秒,看起来不多,但串行积累到一千个调用上,就成了几十分钟。这个浪费是纯粹的等待,不是计算。

异步要做的,就是在这些等待的缝隙里把CPU时间抢回来:当一个请求在等网络响应时,程序不闲着,而是转头去发起下一个请求。这样一千个请求同时都在路上,总耗时约等于最慢那一个,而不是一千个加在一起。

1.2 单线程并发听起来像玄学,但是真的可行

很多人第一次听到“单线程也能并发”时一脸怀疑,觉得并发不是要多线程才行吗。这里要把概念拆开:并行(parallelism)是多个任务真在同一时刻执行,需要多核CPU或多线程;而并发(concurrency)是多个任务在同一个时间段内交替推进,只要切换速度够快,宏观上看就是“同时在跑”。

asyncio做的就是单线程内的事件循环并发。每个协程执行到await处就主动让出控制权,事件循环把控制权交给下一个准备好的协程。网络请求的等待时间是并发的,CPU计算依然串行,但对我们遇到的大多数业务场景来说,等待时间的压缩才是收益的大头。

另外要说清楚一点,很多人把Python的GIL和异步混为一谈。GIL限制的是同一时刻只能有一个线程执行Python字节码,但I/O操作通常会释放GIL,所以多线程在某些场景下也能并行等待。异步的额外优势是调度开销更小——线程切换要内核参与,协程切换是用户态完成的,成本低一个数量级。这也是为什么高并发I/O场景下,异步方案往往比暴力起线程更稳。

2. async/await的本质:事件循环和协程的配合机制

想用好async/await,光会写async def和await不够,得先理解幕后调度的那颗心脏——事件循环(event loop)。

2.1 事件循环的工作方式,像食堂打饭的窗口

把事件循环想象成食堂里负责打饭的窗口阿姨。你点了菜(发起网络请求),阿姨记下订单,告诉你“做好了叫你”(注册回调、挂起协程),然后立刻服务下一个同学。每个同学的菜好了,阿姨就喊一嗓子,那个同学过来端走(恢复协程执行)。整个食堂只有一个阿姨,但所有同学不用干等着,该干嘛干嘛。

asyncio.run()是Python 3.7之后最推荐的程序入口,它内部帮我们做了三件事:创建新的事件循环、把传入的协程注册为入口任务、结束后关闭循环。我经常提醒初学者,不要在async函数内部再嵌套一层asyncio.run(),这会导致事件循环冲突,报错非常经典。

import asyncio async def main(): print("进入事件循环") await asyncio.sleep(1) print("协程恢复执行") asyncio.run(main())

asyncio.sleep(1)这里特别值得琢磨。同步代码里的time.sleep(1)是真把线程挂起一秒钟,期间什么都干不了;await asyncio.sleep(1)则是告诉事件循环“我先歇着,一秒钟后叫我”,这一秒钟事件循环可以去跑别的协程。这个差异就是异步魔法的起点。

2.2 可等待对象只有三类:协程、任务、Future

我在教学和代码审查里发现,很多人的理解卡在“await后面到底能跟什么”上。记住这个结论就好:await后面只能跟“可等待对象”(awaitable),而可等待对象就只有三种——协程对象(coroutine)、任务(Task)、Future。

协程对象是async def函数调用后返回的东西。注意,只调用不await,它会给你一个警告:协程从未被等待。很多人刚学的时候在这里翻车,因为async def foo()和def foo()的调用习惯完全不同。

async def fetch_data(): return 42 # 错误示范:这样不会真的执行 coro = fetch_data() # 正确做法:要么await,要么包成Task async def main(): result = await fetch_data() # 或 task = asyncio.create_task(fetch_data()) result = await task

asyncio.create_task()是创建Task的正确姿势,它会把协程包装成一个独立调度的任务,立刻提交给事件循环。Task可以理解为“被事件循环盯上了的协程”。Future则是更底层的概念,你可以把它当做一个“还没到手的值的占位符”,通常你不会直接碰它,但理解它有助于看懂第三方异步库的源码。

2.3 await挂起点:协程生命周期的关键转折

await的真正含义是“在这里让出执行权”,这也是事件循环能调度的基础。协程的执行过程可以看成:开始执行 → 遇到await挂起 → 事件循环去做别的事 → 条件满足后被唤醒 → 从挂起点继续往下走。

这个机制在Python里面是有状态支撑的。每个协程本质是一个生成器对象(3.5之前的原始实现就是基于yield from),协程的暂停和恢复就是生成器的send操作。await一个可等待对象时,Python会在底层自动调用它的__await__方法,把控制权交给事件循环。

实际写代码的时候,一个常见的误区是认为await能提速。await本身不做任何优化,它只是给了别人“插队”的机会。真正提速的是你同时挂起了多个协程,让它们在等待中重叠。一个人请求要等十秒,你同时发了一百个请求,总耗时还是十秒——这才是异步的收益逻辑。

3. 一个真实改造案例:把同步API调用批量改成异步

光谈原理容易飘,我拿一个具体场景完整走一遍改造流程。假设我们要抓取某个资源站的一千个页面,每个页面解析后提取链接。同步代码写起来非常直白,但跑起来就想砸电脑。

3.1 改造前的代码模式:看清阻塞点在哪

import requests import time urls = [f"https://example.com/item/{i}" for i in range(1000)] def fetch_sync(url): resp = requests.get(url) return resp.text start = time.perf_counter() results = [fetch_sync(url) for url in urls] elapsed = time.perf_counter() - start print(f"同步耗时: {elapsed:.2f}s")

这个版本的时间主要花在requests.get()的等待上。每个请求大约200毫秒,一千个串行就是200秒上下。问题一眼就能看出来:列表推导里的每次requests.get都让程序完全停住,其他请求没法插队。

3.2 改用aiohttp后的完整异步版本

import asyncio import aiohttp import time urls = [f"https://example.com/item/{i}" for i in range(1000)] CONCURRENCY = 50 # 限制同时请求数量,防止把对方打爆 async def fetch_with_semaphore(session, sem, url): async with sem: async with session.get(url) as resp: return await resp.text() async def main(): sem = asyncio.Semaphore(CONCURRENCY) async with aiohttp.ClientSession() as session: tasks = [fetch_with_semaphore(session, sem, url) for url in urls] results = await asyncio.gather(*tasks) return results start = time.perf_counter() results = asyncio.run(main()) elapsed = time.perf_counter() - start print(f"异步耗时: {elapsed:.2f}s")

改造后时间大约能压到5秒左右。有两个细节新手特别容易漏:第一,asyncio.gather(*tasks)接收的是多个位置参数,所以列表要用星号展开;第二,信号量Semaphore(50)是必须的,没有它一千个请求同时涌出去,对方服务器大概率会拒绝服务,你的IP还可能被临时封掉。

3.3 为什么这里用gather而不是逐任务await

有人会问,不用gather,改成循环里逐个await task行不行?行,但就变成串行了——每个await task都会等这个任务完成才继续下一个,起不到并发效果。gather的语义是“一起等这批任务全部结束”,是并发等待;逐个await是“等完一个再等下一个”,是串行等待。

如果你需要“谁先完成就先处理谁”的场景,比如爬虫里先拿到第一个响应就先解析,可以考虑asyncio.as_completed()。它返回一个迭代器,每次产出最早完成的任务,适合做增量处理和进度刷新。二者各有适用场景,我在下面做了个简单对比:

方法行为最佳使用场景
asyncio.gather()等待所有任务完成,返回结果列表批量请求后统一汇总
asyncio.as_completed()按完成顺序逐个产出结果边下边解析、实时展示进度
asyncio.wait()细粒度控制等待条件,可设置超时需要手动管理任务集合的复杂场景

4. 踩坑实录:异步代码写起来顺,跑起来全是意外

异步改造不是把def换成async def、把requests换成aiohttp就万事大吉。我自己和身边人踩过的坑可以单独写一篇,这里挑几个杀伤力最大的教训。

4.1 异步代码里混入同步阻塞调用,性能直接打回原形

这是异步改造里最常见的反模式:表面上是async def函数,内部却调用了time.sleep()或者requests.get()这类同步阻塞库。事件循环被一个阻塞调用卡住,整个循环里的所有协程全部停摆,并发就剩个空壳。

import asyncio import time import requests async def bad_fetch(url): # 错误:time.sleep是同步阻塞,会卡住整个事件循环 time.sleep(0.5) resp = requests.get(url) return resp.text async def good_fetch(session, url): # 正确:对应异步版本 await asyncio.sleep(0) async with session.get(url) as resp: return await resp.text()

排查这类问题有个笨办法但很有效:把并发数从1逐渐往上调,观察耗时曲线。如果并发加到20后时间几乎没变,多半是有同步阻塞调用混进去了。更省事的做法是写代码时定个规矩:async def函数体内不允许出现time.sleep、requests.get、pymysql连接这类高频同步语句。

4.2 创建了Task却忘了await,代码里全是“Task was destroyed but it is pending”

这个坑的报错信息相当有迷惑性——“Task was destroyed but it is pending”一看还以为是内存问题。其实原因是:你用asyncio.create_task()创建了任务,但事件循环结束前没来得及把它await完,任务被强制销毁了。

常见场景是在main()里create_task之后直接返回了,没有gather或await那些Task。解决方式有两个:要么在程序退出前统一await asyncio.gather(*tasks);要么确保create_task之后立刻被引用住并最终等待。千万不要把Task对象只是扔在列表里就结束主协程。

这里还要说一个内存引用的问题。asyncio.create_task()会持有任务对象的强引用,如果你创建大量Task后又不保留引用,某些情况下任务可能被垃圾回收机制提前处理掉。稳妥做法是始终用一个列表或集合显式管理所有Task。

4.3 回调风格和await风格混淆,代码越写越乱

很多人接触异步时先看的是旧教程,里面大量使用add_done_callback这种方式处理结果。不是说它不行,但和await混着用容易把代码结构搞得一团糟。await风格的核心价值就是让异步代码长得像同步代码,顺序读下去就懂逻辑。

我的建议很简单:新代码一律用await风格。只有在对接不提供协程接口的第三方库时,才需要用asyncio.get_event_loop().run_in_executor()把同步函数丢进线程池,或者用loop.call_soon()这类底层API。前者博主级写法是:

import asyncio async def main(): loop = asyncio.get_running_loop() # 把同步的requests.get丢到默认线程池执行,避免阻塞事件循环 result = await loop.run_in_executor(None, requests.get, "https://example.com")

这段代码解决了“第三方库没有异步版本”的尴尬场景。但要注意,run_in_executor(None, func, *args)里的None表示用默认线程池,如果任务量很大,最好传入一个自定义的ThreadPoolExecutor来限制线程数。

4.4 超时控制不做好,gather永生等待

异步代码并发能力上去了,另一个问题就冒出来了:某个请求如果卡死不返回,await asyncio.gather(*tasks)会一直等下去,整个程序像死锁一样挂着。同步代码里顶多一个请求超时抛异常,异步代码里一个顽疾任务能让所有任务跟着陪葬。

处理方式有几种,最推荐的是给gather加return_exceptions参数或者用asyncio.timeout()(Python 3.11+)包住整个等待过程:

import asyncio async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): timeout = asyncio.timeout(10) try: async with timeout: results = await asyncio.gather(*(fetch(session, url) for url in urls)) except TimeoutError: print("整体超时,干点别的逻辑")

还有一点,asyncio.wait_for(coro, timeout)也可以实现单个任务的超时控制,但它的行为是取消这个任务。如果你用的是asyncio.timeout()配合asyncio.gather(),取消行为更可控。工程上我习惯给每个请求单独设超时,再给整体流程设一个兜底超时,双保险。

5. 并发上限、任务管理和其他工程化细节

很多项目从“能跑”到“能上线”,差的就是工程化的细节。异步代码尤其如此,因为它的并发能力强了之后,问题从“慢”变成了“不可控”。

5.1 信号量限流:别让你的程序变成攻击工具

我见过不止一个新手的爬虫程序把别人网站打崩的。一千个请求同时冲过去,对方服务器CPU直接跑满,回来就封你的IP段。这不是技术问题,是缺了基本限流意识。

asyncio.Semaphore是异步版的信号量,用法和threading.Semaphore类似,区别在于使用async with sem而不是普通的with sem。每次进入协程先尝试获取信号量,超过上限就等待,其他请求完成后释放。

async def bounded_fetch(session, sem, url): async with sem: # 这里面的代码最多同时执行CONCURRENCY份 async with session.get(url) as resp: return await resp.text()

限流参数怎么定?没有标准答案,取决于目标服务的承载能力。比较稳妥的做法是从小往大试,比如5、10、20。或者设置一个合理的延迟,配合限流,让请求均匀分布。别贪,稳定才能跑得久。

5.2 不要把CPU密集型计算放进协程里

这是个特别容易混淆的点。异步适合I/O密集,但如果你在一个协程里做了大量列表推导、循环、数据解析,这些计算依然占用单线程的CPU时间。更麻烦的是,这些计算不主动让出控制权,事件循环里的其他协程就只能干等。

处理CPU密集逻辑的标准姿势是交给asyncio.to_thread()(Python 3.9+)或者进程池。前者把协程里的耗时计算挪到线程池执行,适合中等负载;后者适合真正的计算瓶颈,但进程间通信会有额外开销。

import asyncio def heavy_calc(data): # 模拟CPU密集 return sum(i * i for i in data) async def main(): data = list(range(1000000)) # 不要直接在这里跑heavy_calc,会卡住事件循环 result = await asyncio.to_thread(heavy_calc, data) print(result)

判断标准很简单:如果一个操作耗时超过几十毫秒,而且它不是I/O等待,就要警惕它是否会拖垮整个事件循环。

5.3 结构化和非结构化并发的选择:从create_task到trio风格的启示

asyncio里有句名言:“如果你需要gather,说明你的结构已经不对了。”这句话其实来自对结构化并发理念的倡导——与其手动管理一堆Task,不如在代码结构上就把并发边界划分清楚。

我自己的习惯是,能用async with管理资源的地方绝不手动release;能用TaskGroup管理任务的地方绝不裸用gather。Python 3.11起asyncio.TaskGroup已经是个很成熟的选择,它在任务出错时的取消行为比gather更干净,另一个任务抛异常时会自动取消组内其他任务,避免僵尸任务残留。

import asyncio async def main(): async with asyncio.TaskGroup() as tg: task1 = tg.create_task(fetch(url1)) task2 = tg.create_task(fetch(url2)) # 所有任务完成后继续执行,有异常则统一处理

这比手工写[asyncio.create_task(...) for i in range(n)]再gather要清晰得多。代码审查的时候看到老式的手工管理Task和回调混合代码,我基本都会建议重构成TaskGroup版本。

5.4 第三方库的异步适配情况,一表看清

做异步改造前,先确认自己的核心依赖库有没有异步支持,避免中途换方案。我整理了常见库的适配状态,仅供参考:

库或工具同步版本异步支持情况备注
HTTP客户端requestsaiohttp / httpx.AsyncClienthttpx支持同步异步双接口,改造成本低
数据库驱动pymysqlaiomysql / asyncmyasyncmy更快但生态不及aiomysql
PostgreSQLpsycopg2asyncpg性能很能打,但接口差异不小
MongoDBpymongomotor几乎无缝切换,适合新手体验
Redisredis-pyaioredis(已并入redis-py异步接口)新版redis-py自带async支持
FastAPI/FlaskFlaskFastAPI / QuartFastAPI原生支持async端点

一个替代思路:如果你不想把所有代码全部改造成异步,可以只在一个服务入口处使用异步,比如用FastAPI写接口,内部把同步逻辑丢给线程池。慢是慢一点,但侵入性低,适合老项目渐进式改造。

6. 从同步到异步的心理转换与性能验证方法

代码改造完成不等于任务完成。你需要验证异步真的带来了收益,以及收益到底来自哪里。我实践下来有一套简单的验证方法。

6.1 用粗暴实验判断瓶颈在哪里

先跑一个基线版本:同步代码处理一百个请求的耗时。再跑异步版本:带信号量限流,把并发数分别设为10、20、50,记录耗时。如果并发从10调到50,耗时基本不降,那说明瓶颈不在并发等待,而在于某个同步阻塞操作混在异步代码里。

如果异步版本反而更慢,先查是不是任务粒度太小了。协程并不是零成本的,几千个微小任务在事件循环里来回切换,开销可能超过收益。经验值是单个协程至少要处理一个需要几十毫秒以上的等待操作,才值得异步化。

6.2 事件循环里埋日志,观察调度顺序

调试异步代码和调试同步代码有个很大的不同:错误堆栈不一定能帮你定位到任务的全貌。最实用的调试方法是在关键点加日志,观察协程被唤醒的顺序。asyncio官方就提供了一个辅助工具asyncio.run()配合sleep(0)来强制切换,或者使用PYTHONASYNCIODEBUG=1环境变量启动程序,它会启用事件循环的调试模式,可检测到一些常见问题,比如忘记await的协程和过长的回调。

PYTHONASYNCIODEBUG=1 python your_script.py

这个模式下,如果某个协程执行耗时超过100毫秒,它会警告“Executing something took more than 100ms”,这往往是混入同步阻塞或不合理的CPU计算的最直接信号。

6.3 给协程写单元测试:别把async当黑盒

异步代码的测试和同步差异很大,但没那么复杂。核心是unittest的IsolatedAsyncioTestCase或者pytest配合pytest-asyncio插件。测协程时,直接在测试函数里await目标协程即可,但要记得测试框架本身需要跑事件循环,不是把测试函数写成async def就行。

import asyncio import unittest class TestFetch(unittest.IsolatedAsyncioTestCase): async def test_fetch_returns_expected(self): result = await some_async_function("test_url") self.assertEqual(result, expected)

轻量级的脚本项目不想引测试框架,也可以用asyncio.run()包一层来验证关键函数。不管哪种方式,关键是别只测“不报错”,要测并发行为本身。比如同时启动三十个任务,看它们是不是真的交错执行了,可以用asyncio.Event加asyncio.sleep(0)来制造可控的调度窗口。

7. 再说几个补充的底层细节,帮你写代码时心里有底

这部分内容更多是理解性质的,我平时写异步代码时用得上的底层模型,整理出来分享给大家。

7.1 async/await的关键字,确实是语法糖,但不是普通语法糖

从实现上讲,async def定义的是一个原生协程(native coroutine),它和生成器共享底层设施,但行为不同——原生协程不能手动调用next(),只能通过await驱动。Python的dis模块反汇编可以看出端倪,async def函数的字节码带有ASYNC_GEN等指令标记,编译器级别的支持远比字符串补丁式的实现高效。

了解这层实现有个好处:当你在网上搜资料时,能看到很多老帖子讲“协程就是生成器”。那是Python 3.4时代的asyncio基于yield from的遗产。现代Python的协程已经是一条独立的实现路径,不能简单用生成器的思维套用。

7.2 Python版本和asyncio API的演进关系

asyncio的API变化非常大,网上很多教程还在教Python 3.6时代的写法,比如asyncio.get_event_loop()配合loop.run_until_complete()。这些写法在新版本里不一定报错,但已经有了更推荐的替代品。

我建议读者直接按照你正在使用的Python版本查阅官方文档。Python 3.10之后很多老API标识为deprecated,3.11引入了asyncio.timeout()和TaskGroup,3.13进一步稳定了免GIL的多线程方案和部分异步接口。如果你读的教程经常让你用loop = asyncio.get_event_loop()再手动管理循环,多半可以换了。

7.3 事件循环真的能“同时”处理多个socket吗

这是初学者最喜欢问的问题之一。答案是:事件循环靠操作系统提供的I/O多路复用机制来感知多个socket的状态。Linux上是epoll,macOS上是kqueue,Windows上是IOCP。asyncio的底层selector模块会把这些机制封装成统一的接口,事件循环只需要问“哪些socket可读/可写了”,然后挨个处理。所以你看到的一千个并发连接,实际上是事件循环在高频轮询“谁就绪了”。

这也是为什么await后面接的必须是异步I/O:普通的requests.get不会向事件循环注册任何socket事件,事件循环根本不知道该等谁。只有aiohttp这种库内部用loop.create_connection()注册了回调,才能实现真正的非阻塞等待。

8. 踩了无数坑之后,我会怎么开始一个新项目

最后聊点个人习惯。现在我接到一个新的I/O密集型项目,第一步不是写代码,而是先想清楚:这个项目的瓶颈在哪里,是等待多还是计算多;需要依赖什么第三方库,它们有没有异步支持;有没有并发安全的问题需要提前考虑。

想好了之后,我会先写一个最小可运行的异步原型,比如只是同时发十个请求,确认链路通了,再慢慢加并发和业务逻辑。说白了,异步不是银弹,它的价值在于把骑虎难下的等待时间拿回来。适合用异步的场景,收益非常明显;不适合的,硬上异步反而会把简单问题复杂化。

如果你正在学这一块,我建议你从一个小爬虫或者接口轮询场景开始练手,先把asyncio.run、create_task、Semaphore、gather这四样跑熟,再去看事件循环的源码也不迟。代码这东西,读一百篇教程不如动手跑通一个异步Demo,跑通之后你再看任何开源项目的异步代码,都会觉得亲切得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询