Kotlin 协程:挂起与阻塞

发布于

最近在写 Android 端的管理后台应用时,我理所当然地在 ViewModel 里用 viewModelScope.launch 包裹网络请求,心想:协程都启动了,UI 总该流畅了吧?结果界面照样卡死。

带着这个挫败感,我回去翻了 Android 官方协程文档。文档写得信誓旦旦:

轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞

本文尽量保留当时的疑问和推导过程,希望能帮同样被官方文档“骗”过的你少走点弯路。

协程如何运作

挂起与恢复

协程的核心魔法在于 挂起恢复

  1. 执行到 suspend 函数(如 delay()、网络请求)时,协程把当前位置、局部变量等信息打包保存,主动脱离当前线程
  2. 线程被释放,可以去干别的事——比如 Android 主线程继续刷新 UI。
  3. 后台任务完成后,调度器把协程恢复到某个线程(可以是原线程,也可以是新线程),从挂起点继续执行。

编译器通过 CPS 变换suspend 函数变成状态机:每个挂起点对应一个状态,挂起时返回 COROUTINE_SUSPENDED,完成后通过 Continuation.resume() 跳转到下一状态。

三大核心组件

组件 职责
CoroutineScope(作用域) 控制协程生命周期。作用域销毁时,内部所有协程自动取消。
CoroutineContext(上下文) 协程的配置集,包含 Job、调度器、名称等。
CoroutineDispatcher(调度器) 决定协程在哪个线程上执行。

常用调度器:

viewModelScope 与三大组件的关系

viewModelScope 是 Jetpack 为 ViewModel 提供的协程作用域,内部大致等价于:

CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

对应关系如下:

① 自动取消(Scope + Job)

Activity 关闭 → ViewModel.onCleared()viewModelScope 取消 → 所有子协程收到 CancellationException 并停止。这就是结构化并发的价值:不用担心协程泄漏。

② 默认主线程(Dispatcher)

不向 launch 传调度器时,协程默认跑在主线程。这样拿到结果后可以直接更新 UI,不必再切回来。

③ SupervisorJob 防连带崩溃

viewModelScope 使用 SupervisorJob:一个子协程失败,不会拖垮同作用域下的其他子协程。

我的第一个误解:调度器管生命周期,作用域管线程池?

我的理解:调度器相当于管理协程及其上下文的协程池,作用域通知调度器生命周期?

需要修正:

通俗比喻:

launch(Dispatchers.IO) 还是 withContext?

我的疑问:在 launch 里加调度器,和显式调用 withContext 声明调度器,有什么区别?

做法 A:launch(Dispatchers.IO) { ... }

整个代码块从头到尾在 IO 线程池执行。适合纯后台任务(写日志、静默同步),做完后若要更新 UI,还得切回主线程。

做法 B:launch { withContext(Dispatchers.IO) { ... } }(推荐)

协程先在主线程启动,遇到 withContext 时挂起,把内部代码外包给 IO 线程;完成后自动回到原线程继续执行。可以用同步写法表达跨线程逻辑——这正是 Android 开发的标准姿势。

开销上,launch(Dispatchers.IO) 只有一次线程分发;withContext 多一次切出和切回,但在现代设备上几乎可以忽略。

withContext 切换的是上下文,不是启动新协程

我的理解withContext 就是用来切换 launch 所启动协程的上下文的?

基本正确,但有一个极易混淆的点:

比喻:launch 是新开一条生产线;withContext 是项目经理说「这段外包给 IO 团队,做完再继续」。

误区:launch 了就不会卡 UI?

我的疑问:请求网络 I/O 时不用 withContext 切线程,viewModelScope 已经启动了新协程,UI 就不会卡了吧?

不会。关键不在「有没有 launch」,而在「代码跑在哪个线程、会不会阻塞」。

viewModelScope.launch 默认在主线程启动协程。如果你在协程里写同步阻塞代码:

viewModelScope.launch {
    Thread.sleep(3000)  // 主线程死等 3 秒
    updateUI()
}

主线程被堵死 3 秒,UI 冻结,超过 5 秒还可能 ANR。

如果你用的是 Retrofit suspend、Room suspend、Ktor Client 等现代化库,它们在底层已经做了线程切换,即使不写 withContext,主线程也不会被堵死。

协程与 UI 主线程:同一条路,不是平行公路

我的疑问viewModelScope 既然启动了协程,为什么还会影响 UI?协程和 UI 进程是什么关系?

Android 只有一个 UI 主线程(严格说是一个 Looper 驱动的消息循环)。viewModelScope.launch 启动的协程,默认就跑在这条主线程公路上,不是旁边另开一条平行路。

viewModelScope.launch 的真正作用:在主线程上圈出一块地,允许你使用 suspend 挂起魔法。它不提供免费的后台线程

主线程能跑多个协程,为什么还会卡?

我的追问:UI 线程不是可以切换协程、依次执行吗?切换了就不会卡死吧?

主线程的消息循环确实在依次处理任务:触摸事件、布局、绘制、viewModelScope 里的协程……

但关键是 协作式(Cooperative) 调度:

银行排队比喻:

其他调度器上,长任务会卡死后面的协程吗?

我的追问Dispatchers.IO 等调度器如果总在单线程里,一个协程跑很久,后面的协程不也被卡死?

Dispatchers.IODispatchers.Default 底层是多线程池,不是单线程。

单线程环境(如 Dispatchers.Main)里,没有挂起点的长耗时协程一定会饿死后面的任务。极端情况下可在循环里调用 yield(),主动挂起并重新排队。

协程之所以叫「程」,就是因为需要协作

viewModelScope 本质是单线程

我的结论viewModelScope 就是一个单线程环境,即使它可以运行很多协程。

准确。一个线程能跑成千上万个协程,是因为它们通过挂起点交替使用线程:

协程 A 跑 2ms → delay 挂起 → 协程 B 跑 1ms → 挂起 → UI 刷新 → 协程 A 恢复 …

但这一切的前提是:代码里要有真正的挂起点。没有挂起点,就和普通函数一样霸占线程。

质疑官方文档:「不阻塞线程」到底指什么?

我的质疑:文档说协程支持挂起、不会使线程阻塞——是不是在骗人?

文档没有骗人,但容易让人误读。它说的是:

当协程执行 suspend 函数 时,线程不会被阻塞。

没有说:你把任何 Java 代码丢进 launch,框架都会自动帮你挂起。

任务分两类:

① 结构化挂起任务(真挂起)

代表:Retrofit / Room / Ktor Client 的 suspend API、delay()

表现:在单线程里自动挂起,不卡 UI。

② 传统阻塞与 CPU 密集任务(真阻塞)

代表:FileInputStream 同步读写、旧版 OkHttp.execute()、大循环图片处理、巨型 JSON 解析。

表现:无法自动挂起。在 viewModelScope 默认环境里跑,直接卡死主线程。

withContext 的历史使命

表面看:文档说协程在单线程上支持挂起,不阻塞线程。

实际上:只有遵守 suspend 契约的函数才能被自动挂起。

真相:面对传统阻塞 I/O 和 CPU 密集计算,协程无法自动挂起它们,必须靠 withContext 强行外包给其他线程池,保护主线程流畅。

Ktor + OkHttp:不写 withContext 行不行?

我的追问:用 Ktor 的 OkHttp 引擎,直接 viewModelScope.launch 里调,不会阻塞 UI 吧?OkHttp 应该自带 suspend 吧?

要分清两层:

因此,Ktor 的 HttpClient 请求可以直接在 viewModelScope.launch 里调用,底层会挂起而不堵主线程。

但以下情况仍会卡 UI:

// ❌ 假 suspend:函数标了 suspend,内部却是阻塞调用
suspend fun fakeFetch() {
    URL("https://example.com").readText()  // 阻塞!
}

// ❌ CPU 密集计算没有切线程
viewModelScope.launch {
  val data = hugeList.map { heavyCompute(it) }
}

理想世界:库全是协程原生、主线程安全(Ktor、Retrofit suspend、Room suspend),launch 里直接调,不必手写 withContext

现实世界:总有老旧 Java SDK、同步文件 I/O、CPU 密集逻辑混进来。

金科玉律:对无法自动挂起的代码,手动套 withContext(Dispatchers.IO)withContext(Dispatchers.Default),发配到后台线程池。

工业级标准模板:Main-safety

结合项目里的实际写法,推荐把耗时逻辑封装进 主线程安全(Main-safe)suspend 函数,在内部用 withContext 切换调度器。调用方只管 viewModelScope.launch,不必关心线程。

Repository 层PostRepository.kt):

suspend fun getPosts(): List<SitePost> = withContext(Dispatchers.IO) {
    val response: ApiListResponse<SitePost> = api.get("/api/admin/posts")
    response.data
}

ViewModel 层AuthViewModel.kt):

viewModelScope.launch {
    auth.sessionStatus.collect { status ->
        when (status) {
            is SessionStatus.Authenticated -> {
                _userState.value = status.session.user?.email
                verifyAdminAccess()  // suspend,内部 Repository 已切 IO
            }
            // ...
        }
    }
}

模板可以概括为:

// ViewModel:在主线程启动,专注 UI 状态
viewModelScope.launch {
    _isLoading.value = true
    try {
        val result = repository.fetchSomething()  // suspend + withContext(IO) 在 Repository 内
        _uiState.value = result                   // 回到主线程,安全更新 UI
    } catch (e: Exception) {
        _error.value = e.message
    } finally {
        _isLoading.value = false
    }
}

// Repository:保证 Main-safe
suspend fun fetchSomething(): Data = withContext(Dispatchers.IO) {
    // 阻塞 I/O 或 CPU 任务放这里
}

这样写的好处:

  1. 单一职责:Repository 保证任何调用方都是主线程安全。
  2. 可读性:ViewModel 像同步代码一样线性阅读。
  3. 可测试suspend 函数比嵌套 launch 更容易单测。

结论

认知 真相
launch = 后台线程 ❌ 默认仍在主线程,除非传 Dispatchers 或内部 withContext
协程 = 异步 = 不卡 UI ❌ 只有 真挂起 才不卡;真阻塞 照样卡
官方文档在骗人 ❌ 文档说的是 suspend 函数的行为,不是万能护身符
withContext 启动子协程 ❌ 同一协程换线程,是挂起函数不是启动器
viewModelScope 多线程 ❌ 默认单主线程;多协程靠挂起点协作

从把 selected 错打成 sealed,到质疑「单线程不阻塞」的官方表述,再到用 Ktor 和 withContext 把逻辑闭环——这套推导让我终于敢理直气壮地说:不是协程没用,是我以前没分清挂起和阻塞。

如果你也在 viewModelScope.launch 里写过 Thread.sleep 或者同步读文件,欢迎对号入座。记住一行原则就够了:

launch 只负责在主线程上开场;能不能不卡 UI,取决于你调用的函数是真 suspend,还是你手动 withContext 把真阻塞发配到了后台。

参考链接