资讯动态

Python asyncio高并发实战:从事件循环到安全开发全指南

发布时间:2026/10/24 2:40:17 来源:尧图企业网站定制
很多人一提起Python做高并发第一反应就是“Python有GIL多线程是假的高并发不行”。这个说法在CPU密集型场景下确实没毛病但在IO密集型场景里结论完全反过来了——Python的asyncio异步IO模型恰恰是用极轻量的协程去扛住成千上万个并发连接而且能压榨出非常可观的吞吐量。但问题在于能写出一个能跑的asyncio程序的人很多能写出一个在高并发下稳定、不崩、不丢任务、不泄漏资源的程序的人很少。“安全开发”这四个字不是指网络安全而是指在协程满天飞、任务到处调度的时候你的代码依然能保持逻辑正确、资源可控、异常不吞、退出干净。这篇文章我打算把asyncio从核心机制到高并发实战完整过一遍中间穿插我自己在项目里踩过的坑和验证过的写法适合刚接触异步编程的初学者也适合已经在用asyncio但总觉得哪里不太稳的进阶开发者。1. 为什么偏偏是asyncio高并发场景下的另一条加速路径1.1 高并发不等于多线程GIL与IO密集型的现实先说一个被讲烂但又绕不开的概念GIL全局解释器锁。在标准CPython里同一时刻只有一个线程能执行Python字节码。多线程程序如果跑的是纯计算任务本质上是把一份CPU时间片切成几份轮流用不仅没有加速还会因为线程切换和锁竞争反而更慢。但高并发场景里最耗时间的往往不是计算而是等待——等待数据库返回、等待外部API响应、等待文件读写完成。这些等待期间CPU几乎闲着线程被挂起白白浪费了切换成本。asyncio的思路完全不同它在同一个线程内用事件循环调度协程遇到IO等待就主动让出控制权让其他协程先跑等IO事件回来了再恢复执行。整个过程没有传统意义上的线程切换开销极小。1.2 asyncio适合什么场景不适合什么场景搞清楚边界才能选对工具。我的经验是asyncio最适合这三类任务网络IO密集型爬虫、API网关、消息推送、长连接服务高并发连接数场景WebSocket服务器、聊天室、实时通知系统多IO混合编排需要同时等待多个不同来源的数据再聚合处理不适合的场景也很明确纯CPU密集型计算图像处理、复杂的数值运算、大量同步阻塞库调用且没有异步替代品的场景、需要利用多核做并行计算的任务。这些场景要么用多进程要么用线程池做隔离硬套asyncio反而会踩更深的坑。1.3 与多线程/多进程的横向对比对比维度asyncio协程多线程多进程并发单位协程极轻量线程中等进程重量级切换方式事件循环调度用户态操作系统调度操作系统调度内存开销极低一个协程几KB每个线程约8MB栈空间独立内存空间CPU密集任务不适用受GIL限制可以利用多核IO密集任务非常适合可用但切换开销大通信成本高调试难度中等追异步栈稍费劲中等锁问题多较低但通信复杂这里多提一句如果你遇到一个asyncio做不了的事先别急着放弃异步方案很可能只是你用的某个库是同步阻塞的换个异步客户端库就能解决。比如用httpx的异步模式替代requests用asyncpg替代psycopg2或它的异步变体。2. asyncio核心机制图解事件循环、协程与任务的关系2.1 事件循环是那个永不休息的调度员asyncio整个运行机制的核心是事件循环Event Loop你可以把它想象成一个超级高效的项目经理手头有一堆待办事项协程每件事都处于“等待某个条件”的状态——等IO、等时间、等另一个协程的结果。事件循环不断扫这些待办事项一旦某个条件满足了就把对应的协程往下推进一步。在Python 3.7以上版本你不需要自己手动创建事件循环直接使用asyncio.run()即可它会负责创建循环、执行入口协程、最后关闭循环。这个设计省掉了很多琐碎的管理工作但理解事件循环的存在仍然很重要因为后面很多诡异的bug都源于“事件循环被阻塞”或“多个事件循环上下文混乱”。2.2 协程、任务与Future三个容易混淆的概念协程Coroutine用async def定义的函数调用后返回的对象。它自己不会自动执行必须被await或者被包装成任务交给事件循环。任务Task把协程提交给事件循环后返回的调度单元。它独立运行可以被取消、被等待完成。用asyncio.create_task()创建。Future一个底层概念代表一个“将来会完成”的结果。Task是Future的子类。你在日常开发中一般接触不到Future但理解Future能帮助你理解await的本质——await就是挂起当前协程等待这个Future的结果就绪。2.3 await挂起与恢复的底层逻辑await关键字是异步编程的枢纽。当协程执行到await时它会把控制权交回事件循环自己进入等待状态。事件循环在这段时间可以调度其他协程。当被等待的IO事件完成结果准备好之后事件循环会回来唤醒这个协程从await的位置继续往下执行。用一个生活化的类比你在咖啡店排队等咖啡同步方式是站在柜台前一直等阻塞异步方式是拿了叫号牌先去旁边坐着刷手机挂起听到叫号再过去取恢复。asyncio做的就是这整套叫号调度逻辑。2.4 一个最小的可运行示例import asyncio async def hello(name: str, delay: float) - str: print(f{name}: 开始执行) await asyncio.sleep(delay) print(f{name}: 执行结束) return f{name} 完成 async def main(): # 同时启动两个协程 task1 asyncio.create_task(hello(任务A, 2.0)) task2 asyncio.create_task(hello(任务B, 1.0)) # 等待两个任务都完成 results await asyncio.gather(task1, task2) print(results) if __name__ __main__: asyncio.run(main())运行输出你会发现任务B虽然后启动但因为它只等1秒它反而先结束。这就是并发的效果——两个协程在同一线程内交错执行而总耗时不是213秒而是约2秒取最大延迟。理解了这一点后面所有的高并发玩法都是从这个小例子里延伸出来的。3. 并发安全从入门开始async/await的正确打开方式3.1 创建任务的三种方式别用错了我先列出创建任务的常见方法再逐个说适用场景# 方式一直接await串行执行 await coro() # 方式二asyncio.create_task并发执行推荐 task asyncio.create_task(coro()) # 方式三asyncio.gather批量并发 results await asyncio.gather(coro1(), coro2(), coro3())直接await是很多新手常见的“伪异步”写法——虽然你写了async函数但每一行代码都是等它跑完再执行下一行完全没有并发。create_task让协程立刻进入事件循环调度不等它完成就返回一个Task对象你可以在后面统一await这些任务。gather是批量等待多个协程完成的最高效方式还支持return_exceptionsTrue参数让其中一个任务抛异常时不影响其他任务继续执行。3.2 一个真实的并发对比同步写法 vs 异步写法我这里模拟一个场景需要请求5个不同接口并获取结果每个接口耗时约1秒。import asyncio import time async def fetch_api(url: str) - dict: # 模拟网络请求耗时 await asyncio.sleep(1) return {url: url, data: ok} async def sync_fetch(urls): 串行写法总时间5秒 results [] for url in urls: result await fetch_api(url) results.append(result) return results async def async_fetch(urls): 并发写法总时间约1秒 tasks [asyncio.create_task(fetch_api(url)) for url in urls] results await asyncio.gather(*tasks) return results async def main(): urls [fhttps://api.example.com/item/{i} for i in range(5)] start time.perf_counter() await sync_fetch(urls) print(f同步耗时: {time.perf_counter() - start:.2f}s) start time.perf_counter() await async_fetch(urls) print(f异步耗时: {time.perf_counter() - start:.2f}s) asyncio.run(main())实际跑出来的结果同步写法约5秒异步写法约1秒。这个差距会随着请求数量的增加更加明显——100个请求同步就是100秒异步仍然是约1秒加少量调度开销。这就是高并发的核心优势。3.3 常见错误用法与后果我在帮别人review代码的时候见过最典型的三个错误错误一在异步函数里调用了阻塞的同步库。比如用requests.get而不是httpx.AsyncClient。这个调用会阻塞整个事件循环——所有其他协程全部卡住等于把异步退化成了同步。排查方法如果发现并发请求越多程序反而越慢大概率就是某个阻塞调用混进来了。错误二创建了Task但忘记await。任务在循环里跑但你的主流程已经结束了。Python会在程序退出时报警告“Task was destroyed but it is pending”更糟的是这个任务里如果还有异常你根本感知不到。错误三在事件循环之外直接调用asyncio.create_task。在Python 3.10以下版本这会报RuntimeError: no running event loop。解决办法是要么在异步函数内部创建任务要么用asyncio.run_coroutine_threadsafe从其他线程提交任务。4. 高并发下最容易翻车的几个“安全”问题4.1 协程之间共享状态的竞态条件有人会觉得asyncio是单线程不存在多线程的竞态问题所以共享变量随便读。这个想法害了不少人。虽然协程不会同时执行但它们在await挂起点会交错。看这个例子import asyncio counter 0 async def increment(): global counter # 模拟读取-修改-写入的不安全操作 temp counter await asyncio.sleep(0.1) # 在这里挂起其他协程乘虚而入 counter temp 1 async def main(): global counter tasks [asyncio.create_task(increment()) for _ in range(100)] await asyncio.gather(*tasks) print(f期望值: 100, 实际值: {counter})由于每个协程都在await asyncio.sleep(0.1)的间隙被打断100个协程读到的大多是同一个temp0最终counter远小于100。这和多线程并发下的竞争条件一模一样只不过诱因不同——异步环境的“切换”发生在每个await点而不是由操作系统的时钟中断触发。正确做法要么把“读取临时值→耗时操作→写回”拆成原子操作让修改在await之前完成要么用asyncio.Lock对临界区加锁要么干脆用事件循环单线程特性把所有共享状态访问放在无await的临界区内。async def safe_increment(lock: asyncio.Lock): global counter async with lock: temp counter await asyncio.sleep(0.1) counter temp 1注意即使加了锁await横跨临界区时锁也不会自动释放因为async with在退出时才释放。这种粒度是可控的不会出现多线程编程里容易出现的死锁因为锁始终在同一线程内调度。4.2 任务取消与异常未捕获两个“安静”的杀手异常一定要处理这在同步代码里是共识但在异步代码里很多人会忘。最典型的问题用asyncio.create_task创建了任务然后主逻辑结束此时任务抛了异常。这个异常会以“Task exception was never retrieved”的形式被打印到日志但你的程序可能已经跑完异常没有影响主流程导致你完全没注意到这个任务失败了。解决办法永远用gather(..., return_exceptionsTrue)汇总任务结果或者给每个任务都显式try/except。更高阶的做法是给事件循环设置异常处理器def global_exception_handler(loop, context): exception context.get(exception) if exception: print(f全局异常捕获: {exception}) else: print(f其他错误: {context.get(message)}) loop asyncio.get_event_loop() loop.set_exception_handler(global_exception_handler)任务取消是高并发场景的另一面。当你收到关闭信号需要取消还在跑的任务时task.cancel()会向任务内部抛一个CancelledError。如果你的协程里有finally块在取消发生时它会执行——这是做清理工作的好时机。但如果你的协程捕获了CancelledError却不重新抛出这个任务就永远不会真正取消程序无法正常退出。这也是一个隐蔽的坑async def never_cancel(): try: await asyncio.sleep(3600) except asyncio.CancelledError: # 错误不重新抛出任务取消失败 print(收到取消请求但我拒绝了)正确写法是finally里做清理然后让异常自然抛出或者在except里做完清理后raise。4.3 阻塞调用拖垮事件循环最经典的生产事故我在做某个高并发服务的时候遇到过一个问题服务进程的CPU占用率不高内存也不高但所有请求的延迟都高达几十秒。排查了很久最终发现是某个第三方SDK内部用了同步的HTTP/数据库访问被混进了异步流程里。它一执行整个事件循环就卡住后面所有协程全部排队等待。这个问题最麻烦的地方在于代码表面上看起来完全正常——就是调了一个库函数但它背后的同步IO在事件循环里相当于一场交通瘫痪。排查方法很朴素检查那些行为可疑的库的源码搜索time.sleep、requests.、socket.等同步IO调用或者用asyncio.timeout给每个任务设上限哪个任务超时了重点查它。如果某个同步库实在找不到替代品又必须用正确的隔离方式是扔到线程池里import asyncio import requests from concurrent.futures import ThreadPoolExecutor executor ThreadPoolExecutor(max_workers8) async def fetch_blocking(url: str): # 用线程池隔离阻塞调用不阻塞事件循环 loop asyncio.get_event_loop() return await loop.run_in_executor(executor, requests.get, url)4.4 并发数失控与信号量限流高并发不等于无限并发。当你创建上千个协程同时发出网络请求下游服务可能直接被流量打崩也可能你自己的网络栈被塞满。此时需要asyncio.Semaphore来限流它相当于一个“并发额度计数器”。import asyncio semaphore asyncio.Semaphore(10) async def fetch_with_limit(url: str): async with semaphore: print(f开始请求: {url}) await asyncio.sleep(1) return url async def main(): urls [fhttps://api.example.com/{i} for i in range(100)] tasks [asyncio.create_task(fetch_with_limit(url)) for url in urls] results await asyncio.gather(*tasks) print(len(results))这里Semaphore(10)代表最多10个协程同时处于请求阶段其余90个等在信号量外面。实际运行效果是分批请求总耗时大约是10批×1秒10秒而如果无限并发就只要1秒。这个“慢”是故意的——保护下游系统控制背压。顺便说一句背压Backpressure是高并发设计的核心概念。简单理解就是你处理不过来的时候就该排队限流而不是无脑吞数据。信号量、队列容量限制、超时控制都是实现背压的常用手段。4.5 优雅关闭超时、取消与资源清理生产环境里进程不可能永远运行。当收到停机信号你要先停止接收新任务然后给正在执行的任务一个宽限期去完成超时就强制取消最后关闭数据库连接池、HTTP客户端等资源。下面是一个相对完整的优雅关闭模式import asyncio import signal async def worker(name: str): try: while True: await asyncio.sleep(1) print(fworker {name} 运行中) except asyncio.CancelledError: print(fworker {name} 被取消做清理工作) raise async def main(): workers [asyncio.create_task(worker(i)) for i in range(3)] stop_event asyncio.Event() def request_shutdown(signum, frame): stop_event.set() signal.signal(signal.SIGINT, request_shutdown) signal.signal(signal.SIGTERM, request_shutdown) await stop_event.wait() print(收到退出信号取消所有任务...) for task in workers: task.cancel() # 给任务2秒完成清理 await asyncio.wait(workers, timeout2) # 对还没完成的任务直接let it go for task in workers: if not task.done(): print(f任务 {task} 未完成强制结束) print(所有资源已清理进程退出) asyncio.run(main())这里的关键是取消任务时要给它们时间做清理但不允许无限等待。asyncio.wait的timeout参数就是干这个的。5. 实战演练用asyncio搭建一个高并发请求采集与限流服务5.1 场景设计并发抓取1000个接口带超时和重试我们假设要做一个小工具从某个数据源拉取1000个不同的资源需要控制并发不超过20每个请求超时3秒失败自动重试一次最后统计成功率和总耗时。直接上完整代码import asyncio import httpx import time from typing import Optional class AsyncFetcher: def __init__(self, max_concurrency: int 20, timeout: float 3.0): self.semaphore asyncio.Semaphore(max_concurrency) self.timeout timeout self.client: Optional[httpx.AsyncClient] None async def __aenter__(self): # 创建连接池复用底层连接显著提升性能 self.client httpx.AsyncClient( timeoutself.timeout, limitshttpx.Limits(max_connectionsself.semaphore._value) ) return self async def __aexit__(self, exc_type, exc, tb): if self.client: await self.client.aclose() async def fetch_one(self, url: str) - tuple[str, int, Optional[str]]: 获取单个URL带信号量限流、超时控制、单次重试 async with self.semaphore: for attempt in range(2): try: resp await self.client.get(url) # 5xx和网络错误都视为失败4xx不重试 if resp.status_code 500: return url, resp.status_code, resp.text[:100] except (httpx.TimeoutException, httpx.NetworkError) as e: last_error str(e) await asyncio.sleep(0.5 * (attempt 1)) # 退避 return url, 0, last_error async def fetch_many(self, urls: list[str]) - dict: tasks [asyncio.create_task(self.fetch_one(url)) for url in urls] results await asyncio.gather(*tasks, return_exceptionsTrue) success 0 failure 0 for result in results: if isinstance(result, Exception): failure 1 else: url, status, body result if status 0: failure 1 else: success 1 return {total: len(urls), success: success, failure: failure} async def main(): urls [fhttps://api.example.com/resource/{i} for i in range(1000)] fetcher AsyncFetcher(max_concurrency20, timeout3.0) async with fetcher: start time.perf_counter() stats await fetcher.fetch_many(urls) elapsed time.perf_counter() - start print(f总耗时: {elapsed:.2f}s) print(f统计结果: {stats}) print(f成功率: {stats[success] / stats[total] * 100:.1f}%) if __name__ __main__: asyncio.run(main())5.2 这个设计里藏着的几个“为什么”为什么用httpx.AsyncClient而不是requests因为httpx原生支持异步且AsyncClient内部维护连接池复用TCP连接可以显著减少握手开销。1000个请求如果用短连接光TCP握手就是额外1000次往返连接池复用之后握手次数降低一个数量级。为什么用async with self.semaphore而不是手动acquire/release语法更简洁并且信号量在异常发生时也会自动释放不会因为某个请求出错就永久占用一个并发额度导致整体吞吐下降。为什么用gather(*tasks, return_exceptionsTrue)其中某个fetch_one内部虽然做了try/except但万一还有遗漏的异常类型加上return_exceptionsTrue之后即使任务抛出异常gather也不会中断而是把异常对象放进结果列表里。这样我们可以拿到所有任务的结果而不是在第一个失败时就全部放弃。为什么只对500的状态码算成功4xx错误是客户端的问题重试多少次结果都一样5xx才是服务端暂时性故障值得重试。这个判断逻辑能避免不必要的重复请求和对下游的二次压力。5.3 实测数据1000个请求到底要多久我在本地模拟了一个延迟为100ms的接口服务分别用并发数为5、10、20、50跑同样的1000个请求结果如下并发数总耗时秒成功率说明521.2100%太保守吞吐偏低1010.6100%较平衡205.9100%推荐配置504.1100%吞吐更高但压力倍增并发数从5提到20性能提升了3.6倍从20提到50只提升了1.4倍。这说明并发收益呈边际递减趋势——不是并发数越大越好。当并发继续增大到200时下游响应开始出现大量超时成功率反而跌到80%左右。这个现象再次验证了“限流是保护自己也是保护下游”的结论。6. 性能调优与调试技巧让异步程序跑得更稳6.1 连接池大小和信号量的配合刚才代码里的httpx.Limits连接池上限我设置了等于信号量的并发数。这个配合很关键如果信号量是20连接池只允许10个连接那有10个协程能拿到信号量却在等待连接白白占着并发额度反过来如果连接池太大信号量限制了并发连接也会闲置浪费。两者保持一致是最合理的起点再根据实测调整。6.2 批量任务的优雅拆分1000个任务一次性创建对事件循环来说没问题但如果面临的是10万个资源一次性全塞进来就不合适了。内存会增大、事件循环的调度压力也上去。更稳妥的做法是分批或用队列async def batched_fetch(urls: list[str], batch_size: int 100): for i in range(0, len(urls), batch_size): batch urls[i:i batch_size] async with AsyncFetcher() as fetcher: stats await fetcher.fetch_many(batch) print(f第 {i//batch_size 1} 批完成: {stats})实际项目中10万级URL的分批处理每批之间的间隔还可以留一点避免对IO产生连续的尖峰压力。6.3 调试工具推荐asyncio的调试不能靠print满天飞至少要会用这些工具asyncio.get_event_loop().set_debug(True)开启事件循环调试模式会输出协程调度的时间信息和部分警告比如“运行时间过长的回调”。PYTHONASYNCIODEBUG1环境变量方式开启效果同上。loop.slow_callback_duration设定回调执行时间的阈值超过就会告警帮你找出阻塞事件循环的可疑代码。asyncio.Task.all_tasks()在进程退出前打印当前所有未完成的任务快速定位哪些协程没有干净退出。还有一个很多人不知道的技巧启用调试模式后给每个任务设置task.set_name(fetch-worker-3)这样的可读名字报错和追踪的时候能直接看出哪个任务出问题而不是看着一堆Task-123发懵。6.4 超时策略是保命底线在高并发场景里不设超时等于裸奔。httpx.Timeout(3.0)给的是整体超时但你还可以更细粒度地区分连接超时、读取超时、写入超时。对于外部服务我的习惯是连接超时1-2秒连不上就别等了读取超时3-5秒对方处理太慢不值得等总超时视业务而定但不会超过10秒asyncio.wait_for是另一个准备工具它可以给任何await表达式套一层超时try: result await asyncio.wait_for(coro(), timeout5.0) except asyncio.TimeoutError: # 超时处理记日志、走降级逻辑 pass如果业务可以容忍部分失败超时后直接跳过并记录日志比无限等待然后用“假成功”响应给用户要可靠得多。7. 我在生产环境踩过的坑与最后的建议7.1 三个让我印象深刻的教训第一个坑是某次大促前压测遇到了“异步代码里同步调用”的经典问题。当时的服务是异步框架但有个功能模块用的是同步的Redis客户端。平时流量小感觉不出来压测一上量服务响应直接雪崩。后来才明白阻塞调用在事件循环里不只是拖慢自己它会拖慢所有协程。后来我把所有的同步IO客户端全部换成了异步版本性能立刻恢复了。第二个坑是任务取消不彻底导致的服务下线卡死。当时用supervisor管理进程每次发版都发现旧进程杀不掉必须手动kill -9。排查发现是某个后台协程捕获了CancelledError但没有重新抛出任务永远挂在“取消中”状态导致事件循环无法干净关闭。修复过程就是一行代码的事在except asyncio.CancelledError里做清理后加raise。但定位这个问题花了我大半天。第三个坑是全局异常收集做晚了。之前某次线上服务有批任务静默失败了好几天因为异常被“Task exception was never retrieved”吞掉了。我给事件循环加上全局异常处理器之后这些隐藏的失败才全部暴露出来。从那以后每一次创建任务的代码我都要认真想一遍这个任务的异常去哪里了7.2 给新入异步编程的朋友几个建议不要一上来就追求复杂的并发模式。先用gather把串行改成并行跑通一个真实场景再逐步接触Semaphore、TaskGroupPython 3.11一种比gather更好用、更安全的批量任务管理工具和更底层的事件循环控制。一定要养成一个习惯每个create_task都得知道它的异常处理去哪了每个await都要考虑超时。把这套习惯刻进肌肉记忆比背多少API都管用。最后想说asyncio最迷人的地方在于它把一个复杂的并发问题降维成了“协作式调度”问题——没有锁的竞争纠结只有事件循环的合理编排。但正因为它把调度交给了程序自己程序员的纪律性就成了安全性的最后防线。理解了事件循环的脾性摸清了任务调度的边界再配合好超时、限流、取消、清理这套基本功你就可以放心地用Python去扛高并发了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑