很多人一提起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秒,它反而先结束。这就是并发的效果——两个协程在同一线程内交错执行,而总耗时不是2+1=3秒,而是约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_exceptions=True参数,让其中一个任务抛异常时不影响其他任务继续执行。
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 = [f"https://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个协程读到的大多是同一个temp=0,最终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_exceptions=True)汇总任务结果,或者给每个任务都显式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_workers=8) 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 = [f"https://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(f"worker {name} 运行中") except asyncio.CancelledError: print(f"worker {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, timeout=2) # 对还没完成的任务,直接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( timeout=self.timeout, limits=httpx.Limits(max_connections=self.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_exceptions=True) 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 = [f"https://api.example.com/resource/{i}" for i in range(1000)] fetcher = AsyncFetcher(max_concurrency=20, timeout=3.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_exceptions=True)?其中某个fetch_one内部虽然做了try/except,但万一还有遗漏的异常类型,加上return_exceptions=True之后,即使任务抛出异常,gather也不会中断,而是把异常对象放进结果列表里。这样我们可以拿到所有任务的结果,而不是在第一个失败时就全部放弃。
为什么只对<500的状态码算成功?4xx错误是客户端的问题,重试多少次结果都一样;5xx才是服务端暂时性故障,值得重试。这个判断逻辑能避免不必要的重复请求和对下游的二次压力。
5.3 实测数据:1000个请求到底要多久
我在本地模拟了一个延迟为100ms的接口服务,分别用并发数为5、10、20、50跑同样的1000个请求,结果如下:
| 并发数 | 总耗时(秒) | 成功率 | 说明 |
|---|---|---|---|
| 5 | 21.2 | 100% | 太保守,吞吐偏低 |
| 10 | 10.6 | 100% | 较平衡 |
| 20 | 5.9 | 100% | 推荐配置 |
| 50 | 4.1 | 100% | 吞吐更高,但压力倍增 |
并发数从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):开启事件循环调试模式,会输出协程调度的时间信息和部分警告,比如“运行时间过长的回调”。PYTHONASYNCIODEBUG=1:环境变量方式开启,效果同上。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(), timeout=5.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、TaskGroup(Python 3.11+,一种比gather更好用、更安全的批量任务管理工具)和更底层的事件循环控制。
一定要养成一个习惯:每个create_task都得知道它的异常处理去哪了,每个await都要考虑超时。把这套习惯刻进肌肉记忆,比背多少API都管用。
最后想说,asyncio最迷人的地方在于,它把一个复杂的并发问题降维成了“协作式调度”问题——没有锁的竞争纠结,只有事件循环的合理编排。但正因为它把调度交给了程序自己,程序员的纪律性就成了安全性的最后防线。理解了事件循环的脾性,摸清了任务调度的边界,再配合好超时、限流、取消、清理这套基本功,你就可以放心地用Python去扛高并发了。