【KT-C-1】Kotlin 协程的创建方法

1,868 阅读5分钟

Tips 0: 为简化表述,本文中「协程」特指「Kotlin 协程」。

协程是什么-ver2.0

上一篇文章做了很多空洞的理论阐述,来说明协程在概念上应该如何理解,但我们很难把概念直接用作开发实践的指导。所以正式使用协程之前,我们还需要理解代码的世界中协程是什么。

Kotlin 面向对象的编程语言,线程可以直接对应成 Thread 对象,但协程中并没有 Coroutine 类或接口给我们使用,使用协程不是很贴近老 Java 人的直觉,是有一定学习成本的。(追到源码中看的话还是能找到 AbstractCoroutine 的类型的,是标记为 InternalCoroutinesApi 的,不应该在外部使用)

在实际使用中,协程是一段代码,在挂起和恢复之间运行。协程的生命周期就是从挂起运行代码到正常恢复或者异常恢复结束的过程。

协程的生命周期

粗略来看,协程有三种状态(协程运行的过程中状态不经常改变,其实可以更加细分)

image.png

看起来有些麻烦,但也完全不麻烦,随着使用的深入,会自然而然地记住的。现在我们只看如何创建协程,新创建的协程在 Incomplete 状态。

创建协程

从 Hello World 开始,我们都学会了 GlobalScope.launch { } 的方式创建一个协程,本节内容就从 launch 开始。

coroutineScope.launch 用于创建无返回值的协程,在代码执行结束后协程就自动结束了。launch 函数的返回值是 Job 类型,可以通过 Job 获取协程的状态、启动和取消协程以及监听协程执行完毕。

coroutineScope.async 是另一种创建协程的方式,用于创建有返回值的协程,需要主动调用 await() 获取结果后结束。async 的返回值是 Deferred<T 类型,比起 Job 增加了 await 函数。

launch 和 async 的参数相同,都是 CoroutineContext、CoroutineStart 和构成协程内容的 block。CoroutineContext 是协程运行环境,包含了各种协程需要的信息,暂且不展开叙述了。CoroutineStart 用来控制创建的协程如何启动,默认值的 CoroutineStart.DEFAULT 表示立刻执行,所以大部分示例代码中并不需要主动调用 start 开始协程。

举两个简单的栗子看一下吧,首先是一个非常有趣的排序算法「睡眠排序」的协程版本。

private fun sort(nums: IntArray) {
    nums.forEach {
        lifecycleScope.launch {
            delay(it * 10L) // 1ms 容易出现误差,1s 又太久了,折中
            Log.w("CoroutineSampleActivity", "sort $it")
        }
    }
}

// test
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    sort(intArrayOf(10, 4, 2, 1, 9, 3, 5, 8, 6, 7))
}

image.png

我在循环中启动 N 个协程,替代了原算法中的创建 Thread。把 Thread.sleep() 优化成 delay() 应该有一个性能的突破,建议把协程版本命名为「延迟排序」。launch 创建的协程在代码执行完毕之后就不用管了,协程也不需要手动释放。

再看一个 async 的例子吧,这次选一个有用的实践代码,我们读取一个 asset 的文件,获取内容字符串。文件 IO 属于耗时操作,不应该在 UI 线程进行,异步处理正是协程要解决的痛点,看代码之前可以先思考一下直接创建线程的写法应该是什么样的。

private suspend fun loadFile(assetName: String): String{
    val config = lifecycleScope.async(Dispatchers.IO) {
        val inputStream = assets.open(assetName)
        return@async inputStream.string() // 是自定义的普通扩展函数,与协程无关
    }
    return config.await()
}

// test
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    lifecycleScope.launch {
        val config = loadFile("config.json")
        Log.w("CoroutineSampleActivity", "loadFile $config")
    }
}

image.png

文件是我随便写的,读到了就好。用协程代替回调和 handler 跨线程传消息,代码确实简洁了很多。

从实践中理解协程和线程

回顾上面的例子,代码到底在哪些线程呢?读文件的例子中,我们为了不妨碍 UI 线程指定了 CoroutineContext 为 Dispatchers.IO,排序的例子则没有明确提过线程,协程的内容代码到底是在哪个线程执行的呢?这个可以通过加 Log 的方式查看。

image.png

image.png

读文件在一个 worker 线程,其他代码都在 main 线程。看得出来,协程中的代码到底在哪个线程执行其实是由 Dispatcher 控制的,如果示例2的代码中不指定 Dispatchers.IO 的话就影响到 UI 线程了。

涉及并发编程的时候,我们必须考虑线程安全的问题,在使用协程的时候也同样需要注意,敲代码的同时就要在心里梳理好每一行代码应该在哪个线程执行。也就是说,使用协程并不意味着不需要理解线程原理。

在协程的官网介绍中,有一段关于协程「轻量」的描述:

import kotlinx.coroutines.*

//sampleStart
fun main() = runBlocking {
    repeat(100_000) { // launch a lot of coroutines
        launch {
            delay(5000L)
            print(".")
        }
    }
}
//sampleEnd

意思是创建十万个协程同时运行也不会对系统造成负担,每个协程都会在 delay 5 秒之后完成打印自动结束。如果同时创建十万个线程,就几乎不可能顺利运行。我目前并不认可官方的观点,这种对比的确体现了 Kotlin 中协程与线程的区别,只不过不太能证明「轻量」。

从上面实验和之前的理论知识来看,delay 不会阻塞当前线程,通过 launch 启动的十万协程 delay 后应该都在同一线程输出。我们简化一下,就用 100 个协程加日志看看效果。

image.png

流畅运行。

回到原来的例子,100000 个线程和 2 个线程比较协程当然轻松取胜。但实际项目中没人这样滥用线程,实现同样的功能我们也可以用 2 个线程实现,协程的优势还是代码更好写。

中场休息

创建协程并执行已经能实现一些功能了,但还算不上可以使用协程,上面简单略过的 CoroutineContext、CoroutineScope、Job 等都需要更加深入理解。另外从实践代码中我们都能明显看到 suspend 的地方,但还未遇到本应成对出现 resume,究竟是怎么回事呢?欢迎关注后续文章。

(一直咕咕咕有点怕了自己了,尝试一个激励机制,点赞+评论超过 20 下一篇就在发布时间起 7 日内更新)