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: (Result<Snapshot>) -> 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、还是协程的结果收敛点,只要它可能被多次触发,就加一个标志。这个成本极低,但挡掉的是一整类玄学问题。
超时和取消必须成对出现。只设超时不发取消,等于让请求在后台自生自灭。我现在写任何带超时的代码,第一反应都是"超时之后原请求怎么办",答不上来就说明设计还没完成。
给每个协程都找一个"家长"。启动之前问一句:它属于哪个作用域,那个作用域什么时候结束。找不到家长就别启动,或者改成同步处理。
故障注入测试比压测更能暴露问题。正常流量下两种写法都跑得好好的,只有人为让某个下游变慢、让某个接口随机失败,才会看到取消有没有生效、连接有没有泄漏、短路逻辑对不对。我现在做异步改造,至少会跑一轮延迟注入和一轮失败注入。
最后一件事,关于回调与协程的选择,我个人的体会是:不要指望换一种写法就能解决架构上的问题。回调写得乱,往往是因为流程本身就没有想清楚,换成协程只是把乱的地方挪了个位置。真正的分水岭是你能不能把每一步的输入、输出、失败和取消都说清楚。这套东西想明白了,回调也能写得很干净;想不明白,协程只会让问题藏得更深。