7

Kotlin Primer·第七章·协程库(上篇)

 3 years ago
source link: https://kymjs.com/code/2017/11/24/01/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Kotlin Primer·第七章·协程库(上篇)

2017-11-24 By 张涛 | 本文已被访问13845次

版权声明:本文是开源实验室原创文章,如您转载必须以链接形式注明原文地址:https://kymjs.com/code/2017/11/24/01
本篇只讲了协程库的使用。还有中篇讲协程的启动和切换实现原理,下篇核心讲解kotlin协程在JVM层的实现原理。
这可能是至今为止最详细实用的 Kotlin 协程库详解了。
对本文有任何问题,可加我的个人微信询问:kymjs123

第一部分——快速上手
第一章·启程
第二章·基本语法
第三章·Kotlin 与 Java 混编
第二部分——开始学习 Kotlin
第四章·Kotlin 的类特性(上)
第四章·Kotlin 的类特性(下)
第五章·函数与闭包
第六章·集合泛型与操作符
第三部分——Kotlin 工具库
第七章·协程库(上篇)
第七章·协程库(中篇)

如果你觉得我的 Kotlin 博客对你有帮助,那么我强烈建议你看看我的极客时间 Kotlin 视频课程。视频中讲述了很多实际开发中遇到问题的解决办法,以及很多 Kotlin 特性功能在工作中实际项目上的应用场景。

协程,协作代码段。相对线程而言,协程更适合于用来实现彼此熟悉的程序组件。协程提供了一种可以避免线程阻塞的能力,这是他的核心功能。在 kotlin 中使用协程,需要在 gradle 中引入协程库:

//Android 工程使用
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x"  
//Java 工程使用
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x'

7.1 协程是什么

协程的概念其实是很早就被提出的。
这里借用知乎作者阿猫的一段回答(有所修改)来为大家讲解协程究竟是怎么来的:

  1. 一开始大家想要同一时间执行多个代码任务,于是就有了并发。从程序员的角度可以看成是多个独立的逻辑流,内部可以是多 CPU 并行,也可以是单 CPU 时间分片。
  2. 但是一并发就有上下文切换的问题,干了一半跑去处理另一件事,我这做了一半的东西怎么保存。进程就是这样抽象出来个一个概念,搭配虚拟内存、进程表之类,用来管理独立的程序运行、切换。
  3. 后来硬件提升了,一电脑上有了好几个 CPU 就可以一人跑一进程,就是所谓的并行
  4. 但是一并行,进程数一高,大部分系统资源就得用于进程切换的状态保存。后来搞出线程的概念,大致意思就是这个地方阻塞了,但我还有其他地方的逻辑流可以计算,不用特别麻烦的切换页表、刷新 TLB,只要把寄存器刷新一遍就行。
  5. 如果你嫌操作系统调度线程有不确定性,不知道什么时候开始什么时候切走,我自己在进程里面手写代码去管理逻辑调度这就是用户态线程
  6. 而用户态线程是不可剥夺的,如果一个用户态线程发生了阻塞,就会造成整个进程阻塞,所以进程需要自己拥有调度线程的能力。而如果用户态线程将控制权交给进程,让进程调度自己,这就是协程

后来我们的内存越来越大,操作系统的调度也越来越智能,就慢慢没人再去花时间去自己实现用户态线程、协程这些东西了。

7.2 为什么又要用协程了

那既然上面说协程已经淘汰在历史的长河中了,为什么现在又跑来这么声势浩大。
这就要从多线程的效率讲起了。
前面我们讲由于操作系统的多线程调度越来越智能,硬件设备也越来越好,这大幅提升了线程的效率,因此正常情况下线程的效率是高于协程的,而且是远高于协程。
那么线程在什么情况下效率是最高的?就是在一直 run 的情况下。但是线程几乎是很难一直 run 的,比如:线程上下文切换、复杂计算阻塞、IO阻塞。
于是又有人想起了协程,这个可以交给代码调度的东西。

7.3 协程的本质作用

协程实际上就是极大程度的复用线程,通过让线程满载运行,达到最大程度的利用CPU,进而提升应用性能。
什么意思呢?
具体一个例子来说:在android上发起一个网络请求。

第1步,主线程创建一个网络请求的任务。
第2步,通过一个子线程去请求服务端响应。
第2.1步,等待网络传递请求,其中可能包括了TCP/IP的一系列过程。
第2.2步,等待服务器处理,比如你请求一个列表数据,服务器逻辑执行依次去缓存、数据库、默认数据找到应该返回给你的数据,再将数据回传给你。
第2.3步,又是一系列的数据回传。
第3步,在子线程中获取到服务器返回的数据。将数据转换成想要的格式。
第4步,在主线程中执行某个回调方法。

在上面这个例子中,第2步通常我们会用一个线程池去存放一批创建好的线程做复用,防止多次创建线程。
但是使用了线程池,就会遇到一个问题。池中预存多少线程才最适合?存少了,后面的任务需要等待有空余的线程才能开始执行;存多了,闲置的线程浪费内存。这个问题实际上还是线程利用率不高的问题。
还是回到示例中,在第2步至2.3步中,此刻线程其实是处于阻塞状态不做事的,这就是线程利用率不高的原因。
这个例子换做协程是这么个流程:

第1步,主线程创建一个协程,在协程中创建网络请求的任务。
第2步,为协程分配一个执行的线程(本例中肯定是子线程),在线程中去请求服务端响应。
第2.1步,(接下来会发生阻塞),挂起子线程中的这个协程,等待网络传递请求,其中可能包括了TCP/IP的一系列过程。
第2.2步,协程依旧处于挂起状态,等待服务器处理,比如你请求一个列表数据,服务器逻辑执行依次去缓存、数据库、默认数据找到应该返回给你的数据,再将数据回传给你。
第2.3步,协程依旧处于挂起状态,又是一系列的数据回传。
第3步,获取到服务器返回的数据,在子线程中恢复挂起的协程。将数据转换成想要的格式。
第4步,在主线程中执行某个回调方法。

在上面这个例子中,整个步骤并没有发生任何改变,但是因为引入了协程挂起的概念。当线程中的协程发生了挂起,线程依旧是可以继续做事的,比如开始执行第二个协程,而协程的挂起是一个很轻的操作(其内在的只是一次状态机的变更,就是一个switch语句的分支执行,详细内容可以看我的下一篇文章:【Kotlin Primer·第七章·协程库(中篇)】)。这就大大的提升了多任务并发的效率,同时极大的提升了线程的利用率。
这就是协程的本质——极大程度的复用线程,通过让线程满载运行,达到最大程度的利用CPU,进而提升应用性能。

7.4 kotlin 的协程怎么用

在 kotlin 上,使用协程你只需要知道两个方法和他们的返回类型,就可以很熟练的用上协程了。分别是:

fun launch(): Job
fun async(): Deferred

7.4.1 launch方法

从方法名我们就能看出,launch表示启动一个协程。

public fun launch(
    context: CoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
}

launch()方法接收三个参数,通常很少用到第二个参数。
第一个参数是一个协程的上下文,CoroutineContext不仅可以用于在协程跳转的时刻传递数据,同时最主要的功能,是用于表明协程运行与恢复时的上下文环境。
通常Android在用的时候都是传一个UI就表示在 UI 线程启动协程,或者传一个CommonPool表示在异步启动协程,还有一个是Unconfined表示不指定,在哪个线程调用就在哪个线程恢复。

fun test() {
    launch(UI) {
        val isUIThread = Thread.currentThread() == Looper.getMainLooper().thread
        println("UI::===$isUIThread")
    }
    launch(CommonPool) {
        val isUIThread = Thread.currentThread() == Looper.getMainLooper().thread
        println("CommonPool::===$isUIThread")
    }
}

例如这段代码就会输出一个

UI::===true
CommonPool::===false

7.4.2 Job对象

launch()方法会返回一个job对象,job对象常用的方法有三个,叫startjoincancel。分别对应了协程的启动、切换至当前协程、取消。

例如下面是start()方法的使用示例:

fun test() {
    //当启动类型设置成LAZY时,协程不会立即启动,而是手动调用start()后他才会启动。
    val job = launch(UI, CoroutineStart.LAZY) {
        println("hello")
    }
    job.start()
}

join()方法就比较特殊,他是一个suspend方法。suspend 修饰的方法(或闭包)只能调用被suspend修饰过的方法(或闭包)。 方法声明如下:

public suspend fun join()

因此,join()方法只能在协程体内部使用,跟他的功能:切换至当前协程所吻合。

fun test() {
    val job1 = launch(UI, CoroutineStart.LAZY) {
        println("hello1")
    }
    val job2 = launch(UI) {
        println("hello2")
        job1.join()
        println("hello3")
    }
}

这段代码执行后将会输出

hello2
hello1
hello3

7.4.3 async()方法

async()方法也是创建一个协程并启动,甚至连方法的声明都跟launch()方法一模一样。
不同的是,async()方法的返回值,返回的是一个Deferred对象。这个接口是Job接口的子类。
因此上文介绍的所有方法,都可以用于Deferred的对象。

Deferred最大的用处在于他特有的一个方法await()

public suspend fun await(): T

await()可以返回当前协程的执行结果,也就是你可以这样写代码:

fun test() {
    val deferred1 = async(CommonPool) {
        "hello1"
    }
    val deferred2 = async(UI) {
        println("hello2")
        println(deferred1.await())
    }
}

你发现神奇的地方了吗,我让一个工作在主线程的协程,获取到了一个异步协程的返回值。
这意味着,我们以后网络请求、图片加载、数据库、文件操作什么的,都可以丢到一个异步的协程中去,然后在同步代码中直接取返回值,而不再需要去写回调了。
这就是我们经常使用的一个最大特性。

7.5 kotlin 协程使用示例

最后用一个稍微复杂一点的例子,来讲 kotlin 协程的使用

fun test() {
    //每秒输出两个数字
    val job1 = launch(Unconfined, CoroutineStart.LAZY) {
        var count = 0
        while (true) {
            count++
            //delay()表示将这个协程挂起500ms
            delay(500)
            println("count::$count")
        }
    }

    //job2会立刻启动
    val job2 = async(CommonPool) {
        job1.start()
        "ZhangTao"
    }

    launch(UI) {
        delay(3000)
        job1.cancel()
        //await()的规则是:如果此刻job2已经执行完则立刻返回结果,否则等待job2执行
        println(job2.await())
    }
}

最终输出了6次,job1 就被 cancel 了

count::1
count::2
count::3
count::4
count::5
count::6
ZhangTao
了解更多有深度技术的文章,与移动端、大前端未来方向的认知,前往订阅 开源实验室小专栏。
张涛-qrcode

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK