协程基础概念:从线程到协程,到底切换了什么?

各位好。先聊个我自己的经历。几年前我刚从 Java 转 Kotlin,看到 launch 和 async 时第一反应是:这不就是线程池封装吗?结果第一次上线就遇到了启动时序问题——子协程还没执行完,主线程就已经走了。排查了一整天,才意识到我对协程的「本质」理解太浅了。

所以第一章,咱们先把这个地基打扎实。你只有懂了协程到底是什么、怎么工作的,后面那些启动顺序、取消超时、并发逃逸的问题才看得透。

1.1 协程的定义:轻量级线程?不,是轻量级任务上下文

官方说协程是「轻量级线程」。我其实不太喜欢这个比喻。因为线程再轻量也是操作系统级的资源,而协程纯粹是用户态的。

  • 协程:一段可以被挂起和恢复的代码片段。挂起时不阻塞线程,恢复时从上次离开的地方接着跑。
  • 关键区别:线程的切换由内核调度器决定,协程的切换由开发者写的挂起点(suspend 函数)决定。

你想想看,如果每个网络请求都开一个线程,你的手机服务器可能早就撑爆了。协程允许你在同一个线程里跑成千上万个并发任务——只要它们都在等待 I/O。

💡 我的经验: 在 Android 项目里,我经常在主线程直接启动协程做数据库查询。很多人担心 ANR,但其实 Room 的挂起函数会自动切到后台线程,主线程只做挂起等待,根本不会卡死。

1.2 优势对比:协程 vs 线程 vs 回调

写两个例子你就明白了。

线程的方式:

// 假设连续两次网络请求
Thread {
    val result1 = api.fetch1()  // 阻塞当前线程
    val result2 = api.fetch2(result1)
}.start()

这个线程在等待网络时啥也干不了。线程数一多,上下文切换成本就上来了。

回调的方式:

api.fetch1 { result1 ->
    api.fetch2(result1) { result2 ->
        // 继续处理
    }
}

回调地狱来了。而且错误处理分散在各处,你很难在中间加个重试逻辑。

协程的方式:

suspend fun fetchBoth(): String {
    val result1 = api.fetch1()  // 挂起,不阻塞线程
    val result2 = api.fetch2(result1)
    return result2
}

代码看起来就像同步的,但底层用了挂起机制。线程没被占用,可以干别的事。

维度线程回调协程
资源开销高(栈 1MB+)低极低(对象级别)
可读性一般差(回调地狱)优秀(类似同步)
取消与超时困难(interrupt() 不可靠)需要手动维护状态原生支持 cancel()
错误处理try-catch 正常容易遗漏try-catch 正常

说白了:协程把「并发」的复杂度从「资源调度」变成了「任务编排」。你只需要关心业务逻辑,调度由协程框架帮你搞定。

1.3 协程与线程的关系:不是替代,是协作

我见过有人面试时说「协程完全替代线程」。这个观点有问题。协程最终还是要跑在线程上的。

  • 协程是任务单元,线程是执行载体。多个协程可以共享一个线程,通过挂起/恢复实现协作式调度。
  • 协程挂起时,线程不空闲? 不,线程会去跑其他协程。这就是高效的原因。
  • 协程可以切换线程,比如 withContext(Dispatchers.IO) 把协程切到 IO 线程做阻塞操作。
⚠️ 我曾踩过的坑: 以为协程一定比线程快。不对。纯 CPU 密集型任务,协程挂起恢复的额外开销反而比线程栈切换小不了多少。协程的优势在于 I/O 密集或大量等待场景。我曾经在数据解析循环里用协程,性能反而下降了。

1.4 协程的工作原理:挂起与状态机

协程是怎么做到「挂起不阻塞」的?核心是编译器的黑魔法——协程会变成一个状态机。

看一个最简单的例子:

suspend fun foo() {
    println("1")
    delay(1000)   // 挂起点
    println("2")
}

Kotlin 编译后,实际会生成类似这样的状态机:

// 伪代码
class FooContinuation : Continuation {
    var label = 0
    var result: Any? = null
    
    fun resume() {
        when (label) {
            0 -> {
                println("1")
                label = 1
                // 调用 delay,传入 continuation 自身
                delay(1000, this)
                // 注意这里没有 return;delay 会返回 COROUTINE_SUSPENDED
            }
            1 -> {
                println("2")
            }
        }
    }
}

当 delay(1000) 被调用时,如果时间未到,它会返回一个特殊标记 COROUTINE_SUSPENDED,并把当前协程的状态(label=1、局部变量)存在 continuation 对象里。然后线程就可以去执行其他协程。1秒后,定时器触发,调用 continuation 的 resume(),从 label=1 继续执行。

🧠 记得这张图: 协程本质就是「挂起点 + 状态机 + 对象堆栈」。每个挂起点对应一个 label,每次恢复就是跳到下一个 label。这也是为什么协程不能自动支持线程中断(InterruptedException),因为恢复后代码直接跳转,不会经过操作系统线程的异常路径。

1.5 核心知识体系:一张图讲清楚

下面这张图总结了协程基础概念的几个关键节点和它们的关系。你以后做深度调优时,可以回来对照这张图。

协程基础知识体系 协程(Coroutine) 定义 可挂起 + 可恢复的代码片段 用户态调度,不阻塞线程 优势 轻量、可读、易取消 与线程的关系 协程 → 在线程上执行 挂起时线程不阻塞 适用场景 I/O 密集、异步编排 工作原理:编译器变换为状态机 挂起点 → label 分派 → 对象包保存状态 恢复时跳转到下一个 label,继续执行 挂起函数 → 协程的基石

1.6 小结

协程不是魔法。它就是编译器帮你生成的状态机,配合调度器在合适的线程上跑。记住这几个要点,后续章节的「启动时序」问题就会好理解很多:

  1. 协程的创建(launch/async)本身是同步的,启动时机由调度器决定。
  2. 挂起点是协程主动交回执行权的时刻,线程才能切换去跑其他协程。
  3. 同一个协程内顺序执行,不同协程之间是并发(或串行)取决于启动方式。