Kotlin学习记录 协程(三)

  • 前言
  • 什么是并发?什么是并行?
  • 什么是多任务?什么是协作式多任务?什么是抢占式多任务?
  • 什么是同步?什么是异步?
  • 什么是阻塞?什么是非阻塞?
  • 什么是挂起?
  • 什么是非阻塞式挂起?
  • 什么是协程?
  • Kotlin 协程有什么用?
  • 十五、使用协程编写高效的并发程序
  • 15.1 协程的基本用法
  • 使用 GlobalScope.launch 函数创建你的第一个协程
  • 使用 Delay 函数延迟协程执行
  • 使用 runBlocking 函数创建一个能阻塞当前线程的协程作用域
  • 使用 launch 函数在当前的协程作用域下创建子协程
  • GlobalScope
  • 协程作用域作用
  • 使用 suspend 关键字将一个函数声明成挂起函数
  • 使用 coroutineScope 函数创建一个协程作用域
  • 15.2 更多的作用域构建器
  • 使用 async 函数创建一个子协程并获取执行结果
  • 使用 withContext 函数构建一个简化版的 async 函数
  • 15.3使用协程(suspendCoroutine函数)简化回调的写法


前言

什么是并发?什么是并行?

1)、并发就是同一时刻只有一条指令在执行,但是因为 CPU 时间片非常的小,多个指令间能够快速的切换,使得我们看起来拥有同时执行的效果,存在于单核或多核 CPU 系统中

2)、并行就是同一时刻多条指令同时在执行,存在于多核 CPU 系统中

什么是多任务?什么是协作式多任务?什么是抢占式多任务?

1)、多任务就是操作系统能够同时处理多个任务,例如可以使用电脑打开 AndroidStudio 和网易云音乐

2)、协作式多任务就是一个任务得到了 CPU 时间,除非它自己放弃使用 CPU ,否则将完全霸占 CPU ,所以任务之间需要协作,使用一段时间的 CPU 后,放弃使用,其它的任务也如此,才能保证系统的正常运行。一般出现在早期的操作系统中,如 Windows 3.1

3)、抢占式多任务就是由操作系统来分配每个任务的 CPU 使用时间,在一个任务使用一段时间 CPU 后,操作系统会剥夺当前任务的 CPU 使用权,把它排在询问队列的最后,再去询问下一个任务。一般出现在现在使用的操作系统,如 Window 95及之后的 Windows 版本

协作式多任务和抢占式多任务区别:在协作式多任务中,如果一个任务死锁,则系统也会死锁。而抢占式多任务中,如果一个任务死锁,系统仍能正常运行

什么是同步?什么是异步?

计算机领域中的同步和异步和我们平时生活中的同步和异步是不一样的,这就让很多人难以理解

1)、计算机领域中的同步就是当调用者发送一个调用指令,需等待该指令执行完,在继续往下执行,是一种串行的处理方式

2)、计算机领域中的异步就是当调用者发送一个调用指令,无需等待该指令执行完,继续往下执行,是一种并行的处理方式

什么是阻塞?什么是非阻塞?

在 Android 中的体现是阻塞了主线程的运行,非阻塞就是没有卡住主线程的运行

什么是挂起?

挂起就是保存当前状态,等待恢复执行,在 Android 中的体现,挂起就是不影响主线程的工作,更贴切的说法可以理解为切换到了一个指定的线程。

什么是非阻塞式挂起?

通过上面概念的解释,非阻塞式挂起就是不会卡住主线程将程序切换到另外一个指定的线程去执行

什么是协程?

协程(Coroutine,又称微线程)是一种比线程更加轻量级的存在,协程完全由程序所控制,不被操作系统内核所管理。

  • 协程可以比作子程序,但执行过程中,子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。协程之间的切换不需要涉及任何系统调用或任何阻塞调用
  • 协程只在一个线程中执行,是子程序之间的切换,发生在用户态上。而且,线程的阻塞状态是由操作系统内核来完成,发生在内核态上,因此协程相比线程节省线程创建和切换的开销
  • 协程中不存在同时写变量冲突,因此,也就不需要用来守卫关键区块的同步性原语,比如互斥锁、信号量等,并且不需要来自操作系统的支持。

Kotlin 协程有什么用?

方便,借助了kotlin的语言优势,其实最有用的是:用同步的方式写出异步代码,就是说:非阻塞式挂起。

非阻塞式挂起:因为挂起,肯定不在一个线程了,就涉及到多线程了,线程阻塞其实是对单线程来说的,一旦切了线程,那肯定是非阻塞的,跑到别的线程了,之前的线程就自由了

十五、使用协程编写高效的并发程序

大部分编程语言没有协程的概念,协程属于Kotlin中非常有特色的一项技术。

什么是协程?

它和线程是有点类似,可以简单地将它理解成一种轻量级的线程。我们之前所学习的线程是非常重量级的,之前所学习的线程需要依靠操作系统的调度才能实现不同线程之间的切换。而使用协程却可以仅在编程语言的层面就能实现不同协程之间的切换,从而大大提升了并发编程的运行效率

如:现在有one()和two()方法,如果使用协程,在协程A中去调用one()方法,协程B中去调用two()方法,虽然它们仍然会运行在同一个线程当中,但是在执行one()方法时随时都有可能被挂起转而去执行two()方法,执行two()方法时也随时都有可能被挂起转而继续执行one()方法,最终的输出结果就变得不确定了。

所以,协程允许我们在单线程模式下模拟多线程编程的效果,代码执行时的挂起与恢复完全是由编程语言来控制的,和操作系统无关。这种特性使得高并发程序的运行效率得到了极大的提升。

15.1 协程的基本用法

Kotlin 并没有把协程纳入标准库中,而是以依赖库的形式提供的,这是一张 Kotlin 协程的生态图:

kotlin android 锁屏播放音乐_android

添加依赖

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'//Android项目中才会用到

创建一个CoroutinesTest.kt文件,并定义一个main()函数,开启一个协程最简单的方式就是用

使用 GlobalScope.launch 函数创建你的第一个协程

fun main() {
    GlobalScope.launch {
        println("now in coroutine scope")
    }
}

GlobalScope.launch函数可以创建一个协程作用域,这样传递给launch函数的代码块(Lambda表达式)就是在协程中运行的了。每次创建的都是一个顶层协程,顶层协程当应用程序结束时也会跟着一起结束,所以日志没有打印其实就是代码块中的代码还没来得及执行应用程序就结束了。

fun main() {
    GlobalScope.launch {
        println("now in coroutine scope")
    }
    Thread.sleep(1000)
}

但如果使用Thread.sleep1秒内不能运行结束,就会被强制中断,如果使用delay就不会。

使用 Delay 函数延迟协程执行

delay()函数是一个非阻塞式的挂起函数,只会挂起当前协程不会影响其他协程的运行。而Thread.sleep()会阻塞当前的线程,这样运行在该线程下的所有协程都会被阻塞

注意:delay()函数只能在协程的作用域其他挂起函数中调用。

fun main(){
    GlobalScope.launch {
        println("codes run in coroutine scope")
        delay(1500)
        println("code run in coroutine scope finished")//不会被打印出来
    }
    Thread.sleep(1000)
}

如果让协程挂起1.5秒,但主线程阻塞1秒,打印第二条日志还是没有来得及运行。

使用 runBlocking 函数创建一个能阻塞当前线程的协程作用域

怎么让应用程序在 协程中的所有代码 都运行完成后再结束应用程序?要借助runBlocking函数:

fun main() {
   **runBlocking** { 
       println("start coroutine")
       delay(1500)
       println("end coroutine")
   }
}

runBlocking函数会创建一个协程的作用域,它可以保证协程作用域内的所有代码和子协程没有全部执行完之前 一直阻塞runBlocking函数 创建的线程

但runBlocking函数通常只在测试环境下使用,正式环境容易产生性能上的问题。

使用 launch 函数在当前的协程作用域下创建子协程

如何创建多个协程呢?使用launch函数。

这个launch函数与刚刚使用的GlobalScope.launch函数不同。

  1. 这个launch函数必须在 协程的作用域 中才能调用
  2. 这个launch函数会在 当前协程的作用域创建子协程
    子协程的特点 是 如果外层作用域的协程结束了,该作用域下的所有子协程也会一起结束。

GlobalScope.launch函数创建的永远是顶层协程,和线程比较像,因为线程也永远都是顶层的。

fun main() {
   runBlocking {
       launch {
           println("launch1")
           delay(1000)
           println("launch1 finished")
       }
       launch {
           println("launch2")
           delay(1000)
           println("launch2 finished")
       }
   }
}

//结果:
launch1
launch2
launch1 finished
launch1 finished 
//像多线程那样并发运行,实际这两个子协程运行在同一个线程中

多个协程之间如何调度只是由 编程语言 决定,不需要操作系统参与,使协程的并发效率很高。

// launch 函数源码
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    //...
}

可以看到,launch 函数是 CoroutineScope协程作用域 的一个扩展函数,它里面有三个参数:第一个参数: CoroutineContext协程上下文,有默认值。第二个参数: CoroutineStart协程启动模式,有默认值。第三个参数:函数类型参数,无默认值。因此 launch 函数在实际调用的时候,只需要传入一个 Lambda 表达式就可以了,当然你也可以传参去覆盖默认值

GlobalScope

因为 GlobalScope 是一个单例类,且实现了CoroutineScope,所有它拥有了全局的协程作用域,且在整个 JVM 虚拟机中只有一份对象实例。因为它的生命周期贯穿整个 JVM,所以我们在使用它的时候需要警惕内存泄漏。上面代码中调用的 GlobalScope.launch,实质上是调用了 CoroutineScopelaunch 扩展函数

协程作用域作用

协程必须在协程作用域中才能启动,协程作用域中定义了一些父子协程的规则,Kotlin 协程通过协程作用域来管控域中的所有协程

协程作用域间可并列或包含,组成一个树状结构,这就是 Kotlin 协程中的结构化并发。

作用域细分有下述三种:

1)、顶级作用域:没有父协程的协程所在的作用域

2)、协同作用域:协程中启动新协程(即子协程),此时子协程所在的作用域默认为协同作用域,子协程抛出的未捕获异常都将传递给父协程处理,父协程同时也会被取消;

3)、主从作用域:与协同作用域父子关系一致,区别在于子协程出现未捕获异常时不会向上传递给父协程

父子协程间的规则

1)、父协程如果取消或结束了,那么它下面的所有子协程均被取消或结束

2)、父协程需等待子协程执行完毕后才会最终进入完成状态,而不管父协程本身的代码块是否已执行完

3)、子协程会继承父协程上下文中的元素,如果自身有相同 Key 的成员,则覆盖对应 Key,覆盖效果仅在自身范围内有效

使用 suspend 关键字将一个函数声明成挂起函数

随着launch函数中的逻辑越来越复杂,可能需要将launch函数中的部分代码提取到一个单独的函数中。但在launch函数中编写的代码是拥有协程作用域的,如果提取到一个单独的函数中 就没有协程作用域 了,那么我们该如何调用像delay()这样的挂起函数呢?

Kotlin就提供了一个suspend关键字,可以将任意函数声明成挂起函数,而 挂起函数之间是可以相互调用的:

suspend只能将一个函数声明成挂起函数,无法提供协程作用域(下面的代码printDot()里面无法调用launch函数,因为launch函数要求必须在 协程作用域 中才能调用)

suspend fun printDot(){
    println("1")
    delay(1000)
}

要解决suspend关键字只能声明挂起函数,无法使用协程作用域进而无法使用launch函数的问题。

使用 coroutineScope 函数创建一个协程作用域

我们调用借助coroutineScope函数解决。coroutineScope函数也是一个 挂起函数 ,因此可以在任何其他 挂起函数 中调用。

coroutineScope函数的特点是 会继承外部的协程 的作用域并 创建一个子协程。
借助这个特性,我们就可以给任意挂起函数提供协程作用域了。

suspend fun printDot() = **coroutineScope**{
    launch {
        println("1")
        delay(1000)
    }
}

此时我们就可以在printDot()这个挂起函数里面调用launch函数了。

coroutineScope函数runBlocking函数有点类似,它可以保证其 作用域内所有代码和子协程 在全部执行完之前,外部的协程会一直被挂起

coroutineScoperunBlocking不同点:

虽然看上去coroutineScope函数和runBlocking函数的作用有点类似,但是coroutineScope函数阻塞当前协程,不影响其他协程,也不影响任何线程,因此是不会造成任何性能上的问题的。
runBlocking函数由于会 挂起外部线程,如果你恰好又在 主线程 中当中调用它的话,那么就有可能会导致界面卡死 的情况,所以不太推荐在实际项目中使用。

15.2 更多的作用域构建器

回顾:

GlobalScope.launch函数:任意地方都可调用

runBlocking函数:任意地方调用

coroutineScope函数:协程作用域或挂起函数 中调用

lanuch函数:在 协程作用域中 调用

同时我们也了解了,runBlocking会创建一个协程的作用域,保证协程作用域内的所有代码和子协程没有全部执行完之前 一直阻塞runBlocking函数 创建的线程,因此只建议在测试环境中使用。而GlobalScope.launch每次创建的都是顶层协程,也不建议使用,除非明确就是要创建顶层协程。

为什么不建议使用顶层协程?
因为 管理成本高。比如我们在某个Activity中使用协程发起了一条网络请求,由于网络请求是耗时的,用户在服务器还没来得及响应的情况下就关闭了当前Activity,此时就应该取消这条网络请求,起码不该进行回调。

协程要怎么取消呢?不管是GlobalScope.launch函数还是launch函数,它们**都会返回一个Job对象**,只需要调用Job对象的cancle()方法就可以取消协程了,如下:

val job = GlobalScope.launch { 
    //  处理具体逻辑
}
job.cancel()

如果我们每次创建的都是顶层协程,那么当Activity关闭时,就需要逐个调用所有已创建协程的cancel()方法,这种情况代码就很难维护了。因此,GlobalScope.launch这种协程作用域构建器,在实际项目中也是不太常用的。

实际项目常用的写法

val job = Job()
val scope = CoroutineScope(job)
scope.launch { 
    //  处理具体逻辑
}
job.cancel()

先创建一个Jon对象,传入CoroutineScope()函数,CoroutineScope()函数会返回一个CoroutineScope对象,有了这个对象,就可以调用这个CoroutineScope对象的launch函数来创建一个协程了。

所以调用CoroutineScopelaunch函数所创建的协程,都会被关联在**Job对象的作用域下面,这样只需调用一次job.cancel()方法,就可以将同一作用域内的所有协程全部取消**,这就大大降低了协程管理的成本

使用 async 函数创建一个子协程并获取执行结果

虽然launch函数可以创建一个 新的协程,但是launch函数只能用于执行一段逻辑,不能获取执行结果,因为launch函数的返回值永远是一个Job对象。想要创建一个协程并获取它的执行结果,就要用到async函数。

async函数必须在协程作用域中才能调用,它会创建一个协程并返回一个Deferred对象,想要获取async函数代码块的执行结果,只需调用async函数返回的Deferred对象的await()方法:

fun main() {
    runBlocking { 
        **val result = async { 
            5 + 5
        }.await()
        println(result)**
    }
}

//10

在调用了async函数之后,代码块中的代码会立刻执行,当调用await()方法时,如果代码块中的代码没有执行完,那么await()方法会将当前协程阻塞住直到可以获取async函数的执行结果。

fun main() {
    runBlocking {
        val result1 = async {
            delay(1000)
            5 + 5
        }.await()
        val result2 = async {
            delay(1000)
            1 + 2
        }.await()
				
    }
}

//打印结果
result is 20
cost: 2022 ms.

.await()写在async代码块后的情况下,第一个async函数中的代码会在执行完之前一直阻塞当前协程,完成之后才会执行第二个async函数,是一种串行关系,这种写法是非常低效的。
实际上,这两个async函数是可以并行执行的,写法如下:

fun main() {
    runBlocking {
        val start = System.currentTimeMillis()
        val deferred1 = async {
            delay(1000)
            5 + 5
        }

        val deferred2 = async {
            delay(1000)
            4 + 6
        }
        **println("result is ${deferred1.await() + deferred2.await()}")**
        val end = System.currentTimeMillis()
        println("cost: ${end - start} ms.")
    }
}

//打印结果
result is 20
cost: 1020 ms.

我们不需要在每次调用async函数之后就立刻使用await()方法获取结果,仅仅在需要用到async函数的执行结果的时候才调用await()方法进行获取,这样两个函数就是并行关系了。

使用 withContext 函数构建一个简化版的 async 函数

withContext 函数 是一个比较特殊的作用域构建器withContext 函数是一个挂起函数,可以把它理解为async函数的简化版写法:

fun main() {
    runBlocking {
        **val result = withContext(Dispatchers.Default) {
            5 + 5
        }
        println(result)**
    }
}

解释这段代码:调用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程序使用这种类型的线程参数会出现错误。

事实上,在我们刚才所学的协程作用域构建器中,除了coroutineScope函数之外,其他所有的函数都是可以指定这样一个线程参数的,只不过withContext()函数是强制要求指定的,而其他函数则是可选的。

目前为止,掌握了协程中的一些最常用的用法,并且了解到协程的主要用途就是可以大幅度地提高并发编程的运行效率

Kotlin中的协程还可以对传统回调的写法进行优化。

15.3使用协程(suspendCoroutine函数)简化回调的写法

我们之前学习了编程语言的回调机制,并使用这个机制实现了获取异步网络请求数据响应的功能。你会发现,回调机制基本上是依靠匿名类来实现的,但是匿名类的写法通常比较烦琐,比如以下代码:

HttpUtil.sendHttpRequest(address, object : HttpCallbackListener{
    override fun onFinish(response: String) {
        TODO("Not yet implemented")
    }
    override fun onError(e: Exception) {
        TODO("Not yet implemented")
    }
})

发起多少个网络请求,就要编写多少次这样的匿名类实现。现在,Kotlin的协程使我们能有更简单的写法:

suspendCoroutine函数

将传统回调机制的写法大幅简化

  1. 必须在 协程作用域或挂起函数 中调用
  2. 接收一个Lambda表达式参数
  3. 主要作用是将当前协程立即挂起,然后在一个普通的线程中执行Lambda表达式中的代码。
    Lanmbda表达式的参数列表上会传入一个Continuation参数,调用它的**resume()方法或resumeWithException()可让协程恢复执行**
    例子:
//1.首先定义一个request()挂起函数,接受一个address参数
suspend fun request(address:String):String{
	//调用suspendCoroutine函数,当前协程会被立刻挂起,Lambda表达式中的代码会在普通线程中执行
    return suspendCoroutine { continuation ->
        HttpUtil.sendHttpRequest(address,object : HttpCallbackListener{
        //sendHttpRequest()方法发起网络请求,通过传统回调方式监听请求结果
            override fun onFinish(response:String){
                continuation.resume(response)
                //**Continuation的rewsume()方法**恢复被挂起的协程
                //**传入服务器响应的数据,该值会成为suspendCoroutine函数的返回值**
            }
            
            override fun onError(e:Exception){
                continuation.resumeWithException(e)
                //调用**continuation.resumeWithException()恢复被挂起的协程**
                //并传入具体的异常原因
            }
        })
    }
}

//2.简化的原因:不需要重复进行回调实现了
suspend fun getBaiduResponse(){
    try {
        val response = request("https://www.baidu.com/")
        //对服务器响应的数据进行处理
    }catch (e:Exception){
        //对异常情况进行处理
    }
}

getBaiduResponse()是一个挂起函数,因此当它调用了request()函数时,当前的协程就会被立刻挂起,然后一直等待网络请求成功或失败后,当前协程才恢复运行。这样即使不使用回调的写法,我们也能够获得异步网络请求的响应数据,而如果请求失败,则会直接进入catch语句当中。

不过这里会有一个问题,getBaiduResponse()函数被声明成了挂起函数,就只能在协程作用域其他挂起函数中调用了,使用起来就会有局限性。因为suspendCoroutine函数本身就是要结合协程一起使用的。不过通过合理的项目架构设计,我们可以轻松地将各种协程的代码应用到一个普通的项目当中。

suspendCoroutine函数几乎可以用于简化任何回调写法,使用Retrofit发起网络请求:

之前的写法
val appService =  ServiceCreator.create<AppService>()
appService.getAppData().enqueue(object : Callback<List<App>>{
    override fun onResponse(call:Call<List<App>>,response:Response<List<App>>){
        //得到服务器返回的数据
    }
            
    override fun onFailure(call:Call<List<App>>,t:Throwable){
        //对异常情况进行处理
    }
})

使用suspendCoroutine函数进行简化
//由于不同的Service接口返回的数据类型不同,所以这次就要使用泛型的方式。定义一个await()函数:
//await()函数是**一个挂起函数**,声明了一个泛型T,将**await函数定义成了Call<T>的扩展函数**
//**所有返回值是Call类型的Retrofit网络请求接口都可以直接调用await()函数**
suspend fun <T> Call<T>.await():T{
	//使用**suspendCoroutine函数**挂起当前协程
	//由于是扩展函数,所以现在拥有了Call对象的上下文,直接调用enqueue()方法让Retrofit发起网络请求
    return suspendCoroutine { continuation ->
        enqueue(object :Callback<T>{
            override fun onResponse(call: Call<T>?, response: Response<T>?) {
                val body = response.body()
                if(body!=null) continuation.resume(body)
                else continuation.resumeWithException(//为null抛出异常
                    RuntimeException("response body is nulll"))
            }

            override fun onFailure(call: Call<T>?, t: Throwable?) {
                continuation.resumeWithException(t)
            }
        })
    }
}

有了await()函数之后,我们调用所有Retrofit的Service接口都会变得极其简单,比如:
suspend fun getAppData(){
	try{
		val appList = ServiceCreator.create<AppService>().getAppData().await()
		//得到服务器返回的数据
	}catch(e:Exception){
		//对异常情况进行处理                 
	}
}
//还可以用统一的入口函数继续精简。