第088篇 协程入门:suspend 到底挂起了谁

简介: 本文深入解析协程本质:挂起的是协程(状态机),而非线程;线程在挂起时被释放复用。厘清`launch`/`async`/`run`差异、作用域管理、调度器选择及常见坑(如`delay` vs `Thread.sleep`、漏取消、主线程卡顿),助你真正掌握结构化并发。

协程这题的基础门槛不高——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:三兄弟的分工

有任何问题欢迎在评论区留言交流。

相关文章
|
1天前
|
缓存 API Android开发
第125篇Fragment 通信演进:从接口回调到共享 ViewModel
本文梳理Fragment通信演进史,揭示五代方案(接口回调、setFragmentResult、广播、共享ViewModel、Fragment Result API)的耦合本质。核心结论:**仅推荐三种场景化方案——Fragment Result API传一次性结果、共享ViewModel管共同状态、事件总线限于跨模块解耦**。重点剖析“耦合藏在哪”,并指出`viewLifecycleOwner`误用、事件重复消费、ViewModel状态污染等高频坑及自动化防治策略。
19 0
|
1天前
|
缓存 编译器 PHP
第113篇 Compose 与 Kotlin 特性:为什么 Compose 离不开 Kotlin
本文深入解析 Jetpack Compose 的底层机制,揭示其“声明式 UI”背后的三大支柱:带接收者 Lambda、编译器插件(KCP)与稳定性注解(`@Composable`/`@Stable`/`@Immutable`)。重点剖析编译器如何重写函数、实现智能跳过重组,以及常见性能陷阱(如列表无 key、lambda 不稳定)的根因与解法,助你面试直击本质。
27 3
|
1天前
|
API Android开发 开发者
第127篇ViewPager2 与 Fragment:懒加载的正确实现
ViewPager2 内核是 RecyclerView,由 FragmentStateAdapter 托管 Fragment 生命周期,依赖 `getItemId`/`containsItem` 实现稳定复用。旧版 `setUserVisibleHint` 已废弃,应统一用 `repeatOnLifecycle(STARTED)` 控制可见性逻辑。关键在理解“复用机制+生命周期托管”,避免数据错乱、重复加载与内存泄漏。
22 2
|
1天前
|
安全 Java 编译器
第098篇 空安全与 Java 混编:平台类型的风险控制
本文深度剖析Kotlin与Java混编中空安全的“信任边界”:指出平台类型、注解失效与反射泛型三大漏洞,提出“类型收口+运行时断言+架构约定”三层防线,强调用`requireNotNull`替代`!!`、用`sealed`封装多态结果,真正实现NPE可控可追溯。
15 0
|
1天前
|
自然语言处理 Java Android开发
第072篇 中缀表达式与运算符重载:可读性的双刃剑
Kotlin运算符重载比Java更彻底:不仅支持`+ - * /`等映射为`plus`/`minus`等约定函数,还允许任意单参函数通过`infix`声明实现中缀调用(如`a to b`)。核心原则是——符号语义必须与原始含义一致(`+`即相加,`-`即取反或相减),滥用将损害可读性。
27 1
|
1天前
|
并行计算 Java 测试技术
第042篇 CountDownLatch 与 CyclicBarrier:等待与协作
本文深入解析`CountDownLatch`、`CyclicBarrier`与`Semaphore`的核心差异与实战陷阱:前者为一次性事件门闩,后者为可复用集合点,`Semaphore`则基于独占模式限流。重点剖析“计数未归零阻塞”“屏障损坏”“许可泄漏”等生产级坑点,并给出`finally`防护、超时兜底、参与方一致性等落地解法,助你面试直击采分关键。
20 0
第042篇 CountDownLatch 与 CyclicBarrier:等待与协作
|
1天前
|
Java 编译器 Android开发
第106篇 data class 的 equals 与 copy:深浅拷贝的坑
Kotlin `data class` 自动生成 `equals`/`hashCode`/`toString`/`copy`/`componentN`,但**非深拷贝、数组引用比较、可变集合共享引用**——易致UI状态错乱、Diff失效、哈希查找丢失。核心原则:状态类只用不可变类型(`List`/`Set`),数组需覆写 `equals`,`copy` 后禁用就地修改。
14 0
|
1天前
|
存储 Java 编译器
第118篇 const 与编译期常量:延迟初始化之外的第三个选择
`const val` 是 Kotlin 编译期常量,值在编译时内联到各使用点,字节码中无字段、零运行时开销,但**不二进制兼容**——改值后依赖方必须重编译,否则仍用旧值。适用于功能开关、注解参数等编译期确定场景;跨模块配置、敏感信息、需热更新者禁用。
19 0
|
1天前
|
JSON Java API
第102篇 Kotlin 反射与 KClass:运行时元编程
Kotlin/Java反射面试常考深层差异:KClass提供结构化元信息(属性名、泛型、注解默认值),但需额外引入`kotlin-reflect`,增大Android方法数与包体积;Java反射零依赖但元信息原始。关键权衡:能用`::class.java`或编译期方案(如KSP、kotlinx.serialization)就不用Kotlin反射。
14 0
|
1天前
|
传感器 API 调度
第093篇 Flow 入门:冷流、热流与背压
本文深入解析 Kotlin Flow 的核心机制:它本质是**冷流 + 一对一发射 + 结构化协作**的组合。厘清 Flow 与 RxJava Flowable 的差异、冷流“按需生产”特性、`collect` 的挂起本质及天然背压原理(依托 `emit` 挂起),并结合 Android 工程实践(`repeatOnLifecycle`、`flowOn` 误区、异常转数据流)与高频面试题,助你真正掌握 Flow 设计哲学与避坑要点。
19 0

热门文章

最新文章