Python异步编程入门:从async/await到并发实践

发布时间:2026/8/10 8:57:27
Python异步编程入门:从async/await到并发实践 拿一个简单的网络请求来做对比。同步代码里你发起请求后程序就卡在原地直到响应返回才继续执行下一行。异步代码则不同它发起请求后立刻得到一个“挂起”的对象控制权交还给事件循环让其他任务先跑。等网络响应真正到达事件循环再回来唤醒这个挂起状态继续往下走。这就是 async/await 的核心本质它不是让程序变快而是让程序在等待 I/O 时不再空转把宝贵的 CPU 时间让给真正需要计算的任务。很多人误以为异步是 Python 的某个魔法其实它只是一套约定俗成的协作式调度协议。你写的 async def 函数是协程await 是主动让出控制权的信号而事件循环就是那个负责调度所有协程的“交警”。理解了这个模型你就明白了为什么异步编程中绝对不能出现阻塞调用——一旦某个协程里用到 time.sleep() 或同步 socket整个事件循环都会被卡死所有其他协程全部停摆。一个协程的诞生记先看最简单的例子async def hello(): print(Hello) await asyncio.sleep(1) print(World)执行asyncio.run(hello())时Python 解释器会创建一个事件循环把 hello 协程放进去执行。遇到await asyncio.sleep(1)协程把控制权交还给事件循环注册一个 1 秒后的定时器回调。这 1 秒内事件循环可以调度其他协程。1 秒后定时器到期事件循环恢复 hello 的执行。整个过程清晰明了。但这里有个关键细节仅调用协程函数不会执行任何代码它只是创建了一个协程对象。必须通过asyncio.run()、asyncio.create_task()或await将它接入事件循环代码才会真正跑起来。忘了这一步程序只会给你一个 RuntimeWarning告诉你协程从没被等待。更深一层async def 语法糖背后其实是基于生成器和 Future 的历史演变。早期 Python 用asyncio.coroutine和yield from后来 3.5 引入原生协程3.7 又加入了asyncio.run()。语法越简洁底层越复杂。当你写await obj时Python 会检查 obj 是否是 awaitable 对象——原生协程、Task、Future或者实现了__await__方法的对象。理解这一层才能明白为什么await能接住这么多不同类型的东西。事件循环孤胆英雄还是幕后黑手事件循环是异步编程的心脏但它也常成为新手最大的困惑点。在 Python 3.10 中asyncio.run()会自动为你创建并关闭一个事件循环非常省心。但如果你在 Jupyter 或某些 GUI 环境中可能会遇到“事件循环已在运行”的报错。因为 Jupyter 本身就跑着事件循环你不能再asyncio.run()一次。这时你需要使用asyncio.get_running_loop()来获取当前循环然后用loop.create_task()或asyncio.create_task()把协程塞进去。事件循环是单线程的它并不是并发执行协程而是快速切换让每个协程在等待 I/O 时主动让位。假如某个协程内部有 CPU 密集型的计算比如循环 10 亿次事件循环根本无法抢占它——这就是协作式调度的致命弱点。解决之道是让 CPU 密集任务暴露给事件循环。你可以用asyncio.to_thread()把它扔进线程池或者用loop.run_in_executor()交给独立的进程池。这样异步程序就能保持“秒级响应”的假象。但要认清代价线程池和进程池的引入让异步模型变得不再纯粹你得面对线程安全、数据共享、进程通信这些新问题。所以异步编程的黄金准则是不要用 async 做 CPU 密集的事那是多进程的领域。Task真正的并发单位裸协程只是单次执行的小脚本Task 才是异步并发的主角。asyncio.create_task(coro)会把协程包装成一个 Task立即调度到事件循环中返回一个 awaitable 对象。你可以同时创建多个 Task然后用asyncio.gather()或asyncio.wait()等待它们全部完成。async def fetch(url): async with aiohttp.ClientSession() as session: async with session.get(url) as resp: return await resp.text() async def main(): urls [...] # 许多 URL tasks [asyncio.create_task(fetch(url)) for url in urls] results await asyncio.gather(tasks)这段代码就能高效地并发请求多个 URL。如果改成同步顺序请求总耗时将是所有请求延迟之和用 gather 并发后总耗时接近最慢的那个请求。这正是异步 I/O 的魅力。但 Task 也有陷阱。gather()默认是“等待全部完成任一出错则全部取消”的龟缩策略。如果有任务抛异常gather 会立即取消其他还没完成的任务然后抛出异常。这让调试变得棘手因为异常并没有告诉你具体哪个 URL 失败了。更好的做法是给每个任务包一层异常捕获或者使用return_exceptionsTrue参数让 gather 把异常当作结果返回。生产环境中的异步代码永远要显式处理每个任务的异常绝不能让一个烂任务毁掉整批请求。另外注意Task 的执行是“即时调度”的但调度顺序并不一定是你创建的顺序。事件循环会在内部维护一个就绪队列按优先级和放入时间决定谁先跑。不要依赖 Task 的完成顺序除非你用锁或队列显式同步。await 之外的秘密Future 与回调很多人学了 async/await 就以为这是全部。其实在底层Future 对象才是连接协程和事件循环的桥梁。Task 继承自 Future但它多了一个协程包装器。你可以手动创建一个 Future然后在某个地方给它设置结果这样等待它的协程就会恢复。loop asyncio.get_running_loop() future loop.create_future() asyncio.ensure_future(some_coro_that_sets_result(future)) await future这种模式在实现跨协程通信、桥接回调式库时特别有用。Future 就像一把“异步狼烟”——协程等着狼烟升起某个外部事件负责点火。例如你想把第三方回调库比如旧版 Redis 客户端接入 async 世界就可以拿 Future 当适配器在回调里设置 future 结果协程这边await future就能无缝接上。不过手写 Future 容易出错。如果永远没人设置结果协程就永远挂起程序卡死。所以实践中更推荐用asyncio.wait_for()给等待加上超时。超时后wait_for会取消 Future 对应的任务避免资源泄漏。所有 await 操作都应该问问自己如果永远等不到会发生什么合理的超时机制是生产级异步程序的必备素养。并发实践从爬虫到 Web 服务学完语法来看真实场景。最经典的异步实践是网络爬虫。用 aiohttp 同时抓取上百个页面速度比 requests 顺序爬快几十倍。核心代码就是上面的 gather 模式每个 URL 一个 Task全部并发执行。但要注意控制并发度——如果一次创建 1000 个 Task很可能触发目标网站的限流或让本地 socket 耗尽。这时需要信号量 Semaphoresem asyncio.Semaphore(50) async def safe_fetch(url): async with sem: async with aiohttp.ClientSession() as session: ...Semaphore 限制同一时刻最多 50 个协程进入临界区。没有并发限制的异步程序就像没有门禁的地铁站早晚踩踏事故。信号量、连接池、超时、重试这些治理手段是异步程序员必须掌握的工具箱。Web 服务是另一个主战场。FastAPI、Starlette 和 aiohttp 都基于异步框架。当你用 async def 定义路由处理函数时框架会在事件循环中执行它从而能同时处理大量长连接比如 WebSocket 或流式接口。但请注意如果你的 async 路由里用了同步数据库驱动如 psycopg2那么这个路由会瞬间阻塞整个事件循环所有其他请求全部遭殃。必须在 async 路由里使用异步数据库驱动asyncpg、aiomysql或者主动把同步调用扔到线程池去。很多新手项目栽在这一步老板看并发上不去你查半天发现罪魁祸首是那个time.sleep()或者requests.get()。陷阱大赏那些让你熬夜的 Bug异步编程的坑又多又深这里列举几个高频事故。陷阱一在协程里用 time.sleep() 而不是 await asyncio.sleep()。time.sleep 会阻塞线程事件循环无法转动效果等同于让所有协程排队。只要在 async 代码里出现 time.sleep你的异步程序就已经名存实亡。陷阱二忘记 await 就直接调用协程函数。这会产生一个未等待的协程对象触发 RuntimeWarning而且代码根本不会执行。很多人在调试时发现函数没跑排查半天才发现少写个 await。陷阱三共享可变状态。由于事件循环是单线程协程之间不会真正并行所以没有数据竞争问题。但归功于 await 的存在一个协程可能在执行中途让出控制权另一个协程可能同时修改同一个对象。比如count 0 async def increment(): global count temp count await asyncio.sleep(0.001) count temp 1两个协程同时执行 increment可能都读到 count0都写回 1最终 count 变成 1 而不是 2。这看起来像并发问题其实是因为在 await 处发生了切换。解决方案是使用 asyncio.Lock 保护临界区。陷阱四处理任务时用asyncio.create_task()但没保存任务引用。Python 的垃圾回收可能会立即销毁尚未完成的任务导致任务永远跑不完。官方文档提醒要把任务保存到集合中避免被垃圾回收。陷阱五盲目使用 gather() 而不监控单个任务异常。上面说过gather 会让整个批次失败。最好用asyncio.wait()配合FIRST_EXCEPTION等模式或者给每个任务包裹一个返回结果或异常的函数。异常处理永远不要只做兜底要保留上下文。多线程 vs 多进程 vs 异步选谁很多人纠结该用哪种并发方式。简单粗暴地划分I/O 密集且需要极低延迟切换用异步I/O 密集但代码老旧不易改造用多线程CPU 密集用多进程。但真实世界没那么泾渭分明。异步的最大优势是内存开销小、切换上下文快。一个 Task 对象只需要几 KB 内存而一个线程栈一般要几十到几百 KB进程更重。在单机需要支撑数万并发连接时异步几乎是最优解。但异步的代价是心智负担大所有 I/O 函数必须异步化一旦遇到同步阻塞就得绕道。多线程则相对直白用 threading 或 concurrent.futures ThreadPoolExecutor 就能把同步代码跑起来。但线程切换开销大GIL 又限制了 CPU 密集的并行线程安全还要靠锁。多线程解决的是“阻塞时的并发”异步解决的是“等待时的并发”。如果你发现线程池里全是睡大觉的线程就该考虑改用 asyncio 了。多进程则完全绕开 GIL每个进程有独立解释器和内存适合 CPU 密集场景。进程间通信用队列或管道但数据传递要序列化开销不小。在 Python 并发世界里没有银弹只有“什么场景选什么工具”。最优架构往往是混合模式用异步处理高并发 I/O用进程池处理计算密集的重活用线程池桥接不支持异步的阻塞库。这不是炫技而是各取所需。进阶心智从 async 语法到异步思维很多入门者学会了语法却写不出好代码因为他们还在用同步思维。异步不是把 def 改成 async def 那么简单。真正的异步思维是“倒置”的你不再站在一个任务的视角请求数据而是站在事件循环的视角调度任务。你关心的是哪个任务在等什么什么时候能恢复资源是否有限制异常如何传播。一个实用的技巧是设计“任务蓝图”。写复杂异步程序时先画出数据流哪些操作可以并发哪些有先后依赖哪里需要通信哪里需要合并结果。然后规划协程间的交互方式是用 gather 收集用 Queue 分发还是用 Future 等待特定条件。异步编程还逼着你养成“显式管理生命周期”的习惯。协程不是凭空消失的要么正常结束要么被取消要么等待超时。每个 async 函数都应该有清晰的退出路径。最糟糕的代码不是写错的代码而是永远挂起的协程——它们不报错不退出默默吞噬着你的内存和注意力。对于长跑型程序来说定期监控事件循环中活跃任务的数量能帮你尽早发现泄漏。实战打磨用 asyncio 写一个健壮的并发下载器把以上所有知识点融会成一个有节奏的练习。想象一个需求下载 2000 个文件每个文件几十 MB使用 HTTPS需要断点续传和限速。第一步设计并发度。2000 个文件不可能同时打开设置信号量为 20 即可。每个文件内部可以用 Range 头分块下载但每个文件内部的分块也受信号量约束否则文件多了同样撑爆连接。第二步超时和重试。为每个下载任务设置asyncio.wait_for(task, timeout60)如果超时则捕获 asyncio.TimeoutError做指数退避重试。重试次数上限 5 次超过就记录失败。超时不能太长否则一个坏文件拖住整个队列也不能太短正常慢速文件会被误杀。第三步防阻塞。文件写入磁盘不能用阻塞式的open().write()因为写磁盘也可能阻塞事件循环。最好用aiofiles或者把写盘操作转移到线程池。I/O 不仅是网络本地磁盘同样会阻塞事件循环这点常被忽略。第四步监控和取消。用一个定时器协程定期打印每个任务的状态、已下载字节数、当前速率。当用户按下 CtrlC 时优雅地取消所有任务保存断点信息到文件下次启动时读取断点用 Range 续传。实现这样的下载器你会把 Task、Semaphore、Event、Lock、wait_for、asyncio.run 全部过一遍对异步的理解就会从“语法”升维到“系统”。真正的异步高手不是会写很多 async 代码而是能灵活组合这些原语让整个程序像水流一样顺畅流动既不阻塞也不浪费更不崩溃。当 async 遇到 aio生态的威力Python 异步生态已经相当成熟。网络层有 aiohttp、httpx 的 async 模式Redis 有 aioredis数据库有 asyncpg、aiomysql任务队列有 aio-pika、arq。甚至文件系统都有 aiofiles。如果你需要跟 WebSocket、gRPC 打交道也有对应的异步库。选对库比写对语法重要百倍——自己造轮子实现一个异步 MySQL 客户端那是在浪费生命。但也要警惕生态碎片化。有些库的 async 接口实现得不好内部其实用线程池包装了同步 I/O导致“伪异步”。怎么识别看源码或文档如果它在 async 函数里用了run_in_executor包装一个阻塞调用那么虽然不会卡住事件循环但并发能力仍受线程池大小限制且线程切换开销比纯异步高得多。一个真正的高性能 async 库应该是从底层 I/O 到协议解析全部基于 selectors 和回调没有任何阻塞调用。在挑选异步库时除了看 Star 数更要看它是否原生支持 asyncio 的取消机制是否适配超时是否处理了底层资源清理。这些细节决定了生产环境中它是否稳定。建议先在排障工具上多花点时间比如把调试的 flagPYTHONASYNCIODEBUG1开起来它能帮你发现未等待的协程、慢回调等隐患。最后直面异步的代价所有技术都有代价异步也不例外。异步编程的复杂度并不低于多线程它只是把线程切换的复杂性换成了协程调度的复杂性。你不需要担心线程安全了但你需要担心协程之间的状态共享、任务取消、超时泄漏、事件循环被阻塞。调试异步代码很难因为错误发生时协程栈往往已经被切换多次回溯信息支离破碎。这也是为什么性能越好心智负担越重。但无论怎么权衡异步已是大规模 I/O 服务的事实标准。Kafka、Redis、Nginx 这些底层基础设施无一不是事件驱动的异步架构。Python 的 asyncio 让你也能在应用层用上同样的思想。别指望学一天就上手生产环境异步编程需要大量实践和踩坑。如果你愿意沉下心来把每一个挂起、每一个超时、每一个资源泄漏都搞清楚你会发现异步不仅仅是语法更是一种对整个计算世界何时运行、怎么运行的深入思考。写到这里愿你下次写 async 的时候能看清事件循环的脉搏听到任务唤醒的回响。毕竟掌握异步的第一天你就告别了“程序等我”的旧时代进入“我调度程序”的新纪元。