美文网首页Android开发Android开发Android开发经验谈
总是在聊线程Thread,你知道协程吗?

总是在聊线程Thread,你知道协程吗?

作者: 咸鱼正翻身 | 来源:发表于2019-05-05 21:05 被阅读8次

    前言

    本文主要基于Kotlin,之前写过一些Kotlin的文章,比较浅,有兴趣的小伙伴可以看上那么一看

    Kotlin快速入坑指南(干货型文档)

    几个特性,快速上手Kotlin

    对于Java的小伙伴来说,线程可以说是一个又爱又恨的家伙。线程可以带给我们不阻碍主线程的后台操作,但随之而来的线程安全、线程消耗等问题又是我们不得不处理的问题。

    对于Java开发来说,合理使用线程池可以帮我们处理随意开启线程的消耗。此外RxJava库的出现,也帮助我们更好的去线程进行切换。所以一直以来线程占据了我的日常开发...

    直到,我接触了协程...

    正文

    咱们先来看一段Wiki上关于协程(Coroutine)的一些介绍:协程是计算机程序的一类组件,允许执行被挂起与被恢复。但是,到2003年,很多最流行的编程语言,包括C和它的后继,都未在语言内或其标准库中直接支持协程。在当今的主流编程环境里,线程是协程的合适的替代者...

    但是!如今已经2019年了,协程真的没有用武之地么?!今天让我们从Kotlin中感受协程的有趣之处!

    一、协程

    开始实战之前,我们聊一聊协程这么的概念。开启协程之前,我们先说一说咱们日常中的函数

    函数,在所有语言中都是层级调用,比如函数A调用函数B,函数B中又调用了函数C,函数C执行完毕返回,函数B执行完毕返回,最后是函数A执行完毕。

    所以可以看出来函数的调用是通过栈实现的。

    函数的调用总是一个入口,一次return,调用顺序是明确的。而协程的不同之处就在于,执行过程中函数内部是可中断的,也就是说中断之后,可以转而执行别的函数,在合适的时机再return回来继续执行没有执行完的内容。

    而这种中断,叫做挂起。挂起我们当前的函数,再某个合适的时机,才反过来继续执行~这里我们再想想回调:注册一个回调函数,在合适的时机执行这个回调。

    • 回调采用的是一种异步的形式
    • 而协程则是同步

    是不是一时有点懵逼。不着急,咱往下看,往下更懵逼,哈哈~

    二、Kotlin中的协程

    通过Wiki上的介绍,我们不难看出协程是一种标准。任何语言都可以选择去支持它。

    这里是关于Kotlin中协程的文档:https://kotlinlang.org/docs/reference/coroutines-overview.html

    假设我们想在android中的项目中使用协程该怎么办?很简单。

    假设可以已经配好了Kotlin依赖

    2.1、gradle引入

    在Android中协程的引入非常的简单,只需要在gradle中:

    apply plugin: 'kotlin-android-extensions'
    

    然后依赖中添加:

    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.0"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.0"
    

    2.2、基本demo

    先看一段官方的基础demo:

    // 启动一个协程
    GlobalScope.launch(Dispatchers.Main) {
        // 执行一个延迟10秒的函数
        delay(10000L)
        println("World!-${Thread.currentThread().name}")
    }
    println("Hello-${Thread.currentThread().name}-")
    

    这段代码执行结果应该大家都能猜到:Hello-main-World!-main。大家有没有注意到,这俩个输出,全部打印了main线程。

    这段代码在主线程执行,并且延迟了10秒钟,而且也没有出现ANR!

    当然,这里有小伙伴会说,我可以通过Handler进行postDelay()也能做到这种效果。没错,我们的postDelay(),是一种回调的解决方案。而我们开头提到过,协程使用同步的方式去解决这类问题。

    所以,协程中的delay()也是通过队列实现的。但是!它用同步的形式屏弃掉了回调,让我们的代码可读性+100%。

    2.2.1、delay()的实现

    预警...这里将会引入大量的Kotlin中的协程api。为了避免阅读不适。这一小节建议直接跳过

    跳过总结:

    Kotlin为我们提供了一些api,帮我们能够摆脱CallBack,本质也是通过封装CallBack的形式,实现同步化异步代码

    public suspend fun delay(timeMillis: Long) {
        if (timeMillis <= 0) return // don't delay
        // 很明显可以看出,实现仍然是用CallBack的形式
        return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
    
    /** Returns [Delay] implementation of the given context */
    internal val CoroutineContext.delay: Delay get() = get(ContinuationInterceptor) as? Delay ?: DefaultDelay
    internal actual val DefaultDelay: Delay = DefaultExecutor
    

    delay()使用suspendCancellableCoroutine()挂起协程,一般情况下控制协程恢复的关键在DefaultExecutor.scheduleResumeAfterDelay()中,中实现是schedule(DelayedResumeTask(timeMillis, continuation)),关键逻辑是将DelayedResumeTask放到DefaultExecutor的队列最后,在延迟的时间到达就会执行DelayedResumeTask,那么该 task 里面的实现是什么:

    override fun run() {
        // 直接在调用者线程恢复协程
        with(cont) { resumeUndispatched(Unit) }
    }
    

    2.3、继续理解

    接下来,咱们来好好理解一下上面代码的含义。

    首先delay()被称之为挂起函数,这种函数在协程的作用域中,可以被挂起,挂起后不阻塞当前线程协程作用域以外的代码执行。并且协程会在合适的时机,恢复挂起继续执行协程作用域中后续的代码。

    而上述代码中的GlobalScope.launch(Dispatchers.Main) {},就是在主线程创建一个全局的协程作用域。而我们的delay(10000)是一个挂起函数,执行到它的时候,协程会挂起此函数。让出CPU,此时我们协程作用域之外的println("Hello-${Thread.currentThread().name}-")就有机会执行了。

    当合适的时机到来,也就是10000毫秒过后。协程会恢复挂起函数,继续执行后续的代码。

    思考

    看到这,我猜肯定有小伙伴,内心卧槽了一声:“这不完全不需要线程了?以后阻塞操作,直接写在挂起函数了?”。这是完全错误的想法!协程提供的是同步化异步代码的能力。协程是在用户态帮我们封装了对应的异步api。而不是真正提供了异步的能力。所以如果我们在主线程的协程中进行IO操作,一样会阻塞住主线程。

    GlobalScope.launch(Dispatchers.Main) {
        ...网络请求/...大量数据的数据库操作
    }
    

    一样会抛出NetworkOnMainThread/一样会阻塞主线程。因为上述代码,本质还是在主线程执行。所以假设我们在协程中运行阻塞当前线程的代码(比如IO操作),仍然会阻塞住当前的线程。也就是有可能出现我们常见的ANR。

    因此,在这种场景下,我们需要这么调用:

    GlobalScope.launch(Dispatchers.IO) {
        ...网络请求/...大量数据的数据库操作
    }
    

    我们在启动一个协程的时候,改了一个新的协程上下文(这个上下文会将协程切换到IO线程进行执行)。这样我们就做到在子线程启动协程,完成我们曾经线程的样子...

    思考

    很多朋友,肯定这里就产生疑问了。既然还是用子线程做后台任务...那协程存在的意义有是什么呢?那接下来让咱们走进协程的意义。

    三、协程的作用

    3.1、拒绝CallBack

    我们日常开发时,经常会遇到这样的需求:比如一个发文流程中,我们要先登录;登录成功后,我们再进行发文;发文成功后我们更新UI。

    来段伪码,简单实现一下这样的需求:

    // 登录的伪码。传递一个lambda,也就是一个CallBack
    fun login(cb: (User) -> Unit) { ... }
    // 发文的伪码
    fun postContent(user: User, content: String, cb: (Result) -> Unit) { ... }
    // 更新UI
    fun updateUI(result: Result) { ... }
    
    fun ugcPost(content: String) {
        login { user ->
            postContent(user, content) { result ->
                updateUI(result)
            }
        }
    }
    

    这种需求下,我们通常会由俩个CallBack完成这种串行的需求。不知道大家日常写这种代码的时候,有没有思考过,为什么串行的逻辑,要用CallBack的形式(异步)完成?

    可能大家会说:这些需求要用线程去进行后台执行,只能通过CallBack拿到结果。

    那么问题又来了,为什么用线程做后台逻辑时,我们就必须要用CallBack呢?毕竟从我们的思维逻辑上来说,这些需求就是串行,理论上顺序执行代码就ok了。所以协程的作用就出现了...

    这种通过异步形式的逻辑,在协程的辅助下就可变成同步执行:

    // 挂起函数,不需要任何CallBack,我们CallBack的内容,只需要当做返回值return即可
    suspend fun login(): User { ... }   
    suspend fun postContent(user: User, content: String): Result { ... } 
    fun updateUI(result: Result) { ... }
    
    fun ugcPost(content: String) {
        GlobalScope.launch {
            val user = login()
            val result = postContent(user, content)
            updateUI(result)
        }
    }
    

    这样我们就完成了原本需要层层嵌套的CallBack代码,直来直去,直接顺序逻辑写即可。

    没错,这就是协程的作用之一。

    • 1、当然,很多小伙伴会说Java8引入的Future也可以完成类似的串行执行。(不过,话说回来是不是很多小伙伴没有升到Java8)...
    • 2、肯定也有其他小伙伴说,我可以使用Rx的方式,也能完成这种调用...

    哈哈,完全没错。因为大家都是为了解决同样的问题,但是协程还有其他好用的地方...

    3.2、方便的线程切换

    想一个我们很常见的需求,子线程网络请求,数据回来后切到主线程更新UI。

    runOnUiThread()、RxJava都能很方便的帮我们切换线程。这里我们看一下协程的方式:

    GlobalScope.launch(Dispatchers.Main) {
        val result = withContext(Dispatchers.IO){
            // 网络请求,并return请求结果
            ... result
        }
        // 更新UI
        updateUI(result)
    }
    

    很直来直去的逻辑,很直来直去的代码。可读性简直+100%。

    withContext()可以方便的帮我们在协程的上下文环境中切换线程,并返回执行结果。

    3.3、方便的并发

    我们再来看一段官方代码:

    import kotlinx.coroutines.*
    import kotlin.system.*
    
    fun main() = runBlocking<Unit> {
        val time = measureTimeMillis {
            val one = doSomethingUsefulOne()
            val two = doSomethingUsefulTwo()
            println("The answer is ${one + two}")
        }
        println("Completed in $time ms")    
    }
    
    suspend fun doSomethingUsefulOne(): Int {
        delay(1000L) // 假设我们在这里做了些有用的事
        return 13
    }
    
    suspend fun doSomethingUsefulTwo(): Int {
        delay(1000L) // 假设我们在这里也做了一些有用的事
        return 29
    }
    

    输出结果如下:
    The answer is 42

    Completed in 2017 ms

    假设我们耗时计算操作,没有任何依赖关系。因此最佳的方案,就是让它们俩并行执行。如何让doSomethingUsefulOne()doSomethingUsefulTwo()同时执行呢?

    答案是:async + await

    fun main() = runBlocking<Unit> {
        val time = measureTimeMillis {
            val one = async { doSomethingUsefulOne() }
            val two = async { doSomethingUsefulTwo() }
            println("The answer is ${one.await() + two.await()}")
        }
        println("Completed in $time ms")    
    }
    
    suspend fun doSomethingUsefulOne(): Int {
        delay(1000L) // 假设我们在这里做了些有用的事
        return 13
    }
    
    suspend fun doSomethingUsefulTwo(): Int {
        delay(1000L) // 假设我们在这里也做了些有用的事
        return 29
    }
    

    四、总结

    这篇文章,主要是引出协程。协程不是一个新概念,很多语言都支持。

    协程,引入了挂起的概念,让我们的函数可以随意的暂停,然后在我们原意的时候再执行。通知提供给了我们同步写异步代码的能力...帮助我们更高效的写代码,更直观的写代码。

    尾声

    关于协程,有很多很多的内容,可以聊。因为篇幅和时间的关系更多的细节,留给我们接下来的文章吧。

    我是一个应届生,最近和朋友们维护了一个公众号,内容是我们在从应届生过渡到开发这一路所踩过的坑,以及我们一步步学习的记录,如果感兴趣的朋友可以关注一下,一同加油~

    个人公众号:咸鱼正翻身

    相关文章

      网友评论

        本文标题:总是在聊线程Thread,你知道协程吗?

        本文链接:https://www.haomeiwen.com/subject/vtjkoqtx.html