Kotlin协程入门:挂起函数、作用域与调度器全解析

📅 发布时间:2026/9/12 3:07:40
Kotlin协程入门:挂起函数、作用域与调度器全解析
2. 协程到底是什么线程的那点痛它是药先聊个场景做Android的朋友应该都经历过。你需要在界面上展示用户信息得先卡接口好不容易拿到数据还得解析解析完再塞进列表。这一套流程在主线程上跑肯定不行一卡就ANR经典的解决方案是开子线程干活干完再切回主线程更新UI。单次操作还好要是多个接口之间有依赖关系呢比如先查用户信息再用userId去查他的订单回调嵌套就来了三层、五层回调地狱的名场面。// 原生回调风格感受一下 api.getUser { user - api.getOrders(user.id) { orders - api.getDetail(orders.first().id) { detail - // 再嵌套一层就彻底崩溃了 } } }Kotlin协程解决的就是这个问题。它不是一个新概念也不是Kotlin的专利Python里有、Go里有、C里也有但在Kotlin这个语言里它被做成了语言层面的一等公民跟Android开发结合得尤其丝滑。协程的核心就八个字可挂起、可恢复、不阻塞线程。你可以在一个看似同步的代码风格里写出真正的异步逻辑不需要回调嵌套不需要手动切线程代码读起来像一条直线往下走。协程并不是“更快”它跟线程不是一类东西不能简单说谁比谁快。它更像是在线程之上加了一层“调度编排层”让你用写同步代码的方式去写高性能的异步代码。这篇基础篇我打算把一个协程新手最该知道的东西一次讲透协程是什么、挂起函数到底干了啥、CoroutineScope和CoroutineContext是什么关系、launch和async用哪个、Dispatcher怎么选、结构化并发是什么、还有最常见的异常处理。每个点我都配上能直接跑的代码示例和现场经验尽量让看完的你能直接上手用也能在面试时把逻辑讲顺。3. 核心概念拆解挂起、作用域、上下文3.1 挂起函数suspend到底挂起了什么理解挂起函数是理解协程的钥匙。你定义一个函数前面加个suspend关键字这个函数就被标记成了“可挂起函数”它只能在协程里或者其他挂起函数里被调用。但它挂起的到底是什么很多人以为是挂起线程这是最大的误解。线程该跑还是跑协程挂起时线程是被释放的可以去做别的任务。suspend fun fetchUserInfo(): User { // 假装这是个耗时的网络请求 return withContext(Dispatchers.IO) { api.getUser() } }这个挂起的本质是协程机制用状态机帮你自动完成了“切出去”和“切回来”两步。编译器遇到suspend函数时会把函数编译成一个状态机对象每个挂起点拆成一个状态。线程在执行到挂起点时发现“这活儿还没好”就直接回去干别的了等数据准备好了它再按照状态继续往下走。我在最开始接触协程时犯过一个错误以为在挂起函数里写耗时操作就万事大吉了。如果只是在一个不指定调度器的挂起函数里做CPU密集运算它照样会阻塞线程。挂起和切换线程是两码事挂起只管“暂停与恢复”这个语义切线程要靠后面说的Dispatcher。想直观理解你就把协程想成“一个能随时暂停的任务清单”。普通函数跑起来只能从头到尾一口气执行完挂起函数就是清单里可以暂停的任务点。你做着做着发现某一步要等网络返回先把这个任务挂起人线程去忙别的等网络好了再回来接着做。这就是非阻塞式挂起。3.2 CoroutineScope协程的“活动边界”CoroutineScope可以直译为“协程作用域”。它定义了一个协程的生命周期边界也是结构化并发的基础。协程必须在作用域里启动作用域被取消里面所有子协程都会被联动取消。这设计是为了防止“野协程”——一个协程在网络请求返回后页面已经销毁了你还在傻乎乎更新UI直接空指针或崩溃这种问题在没用作用域管理的异步任务里太常见了。// 全局作用域能不用就别用生命周期不随组件销毁 val scope CoroutineScope(SupervisorJob() Dispatchers.Main)在Android生态里三个现成的作用域最常用viewModelScopeViewModel销毁时自动取消最常用的一个。lifecycleScopeActivity、Fragment的lifecycle被销毁时自动取消。GlobalScope全局单例作用域生命周期跟整个应用一样长除非有非常特殊的诉求否则强烈不建议用因为它容易造成内存泄漏。新手最容易踩的坑是自定义一个CoroutineScope往里面发协程但是没有任何地方调用它的cancel()结果Activity销毁了协程还在后台跑。等到回调执行时页面已经没了轻则日志报一堆异常重则直接闪退。所以我的经验法则很简单能用viewModelScope和lifecycleScope就用别自己手搓作用域没有金刚钻别揽瓷器活。3.3 CoroutineContext协程的“环境配置包”CoroutineContext中文叫协程上下文本质是一个集合用来存放协程运行时的各类配置项。它由几个核心元素组成CoroutineScope(Dispatchers.IO SupervisorJob() CoroutineExceptionHandler { _, _ - })上面这种写法就是创建了一个上下文里面放了三个配置。注意中间那个加号上下文元素就是靠这种操作符重载组合在一起的跟你拼积木差不多。IntelliJ IDEA / Android Studio对加号有高亮提示你看到那种拼起来的Context就知道是在组合配置。实际开发中你不需要90%场景下手动去拼Context绝大多数时候创建一个CoroutineScope只需要指定Dispatcher即可其他的通常走默认值。但理解Context存在你才能明白为什么viewModelScope里的协程在ViewModel onCleared时会被取消——因为viewModelScope内部就存了一个Job作用域被取消就相当于把Context里的Job取消了一损俱损。4. 实操上手launch、async 与调度器选择4.1 launch丢个任务出去就跑launch是最常见的协程启动方式它的作用是在当前协程作用域里“发射”一个新协程返回值是一个Job代表协程的句柄。可以用Job去监听状态、取消协程。记住一点用launch启动的协程没有返回值它适合那些“只管去执行、不需要结果回来继续处理”的任务。fun loadUserAndRefresh() { viewModelScope.launch { // 在IO线程池执行网络请求 val user withContext(Dispatchers.IO) { api.getUser() } // 自动切回主线程更新UI userName.text user.name } }注意上面代码的写法launch默认在主线程上启动取决于你在哪种作用域里但它里面用withContext(Dispatchers.IO)切去了IO线程池执行完自动切回主线程。这个“自动切回”是协程最让我舒服的地方。在回调风格里切回主线程得自己runOnUiThread或者post线程池风格更是各种回调。协程里就是一次withContext的调用代码路径是线性的好读好维护。4.2 async要结果的并发请求就靠它如果这个任务执行完你需要拿到结果继续处理那就得用async。async返回的是一个Deferred对象可以理解成“一个将来才有的结果”的占位符对它调用await()就能拿到真正的返回值await()本身也是一个挂起点会挂起当前协程直到拿到结果。fun loadUserAndOrders() { viewModelScope.launch { val userDeferred async(Dispatchers.IO) { api.getUser() } val orderDeferred async(Dispatchers.IO) { api.getOrders() } // 两个请求并发执行谁先谁后无所谓两个都好了再继续 val user userDeferred.await() val orders orderDeferred.await() show(user, orders) } }这是我日常开发里特别常见的模式两个互不依赖的接口同时请求最后合并结果。如果用launch挨个写就是一个请求等另一个白白浪费时间用async并发两个请求同时发出去总耗时基本等于最慢那一个。这里有个细节值得多说一句async在协程内部是有父子结构的在某个scope里发起多个async那么它们和scope的Job是一个层级关系取消scope所有async子任务一起取消。4.3 Dispatcher协程跑在哪个线程上Dispatchers是协程自带的调度器它决定了协程被放在哪个线程池执行。Kotlin协程内置了几种DispatcherDispatcher用途类比Dispatchers.Main主线程UI操作、界面刷新Android唯一指定前台窗口Dispatchers.IOIO密集型操作网络、数据库、文件读写仓库装卸工线程池很大Dispatchers.DefaultCPU密集型操作解析、计算、排序实验室搬砖线程数是CPU核数Dispatchers.Unconfined不限制工作时线程随意切换自由人但一般不推荐我在团队代码评审时经常发现两类倾向一类是啥都是Dispatchers.IO连一个简单的整数运算都要切去IO线程池完全没必要另一类是啥都不指定结果在主线程上做了大运算导致掉帧卡顿。选择Dispatcher的原则其实没那么玄学IO密集任务选IOCPU密集任务选Default涉及UI必然Main如果你不确定先想想你的任务最耗的是什么资源。5. 结构化并发与取消机制5.1 父取消子跟随为什么协程不会“野”结构化并发是协程设计里最重要的思想它意味着协程之间存在清晰的父子关系。父作用域被取消子协程全部取消子协程抛异常父协程中其他子协程是否继续取决于用的是普通Job还是SupervisorJob。val scope CoroutineScope(Job()) scope.launch { launch { delay(1000) Log.d(TAG, 子协程1) } launch { throw NullPointerException(出错了) } }上面这段代码第一个子协程里的日志不会打印出来。因为第二个子协程抛了异常在默认Job策略下异常会向上传播把父Job标记为异常然后第一个子协程也被父协程取消。整套机制的底层逻辑就是一个协程出问题整个作用域取消避免一半成功一半失败地运行在不可知状态。这在Android里其实很合理——页面都销毁了还在跑的子协程就没意义了越早清理越安全。5.2 取消不是强杀是协作式取消协程的取消有一个重要的机制协作式取消。也就是说你不能随便把一个正在跑的协程“掐死”而是要靠协程自己配合。怎么配合主要是两个点一是在协程代码里在合适的挂起点上检查协程是否被取消二是任务本身就是可取消的比如delay()、withContext()这些内置挂起函数都会自动检查取消状态并抛出CancellationException。// 一个没有挂起点、不会主动退出的循环 viewModelScope.launch { var n 0 while (n 100000) { // 这么写cancel之后协程并不会立刻停止 n } }这算是一个新手极易踩的坑。你以为调用了job.cancel()协程就会瞬间终结实际它还在循环里跑得欢。正确做法是在循环里加一个检查点while (n 100000 isActive) { n }isActive是CoroutineScope的扩展属性用来判断当前协程是否活动。在commit为主的代码里能主动给协程添加取消检查的地方尽量加能省很多排查心累的时间。6. 异常处理深入与实战坑位排查6.1 异常处理三板斧try-catch、CoroutineExceptionHandler、SupervisorJob协程的异常处理有几种手段各有适用场景别混着用。最容易理解的当然是try-catch协程体内部的代码可以用它包起来viewModelScope.launch { try { val data withContext(Dispatchers.IO) { api.getUser() } // 成功继续 } catch (e: Exception) { Log.e(TAG, 请求失败, e) } }try-catch的问题是它只能捕获当前协程体内的异常如果这个异常是从子协程抛出来的父协程里try是接不住的。于是有了CoroutineExceptionHandler专门用来处理未捕获的异常有点类似Thread的uncaughtExceptionHandler。但注意这个handler只有在协程的根节点才能起作用在子协程里设置了并不会生效。SupervisorJob则是另一种思路。它跟Job的区别是Job下子协程失败会连带取消父协程和兄弟协程SupervisorJob下子协程失败只影响它自己不影响兄弟协程。直播推流、埋点上报这种独立性强的场景一般建议用SupervisorJob彼此失败隔离。看下两者的区别表比较维度JobSupervisorJob子协程异常向父级传播父级和兄弟协程都会取消只取消自己不影响兄弟典型场景独立小任务组失败后整体重来互相不相关的任务集合与异常处理器配合不配合handler容易全局炸配合handler体验更丝滑6.2 我的实战经验永远别把异常“静默吞掉”分享一条我真实的踩坑经历。刚用协程那会儿写了一段网络请求什么都正常就是偶尔线上日志里有大量协程取消的异常堆栈。排查很久才发现用户快速退出页面时viewModelScope被取消里面的网络请求正在执行协程自动抛出了CancellationException——这在协程机制里属于正常现象不是错误。但因为我没任何处理日志里全是红晃晃的异常栈看着非常崩溃。后来长记性了凡是使用协程的地方我至少要分清楚异常类型。CancellationException不需要特殊处理它本身就是协程取消的正常信号但如果你用try-catch把异常吃掉了反而可能导致协程取消不彻底引发更隐蔽的问题。我自己现在的异常处理习惯是根协程设置CoroutineExceptionHandler兜底业务代码里需要给用户友好提示的场景用try-catch精准处理凡是catch到的异常一定要打印日志哪怕你觉得不可能发生。闷声吞异常是最坑的为排查问题留一条活路。6.3 一个功能完整性的示例请求 状态管理把上面所有知识点拼在一起我写一个相对完整的小示例模拟加载用户数据并处理加载状态。注意看结构化并发、Dispatcher、异常处理是怎么协同的fun loadUser() { viewModelScope.launch { // 用state控件更新UI状态 _uiState.value UiState.Loading try { val user withContext(Dispatchers.IO) { repository.getUser() } _uiState.value UiState.Success(user) } catch (e: CancellationException) { throw e // 取消必须继续抛出不能吞 } catch (e: Exception) { _uiState.value UiState.Error(e.message ?: 未知错误) } } }注意里面那一行throw e我特别提一下。如果你在catch里把CancellationException吞了协程被取消时可能不会真正退出而是继续执行曾经有同事因为这个bug页面都销毁了还收到回调排查了很久。别管日志显示什么但凡catch到了CancellationException不做业务处理重新抛出去准没错。这是协程异常处理里最核心、也最容易被忽略的细节。7. 协程和线程的对比及典型面试梳理7.1 一文搞懂协程与线程的区别很多人问协程是不是就是轻量级线程这个说法对一半。协程确实是跑在线程上的但它的轻量不是因为不占资源而是因为它支持挂起恢复不用像线程那样为了等待而盲占资源。特性线程协程归属操作系统级别应用库级别切换开销内核态切换开销大用户态切换开销极小阻塞性线程阻塞时系统调度负担重挂起不阻塞线程并发数量千级别背景已经吃力轻松支持上万甚至十万级并发任务代码结构回调、锁、barrier全靠手动线性代码自动切线程你开一万个线程机器直接吃不消但Kotlin协程开一万个绝大多数时间它们是挂起状态不占线程池。拿我说过的“任务清单”类比线程是员工协程是清单里的任务项。员工就那几个任务可以很多员工做完一个再领下一个。协程的多是企业级的任务多不代表员工多。7.2 面试高频题自查清单把这几个问题作为自我检查如果都能答上说明你对协程的基础理解过关了协程和线程的区别协程为什么能实现非阻塞式挂起suspend函数一定在子线程执行吗不一定它只是可挂起执行线程由Dispatcher决定launch和async的区别Deferred和Job有什么关系withContext做了什么它和async-await有何不同协程的取消是怎么实现的如何处理CancellationExceptionSupervisorJob和Job有啥区别日常用哪个最后一题日常开发里我通常会选SupervisorJob或者viewModelScope内部自带。多任务之间如果只是“同时干几个活”它们彼此之间的失败隔离会让整体更稳。8. 进阶路线建议与常见避坑备忘8.1 清楚这篇是地基后续还需要补这四块这篇基础篇讲的是协程的地基作用域、上下文、启动方式、调度器、结构化并发、异常处理。地基打牢之后你会发现协程真正的大招还在后面。我个人建议按这个顺序往下学Flow协程版的异步数据流处理连续数据、状态流比集合流的操作符更灵活和Room、Retrofit配合很主流。Channel协程间的通信管道有点像线程的BlockingQueue但语义和挂起结合更紧密。select表达式在多个挂起函数之间做选择谁先完成用谁进阶必备。控制器如Mutex、Semaphore涉及共享状态时控并发保证不会出现数据竞争。8.2 个人实操经验总结再啰嗦三个我踩过坑之后的固定习惯希望能帮你少走弯路。第一条能用现成scope就绝不手写。Project里所有人写协程必须用viewModelScope或者lifecycleScope特殊场景需要自定义scope时一定记得找个地方cancel。无脑用GlobalScope的代码在我这里review绝过不了。第二条在协程内做任何IO操作一定切Dispatcher.IO做数据解析一定切Dispatcher.Default。网络请求还好框架一般自带切线程最怕的是有人直接把内存数据库查询、超大JSON解析扔在主线程。一次卡顿还好每次都卡顿就废了。第三条给每个协程任务想清楚“如果取消怎么办”。页面销毁、用户退出都是再正常不过的操作你的协程要有能力干净利落地退出不输出异常栈、不更新已销毁的UI。每次写协程前默念一遍结构清楚异常合法取消合规。这九个字比任何复杂的设计模式都更可持续。协程这块内容很大基础篇我先写到这里之后可以继续聊Flow、Channel、协程在ViewModel里的完整生命周期管理等。希望这篇对你真正有用下一篇文章见。