谱写Kotlin协程面试进行曲-进阶篇(第一乐章)

552 阅读20分钟

前言

时光飞逝,距离上次整理Kotlin面试题,一晃两年过去了,恍恍惚惚,已经隔世。这期间,依旧能看到不少同学阅读、收藏笔者之前整理的Kotlin面试题目,能够帮到大家还是挺开心的,授人以鱼不如授人以渔。转眼又快要进入金三银四了,不少人(当然包括笔者)也开始考虑换个环境,重新投入到面试准备中。已经2026年了,随着协程在实际项目中的广泛使用,面试中对Kotlin协程的考察也在不断升级——从最初的 API 使用,逐渐深入到运行机制、调度模型,以及异常与取消等边界问题。

很多时候,这些问题本身并不算多复杂,但一旦被追问“为什么”,答案就容易变得模糊。如今市场早已不缺能熟练调用API的初中级工程师了,再加上 AI 的兴起,现在越发成熟,甚至于日常工作都离不开AI的帮助,可想而知技术门槛又被进一步抬高。生活不易,想要混口饭吃,把基础打牢的同时,还得兼顾当下技术的浪潮,避免一问三不知。所谓知其然,更要知其所以然。

因此,笔者在原有内容的基础上,结合这段时间的实践经验,并参考现阶段网络资料与 AI 的整理,又梳理了 三十多道 Kotlin 协程进阶问题。 由于整体篇幅解答起来实在太过冗长,所以分为了几个篇幅。这些问题尽量覆盖了面试中高频出现、却又容易被忽略的关键点,希望通过问题本身,帮助自己,也帮助大家,在协程相关的考察中,真正做到心中有数。

以下是笔者的Kotlin面试指南系列:

下面开始我们的第一篇章的几个问题

1. Kotlin 协程与线程的本质区别是什么?为什么说协程是“轻量级线程”?

快问快答

这是一个非常经典、也非常容易被“说模糊”的问题,这里笔者先给出结论

线程是操作系统管理的执行单位,而协程是由Kotlin运行时程序管理的的可挂起计算单元,前者属于是内核态,后者是用户态 。协程并不等于线程,但它必须要跑在线程上面的,因为创建成本低、切换快、数量多,所以常常被戏称为”轻量级线程“

下面我们来分层详细补充下这个结论

线程的本质:操作系统的”打工人“

线程是操作系统级别的最小单位,由操作系统内核创建和管理,真正参与到CPU调度,可以简单理解为:线程=操作系统派给CPU的“天命打工人"

一个线程中至少包含:

  • 独立的调用栈(Stack)
  • 程序计数器
  • 寄存器上下文
  • 内核态调度信息

协程进阶.png

可以看到,创建一个新的线程,但是付出的代价相对是有点大,如下表所示

创建成本高(分配栈内存,通常是MB级别的)
切换成本高(用户态 ↔ 内核态切换)
数量存在上限(几百-几千不等)
阻塞IO/sleep会阻塞整个线程

一旦该线程发生了阻塞了,CPU就无法再利用它做别的事情了。

协程的本质:可挂起的”任务脚本“

对于协程来说,它不属于操作系统的概念了,而是Kotlin语言层面的东西。拿官方的话术来说,协程是一段可以被挂起(suspend)并在之后恢复(resume)的代码 。不同于线程,协程属于用户态,由Kotlin编译器+kotlinx.coroutines库实现,OS并不知道协程的存在的。

就拿最简单的,我们启动一个协程,然后在里面做某些事情

launch {
    val data = fetchData()
    showData(data)
}

表面上是同步的写法,但实际上:

  • fetchData函数方法可以被挂起
  • 在这个线程中可以同时执行的别的协程
  • 等IO完成后回来再继续执行

值得注意的是,虽然我们常常把协程和线程进行比较,但要记住协程不是脱离线程运行的,它是一定要运行在线程之中的。当然,一个线程中可以存在多个协程的,每个协程的执行时间由用户端决定分配, 线程负责“真正跑”,协程负责“切换和挂起逻辑”

e7a992c9a5866ba484354d5682d6464b.png

为啥说协程是”轻量级线程“?

这个问题相信各位同学都不陌生了,为了方便大家理解,才有了这个形象说法,可以说协程之所以”轻量“,因为它不由操作系统管理,不需要独立线程栈,切换发生在用户态,只保存极少的状态。

不够形象?首先我们明确一个点,在计算机里什么东西才叫”重“?通常体现在三点:1. 创建成本高 2. 内存占用大 3.切换代价高

好家伙,这三点线程全给占了,协程的出现刚好弥补了这些,我们从两方面说明下

  • 从使用体验上来看,很像线程

    scope.launch {
        doWork()
    }
    

    这个写法上非常像线程,它可以在里面并发执行,可以切换上写文,可以取消,等待等等

  • 从实现成本上看,极其轻量

    我们可以在一个线程中创建成百上千个协程,它的成本相对来说是比较低的

    对比项线程协程
    管理者操作系统Kotlin程序运行时
    内存占用MB 级KB 级
    创建数量非常多
    切换方式内核态用户态
    切换成本极低

因为协程的创建、切换和销毁成本都非常低,一个线程可以承载成千上万个协程,“轻”的不是功能,而是成本。此外由于协程调度发生在用户态,因此可以用更少的线程处理更多任务,这也是为什么说协程是“轻量级线程”了。

2. Kotlin协程是 stackless 还是 stackful,这种设计有什么影响?

快问快答

Kotlin协程是典型的 "无栈协程" 实现。这意味着:它本身不拥有自己的调用栈结构,而是通过编译器生成的状态机 + Continuation 保存程序状态。 不过值得思考的是,在底层JVM层面,调用普通函数同样会用JVM栈,但这并不改变Kotlin协程本质总体来说是 stackless

此外,如果严格来说的话,Kotlin 协程是 stackless coroutine + JVM 原生栈配合的混合模型实现。但是协程的暂停和恢复不靠真实挂载的栈帧,而靠状态机与Continuation

在这种设计下,协程并不保存真实的调用栈,而是由 编译器把 suspend 挂起函数改写成状态机在挂起点把必要状态封装进 Continuation对象存到堆上,恢复时再由调度器把它重新放回线程执行。这样以来的话

  • 内存占用低,可以创建百万级别协程,对高并发特别友好
  • 不会阻塞线程,但调用栈不会跨suspend点存在

好的,那么下面我们来详细拆解下这个问题

什么是StacklessStackful

首先我们要明确一点的是,一般协程分为了两种,有栈协程无栈协程,笔者分别解释下

有栈协程

想象它像 真正的小线程,每个协程都有自己的执行栈(类似线程栈),可以在任意深度的调用中任意位置暂停(yield)

一些典型的例子像是某些C/C++ 协程库 、Go CoroutineLua coroutine等。

  • 它的优点在于:

    • 可在任意调用层级暂停
    • 能保存真实调用栈
  • 它的缺点在于:

    • 每个协程需要占用栈内存(开销大)
    • 实现复杂度高
无栈协程(Stackless)

它没有自己的栈空间,而是 由编译器转换成状态机 + Continuation,再在需要挂起的地方保存状态。典型例子像是KotlinJavaScript async/awaitC# async/awaitC++20协程一样。

  • 它的优点在于:

    • 非常轻量,不需要为每个协程分配真实栈
    • 能支持成千上万协程同时存在
    • 内存占用小
  • 它的缺点在于:

    • 只能在特定的suspend点(编译器知道的地方)才能挂起
    • 挂起点之外无法"随意暂停"

怎么说呢,是不是有点抽象难以理解,下面我们来打个比喻,想象一下

Stackful 协程就像一个电影拍摄团队

  • 每个演员、工作人员都随身带着完整的道具和场景布置(对应完整栈)
  • 无论拍摄到哪,都可以随时停下来继续上一秒的状态
  • 停下来后,几乎可以立刻从任意一帧继续拍摄
  • 缺点:道具重、场景大,搬运成本高,随便暂停/切换成本大

Stackless 协程就像剧本排练演员

  • 演员不带道具,只带一个剧本和记录表(对应保存的少量状态)
  • 暂停时,只要记下当前排练到哪一页、哪一行即可
  • 轻量、容易随时切换任务
  • 限制:只能在明确的“剧本节点”停下来(检查点)

Kotlin 协程为啥是Stackless?为啥不能是Stackful呢?

我们上面简单了解了下有栈协程和无栈协程的区别,那有同学就会有疑问了,为啥Kotlin协程说是StackLess的,而不能是Stackful的呢?

请允许我从Kotlin协程的核心机制开始说起,众所周知,Kotlin协程编译后不是传统递归,而是把每个 suspend fun 和其调用点改写成 连续状态的状态机(state machine 。当碰到挂起点(suspend) 的时候,它不会真的把整个调用栈存起来,反而只存需要的变量状态和 Continuation对象,其中Continuation简单来说就是一个携带状态的对象,用来"标记我们要恢复执行的位置和数据"。

那么也就是说:

  • 所谓挂起 = 状态保存到heap
  • 所谓恢复 = 从heap按状态机器继续执行下去

这就是所谓无栈协程stackless的定义:没有用真正的栈用来保存执行状态

协程状态机编译.png

另外,为啥不能是Stackful呢?原因在于Kotlin还是基于JVM的,要实现真正的 stackful coroutine 其实是不太可能的,原因如下:

  • JVM虚拟机是不直接暴露切换栈的机制
  • 真实栈切换需要改变执行栈位置,这在JVM bytecode里面是很难做到的 。

所以Kotlin想了个聪明办法:制造状态机 + Continuation,这样无需修改JVM,也能实现挂起/恢复。

那么这种设计有什么影响?

属于是超轻协程

因为不需要实际栈,所以:

  • 在有限的内存中可以创建中成千上百个协程,不会有过重的消耗
  • 内存占用比线程低 10 至 100 倍
  • 创建/销毁速度非常快,这对高并发特别友好

超轻协程.png

只能在挂起点挂起

这属实是stackless协程最大的局限了:

  • 不能随便在非 suspend 函数里暂停
  • suspend 函数调用链必须清晰
  • 但是,编译器已经帮你生成好了状态机,所以你不用手写这些逻辑

一句话:要挂起,得走 suspend 或 async 的门

suspend调用规则.png

写起来更像"同步代码"

虽然是异步执行,但写起来就像同步一样

scope.launch {
    val res = networkCall()
    println(res)
}
​
suspend fun networkCall() {
    ...
}

这一点相对来说,比回调地狱或复杂的线程池好使多了

协程写法.png

有趣的是,虽然Kotlin协程 本质stackless,但在suspend之前函数调用仍然使用JVM stack , 这叫混合模型 —— 状态机 + JVM 栈混合执行状态机负责存储挂起位置,而JVM栈负责函数内部的局部执行。

混合执行模型.png

就说到这了,现在用个表格简单总结下:

Kotlin 协程 stackless 还是 stackful?Stackless(无栈) (混合模型)
怎么实现挂起?Continuation + 状态机
为什么不用 stackful?JVM 无栈切换支持 + 内存效率更好
有什么影响?轻量、性能好、只能 suspend 点挂起

AE95E35F-16A0-460F-A6A0-115EA6287EF8.png

3. suspend 函数的底层实现原理是什么?它如何通过 Continuation 实现挂起与恢复?

快问快答

还是老规矩,笔者先将该问题总结下。

suspend函数本质实现原理主要是,编译器把它改写成「带 Continuation参数的普通函数 + 状态机」,它在挂起时保存执行状态并返回 COROUTINE_SUSPENDED,恢复时通过 Continuation.resumeWith() 从上次挂起点继续执行,在这个期间不阻塞线程。

下面笔者来详细展开说说

什么是 suspend函数?

这里先用一句话简单说明下,suspend函数就是可以“暂停自己执行,然后等合适的时候再唤醒继续执行的特殊函数。”

啥意思呢,它和普通函数有什么区别呢?

  • 普通函数调用是 同步阻塞 → 执行完才返回结果;
  • suspend函数想做的是 “不阻塞主线程的异步逻辑写成同步风格”

是不是比较抽象,下面笔者打个比方把,比如说我们点外卖

  • 普通函数 → 你就默默地等在门口(当然现实中很少这种),看着外卖小哥路上走了半小时 → 等到送到了再继续吃饭,中间不会去干任何事情
  • suspend 函数 → 你先去打游戏 或者去干些别的事情,外卖到了小哥喊一声你回来取 → 继续吃饭,美滋滋

值得注意的是挂起函数不是新的线程,它只是把当前执行状态 “保存起来”,到点再恢复。

suspend的底层核心秘密武器:Continuation

经过剖析,我们知道suspend函数的底层其实 被编译成带 Continuation 参数的普通函数

编译后的情况

我们像这样写一个挂起函数

suspend fun foo(): Int { … }

经过编译后,编译器帮我们变成如下函数

fun foo(continuation: Continuation<Int>): Any?

发现了没?这里的返回值不是 Int,而是 Any? ,因为它可能返回两种情况,如下表所示

情况返回值
正常立即完成int(装箱成 Any)
挂起中特殊标记 —— COROUTINE_SUSPENDED
那么Continuation到底是啥东东?

我们把Continuation理解成一个 “执行状态的存钱罐 + 回复回调” 。下面结合源码来简单看下

可以看到,它内部有两个重要东西:

interface Continuation<T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

我们可以想象Continuation像下图这样

Continuation.png

那么suspend 具体如何运作的呢?

下面我们还是用一个简单例子来模拟它的执行:

suspend fun doWork(): String {
    println("start")
    val result = fetchFromNetwork()   // 假装这个是挂起点
    println("got $result")
    return result
}

底层被编译成类似以下这样的代码:

fun doWork(continuation: Continuation<String>): Any? {
    println("start")
​
    val r = fetchFromNetwork(object : Continuation<String> {
         override fun resumeWith(result: Result<String>) {
             // 网络返回时回来执行
         }
    })
​
    if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
​
    println("got $r")
    return r
}

具体的流程图所下所示:

suspend执行流程.png

整体流程已经非常清晰明了了,此外,我们再思考下,为什么挂起后不阻塞线程?

因为编译器在遇到挂起点时会把当前栈帧展开成状态机,每个挂起点变成“状态编号”,类似这样子:

state = 0  // start
...
state = 1  // next
...

于是乎,函数不会真正阻塞线程,只会 保存状态,返回 COROUTINE_SUSPENDED,线程继续做别的。等到异步结果回来后,调用Continuation.resume ,然后再继续执行后面的状态!

其次就是它的关键魔法:状态机转换,可以看到编译后的状态机看起来大概像这样,具体的后面问题会详细说明的

fun doWork(cont: Continuation<String>): Any {
    when (cont.label) {
        0 -> {
            println("start")
            cont.label = 1
            val tmp = fetchFromNetwork(cont)
            if (tmp == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        }
        1 -> {
            val result = cont.result  // 网络回来
            println("got $result")
            return result
        }
    }
}

其中Continuation就是相当于“挂起 & 复活的钥匙”,它做了三件事情,上文也提了一嘴,做了一个表格简单总结下:

功能解释
保存现场保存当前执行点 & 局部变量
提供 resumeWith()等异步完成后继续
持有 CoroutineContext控制调度(主线程/IO/默认)

下面我们简单总结下这个问题

  • suspend函数底层是编译成带 Continuation 的普通函数
  • 挂起本质上是:保存状态 & 返回特殊标记
  • 恢复则由 Continuation.resumeWith() 继续执行状态机
  • 执行过程中不会阻塞线程,是 协程轻量非阻塞 的核心

4. 协程启动后真正执行挂起/恢复的流程(状态机如何工作)?

快问快答

这个问题相对来说真的有点复杂了,一时半会儿也没法详细展开说明,这里笔者只是将大致流程梳理一下,通过不断问问题的方式,让大家层层递进,另外具体详细的内容同学们可自行扩展。老样子,还是先上总结。

协程启动后,suspend 函数经过 CPS(Continuation Passing Style)转换,它被编译成一个状态机类, 每个挂起点等于一个 label,它真正的执行入口是 invokeSuspend() , 如果此时挂起了,那么返回 COROUTINE_SUSPENDED,如果恢复了会调用 invokeSuspend()

其实简单来说,协程启动后代码是不会一次性跑完的,而是通过 CPS 转换成状态机,由 invokeSuspend 驱动执行, 每次挂起和恢复本质都是状态机(State Matchine)的一次“跳转”。

下面笔者简单展开说说

协程从“启动”开始发生了什么?

首先,我们从最常见最初的代码开始说起:

GlobalScope.launch {
    val data = loadData()
    show(data)
}

乍一看,这段代码很容易让人产生一种错觉:是不是launch一调用,代码就立刻开始执行,等跑完就结束了。

但真实发生的,其实完全不是这样的,协程的执行过程远比这看起来要“拐弯抹角”得多

launch并不是直接执行你的代码

它干的第一件事就是:创建了一个协程对象,更确切的来说,launch会创建一个实现了 Continuation的协程对象,用来承载后续整个协程的执行状态。

这个对象呢,可以理解为状态机的载体,或者说是之后挂起与恢复的核心对象,大概长这样(简化伪代码):

class XxxCoroutine(
    completion: Continuation<Unit>
) : SuspendLambda(completion) {
​
    override fun invokeSuspend(result: Any?): Any? {
        // 状态机在这里
    }
}

可以看到,我们具体的协程代码,全都被塞进了invokeSuspend()里面了

为什么说 invokeSuspend是协程的“心脏”?

看到网上有人这么说,invokeSuspend 是协程的“心脏”,这个说法其实非常形象,而且并不夸张。

我们只需要记住一句话就行:invokeSuspend() 是协程真正执行业务逻辑的唯一入口。

无论协程处于哪种阶段:

  • 第一次启动协程
  • 在某个挂起点被恢复
  • 甚至是异常恢复

最终,都会回到 invokeSuspend() 方法中继续执行。

既然协程代码都跑在 invokeSuspend() 里,那么有同学就会问了,为什么代码可以在中途“暂停”,而不是一次性执行完?

这就要引出协程中的CPS(Continuation Passing Style)

CPS:Continuation Passing Style到底干了啥?

普通函数的执行方式

我们先来看下普通函数是怎么执行的

fun foo(): Int {
    return bar()
}

可以看到,普通函数依赖 返回值 把结果交给调用方,一旦函数开始执行,就必须一路跑到结束。

CPS核心思想

我们同样写个伪代码看下

fun foo(continuation: Continuation<Int>) {
    bar { result ->
        continuation.resume(result)
    }
}

由于源码相对较多,这里就不一一贴出来了,只要知道CPS核心思想就足够了,它不再通过 return 返回结果,而是把“下一步怎么执行”交给Continuation

suspend函数里的CPS转换

suspend 函数中,CPS主要干了两件事:

  • 给函数多加了一个 Continuation参数
  • 把后续逻辑拆解出来,交给Continuation继续执行

但问题也随之而来:一个suspend函数里可能有多个挂起点,恢复时,Continuation是怎么知道该从哪一行继续?

答案呼之欲出:那就是状态机

状态机:协程“不死”的秘密

接下来看一个最简单的suspend函数:

suspend fun demo() {
    println("A")
    delay(1000)
    println("B")
}

但在编译器眼里,这一句不再是一个普通的挂起函数了,而是一个状态机类,编译器生成的伪代码如下所示

class DemoContinuation(
    completion: Continuation<Unit>
) : ContinuationImpl(completion) {
​
    var label = 0
​
    override fun invokeSuspend(result: Any?): Any? {
        when (label) {
            0 -> {
                println("A")
                label = 1
                if (delay(1000, this) == COROUTINE_SUSPENDED) {
                    return COROUTINE_SUSPENDED
                }
            }
            1 -> {
                println("B")
                return Unit
            }
        }
        return Unit
    }
}
​

这里的核心点只有两个

  • label:记录当前执行到哪一步
  • invokesuspend() : 根据label标志决定执行哪一段逻辑

状态机.png

到这里,协程已经具备了“记住执行位置”的能力。接下来,我们看看 挂起到底是在什么时候发生的

真正的“挂起”是怎么发生的?

首先,有一个必须先澄清的误区:挂起 ≠ 阻塞线程。 挂起发生时,线程并不会被卡住。

以最常用的delay() 为例子,我们来看下挂起的真实流程

  1. invokeSuspend() 执行到delay
  2. delay注册一个定时器
  3. 返回 COROUTINE_SUSPENDED
  4. invokeSuspend() 立刻结束
  5. 线程被释放,去干别的活

协程delay流程.png

所谓“挂起”,本质就是一次提前返回。就像协程老哥说:“我先暂停一下,你把我现在的状态记好,一会儿有人喊我,我再接着演。”

那“恢复”又是怎么发生的?

既既然协程已经提前返回了,那它是怎么继续执行的?

还是以delay为例子,当我们异步任务完成的时候,也就是这个延时定时器到点后会调用

continuation.resume(Unit)

接着我们来看看resume背后发生了什么事

  1. resume()resumeWith()
  2. 调用到原来的Continuation
  3. 再次调用invokeSuspend()
  4. 此时将label = 1
  5. 从上次挂起点继续执行

协程恢复流程.png

值得注意的是,恢复 ≠ 回到原线程;也就是恢复在哪个线程,取决于CoroutineDispatcher,这个后续文章中会说到,这里就不作过多解释。

invokeSuspend为什么能反复调用?

现在回到之前的一个关键问题:为什么多次调用 invokeSuspend() ,不会把代码从头重新执行一遍?

答案就在它的设计上。

invokeSuspend() 并不是一个普通函数,而是一个“可重入的状态机执行函数”。

每次调用的时候,根据当前的label标志位,决定从哪一段逻辑继续执行

我们可以把它理解为:一个自带“执行记忆”的函数。

总的来说,协程启动后并不会“一口气跑完”,而是被编译成一个由 invokeSuspend() 驱动的状态机;每一次挂起和恢复,都是状态机的一次跳转;遇到挂起点时提前返回并保存状态,恢复时再次进入同一个状态机,依靠 label从上一次中断的位置继续执行。整个过程不阻塞线程,只是在用户态保存和恢复执行状态。

以上就是协程挂起 / 恢复真正发生的全过程

参考资料

  1. scaler.com - Stackless vs Stackful Coroutines
  2. Stack Overflow - Are Kotlin coroutines stackless?
  3. bennyhuo.com - Kotlin 协程原理
  4. 51CTO博客 - 协程详解
  5. Kotlin 官方文档
  6. Stack Overflow - Kotlin coroutines implementation
  7. geeksforgeeks.org - Kotlin Coroutines

我们下一篇见

祝你早安午安晚安

顺颂时祺,秋绥冬禧