Kotlin 协程入门实战:用 launch/async 告别回调地狱与线程管理
你有没有写过这样的代码?一个"先拿用户信息,再拿用户订单"的需求,硬是套了两层回调:
// 传统异步:回调地狱的入口
getUser("1001") { user ->
getOrders(user.id) { orders ->
getOrderDetail(orders.first().id) { detail ->
// 还想再嵌套?来啊
runOnUiThread { textView.text = detail.toString() }
}
}
}
每多一步异步操作,代码就往右缩进一格。写到第五层的时候,你已经开始怀疑人生了。而且你还得手动管理线程:哪个回调在子线程、哪个能更新 UI,一不小心就 CalledFromWrongThreadException。
Kotlin 协程(Coroutines)就是来终结这一切的。它最魔性的地方在于——让你用同步的写法,做异步的事。上面的代码改成协程是这样的:
lifecycleScope.launch {
val user = getUser("1001") // 看起来是同步,其实不阻塞
val orders = getOrders(user.id)
val detail = getOrderDetail(orders.first().id)
textView.text = detail.toString() // 自动回到主线程
}
一行套一行,读起来像流水账,但底层是异步非阻塞的。是不是很舒服?今天我们把它拆开讲明白。
协程到底是什么?
一句话:协程是"轻量级的线程"。但它不是线程——线程是操作系统调度的,创建和切换都很贵;协程由 Kotlin 运行时管理,切换成本极低。
有多轻?下面这行开 10 万个协程毫无压力,但 10 万个线程直接就崩了:
repeat(100_000) {
launch { delay(1000) }
}
协程的核心能力是挂起(suspend)而不是阻塞(block)。当协程遇到一个耗时操作时,它不是傻等占着线程,而是"暂停自己、把线程让出去",等结果好了再回来继续。这就是为什么它能用同步写法实现异步效果。
suspend:标记"这里可以暂停"
suspend 关键字用来标记一个挂起函数——它告诉编译器:"这个函数执行到一半可能会暂停,稍后再恢复。"
// 模拟一个耗时 1 秒的网络请求
suspend fun fetchUser(id: String): String {
delay(1000) // delay 是协程版的 sleep,非阻塞
return "用户$id"
}
这里有两个关键点,新手最容易踩:
suspend函数只能在协程或另一个suspend函数里调用。 你在普通的main函数里直接fetchUser("1")会编译报错。这是 Kotlin 在保护你——它得知道"在哪个协程里暂停、由谁来恢复"。suspend不等于"自动切到子线程"。 挂起是暂停执行,切线程是调度器的事,两者是独立的。这点后面说。
那 main 里想调用怎么办?用 runBlocking 开一个协程作用域来"桥接":
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println(fetchUser("1001")) // 现在可以调了
}
launch 和 async:两种启动方式
suspend 函数是"能暂停的操作",那谁来启动协程?两个最常用的构建器:launch 和 async。
launch:启动一个协程,不关心返回值,返回一个 Job(用来取消或等待)。适合"发出去就不用管"的任务,比如打日志、存数据。
val job = scope.launch {
saveToDatabase(data)
}
// 需要的话可以取消它
job.cancel()
async:启动一个协程,需要拿结果,返回一个 Deferred<T>(可以理解为"未来的结果"),用 .await() 取值。
val deferred = scope.async {
fetchUser("1001") // 返回 Deferred<String>
}
val user = deferred.await() // 拿到 "用户1001"
选择原则很简单:要结果用 async,不要结果用 launch。
经典踩坑:串行 ≠ 并行
这是最多人踩的坑,也是协程最"哇"的知识点。假设你要同时请求用户和订单,然后合并:
// ❌ 错误写法:看着像并行,其实是串行
scope.launch {
val user = async { fetchUser("1001") }.await() // 等 1 秒
val orders = async { fetchOrders("1001") }.await() // 再等 1 秒
// 总耗时:2 秒
}
问题出在 .await():你每 async 完就立刻 await,等于"启动一个、马上等它结束、再启动下一个",退化成串行排队了。
正确做法是先把所有 async 发出去,再统一 await:
// ✅ 正确写法:真正的并行
scope.launch {
val userDeferred = async { fetchUser("1001") } // 同时开始
val ordersDeferred = async { fetchOrders("1001") } // 同时开始
val user = userDeferred.await()
val orders = ordersDeferred.await()
// 总耗时:1 秒(两个任务并行跑)
}
两个 async 一发出就都在跑了,await 只是去"取已经算好的结果"。这就是协程并发的正确姿势——先发射,后收集。
Dispatchers:活儿派给哪个线程
挂起解决"暂停不阻塞",那到底在哪个线程执行,靠调度器(Dispatcher)指定。常用的三个:
| 调度器 | 用在哪 | 例子 |
|---|---|---|
Dispatchers.Main | 更新 UI(Android 主线程) | 给 TextView 赋值 |
Dispatchers.IO | 磁盘/网络等阻塞 IO | 读文件、调接口 |
Dispatchers.Default | CPU 密集型计算 | 排序、解析大 JSON |
用法:
scope.launch(Dispatchers.IO) {
val data = readFromFile() // 在 IO 线程读
withContext(Dispatchers.Main) {
textView.text = data // 切回主线程更新 UI
}
}
withContext 用来在协程内部切换线程并拿结果,是"在指定线程执行一小段代码并返回"的最佳工具。
launch / async / withContext / runBlocking 怎么选?
一张表总结,收藏备用:
| 构建器 | 有返回吗 | 阻塞当前线程吗 | 典型场景 |
|---|---|---|---|
launch | 无(返回 Job) | 否 | 执行任务,不关心结果 |
async | 有(返回 Deferred) | 否 | 需要并发拿多个结果 |
withContext | 有 | 是(挂起等待) | 切换线程执行并取结果 |
runBlocking | 有 | 是(真阻塞) | 只在 main 或测试里用 |
⚠️ 特别提醒:runBlocking 会真正阻塞当前线程,它是给 main 函数和单元测试兜底用的。业务代码里千万别用 runBlocking,否则协程的非阻塞优势全白费了。
小结
- 协程 = 用同步写法做异步,彻底告别回调地狱
suspend标记"可暂停",只能在协程里调用;挂起 ≠ 切线程- 不关心结果用
launch,要结果用async+await - 并发时先把
async全发出去再await,别一个个await(串行陷阱) - 用
Dispatchers决定线程,用withContext切换线程 runBlocking留给main和测试,业务代码别碰
协程是 Kotlin 最好用、也最值得投入的特性之一。前期理解清楚"suspend/launch/async/调度器"这几个概念,后面写再多异步逻辑都不慌。
今日挑战
写一个小程序:用协程并发请求 3 个"接口"(用 delay 模拟每个耗时不同,比如 800ms、500ms、1200ms),最后把 3 个结果拼在一起打印。
要求:总耗时接近最慢的那个(约 1200ms),而不是三个相加(2500ms)。做完用 measureTimeMillis 打印一下,验证你真的实现了并行而不是串行——这才是理解协程并发的分水岭。