协程是 Kotlin 最大的亮点,也是 Android 开发里绕不开的东西。平时 launch、async 写得飞起,但"挂起"到底是什么意思、协程和线程什么关系、作用域为什么重要,这些背后的原理平时没细想。这篇把协程从概念到用法系统梳理一遍,把散落的知识点串成一条线。
一、协程是什么:轻量级线程
先纠正一个最常见的误解:协程不是线程,而是"跑在线程上的轻量级任务"。
- 一个线程同时只能跑一个任务,切换线程代价很大(上下文切换)。
- 协程则是在一个线程上可以挂起、切换成千上万个,切换几乎零开销(用户态切换,不涉及系统调度)。
核心在于挂起(suspend)和阻塞(block)的区别:
| 阻塞 block | 挂起 suspend | |
|---|---|---|
| 谁在等 | 线程被卡住,啥也干不了 | 协程让出线程,线程去干别的 |
| 资源 | 线程资源被占用浪费 | 线程被释放,可以跑别的协程 |
| 典型 | Thread.sleep()、同步 IO | delay()、suspend 的 IO |
一句话:阻塞浪费线程,挂起释放线程。这就是协程能"用少量线程扛大量并发"的根本原因——比如 1 个线程通过挂起,可以同时"等待" 10000 个网络请求,而不是开 10000 个线程。
二、挂起函数(suspend)
suspend 是协程的地基:
kotlin
suspend fun fetchUser(): User { ... }- 挂起函数可以被挂起、也能恢复,但它本身不阻塞当前线程。
- 挂起函数只能在协程里或另一个挂起函数里调用(普通函数不能直接调)。
- 调一个挂起函数,看起来是顺序执行,实际上内部遇到挂起点会让出线程,等结果回来了再接着往下跑——代码是"同步写法",执行是"异步效果"。
三、启动协程:launch / async / runBlocking
三个最常用的启动方式:
kotlin
// 1. launch:启动协程,返回 Job,不关心结果("发射后不管")
val job = launch {
delay(1000)
println("done")
}
// 2. async:启动协程,返回 Deferred,用 await 拿结果(要返回值)
val deferred = async { fetchUser() }
val user = deferred.await()
// 3. runBlocking:阻塞当前线程直到协程结束(一般只在 main / 测试用)
runBlocking {
delay(1000)
}记忆:launch 不返回值(Job),async 返回值(Deferred.await),runBlocking 会阻塞线程(别在业务里用)。
四、作用域(CoroutineScope):为什么重要
作用域解决的是"协程的生命周期谁来管"。
kotlin
// 作用域里启动的协程,作用域取消时全部跟着取消
val scope = CoroutineScope(Dispatchers.Main)
scope.launch { ... }
scope.cancel() // 所有子协程一起取消几个要点:
- 结构化并发:协程在作用域里是"父子关系",父协程取消,子协程自动跟着取消——这是协程不会"泄漏"的关键。
- GlobalScope 慎用:
GlobalScope是应用级作用域,生命周期和整个应用一样长,用它启动的协程不会被自动取消,容易内存泄漏,生产环境基本不用。 - Android 里的现成作用域:
lifecycleScope(跟随 Activity/Fragment 生命周期自动取消)、viewModelScope(跟随 ViewModel)。日常开发优先用这两个,而不是自己 new CoroutineScope。
五、调度器(Dispatchers):协程跑在哪个线程
kotlin
launch(Dispatchers.IO) { /* 跑在 IO 线程池 */ }
launch(Dispatchers.Main) { /* 跑在主线程,更新 UI */ }
launch(Dispatchers.Default) { /* 跑在 CPU 密集线程池 */ }| 调度器 | 用途 |
|---|---|
Dispatchers.Main | 主线程,更新 UI |
Dispatchers.IO | 网络、磁盘等 IO 操作 |
Dispatchers.Default | CPU 密集计算 |
Android 的经典写法:IO 线程拿数据 → 切回 Main 更新 UI。
kotlin
lifecycleScope.launch {
val user = withContext(Dispatchers.IO) { fetchUser() } // IO 线程拿数据
textView.text = user.name // 自动切回 Main
}withContext 是"切线程"的常用工具——切到指定调度器执行一段代码,执行完自动切回来。
六、常见坑
- 滥用 GlobalScope:协程泄漏的头号原因。用
lifecycleScope/viewModelScope替代。 - 异常会冒泡:
launch里抛异常会让父协程一起挂(甚至崩溃),要try-catch包住协程体,或配CoroutineExceptionHandler。async的异常在await()时才抛出。 - 取消是协作式的:
job.cancel()只是打个标记,协程要自己检查取消状态(delay、withContext这类挂起点会自动响应取消;但死循环里要自己isActive判断)。 - 别在挂起函数里用
Thread.sleep:它会真的阻塞线程,协程的优势就没了。用delay()。
小结
- 协程 = 跑在线程上的轻量级任务,核心是挂起不阻塞线程
suspend函数:可挂起恢复,同步写法异步执行- 启动三件套:
launch(Job)、async(Deferred.await)、runBlocking(阻塞,测试用) - 作用域管生命周期:优先用
lifecycleScope/viewModelScope,别用GlobalScope - 调度器:Main 更新 UI、IO 做 IO、Default 做计算,
withContext切线程 - 坑:GlobalScope 泄漏、异常冒泡、取消是协作式、别用
Thread.sleep
