☰
Kotlin协程实战手册:从启动、取消到异常处理的完整指南
2026/10/13 5:34:11 网站建设 项目流程

1. 从回调地狱到协程:这篇手册到底在聊什么

Kotlin 协程上手之后,很多代码就再也回不去以前那种回调嵌套的写法了。这篇内容是整理出来的一个持续更新的协程使用手册,不是官方文档的翻译,而是我在真实项目里反复用到、反复踩坑之后沉淀下来的思路和套路。适合已经开始用协程但还没完全吃透的开发者,也适合刚入门、想直接看到"这东西在业务里到底怎么用"的人。

协程解决的核心问题,总结起来其实就三个:一是回调地狱,一段网络请求套一段回调,逻辑被拆得稀碎;二是线程切换太繁琐,业务代码到处是 runOnUiThread 或者 handler.post;三是生命周期管理难,异步任务跑着跑着页面关了还在执行,轻则泄漏,重则崩溃。协程把这些问题统一成一套语言层面的异步模型:代码看起来是同步的,执行是异步的,取消和异常是结构化的。

有一点需要先纠正观念:协程不是"更轻量的线程"。线程是操作系统调度的资源,协程是语言运行时实现的可挂起计算状态。一个线程上可以跑成千上万个协程,协程的切换不需要操作系统介入,本质是把一段代码切分成多个挂起点,每次执行到一个挂起点就保存现场、让出线程,等条件满足后再恢复现场继续跑。理解了这一点,后面调度器、取消机制、异常传播才看得懂。

2. 协程上手必会的九个基础 API

2.1 启动方式怎么选:launch / async / runBlocking

协程的启动入口就三个:launch、async、runBlocking。下面这段代码把它们的返回值差异一次说清楚:

fun main() = runBlocking { // launch:返回 Job,适合"发出一个任务,不关心结果"的场景 val job: Job = launch { delay(1000) println("launch 任务执行完毕") } job.join() // 等待这个 Job 完成 // async:返回 Deferred,适合需要拿结果的场景 val deferred: Deferred<String> = async { delay(500) "异步结果" } val result: String = deferred.await() println("async 结果: $result") }

runBlocking 是一个比较"重"的入口,它会阻塞当前线程直到协程体执行完。它主要是给测试代码、main 函数用的,不建议在正式 Android 业务里用,也不建议在 UI 线程用,因为在主线程调用 runBlocking 等于把主线程卡死等一个异步任务完成,这会直接导致 ANR。

三者的选择可以记一句口诀:发任务不关心结果用 launch,需要结果用 async,写测试或脚本入口用 runBlocking。还有一个细节:async 的异常不会立刻抛出,而是在 await() 的时候抛出,这个差异在异常处理一章会专门细说。

2.2 Dispatchers 调度器怎么用不踩坑

调度器决定了协程跑在哪个线程上。常用的就四个:

调度器线程环境适用场景注意点
Dispatchers.Main主线程(Android)UI 操作、更新界面不能随意在它上面执行耗时操作
Dispatchers.IO共享线程池网络请求、文件读写、数据库操作适合阻塞型 IO
Dispatchers.DefaultCPU 核心数对应的线程池计算密集型任务、JSON 解析、图片压缩线程数量默认近似 CPU 核数
Dispatchers.Unconfined不限线程几乎不用,仅测试/调试用容易在非预期线程上恢复执行

实际项目里最常见的问题是"把耗时任务直接挂到 Dispatchers.Main 上"。很多新手会写出这种代码:

lifecycleScope.launch(Dispatchers.Main) { // 直接在 Main 上跑大循环,页面照样卡 val bigResult = 拉取大数据() textView.text = bigResult }

如果拉取大数据这个函数本身是耗时的同步操作,那么在 Main 调度器上执行照样卡死主线程。协程能解决的是异步问题,不是 CPU 耗时问题。正确的做法是让耗时操作切到 IO 线程:

lifecycleScope.launch(Dispatchers.Main) { val result = withContext(Dispatchers.IO) { 拉取大数据() } textView.text = result }

withContext 并不会新开一个协程,它只是在当前协程内切到别的线程执行完再切回来。这是协程开发里最常用的一个函数,比 async + await 的写法在语义上更清晰,也更符合"一段逻辑整体放在一个协程里"的结构。

2.3 Job 和结构化并发:协程的"亲缘关系"

协程是有父子关系的。当一个协程在另一个协程的 CoroutineScope 里启动,它就会成为那个作用域的子协程。父协程会等待所有子协程完成后才结束,父协程取消时子协程也会跟着取消。这个规则叫结构化并发。

看这段代码感受一下:

fun main() = runBlocking { val parentJob = launch { launch { repeat(5) { delay(100); println("子任务1: $it") } } launch { repeat(5) { delay(100); println("子任务2: $it") } } } delay(250) parentJob.cancel() // 父协程取消,两个子协程全部停止 println("父协程已取消") }

这里取消 parentJob 后,两个子协程不会自己"硬扛着"把任务跑完,而是会同时被取消。这套机制就是崩溃现场最常见的"协程泄漏"解法的设计基础:只要把协程挂在正确的作用域上,页面销毁时把作用域 cancel 掉,所有后代协程都被收拾干净。

还有一个容易被忽略的点:Job.join() 和 Job.cancel() 的组合使用。cancel() 是立即返回的,它只是发出取消信号,不会等到协程真正停止。如果 cancel 之后立刻调 join(),才是真正等待协程完成取消流程。很多线上问题就是因为 cancel 后马上往下走,而协程还在释放资源,导致状态被跳过。

3. 协程取消:这块我看十个项目有九个写错

3.1 协作式取消到底是怎么运作的

协程的取消不是暴力停掉线程。协程的取消是协作式的:当协程收到取消信号后,它只会在下一个挂起点抛出一个 CancellationException。所以,取消要生效,前提是协程代码里得有"响应取消"的地方。

能响应取消的典型位置包括:delay、withContext、withTimeout、挂起 IO 操作等。这些挂起函数在恢复执行时会检查当前协程是否处于取消状态,如果是就直接抛异常退出。

但如果协程体里写了一个死循环,循环里是 CPU 计算,没有任何挂起函数,那 cancel 信号发了也白发。举个最简单的例子:

val job = launch(Dispatchers.Default) { var i = 0 while (true) { i++ // 纯计算,不停,无法响应取消 } } delay(100) job.cancel() println("job is cancelled: ${job.isCancelled}") // true,但协程还在跑

Job 的 isCancelled 已经是 true 了,但那个 while(true) 还在疯狂消耗 CPU。这种"取消不生效"的问题在真实项目里绝大多数是这种场景。

3.2 取消不生效的排查实录:两个典型场景

之前排查过一个问题,现象是页面已经销毁,但后台的日志还在不断打印"轮询中"。查到代码发现是一个 while(isActive) 没写对。原始代码长这样:

scope.launch(Dispatchers.IO) { // 错误的写法:没有检查协程是否还活着 while (true) { doSomething() delay(1000) } }

这段代码其实是可以取消的,因为 delay 会响应取消。真正的坑是另一种写法:

scope.launch(Dispatchers.Default) { // 里面有一大段 CPU 计算,for 循环几百万次,中间没有挂起点 while (true) { heavyCompute() Thread.sleep(20) // 用线程休眠代替 delay,这是原罪 } }

Thread.sleep 不是协程的挂起函数,它不能响应取消。协程挂起必须用 delay,而不是 Thread.sleep。前者让出的是协程的执行权,后者让出的是线程的执行权,语义完全不同。排查这类问题时,优先检查三处:协程体里是否用 Thread.sleep 冒充 delay、循环条件是否用了 isActive、耗时操作是否包在挂起函数外面没检查取消状态。

3.3 清理资源时必须用的 NonCancellable

协程被取消时,finally 块会执行,但如果 finally 块里有挂起函数,代码就会编译不通过。原因是协程已经处于取消状态,不能继续挂起。这时需要 NonCancellable 来"借"一个新的上下文执行清理逻辑:

scope.launch { try { delay(1000) println("任务完成") } finally { // 这里不能直接调用 delay 等挂起函数 withContext(NonCancellable) { // 必要的清理操作,比如关闭数据库连接、上报日志 closeConnection() } } }

NonCancellable 是一个特殊身份,它会让代码块在取消状态下也能继续执行。但是要注意,它只能用于清理场景,不能拿它包裹核心业务逻辑,否则取消机制就白设计了。

4. 异常处理:从裸奔到全副武装

4.1 launch 和 async 的异常传播差异

协程的异常传播机制是最容易让人困惑的地方。核心区别就一句话:launch 启动的协程里,未捕获异常会被当作"未处理异常"向上传播,最终交给父协程的异常处理机制;async 启动的协程则傻傻地存着异常,直到有人调用 await() 时才抛出。

fun main() = runBlocking { // launch 风格的异常 val job = launch { throw IllegalStateException("launch 里的异常") } job.join() // 此刻协程内的异常已经被传播给了父级 // async 风格的异常 val deferred = async { throw IllegalArgumentException("async 里的异常") } // 不调用 await 的话,这个异常会被吞掉 try { deferred.await() } catch (e: IllegalArgumentException) { println("捕获 async 异常: ${e.message}") } }

这个差异还会影响父协程。如果一个用 launch 启动的子协程抛出了未捕获异常,默认行为是把整个父作用域都取消。很多页面闪退就是这么来的:某个子任务异常了,结果同级的兄弟协程全部被取消,页面数据集体消失。要避免这种"一个异常带走全家"的情况,得引入 SupervisorJob 或 supervisorScope。

4.2 全局异常处理只适合兜底

CoroutineExceptionHandler 是协程的全局异常处理器,但它只对 launch 启动的协程生效,async 的异常不归它管。项目里的最佳实践是把全局处理当"最后一道防线",记录日志、弹出通用错误,具体业务异常还是应该放在业务代码里 try-catch。

val handler = CoroutineExceptionHandler { _, throwable -> // 打日志、上报崩溃追踪 println("未处理异常: ${throwable.message}") } val scope = CoroutineScope(Dispatchers.Main + handler) scope.launch { throw RuntimeException("测试未处理异常") }

全局 handler 的坑在于它是"最后一道"机制,一旦你把异常自己 try-catch 了,handler 就不会收到。所以不要依赖 handler 处理业务错误,它就是个兜底网。

4.3 SupervisorJob 和 supervisorScope 怎么选

SupervisorJob 和普通 Job 的区别:当父作用域里某个子协程异常失败时,SupervisorJob 不会取消其他子协程。这个特性在多任务并行、互不影响的场景特别有用,比如一个页面同时拉取用户信息、拉取商品列表、拉取配置信息,其中任何一个失败,不应该影响另外两个。

// 不推荐直接用 SupervisorJob + CoroutineScope 手写作用域 val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) scope.launch { throw RuntimeException("任务1失败") } scope.launch { println("任务2仍然执行") }

注意上面的写法有一个隐藏问题:直接用 supervisorScope() 的写法更适合业务代码内部使用:

lifecycleScope.launch { supervisorScope { launch { loadUserInfo() } launch { loadProductList() } launch { loadConfig() } } }

supervisorScope 是挂起函数,它等所有子协程完成才返回,子协程之间互相隔离异常。在这个 scope 里 launch 的协程异常不会影响兄弟协程,使用起来比手写 SupervisorJob 更安全,因为手写构造的 SupervisorJob 容易出现"根 Job 和传入 Scope 的 Job 不一致"的问题。

4.4 手动封一个统一错误处理挂起函数

实际项目里,我会给网络请求的协程套一层防护壳,核心思路是内部 try-catch 吞掉异常、返回业务结果,不让异常冒到上层:

suspend fun <T> safeCall(block: suspend () -> T): Result<T> { return try { Result.success(block()) } catch (cancel: CancellationException) { // 协程取消异常不能吞,必须继续抛 throw cancel } catch (e: Exception) { Result.failure(e) } } // 用起来: lifecycleScope.launch { val result = safeCall { api.getUserInfo() } result.onSuccess { showUser(it) } .onFailure { showError(it) } }

注意这个封装里捕获了 CancellationException 之后必须要重新抛出,否则取消了协程又被当成普通异常处理,业务上会出现极难排查的"页面销毁了但回调还在跑"的问题。CancellationException 在协程体系里不是普通异常,它代表的是控制流,吞掉它等于破坏了取消机制。

5. 实战套路:从串行请求到并发限流

5.1 串行请求与并行请求的标准写法

最直白的场景:先拿用户信息,再根据用户信息去拿订单列表。如果用户信息不返回就拿不到订单列表,这就是串行依赖:

scope.launch { val user = withContext(Dispatchers.IO) { api.getUser() } val orders = withContext(Dispatchers.IO) { api.getOrders(user.id) } render(orders) }

代码看起来是同步的,但每一步都在 IO 线程执行完再切回主线程,这就是协程最舒服的地方。反过来,如果两个请求之间没有依赖关系,比如一个页面同时要展示今日推荐和公告,那就应该并行:

scope.launch { val recommendDeferred = async { api.getRecommendList() } val noticeDeferred = async { api.getNoticeList() } val recommend = recommendDeferred.await() val notice = noticeDeferred.await() render(recommend, notice) }

注意这里 async{} 默认会在当前作用域的调度器上执行,如果不加 Dispatchers.IO,如果当前是 Main 调度器,网络请求就直接发生在 Main 线程上了。所以并行请求建议这样写:

scope.launch { val list = async(Dispatchers.IO) { api.getRecommendList() } val notice = async(Dispatchers.IO) { api.getNoticeList() } render(list.await(), notice.await()) }

还有个性能细节:两个 async 并行并不能保证比串行快一倍,因为最终要等最慢的那个。如果其中一个是秒回,另一个是 3 秒,总耗时约等于 3 秒,这是正常的。

5.2 用 withTimeoutOrNull 给请求加超时保护

网络请求是最容易"卡住"的地方,协程提供了一个非常实用的超时工具:withTimeout 和 withTimeoutOrNull。前者超时抛出 TimeoutCancellationException,后者超时返回 null,业务上更常用的是 withTimeoutOrNull。

scope.launch { val result = withTimeoutOrNull(3000) { api.fetchSomething() } if (result == null) { showTimeout() } else { showSuccess(result) } }

这个 3000 的粒度可以根据接口情况定制。更严谨的写法是把超时时间抽成常量,按接口差异配置。withTimeout 还有一个隐藏特性:它会在超时时主动取消子协程,所以即便 api.fetchSomething() 内部里有耗时操作,也会在触发超时的时候被打断,这个对防止"僵尸任务"很有帮助。

5.3 重试机制与退避策略

协程本身没有自带重试 API,但用 suspend 函数可以很方便地封装。一个简单的重试模板:

suspend fun <T> retry( times: Int = 3, initialDelayMillis: Long = 1000, block: suspend () -> T ): T { var currentDelay = initialDelayMillis repeat(times - 1) { try { return block() } catch (e: Exception) { delay(currentDelay) currentDelay *= 2 // 指数退避 } } return block() } // 使用: val data = retry(3, 1000) { api.getConfig() }

重试的坑有两个。第一,CancellationException 会被上面的 catch(Exception) 捕获,必须在 catch 里重新抛出 CancellationException,否则用户取消了协程还在傻傻重试。第二,网络错误要区分,像 401、参数错误这种"再试多少次都一样"的错误就不要重试,设置重试条件:

suspend fun <T> retryWhen( times: Int = 3, shouldRetry: (Exception) -> Boolean, block: suspend () -> T ): T { repeat(times - 1) { attempt -> try { return block() } catch (e: Exception) { if (!shouldRetry(e)) throw e delay(interpret(attempt)) // 按次数递增等待 } } return block() }

5.4 Mutex 与 Semaphore:给并发加上限流

协程里多个任务同时操作同一个资源时,线程安全仍然需要关注。比如多个协程同时往同一个 List 里添加数据,仍然会出现并发问题。常用的工具是 Mutex(互斥锁)和 Semaphore(信号量)。

val mutex = Mutex() var count = 0 scope.launch { repeat(100) { mutex.withLock { count++ } } }

Mutex 的 withLock 里可以挂起,这一点比 Java 的 synchronized 自然得多,因为持锁期间不会死占一个线程。Semaphore 用于限制同时执行的任务数量,比如同时最多允许 3 个下载任务:

val semaphore = Semaphore(3) scope.launch { repeat(20) { index -> launch { semaphore.withPermit { downloadFile(index) } } } }

Semaphore 在 kotlinx.coroutines 的同步包下,从 1.6.0 开始稳定可用。这套限流方案在直播间、图片批量上传、批量文件下载场景里都实测过,比 Java 的 Semaphore 好用,因为支持挂起函数调用。

5.5 Android 里和生命周期组件协作的协程

Android 开发绕不开 ViewModel 和 Lifecycle。老一套做法是手动维护 CompositeDisposable,协程时代直接用官方扩展:lifecycleScope 和 viewModelScope。

class MainViewModel : ViewModel() { fun loadData() { viewModelScope.launch { val data = withContext(Dispatchers.IO) { loadFromDb() } _state.value = data } } }

viewModelScope 会在 ViewModel 被 clear 的时候自动取消所有协程,lifecycleScope 会在对应的 Lifecycle 生命周期销毁时取消。这俩扩展直接解决了协程泄漏问题,比自己在 onDestroy 里手动 cancel 可靠得多。唯一要注意的是业务里如果需要"页面被销毁但任务仍然继续"的场景,比如长上传任务,不能简单挂在这些作用域上,得单独用进程级作用域管理。

6. 高频踩坑记录:这些代码我每隔一段时间就想改一次

6.1 GlobalScope 是泄漏的第一大源头

GlobalScope 是一个进程级的协程作用域,它没有父级、不随页面销毁自动取消。在 Android 代码里直接写 GlobalScope.launch { ... },页面销毁后任务还在后台跑,轻则白耗资源,重则触发对已销毁 View 的引用导致内存泄漏。

看一下这类问题的典型修法:

class MainActivity : AppCompatActivity() { // 错误:GlobalScope GlobalScope.launch { fetchData() } // 正确:用 lifecycleScope private fun loadData() { lifecycleScope.launch { val result = withContext(Dispatchers.IO) { fetchData() } updateUi(result) } } }

如果确实需要一个非页面绑定的作用域,比如定时上报任务,请自己创建 CoroutineScope 并维护好它的取消时机,至少不要每次都 new 一个 scope 然后不取消。

6.2 主线程误用挂起函数,新手最常见的问题

挂起函数不一定在主线程执行,但大家经常误以为"协程就是异步的,所以在协程里做什么都安全"。实际上挂起函数只在遇到真正的挂起点时才会让出线程,中间的普通同步代码依旧是当前线程执行的。

scope.launch(Dispatchers.Main) { val text = heavyParse() // 这个函数不是挂起函数,执行时照样卡主线程 textView.text = text }

如果 heavyParse() 是耗时操作,这段代码就会卡 UI。正确的做法是包进 withContext(Dispatchers.Default) 里。判断标准很简单:这个函数内部有没有挂起等待 IO 或者计算密集,如果有,就必须配调度器。

6.3 协程并不保证线程安全

协程本质是线程调度框架,不是并发安全锁。多个协程共享一个可变对象,如果不加锁,就有竞态条件。某些人在协程里大胆地写:

class ApiClient { private var token: String? = null suspend fun refreshToken() { token = api.getToken() // 多个协程同时调用时,这里会互相覆盖 } }

如果两个协程同时走到这行代码,token 被赋值的顺序不可控。解决办法是用 Mutex 保护关键区,或者干脆用不可变数据模式、把状态集中到 StateFlow 里做合并处理。协程只是帮你在异步世界里更好地写代码,不是帮你免了并发安全这门课。

6.4 withContext 和 async-await 别混着用

经常看到有人把 withContext 当成 async 来用:

scope.launch { val deferred = withContext(Dispatchers.IO) { api.getData() } // 错误的用法:withContext 的结果就是直接值,不是 Deferred }

withContext 返回的是代码块里最后一行表达式的值,是直接结果,不需要 await。而 async 返回 Deferred,需要 await 取结果。把 withContext 的结果当成 Deferred 调用 await,会直接编译报错。这种错误发生的原因是对两者的语义没分清:withContext 是当前协程切换上下文执行一段代码,async 是开启一个新协程。在同一个协程里想并行多个任务时用 async,只想切线程执行完再回来时永远优先用 withContext。

7. 手册持续更新的方向

这篇手册目前覆盖了启动方式、调度器、取消、异常、并发限流、生命周期协作和坑点总结,但 Kotlin 协程的内容远远不止这些。接下来我会按优先级补充下面几块:

  • Flow 响应式编程:flow、flowOn、map、catch、flatMapConcat、debounce 在业务里的组合套路
  • 协程单元测试怎么写:runTest、假时钟、testDispatcher 的正确打开方式
  • 常见中间件源码级的钩子原理,比如 Retrofit 的 suspend 支持
  • Actor 模式的协程实现
  • 协程与 Reactor/流式框架的取舍

这些内容都是项目里踩过坑之后总结出来的,而不是粘贴官方文档。我会边写边更新,保持代码风格一致,尽量贴近真实业务场景,每一条都带上能直接跑的 Kotlin 代码。

最后再分享一个个人习惯:写协程代码时保持"职责边界"意识。每个协程只做一件事,作用域命名清楚,不用一个 scope 包打天下。页面级任务挂 lifecycleScope,数据层任务视情况单独建 scope,尽量不把业务逻辑的异常吞成空白,让失效的代码尽早发声。这套习惯帮我少排查了很多诡异问题。协程是好东西,但它在并发、线程、异常之间的复杂性仍然真实存在,需要我们用纪律性去约束它。希望这篇手册对你也有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询