「最后一次,彻底搞懂kotlin协程」(六) | 全网唯一,手撸协程!

引言

当我发现我不停的看到关于Kotlin协程的文章的时候,我突然意识到:可能现有的文章并没有很好的解决大家的一些问题。在看了一些比较热门的协程文章之后,我确认了这个想法。此时我想起了一个古老的笑话:当一个程序员看到市面上有50种框可用架的时候,决心开发一种框架把这个框架统一起来,于是市面上有了51种框架我最终还是决定:干,因为异步编程实在是一个过于重要的部分。

我总结了现存资料所存在的一些问题:

  1. 官方文档侧重于描述用法,涉及原理部分较少。如果不掌握原理,很难融会贯通,使用时容易踩坑
  2. 部分博客文章基本是把官方文档复述一遍,再辅之少量的示例,增量信息不多
  3. 不同的博客文章之间内容质量参差不齐,理解角度和描述方式各不相同,部分未经验证的概念反而混淆了认知,导致更加难以理解
  4. 部分博客文章涉及大量源码相关内容,但描述线索不太容易理解,缺乏循序渐进的讲述和一些关键概念的铺垫和澄清

而为什么 coroutine 如此难以描述清楚呢?我总结了几个原因:

  1. 协程的结构化并发写法(异步变同步的写法)很爽,但与之前的经验相比会过于颠覆,难以理解
  2. 协程引入了不少之前少见的概念,CoroutineScope,CoroutineContext... 新概念增加了理解的难度
  3. 协程引入了一些魔法,比如 suspend,不仅是一个关键字,更在编译时加了料,而这个料恰好又是搞懂协程的关键
  4. 协程的恢复也是非常核心的概念,是协程之所以为协程而不单单只是另一个线程框架的关键,而其很容易被一笔带过
  5. 因为协程的"新"概念较多,技术实现也较为隐蔽,所以其主线也轻易的被掩埋在了魔法之中

那么在意识到了理解协程的一些难点之后,本文又将如何努力化解这些难题呢?我打算尝试以下一些方法:

  1. 从基础的线程开始,澄清一些易错而又对理解协程至关重要的概念,尽量不引入对于理解协程核心无关的细节
  2. 循序渐进,以异步编程的发展脉络为主线,梳理引入协程后解决了哪些问题,也破除协程的高效率迷信
  3. 物理学家费曼有句话:"What I cannot create, I do not understand",我会通过一些简陋的模拟实现,来降低协程陡峭的学习曲线
  4. 介绍一些我自己的独特理解,并引入一些日常场景来加强对于协程理解
  5. 加入一些练习。如看不练理解会很浅,浅显的理解会随着时间被你的大脑垃圾回收掉,陷入重复学习的陷阱(这也是本系列标题夸口最后一次的原因之一)

回顾

在前面几篇我们已经循序渐进的陆续覆盖了 Kotlin 协程的重要组成部份,线程线程池 ,任务在线程间流转的基础: EventLoop 模型 ,和Kotlin 协程的设计的重要概念:结构化并发 ,以及 Kotlin 协程中最为让人惊呼 Amazing 也最为让人困惑的:挂起恢复 -异步变同步的秘密, 以及让这一切可以生效的自动 状态机 。这一篇,我们就基于这些基础概念来手撸一个极简的协程,让我们通过一个完整的 demo 彻底掌握 kotlin 协程。

演示

下面我们延用一个在异步变同步篇中的例子,来演示一下 Kotlin 协程与我们自己的协程的对比(出于篇幅和主线控制考虑,本篇不会涉及到自动状态机生成的部份,关于状态机的部份我们会手动添加),下面回顾一下这个示例(稍作改动):

  1. 从后端获取 UserProfile 信息,包含名字和头像地址
  2. 为丰富演示内容,额外加入 delay
  3. 同时设置一个头像占位图
  4. 拿到 UserProfile 后,设置用户名字
  5. 用 UserProfile 中的头像地址去后台获取图片数据
  6. 获取到用户头像数据后展示头像
  7. 再把用户头像渲染出阴影和边框效果,为丰富演示内容,使用 async
  8. 加入 log 演示挂起点的作用
  9. 把渲染出来的结果再次展示出来
kotlin 复制代码
// CoroutineSample.kt
fun main() {
    val coroutineScope = CoroutineScope(mainDispatcher)
  
    coroutineScope.launch {
        // 1
        printlnWithThread("set place holder: ${System.currentTimeMillis()}")
        // 2 为了丰富演示内容,加入delay
        delay(1000L)
        // 3
        val userProfile = withContext(Dispatchers.IO) { getUserProfile() }
        // 4
        printlnWithThread("set user name: ${userProfile.first} ${System.currentTimeMillis()}")
        // 5
        val avatar = withContext(Dispatchers.IO) { getImageBy(userProfile.second) }
        // 6
        printlnWithThread("set avatar: $avatar")
        // 7 为了丰富演示内容,加入async
        val finalAvatar = async(Dispatchers.Default) { renderImage(avatar) }
        // 8 加入 log 演示挂起点的作用
        printlnWithThread("log record")
        // 9
        printlnWithThread("set avatar: ${finalAvatar.await()} ${System.currentTimeMillis()}")
    }
}

// log
main: set place holder: 1718614154076
DefaultDispatcher-worker-1: get User Profile
main: set user name: userName 1718614155100
DefaultDispatcher-worker-1: get Image
main: set avatar: avatar
main: log record
DefaultDispatcher-worker-1: render Image
main: set avatar: avatar with border and shadow 1718614155104

// 因为 IO 和 Default 复用线程池,所以这里我们把线程池的名字优化一下方便和下面对比
main: set place holder: 1718614154076
io: get User Profile
main: set user name: userName 1718614155100
io: get Image
main: set avatar: avatar
main: log record
DefaultDispatcher-worker-1: render Image
main: set avatar: avatar with border and shadow 1718614155104

接下来是我们自己的协程:

kotlin 复制代码
// CustomCoroutine.kt
fun main() {
    val coroutineScope = MyCoroutineScope(MyDispatchers.mainDispatcher)

    coroutineScope.launch {
        when (state) {
            1 -> {
                printlnWithThread("set place holder: ${System.currentTimeMillis()}")
                delay(myCoroutineContext, 1000L, this)
            }

            2 -> withContext(MyDispatchers.ioDispatcher, this) { getUserProfile() }
            3 -> {
                val userProfile = it as Pair<String, String>
                printlnWithThread("set user name: ${userProfile.first} ${System.currentTimeMillis()}")
                withContext(MyDispatchers.ioDispatcher, this) { getImageBy(userProfile.second) }
            }

            4 -> {
                val avatar = it as String
                printlnWithThread("set avatar: $avatar")
                val deferredCoroutine = async(MyDispatchers.defaultDispatcher, this) { renderImage(avatar) }
                printlnWithThread("log record")
                deferredCoroutine.await()
            }

            5 -> {
                val finalAvatar = it as String
                printlnWithThread("set avatar: $finalAvatar ${System.currentTimeMillis()}")
            }
        }
    }
}

// log
main: set place holder: 1718614565795
io: get User Profile
main: set user name: userName 1718614566830
io: get Image
main: set avatar: avatar
main: log record
default: render Image
main: set avatar: avatar with border and shadow 1718614566832

从日志可以看出,两者效果基本等价。从代码风格上看两者大体一致。从代码量上看我们自己的协程手动增加了状态机,以及出于可读性增加了赋值操作外,两者大体相同。如果对于上面的状态机为什么这样划分有疑问的话,请先阅读异步变同步篇。我们先回到本例,这到底如何实现呢,下面就跟着笔者的思路,我们一起来实现自己的协程。

实现

CoroutineScope

我们就从协程代码的入口 CoroutineScope 开始,CoroutineScope 非常简单,内部只有一个 CoroutineContext,在这里我们只用到了 CoroutineContext 的 Dispatcher 实现,在前面的文章中我们已经知道 Dispacher 最终会把任务分发到线程池,所以我们的 CoroutineScope 实现也就有了:

kotlin 复制代码
class MyCoroutine {
    var myCoroutineContext: ScheduledExecutorService = MyDispatchers.defaultDispatcher
}

我们顺便看一下 Dispatcher 的实现:

kotlin 复制代码
object MyDispatchers {
    val mainDispatcher = ScheduledThreadPoolExecutor(1, ThreadFactory { Thread(it, "main") })
    val defaultDispatcher = ScheduledThreadPoolExecutor(4, ThreadFactory { Thread(it, "default") })
    val ioDispatcher = ScheduledThreadPoolExecutor(8, ThreadFactory { Thread(it, "io") })
}

实现方式在前面的线程池篇有涉及,这里不再赘述。

launch

接下来我们实现 CoroutineScope 最常用到的 launch 方法,简单起见,这里 launch 方法只接收一个参数,一个 block,这个 block 用于构建协程要运行的内容,所以这个 block 参数有一个 Coroutine receiver,其实现如下:

kotlin 复制代码
fun MyCoroutineScope.launch(block: MyCoroutine.() -> Unit) {
    val myCoroutine = object : MyCoroutine() { override fun run() = block() }
    this.myCoroutineContext.dispatch(myCoroutine)
}

我们用 block 构造了一个 MyCoroutine,然后用 MyCoroutineScope 的 myCoroutineContext 对 myCoroutine 做了 dispatch,我们再来看看 dispatch 方法:

kotlin 复制代码
fun ScheduledExecutorService.dispatch(myCoroutine: MyCoroutine) {
    execute {
        myCoroutine()
    }
}

我们为 ScheduledExecutorService 扩展了 dispatch 方法,其内部就是使用了线程池 的方法执行了 myCoroutine。这样我们就完成了用 coroutineScope 的 coroutineContext 运行 coroutine 的任务了。

delay

接下来我们看看 delay 方法的延迟是如何实现的:

kotlin 复制代码
// 调用
delay(myCoroutineContext, 1000L, this)

private fun delay(
    myCoroutineContext: ScheduledExecutorService,
    delay: Long,
    continuation: Continuation
) {
    myCoroutineContext.schedule({
        continuation.resume()
    }, delay, TimeUnit.MILLISECONDS)
}

我们直接借用了 ScheduledExecutorService 的 schedule 方法完成了 delay,这个 continuation 是就是传入的 this,即 MyCoroutine。delay 之后需要恢复 ,恢复时我们调用了 continuation.resume() 方法。

resume

我们知道在 Kotlin 协程中我们可以把任务流转到 coroutineContext 所指定的线程上,上面我们已经完成了这个工作。同时,我们也知道我们的任务可以被流转回来,我们下面就来看看 resume 机制如何实现:

kotlin 复制代码
// Continuation
interface Continuation {
    fun resume()
}

// MyCoroutine
class MyCoroutine: Continuation {
    var myCoroutineContext: ScheduledExecutorService = MyDispatchers.defaultDispatcher
    override fun resume() {
        myCoroutineContext.execute(this)
    }
}

这里我们抽象出一个 Continuation 接口,并让 MyCoroutine 实现,实现的方式就是用 MyCoroutine 自己的 myCoroutineContext 来执行自己(的不同阶段),这样我们就把任务重新流转回到了 MyCoroutine 自带的 MyCoroutineContext了,在异步变同步篇中我们提到了协程在挂起和恢复之间执行了不同的阶段 ,其利用的就是 Kotlin 协程框架自动生成的状态机,下面我们就来手动实现它。

状态机

出于简单考虑,很多实现没有完全跟 Kotlin 的协程完全一致,我们的实现把状态集成到了 MyCoroutine 内部:

kotlin 复制代码
class MyCoroutine: Continuation {
    var state = 1
    var myCoroutineContext: ScheduledExecutorService = MyDispatchers.defaultDispatcher
    override fun resume() {
        state++
        myCoroutineContext.execute(this)
    }
}

我们定义了一个 state 字段,并设置了初始值,在每次调用 resume 时,自动把 state 状态 ++,这样我们的状态机就生效了。这种 resume 再次传递执行 Continuation(在这里是this) 的风格就叫做 CPS :Continuation-passing style.

执行 Coroutine

我们的 Coroutine 是可执行的,即可以通过 myCoroutine() 直接调用的,为了实现这一点,我们需要重写其 invoke() 方法,为了获得 invoke 方法,这里我们使用如下方式:

kotlin 复制代码
// Executable
typealias Executable = () -> Unit

// MyCoroutine
abstract class MyCoroutine : Continuation, Executable {
    var state = 1
    var myCoroutineContext: ScheduledExecutorService = MyDispatchers.defaultDispatcher
    override fun resume(any: Any) {
        state++
        myCoroutineContext.submit { this(any) }
    }
}

// launch
fun MyCoroutineScope.launch(
    myCoroutineContext: ScheduledExecutorService? = null,
    block: MyCoroutine.() -> Unit
) {
    val myCoroutine = object : MyCoroutine() {
      	// overide
        override fun invoke() = block()
    }
    his.myCoroutineContext.dispatch(myCoroutine)
}

我们定义一个了一个 Executable 接口类型,并让 MyCoroutine 实现这个接口类型,因为要执行的内容是在使用时再确定的,在 MyCoroutine 中我们并不实现这个方法,所以我们让 MyCoroutine 保持 abstract,并且在 launch 方法中创建 MyCoroutine 的实例时重写 invoke 方法并调用传入的 block,这样我们就可以通过 myCoroutine() 直接调用来执行 MyCoroutine 了。

至此,我们的状态机已经可以运转起来了,直到执行到分支 3:

kotlin 复制代码
// CustomCoroutine.kt
fun main() {
    val coroutineScope = MyCoroutineScope(MyDispatchers.mainDispatcher)

    coroutineScope.launch {
        when (state) {
            1 -> {
                printlnWithThread("set place holder: ${System.currentTimeMillis()}")
                delay(myCoroutineContext, 1000L, this)
            }

            2 -> withContext(MyDispatchers.ioDispatcher, this) { getUserProfile() }
            3 -> {
              	// it 从哪里来?
                val userProfile = it as Pair<String, String>
                printlnWithThread("set user name: ${userProfile.first} ${System.currentTimeMillis()}")
                withContext(MyDispatchers.ioDispatcher, this) { getImageBy(userProfile.second) }
            }
          	...
        }
    }
}

withContext

当我们从异步的任务中恢复得到返回一个值时(CoroutineSample part3),我们就需要 withContext 了。为此,我们需要对 Continuation 的 resume 方法做一系列的改造,让其可以传递一个值,并且在用于构造 MyCoroutine 的 block 里增加这个值,下面是具体的改动:

kotlin 复制代码
// Continuation
interface Continuation {
    fun resume(any: Any)
}

// Executable
typealias Executable = (Any) -> Any

// MyCoroutine
abstract class MyCoroutine : Continuation, Executable {
    var state = 1
    var myCoroutineContext: ScheduledExecutorService = MyDispatchers.defaultDispatcher
    override fun resume(any: Any) {
        state++
        myCoroutineContext.execute { this(any) }
    }
}

// launch
fun MyCoroutineScope.launch(
    myCoroutineContext: ScheduledExecutorService? = null,
    block: MyCoroutine.(Any) -> Any
) {
    val myCoroutine = object : MyCoroutine() {
        override fun invoke(any: Any): Any = block(any) // any is it
    }
    myCoroutineContext.dispatch(myCoroutine)
}

这样,我们的 MyCoroutine 的 resume 方法就可以接受一个值作为参数,当我们调用 launch 并构造一个 Coroutine 时就可以为 receiver 增加一个参数,这样上面 it 从哪里来的问题我们就解决了。

有了可以携带参数的 resume 方法,withContext 的实现就非常简单了:

kotlin 复制代码
// withContext
private fun withContext(
    dispatcher: ScheduledExecutorService,
    continuation: Continuation,
    executable: Executable
) {
    dispatcher.execute {
        val result = executable(Unit)
        continuation.resume(result)
    }
}

// use
withContext(MyDispatchers.ioDispatcher, this) { getUserProfile() }

通过 dispatcher 执行 executable 的内容,得到 result,在调用 continuation.resume 时传入 result 即可。

这样,我们的状态机可以运行到分支 4 了:

kotlin 复制代码
// CustomCoroutine.kt
fun main() {
    val coroutineScope = MyCoroutineScope(MyDispatchers.mainDispatcher)

    coroutineScope.launch {
        when (state) {
            1 -> {
                printlnWithThread("set place holder: ${System.currentTimeMillis()}")
                delay(myCoroutineContext, 1000L, this)
            }
            2 -> withContext(MyDispatchers.ioDispatcher, this) { getUserProfile() }
            3 -> {
                val userProfile = it as Pair<String, String>
                printlnWithThread("set user name: ${userProfile.first} ${System.currentTimeMillis()}")
                withContext(MyDispatchers.ioDispatcher, this) { getImageBy(userProfile.second) }
            }
            4 -> {
                val avatar = it as String
                printlnWithThread("set avatar: $avatar")
                val deferredCoroutine = async(MyDispatchers.defaultDispatcher, this) { renderImage(avatar) }
              	// 为什么 async 不像 withContext 一样从上面开始挂起?
                printlnWithThread("log record")
                deferredCoroutine.await()
            }
            ...
        }
    }
}

async

为什么 async 不像 withContext 一样从 async 调用点开始挂起?难道 async 的实现跟和 withContext 有很大差别?让我们看看我们的 async 的实现:

kotlin 复制代码
// async
private fun async(
    myCoroutineContext: ScheduledExecutorService,
    delegate: MyCoroutine,
    executable: Executable
): MyDeferred {
    // 1. defer
    val myDeferred = MyDeferred()
    myDeferred.delegate = delegate
    myDeferred.myCoroutineContext = myCoroutineContext

    // 2. execute async
    myCoroutineContext.execute {
        val result = executable(Unit)
        myDeferred.result = result
        myDeferred.isCompleted = true
    }

    // 3. return myDeferred
    return myDeferred
}

看起来确实跟 withContext 很不一样。大体分为三步,创建 deferred ,然后异步 (同时)执行 async 的闭包,紧接着立马返回了这个 defer,这时闭包大概率还未执行完成。我们拿到 defer 之后调用其 await 函数,等待其完成。事实上,在Kotlin 协程中,async 并非 suspend 函数,await 才是,所以挂起点不在 async,而在 wait。接下来我们就来看看 Deferred 的实现,以及其等待的细节:

kotlin 复制代码
// MyDeferred
class MyDeferred {
    lateinit var delegate: MyCoroutine
    lateinit var myCoroutineContext: ScheduledExecutorService
    var isCompleted = false
    var result: Any? = null

    fun await() {
        if (!isCompleted) {
            myCoroutineContext.submit { await() }
        } else {
            return delegate.resume(result!!)
        }
    }
}

MyDeferred 有几个属性,一个是用于控制 async 的闭包是否完成的 isCompleted 变量,一个是完成后保存结果的 result 变量,另外一个是 async 传入的 dispacher 变量 myCoroutineContext,还有一个是用于 resume 的 MyCoroutine 变量 delegate。这里的 Deferred 可以看作是我们在线程池篇中讲到的 Future,不过 Future 是阻塞 的,而协程框架中的 Deferred 依然是非阻塞 的(EventLoop机制)。

在 await 内部,如果状态是未完成,会立马把一个新的任务加到 myCoroutineContext 中,并不会等待,也不会阻塞 myCoroutineContext 对应的线程,直到在 async 中的闭包完成任务之后把 isCompleted 置为 true,这样,在下一次执行到 await 函数时 await 就会调用 delegate.resume(result!!) 将 state ++,然后回到我们状态机中的最后一个分支。自此,我们自定义的协程也能够顺利跑完了,再来看看我们的协程使用的完整样子:

kotlin 复制代码
fun main() {
    val coroutineScope = MyCoroutineScope(MyDispatchers.mainDispatcher)

    coroutineScope.launch {
        when (state) {
            1 -> {
                printlnWithThread("set place holder: ${System.currentTimeMillis()}")
                delay(myCoroutineContext, 1000L, this)
            }

            2 -> withContext(MyDispatchers.ioDispatcher, this) { getUserProfile() }
            3 -> {
                val userProfile = it as Pair<String, String>
                printlnWithThread("set user name: ${userProfile.first} ${System.currentTimeMillis()}")
                withContext(MyDispatchers.ioDispatcher, this) { getImageBy(userProfile.second) }
            }

            4 -> {
                val avatar = it as String
                printlnWithThread("set avatar: $avatar")
                val deferredCoroutine = async(MyDispatchers.defaultDispatcher, this) { renderImage(avatar) }
                printlnWithThread("log record")
                deferredCoroutine.await()
            }

            5 -> {
                val finalAvatar = it as String
                printlnWithThread("set avatar: $finalAvatar ${System.currentTimeMillis()}")
            }
        }
    }
}

最后我再次借用一下之前的一张图来表示这个协程调用的过程:

协程执行过程

总结

线程,线程池,EventLoop,Future 是异步的基础,也是 Kotlin 协程的骨架 。结构化并发设计,挂起与恢复机制,语言级的状态机是 Kotlin 协程的灵魂,在前面 5 篇我们分别从骨架讲到灵魂,然后本篇我们综合利用这些知识构建了一个自己的极简版协程,灵肉自此合一,相信在看完这几篇文章之后我可以说你已经彻底掌握了 Kotlin 协程。

历时近两个月,到这里,这个协程系列的主要部份就已经讲完了。不过,我们还没到说再见的时候,应部分读者的要求,系列后续会继续涵盖 Kotlin Flow 的内容,直到最后我们:全网唯一,手撸Flow 😄。下一篇我们从 Flow 基础开始。

示例源码github.com/chdhy/kotli...
练习 :利用前面讲到的知识点,构建一个自己的协程 Demo,其最终运行效果类似于本文的例子

点赞👍 文章,关注❤️ 笔者,获取后续文章更新

  1. 「最后一次,彻底搞懂kotlin协程」(一) | 先回到线程
  2. 「最后一次,彻底搞懂kotlin协程」(二) | 线程池,Handler,Coroutine
  3. 「最后一次,彻底搞懂kotlin协程」(三) | CoroutineScope,CoroutineContext,Job: 结构化并发
  4. 「最后一次,彻底搞懂kotlin协程」(四) | suspend挂起,EventLoop恢复:异步变同步的秘密
  5. 「最后一次,彻底搞懂kotlin协程」(五) | Dispatcher 与 Kotlin 协程中的线程池
  6. 「最后一次,彻底搞懂kotlin协程」(六) | 全网唯一,手撸协程!
相关推荐
长亭外的少年7 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
JIAY_WX7 小时前
kotlin
开发语言·kotlin
建群新人小猿10 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
1024小神11 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri
兰琛11 小时前
20241121 android中树结构列表(使用recyclerView实现)
android·gitee
Y多了个想法12 小时前
RK3568 android11 适配敦泰触摸屏 FocalTech-ft5526
android·rk3568·触摸屏·tp·敦泰·focaltech·ft5526
NotesChapter13 小时前
Android吸顶效果,并有着ViewPager左右切换
android
_祝你今天愉快14 小时前
分析android :The binary version of its metadata is 1.8.0, expected version is 1.5.
android
暮志未晚Webgl14 小时前
109. UE5 GAS RPG 实现检查点的存档功能
android·java·ue5
麦田里的守望者江14 小时前
KMP 中的 expect 和 actual 声明
android·ios·kotlin