Python异步编程避坑指南:事件循环、阻塞调用与运行时错误全解析
写这份避坑指南之前我先把话说在前面异步编程的绝大多数报错都不在语法层而在运行时。前几篇我们把 Python 异步编程的事件循环、协程、任务、常用 API 都过了一遍按道理你的代码已经能跑起来了但能跑和能扛之间隔着一整片雷区。很多问题在小 Demo 里根本不会暴露一上并发、一上生产环境就集体爆发。这篇我打算完全站在踩坑的角度说话把真实项目里反复出现的问题一个个拆开再给出一份我实测下来值得长期使用的生态选型清单。1. 所有坑的根源async 不等于不阻塞1.1 事件循环是单线程厨师不是并行加速器很多新手对 asyncio 的第一个误解是用了 async/await我的程序就并行了性能就一定上去了。这个认知必须第一时间纠正。asyncio 的事件循环本质上跑在一个线程里它做的事情更像一个同时照看多个灶台的厨师——厨师只有一双手切菜的时候只能切菜但等水烧开的时候可以去切下一道菜的配料。协程之间的同时进行共享的是同一段时间内互相穿插的 CPU 时间片真正并行执行同时占着多个 CPU 核在纯 asyncio 里是不存在的。所以异步编程只对IO 密集型场景有效网络请求、数据库读写、文件读写、消息队列消费这些操作的特点是大部分时间在等别人返回而等待期间 CPU 是空闲的。这时候事件循环可以把这个任务的等待时间让给其他任务整体吞吐量才会上去。反过来如果你有一个纯 CPU 计算的函数比如图像处理、加密哈希、大数据量的列表排序把它写成 async 函数不会有任何收益该跑多久还是跑多久甚至因为要切来切去还会慢一点。正确的做法是把它丢到进程池里让多个 CPU 核真正并行跑。理解了这一点后面所有的坑就都好解释了。你之所以会遇到异步程序卡死所有请求排着队等一个任务十有八九是有人在协程里塞了阻塞调用把整个事件循环卡住了。1.2 一个 time.sleep 就能让整个服务陪葬这是我在帮别人排查异步项目时遇到最多的一个问题没有之一。直接看代码import time import asyncio async def worker(name): print(f{name} 开始) time.sleep(2) # 错误示范这里是同步阻塞 print(f{name} 结束) async def main(): await asyncio.gather(worker(A), worker(B), worker(C)) asyncio.run(main())直觉上你可能会觉得gather同时启动了三个任务总耗时应该是 2 秒左右。但实际上这段代码要跑 6 秒因为time.sleep(2)阻塞的是整个线程事件循环和三个任务全住在同一个线程里A 睡的时候 B、C 也在跟着睡连事件循环的调度都停了。把time.sleep(2)换成await asyncio.sleep(2)三个任务才是真正交替等待总耗时才回到 2 秒。生产环境里time.sleep只是最显眼的一个真正防不胜防的是下面这些阻塞场景错误写法正确姿势睡眠等待time.sleep(1)await asyncio.sleep(1)HTTP 请求requests.get(url)await httpx.AsyncClient().get(url)子进程调用subprocess.run(cmd)await asyncio.create_subprocess_exec(...)文件读写open(f).read()await asyncio.to_thread(open, f).read()或用 aiofiles第三方同步 SDK直接在协程里调用await asyncio.to_thread(sdk_call, arg)最后一行我要特别强调很多云厂商、消息中间件、监控上报的官方 SDK 都是同步实现文档里根本不提 async 的事。你在协程里直接调这些 SDK一个调用几百毫秒全部阻塞在事件循环上唯一的症状就是并发一高整个服务就卡极难定位。我的习惯是凡是第三方同步库的调用全部用asyncio.to_thread包一层再await先把阻塞隔离出去后面再考虑换异步原生 SDK。2. 三个让你当场破防的运行时错误2.1 RuntimeWarning: coroutine was never awaited这个警告是异步新手最常见的第一次被教育。原因很简单忘了加await。async def fetch(): return 42 async def main(): fetch() # 这里没 await这样写代码不会立刻报错而是等到程序退出、垃圾回收的时候Python 才幽幽地弹出一句RuntimeWarning: coroutine fetch was never awaited。更麻烦的是如果这个协程是在一个深层调用链里被错误调用的到你看到警告时根本想不起来是哪一行漏了await。我遇到过最夸张的一次一个服务上线跑了三天监控里偶尔冒一条这个警告最后定位到是某个异常分支里少写了await——那个分支只有请求失败时才会走到而失败请求的日志又没打全。排查建议分两步第一步全局搜索所有async def函数的调用点看有没有调用了一个协程函数但没await的写法第二步直接把事件循环切到 debug 模式debug 模式会给没被 await 的协程打上完整的创建堆栈定位快得多。代码上线前我也会在 CI 里加一条-W error::RuntimeWarning的 Python 选项让这类警告直接变成错误逼着你在开发阶段就解决。2.2 RuntimeError: no running event loop这个错误的典型出现场景是把asyncio.run()或者asyncio.get_event_loop()放在了一个没有运行中事件循环的上下文里最常见的是新起的线程、某些框架的工作线程、还有 Jupyter Notebook。早期代码里经常有人写loop asyncio.get_event_loop() loop.run_until_complete(some_coro())这个写法在 Python 3.10 之前勉强能跑但 3.10 开始get_event_loop()在没有运行中事件循环时会发出DeprecationWarning到了 3.12 直接变成RuntimeError。所以在新代码里协程内部统一用asyncio.get_running_loop()协程外部要启动异步逻辑统一用asyncio.run()。如果确实需要在线程里手动跑一个事件循环那就老老实实创建独立循环import asyncio def thread_entry(): loop asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(do_work()) loop.close()2.3 This event loop is already running这是 2.2 的孪生兄弟。差别在于这次是在一个已经运行中的事件循环内部又调用了一次asyncio.run()或loop.run_until_complete()。典型场景你在 FastAPI 的异步接口里直接调用了某个同步库封装好的asyncio.run()入口函数或者在 Jupyter 里写了一个asyncio.run(main())而 Jupyter 内核自己就有一个事件循环在跑。遇到这种情况网上最常见的建议是装nest_asyncio一行代码把已运行循环重新包装允许嵌套。我明确建议能不用就不用。nest_asyncio是通过给事件循环的_run_once打补丁实现的绕过了官方设计某些事件循环监控、信号处理、asyncio debug 模式都会出现行为偏移。真正该做的是把代码结构理顺协程里就只做await启动逻辑只放在最外层的入口不要在异步函数里再创建事件循环。3. 任务取消、异常与并发控制比语法深得多的暗坑3.1 gather 的连带坑一个失败结果全丢asyncio.gather可能是 asyncio 里被用得最多的并发聚合函数但它有一个极其反直觉的默认行为只要其中一个任务抛了异常gather立刻把这个异常抛给await的那一侧其他尚未完成的任务并不会被取消而是继续在后台跑。async def bad(): raise ValueError(boom) async def ok(): await asyncio.sleep(1) return ok async def main(): results await asyncio.gather(ok(), bad())这个例子里bad()立刻抛异常await gather(...)这一行立刻把ValueError抛出来而ok()打印不出来但它在后台还要再睡一秒才结束。结果就是你的业务代码以为这批任务已经结束了实际上后台还有任务在跑日志里可能会出现程序好像退出了但进程一直不退出的诡异现象。处理方式分三种如果希望异常作为结果返回而不是打断整体设置return_exceptionsTrue如果需要任何一个失败就取消其他所有任务Python 3.11 起直接用asyncio.TaskGroup它会在子任务失败时取消整个组等所有任务真正结束才返回——这才是符合直觉的结构化并发。除非你确实想让其他任务在后台继续跑完否则不要用裸gather。3.2 CancelledError 是 BaseExceptionexcept Exception 拦不住它任务取消是 asyncio 里语义最细微的部分。很多人以为task.cancel()就是立刻杀掉这个任务大错特错。cancel()只是向任务发送一个取消请求任务要在下一个挂起点下一个await才会收到CancelledError如果任务全程没有挂起点这个取消请求会一直等到协程结束才被处理。更要命的是CancelledError在 Python 3.8 之后继承自BaseException而不是Exception。这意味着你在协程里写except Exception: pass想兜底所有错误是完全拦不住取消信号的。反过来如果你用except Exception把异常都吞了而某个依赖库内部通过抛CancelledError来做超时取消你的异常处理代码很可能把取消请求也吞掉导致任务永远不响应取消资源无法释放。规范的取消处理长这样async def worker(): try: await asyncio.sleep(10) except asyncio.CancelledError: # 拿到取消信号先做必要的清理 await do_cleanup() raise # 关键必须把取消信号继续抛出去清理逻辑里尽量别放长耗时的异步操作因为清理期间再次await很可能立刻又收到一个取消信号。Python 3.11 引入了任务取消计数和Task.uncancel()用来处理取消期间被反复取消的边界场景标准库的asyncio.timeout就是基于这套机制实现的这也是我推荐把项目升到 3.11 的原因之一。3.3 一口气 create_task 一万个文件描述符与内存的无声消失异步编程给人的错觉是创建任务很便宜那就多开点。于是经常看到这样的代码async def main(): tasks [asyncio.create_task(do_request(i)) for i in range(100000)] await asyncio.gather(*tasks)十万个任务确实是能创建出来的但每个任务的协程栈、变量引用、以及底层连出去的 socket 连接都要占资源一口气开出去先是文件描述符打到上限报OSError: [Errno 24] Too many open files接着内存飙升最后整个进程 OOM。这不是任务本身的问题是没有做并发控制。标准解法是信号量sem asyncio.Semaphore(100) async def do_request(i): async with sem: await make_http_request(i) async def main(): tasks [asyncio.create_task(do_request(i)) for i in range(100000)] await asyncio.gather(*tasks)信号量上限怎么定如果是连数据库上限参考连接池大小如果调外部 API参考对方给你的 QPS 配额如果只是内部计算参考 CPU 核数。别拍脑袋给个大数。更细粒度的限速比如每秒最多 50 次请求可以用aiolimiter这种令牌桶库不过它只解决速率问题资源控制还是信号量更直接。4. 多线程、阻塞库与事件循环的时间片纠缠4.1 在 async 函数里调 requests我为什么建议你把这行代码当成 bug说个真实的线上事故。有一次我给一个 FastAPI 服务做性能排查接口本身逻辑很简单调一个内部 HTTP 服务拿数据再返回给前端。压测到 50 并发的时候接口延迟从 20ms 飙升到 5 秒CPU 占用却很低。查了半天发现接口里用了requests.get()——在协程里跑同步请求事件循环被这一个请求卡住其余 49 个并发请求全在排队等同一个线程。事件循环不是多线程服务器它不会为每个请求开一个线程它只有一条线程任何人在这条线程上阻塞所有人一起遭殃。这个问题的隐蔽性在于本地开发时并发低你根本感觉不到上了生产一个上游服务偶发慢请求你的整个服务延迟就跟着雪崩。所以我在代码评审里有一条红线async 函数内不允许出现任何同步网络库的调用。HTTP 请求用httpx.AsyncClient或aiohttp数据库操作用对应的异步驱动实在绕不开的同步 SDK就用asyncio.to_thread隔离。顺便提一句很多第三方 SDK 文档里写着支持异步但实际只是内部开了线程池去做阻塞调用对事件循环的友好度和真正的 async 实现是不一样的。判断方法很简单看它的方法签名是否是async def是就是原生协程不是就要谨慎。4.2 跨线程通信asyncio.Queue 不是线程安全的事件循环跑在主线程但你的程序里可能有其他线程比如线程池里的工作线程、消息回调线程它们需要把数据递给事件循环里的协程。很多人直觉上会用asyncio.Queue然后从另一个线程直接queue.put_nowait(item)运气好能跑通但这是未定义行为asyncio.Queue没有做线程同步多线程同时 put 的时候可能丢数据、可能顺序错乱最坏情况下直接报错。从外部线程安全地往事件循环里提交任务只有两个可靠入口# 从线程提交协程返回 concurrent.futures.Future可阻塞等待结果 future asyncio.run_coroutine_threadsafe(coro, loop) result future.result(timeout5) # 从线程提交普通回调不关心返回值 loop.call_soon_threadsafe(plain_callback, arg)注意loop对象要在线程启动前拿到并保存不要在回调内部再去get_event_loop()——那个线程里根本没有可用的运行中循环。如果只是协程内部需要和外部线程交换数据我建议在外部线程用标准的queue.Queue然后通过call_soon_threadsafe把有新数据这个信号传给事件循环由协程在事件循环内读取queue.Queue各用各的安全机制谁也不越界。4.3 to_thread 与 run_in_executor把脏活隔离出去的正确姿势Python 3.9 引入的asyncio.to_thread是我最推荐的隔离阻塞调用工具它把调用丢进默认线程池返回一个可await的协程result await asyncio.to_thread(blocking_func, arg1, arg2)默认线程池大小是min(32, os.cpu_count() 4)对绝大多数 IO 型阻塞调用够用。如果 IO 阻塞时间很长且并发很高可以自己建一个ThreadPoolExecutor传给run_in_executor控制线程池大小别让线程无限制增长。这里有两个容易踩的细节。第一to_thread只是把阻塞隔离出事件循环它不是魔法线程池还是会有排队如果阻塞任务本身特别多线程池打满任务依然会等。第二纯 CPU 密集任务别用线程池应该用ProcessPoolExecutor配合loop.run_in_executor(process_pool, fn, arg)因为 Python 线程有 GIL多线程跑 CPU 任务不会并行。但进程池有序列化开销小任务频繁提交反而更慢所以短 CPU 任务就直接在协程里同步算完长 CPU 任务才值得走进程池。5. 版本差异与 Windows 平台异步分支里最容易被忽视的雷区5.1 get_event_loop 的演进升级 Python 时爆的雷Python 3.7 引入asyncio.run()之后官方一直在收拢启动事件循环的入口。asyncio.get_event_loop()这个老 API 的结局是3.10 开始在没有运行中事件循环时调用发出DeprecationWarning3.12 开始直接抛RuntimeError。这个变化本身是好事但升级 Python 版本时它就是一颗定时炸弹。我的一个项目从 3.8 升到 3.12CI 直接红了一片查到最后是某个第三方库内部的某个辅助函数在线程里调用了get_event_loop()以前靠自动创建一个新循环蒙混过关现在直接炸。排查这类问题最痛苦的是报错信息发生在库内部跟你的业务代码毫无关系。建议升级前先全局搜一遍自己代码里有没有get_event_loop()有就改成get_running_loop()协程内或asyncio.run()入口处然后再逐个升级依赖库。5.2 asyncio.timeout 与 wait_for 的超时傻等问题做超时控制老代码里全是asyncio.wait_for(coro, timeout)。它有一个经典毛病如果被等待的协程内部捕获了CancelledError并且自己处理掉了没立刻往外抛wait_for会一直等它结束才抛超时异常——说好的超时结果远超时。Python 3.11 引入了asyncio.timeout()它是一个异步上下文管理器用起来简洁得多而且底层配合了新的取消计数机制能更可靠地处理超时即取消的语义try: async with asyncio.timeout(3): await slow_operation() except TimeoutError: handle_timeout()注意这个写法对TimeoutError的捕获是except TimeoutError不是asyncio.TimeoutError——3.11 之后asyncio.TimeoutError只是内置TimeoutError的别名。如果你的项目还在 3.8/3.9/3.10继续用wait_for时就要格外小心被等待协程的异常处理逻辑确保它不吞CancelledError。5.3 Windows 上的事件循环怪癖先说结论异步服务端部署在 Linux 上Windows 只适合做开发调试。但开发调试时 Windows 的坑也够你喝一壶的。Python 3.8 起 Windows 默认事件循环从SelectorEventLoop换成了ProactorEventLoop目的是支持子进程和命名管道但这也带来了行为差异signal.add_signal_handler()在 Windows 上不可用某些 asyncio 特性在两个平台表现不一致。更常见的是在 Windows 上开发时并发问题不暴露一部署到 Linux 容器里量一起来各种连接数、文件描述符问题才出现。我的建议是如果你写的是网络服务类程序从第一天就把 Docker 环境当成主开发环境至少在 CI 里用 Linux 容器跑一遍并发测试。Windows 上能跑通不等于 Linux 上能跑通反之 linux 上的行为才是生产的真实行为。5.4 用 debug 模式和警告定位幽灵问题asyncio 自带的调试能力足够解决 90% 的排查需求不必一上来就上复杂工具。启动时打开 debug 模式asyncio.run(main(), debugTrue)也可以设置环境变量PYTHONASYNCIODEBUG1效果一样。debug 模式下事件循环会把执行超过slow_callback_duration默认 0.1 秒的回调和时间打印出来哪段代码拖慢了循环一目了然前面说的coroutine was never awaited也会带上创建堆栈。生产环境初上线的一周我建议先开着 debug 模式跑观察日志里有没有慢回调警告稳定一周之后再关掉。另一个高频警告是Task was destroyed but it is pending!。意思是某个任务还没有完成事件循环就被关闭了任务对象被回收。这通常说明你的关闭流程没有等所有任务收尾——正确做法是关闭前统一gather主任务或者在finally里把所有剩余任务cancel()后再关闭循环。这类进程退出不干净的问题多半都能从这条警告里找到线索。6. 生态推荐我的生产环境异步选型清单6.1 框架与服务器FastAPI uvicorn uvloopWeb 框架层目前最稳的选择还是 FastAPI它不是最快的框架但它的异步支持最自然路由函数直接