Kotlin 协程学习

这几天跟着各大博文来学习协程,总结一篇,日后维护。

1. 协程概念

1.1 协程和线程

协程在Kotlin中的写法是 Coroutines,它并不是Kotlin中特有的概念,早在Go语言中,就已经有了协程的Api。
所以它本身不是局限在特定的语言中,而是一种编程的思想:建立多个子任务来协助程序运行,即协作式多任务

那这个概念不就和线程差不多嘛?这里列一下他们的主要的异同:

  • 我们的代码都是基于线程上运行的,而线程是基于进程运行。而协程则是在线程上运行的,它可以在主线程,可以子线程上运行。即 1进程->n线程,1线程->n协程
  • 线程和操作系统直接联系,我们知道线程是CPU程序执行的最小单位,它的运行是受到操作系统调度。而协程是运行在线程上的。
  • 在Android中,我们不能在非UI线程更新UI,否则会报错,而主线程上的协程也不例外,所以这种场景还是要切换一下的

在Kotlin里,协程就是一套封装了线程的Api,如同我们用过的 Java的Executor、Android的AsynTask,它可以让我们更优美的写并发操作。
虽然说协程和线程概念不一样,但是在Android开发中,这两者就是一样的,因为语言面向JVM编程,最终都转化成了字节码,所以协程不可能做到线程做不到的事情。

1.2 使用协程的好处

只有一点:

将异步的代码写成同步的形式,解决(地狱)回调带来的麻烦。

可以看下下面的代码,我们需要等待多层网络请求然后展示结果,可以这么写

// (代码来自Hencoder)(网络之间存在依赖)
coroutineScope.launch(Dispatchers.Main) {       // 开始协程:主线程
    val token = api.getToken()                  // 网络请求:IO 线程
    val user = api.getUser(token)               // 网络请求:IO 线程
    nameTv.text = user.name                     // 更新 UI:主线程
}

大家再脑补一下Java的回调式写法,是不是感觉差别巨大呢?

再来看个场景,我们需要等待多个网络请求,等到所有请求结束之后,对UI进行更新,如果是Java的回调式写法,那么我们可能会这么写:

api.getAvatar(user) { avatar ->
    api.getCompanyLogo(user) { logo ->
        show(merge(avatar, logo))
    }
}

getAvatar 和 getCompanyLogo本来可以并行的请求,却被强制通过实串行来实现,会导致等待时间长了一倍,也就是性能慢了一倍。
而如果java要实现并行的方式,我们可能又要通过 标志位、轮询 来查看两个网络请求是否走完,虽然这样性能不会慢多少,但是会让代码变得很恶心。

而Koltin协程可以这么写,让两者进行合并:

coroutineScope.launch(Dispatchers.Main) {
    val avatar = async { api.getAvatar(user) }    // 获取用户头像
    val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
    val merged = suspendingMerge(avatar, logo)    // 合并结果
    show(merged) // 更新 UI
}

代码变得十分简单优雅~

这就是传说中的 非阻塞式挂起的魅力。这个概念很后面再说。

注:它和Dart语言中的那个不一样哦。
因为其使用lambda表达式,所以代码风格简洁,效率又高,下面一节可读可不读。

1.3 Lambda表达式和匿名内部类的区别

该节可读可不读。

注:Kotlin出现时,Java还在6/7,这也是为什么Kotlin从一开始就被提倡应用于Android开发的一个小小的原因(因为Java还没有Lambda表达式)

在使用协程(以及很大一部分代码块)的时候,我们经常会用到Lambda表达式。
我们为什么用它?

  • 简洁好写
  • 减少样板代码

但是你以为这就是它的好处吗?我之前一直搞不懂为什么仅仅是实现一个函数式编程,却能成为Java8新特性中必须要吹、必须要学的知识,而究其原因其实是:
Lambda函数(如果不捕获)在运行时产生,在使用时作为单例对象(私有方法)存在,重用时不会产生其他开销,而匿名内部类则在每次调用都要加载并实例化成一个对象。

这句话有点高度概括。
需要知道Java是如何去编译匿名内部类的,在我之前学习热修复的时候热修复原理学习(3)编译器与语言特性的影响学习过 内部类在编译期会被编译为跟外部类一样的顶级类,匿名内部类也是内部类,所以它会走和类一样的加载-使用-垃圾回收的过程,而Lambda表达式则是在运行时产生一个调用的函数入口。

所以使用 Lambda表达式 是完爆 使用匿名内部类的。

这里的捕获指的是该Lambda使用了其作用域之外的变量,可以将其视为私有字段的类中的函数,而不捕获指的Lambda就是纯函数。

2. 开启协程的三个基础方法

在Kotlin本来的包中时没有协程api的,所以要在build.gradle中引入:

// build.gradle
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"

协程的入口是 launch{} 语句块,但它不是顶层函数,所以不能直接使用,有三种实现,分别来看看他们来实现Hello World

2.1 使用GlobalScope.launch实现

使用 GlobalScope.launch 可以手动开启一个协程:

fun main() {
    println("Thread name:" + Thread.currentThread().name)
    GlobalScope.launch {                       // 在后台启动一个新的协程并继续
        println("Coroutines Thread name:" + Thread.currentThread().name)
        delay(100L)                           // 假设这是一个100ms耗时任务
        println("World!")                     // 这是耗时任务的结果
    }
    println("Hello,")                         // 主线程中的代码会立即执行
    Thread.sleep(200L)                        // 延迟 200 毫秒来保证 JVM 的存活
}

// 打印:
Thread name:main
Hello,
Coroutines Thread name:DefaultDispatcher-worker-1
World!

在开启的协程中,我们使用 delay来让协程挂起100ms后答应world,表明这是一个耗时的任务。
主线程中输入了Hello后,使用Thread.sleep()来阻塞主线程,防止在协程的任务还没执行完时,主线程就关闭了。

这个时候我们会发现,协程所在的线程和外边线程不一致。

2.2 使用runBlocking实现

runBlocking{..}代码块里面,就是协程。相比于GlobalScope,它是阻塞线程的,一般开发中我们用不到,常用于单元测试,来看一下使用语句块实现:

fun main() {
    println("Thread name:" + Thread.currentThread().name)
    runBlocking {
        println("Coroutines Thread name:" + Thread.currentThread().name)
        delay(1000L)
        print("Hello, ")
    }
    print("World!")
}

// 打印结果:
Thread name:main
Coroutines Thread name:main
Hello, World

可以看到 runBlocking{...}开启了一个协程,但是他没有在新线程里面执行,而是一直阻塞到里面的代码块完成。

可以来看下下面的用法:

fun main() = runBlocking<Unit> {    // 使用runBlocking开启一个协程
    launch {          // 里面再开启一个协程
        delay(1000L)
        println("World! " + Thread.currentThread().name)
    }
    println("Hello, " + Thread.currentThread().name)
}

// 打印结果:
Hello, main
World! main

这个代码中,我们在 runBlocking{}里,又开启了一个launch协程,相当于大协程里面的小协程。
我们没有使用 Thread.sleep()来防止主线程执行完而launch中的代码还没有执行的问题,它依然能够等所有任务都执行完后才关闭程序。

上面两片代码可以得出runBlocking的两个结论

  1. runBlocking{}协程会产生线程阻塞,每当线程执行到这个代码块时,会等到代码块里的所有内容执行完,才会继续走下面的代码
  2. runBlocking{}协程里面可以加入其它的小协程。这个时候,可以把这个代码块看成一个线程的执行顺序(但本身不是线程)

2.3 使用CoroutineScope实现

在讲解 CoroutineScope之前,我们需要先搞清楚: CountineScopecountineScope{...}是两个东西

  • 前者可以创建一个协程作用域
  • 后者产生的是一个挂起函数

他们虽然名称相同,但是作用有很大的不同,我认为官方文档举的例子不是很好,让我饶了很大的弯路。有兴趣的可以读一下这篇:Coroutines: runBlocking vs coroutineScope

fun main(){
    val coroutineScope = CoroutineScope(Dispatchers.Unconfined)   // 1 创建一个CoroutineScope对象
    coroutineScope.launch {              // 使用该对象开启一个协程
        delay(100L)                      // 模拟一个100ms的延时任务
        println("World")
    }
    print("Hello, ")
    Thread.sleep(200L)     // 让主线程睡200MS以免主线程关闭
}

// 打印结果:
Hello, World

可以看到它和一开始的 GlobalScope.launch{..}的作用差不多,但它们是有许多差别的,在Android并不推荐使用 GlobalScope.launch{..},而是推荐使用 CoroutineScope{..},原因后面会讲到。

注释1中, CoroutineScope的构造函数里面传入的是 Dispatchers.Unconfined(不受限的工作在主线程中),它是一个 CoroutineContext类,它的作用是调度这个协程在哪个线程工作,和Android的Context不一样,叫这个名字是帮助我们去理解。所以这个Context被称为 协程上下文

因为平时经常会遇到线程切换的场景,我们可以利用这个协程上下文来实现线程的切换,在上面的代码中,模拟了一个我们做耗时任务的操作,那么接下来有个场景,我想在后台线程去做这个任务,做完之后把结果拿回主线程进行输出,那我们可能会这么写:

coroutineScope.launch(Dispatchers.IO) {
    ... // 做耗时任务
    launch(Dispatchers.Unconfined) {
        .. // 去到想要的线程做xxx
    }
}

这个时候就发现产生了嵌套!?

确实,如果只是使用launch{...},它本身的能力是有限的, 但是协程中却提供了一个很有帮助的函数:withContext(),这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行,看下下面的一个代码:

fun main() {
    val coroutineScope = CoroutineScope(Dispatchers.Unconfined)
    coroutineScope.launch(Dispatchers.Unconfined) {     // 在未指定线程中开启协程
        val text = withContext(Dispatchers.IO) {        // 开启一个子协程,并指定在I/O线程,去做一个耗时操作
             delay(200L)                     // 1 做一个200MS的耗时操作
            "World"
        }
        print(text)          
    }
    print("Hello, ")
    Thread.sleep(300L)   // 让主线程睡300MS,防止主线程关闭
}

// 打印结果:
Hello, World

如果说 withContext(){..}里面的代码很长,或者需要复用,我们需要将他们抽成一个方法,可以写成这样:

...
coroutineScope.launch(Dispatchers.Unconfined) {
    val text = withContext(Dispatchers.IO) {
            getText()
     }
}
...
suspend fun getText(): String  {
    delay(200L)
    return "World"
}

我们抽出了一个方法,并在前面加上了 suspend 关键字。
这个关键字是Kotlin协程中最为核心鹅关键字,它的中文意思是 [暂停] 或者 [挂起],它所代表的意义其实就是将当前协程任务在线程中挂起: 线程在执行到遇到挂起的代码时,可以绕过去,不用等到这些代码执行完毕才能执行后面的代码,而是你走你的,我走我的,你占的道反正不在我这。
launch{..}里面的代码块,其实就是默认被 suspend修饰了,所以抽出来的方法,我们也额外的手动加上。

3. 非阻塞式挂起

3.1 挂起

挂起是什么?请看下面的代码(还是上面那个例子):

coroutineScope.launch(Dispatchers.Unconfined) {
    val text = withContext(Dispatchers.IO) {
            getText()
     }
     print(text)   
}

上面可以看成做了两个事情:

  • 开启一个(Unconfined即当前线程所在的)协程
  • 在这个协程中 开启一个(IO线程)子协程做耗时任务

这个时候,我们就成 coroutineScope.launch{..}挂起了。
我们以线程的角度来考虑这段代码,请看下图:

在这里插入图片描述

假设在CPU单核运算,只有一条线来走时间片轮换,那我们假设每个时间只有一个线程在执行代码。
那么在走到 withContext(Dispatchers.IO){..}时,它其实就是开启了一个IO线程。并将代码块中的代码放入到这个线程里面去做。

那这不就是切线程吗?为啥美其名曰挂起?
是因为,这个协程的API在完成了I/O线程的任务之后,不需要我们手动切回到原来的线程(可以对比一下RxJava在子线程做完网络请求后我们手动切回主线程),自动的帮我们切回了原来的线程。
我上面的代码,在 withContext(){..} I/O线程做完延时操作后,自然而然的回到了 Unconfined的线程去做print。

这个恢复到原来线程的操作在Kotlin里叫 resume。它本身一点儿也不稀奇,因为我们在RxJava中就已经知道通过Handler来切换线程了, 只是Kotlin包装到我们意识不到原来已经切换回来的程度。

总结成一句话:挂起就是: 切换线程 + 恢复到原来线程

3.2 suspend

我们用一个 suspend 来修饰挂起函数。suspend的意义是什么?它能实现挂起吗?

答案是否定的,单单给一个普通函数加上一个 suspend,没有任何效果,可以看下下面代码:
在这里插入图片描述
我们在函数中调用了 print(),发现 suspend置灰了,这说明只是用 suspend是无法让函数挂起的。
CoroutineScope.launch(){}代码块能让语句挂起吗?答案也是否定的。

那让函数挂起的语句是什么?

答案是 withContext(){..},它是真正的创建了线程的代码,所以当出现了这个语句,协程才能真正的挂起。
当然,除了 withContext() ,还有别的能让协程挂起,比如 delay(),因为他本身也是个操作线程的语句,包括别的。

总结下suspend的作用:

  • 提醒调用者,该函数会切换线程(也就是挂起)
  • 静态代码扫描,检测该函数是否有线程操作
  • 只有在协程中调用withContext(){..}类的代码, 该协程才会被挂起

3.3 非阻塞式挂起的含义

当我们了解了挂起的本质是切换线程,那我们就明白了非阻塞的含义是什么了。

在Java开发中,我们通过调用 Executor来开一个子线程,子线程会阻塞主线程的代码吗?
答案是不会的,因为当我们只是开一个子线程出来,主线程还是会继续走,等时间片不在主线程时,子线程再去走,但对于主线程来说,它里面的代码并没有因为子线程的存在而产生阻塞。

↑所以这个情况,也是非阻塞的。

而Koltin中的【非阻塞式挂起】,其实和Java一样,它只是通过协程达到了这个效果:代码明明看起来是阻塞的,但是运行却是不阻塞的。 如下所示:

coroutineScope.launch(Dispatchers.Main) {
    val avatar = async { api.getAvatar(user) }    // 获取用户头像
    val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
    val merged = suspendingMerge(avatar, logo)    // 合并结果
    show(merged) // 更新 UI
}

再往前想一想 runBlocking(){..}为啥线程阻塞了?因为它本身就没有切换线程,它是直接在本线程上做耗时任务。
嘿嘿,是不是有点恍然大悟了?它本身就是噱头。

4. 协程的取消和超时

官方的例子举得很好,接下来就都用官方的代码了~

4.1 协程的取消

4.1.1 协程的取消

协程任务是可以取消的

// 省略一些CoroutineScope的代码
val job : Job = launch {
    repeat(1000) { i ->
        println("job: I'm sleeping $i ...")
        delay(500L)
    }
}
delay(1300L) // 延迟一段时间
println("main: I'm tired of waiting!")
job.cancel() // 取消该作业
job.join() // 等待作业执行结束
println("main: Now I can quit.")

// 执行结果:
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

我们可以看到 CoroutineScope 提供了一个 Job类型的句柄,我们可以调用其 Job.cancel()Job.join()来停止和加入该协程。
这两个方法也可以合并成一个 Job.cancelAndJoin()来使用

4.1.2 协程是不能随时取消的

和线程一样,我们在调用了 Thread.cancel()后,线程不会马上取消。

协程也一样,当协程在进行计算任务任务时,如果你直接cancel,是不会取消任务的:

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (i < 5) { // 一个执行计算的循环,只是为了占用 CPU
        // 每秒打印消息两次
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // 等待一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消一个作业并且等待它结束
println("main: Now I can quit.")

// 打印结果:
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...
main: Now I can quit.

我们需要像 Thread那样,去调用isInterrupted()来检查。
协程提供了一个变量 isActive来判断当前协程是否取消了,如果取消了,就不用再走下去了:

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (isActive) { // 可以被取消的计算循环
        // 每秒打印消息两次
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // 等待一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消该作业并等待它结束
println("main: Now I can quit.")

// 打印结果:
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

4.1.3 在取消时释放内存

我们是可以感知得到Job被取消的,当Job被取消的时候,它会抛出一个 CancellationException,我们把它当成结束回调,在里面做释放资源:

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    } finally {
        println("job: I'm running finally") // 此时任务取消,释放资源
    }
}
delay(1300L) // 延迟一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消该作业并且等待它结束
println("main: Now I can quit.")

4.1.4 假如你的释放资源也是耗时的怎么办

这是一个比较少见的情况,在 finally块中调用了耗时操作,此时协程又被取消,那相当于你finally的耗时操作执行不了
协程提供了 withContext(NonCancellable){...}来帮助协程延迟取消:

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    } finally {
        withContext(NonCancellable) {
            println("job: I'm running finally")
            delay(1000L)
            println("job: And I've just delayed for 1 sec because I'm non-cancellable")
        }
    }
}
delay(1300L) // 延迟一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消该作业并等待它结束
println("main: Now I can quit.")

4.2 协程的超时

在实践中绝大多数取消一个协程的理由是它有可能超时。
当你手动追踪一个相关 Job 的引用并启动了一个单独的协程在延迟后取消追踪,这里已经准备好使用 withTimeout() 函数来做这件事。

来看看示例代码:

withTimeout(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
}

// 打印结果:
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms

发现抛出了 TimeoutCancellationException异常,它是 CancellationException的子类。
之所以后者没有产生堆栈信息,是因为协程认为我们取消它,这是一个正常的结束。

如果遇到特殊的场景,比如我必须要知道这个任务在规定时间内还没有做完,,可以使用类似 withTimeoutwithTimeoutOrNull 函数,并把这些会超时的代码包装在 try {...} catch (e: TimeoutCancellationException) {...} 代码块中,而 withTimeoutOrNull 通过返回 null 来进行超时操作,从而替代抛出一个异常:

val result = withTimeoutOrNull(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
    "Done" // 在它运行得到结果之前取消它
}
println("Result is $result")

// 打印结果:
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null

就不会抛出异常,而是返回一个null。个人认为这样的做法比catch异常要温柔一点。

5. coroutineScope

coroutineScope{..} 会创建一个挂起函数,所以调用它的函数必须被 suspend修饰。

官方文档是这么描述的:

This function is designed for parallel decomposition of work.
When any child coroutine in this scope fails, this scope fails and all the rest of the children are cancelled
This function returns as soon as the given block and all its children coroutines are completed.

翻译一下就是

这个方法是为了并行工作而设计的
当这个作用域内的任何一个子协程失败,那么这个作用域失败,并且其所有剩余的子协程都失败
当这个作用域所有的子协程都成功完成任务了,那么这个作用域将会返回(结果)

5.1 coroutineScope 和 CoroutineScope的区别

因为 coroutineScope的描述,所以可以从结构化和非结构化入手,coroutineScope是结构化的,CoroutineScope是非结构化的。
结构化更利于并行程序的执行。

举个例子,我们在主线程中,点击一个Button,这个button会去做耗时操作,然后返回结果,使用 CoroutineScope是这样的:

       ...
        btn.setOnClickListener( () -> {
        CoroutineScope(Dispatchers.Main).launch {
            int result = downloadUserData()
            Toast.makeText(applicationContext, "Result : " + result, Toast.LENGTH_LONG).show()
        });
        ...
        
    private suspend int downloadUserData() {
        int result = 0;
        // 这里我们使用一个CoroutineScope并切换线程
        CoroutineScope(Dispatchers.IO).launch {
            for (int i = 0; i < 2000; i++) {
                kotlinx.coroutines.delay(400);
                result++;
            }
        }

        return result;
    }
}

Toast打印出来的result是0
这是切了线程后,并没有等到该协程执行完返回,直接执行了Toast的展示。
这很明显不是我们想要的结果。

这时,我们换成 coroutineScope {}来试试:

    private suspend int downloadUserData() {
        int result = 0;
        coroutineScope {
            for (int i = 0; i < 20000; i++) {
                kotlinx.coroutines.delay(400);
                result++;
            }
        }
        return result;
    }

Toast的打印result是20000,使我们想要的结果。

这就是因为 coroutineScope它等待到代码块执行完,才返回结果。

5.2 coroutineScope 和 runBlocking的区别

猛地一看,coroutineScope{}会等到里面所有的代码执行完,这不跟 runBlocking{}的阻塞等待 一样吗?

他们最大的区别就是,当他们代码块的函数被挂起时,当前的线程会怎么做。

  • coroutineScope{} 函数被挂起时,线程会继续往之后走
  • runBlocking{} 函数被挂起时,线程会等待执行完这个函数里面所有的代码才会往之后走

看个代码:

fun main()  {
    CoroutineScope(Dispatchers.Unconfined).launch {  // 开启一个主协程
        val result = downloadUserData()
        println(result)
    }
    println("OUT")     // 在协程之外打印东西
    Thread.sleep(12000L)    // 保证程序不结束
}

suspend fun downloadUserData(): Int {
    var res = 0
    coroutineScope {     // 使用coroutineScope来开启一个子协程
        for (i in 0..10) {
            delay(100L)
            res++
        }
    }
    return res
}
// 打印结果:
OUT
11

也就是说,当 coroutineScope挂起时,线程会继续往下走,但由于他没执行完,所以 println(result)没有执行,继续往下走,会执行println("OUT"),所以先打印 OUT , 再打印计算结果。

我们换成runBlocking试试:

suspend fun downloadUserData(): Int {
    var res = 0
    runBlocking {
        for (i in 0..10) {
            delay(100L)
            res++
        }
    }
    return res
}

// 答应结果:
11
OUT

显然,runBlocking会不会把控制权还给线程,(虽然他挂起来了),但还是会一直执行,直到代码块走完

6. async

6.1 一个例子

终于来到了关键的地方了,在前面的学习中,我好像只学习了每个关键字、语句块的概念,但是我并没有让他们实践,并行执行,因为学习协程的最终目的是高效进行高并发操作。

我们先来看一下两个耗时操作:

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // 假设我们在这里做了一些有用的事
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // 假设我们在这里也做了一些有用的事
    return 29
}

接下来,我们的程序要将两者的最终结果相加,我们可能会这样写:

val time = measureTimeMillis {
    val one = doSomethingUsefulOne()
    val two = doSomethingUsefulTwo()
    println("The answer is ${one + two}")
}
println("Completed in $time ms")

// 打印结果:
The answer is 42
Completed in 2017 ms

通过耗时来看,我们发现程序它的结果像是串行执行的,没有起到并行的结果。

6.2 使用 async

doSomethingUsefulOne()doSomethingUsefulTwo()并没有产生依赖,并且我们为了更快的得到结果,我们需要将两个方法并行执行。

asynclaunch类似,它可以用来启动一个单独的协程。不同之处在于 launch返回的是Job类型,而 async返回的是一个 Deferred类,这是一个轻量级的非阻塞future,也就是会在稍后提供给我们结果。我们可以使用 .await() 在一个延期的值上得到它的最终结果, Deferred继承自Job,所以我们也可以通过 Deferred来结束一个协程。

val time = measureTimeMillis {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    println("The answer is ${one.await() + two.await()}") // 使用wait来等待结果
}
println("Completed in $time ms")

// 打印结果:
The answer is 42
Completed in 1017 ms

显然,性能快了一倍。

6.3 懒启动async

CoroutineStart 可以用来设置启动属性,他有下面四种属性:

  • DEFAULT
    默认,立即根据Context来安排协程的执行。
  • ATOMIC
    自动。这与默认值类似,但是协程在开始执行之前不能取消。
  • UNDISPATCHED
    立即执行协程,一直到它在当前线程的第一个挂起点
    当resume时,会根据 CortuineDispatcher来找到对应的线程
  • LAZY
    不会马上执行,只有在需要的时候执行。

使用这些修饰的协程,需要手动调用 Job.start()来开启协程

对于 async来说,懒启动也就是等到调用await()的时候,才去执行计算:

val time = measureTimeMillis {
    val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
    val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
    // 执行一些计算
    one.start() // 启动第一个
    two.start() // 启动第二个
    println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")

// 打印结果:
The answer is 42
Completed in 1017 ms

7 解疑

7.1 协程用于什么场景

既然我们知道了协程是一种编程思想,它是为了帮助主程序更流畅的执行下去,而他的实现是切换线程。
那么我们知道了其实它的使用场景:

  • I/O操作
  • 耗时计算

7.2 协程轻量吗?没开销吗?

在看到很多篇文章的时候,可能动不动就会来一句话:因为协程是微线程,所以使用协程切换会比线程节省很多开销。

这句话是很不严谨,甚至是错误的。

考虑到我们使用协程的场景,99%都是需要异步的去处理一些东西,所以这必须要切换线程。
那 【协程的挂起】 其实就是 【线程的切换和恢复】,很明显,这肯定是有开销的。

官方使用了下面代码来证明协程轻量:
在这里插入图片描述
举出一个使用协程来异步打印 100000个点的例子。
这本身是有问题的,因为它这句 “尝试使线程来实现”,是明显要让我们使用 Thread.run()方式来实现,这很显然会产生内存溢出。

但是 协程的原理使用了线程池,所以很明显不能用 Thread,而是需要对比使用 ThreadPoolExecutor,因为还有delay,所以需要使用 newScheduledThreadPool来进行对比,这样一对比,我们发现其实 性能差别并不大。(来自Hencoder)

所以官方的例子有非常明显的误导性。

7.3 为啥推荐使用CoroutineScope而不是GlobalScope来开启协程

来看下两者的定义
CoroutineScope:
在这里插入图片描述
大致翻译为:
CountineContext来创建一个 CoutineScope作用域,如果该context没有Job,则创建一个默认的Job,
这样的话,你通过这个 Job 来关闭任意一个作用域内的 子协程,其他的子协程都会被关闭

再看看 GlbalScope的:
在这里插入图片描述
大致翻译为:
能定义一个全局的不受任何约束的顶级协程。
它和App生命周期绑定,在App结束前,该协程不会提前结束。
如果你在里面使用 CoroutineScope.launch()CoroutineScope.async(),那很明显,这个作用域的资源释放不掉,是极其不推荐在GlobalScope里面使用的。

所以总结如下:

  • CoroutineScope协程可以提供一个 Job句柄来提前取消或结束任务(而且是作用域内的所有任务)
  • GlbalScope创建的协程生命周期和App一样长,所以它不能提前取消,也绝对不要在里面 放 CoroutineScope协程,因为那样他就不能及时释放资源,而内存消耗
  • 因为很少有场景遇到需要全局都在子线程上处理的任务,所以官方推荐我们尽量使用 CoroutineScope来完成协程的任务

8 参考文章

Kotlin中文网
Idiomatic Kotlin: Lambdas and SAM constructors
基础篇:Lambda 表达式和函数对象
Kotlin 的协程用力瞥一眼 - 学不会协程?很可能因为你看过的教程都是错的
Kotlin 协程的挂起好神奇好难懂?今天我把它的皮给扒了
到底什么是「非阻塞式」挂起?协程真的更轻量级吗?
【译】Kotlin协程的取消

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值