回调与协程怎么选:异步编程机制、实战对照与避坑 1. 概念对齐回调与协程各自站在什么位置前阵子帮一位做后端的同行看他负责的一个数据聚合模块一段本来三十行就能说清楚的逻辑被拆成了六层嵌套的函数调用最里层还套着一个错误分支他自己隔了两周回来看都得拿纸把执行路径画一遍才敢改。这段代码的功能其实很朴素依次请求三个数据源合并结果返回。问题不在业务复杂而在于它用了最原始的回调写法中间又叠加了重试和超时。这类代码在我这些年经手的老系统里非常常见也正因为常见回调与协程这个话题才一直有人反复问到底什么时候该用回调什么时候该上协程两者能不能混着来。先把话说清楚回调是一种把后续要做什么提前交出去的编程约定本质是接口设计层面的东西协程是一种能在执行中途暂停、之后从暂停处继续跑的程序结构本质是执行模型层面的东西。很多人下意识把它们当成新旧两代的替代关系这个理解是偏的。协程流行起来之后回调并不会消失你调用的每一个底层库、每一个事件注册接口、每一个定时器背后仍然是回调。真正的区别在于协程把回调的编排工作从人手里拿走交给了编译器和运行时。这篇内容适合两类人看。一类是被嵌套回调折磨过、想搞清楚 async/await 到底在背后做了什么的人另一类是把协程当成万能异步方案、结果踩了协程泄漏和取消失效的坑想回头补一补底层机制的人。我会从概念、机制、代码对照、参数计算一路讲到排查实录中间穿插我自己踩过的坑尽量让刚接触异步编程的读者也能跟下来。1.1 一次卡顿引出的两种思路设想一个最朴素的场景程序要读一个文件读完再处理。同步写法是调用读文件函数原地等等到数据返回继续往下走。这种写法直观但等待期间整个执行流是停住的如果一个线程只干这一件事那这段时间就是纯浪费。回调的思路是我不等了我把读完之后要做的处理打包成一个函数交给读文件的接口然后立刻返回去做别的事。等数据真的到了由底层或者事件循环来调用我交出去的那个函数。这就是所谓的控制反转原本由我主动发起、主动等待、主动继续的流程变成了我注册别人来触发。协程的思路又不一样我还是写成读文件然后处理这样的顺序代码但这次等待不是把线程停死而是把当前这个执行单元挂起让出底层线程去干别的等数据回来后从挂起的那一行继续往下执行。代码看起来是同步的执行上是异步的这是协程最核心的价值主张。注意协程的看起来同步只是写法上的它不是把异步变同步而是把异步的调度细节藏起来了。藏起来的东西不会消失只会在出问题时以更难查的形式出现。1.2 回调是你好了叫我协程是我先去忙好了回来继续用一个生活化的类比。你去一家生意很好的餐厅前台说菜要现做大概二十分钟。回调模式下的你把手机号留给前台注册回调然后出门逛街去了返回不阻塞二十分钟后前台打电话给你触发回调你再回来取餐。整个过程中你这个执行单元是自由的可以干别的事。但问题是如果你同时点了三家店三张单子都要你去取你得记住每张单子对应哪家店、取餐时要不要带凭证、某家店做失败了怎么退单——这些记账工作全在你自己脑子里。协程模式下的你你坐在店里跟店员说菜好了叫我一声我在这儿眯一会儿然后你进入一种半睡半醒的状态挂起。菜好了店员叫你恢复你睁眼继续吃。对外表现上你就是一条线走到底进店、点单、等、吃、结账。中间那次眯一会儿对你是透明的但你的身体确实休息了没有空转消耗。差别在于回调把编排责任交给你协程把编排责任交给运行时。回调灵活、零依赖、能在任何语言里手搓协程省心、可读性强但需要语言或库的支持并且引入了一套新的生命周期管理问题。1.3 两者不是替代关系是不同抽象层我见过一个挺典型的误解某开发者接手一个老服务看到全是回调第一反应是太落后了全部改成协程。改完之后代码确实短了一半但上线后出现了一批偶发的请求堆积排查了两天才发现原回调里有一处靠注册顺序隐式保证的执行次序改成并发协程后被打乱了而且新的协程作用域没有正确等待子任务主流程提前返回了空结果。这个例子说明回调承载的不只是流程有时候还承载了隐含的时序契约。你在改写法的时候实际上是在改执行模型而执行模型一变那些没写在注释里的假设就全成了隐患。所以正确的态度不是哪个先进用哪个而是先判断这段代码的核心矛盾是什么是可读性问题、并发度问题还是资源生命周期问题。不同矛盾对应不同解法下一节我会把回调的机制掰开讲清楚它在什么情况下会变成负担。2. 回调的机制拆解与地狱的真正成因2.1 回调的三要素注册、触发、执行上下文任何一个回调机制拆开来看都只有三件事。第一是注册调用方把一个函数引用交给被调用方同时约定好这个函数会被调用几次、参数是什么形态。业界最通用的约定是 error-first也就是第一个参数是错误对象第二个参数才是结果例如(err, data) {}。这个约定的价值在于统一了错误通道让调用方不需要为每个接口单独记一套错误处理方式。第二是触发被调用方在某个时刻决定调用这个函数。这个时刻可能是 I/O 完成、定时器到期、事件发生也可能是同步立即执行。同步立即执行是最容易出事的一种因为它意味着你注册回调的那行代码还没返回回调就已经跑完了回调里如果引用了后面才初始化的变量拿到的就是 undefined。第三是执行上下文回调在哪里执行、以什么上下文执行、抛出的异常会被谁捕获。这三点如果没搞清楚调试时会非常痛苦。比如在浏览器环境里定时器回调抛出的异常不会被你外层的 try/catch 捕获因为调用栈早就不是原来那一条了。我做过一个小实验来验证第三点在外层包一个 try/catch里面注册一个 setTimeout 回调回调里主动抛错。结果是外层 catch 完全没有反应错误直接冒到了全局错误处理。原因很直白——try/catch 只能捕获当前调用栈上同步抛出的异常而定时器回调执行时原来的栈早就销毁了。提示理解调用栈是否连续是理解回调一切怪异行为的钥匙。回调的栈是断开的所以 try/catch 断、局部变量作用域断、调试器的单步跟踪也断。2.2 嵌套只是表象控制流反转才是根因提到回调的痛点大部分人的第一反应是嵌套太深。但嵌套深度本身不是什么大问题你把每个回调抽成具名函数深度立刻就降到一层了。真正难缠的是嵌套背后那件事控制流反转。正常写代码控制流是自上而下的我读代码的顺序就是执行顺序。一旦引入回调下一步做什么被塞进了参数里你读主流程的时候看到的是发起请求实际执行路径却在另一个函数里继续。当有三个以上步骤串联时你脑子里要维护一个栈现在在第几层、上一层失败会怎样、这一层的异常会飘到哪里去。这就是认知负担的来源。对比一下两种写法在读代码时的心理负担上的差别维度嵌套回调顺序写法协程阅读顺序与执行顺序不一致需要脑内跳转基本一致步骤增删的改动面要动嵌套结构缩进全变增删一行中间变量可见性每层各自作用域共享要外提同一作用域直接可见断点调试体验栈断裂单步会跳走栈连续可单步局部逻辑复用难以单独抽出可抽成独立函数这张表里我最看重最后一行。回调写法下中间某一步的逻辑想复用到别处你得把它从嵌套里挖出来挖的过程还要处理它依赖的上层变量顺序写法下它本来就是一个普通的语句块抽函数是顺手的事。可复用性差才是回调写法在长期维护中真正的成本。2.3 错误传播在回调模型里的天然缺陷error-first 约定解决了错误往哪放的问题但没解决错误往上抛的问题。在回调模型里错误只能靠每一层手动检查、手动传递漏一层就断链。更麻烦的是部分失败的场景。三个请求并行发出去第一个成功了第二个失败了第三个还在飞。这时候你要决定是立刻返回失败还是等第三个回来再统一返回如果立刻返回第三个的回调稍后还是会执行它的结果和错误怎么办如果那个回调里还有副作用写缓存、发通知副作用就发生了两次或者发生在一个已经失败的请求上。我在一个老项目里见过这类 bug并行拉三个数据源任一失败就回调 done但没有设置已完成标志。结果第一个失败触发 done 之后另外两个成功回来又各触发了一次 done下游拿到三次回调一次错误两次成功直接把状态机写乱了。修复方式很朴素加一个布尔标志做短路let settled false; function finish(err, data) { if (settled) return; // 关键幂等保护 settled true; done(err, data); }这个settled标志看起来是两行代码的事但它是回调模型里必须养成的肌肉记忆。凡是可能被多次调用的回调出口都要做幂等保护。协程模型下这个问题基本不存在因为 await 一个 Promise 只会得到一次结果重复 resolve 会被运行时忽略。2.4 回调什么时候依然是最优解说了这么多缺点得给回调正个名。以下几类场景回调不但不过时反而比协程更合适。极简的一次性通知。比如连接断开时通知我一声就一个动作没有任何后续编排用回调一行写完上协程反而要引入作用域、启动函数、异常处理一整套。底层接口与跨语言边界。几乎所有运行时的底层 API 都是回调形态的因为它是各语言都能表达的最小公共子集。你在协程里 await 的东西剥到最里面往往还是一个回调。对启动开销极度敏感的热路径。协程对象的创建虽然轻但不是零成本。在每秒几百万次调用的极热路径上一个回调函数引用的传递可能比构造一个协程对象更划算。这种场景不多但存在。事件驱动型的长期订阅。UI 的事件监听、消息总线的订阅本质是注册一次触发多次这和协程执行一次得到一个结果的模型是错位的。硬要用协程表达就得引入 Channel 之类的中间结构绕了一圈反而更复杂。3. 协程的底层逻辑挂起、恢复与调度3.1 协程不是线程别拿它当线程用最常见的误解是把协程当成更轻量的线程。它们在用法上确实像但在实现上完全是两码事。线程由操作系统调度有独立的内核栈切换要走内核态成本在微秒级一个进程开几千个线程就开始吃力因为每个线程的栈默认就是 1MB 级别的虚拟内存。协程由用户态的运行时调度本质是一个状态对象切换就是改几个指针成本在纳秒级开十万个协程在某些运行时里也就是几百 MB 的量级。我做过一次估算拿一个常见的带栈协程实现举例初始栈 2KB十万个协程起步就是 200MB随着调用加深栈会扩容实际可能到 400MB 以上。听起来不少但换成线程十万个线程按 1MB 栈算就是 100GB 虚拟地址空间直接不现实。但轻量带来的代价是协程没有真正的并行。同一时刻在一个调度器线程上只能跑一个协程协程之间是协作式的靠挂起点主动让出。这意味着如果一个协程里写了死循环同线程上的其他协程全部饿死而线程模型下操作系统还能强行抢占。所以协程里更要注意不要写长时间不挂起的计算密集逻辑需要的话得显式让出或者切到专门的调度器上。3.2 挂起点与状态机协程到底挂在哪这是最值得搞明白的一点。协程的挂起到底挂了什么以编译器实现的无栈协程为例一个带挂起点的函数在编译后会被改造成一个状态机。编译器在函数内部插入若干状态编号把跨越挂起点的局部变量搬到一个堆上的上下文对象里因为栈帧在挂起后就不在了。每次挂起记录当前状态编号和局部变量每次恢复根据编号跳到对应的代码位置把变量搬回来继续往下跑。所以挂起挂的是一个状态对象恢复就是拿这个对象接着跑。理解了这一点很多事情就顺了为什么挂起函数只能在挂起函数里调用因为调用方也得是状态机不然它没法保存自己的现场。为什么挂起点的局部变量不能随便放引用因为它们会被搬到堆上生命周期变长了。为什么在 finally 里调用挂起函数要小心因为取消时状态机可能在任意挂起点被中断。注意挂起不等于线程阻塞也不等于切换线程。挂起后恢复可能在同一个线程也可能在另一个线程具体取决于调度器配置。如果代码里对线程上下文有依赖比如某些只能在特定线程访问的资源就必须显式声明调度器否则会出现随机性的访问异常。3.3 async/await 的糖衣与内核很多人把 async/await 当成一个新东西其实它就是协程能力的语法包装。async标记一个函数会被编译成状态机await标记一个挂起点被 await 的对象负责在完成时回调恢复。用一段代码把这个转换关系摆出来看。底下是回调中间是包装上面是语法糖// 第一层原始回调接口 function readFileRaw(path, cb) { /* ... */ } // 第二层包装成可 await 的对象 function readFile(path) { return new Promise((resolve, reject) { readFileRaw(path, (err, data) { err ? reject(err) : resolve(data); }); }); } // 第三层用 await 书写顺序逻辑 async function load(path) { const data await readFile(path); return data.length; }看清楚这三层很多困惑就消解了await 右边如果不是一个能被等待的对象它就会退化成同步等待一个已经完成的值async 函数返回的其实是一个包装对象不是裸值所以你在外面拿到的永远不是直接的返回值。这个包装层的存在带来了一类特别隐蔽的坑一个 async 函数在第一个 await 之前抛出的异常和普通函数抛异常看起来一样但它实际会被包进返回对象里变成拒绝。如果你调用一个 async 函数却忘了处理它的返回异常就静默丢失了。我在线上排查过一个任务丢了但不报错的问题最后定位到就是一行漏了 await 的调用。3.4 结构化并发为什么重要协程用久了会发现一个问题启动一个协程太容易了一行代码就发出去了于是代码里到处是发射后不管的协程。它们的生命周期完全游离于业务逻辑之外业务结束了它们还在跑业务失败了它们还在跑最后变成一堆没人管的孤儿。结构化并发就是来解决这个问题的。核心规则有三条子协程必须在父作用域内启动父作用域必须等待所有子协程结束才结束任何一个子协程失败父作用域和兄弟协程都要被取消。这三条合起来让协程的树形结构和代码的块结构对应上了你看到一对大括号就知道里面启动的协程什么时候收尾。suspend fun buildSnapshot(userId: String): Snapshot coroutineScope { val profile async { loadProfile(userId) } val orders async { loadOrders(userId) } val coupons async { loadCoupons(userId) } Snapshot(profile.await(), orders.await(), coupons.await()) }上面这段里coroutineScope会等三个async全部完成如果loadOrders抛了异常另外两个会被自动取消异常往上传给调用方。你不需要手写计数器不需要手写取消逻辑这就是结构化并发给出的保证。作为对照如果改成在一个不受约束的全局作用域里launch那这三个任务就成为孤儿函数返回了它们还在跑异常也传不回来。4. 同一需求的三种写法实战对照4.1 需求与约束定义光讲概念没意思我把同一个需求用三种写法实现一遍横向对比。需求定义如下给定一个用户 ID拉取三份数据用户资料、订单列表、优惠券列表。三份数据可以并行拉取也可以串行我们需要对比差异。每一个请求都要有超时超时后重试一次。最终结果要么是完整的三份数据要么是一个明确的失败不能是部分成功。整个流程要能被上层主动取消。约束定得具体一点上游网关给的整个接口超时是 5 秒单次请求的 P99 延迟约 600 毫秒重试退避采用 200 毫秒、400 毫秒递增。4.2 回调版能跑但要小心翼翼先用最原始的回调写一版并行的function fetchAll(userId, done) { const out {}; let pending 3; let settled false; let failed false; function handler(key) { return function (err, data) { if (settled) return; // 出口幂等 if (err) { settled true; return done(err); // 一旦失败立刻短路 } out[key] data; if (--pending 0) { settled true; done(null, out); } }; } fetchProfile(userId, handler(profile)); fetchOrders(userId, handler(orders)); fetchCoupons(userId, handler(coupons)); }这段代码有几个关键设计值得逐个说。settled标志的作用前面讲过防止重复回调。failed变量在这个版本里其实用不上我留着是为了提醒自己曾经有一个版本用failed做了失败后仍然等剩余请求回来再统一返回的逻辑结果因为等待整个接口耗时被最慢的那个请求拖长反而更容易触发网关超时。后来改成失败立即短路——反正结果不可用早返回早让上游释放资源。pending计数器是并行回调的经典写法注意--pending 0这个判断必须在赋值之后。曾经有同事写成if (pending-- 0)结果计数 logically 提前了一次导致最后一个结果被丢掉、而且 done 永远不被调用请求就挂在那里直到超时。这种错误的可怕之处在于它不是崩溃而是静默挂起。超时和重试在回调版里必须每个请求单独处理代码量会翻倍function withRetryTimeout(task, timeoutMs, retries, cb) { let finished false; const timer setTimeout(() { if (finished) return; if (retries 0) { retries - 1; withRetryTimeout(task, timeoutMs, retries, cb); } else { finished true; cb(new Error(TIMEOUT)); } }, timeoutMs); task((err, data) { if (finished) return; clearTimeout(timer); finished true; cb(err, data); }); }这段里有个隐患超时后发起重试但原来那次请求并没有被取消它可能稍后才回来回调里虽然有finished保护但底层的连接、内存、上游压力都还在。这就是回调模型最头疼的地方——取消语义需要底层配合而很多老接口压根没有取消这个参数。4.3 协程版短但短不等于简单同样的需求协程写法async function fetchAll(userId, signal) { const fetchOne (p) withTimeout(p, 1500, signal); const [profile, orders, coupons] await Promise.all([ fetchOne(loadProfile(userId, signal)), fetchOne(loadOrders(userId, signal)), fetchOne(loadCoupons(userId, signal)), ]); return { profile, orders, coupons }; } function withTimeout(promise, ms, signal) { return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(TIMEOUT)), ms); signal?.addEventListener(abort, () { clearTimeout(timer); reject(new Error(CANCELLED)); }, { once: true }); promise.then( (v) { clearTimeout(timer); resolve(v); }, (e) { clearTimeout(timer); reject(e); } ); }); }注意这里用到了取消信号。对比回调版的超时后不取消原请求协程版把signal传进了每个底层请求超时或上层取消时底层出站请求会被真正中止。这个差别在压测时会非常明显回调版在超时率高的时候会出现大量幽灵请求继续占用连接而协程版能把连接释放出来。但协程版也不是没有代价。Promise.all有一个行为需要特别注意任何一个分支失败整体立即拒绝但其他分支并不会自动中止。也就是说如果订单接口先失败了资料和优惠券的请求还在跑它们的连接不会因为Promise.all拒绝而断开——除非它们自己也监听了同一个 signal。我在一个项目里就是漏了这一步导致故障时连接数不降反升。修复方式是给三个请求共享同一个取消控制器在Promise.all拒绝后主动触发。Kotlin 风格的结构化并发版本则不需要手动做这件事coroutineScope里的兄弟协程会自动取消suspend fun fetchAll(userId: String): Snapshot coroutineScope { val profile async { loadProfile(userId) } val orders async { loadOrders(userId) } val coupons async { loadCoupons(userId) } Snapshot(profile.await(), orders.await(), coupons.await()) }这里取消是协作式的底层请求需要定期检查取消状态或者本身就是可中断的挂起调用。如果loadOrders里包了一段不检查取消状态的阻塞代码取消就不会生效——这是下一节要展开的问题。4.4 关键参数怎么算超时、并发度、重试次数这部分是很多人凭感觉拍脑袋的地方我给一套可复算的方法。先算超时上限。上游给的总预算 5 秒这里要留出序列化、网络回程、以及我们自己逻辑的余量按 4.5 秒可用算。重试一次退避 200 毫秒起如果两次都超时总耗时约等于单次超时 × 2 退避。要求这个值小于 4.5 秒即2T 0.2 4.5得T 2.15秒。取整并留余量单次超时定 1.5 秒。这个数还有一个校准依据单次请求 P99 是 600 毫秒1.5 秒相当于 P99 的 2.5 倍正常波动不会被误杀同时又能兜住真正的慢请求。再算并发度。假设这个接口的目标吞吐是 200 QPS单请求平均耗时 150 毫秒。按利特尔法则需要的并发数大约是200 × 0.15 30。考虑到 P99 抖动会把部分请求的驻留时间拉长实际并发要往上抬一档取 40 比较稳妥。那么连接池的上限必须 ≥ 40否则协程会卡在等连接上你以为的并发 40实际只有连接池那么宽。最后校核内存。40 并发不算什么但如果这个服务有 500 个这样的接口同时在跑峰值协程数可能上千。按每个协程上下文 1KB 到 4KB 估最坏 4MB 量级可以忽略。真正要注意的是那些不受控的发射点如果代码里对每个请求都launch一个后台任务做日志上报那量级就完全不可控了。参数取值计算依据单次请求超时1500ms(4500 - 200) / 2且 ≥ P99 × 2重试次数1再多会挤占总预算退避时长200ms覆盖一次典型网络抖动目标并发40利特尔法则 30上浮 33%连接池上限≥ 40必须不小于并发数4.5 实测观察两种写法在故障时的表现差异我在一个测试环境里把两种写法都跑了一遍用故障注入让订单接口的延迟从 200 毫秒涨到 3 秒观察 5 分钟。回调版的表现接口整体 P99 直接顶到超时线失败的请求因为原请求没被取消在超时之后仍然占着连接跑完那 3 秒。连接池被打满同一实例上其他接口开始排队出现了一个慢接口拖垮整个实例的连锁反应。协程版的表现超时触发后取消信号下发底层连接被主动释放连接池水位维持在 50% 上下其他接口的 P99 只上涨了 15% 左右。但也不是没有问题——日志里出现了不少 CANCELLED 的异常堆栈因为这些取消被当成了错误上报把错误率指标打满了。后来在错误分类里把取消单独拎出来不算失败指标才恢复正常。这个对比给我的结论是协程本身不会让你的系统更稳它只是让你更容易实现正确的取消和边界控制。如果你不写取消逻辑协程版的资源和回调版一样会泄漏只是泄漏得没那么显眼。5. 落地选型什么场景用回调什么场景上协程5.1 一张决策表选型这件事我习惯把判断条件压缩成一张表遇到新代码直接对号入座比每次重新权衡快得多。判断条件倾向回调倾向协程编排步骤数1 到 2 步3 步以上是否需要取消无需取消需要精确取消是否长期订阅是触发多次否一次一结果错误处理复杂度简单单点失败多分支需要统一收敛所在代码层底层 SDK、跨语言边界业务编排层团队熟悉度全员熟有异步调试经验表格的用法是数一数落在右边有几条四条以上就别犹豫了直接上协程两条以下硬上协程收益抵不过学习成本。有一类情况我要单独提醒跨进程或者跨服务的边界上回调协议往往更稳。因为协程的语义是进程内概念一旦跨出去你只能传事件和回调地址中间还要考虑幂等、重放、超时。这种地方硬套协程思维很容易写出以为 await 了对方就一定会回的代码然后在对方重启时永久挂起。5.2 回调与协程混用时的桥接写法现实项目里不可能纯一种桥接是必备技能。核心思路只有一句把注册回调这个动作转成一次性的可等待对象。function bridge(fn) { return function (...args) { return new Promise((resolve, reject) { let done false; fn(...args, (err, data) { if (done) return; done true; err ? reject(err) : resolve(data); }); }); }; } // 用法 const loadProfile bridge(rawLoadProfile); const profile await loadProfile(userId);这个桥接函数看着简单但有两个细节不能省。一是done标志防的是底层接口在某些错误路径上回调两次二是要确认底层接口是不是真的只回调一次有些老接口在超时和成功两条路径上都会回调这种必须在外层做去重不能指望它守规矩。反过来把协程能力暴露给回调世界的场景也存在一个老的插件系统只认回调你要在里面调用一个新写的挂起函数。做法是在边界上启动一个受控的作用域把结果通过回调传出去同时确保这个作用域有明确的父级和超时别让它变成孤儿。fun loadLegacy(userId: String, cb: (ResultSnapshot) - Unit) { scope.launch { val r runCatching { fetchAll(userId) } cb(r) } }这段里scope必须是受管理的不能是全局作用域否则应用关闭时这些任务不会被等待可能丢数据。这是我在一个服务优雅下线场景里踩过的坑停机时以为请求都处理完了实际上还有一批在全局作用域里跑进程一退出全丢了。5.3 取消语义协作式取消为什么经常失效取消在协程里不是强制杀死而是打个标记等你自己检查。这带来一个非常现实的问题只要代码里有一处不检查取消就会失效。常见的失效点有这么几类。第一类是阻塞式调用比如同步读文件、同步等锁这些调用不会响应取消标记整个协程就卡在那里。第二类是 CPU 密集的循环中间没有挂起点取消标记检查不到。第三类是第三方库内部自己开了线程池你的取消传不进去。我的处理习惯是给每个外部调用都套一层可取消边界进入前检查一次状态出站请求带上取消信号返回后再检查一次。对于确实无法取消的阻塞调用把它隔离到独立的调度器上并且设一个硬性的等待上限超时就直接放弃等待、记录一条告警不要在关闭流程里无限等。async function safeCall(signal, fn) { if (signal.aborted) throw new Error(CANCELLED); const r await fn(signal); if (signal.aborted) throw new Error(CANCELLED); return r; }这个双层检查看起来冗余但它能覆盖请求发出后、返回前被取消这个窗口避免把一个已经作废的结果继续往上游传。上线之后因取消导致的脏写少了一大半。6. 常见问题与排查实录6.1 协程泄漏最容易被忽视的资源问题协程泄漏的症状很隐蔽内存缓慢上涨、线程数或连接数只增不减、某个后台任务的日志越打越密。它的成因基本都是同一个——启动了一个没有归属的协程它在一个永远不会结束的循环里等待。我排查过一例。某服务运行一周后内存从 400MB 涨到 2GB堆转储里全是某个消息监听对象。追下去发现每次配置变更都会重新订阅一次消息但旧的订阅从来没有被取消因为订阅时用的是全局作用域没有任何东西持有它的引用也就没有任何东西能取消它。改法是把订阅绑定到一个和配置对象同生命周期的子作用域上配置对象销毁时作用域一起取消。// 错误无归属无法取消 GlobalScope.launch { bus.subscribe(topic) { handle(it) } } // 正确绑定到可管理的生命周期 configScope.launch { bus.subscribe(topic) { handle(it) } }提示审查协程代码时看到全局作用域就要警觉。它本身不是错的但必须能回答谁在什么时候取消它这个问题答不上来就是泄漏。6.2 回调重入导致的状态错乱回调的另一个经典坑是重入你在回调里做了一件事这件事又同步触发了另一个回调另一个回调回头改了你还未处理完的状态。有个具体例子。一个连接池的归还逻辑写在回调里归还时如果池已满会触发淘汰最旧连接淘汰会关闭连接关闭连接又会触发连接关闭回调而这个回调里有段逻辑会去检查池状态并把某些请求标记为失败。结果就是一次归还操作把正在排队的一批请求全标失败了。这类 bug 的特点是偶发、难复现因为只有池恰好满的时候才触发。排查手段是加一层重入检测。用一个简单的标记在关键路径入口置位、出口复位检测到重入就打日志并降级处理比如把关闭动作推迟到下一个事件循环周期执行。let inCallback false; function onClose(conn) { if (inCallback) { return queueMicrotask(() onClose(conn)); // 推迟避免重入 } inCallback true; try { // 真正的处理逻辑 } finally { inCallback false; } }queueMicrotask这里是延后一拍的常用手法。它的作用是把同步重入变成异步执行让调用栈先退出去状态回到一致。这个技巧我在好几个老系统里都用过改动小、见效快。6.3 问题速查表把上面这些整理成一张表出问题时按症状对号入座。症状最可能的原因排查动作修复方向请求永久挂起不返回回调未被调用计数判断写错、短路标志提前置位在出口处打点确认是否到达检查计数器自增自减顺序加幂等标志同一结果被处理多次回调被重复调用无幂等保护在出口打印调用栈加 settled 标志内存缓慢上涨协程泄漏、订阅未取消堆转储找重复对象看生命周期绑定作用域随宿主一起取消取消后请求仍在跑阻塞调用或底层未接取消信号看底层接口是否接受取消参数隔离到独立调度器设硬超时故障时连接数暴涨超时未真正中止底层请求对比出站连接数与并发数超时同时下发取消偶发状态错乱回调重入加重入检测打日志延后一拍执行拆解状态修改异常静默丢失调用了 async 函数但未处理返回搜索未 await 的调用点补 await 或显式处理拒绝7. 几个踩坑之后固化下来的做法写到这里把一些不写在文档里、但每次做异步改造都会用上的习惯留一下。先画时序再动代码。改任何一段异步逻辑之前我会先在纸上列出有哪几个参与者、谁先谁后、失败走哪条路径、取消从哪进来。画完通常会发现原代码里有一两个隐含假设是没写在任何地方的。这一步花十分钟能省掉上线后两天的排查。所有对外出口都做幂等。不管是回调的 done、还是协程的结果收敛点只要它可能被多次触发就加一个标志。这个成本极低但挡掉的是一整类玄学问题。超时和取消必须成对出现。只设超时不发取消等于让请求在后台自生自灭。我现在写任何带超时的代码第一反应都是超时之后原请求怎么办答不上来就说明设计还没完成。给每个协程都找一个家长。启动之前问一句它属于哪个作用域那个作用域什么时候结束。找不到家长就别启动或者改成同步处理。故障注入测试比压测更能暴露问题。正常流量下两种写法都跑得好好的只有人为让某个下游变慢、让某个接口随机失败才会看到取消有没有生效、连接有没有泄漏、短路逻辑对不对。我现在做异步改造至少会跑一轮延迟注入和一轮失败注入。最后一件事关于回调与协程的选择我个人的体会是不要指望换一种写法就能解决架构上的问题。回调写得乱往往是因为流程本身就没有想清楚换成协程只是把乱的地方挪了个位置。真正的分水岭是你能不能把每一步的输入、输出、失败和取消都说清楚。这套东西想明白了回调也能写得很干净想不明白协程只会让问题藏得更深。