公司动态

Kotlin suspend 与 Continuation:挂起不是子线程

📅 2026/7/19 13:56:06
Kotlin suspend 与 Continuation:挂起不是子线程
文章目录第 1 章 挂起不是子线程打破最常见的协程误区原理补充踩坑动手练第 2 章 Continuation 与 CPS编译器替你做回调传递原理补充踩坑动手练第 3 章 状态机label、挂起点与 resume 从哪继续原理补充踩坑动手练第 4 章 工程接入withContext、Retrofit 与同一 Continuation 链原理补充踩坑动手练第 5 章 suspendCancellableCoroutine把回调 API 接进 suspend 世界原理补充踩坑动手练第 6 章 反模式与边界何时占线程、何时真挂起动手练面试速查 · 追问链追问链 #1suspend 函数挂起时线程释放了吗为什么 追问链 #2Continuation 是什么与 Callback 什么关系 追问链 #3withContext 和 launch 有什么区别 ⭐追问链 #4suspendCancellableCoroutine 用在什么场景 追问链 #5状态机的 label 是干什么的 ⭐完整链路一句通相关推荐第 1 章 挂起不是子线程打破最常见的协程误区读完作用域函数篇后你在 Repository 里已经能写suspend fun loadUser()。但中级工程师最常见的误判是「加了suspendKotlin 就会帮我在后台线程跑。」这是错的。suspend只表示这个函数可以挂起——在挂起点暂停执行、释放当前线程等结果就绪后从下一行继续。至于挂起期间线程去哪、恢复时在哪个线程执行由协程上下文里的Dispatcher决定不是suspend关键字本身。三类场景说明「为什么需要挂起」网络 / 磁盘 I/O等待响应时不能占着主线程但也不能把业务逻辑拆成三层回调。多步串行异步fetchConfig()→fetchList()→mapDto()希望像同步代码一样从上到下读。与结构化并发配合viewModelScope取消时挂起链上的 Retrofit Call、Room 查询应一并中断。suspend解决的是控制流写法线性、可取消不是线程模型。线程切换是withContext(Dispatchers.IO)、launch的context、Retrofit adapter 内部调度等额外机制叠加的结果。原理补充从调用方视角suspend fun foo(): User看起来像普通函数编译后实际签名为foo(ContinuationUser)返回值类型变为Any?正常结果或特殊挂起标记。这是Continuation Passing StyleCPS把「函数执行完后去哪」打包进Continuation交给运行时或库在合适时机resume。踩坑把Dispatchers.IO当成suspend的同义词——在IO线程里Thread.sleep仍会占满该线程。认为「主线程不能调 suspend」——主线程可以调 suspend关键是挂起点不能阻塞主线程。在suspend函数里写阻塞 APIThread.sleep、InputStream.read()循环却不切IODispatcher。动手练A用一句话区分「挂起」与「切线程」。D在工程里搜suspend fun找 1 处并判断若去掉suspend改同步阻塞会卡哪个线程。第 2 章 Continuation 与 CPS编译器替你做回调传递要理解suspend必须先理解Continuation一个「接收结果并决定下一步」的句柄。标准库核心接口简化// Tier A — 教学示意展示 resume 语义非生产手写interfaceContinuationinT{valcontext:CoroutineContextfunresumeWith(result:ResultT)}funTContinuationT.resume(value:T)resumeWith(Result.success(value))funTContinuationT.resumeWithException(e:Throwable)resumeWith(Result.failure(e))下面用手写 CPS对比编译器行为。假设有两个逻辑步骤先取 ID再取详情——源码是线性的// 你写的 Kotlin 源码suspendfunloadUserDetail(repo:UserRepo):UserDetail{validrepo.fetchUserId()// 挂起点 1returnrepo.fetchDetail(id)// 挂起点 2}编译器大致把它变成「把 Continuation 往下传」// Tier A — CPS 教学伪代码省略状态机细节funloadUserDetail(repo:UserRepo,cont:ContinuationUserDetail){repo.fetchUserId(object:ContinuationString{overridevalcontextcont.contextoverridefunresumeWith(result:ResultString){result.fold(onSuccess{id-repo.fetchDetail(id,cont)// 第二步直接把外层 cont 传下去},onFailure{cont.resumeWithException(it)})}})}若不用suspend等价于手写嵌套回调fetchUserId { id - fetchDetail(id) { detail - ... } }。suspend的价值是编译器自动生成并串联 Continuation让你读线性代码运行时仍走 CPS。原理补充每个suspend调用点在字节码层是一次对带Continuation参数版本的调用。若被调函数需要等待网络、锁、delay它向运行时返回COROUTINE_SUSPENDED并保存当前进度完成时通过传入的Continuation.resumeWith恢复。若调用立即完成则直接返回结果不挂起。踩坑以为 Continuation 等于「子线程回调对象」——它只承载恢复点不绑定线程。在生产代码里手写 CPS 替代suspend——可读性与取消语义都会变差仅用于理解原理。动手练A把suspend fun a(); suspend fun b()两步串行改写成两段嵌套Continuation伪代码各 5 行以内。B起点指出下面「假 suspend」的问题——它阻塞线程而非挂起suspendfunfakeFetch():String{Thread.sleep(2_000)// 占住当前线程 2 秒returndone}验收改用delay(2000)说明差异或说明为何应包在withContext(Dispatchers.IO)且仍不推荐Thread.sleep。第 3 章 状态机label、挂起点与 resume 从哪继续单个suspend函数若有多个挂起点编译器会生成状态机类用label记录「下次 resume 从哪一行继续」并把跨挂起点的局部变量存进状态对象字段。// 源码suspendfunloadSummary(repo:SummaryRepo):Summary{valmetarepo.loadMeta()// label 0 → 1valitemsrepo.loadItems(meta)// label 1 → 2returnSummary(meta,items)}概念上等价于极度简化// Tier A — 状态机教学伪代码privateclassLoadSummaryMachine(privatevalrepo:SummaryRepo,privatevalcont:ContinuationSummary):ContinuationAny?{varlabel0lateinitvarmeta:Metalateinitvaritems:ListItemoverridefunresumeWith(result:ResultAny?){result.fold(onSuccess{when(label){0-{metaitasMeta;label1;repo.loadItems(meta,this)}1-{itemsitasListItem;cont.resume(Summary(meta,items))}}},onFailure{cont.resumeWithException(it)})}}读代码时的心智模型suspend 调用 → 进入状态机 → 遇挂起点返回 SUSPENDED → 线程释放 ↑ ↓ resume 携带结果 ← Continuation.resumeWith ← IO/网络完成挂起不等于新开线程主线程上的协程在delay或 Retrofit 等待响应时主线程可去处理别的消息恢复时是否回到主线程看Continuation.context里的Dispatcher。原理补充label与局部变量打包是 Kotlin 编译器CoroutineTransformer的职责。多个suspend函数嵌套调用时各层状态机通过 Continuation 链接成链取消时沿链传播CancellationException。踩坑在挂起点之间使用可变var并期待「挂起期间别处修改」——状态机捕获的是快照字段易出竞态。忽略CancellationException在catch (e: Exception)里吞掉取消导致协程无法及时退出。动手练A有 3 个挂起点的函数状态机最多需要几个label值含函数出口C写出try/finally包在suspend函数里finally在取消时是否执行答案会除非进程被杀第 4 章 工程接入withContext、Retrofit 与同一 Continuation 链suspend机制本身不切换线程工程里withContext(Dispatchers.IO)在挂起块前后替换Continuation.context中的Dispatcher块结束后自动切回调用方上下文。它与状态机是同一套模型上的「上下文替换」不是第二套异步系统。典型 RepositoryclassSceneryRepository(privatevalapi:SceneryApi,privatevaldao:ProvinceDao,){suspendfunloadProvinces(category:Category?):ListProvincewithContext(Dispatchers.IO){valcacheddao.getAll()if(cached.isNotEmpty())returnwithContextcached api.getProvinces(category?.name).map{it.toDomain()}.also{dao.insertAll(it)}}}Retrofit 2.6 的suspend fun由SuspendForBody等 CallAdapter实现在协程里调用接口方法时内部把 OkHttp 异步enqueue包装成挂起响应到达后resume传入的Continuation失败则resumeWithException(HttpException)。interfaceSceneryApi{GET(v1/provinces)suspendfungetProvinces(Query(category)category:String?null,):ListProvinceDto}ViewModel 侧只启动结构化并发边界不手动管线程classExploreViewModel(privatevalrepository:SceneryRepository,savedStateHandle:SavedStateHandle,):ViewModel(){privatevalcategory:Category?savedStateHandle.getString(category)?.let{name-runCatching{Category.valueOf(name)}.getOrNull()}privateval_uiStateMutableStateFlow(ExploreUiState())valuiState_uiState.asStateFlow()funload(){viewModelScope.launch{_uiState.update{it.copy(loadingtrue)}try{valprovincesrepository.loadProvinces(category)_uiState.update{it.copy(loadingfalse,provincesprovinces)}}catch(e:CancellationException){throwe}catch(e:Exception){_uiState.update{it.copy(loadingfalse,errore.message)}}}}}链路解读viewModelScope.launch创建协程 →loadProvinces进入状态机 →withContext(IO)在挂起点切换 Dispatcher →api.getProvinces再次挂起等网络 → 两次resume后回到Main更新StateFlow。整条链共享 Continuation 祖先onCleared取消 scope 时可中断未完成的 Call。工作区对照ExploreViewModel导航参数解析 suspend 加载private val category: Category? savedStateHandle.getString(category)?.let { name - runCatching { Category.valueOf(name) }.getOrNull() }suspend加载列表与上述load()模式一致解析参数是同步初始化异步部分在viewModelScope.launch内调用repository的suspend方法。原理补充withContext实现为suspend函数挂起当前协程、在目标 Dispatcher 上执行 block、完成后恢复原始上下文。Retrofit suspend adapter 本质是suspendCancellableCoroutine { cont - call.enqueue(...) }的库内封装。踩坑在viewModelScope.launch(Dispatchers.IO)里又包一层withContext(IO)——通常多余除非内层需要切回Main。以为 Retrofitsuspend自动在主线程回调——响应解析在 OkHttp 线程恢复后的下一行才回到协程上下文常为Main。Repository 返回Flow却用suspend fun拉一整页——流式场景应用FlowcallbackFlow另篇展开。动手练ARepository读库 打 API应在一个withContext(IO)里还是两个说明理由。C为SceneryApi写suspend fun getProvinces()接口声明并在ExploreViewModel.load()里用try/catch区分IOException与HttpException。第 5 章 suspendCancellableCoroutine把回调 API 接进 suspend 世界标准库与三方库并非处处提供suspend。旧 SDK、定位、蓝牙、手写enqueue仍是回调模型。桥接标准写法是suspendCancellableCoroutine在挂起函数内注册回调完成时cont.resume协程取消时通过cont.invokeOnCancellation释放资源。suspendfunLocationManager.awaitLastLocation():LocationsuspendCancellableCoroutine{cont-valcallbackobject:LocationCallback(){overridefunonResult(location:Location){cont.resume(location)}overridefunonError(e:Throwable){cont.resumeWithException(e)}}requestLocation(callback)cont.invokeOnCancellation{cancelRequest(callback)}}与裸suspendCoroutine相比可取消版本在viewModelScope销毁或用户返回时会触发invokeOnCancellation避免泄漏回调。ExploreViewModel 若需「先拿定位再拉附近景点」suspendfunloadNearby(){vallocationlocationManager.awaitLastLocation()valprovincesrepository.loadProvincesNear(latlocation.latitude,lnglocation.longitude,categorycategory,)_uiState.update{it.copy(provincesprovinces)}}整段仍是线性suspend底层却是两次挂起定位回调一次、Retrofit/Room 一次状态机label在两次 resume 之间保存location变量。Retrofit 未提供 suspend 前的社区写法同一模式// 理解 Retrofit 内置 adapter 即可不必手写suspendfunTCallT.await():TsuspendCancellableCoroutine{cont-enqueue(object:CallbackT{overridefunonResponse(call:CallT,response:ResponseT){if(response.isSuccessful)cont.resume(response.body()!!)elsecont.resumeWithException(HttpException(response))}overridefunonFailure(call:CallT,t:Throwable){cont.resumeWithException(t)}})cont.invokeOnCancellation{cancel()}}原理补充suspendCancellableCoroutine返回时若cont.resume尚未调用协程处于挂起态resume后状态机进入下一label。重复resume会抛IllegalStateException故回调里应保证只 resume 一次。踩坑忘记invokeOnCancellation页面退出后回调仍触发并resume已取消的协程。在Main线程的回调里做重计算——应resume后由withContext(Default)处理。用GlobalScope.launch包装桥接函数破坏结构化并发。动手练A说明suspendCancellableCoroutine与手写Callback在取消语义上的差别一句话。B起点下面桥接缺少取消补全invokeOnCancellationsuspendfunSensorManager.awaitReading():FloatsuspendCancellableCoroutine{cont-vallistenerobject:SensorEventListener{overridefunonSensorChanged(event:SensorEvent){cont.resume(event.values[0])}overridefunonAccuracyChanged(sensor:Sensor?,accuracy:Int)Unit}registerListener(listener)// 缺什么}第 6 章 反模式与边界何时占线程、何时真挂起掌握 Continuation 后应能识别「写了suspend却仍在占线程」的代码——这类 bug 在 Profiler 里表现为 IO 线程池耗尽或主线程 ANR。反模式清单反模式问题改法suspend内Thread.sleep/ 阻塞 IO占住 Dispatcher 线程不是挂起delay或真异步 API suspendCancellableCoroutine以为suspend 后台线程主线程launch里跑 CPU 密集计算withContext(Default)或async到处withContext(IO)包一行赋值无谓切换增加状态机开销合并 IO 块或依赖 Room/Retrofit 内置调度catch (Exception)吞CancellationException取消失效单独rethrow取消桥接回调不invokeOnCancellation泄漏与重复 resume见第 5 章坏代码完整示例类型 B 重构起点classBadRepository(privatevalapi:SceneryApi){suspendfunloadProvinces():ListProvince{// 误以为 suspend 会自动在后台跑Thread.sleep(3_000)// 占线程 3 秒returnapi.getProvinces().map{it.toDomain()}}}Thread.sleep不会让出线程协程调度器认为你还在执行线程池一个 worker 被白白占用。若这段跑在Dispatchers.IO默认 64 线程上限高并发时会排队甚至饿死其他请求。正确方向suspendfunloadProvinces():ListProvincewithContext(Dispatchers.IO){api.getProvinces().map{it.toDomain()}// 需要等待时用 delay挂起不占线程或依赖 Retrofit 挂起}边界总结suspend → 能否暂停函数、串联 Continuation Dispatcher → 在哪个线程池执行挂起前的同步段与 resume 后的代码 delay/异步库 → 是否真正释放线程动手练Adelay(1000)与Thread.sleep(1000)对线程池的影响各用一句话说明。B起点重构上文BadRepository去掉Thread.sleep并说明api.getProvinces()suspend 调用在何处挂起。D 验收清单在项目中找 1 个suspend函数检查是否存在阻塞调用若有写出是否应改为withContext/ 桥接 /Flow。面试速查 · 追问链追问链 #1suspend 函数挂起时线程释放了吗为什么 标准回答≤200 字释放了——若挂起点实现正确。suspend函数在需要等待时向运行时返回COROUTINE_SUSPENDED当前协程帧保存到状态机线程回到 Dispatcher 线程池可执行其他协程。恢复由Continuation.resumeWith触发不占用等待期的线程。若在suspend里Thread.sleep或阻塞 IO则不会释放。追问 1主线程上可以调用suspend fun吗答可以。viewModelScope.launch常在 Main 启动挂起点如 Retrofit、delay会暂停协程而不阻塞 Looper前提是内部不用阻塞 API。追问 2delay和Thread.sleep对线程的影响答delay真挂起不占线程sleep阻塞当前线程协程框架仍认为在执行浪费 worker。追问链 #2Continuation 是什么与 Callback 什么关系 标准回答≤200 字ContinuationT是「异步结果就绪后如何恢复执行」的句柄带context与resumeWith(ResultT)。编译器把suspend函数改成 CPS每个挂起点把Continuation传给下层。手写Callback.onSuccess/onFailure等价于手动传 Continuationsuspend是编译器自动生成并串联这条链。追问 1为什么suspend函数签名里看不到 Continuation答源码语法糖JVM 字节码层最后一个参数是Continuation且返回类型可能为Any?。追问 2resume两次会怎样答IllegalStateException桥接回调时要保证只 resume 一次取消用invokeOnCancellation清理。追问链 #3withContext 和 launch 有什么区别 ⭐标准回答≤200 字withContext是挂起函数在同一协程内切换Dispatcher块结束自动恢复原上下文常用来拿返回值。launch启动新的并发子协程不直接返回业务结果。串行「切 IO 取数再回 Main 更新」用withContext并行多个任务用多个launch/async。追问 1withContext(IO)会创建新线程吗答不保证向 IO 线程池提交任务池内复用线程。关键是协程挂起不占用等待期线程。追问 2Retrofitsuspend还要不要包withContext(IO)答Retrofit 已在内部挂起等待网络是否再包IO看团队规范——解析大 JSON 或额外磁盘 IO 时常包一层。追问链 #4suspendCancellableCoroutine 用在什么场景 标准回答≤200 字把只有回调、没有 suspend的 API 接入协程世界注册监听/enqueue在回调里cont.resume或resumeWithException用invokeOnCancellation在协程取消时注销监听、取消 Call。Retrofit 内置 suspend adapter、Room、Play Services 部分 API 已封装业务层仅在遗留 SDK 桥接时手写。追问 1和 RxJavaSingle.create像吗答角色类似都是桥接suspend 版本与结构化并发、CancellationException集成更好。追问 2viewModelScope取消后回调仍触发怎么办答invokeOnCancellation里移除监听resume前检查cont.isCancelled或依赖库内原子保证。追问链 #5状态机的 label 是干什么的 ⭐标准回答≤200 字一个suspend函数多个挂起点时编译器生成状态类用label标记「下次 resume 从哪段代码继续」跨挂起点的局部变量存为字段。resumeWith带着成功/失败进入when(label)分支模拟线性执行。理解 label 能读懂反编译协程代码也能解释为何取消能在挂起点之间生效。追问 1嵌套suspend调用有几个状态机答每个suspend函数各有一个Continuation 链把它们串起来。追问 2能否在反编译里看到$$continuation答常见类名带$与label字段是协程状态机特征。完整链路一句通suspend是 CPS 状态机不是子线程→挂起点SUSPENDED释放线程→Continuation.resumeWith从下一label继续→withContext/ Retrofit adapter 在同一链上挂起与恢复→遗留回调用suspendCancellableCoroutine 取消清理→拒绝Thread.sleep等阻塞假挂起。相关推荐Kotlin 语法与空安全Android 开发第一课Kotlin 作用域函数let/apply 工程选型