协程这题的基础门槛不高——launch、async、suspend 谁都会写。真正区分开的是"挂起"这件事到底挂起了什么:挂起的是协程(一个轻量状态机),不是线程;线程在挂起期间是空闲可复用的。这个认知一旦建立,协程的所有设计就都说得通了。这篇按"是什么 → 怎么执行 → 怎么落地"三步讲。
先把结论放在前面:协程是用户态的轻量并发单元,本质是一个被编译器改写为状态机的 suspend 函数。suspend 函数被编译成 (Object continuation, int flags) -> Object 这样的方法签名——参数里多了 continuation,返回值是 Object(协程挂起时是 COROUTINE_SUSPENDED)。这解释了协程为什么"挂起不占线程":线程执行到挂起点就返回,把 CPU 让给别人,恢复时再进来。
机制拆解
launch 返回 Job(只管生命周期,不返回值)、async 返回 Deferred<T>(是 Job 的子类,能 await() 拿结果)、run 是普通函数写法。三个"启动"函数的差别只在返回值类型,取消与异常传播的规则完全一致——都是结构化的(parent 取消则 child 取消,child 失败则 parent 失败,除非父是 SupervisorJob)。
这些坑的正确绕法
最常见的坑是把 delay 换成 Thread.sleep 后线程被占死,并发能力瞬间崩塌。 表现是本该并发处理的 100 个任务改用 Thread.sleep 后变成串行,耗时从 2 秒变成 200 秒;线程池从 200 个活跃线程掉到 4 个。根因是 delay 在内部是 suspendCancellableCoroutine + select 的等待注册,挂起时线程被释放去干别的;Thread.sleep 则真的让线程进入等待状态,不释放。修法是协程里只用挂起版本的等待(delay、await、以及各种库的 suspend 接口),任何阻塞调用都要包在 withContext(Dispatchers.IO) 里并保证那个线程池能承受。
其次是漏掉作用域,协程活得比界面还长。 表现是页面退出后接口回调仍触发,尝试更新已销毁的 View 导致内存泄漏或崩溃;日志里能看到"任务完成"但界面早没了。根因是协程本身没有生命周期——它只活在 CoroutineScope 里,如果这个 scope 是 GlobalScope 或一个自建的对象,页面销毁不会取消它。修法是始终从生命周期感知的 scope 启动:lifecycleScope(Activity/Fragment)、viewModelScope(ViewModel)、或者自定义 scope 并在 onDestroy 里 cancel()。这与前面讲的"单例持有界面对象"是同一类问题的不同表现形式。
还有一个更隐蔽的坑:在 suspend 函数里做 CPU 密集计算,界面卡住但看不出原因。 表现是协程写法取代了回调写法之后,界面反而更卡;Profiler 显示主线程一直在跑,没有 IO 等待。根因是协程默认在调用方的线程执行——lifecycleScope.launch { heavyCompute() } 里的重计算就在主线程上跑,协程并不会自动换线程。修法是显式指定调度器(下一节的主题):CPU 密集用 Dispatchers.Default,IO 用 Dispatchers.IO,UI 更新回 Dispatchers.Main。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) 三种启动方式:差别只在返回值
fun demoScope(scope: CoroutineScope) {
val j: Job = scope.launch {
doA() } // 只管生命周期
val d: Deferred<Int> = scope.async {
compute() } // 能 await 拿结果
val r = run {
doB() } // 阻塞式写法,返回值直接拿
}
// 2) delay 是挂起,sleep 是阻塞 —— 这条差异是本篇核心
scope.launch {
Log.d("t", "start ${System.currentTimeMillis()}")
delay(1000) // 挂起:线程释放去跑别的协程,1 秒后回来
Log.d("t", "resumed ${System.currentTimeMillis()}")
}
// 对照:Thread.sleep(1000) 会真的占住当前线程
// 3) 并发 vs 串行:由 suspend 函数决定
suspend fun fetchA(): String {
delay(300); return "A" }
suspend fun fetchB(): String {
delay(300); return "B" }
suspend fun test() = coroutineScope {
val t0 = System.currentTimeMillis()
val a = async {
fetchA() } // 并发:两个都挂起,线程被复用
val b = async {
fetchB() }
a.await(); b.await()
Log.d("t", "并发耗时 ${System.currentTimeMillis() - t0}") // ≈300ms
// 若把 async 换成直接 fetchA(); fetchB(),则 ≈600ms 串行
}
// 4) 作用域:从生命周期感知的地方启动
class DetailActivity : AppCompatActivity() {
private val scope = lifecycleScope // onDestroy 自动 cancel
fun load() {
scope.launch {
val data = withContext(Dispatchers.IO) {
readFile() } // IO 挪到 IO 线程
binding.title.text = data // 回到主线程更新 UI
}
}
}
// 反例:GlobalScope.launch —— 页面没了任务还在
// GlobalScope.launch {
fetchUser {
binding.name.text = it } }
// 5) try-catch:挂起点可能抛异常,协程里的异常要显式处理
scope.launch {
try {
val data = withContext(Dispatchers.IO) {
readFile() }
render(data)
} catch (e: IOException) {
Log.w("t", "读取失败", e)
} finally {
Log.d("t", "收尾")
}
}
// 6) 取消是"协作式"的:挂起函数会响应,取消阻塞代码不会
scope.launch {
val job = launch {
while (true) {
delay(50); tick() } }
delay(1000)
job.cancel() // 下一个挂起点抛出 CancellationException
Log.d("t", "已请求取消") // 这行仍会执行
job.join() // 等它真正结束
Log.d("t", "已结束") // 到这里才真的停了
}
这段代码值得盯三处:第一处,第 2 段用注释点明"挂起:线程释放去跑别的协程"与"Thread.sleep 会真的占住当前线程"的对照;第二处,第 3 段把并发(≈300ms)与串行(≈600ms)的耗时写进注释,让"async 才有并发"这件事有了具体数字;第三处,第 4 段用注释在反例旁写明"页面没了任务还在",第 6 段则演示了取消是协作式的、要在挂起点才响应。
这题在面试里怎么问、怎么答
"协程挂起时线程去哪了?"答:线程执行到挂起点(delay、挂起函数、await)时,协程的 invokeSuspend 返回 COROUTINE_SUSPENDED,执行框架把这个 continuation 挂到某个等待队列上,然后线程从栈帧返回,去执行调度器里的其他任务。恢复时,调度器把 continuation 塞回某个空闲线程,状态机从挂起点继续。所以挂起/恢复的线程可能不是同一个。
"launch 和 async 的区别?什么时候用哪个?"答:只关心"做完"用 launch;需要返回值或要 await 才继续用 async;async 的返回值 Deferred 在不 await 时,异常会被静默吞掉(除非作用域是 supervisorScope 或显式 await)——这是 async 最大的坑。
"suspend 函数和普通函数的区别是什么?"答三处:①签名不同(多 continuation 参数,返回 Object);②调用方只能是协程或另一个 suspend 函数;③挂起恢复后可能跑在另一个线程上,所以 suspend 函数里不能假定线程不变——需要固定线程就用 withContext。
"使用中遇到过什么问题?"案例一:一个列表页同时请求 5 个接口,改成协程后仍然很慢;定位到代码里是顺序 await 而非并发 async;修复为并发启动后 await。案例二:把回调包成协程后界面更卡;定位到重计算没切调度器;修复为 withContext(Dispatchers.Default)。
再补一个工程上值得讲清的一点:协程最大的价值是"结构化并发",而不仅仅是"写起来像同步"。 传统回调的致命问题是任务与作用域脱钩——启动任务时拿不到"这个任务属于谁"的句柄,于是无法保证它被取消、无法保证异常被正确传播、无法保证任务不会在界面消失后还回来。协程因为天然有 scope 与 parent-child 关系,这三件事都自动成立。所以在 Android 上判断"该不该用协程"的一个好标准是:这个任务有没有明确的归属与取消时机? 有(页面加载、轮询、上传进度)就用协程;没有(一次性工具调用、已有成熟库的线程池 API)就用现成的库接口。
给正在准备面试的你
把这题画成一张"线程 vs 协程"的对比图:左侧画线程模型——4 个线程方块各挂一个任务,方块内写"阻塞 sleep",其中只有 1 个在算,其余标灰"等待中占着线程";右侧画协程模型——1 个线程方块,协程 A/B/C 三个方块堆叠,只有 A 在跑,B/C 挂起时箭头指向"调度器队列",标注"挂起不占线程"。图的底部横着写一条时间轴对比"100 个 1 秒任务"的耗时:线程池版 25 秒(4 线程)、协程版 1 秒。图右侧再画一个"三层"说明:launch/async(生命周期)→ suspend(挂起点)→ withContext(切线程)。这张图能完整回答"协程到底快在哪"。
再补工程案例与踩坑——应用落点是排查项目里所有 GlobalScope 与自建 scope 的协程启动点,改用 lifecycleScope/viewModelScope;给 async 的 Deferred 补 await 或改用 launch + 状态回传,避免异常被吞;把 Thread.sleep/阻塞 IO 从协程体里移出到 withContext(Dispatchers.IO);给顺序 await 的批量请求改成并发 async。
复习时别孤立刷题:调度器 Dispatchers——withContext(Dispatchers.IO) 里的 Dispatchers 就是下一篇的主题,它决定了协程在哪条线程上跑。
划两句重点:协程挂起的是协程不是线程,线程在挂起期间被释放;delay 挂起、Thread.sleep 阻塞;launch/async/run 只差返回值,取消与异常规则一致;协程默认在调用方线程执行,重计算要显式切 Default;async 不 await 会吞异常;始终从生命周期感知的 scope 启动,禁用 GlobalScope。
下一篇聊调度器 Dispatchers:三兄弟的分工——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:函数类型与-typealias:回调接口的新写法
下一篇预告:调度器-Dispatchers:三兄弟的分工
有任何问题欢迎在评论区留言交流。