ARTICLE DETAIL

建站实战干货

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

Kotlin 协程从入门到面试:27 个问题讲透 suspend、Job、Flow 与状态机

2026/9/28 21:07:36 拓冰建站 浏览量
Kotlin 协程从入门到面试:27 个问题讲透 suspend、Job、Flow 与状态机 Kotlin 协程的 API 看起来不多launch、async、withContext、Flow。但面试一旦从“怎么用”追问到“为什么能暂停”“子协程失败后谁会被取消”“为什么catch没接住异常”只记几个函数名就不够了。这篇文章按四层逐步推进第一层会使用 suspend、launch、async、Dispatcher 第二层能控制 Scope、Job、取消、异常与并发 第三层会落地 Flow、Android 生命周期、测试 第四层讲清原理 Continuation、状态机、回调桥接每个问题都先给出面试时能直接说清楚的答案再解释容易答错的边界。示例基于现代 Kotlin 与kotlinx.coroutines代码片段省略了常规import。第一层先建立正确的心智模型1. 协程和线程有什么区别**简答**线程是操作系统调度的执行资源协程是可以暂停、恢复的任务。协程最终仍在线程上运行但等待异步结果时可以让出线程。多个协程可以复用少量线程。协程的“轻量”主要来自任务切换不必像创建和切换系统线程那样付出相同成本并不表示零成本。一个协程若执行Thread.sleep()、同步文件读写或死循环仍会占住承载它的线程。面试追问**协程等于并行吗**不等于。并发表示任务可交替推进并行要求同一时刻在多个线程或 CPU 核上运行。2.suspend到底做了什么会自动开启线程吗简答suspend声明一个函数可以在挂起点暂停并在以后恢复。它本身不会创建协程、切线程也不会把阻塞调用变成非阻塞调用。suspendfuncalculate():Int11suspendfunstillBlocking():StringlegacyBlockingClient.execute()// 当前线程仍会被阻塞调用挂起函数通常需要处于另一个挂起函数或协程构建器内。封装遗留阻塞 API 时可以明确将它放到适合阻塞工作的执行环境suspendfunreadLegacyFile():StringwithContext(Dispatchers.IO){legacyFileReader.readAll()}如果底层 API 已提供真正的挂起实现例如 Retrofit 的suspend服务方法或 Room 的挂起 DAO 方法通常无需机械地再包一层withContext(Dispatchers.IO)。3.launch、async、withContext分别什么时候用API返回值典型用途容易误解的点launchJob启动不直接返回结果的子任务调用后不会自动等待它完成asyncDeferredT并发计算一个结果之后await()子任务失败可能立即影响父作用域不必等到await()withContextT在指定上下文执行并等待结果它本身是挂起调用不会额外启动一个独立的异步任务viewModelScope.launch{valuserwithContext(Dispatchers.IO){blockingStore.readUser()}render(user)}如果工作只是顺序执行并返回结果优先考虑suspend函数和withContext。只有确实存在可并发的独立工作时才用async。4.delay()和Thread.sleep()的区别简答delay()挂起协程不在等待期间持续占用线程并且响应协程取消Thread.sleep()阻塞当前线程。delay(1_000)// 协程暂停线程可去执行别的任务Thread.sleep(1_000)// 当前线程原地等待面试追问**delay()一定会换线程吗**不会。恢复时使用哪个线程由协程上下文和调度器决定。5.Dispatchers.Main、Default、IO怎么选MainUI 状态和主线程相关操作。DefaultCPU 密集型计算例如较重的解析、排序和算法。IO需要占用线程等待的阻塞 I/O例如遗留同步文件或数据库接口。调度器决定协程在哪里执行并不改变函数的同步/异步本质。Dispatchers.IO也不是“把任何数量的阻塞任务丢进去都没成本”的许可。网络库若已有异步挂起接口让它管理自己的执行机制通常更合适。6.CoroutineScope、CoroutineContext、Job是什么关系简答CoroutineScope定义协程的工作边界持有一个CoroutineContextCoroutineContext是元素集合常见元素包括Job、CoroutineDispatcher、CoroutineNameJob管理任务生命周期和父子关系。valscopeCoroutineScope(SupervisorJob()Dispatchers.Main)valjob:Jobscope.launch(CoroutineName(load-profile)){loadProfile()}这里的SupervisorJob是作用域中的根任务。对自己创建的长期作用域必须明确谁持有它、什么时候调用scope.cancel()。Android 页面通常直接使用已有的viewModelScope或lifecycleScope避免制造无人管理的 Scope。7.runBlocking能不能在 Android 主线程用**简答**一般不应该。runBlocking会阻塞调用线程直到它的协程体和子任务结束。在 Android 主线程中使用可能造成掉帧或 ANR。它适合命令行main函数等同步入口协程单元测试通常使用runTest。不要用runBlocking把异步 API“强行变同步”来解决页面代码组织问题。第二层结构化并发、取消和异常8. 什么是结构化并发**简答**子协程属于一个有所有者的作用域作用域知道有哪些子任务结束时会等待它们取消时可以向下取消它们失败也按规则传播。suspendfunloadHome():HomecoroutineScope{valprofileasync{api.loadProfile()}valmessagesasync{api.loadMessages()}Home(profile.await(),messages.await())}loadHome()不会在两个子任务还未完成时“悄悄返回”。如果其中一个普通子任务失败另一个会被取消异常传给调用方。这里的两个async是先启动、后等待因此能并发进行连续写async { ... }.await()两次通常只是顺序执行。9. 父协程和子协程的取消如何传播普通Job关系下父任务取消会取消子任务子任务出现非取消异常通常会使父任务失败进而取消兄弟任务。子任务正常取消一般不会让父任务失败。ViewModel 的作用域 └─ 刷新页面 Job ├─ 用户请求 └─ 订单请求ViewModel 被清除时其viewModelScope会被取消下面的任务都会收到取消信号这不等同于每次视图销毁例如 Fragment 重建视图都清除 ViewModel。取消是协作式的信号发出不等于所有底层操作已经立即停止。10.coroutineScope和supervisorScope有什么区别两者都会等待其子协程结束。差别主要在子任务失败时的相互影响普通coroutineScope中一个子任务失败会使作用域失败并取消兄弟任务supervisorScope中一个子任务失败不会自动取消其他子任务。suspendfunloadIndependentCards():PairResultCard,CardsupervisorScope{valoptionalasync{api.loadOptionalCard()}valrequiredasync{api.loadRequiredCard()}valoptionalResulttry{Result.success(optional.await())}catch(e:CancellationException){throwe}catch(e:Exception){Result.failure(e)}optionalResulttorequired.await()}示例里两个请求可以独立完成。但监督作用域不会替你处理失败async的失败仍需在await()时处理若required.await()失败函数仍会向调用方抛出异常。11.SupervisorJob和普通Job的区别SupervisorJob允许其直接子任务的失败相互隔离。这个规则不会自动传遍整棵任务树某个直接子任务内部仍可能有普通父子关系内部一个孩子失败仍会取消它的兄弟。面试追问**用了SupervisorJob是否再也不会崩**不是。未处理的异常仍需在合适的业务边界捕获监督只改变传播关系不消除异常。12. 为什么说协程取消是“协作式”的取消会改变Job状态并让delay()、withContext()等可取消挂起点抛出CancellationException。纯计算循环如果从不检查取消状态就可能继续占用 CPU。suspendfunexpensiveCalculation():ResultwithContext(Dispatchers.Default){repeat(10_000){ensureActive()performOneStep()}buildResult()}也可以在适当位置调用yield()。对不支持中断或取消的底层同步 API协程取消未必能使正在进行的系统调用立刻结束要查看底层库自己的取消机制。13.finally中还能调用挂起函数吗可以写但被取消的协程在finally中再次调用普通可取消挂起函数时通常会立即收到取消异常。若必须完成短暂的挂起清理可以把小范围、可控时长的清理放入withContext(NonCancellable)。try{work()}finally{withContext(NonCancellable){releaseRemoteLease()// 必须完成的短暂清理}}不要把长时间网络任务整体放进NonCancellable这会让页面取消失效。普通资源释放优先使用不需要挂起的close()/use。14.withTimeout和withTimeoutOrNull有什么区别withTimeout超时会取消其代码块并抛出TimeoutCancellationExceptionwithTimeoutOrNull超时返回null。二者都依赖被执行代码配合取消无法保证不可中断的阻塞调用按时停止。valresultwithTimeoutOrNull(2_000){api.loadPreview()}?:CachedPreview.EMPTY15. 为什么不能随便吞掉CancellationException因为它是协程正常取消的控制信号。若用宽泛的catch (Exception)把它转成普通错误页面离开后任务可能继续走业务分支破坏结构化并发。try{repository.refresh()}catch(e:CancellationException){throwe}catch(e:Exception){showError(e)}更好的做法是只捕获预期的业务异常例如IOException或明确的领域错误是否要捕获其他异常应由应用的错误边界决定。16.launch和async的异常表现为什么不同launch没有结果容器未处理异常会沿协程层级传播最终可能到达未捕获异常处理器。async把异常保存在Deferred中调用await()时重新抛出但如果它是普通父任务的子任务失败也会立即使父任务失败并非只在await()时才有影响。coroutineScope{valfirstasync{api.first()}valsecondasync{api.second()}first.await()tosecond.await()}如果second很快失败first可能在代码执行到second.await()之前就已经被取消。这是面试里常见的“只看await()位置”的陷阱。17.CoroutineExceptionHandler能代替try/catch吗不能。它主要观察到达协程层级顶端的未处理异常适合日志和兜底上报无法让已经失败的协程“恢复继续执行”。处理一次请求的可恢复错误应在try/catch、Result或明确的 UI 状态边界完成。对于async应在await()的业务位置处理结果或异常。给每个子协程都挂一个CoroutineExceptionHandler不能替代正确的父子作用域设计。18. 两个async一定比顺序调用快吗不一定。只有任务彼此独立且可以重叠等待或计算时并发才可能降低总耗时。并发还会增加服务端压力、共享状态复杂度和取消成本。// 可并发两个请求彼此独立coroutineScope{valuserasync{api.user()}valnoticesasync{api.notices()}UserPage(user.await(),notices.await())}// 必须顺序第二个请求依赖第一个结果valuserapi.user()valordersapi.orders(user.id)19. 多个协程同时修改状态怎样避免竞态协程并不天然线程安全。对共享可变状态按数据类型选择原子操作、Mutex或单线程所有权。privateval_countMutableStateFlow(0)funincrement(){_count.update{current-current1}}_count.value _count.value 1是“先读后写”在并发下可能丢失更新。Mutex.withLock适合需要挂起的临界区不要在持锁期间执行不受控的长时间网络请求。第三层Flow 与 Android 生命周期20.Flow为什么说是冷流普通flow {}描述了一段数据生产过程每次收集通常都会重新执行生产代码。只创建 Flow 而不collect上游一般不会开始工作。valnumbersflow{println(start)emit(1)emit(2)}numbers.collect{println(it)}// 打印一次 startnumbers.collect{println(it)}// 再打印一次 start“冷”描述的是生产和订阅的关系不代表 Flow 一定运行在后台线程。21.StateFlow和SharedFlow有什么区别特性StateFlowSharedFlow主要用途当前状态向多个订阅者广播值是否必须有初始值是否新订阅者拿到什么当前最新值取决于replay配置默认不重放更新语义基于相等性合并相同状态由replay、缓冲与溢出策略决定StateFlow适合页面状态例如加载中、内容和错误。SharedFlow可用于广播事件但一次性导航/Toast 是否能在无人订阅时保留、页面重建后是否重放需要按产品语义设计。不要仅凭类型名称假定事件不会丢失或重复。面试追问**stateIn/shareIn做什么**它们把普通 Flow 在指定 Scope 中转为可共享的热流需选择启动策略和重放行为。Scope 的生命周期会影响上游工作何时结束。22.flowOn和catch分别作用于哪里flowOn改变它上游执行的上下文不会把下游collect代码也切过去。catch主要捕获它上游流操作的异常不能接住写在后续collect {}中的异常。repository.events().map{heavyTransform(it)}.flowOn(Dispatchers.Default)// map 和更上游运行在 Default.catch{error-emit(Event.Fallback(error))}.collect{event-render(event)}// 使用收集方上下文用于停止 Flow 的协程取消不能当作普通数据错误吞掉。若render抛异常应在收集边界处理而不是误以为上游的catch会兜住一切。23.buffer、conflate、collectLatest有什么区别buffer()允许生产与消费在缓冲范围内重叠减少上游被慢消费者立即拖住的情况。conflate()消费者跟不上时跳过中间值优先保留较新的值。collectLatest()新值到来时取消上一个值的收集处理改为处理最新值。searchQueryFlow.collectLatest{query-valresultsrepository.search(query)render(results)}搜索建议通常适合“最新值优先”支付确认、流水记录则不能随意丢中间值。选择算子要先判断每个值是否都必须处理。24. Fragment 与 Compose 怎样安全收集 FlowFragment 中应使用视图生命周期收集 UI 数据并在至少STARTED时运行viewLifecycleOwner.lifecycleScope.launch{viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED){viewModel.uiState.collect{state-render(state)}}}repeatOnLifecycle在低于目标状态时取消内部块重新进入时再启动。Compose 页面通常使用collectAsStateWithLifecycle()将 Flow 转成生命周期感知的 State。不要在每次onResume()或重组时无管理地新开一个收集器。第四层原理、桥接和可测试性25. 协程为什么能在原位置恢复Continuation是什么**简答**Kotlin 编译器把有挂起点的函数改写为可记录执行位置的状态机Continuation保存恢复所需的上下文和回调入口。挂起时函数返回一个特殊标记异步结果准备好后通过 Continuation 恢复后续计算。概念上一个挂起函数的 JVM 形态接近// 仅是帮助理解的伪代码不是可直接调用的 Kotlin APIfunloadUser(id:String,completion:ContinuationUser):Any?状态机里通常有label表示继续执行的位置挂起后还要用到的局部变量会成为状态字段。只有真正遇到未完成的挂起操作时协程才需要暂停挂起函数也可能同步返回结果。面试追问**暂停是否冻结线程栈**不是。协程保存必要状态并让出线程恢复时可以被调度到合适线程继续执行。26. 如何把回调 API 改为可取消的挂起函数可以使用suspendCancellableCoroutine但要同时处理成功、失败与取消避免回调重复恢复和底层工作泄漏。suspendfunImageClient.awaitImage(url:String):ImagesuspendCancellableCoroutine{continuation-valrequestfetch(urlurl,onSuccess{image-if(continuation.isActive)continuation.resume(image)},onError{error-if(continuation.isActive)continuation.resumeWithException(error)})continuation.invokeOnCancellation{request.cancel()}}这段代码展示主要结构。真实 SDK 还要检查回调是否可能同步触发、是否可能重复调用、cancel()是否线程安全以及结果对象在取消竞态下是否需要主动释放。isActive检查不是让“检查后立即被取消”的竞态凭空消失API 仍要有明确的资源所有权约定。27. 协程代码怎么测试为什么要注入 Dispatcher把耗时工作的 Dispatcher 作为依赖传入测试时就可以用测试调度器控制任务推进而不必等待真实时间。classUserRepository(privatevalstore:BlockingStore,privatevalio:CoroutineDispatcher){suspendfunload():UserwithContext(io){store.read()}}TestfunreturnsUser()runTest{valrepositoryUserRepository(fakeStore,StandardTestDispatcher(testScheduler))valuserrepository.load()assertEquals(expectedUser,user)}runTest为协程测试提供受控环境和虚拟时间能力。若测试的代码依赖Dispatchers.Main需要按kotlinx-coroutines-test的方式替换 Main 调度器并在测试后恢复。测试重点是取消、异常和最终状态而不只是断言“某个函数被调用一次”。两道经常出现的现场追问题题一取消后输出什么runBlocking{valchildlaunch{try{delay(Long.MAX_VALUE)}finally{println(finally)}}yield()child.cancelAndJoin()println(done)}输出顺序是finally然后done。yield()让子协程有机会先运行cancelAndJoin()先发出取消再等待子任务真正结束因此finally完成后才继续打印done。题二监督作用域中一个async失败会影响另一个吗runBlocking{supervisorScope{valfailedasyncInt{error(boom)}valhealthyasync{delay(50);42}try{failed.await()}catch(_:IllegalStateException){println(handled)}println(healthy.await())}}这里会打印handled和42。两个async是监督作用域的直接子任务前者失败不会自动取消后者异常在failed.await()处被处理。把supervisorScope换成普通coroutineScope传播结果就不同。面试前的最后一张速查表被问到先回答什么最容易漏掉什么suspend可暂停与恢复不自动切线程同步阻塞 API 仍会阻塞launch/asyncJob 与 Deferred是否返回结果async子任务失败可立即影响普通父作用域结构化并发子任务有所有者生命周期可追踪无主 Scope 导致任务泄漏取消协作式挂起点或主动检查不可中断阻塞调用不一定立刻停止监督作用域直接子任务失败相互隔离监督不等于自动处理异常Flow普通 Flow 冷StateFlow/SharedFlow 热flowOn/catch只作用于上游Android 收集ViewModel 发起UI 按生命周期收集Fragment 要用viewLifecycleOwner底层原理状态机 Continuation 调度挂起不是冻结线程栈协程面试最稳的答法不是背一串 API 名而是沿着一条链解释谁创建任务 → 谁拥有任务 → 在哪里运行 → 怎样暂停和恢复 → 失败与取消传到哪里 → 页面结束后如何清理。能把这条链讲清楚常见追问基本都有落点。延伸阅读与参考资料本项目Kotlin 协程完全指南Kotlin 协程基础取消与超时协程异常处理Coroutine Context 与调度器Kotlin Flow 官方文档Android 上的协程与生命周期kotlinx-coroutines-test API