跳到主要内容

Kotlin 协程入门实战:用 launch/async 告别回调地狱与线程管理

· 阅读需 6 分钟
重庆 Kotlin User Group

你有没有写过这样的代码?一个"先拿用户信息,再拿用户订单"的需求,硬是套了两层回调:

// 传统异步:回调地狱的入口
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"
}

这里有两个关键点,新手最容易踩:

  1. suspend 函数只能在协程或另一个 suspend 函数里调用。 你在普通的 main 函数里直接 fetchUser("1") 会编译报错。这是 Kotlin 在保护你——它得知道"在哪个协程里暂停、由谁来恢复"。
  2. 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.DefaultCPU 密集型计算排序、解析大 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 打印一下,验证你真的实现了并行而不是串行——这才是理解协程并发的分水岭。