Kotlin 协程源码解析(六)`delay()` 是如何挂起协程的?

1 阅读7分钟

Kotlin 协程源码解析(六)delay() 是如何挂起协程的?

在上一篇文章中,我们一直在研究 Continuation

我们已经知道,Kotlin 编译器会把 suspend 函数转换成状态机,而 Continuation 保存了状态机继续执行所需要的信息。

当协程恢复时,大致会经历:

resumeWith()
    ↓
invokeSuspend()
    ↓
状态机继续执行

但是这里还留下了一个非常关键的问题:

一个已经挂起的协程,到底是谁在未来某个时刻调用了它的 resume()

例如:

launch(Dispatchers.Main) {
    println("A")

    delay(1000)

    println("B")
}

我们知道 delay(1000) 会让协程挂起。

但是:

  • delay() 到底做了什么?
  • 它为什么不会阻塞线程?
  • 挂起以后,Continuation 去哪里了?
  • 未来又由谁负责恢复它?

这一篇我们先只解决前两个问题:

delay() 是如何让当前协程挂起的?

至于 Android 是如何实现 Dispatchers.Main,以及 delay 到期以后到底是谁调用 resume(),我们留到后面的文章继续追踪。


一、delay() 并不是让线程等待

先看最简单的代码:

launch(Dispatchers.Main) {
    println("A")
    delay(1000)
    println("B")
}

第一次接触协程时,很容易产生这样的直觉:

Main Thread
    ↓
delay(1000)
    ↓
等待 1000ms
    ↓
继续执行

如果真是这样,那么 delay() 和:

Thread.sleep(1000);

就没有本质区别了。

但实际上,delay() 不会阻塞当前线程。

真正发生的事情更接近:

协程
    ↓
保存继续执行所需要的信息
    ↓
安排未来恢复
    ↓
当前这次执行结束

线程则可以继续执行其他任务。

所以:

协程挂起,不等于线程等待。

协程只是暂时停止执行,而承载它的线程可以被释放出来执行其他工作。


二、从 delay() 源码开始

我们先直接看 Kotlin 协程中的 delay()

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return

    return suspendCancellableCoroutine { cont ->
        cont.context.delay
            .scheduleResumeAfterDelay(timeMillis, cont)
    }
}

真正值得关注的是:

suspendCancellableCoroutine { cont ->
    cont.context.delay
        .scheduleResumeAfterDelay(timeMillis, cont)
}

这里出现了两个非常重要的东西:

cont

和:

cont.context.delay

其中 cont 是当前协程对应的:

CancellableContinuation<Unit>

也就是说:

delay() 拿到了当前协程的 Continuation。

然后它又从:

cont.context

中找到:

delay

并调用:

scheduleResumeAfterDelay(timeMillis, cont)

这个方法的名字其实已经把职责表达得非常清楚:

安排在指定的时间之后恢复这个 Continuation。

所以 delay() 自己并没有:

sleep()

也没有:

创建线程等待

它做的是:

当前 Continuation
      ↓
交给 Delay
      ↓
安排未来恢复

三、Delay 到底是什么?

Delay 可以理解成协程运行时提供的一种能力。

它的核心接口可以简化为:

interface Delay {

    fun scheduleResumeAfterDelay(
        timeMillis: Long,
        continuation: CancellableContinuation<Unit>
    )
}

这个接口的职责非常单纯:

在指定的时间之后恢复 Continuation。

它并不关心具体怎么实现。

例如它不需要知道:

Android Handler 怎么工作

也不需要知道:

Java Timer 怎么工作

更不需要知道:

线程池怎么实现

所以可以把它理解成一个抽象:

delay()
    ↓
“我需要等待一段时间”
    ↓
Delay
    ↓
“好,我负责安排未来的恢复”

这是一种非常典型的职责分离。

delay() 只负责表达:

我要延迟。

而具体的 Delay 实现负责:

怎么延迟,以及什么时候恢复。


四、suspendCancellableCoroutine 在这里做了什么?

现在再看:

suspendCancellableCoroutine { cont ->
    cont.context.delay
        .scheduleResumeAfterDelay(timeMillis, cont)
}

这里实际上完成了两个重要动作。

1. 获取当前协程的 Continuation

也就是:

当前协程
    ↓
CancellableContinuation

这个 Continuation 代表:

将来恢复时,从这里继续执行。

例如:

println("A")

delay(1000)

println("B")

编译器生成的状态机已经知道:

当前执行到 delay
    ↓
下一次恢复
    ↓
继续执行 println("B")

所以 Continuation 可以理解成:

状态机的一份“继续执行说明书”。


2. 把未来恢复的责任交出去

cont.context.delay
    .scheduleResumeAfterDelay(timeMillis, cont)

意思就是:

Continuation
    ↓
交给 Delay
    ↓
Delay 负责安排未来恢复

于是 delay() 本身就没有必要继续等待了。


五、什么时候真正返回 COROUTINE_SUSPENDED

这一步可以和我们上一篇研究过的 BaseContinuationImpl.resumeWith() 联系起来。

我们之前看到过这样的代码:

val outcome = invokeSuspend(param)

if (outcome === COROUTINE_SUSPENDED) {
    return
}

现在把 delay() 放进去:

invokeSuspend()
    ↓
delay()
    ↓
suspendCancellableCoroutine
    ↓
安排未来恢复
    ↓
COROUTINE_SUSPENDED

最终当前状态机得到:

COROUTINE_SUSPENDED

于是:

if (outcome === COROUTINE_SUSPENDED) {
    return
}

直接返回。

这里有一个非常重要的理解:

这个 return 并不意味着协程结束了。

它只意味着:

当前这一次状态机执行结束了。

因为现在没有继续执行的条件,所以状态机暂时停下来。

而 Continuation 仍然存在。

状态机也仍然存在。

只是在等待未来某个时刻再次:

resume()

六、为什么线程没有被阻塞?

现在重新看:

delay(1000)

执行过程:

Main Thread
    ↓
执行 delay()
    ↓
获取 Continuation
    ↓
告诉 Delay:
“1000ms 后恢复这个 Continuation”
    ↓
返回 COROUTINE_SUSPENDED
    ↓
当前协程执行结束

注意最后一步:

当前协程执行结束

并不是:

线程等待 1000ms

所以 Main Thread 可以继续做自己的事情。

例如:

Main Thread
    │
    ├── 执行协程 A
    │       ↓
    │    delay()
    │       ↓
    │    挂起
    │
    ├── 处理 UI 事件
    │
    ├── 执行其他 Runnable
    │
    ├── 执行协程 B
    │
    └── ...

这就是协程能够在等待期间释放线程的原因。


七、COROUTINE_SUSPENDED 到底意味着什么?

到这里,我们可以重新理解:

COROUTINE_SUSPENDED

它并不是:

“线程进入等待状态。”

也不是:

“协程创建了一个新的线程。”

它表达的是:

当前状态机暂时没有结果可以继续向下执行,调用者不应该再继续驱动这个 Continuation。

例如:

invokeSuspend()
    ↓
执行到 delay()
    ↓
注册未来恢复
    ↓
返回 COROUTINE_SUSPENDED

于是:

当前 resumeWith()
    ↓
结束

未来某个时刻,外部事件发生:

某个事件
    ↓
continuation.resume(...)
    ↓
再次进入状态机
    ↓
invokeSuspend()
    ↓
从之前保存的状态继续执行

因此,COROUTINE_SUSPENDED 实际上是连接:

这一次执行

和:

未来下一次执行

之间的边界。


八、delay() 为什么需要 Continuation?

如果我们把 delay() 想象成一个普通的同步函数:

fun delay(time: Long) {
    Thread.sleep(time)
}

它不需要 Continuation,因为线程会停在那里。

但协程不能这么做。

协程必须在挂起之前保存:

以后应该从哪里继续?

例如:

launch {
    println("A")

    delay(1000)

    println("B")
}

挂起的时候,我们不能只保存:

“我要等待 1 秒”

还必须保存:

1 秒之后我要继续执行 println("B")”

而这个信息正是状态机和 Continuation 保存的。

所以整个过程可以理解成:

                    协程执行
                       │
                       ▼
                  println("A")
                       │
                       ▼
                   delay()
                       │
             ┌─────────┴─────────┐
             │                   │
             ▼                   ▼
       保存 Continuation      安排未来恢复
             │                   │
             └─────────┬─────────┘
                       │
                       ▼
              COROUTINE_SUSPENDED
                       │
                       ▼
                当前执行结束

九、这里其实有两个完全不同的问题

到这里,我们可以把“挂起”拆成两个问题。

问题一:以后怎么继续?

答案是:

Continuation + 状态机

它保存:

当前状态
局部变量
completion
下一次应该执行的位置

所以:

Continuation 解决“怎么继续”。


问题二:什么时候继续?

答案是:

Delay

它负责:

安排未来某个时间点
    ↓
恢复 Continuation

所以:

Delay 解决“什么时候继续”。

这两个概念非常重要。

我们不能把:

Continuation

和:

Delay

混成一件事情。

Continuation 是:

继续执行所需要的信息。

Delay 是:

安排继续执行的机制。


十、现在还有一个关键问题没有回答

到这里,我们已经把:

delay(1000)

的“挂起”部分追通了:

delay()
    ↓
suspendCancellableCoroutine
    ↓
获取当前 Continuation
    ↓
获取 CoroutineContext 中的 Delay
    ↓
scheduleResumeAfterDelay()
    ↓
COROUTINE_SUSPENDED
    ↓
当前协程停止执行

但是我们故意留下了最后一段没有展开:

scheduleResumeAfterDelay()
        ↓
      ???
        ↓
resume()

到底是谁实现了 Delay

我们的代码明明是:

launch(Dispatchers.Main) {
    delay(1000)
}

那么:

Dispatchers.Main

和:

Delay

到底是什么关系?

为什么在 Android 中,它最终可以和:

Handler
Looper
MessageQueue

连接起来?

这就是下一篇文章要解决的问题。


下一篇我们会继续沿着:

scheduleResumeAfterDelay()
        ↓
      ???
        ↓
resume()

这条线继续追踪。

我们会看到 Android 的 Dispatchers.Main 是如何通过 Handler + Looper 实现延迟恢复的,

同时也会看看 Android 平台提供的 Main Dispatcher 是如何被 kotlinx.coroutines 找到并创建出来的。

这一篇我们仍然不会深入 Dispatcher 的调度算法, 而是只关注一个问题:

“delay 挂起以后,1000ms 到底发生了什么?”