协程
协程
协程的基本用法
Kotlin并没有将协程纳入标准库的API当中,而是以依赖库的形式提供的。所以如果我们想要使 用协程功能,需要先在app/build.gradle文件当中添加如下依赖库:
dependencies { |
第二个依赖库是在Android项目中才会用到的,本节我们编写的代码示例都是纯Kotlin程序,所 以其实用不到第二个依赖库。但为了下次在Android项目中使用协程时不再单独进行说明,这里就一同引入进来了。
接下来创建一个CoroutinesTest.kt文件,并定义一个main()函数,然后开始我们的协程之旅 吧。
首先我们要面临的第一个问题就是,如何开启一个协程?最简单的方式就是使用 Global.launch函数,如下所示:
fun main(){ |
GlobalScope.launch函数可以创建一个协程的作用域,这样传递给launch函数的代码块 (Lambda表达式)就是在协程中运行的了,这里我们只是在代码块中打印了一行日志。那么 现在运行main()函数,日志能成功打印出来吗?如果你尝试一下,会发现没有任何日志输出。
这是因为,Global.launch函数每次创建的都是一个顶层协程,这种协程当应用程序运行结束 时也会跟着一起结束。刚才的日志之所以无法打印出来,就是因为代码块中的代码还没来得及 运行,应用程序就结束了。
要解决这个问题也很简单,我们让程序延迟一段时间再结束就行了,如下所示:
fun mian(){ |
这里使用Thread.sleep()方法让主线程阻塞1秒钟,现在重新运行程序,你会发现日志可以 正常打印出来了
可是这种写法还是存在问题,如果代码块中的代码在1秒钟之内不能运行结束,那么就会被强制 中断。观察如下代码:
fun main() { |
我们在代码块中加入了一个delay()函数,并在之后又打印了一行日志。delay()函数可以让 当前协程延迟指定时间后再运行,但它和Thread.sleep()方法不同。delay()函数是一个非 阻塞式的挂起函数,它只会挂起当前协程,并不会影响其他协程的运行。而Thread.sleep() 方法会阻塞当前的线程,这样运行在该线程下的所有协程都会被阻塞。注意,delay()函数只 能在协程的作用域或其他挂起函数中调用。
这里我们让协程挂起1.5秒,但是主线程却只阻塞了1秒,最终会是什么结果呢?重新运行程 序,你会发现代码块中新增的一条日志并没有打印出来,因为它还没能来得及运行,应用程序 就已经结束了。
那么有没有什么办法能让应用程序在协程中所有代码都运行完了之后再结束呢?当然也是有 的,借助runBlocking函数就可以实现这个功能:
fun mian(){ |
runBlocking函数同样会创建一个协程的作用域,但是它可以保证在协程作用域内的所有代码 和子协程没有全部执行完之前一直阻塞当前线程。需要注意的是,runBlocking函数通常只应 该在测试环境下使用,在正式环境中使用容易产生一些性能上的问题。
虽说现在我们已经能够让代码在协程中运行了,可是好像并没有体会到什么特别的好处。这是 因为目前所有的代码都是运行在同一个协程当中的,而一旦涉及高并发的应用场景,协程相比 于线程的优势就能体现出来了。
那么如何才能创建多个协程呢?很简单,使用launch函数就可以了,如下所示:
fun main(){ |
注意这里的launch函数和我们刚才所使用的GlobalScope.launch函数不同。首先它必须在 协程的作用域中才能调用,其次它会在当前协程的作用域下创建子协程。子协程的特点是如果 外层作用域的协程结束了,该作用域下的所有子协程也会一同结束。相比而言, GlobalScope.launch函数创建的永远是顶层协程,这一点和线程比较像,因为线程也没有层 级这一说,永远都是顶层的。
可以看到,两个子协程中的日志是交替打印的,说明它们确实是像多线程那样并发运行的。然 而这两个子协程实际却运行在同一个线程当中,只是由编程语言来决定如何在多个协程之间进 行调度,让谁运行,让谁挂起。调度的过程完全不需要操作系统参与,这也就使得协程的并发 效率会出奇得高。
那么具体会有多高呢?我们来做下实验就知道了,代码如下所示:
fun main() { |
不过,随着launch函数中的逻辑越来越复杂,可能你需要将部分代码提取到一个单独的函数 中。这个时候就产生了一个问题:我们在launch函数中编写的代码是拥有协程作用域的,但是 提取到一个单独的函数中就没有协程作用域了,那么我们该如何调用像delay()这样的挂起函 数呢?
为此Kotlin提供了一个suspend关键字,使用它可以将任意函数声明成挂起函数,而挂起函数 之间都是可以互相调用的,如下所示:
suspend fun printDot(){ |
但是,suspend关键字只能将一个函数声明成挂起函数,是无法给它提供协程作用域的。比如 你现在尝试在printDot()函数中调用launch函数,一定是无法调用成功的,因为launch函 数要求必须在协程作用域当中才能调用。
这个问题可以借助coroutineScope函数来解决。coroutineScope函数也是一个挂起函数, 因此可以在任何其他挂起函数中调用。它的特点是会继承外部的协程的作用域并创建一个子协 程,借助这个特性,我们就可以给任意挂起函数提供协程作用域了。示例写法如下:
suspend fun printDot() = coroutineScope{ |
另外,coroutineScope函数和runBlocking函数还有点类似,它可以保证其作用域内的所 有代码和子协程在全部执行完之前,外部的协程会一直被挂起。我们来看如下示例代码:
fun main() { |
更多的作用域构建器
协程要怎样取消呢?不管是GlobalScope.launch函数还是launch函数,它们都会返回 一个Job对象,只需要调用Job对象的cancel()方法就可以取消协程了,如下所示:
val job = GlobalScope.launch { |
但是如果我们每次创建的都是顶层协程,那么当Activity关闭时,就需要逐个调用所有已创建协 程的cancel()方法,试想一下,这样的代码是不是根本无法维护?
因此,GlobalScope.launch这种协程作用域构建器,在实际项目中也是不太常用的。下面我 来演示一下实际项目中比较常用的写法:
val job = Job() |
可以看到,我们先创建了一个Job对象,然后把它传入CoroutineScope()函数当中,注意这 里的CoroutineScope()是个函数,虽然它的命名更像是一个类。CoroutineScope()函数 会返回一个CoroutineScope对象,这种语法结构的设计更像是我们创建了一个 CoroutineScope的实例,可能也是Kotlin有意为之的。有了CoroutineScope对象之后,就 可以随时调用它的launch函数来创建一个协程了。
现在所有调用CoroutineScope的launch函数所创建的协程,都会被关联在Job对象的作用域 下面。这样只需要调用一次cancel()方法,就可以将同一作用域内的所有协程全部取消,从而大大降低了协程管理的成本。
不过相比之下,CoroutineScope()函数更适合用于实际项目当中,如果只是在main()函数 中编写一些学习测试用的代码,还是使用runBlocking函数最为方便。
协程的内容确实比较多,下面我们还要继续学习。你已经知道了调用launch函数可以创建一个 新的协程,但是launch函数只能用于执行一段逻辑,却不能获取执行的结果,因为它的返回值 永远是一个Job对象。那么有没有什么办法能够创建一个协程并获取它的执行结果呢?当然有, 使用async函数就可以实现。
async函数必须在协程作用域当中才能调用,它会创建一个新的子协程并返回一个Deferred对 象,如果我们想要获取async函数代码块的执行结果,只需要调用Deferred对象的await() 方法即可,代码如下所示:
fun main(){ |
这里我们在async函数的代码块中进行了一个简单的数学运算,然后调用await()方法获取运 算结果,最终将结果打印出来。重新运行一下代码,结果如图11.16所示。
不过async函数的奥秘还不止于此。事实上,在调用了async函数之后,代码块中的代码就会 立刻开始执行。当调用await()方法时,如果代码块中的代码还没执行完,那么await()方法 会将当前协程阻塞住,直到可以获得async函数的执行结果。
为了证实这一点,我们编写如下代码进行验证:
fun main() { |
这里连续使用了两个async函数来执行任务,并在代码块中调用delay()方法进行1秒的延迟。 按照刚才的理论,await()方法在async函数代码块中的代码执行完之前会一直将当前协程阻 塞住,那么为了便于验证,我们记录了代码的运行耗时。
可以看到,整段代码的运行耗时是2032毫秒,说明这里的两个async函数确实是一种串行的关 系,前一个执行完了后一个才能执行。
但是这种写法明显是非常低效的,因为两个async函数完全可以同时执行从而提高运行效率。 现在对上述代码使用如下的写法进行修改:
fun main() { |
现在我们不在每次调用async函数之后就立刻使用await()方法获取结果了,而是仅在需要用 到async函数的执行结果时才调用await()方法进行获取,这样两个async函数就变成一种并 行关系了。
最后,我们再来学习一个比较特殊的作用域构建器:withContext()函数。withContext() 函数是一个挂起函数,大体可以将它理解成async函数的一种简化版写法,示例写法如下:
fun main() { |
我来解释一下这段代码。调用withContext()函数之后,会立即执行代码块中的代码,同时将 外部协程挂起。当代码块中的代码全部执行完之后,会将最后一行的执行结果作为 withContext()函数的返回值返回,因此基本上相当于val result = async{ 5 + 5 }.await()的写法。唯一不同的是,withContext()函数强制要求我们指定一个线程参数, 关于这个参数我准备好好讲一讲。
你已经知道,协程是一种轻量级的线程的概念,因此很多传统编程情况下需要开启多线程执行 的并发任务,现在只需要在一个线程下开启多个协程来执行就可以了。但是这并不意味着我们 就永远不需要开启线程了,比如说Android中要求网络请求必须在子线程中进行,即使你开启了 协程去执行网络请求,假如它是主线程当中的协程,那么程序仍然会出错。这个时候我们就应 该通过线程参数给协程指定一个具体的运行线程。
线程参数主要有以下3种值可选:Dispatchers.Default、Dispatchers.IO和 Dispatchers.Main。Dispatchers.Default表示会使用一种默认低并发的线程策略,当 你要执行的代码属于计算密集型任务时,开启过高的并发反而可能会影响任务的运行效率,此 时就可以使用Dispatchers.Default。Dispatchers.IO表示会使用一种较高并发的线程策 略,当你要执行的代码大多数时间是在阻塞和等待中,比如说执行网络请求时,为了能够支持 更高的并发数量,此时就可以使用Dispatchers.IO。Dispatchers.Main则表示不会开启 子线程,而是在Android主线程中执行代码,但是这个值只能在Android项目中使用,纯Kotlin 程序使用这种类型的线程参数会出现错误。