ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

协程无法退出?三大生态踩坑实录与排查指南

2026/9/26 21:23:27 拓冰建站 浏览量
协程无法退出?三大生态踩坑实录与排查指南 协程这东西用起来是真爽排查起退出问题来是真要命。我接手过一个内部服务日志一直打得热热闹闹但某个协程明明已经被取消却还赖着不肯走数据库连接、内存、线程池全都跟着遭殃。你以为是偶发现象实际上这类问题只要踩过一次就会明白背后其实就那么几个固定套路。今天这篇不聊怎么用协程就专门聊协程为什么无法退出。我是从自己线上排查的经验出发把我在 Python、Kotlin、Unity 三个生态里踩过的坑全部摊开来说。无论你是写后端服务、客户端逻辑还是游戏脚本大概率都能对号入座。1. 先分清假死和真死协程卡住时的四种症状很多同学一遇到协程退不出去就急着查代码、加日志其实是把方向搞错了。因为协程无法退出这句话描述的是一个现象而不是一个原因。现象不同根因完全不一样排查手段也完全不同。我把实际遇到的情况归纳成四种1.1 程序整体被钉死现象是整个进程都不动了日志停更、API 无响应、CPU 占用掉到 0。这种情况下协程根本不是不退出而是根本没机会执行退出逻辑。我遇到过一个很典型的场景有人在协程里直接调用了time.sleep(60)结果主线程连同事件循环一起被睡眠堵死cancel()发出去之后连个水花都没有。判断方式特别简单连普通任务都开始卡了。如果不止你那个协程卡住其他协程也全部原地不动那必然是阻塞了线程级资源。1.2 协程自己永远跑不完事件循环还在正常转其他任务都很健康就是某个协程永远执行不到最后一行。这种情况最迷惑人因为日志看起来都在打程序也没死但你的协程确实一直不退出。我排查过一个 Python 异步爬虫某个下载任务在while循环里反复重试重试条件写得特别宽导致断网后它永远在等待下一次重试。这种卡住的本质是逻辑死循环而不是调度器故障。1.3 取消之后还在继续跑cancel()调了、StopCoroutine也调了协程却像没听到一样继续运行。这在三个生态里都很常见也是无法退出这个标题里真正的重头戏。核心原因是协程的取消是协作式的不是抢占式的你喊它停它自己必须配合才行。1.4 任务列表里躺着一排僵尸协程对象本身还在任务列表里状态却是已完成但没被消费或者取消中。这种情况在 Python 的asyncio.all_tasks()里看得最清楚一堆任务挂在pending或cancelling状态既不结束也不消失占着引用和资源。先分清楚你是哪一种再往下面排查。定位错了方向后面全是白忙。2. 头号元凶阻塞调用把协作式调度变成了单轨火车协程最核心的机制是协作式调度先把这一点吃透了很多退不出的问题就只剩一层窗户纸了。2.1 协作式调度的基本逻辑协程跑得再好它也得靠线程来执行。多个协程共享同一个线程谁想切换、谁想挂起、谁想响应取消都必须在挂起点上主动让出控制权。用大白话说协程之间是互相商量着来跑而不是操作系统在旁边强行分配时间片。这也意味着一旦某个协程在挂起点之间塞了一段不肯让路的东西整条线程上的所有事都得排队。这个东西最典型就是同步阻塞调用time.sleep、Thread.sleep、同步网络 IO、requests.get()、大文件同步读取。它们会直接卡住底层的那个线程让事件循环和协程调度器完全转不起来。2.2 Python 里误用time.sleep在asyncio里写await asyncio.sleep(1)是让出控制权给事件循环而time.sleep(1)则是把当前线程真真切切睡过去一秒。最坑的一点是time.sleep不会报错程序看起来还能继续跑只是你在协程里调用了它整个事件循环就在这段时间内断供了。这个时间里你发出去的task.cancel()根本没有机会被处理因为事件循环连执行回调的机会都没有。我之前就见过有人把time.sleep和asyncio.sleep混着用导致一个定时任务随缘触发。你想加日志定位结果日志输出本身也被阻塞给拖住了。解决办法不是嘴上说说记得用await asyncio.sleep()这么简单而是要在代码审查阶段就盯住协程函数里出现了time.sleep、requests.get、open().read()这一类同步调用基本就可以判定是潜在隐患。2.3 Kotlin 里的Thread.sleep同样致命Kotlin 协程的调度基于Dispatcher默认的Dispatchers.Default和Dispatchers.Main线程池里一旦出现Thread.sleep后果比 Python 还隐蔽。因为你在runBlocking里写launch { Thread.sleep(5000) }不是直接在主线程睡而是调度器线程被占用了。别的协程可能正常跑因为这个调度器下面还有别的线程可以接活但你的那个协程本身无法响应取消。等Thread.sleep执行完协程才恢复挂起。此时如果你调用了job.cancel()它会知道要取消了但如果一直卡在Thread.sleep里取消信号进不去。这一点上Kotlin 的错误隔离做得更差你用delay(5000)可以随时取消用Thread.sleep(5000)就变成一个不可取消的硬阻塞。很多人写 Android 代码时习惯性用Thread.sleep模拟耗时操作等真正接入协程后这个习惯就会在退出逻辑上埋雷。2.4 Unity 里被低估的线程阻塞Unity 协程走的是迭代器 yield return的路子跟 Python、Kotlin 还不一样。Unity 协程不是并发线程它只是主循环里反复检查MoveNext()。如果协程里你用了Thread.Sleep()那不是协程卡住而是整个游戏主线程卡住。之后不只是协程退不了连渲染帧、物理、输入全部停摆。有的项目为了做假加载故意用Thread.Sleep(100)这我劝你别碰。正确的做法是yield return new WaitForSeconds(0.1f)让出执行权或者把耗时操作放到真正的后台线程里用Task.Run处理再回到主协程拿结果。一句话总结协程能退不出的第一个大坑就是你自己把底层线程堵死了调度器想帮你处理取消都插不上手。3. 取消信号断在半路跨层传递时最容易失联排除了线程阻塞问题之后下面要考虑的就是取消信号到底有没有传到协程内部。3.1 Python 的取消机制为什么慢一拍Pythonasyncio的取消依赖task.cancel()在任务的下一个挂起点抛出一个CancelledError。注意它必须靠协程当前挂起才能注入异常。如果协程正卡在一个永不结束的while循环里或者在一个持续占用 CPU 的同步计算里那CancelledError就一直送不进去。一个常见的错误写法是import asyncio async def worker(): while True: # 这里没有任何 awaitCancelledError 根本进不来 pass async def main(): task asyncio.create_task(worker()) await asyncio.sleep(0.1) task.cancel() await task asyncio.run(main())这个worker里的while True: pass会让 CPU 被占满并且不经过任何挂起点。你调用task.cancel()只是在任务对象上挂了一个取消请求但任务本身根本不停下来检查这个请求await task就会一直等直到永远。如果说得直白点Python 的协程取消机制像个快递员他必须在你家门口等你开门你不开门不挂起、一直埋头干活那快递就永远送不到。3.2 Kotlin 的协作取消与不可取消的夹层Kotlin 协程的取消从原理上说是Job.cancel()之后协程会在下一个suspend挂起点抛CancellationException。有一个坑是你的协程体里调了一层普通同步函数这层函数内部还在做循环处理。普通函数不是suspend所以它不会响应取消。只有等你从这个普通函数返回重新进入可挂起的协程作用域取消才会生效。这种感觉就好比你让下属去传话但下属在半路走进了一间隔音房外面喊破喉咙他也听不见。更隐蔽的是Kotlin 协程里的withContext(Dispatchers.IO) { ... }如果内部用阻塞库比如直接调老的InputStream.read()那么即使外部job.cancel()了这个withContext也得等阻塞调用返回才肯罢休。对策就一条在使用协程时凡是跨进第三方库或者老的同步接口都要主动检查它有没有协程版本。比如网络请求用suspend版本、磁盘读写用Dispatchers.IO配非阻塞库或者在循环里主动调用ensureActive()/yield()来让取消信号有机会渗透进去。3.3 Unity 里嵌套协程的子承父业问题Unity 的StopCoroutine()看你从哪里停止。如果你停的是父协程它内部启动的子协程并不会自动跟着停。很多人以为停了父协程子协程也一起结束这是 Unity 里协程退不干净最典型的坑。比如这种情况IEnumerator ParentRoutine() { StartCoroutine(ChildRoutine()); while (true) { yield return null; } } IEnumerator ChildRoutine() { while (true) { Debug.Log(child running); yield return null; } }如果你在外部调用StopCoroutine(ParentRoutine)父协程停了但ChildRoutine还在每帧跑。因为它独立挂在这个MonoBehaviour上除非你显式StopCoroutine(ChildRoutine)或者销毁整个组件对象否则它就一直存在。这种嵌套协程残留在游戏里特别容易造成隐性 Bug比如角色死亡后子协程还在刷屏幕。排查时你光看父协程的停止逻辑根本没毛病毛病全在子依赖上。4. 循环不退出 异常被吞看似玄学实则逻辑漏洞如果说阻塞调用是被动防守那么循环和异常处理就是你主动把协程退出的大门给焊死了。4.1 无限循环里没有逃生通道很多协程本身就是做持续任务的消息循环、轮询、重试。这类协程退出靠的是在循环里检查取消状态、或者等待某个标志位。Python 里最典型的写法是async def worker(): while True: await do_something()问题在于如果do_something()永远不会因为取消而结束协程自己也不知道该什么时候退出。更好的做法是async def worker(): while True: await asyncio.sleep(0) # 这里天然是挂起点取消信号有机会注入如果是纯 CPU 型循环还应该主动让出async def worker(): while True: await asyncio.sleep(0) calculate_something()这个await asyncio.sleep(0)不是可有可无的摆设它是让 Python 调度器有机会把取消信号和任务切换操作插进来的关键。Kotlin 里对应的是在循环里调ensureActive()fun CoroutineScope.worker() launch { while (true) { ensureActive() doSomething() } }不要求你不是每次循环都必须调但如果循环里没有挂起点做不做这个检查就是天壤之别。Unity 里更是重灾区IEnumerator Loop() { while (true) { yield return null; } }只要你不StopCoroutine它就跑一辈子。很多开发者会设一个bool isRunning true然后写成while (isRunning)结果外部明明已经isRunning false了循环还是没退出。后来才发现是闭包和yield return null的执行时序没对齐或者StopCoroutine停的是另外一份协程实例。4.2except把取消异常给吃了这个坑在 Kotlin 里尤其致命。CancellationException在 Kotlin 中继承自Exception所以如果你写了一个通用兜底捕获try { doSomething() } catch (e: Exception) { // 你以为是普通异常连取消一起吞了 }那协程在取消瞬间抛出的CancellationException就被你接住了处理完之后代码继续往下走。此时外部Job已经处于Cancelling状态但你的协程还在执行等于取消信号被本地拦截。更麻烦的是在 Kotlin 的finally块里如果有挂起调用取消状态下这个挂起点会立刻再抛一次CancellationException导致清理没做完。解决方式是withContext(NonCancellable)包住清理逻辑。Python 这边稍微好些因为asyncio.CancelledError在 Python 3.8 起继承自BaseException不会落到except Exception里。但如果你写的是except BaseException:或者裸except:照样会把它吞掉。我在老项目里就见过同事用裸except:把取消异常吃了之后协程还继续跑任务状态却成功标成了取消场面非常混乱。4.3 关不掉的资源清理协程退出未必是立刻消失也可能是进入了清理一半卡住的中间状态。比如协程里已经打开了数据库连接、文件句柄、锁准备退出时在finally里做了同步阻塞调用结果清理没走完任务状态卡在cancelling。这时表面上协程没退出实际上是清理逻辑被堵住。实战经验是协程清理路径上不要放任何不可取消的同步阻塞调用。清理也要设超时或者用非阻塞方案。5. 资源未收尾协程退出后任务还在借尸还魂这一类坑最阴但排查起来也最有成就感因为根因往往藏在协程外面。5.1 等待一个永远不会完成的 Future/Event无论是asyncio.Future、Event还是 Kotlin 的CompletableDeferred、Unity 的TaskCompletionSource只要你的协程正在await一个永远没人set的挂起对象那这个协程就不可能退出。最常见的原因有两个一是发起点自己忘了在完成时设置结果二是对象在传递过程中被复制或者替换了。我的建议是生产环境里任何跨模块传递的Future/Deferred都要给它配一个超时兜底比如asyncio.wait_for(future, timeout10)或withTimeout。5.2run_in_executor背后的线程池任务Python 里用run_in_executor在线程池跑同步函数是比较经典的坑点。考虑这个代码async def main(): loop asyncio.get_running_loop() result await loop.run_in_executor(None, some_sync_blocking_function)你cancel()这个协程只能让它不再等待线程池的结果但正在线程池里跑的那个some_sync_blocking_function并不会被取消。它还会继续执行等它执行完结果交到已经取消的 Future 上然后被丢弃。如果线程池里的同步函数接收数据、写数据库、做重活那么它依然在占用资源而且你连个引用都不好拿。解决思路是把线程池职责边界划清楚线程池处理的是不关心结果、可独立完成的任务不应该把需要跟随协程生命周期取消的长任务放到run_in_executor里。Kotlin 的Dispatchers.IO也有类似问题。withContext(Dispatchers.IO)里跑的阻塞代码外部job.cancel()时如果代码没有主动响应取消线程还是会继续忙活。5.3withTimeout超时后内部任务仍然在跑Kotlin 的withTimeout超时抛出TimeoutCancellationException但这个异常只是让当前作用域停止等待并不会强制终止已经在另一个协程里启动的工作。有同事问我withTimeout(1000) { async { longWork() } }超时后longWork会停吗答案是不会。async启动的子协程如果没有被取消它会自顾自继续执行。withTimeout只负责管它自己的作用域里面的async如果没传CoroutineStart.LAZY且没有被主动取消就处于父超时、子照跑的状态。正确姿势是主动取消val job async { longWork() } try { withTimeout(1000) { job.await() } } catch (e: TimeoutCancellationException) { job.cancelAndJoin() // 处理超时 }很多协程没有退出的线上问题其实就是这种父子协程关系没有正确取消导致的。5.4 Python 中 Task 引用丢失被 GC还有一个比较冷门但确实见过的用asyncio.ensure_future创建了 Task但没保存引用Task 对象被垃圾回收了。此时事件循环会打出一条 Task was destroyed but it is pending 的警告然后任务就没有后续了。如果你依赖这个协程做清理、做退出、做状态上报那你会看到协程消失了但没退出还伴随着莫名奇妙的资源泄漏。正确的做法是保存引用或者在任务结束时add_done_callback里做清理。6. 从卡住到抓到凶手一次完整排查链路理论讲再多不如走一遍真实排查流程。我这里分享一个我自己的排查实录细节做了脱敏但链路是完整的。6.1 稳定复现锁定范围那次服务是 Python 异步写的关闭时执行优雅退出asyncio.run()一直不返回。进程不退测试环境直接卡住。我的第一步不是看业务代码而是先写一个最小复现场景把服务里的所有任务打印出来。这一步让我确定了问题是任务不退还是事件循环卡住。判断方式如果事件循环卡住连打印都做不到如果任务不退打印能正常出来但那些任务状态永远是pending。6.2 Pythonall_tasks() 栈堆快照我当时用了一个组合拳import asyncio import faulthandler # 定期打印所有任务及其栈 async def dump_tasks(): while True: for task in asyncio.all_tasks(): if not task.done(): print(PENDING:, task) task.print_stack() await asyncio.sleep(5) # 为线程设置定时栈快照排查阻塞 faulthandler.dump_traceback_later(10, repeatTrue)task.print_stack()直接把每个 pending 任务的当前执行位置打印出来这比看日志高效太多。最后我锁定了某个任务它的栈停在await response.json()但上游服务早就把连接断开了响应体永远读不完。这是典型的底层 IO 卡住导致协程不退出问题后来通过给请求加超时解决。6.3 Kotlin利用调试器与协程栈Kotlin 协程排查起来没有 Python 那么方便但方向一致。我会先在代码里塞一个定时打印while (true) { val jobs coroutineContext.job.children.toList() println(active children: ${jobs.map { it.isActive }}) delay(1000) }更推荐的方式是用 IDEA 的 Coroutines 调试面板取消或卡住时能看到每个协程的挂起点、状态、所属调度器和父 Job。但线上环境不能靠 IDE还是要靠主动打点在launch时给协程命名例如launch(context CoroutineName(LongTask))日志里就能追踪到名。配合Thread.currentThread().stackTrace可以打印出当前线程到底在哪个栈帧里卡着。6.4 UnityProfiler 协程状态追踪Unity 项目里排查协程退不出用Profiler看主线程调用栈最直接。如果某个MoveNext()一直占着 CPU 或者堆栈长期停在某个while那基本就是协程死循环了。还有一个经验在协程里加日志比如协程的Start、每次循环计数、退出break。在请求游戏卡协程时先看最后一条日志停在哪个阶段基本能判断是没进入退出分支还是进入退出分支后清理卡住。6.5 定位、修复、回归那次我还查过一个典型的假现象服务已经退出了但客户端还连着一个 Task其实是客户端那边把async方法当成发后即忘没有实际await导致 Task 一直挂着。这不是服务端问题而是调用方没有正确等待协程完成。排查时一定要分清楚你的协程是真的无法退出还是没人管它。如果是后者改的是调用方不是协程本体。修复之后我习惯在回归测试里加上退出超时断言比如asyncio.wait_for(shutdown_task, timeout10)如果优雅退出超过十秒就直接判定失败倒逼大家不再往退出路径里塞阻塞逻辑。7. 不同生态下的共同对策写在代码里的保命习惯讲到这里三个生态的坑其实都能归纳到同一套底层思想上。最后把这套思想落成可以直接抄的编码习惯。7.1 三个生态取消机制对比维度Python asyncioKotlin CoroutinesUnity Coroutine取消方式task.cancel()注入CancelledErrorjob.cancel()抛CancellationExceptionStopCoroutine/StopAllCoroutines是否协作式是是半抢占式可强行停止当前迭代器取消注入点下一个挂起点下一个挂起点下一次MoveNext之前同步阻塞影响阻塞事件循环线程取消失效占用调度线程取消失效阻塞主线程游戏卡死嵌套任务需要手动取消子 Task父 Job 自动取消子 Job继承结构父协程停止不影响子协程异常吞噬风险较低CancelledError 继承 BaseException高CancellationException 继承 Exception低终止直接作用于枚举器这张表不用背但有一个核心结论要记住除 Unity 的StopCoroutine外Python 和 Kotlin 的取消都是协作式你不给机会取消就是空话。7.2 给循环留一个逃生通道每次写协程内的循环我都要问自己一个问题这个循环什么时候能让出控制权检查取消如果答案是永远让不出去那这个循环就是定时炸弹。具体落地Python循环里至少有一个await挂起点必要时加await asyncio.sleep(0)。Kotlin循环里至少调用一次ensureActive()或yield()。Unity循环里必须有yield return null外部用标志位控制时检查条件的时机要确认在下一次迭代早点发生。7.3 资源清理放在哪里协程清理资源的位置很讲究。最保险的做法是try/finally但finally里不要放不可取消的挂起操作。Kotlin 的正确写法是try { doWork() } finally { withContext(NonCancellable) { releaseResource() } }Python 对应的是在finally里做纯同步清理或者用asyncio.shield包裹必须完成的清理操作。Unity 里则尽量把资源释放放在父协程的finally或OnDisable里不要依赖某个子协程来终结。7.4 退出时加超时兜底无论哪个框架我对优雅退出都持怀疑态度。所谓优雅必须在有边界的时间范围内完成才是优雅否则就是无限挂起。实际工程中我会给退出流程加一个总超时。比如 Python 服务关闭时asyncio.run包一层timeoutKotlin 服务停止时用withTimeout包裹根协程的等待Unity 切场景时统一调用StopAllCoroutines再显式销毁对象。超时的作用不是为了省那几秒钟而是把无法退出的问题从静默的隐性 Bug 变成可以被监控到的显性故障。系统一旦能在超时后强制退出、把栈信息打到日志里下回排查就是按图索骥。协程退不出去从来不是玄学它要么是阻塞调用卡了线程要么是取消信号被断在半路要么是循环和异常处理把退出通道焊死了要么是底层资源根本没收干净。我自己的习惯是每写一个新的协程就先想一遍如果现在取消它它会在多少毫秒之内真的停下来想清楚这个问题另一头踩坑的概率会小很多。