Kotlin协程(一)—— 基础部分

202 阅读7分钟

1. 概念

Coroutines basics | Kotlin (kotlinlang.org)

Kotlin的协程机制是Kotlin的一大特色,主要用于处理形形色色的异步任务,并优化其性能,简化其操作

在现在的Android开发中,将协程与Retrofit进行结合,运用到各种需要异步加载的场景当中,Kotlin作为Goolgle官方推荐的语言,正在逐步发挥其光热,而协程则是其中的利器之一
kotlinx.coroutines作为官方的重要程序库提供了协程的支持

2. 项目创建

首先,还是先创建一个项目,当然使用Koltin语言,使用Gradle进行构建,因为这个Android里用得比较多,DSL可以随便选,主要也是一些固定API

image.png

项目创建完成之后,需要对于依赖进行一个配置,由于使用的DSL是Kotlin,这边生成的文件就成了build.gradle.kts了,这是项目构建的配置文件

image.png

需要在依赖的代码块中加入对于Kotlin协程核心库的依赖

dependencies {
    testImplementation(kotlin("test"))
    // 协程的核心依赖
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}

接下来同步Gradle,会到远程仓库下载依赖到本地

3. 基础

3.1. 初识协程

协程是一个可挂起计算实例,在概念上与线程相近,利用一个代码块和其他的代码并发执行
但是,协程不与特定的线程绑定,可能会在线程A中挂起,随后又在线程B中恢复执行

可以将协程看作是轻量级线程,但是二者之间也存在巨大差异,使得二者在实际使用上大不相同

先编写最简单的协程代码,观察并运行一下

fun main() = runBlocking {  // 协程域(前面提到的代码块)
    launch {
        delay(1_000L)   // 非阻塞延时
        println("World!")  
    }
    println("Hello")  // 主协程输出
}

image.png

最终的输出先输出Hello,然后等待约1s,再输出World!

3.2. 剖析

上面的代码中包含了协程的几个要素,接下来意义理解一番

launch是协程的builder,它发起一个新的协程,该协程与其余的代码并发执行,并且独立工作,因而Hello首先输出,它就是所谓的其余代码

delay是一个特殊的挂起函数
它会将当前的协程挂起一段指定的时间
挂起协程不会阻塞底层的线程,但是允许其他的协程为自己的代码运行并使用底层的线程

runBlocking也是一个协程的builder,在这里的使用是将main()(非协程部分)与runBlocking{}包裹的协程代码进行了连接

image.png

在Idea的环境中对应会有协程作用域的提示,对应的正是刚介绍的两个协程builder方法

如果将当前的runBlocking代码块移除掉,那么代码就会直接报错

image.png

定位到launch{}代码块上查看一下其定义相关的信息,可以发现launch实际上是一个扩展函数,需要其调用者是CoroutineScope类型,因此runBlocking{}用以提供这样的一个环境,没有这样的环境,就不能直接使用launch{}

接下来继续分析runBlocking{} 调用runBlocking{}的线程(在这里就是主线程),会在调用的期间一直阻塞,直到runBlocking{}代码块中的协程代码全部执行完毕
runBlocking{}通常用于应用程序的顶层(这里我的理解是应用程序各功能模块的调用方),而很少用于真实的业务代码的内部;这是因为线程是很昂贵的资源,如果阻塞它们代码执行会非常低效,并且通常情况下也不会有这样的需求

3.3. 结构化并发

协程都遵循一个原则——结构化并发 结构化并发的含义就是新的协程只能在一个指定的协程域,即CoroutineScope之上创建,而创建新协程的这个协程域,也就决定了新协程的存活时间

根据之前的例子,runBlocking{}创建了CoroutineScope,因此在其上创建协程的launch{}的存活时间被拿捏了,在协程域中创建一个协程去打印World!,同时也打印Hello;先打印Hello,但是并没有结束,因为协程要等待1s执行World!,完成后程序退出

state.gif

既然结构化并发采用这种代码块嵌套的方式来编写,那么似乎也是采用一种树形结构的方式将协程进行关联和管理,有点类似文件树和视图树结构

在实际的应用开发中,可能需要启动许多的协程进行业务执行,如何有条不紊地进行管理就需要依赖于结构化并发,因为这能确保它们不会丢失或者引起内存的泄漏

外层的协程域需要等它的所有子级协程全部执行完毕,方可结束
结构化并发也可以确保任何代码中的错误都可以被正确报告,不会遗漏

3.4. 提取函数,进行重构

launch{}的代码进行提取,会生成一个带有suspend标记的函数,这便是挂起函数
挂起函数在协程中可以像非协程环境中的常规函数那样调用
不同的是,在其中使用其他挂起函数可以挂起当前协程执行

fun main() = runBlocking {
    launch {
        print()
    }
    println("Hello") 
}

// 提取生成挂起函数
private suspend fun print() {
    delay(1_000L)
    println("World!")
}

3.5. 作用域建造器(Scope Builder)

除了提供的不同的协程域建造器,也可以由开发者借助coroutineScope建造器进行自定义
coroutineScope将会创建一个协程作用域,并且直至由其发起的所有协程全部完成才会结束

runBlocking{}coroutineScope{}可能在看上去比较相似,这二者都会等待它们内部的代码以及子级的协程执行完毕才会结束
二者主要的不同在于runBlocking{}会阻塞当前的线程,用以等待内部任务执行完毕;而coroutineScope{}仅仅是挂起,它会释放底层的线程用作其他用途
正是由于这点不同,runBlocking{}仅仅是一个常规函数,而coroutineScope{}是一个挂起函数

image.png

fun main() = runBlocking {
        coroutineScope {   // 挂起函数,包裹原逻辑,结果其实一样
            launch {
                delay(1_000L)
                println("World!")
            }
            println("Hello")
        }
}

state.gif

3.6. 作用域建造者与并发

coroutineScope{}可以在任意的挂起函数内部进行调用,进行多个并发操作

fun main() = runBlocking {
        coroutineScope {
            launch {    // 启动协程1
                delay(1_000L)
                println("World!")
            }
            launch {   // 启动协程2
                delay(3_000L)
                println("...")
            }
            println("Hello")   
        }
    println("Over")    // 全部结束执行
}

state.gif

首先,在coroutineScope{}中有三个并发操作,两个launch{}以及本身的Hello,因为没有延时,先打印Hello,随后1s后打印World!,再过2s打印...,等到全部结束之后,位于runBlocking{}中的Over被打印

3.7. Job对象

launch{}协程建造器会返回一个Job类型的对象,它会持有被创建的协程,通常可以用于等待特定的协程完成

fun main() = runBlocking {
        coroutineScope {
            val job = launch {
                delay(1_000L)
                println("World!")
            }
            job.join()  // 等待Job对应的协程执行完
            println("Hello")
        }
    println("Over")
}

通过Job对象对应launch{}的内容,调用join()等待代码块执行完毕,原本直接执行的Hello被迫等待1s,让World!先执行完

3.8. 协程是轻量的

协程使用的资源密度要低于同等情况下的线程
一些耗尽资源的线程操作换做协程表示,并不会超出限制

fun main() = runBlocking {
    repeat(100_000) {
        launch {    // 协程并不会消耗太多
            delay(5_000L)   
            print(".")
        }
    }
}
fun main() = runBlocking {
    repeat(100_000) {
        thread {   // 换成线程就很卡顿
            Thread.sleep(5_000L)
            print(".")
        }
    }
}

协程短短几秒就输出结果,并且内存上涨起伏很小(从原来500M ~ 550+M吧)
而线程运行了很长时间,而且内存增长很快,被反复回收(从500M ~ 700+M,然后被回收了(只分配了750M))