本节摘要:挂起函数 suspend 是协程体系的核心原语:它让异步代码获得顺序形态,同时不阻塞线程。本节先解剖一段五层嵌套的真实回调代码,看它丢掉了哪些结构保障;再给出挂起改写;然后下探到字节码层看 CPS 变换与续体,回答"挂起到底挂住了什么";最后澄清"挂起不阻塞"与线程阻塞的本质区别。
支付成功后要给用户加积分,老项目的实现长这样(脱敏压缩,但嵌套层数是真实的):
payManager.pay(order, new PayCallback() { // 第一层 支付 @Override public void onSuccess(Receipt receipt) { userManager.loadProfile(new ProfileCallback() { // 第二层 取用户 @Override public void onLoaded(Profile p) { pointsManager.addPoints(p.getId(), receipt.getPoints(), new PointsCallback() { // 第三层 加积分 @Override public void onDone(int total) { uiThreadExecutor.execute(() -> { badgeManager.refresh(p.getId(), // 第四层 刷徽章 new BadgeCallback() { @Override public void onDone(Badge b) { analytics.track("paid", // 第五层 埋点 bundleFor(receipt, total, b)); } @Override public void onError(int code) { // 刷徽章失败要埋点吗 没人知道 } }); }); } }); } @Override public void onError(int code) { // 取用户失败时 支付已经成功了 钱扣了 积分没加 这算什么状态 } }); } @Override public void onError(int code) { /* 支付失败的分支 */ } });
逐项清点这段代码丢掉的结构保障。没有统一的作用域:五个回调各自为政,没有任何东西能表达"这五步同属一次业务",页面销毁后回调照常执行。没有统一的取消:用户退出页面,五层回调链没有任何一环知道该停下。没有统一的异常路径:每层都有自己的 onError,中间层失败时"已做的一半怎么办"没有任何机制过问——注释里的"钱扣了积分没加"就是业务上的悬案。没有顺序可读性:执行顺序要从缩进的迷宫里人肉推导,第五层的 analytics 依赖前面所有层的数据,参数在闭包里层层透传。没有组合能力:想把其中两步并行、或给整个流程加超时,都意味着重写。
这五条缺失不是风格问题,是回调这个原语的结构性缺陷。解决它们需要一个新的抽象——挂起函数。
suspend fun onPaid(order: Order) = coroutineScope { val receipt = payManager.pay(order) // 第一层 变成顺序的一行 val profile = userManager.loadProfile() // 第二层 val total = pointsManager.addPoints(profile.id, receipt.points) val badge = withContext(Dispatchers.Main) { // 第四层 显式切主线程 badgeManager.refresh(profile.id) } analytics.track("paid", bundleFor(receipt, total, badge)) }
五行对五层。执行顺序从缩进迷宫变成从上到下;中间数据的传递从闭包透传变成局部变量;页面销毁时整个函数随作用域取消;任何一步抛异常,直接沿调用栈向上——try、finally、异常传播这些顺序代码的百年老工具全部重新可用。调用方还可以把前两步并行化:
val deferredProfile = async { userManager.loadProfile() } val receipt = payManager.pay(order) val profile = deferredProfile.await() // 支付与取用户并行
并行从"重写"变成"改两行"。这就是挂起函数的第一重价值:异步逻辑重新获得了顺序代码的全部结构工具。
suspend 修饰符在源码层只是个关键字,编译产物里它变成了两样东西:续体(Continuation)参数与状态机。这个过程叫 CPS 变换(Continuation-Passing Style)。
suspend fun loadProfile(): Profile { val token = tokenProvider.token() // 挂起点一 val raw = api.getProfile(token) // 挂起点二 return raw.toDomain() }
编译器生成的骨架逻辑(示意,真实字节码更啰嗦):
fun loadProfile(cont: Continuation<Profile>): Any? { val sm = cont as? LoadProfileSM ?: LoadProfileSM(cont) // 状态机对象 when (sm.label) { 0 -> { // 第一次进来 sm.label = 1 val r = tokenProvider.token(sm) // 把状态机递进去 if (r == COROUTINE_SUSPENDED) return r // 挂起 返回哨兵值 } 1 -> { // 从挂起点一恢复 sm.token = sm.result as String sm.label = 2 val r2 = api.getProfile(sm.token, sm) if (r2 == COROUTINE_SUSPENDED) return r2 } 2 -> return (sm.result as ProfileRaw).toDomain() // 从挂起点二恢复 } }
三个要点。续体是"接下来做什么"的对象化:每个挂起点把"恢复后该执行的代码"打包进状态机,token 拿到后从 label 1 继续。挂起的返回值是一个哨兵:调用方看到 COROUTINE_SUSPENDED 就知道"这次调用还没完,线程可以走了"。状态机的字段就是局部变量:sm.token 说明了恢复后局部变量为什么还在——它们活在堆上的状态机对象里,不活在栈帧上。这也顺带解释了协程为什么轻:栈不跟着走,只是把"记事本"放进了堆。一个线程几 MB 栈空间,一个状态机对象几百字节,万级协程同线程共存才成为可能。
挂起与阻塞的区别,用主线程最容易看清:
// 阻塞 主线程卡死 ANR 风险 lifecycleScope.launch(Dispatchers.Main) { Thread.sleep(2000) // 线程被占住 UI 冻结两秒 binding.tvTitle.text = "醒来" } // 挂起 主线程全程可用 lifecycleScope.launch(Dispatchers.Main) { delay(2000) // 协程挂起 线程回事件循环照常渲染 binding.tvTitle.text = "醒来" }
两个版本都是"两秒后改文字",但 Thread.sleep 期间点击无响应、动画冻结;delay 期间界面完全正常。delay 的实现是向调度器注册一个延时任务然后挂起,线程立刻回到事件循环去干别的活。阻塞是线程停下来等,挂起是逻辑停下来、线程去忙别的——前者占着茅坑,后者把位置让出来还留了号(续体)。
由此得到挂起函数的纪律雏形:挂起函数里不许出现阻塞调用。Thread.sleep、同步 IO、重的 CPU 计算(应该切 Default 调度器)出现在挂起函数里,等于挂羊头卖狗肉——协程的结构再好,线程被堵住一切白搭。5.3 节的调度器专门处理"计算重的活该去哪"。
下图把支付案例的两种形态并排透视:左边的回调金字塔逐层失去结构保障,右边的顺序代码每行都被作用域与异常传播罩住。

真实工程里满是回调式 API(老 SDK、第三方库),把它们包装成挂起函数是迁移期的必修课:
suspend fun getLocation(context: Context): Location = suspendCancellableCoroutine { cont -> val client = LocationServices.getFusedLocationProviderClient(context) client.lastLocation.addOnSuccessListener { loc: Location? -> if (loc != null) cont.resume(loc) else cont.resumeWithException(IllegalStateException("无定位")) }.addOnFailureListener { e -> cont.resumeWithException(e) } // suspendCancellableCoroutine 还提供 invokeOnCancellation // 协程取消时可以主动断开定位回调 防泄漏 }
suspendCancellableCoroutine 把"续体"直接交到你手里:回调成功就 resume,失败就 resumeWithException——异常会沿着挂起函数的调用栈向上抛,重新进入 try-catch 的世界。invokeOnCancellation 钩子让取消也能反向通知老 SDK。包装一次,全工程受益,这是协程渐进吞噬回调存量 的标准动作。
把本节收进可执行的纪律。纪律一:挂起函数只做挂起或纯计算。阻塞调用(同步 IO、Thread.sleep、锁等待)出现在挂起函数里,等于在协程的世界里私设路障——结构再好,线程堵住一切白搭。 Retrofit 的 suspend 接口、Room 的挂起 DAO 都遵守这条,自家封装也要遵守。纪律二:挂起函数不藏作用域。函数内部 new 一个 CoroutineScope 启动协程再立刻返回,调用者的取消传不进去(5.2 节的逃逸三)——要么同步完成(挂起到结果出来),要么把异步性显式化(返回 Flow 或要求调用方传 scope)。纪律三:挂起函数的异常语义要稳定。要么文档化"失败抛某类异常",要么返回密封结果——最糟的是"有时抛有时返回 null",调用方两头防。
实读一次协程堆栈,把机制落地到排查技能。协程代码抛异常时,堆栈里会出现 WithCoroutineSuspended 标记的帧与状态机类名(编译器生成的 loadProfile$1 这类):同一行源码可能对应堆栈里的多个帧(状态机的不同 label 恢复点),且挂起点之间的"调用者"帧可能已经不在栈上(线程早换过了)。读的诀窍:看异常顶部的业务帧(状态机类名对应源码行号仍然有效),忽略中间的调度器帧;跨协程的因果链要看异常聚合(async 的多异常)或给协程命名(launch(CoroutineName("sync-cart"))),命名会出现在堆栈与线程名里,是排查的第一杠杆。
问:suspend 函数能被普通函数调用吗?
不能,这是类型系统的硬约束——suspend 只能被 suspend 或协程块(launch、runBlocking 等)调用。这条限制经常被新人当成麻烦,其实是护栏:它阻止"异步的深度"无感地渗进同步代码。当你发现一个普通函数"很想"调用挂起函数时,说明该函数本身就该是挂起的(把异步性向上传播),或者调用处应该开一个有明确作用域的协程(把异步性显式收口)。编译器逼你做的这个选择,正是结构化并发的入口。
问:挂起函数与返回 Future 或回调包装的函数,性能差在哪?
微观上,一次挂起恢复的代价是状态机对象的分配与一次调度器入队,与一次接口调用加一次线程切换相比通常是净赚——尤其在高并发下,线程不阻塞意味着池子不用扩容。真正拉开差距的是万级并发场景:回调式方案每个在途操作占一个线程栈时内存与上下文切换成本线性涨,协程把这两项都压成了常量级。但日常业务代码不必为性能选择协程(差异在微秒级),选择的理由始终是结构与正确性——性能收益只在并发规模上来之后才兑现。
问:老代码全是回调,按什么顺序改造?
从"叶子"开始:最底层的回调式 SDK 先用 suspendCancellableCoroutine 包装成挂起函数(一次包装全工程受益),上层业务调用点从嵌套改平铺。不要从中间层动手——那里回调与业务逻辑缠在一起,改一处动全身。优先改"有取消需求"的链路(页面级的请求),那些链路的收益最直接:改完即可挂上 viewModelScope,泄漏与僵尸请求一起消失。
顺序形态拿到了,但"这些协程归谁管、页面销毁谁来收摊"还没回答。下一节:结构化并发——本册认为的协程体系里最重要的一个设计。