稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Kotlin进阶之协程从入门到放弃

协程Coroutine是最新式的并发编程范式,它是纯编程语言层面的东西,不受制于操作系统,轻量级,易于控制,结构严谨,不易出错,易于测试,工具和配套设施都比较完备。在新生代编程语言(如Kotlin和Swift)中支持良好,在Kotlin中有着非常友好的支持,并且是写异步和并发程序的推荐方式。为了彻底学会使用协程和理解协程背后的原理,计划用三篇文章专注来学习协程。

  • 第一篇:主要介绍协程的基本概念,以及如何使用协程,目标就是讲清基本概念,并快速上手。
  • 第二篇:协程的高级用法,如结构化协程,Scope,Context,Exception handling,在框架中使用(如在Compose和Jetpack中),与Flow一起使用。目标就是进一步发挥协程的威力,写出专业健壮的协程代码 。
  • 第三篇:理解协程的核心原理,以及协程的实现机制,以及在其他编程语言中的支持情况。目标是深刻理解协程的原理的实现机制,做到心中无剑,以及尝试在不支持协程的语言中实现协程

注意:在任何一个编程语言中异步和并发编程总是略微复杂的话题,Kotlin中的协程也不例外,因此需要先有一定的前置知识,也就是说要大概弄懂操作系统中的进程与线程, 以及要有一些Java中的线程和并发编程经验,否则是没有办法很好理解和使用Kotlin协程的。

Hello, coroutines

每当学习一门新的技术,最喜欢的方式就是快速的上手,比如先弄个『Hello, world!』之类的,而不是上来就讲什么概念,原理,范式和方法论。编程是门实践性很强的学科,要快速上手快速体验,当有了一定的感觉之后,再去研究它的概念和原理。

我们也要从一个『Hello, coroutines!』开始我们的Kotlin协程之旅。

1
2
3
4
5
6
7
8
fun main() = runBlocking {
    launch {
        delay(1000)
        println(", coroutines!")
    }
    print("Hello")
}
// Hello, coroutines!

以常规的方式来思考,写在前面的语句会先执行,写在后面的语句会后执行,这就是同步的意思,似乎应该输出:

1
2
, coroutines!
Hello

但我们得到了期望的输出『Hello, coroutines!』,这就是协程的作用,它可以实现异步。这里launch是一个函数,后面的lambda是它的参数,它的作用就是启动一个协程来运行传入的代码块。这个代码块很简单,它先delay了1秒,然后再输出语句。因为启动了协程,并且协程里的代码等了1秒再执行余下的语句,因此,主函数中的输出语句先执行了,这样就得到了我们期望的输出顺序。

配置协程运行环境

注意,注意,协程并不是Kotlin标准库的一部分,它属于官方扩展库的一部分,有自己单独的版本号,要想使用协程还需要单独配置依赖。协程模块的名字是kotlinx.coroutines,有自已独立的版本号,需要注意的是,要注意Kotlin版本与协程版本之间的匹配关系,协程库对它所支持的Kotlin有最低版本要求。目前协程库最新版本是1.8.0-RC2,它对应的Kotlin版本是1.9.21。

配置协程库依赖:

Maven

1
2
3
4
5
6
7
8
9
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>1.8.0-RC2</version>
</dependency>

<properties>
    <kotlin.version>1.9.21</kotlin.version>
</properties>

Gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0-RC2")
}

plugins {
    // For build.gradle.kts (Kotlin DSL)
    kotlin("jvm") version "1.9.21"

    // For build.gradle (Groovy DSL)
    id "org.jetbrains.kotlin.jvm" version "1.9.21"
}

repositories {
    mavenCentral()
}

Android

1
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0-RC2")

协程是啥

那么协程是啥呢?协程就是一个子例程,或者说一个函数,与常规的函数其实也没啥区别,只不过它可以异步地执行,可以挂起,当然不同的协程也可以并行的执行(这就是并发了)。协程是没有阻塞的,协程只会挂起,一旦协程挂起,就交出CPU的控制权,就可以去执行其他协程了。协程是一种轻量级的线程,但它并不是线程,跟线程也没有直接关系,当然它跟其他函数一样,也是要运行在某一个线程里面的。

在Kotlin中协程的关键字是suspend,它用以修饰一个函数,suspend函数只能被另一个suspend函数调用,或者运行在一个协程内。另外就是delay函数了,它是将协程挂起一定时间。launch/async函数则是创建并启动一个协程,await函数是等待一个协程执行结束并返回结果。runBlocking函数则是创建一个可以使用协程的作用域,叫作CoroutineScope,协程只能在协程作用域内启动,作用域的目的就是为了管理在其内启动的协程。不理解或者记不住这些关键字和函数也没有关系,这里只需要先有一个印象就够了。

动动手,折腾一下

对于我们的『Hello, coroutines!』程序,可以尝试进行一些修改,比如改一下delay的值,去掉runBlocking,或者去掉launch看看会发生什么!

创建协程

在继续之前,我们把之前的代码重构一下,把协程代码块抽象成一个函数:

1
2
3
4
5
6
7
8
9
10
11
fun main() = runBlocking { // this: CoroutineScope
    launch { doWorld() }
    println("Hello")
}
// Hello, coroutines!

// this is your first suspending function
suspend fun doWorld() {
    delay(1000L)
    println(", coroutines!!")
}

功能没变仍是输出『Hello, coroutines!』只不过代码块变成了一个suspend函数,被suspend修饰的函数只能运行在协程之中,或者被另一个suspend函数调用,当然 最终仍是要运行在某一个协程之中的。

创建协程的函数是launch()和async(),它们都是函数,参数都是一个代码块,它们的作用是创建一个协程并让代码块参数运行在此协程内。把上面的launch换成async得到的结果是一模一样的:

1
2
3
4
5
fun main() = runBlocking { // this: CoroutineScope
    async { doWorld() }
    println("Hello")
}
// Hello, coroutines!

当然了,它们之间肯定是区别的,要不然何必费事弄两个函数呢,我们后面再讲它们的具体区别。

到现在我们知道了如何创建协程了,但如我们手动把runBlocking删除掉,就会有编译错误,说launch/async找不到,那是因为这两个函数是扩展函数,它们是CoroutineScope类的扩展函数。前面说了,所有的协程必须运行在一个CoroutineScope内,前面的runBlocking函数的作用就是创建一个CoroutineScope,下面我们重点来看看啥是CoroutineScope。

协程作用域

作用域(CoroutineScope)是用于管理协程的,所有的协程必须运行在某个作用域内,这样通过作用域就可以更好的管理协程,比如控制它们的生命周期。这里面的概念就是结构化并发(structured concurrency),也就是让所有的协程以一种结构化的方式来组织和管理,以让整体的并发更为有秩序和可控。

这与人类社会是类似的,比如军队,要把士兵编为不同的组织结构(如团,旅,师,军,集团军),目的就是增强整体的执行效率,进而增强战斗力,试想一个军队,如果没有组织结构,那就会是一盘散沙,战斗力可想而知。

如何创建作用域

有很多构造器方法可以用于创建作用域,基本上不会直接创建作用域对象。最常见的就是用coroutineScope函数,它的作用是创建一个CoroutineScope,执行里面的协程,并等待所有的协程执行完毕后再退出(返回),我们可以继续改造我们的例子,自己为我们的协程创建一个作用域:

1
2
3
4
5
6
7
8
9
10
11
fun main() = runBlocking {
    doWorld()
}

suspend fun doWorld() = coroutineScope {  // this: CoroutineScope
    launch {
        delay(1000L)
        println(", coroutines!!")
    }
    println("Hello")
}

还有一些其他的作用域生成方法如runBlocking和GlobalScope,GlobalScope是一个全局的作用域,也就是Kotlin提供的一个在整个Kotlin中都可以直接使用的协程作用域,显然,我们不应该使用它,因为作用域的目的在于组织和管理协程,如果把所有的协程都放在一个全局作用域下面了,那跟没有使用域也没有啥区别了。就好比一个军队,只有一个将军,下面直辖一万个士兵,这跟没有将军是没有分别的。

至于runBlocking,它是创建一个作用域,执行其里面创建的协程,等待所有协程执行完毕后退出,但它还有一个重要的功能就是,在等待协程执行的过程中它会阻塞线程,以保证调用者的线程一定比协程晚些退出。因此,只应该在一个地方使用runBlocking,那就是在主函数中使用,其他地方都不应该使用它。

虽然说协程必须运行在某一个CoroutineScope中,但是不是说在每个要创建协程的地方都使用coroutineScope创建一个新的作用域呢?这显然是滥用了。作用域的目的在于组织和管理协程,因此作用域应该符合架构设计的原则,比如为一个模块或者同一类功能创建一个作用域,以方便管理其内部分的协程。并且CoroutineScope是树形结构的,也就是说作用域本身也可以管理其他作用域,这才能形成完整的结构,体现结构化并发的思想。

使用框架中的CoroutineScope

如前所述作用域更多的要从架构角度来考虑。实际上大多数时候,我们并不需要自己创建作用域,因为框架会为我们准备好。就好比Jetpack中的ViewModel,它的作用是把UI操作的逻辑封装起来,那么ViewModel中的所有协程都应该运行在viewModelScope之中,而这是框架已经为我们创建好了的,它会结合系统组件生命周期来管理协程。

运行上下文

协程不是什么神密的东西,也不是什么银弹,它就是一个普通的函数(例程routine),只不过它可以异步执行,也就是说launch了一个协程后,这条语句很快就执行完了,马上去执行launch {…}下面的语句了,协程代码块的执行是在协程里面,它什么时候返回结果是不知道的。也可以挂起,协程挂起后就释放了运行它的线程,并不会阻塞运行它的线程,那么其他协程就有机会运行。

这就涉及另一个重要的东西,就是协程运行的上下文,或者说协程运行的线程环境。协程它就是一个函数,它当然需要运行在某个线程里面。除非特别指定以切换运行的线程,否则所有的协程是运行在主线程中的。

协程的运行环境由CoroutineContext来定义,但其实基本上不会直接创建这个对象,都是通过参数或者其他构建函数来指定协程的运行上下文环境。

创建协程时指定上下文

创建协程的函数launch和async是有多个参数,一共有三个参数,最后一个当然是代码块,前面两个都是有默认值的参数,因此大部分时候可以省略,它们的完整函数签名是:

1
2
3
4
5
6
7
8
9
10
11
fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job

fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T>

第一个参数便是指定协程运行的上下文。现在可以为我们的协程加上线程环境了:

1
2
3
4
5
6
7
8
9
10
11
fun main() = runBlocking {
    doWorld()
}

suspend fun doWorld() = coroutineScope {  // this: CoroutineScope
    launch(Dispatchers.Default) {
        delay(1000L)
        println(", coroutines!!")
    }
    println("Hello")
}

使用扩展函数withContext

另外一种方式就是使用扩展函数withContext,在其参数指定的上下文环境中调用代码块中的协程,等待其执行完,并返回结果。

1
suspend fun <T> withContext(context: CoroutineContext, block: suspend CoroutineScope.() -> T): T

上面的例子也可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
fun main() = runBlocking {
    doWorld()
}

suspend fun doWorld() = coroutineScope {  // this: CoroutineScope
    launch {
        withContext(Dispatchers.Default) {
            delay(1000L)
            println(", coroutines!!")
        }
    }
    println("Hello")
}

但这并不是好的用法,withContext应该用在一些suspend方法中,并且这些方法想自己指定执行环境,并且执行环境对调用方是透明的。比如说,一个负责用户操作的UesrRepository,它只向外部暴露一些suspend方法,在这些suspend方法内部通过withContext来指定它自己运行的上下文环境,从而不用管调用者的执行环境,不也需要调用者知道repo的执行环境:

1
2
3
4
5
6
7
8
9
class UserRepository(
    val dispatcher: Dispatcher = Dispathers.IO
) {
    suspend fun login() {
        withContext(dispatcher) {
            // Do login
        }
    }
}

让每一个架构层次或者模块自己管理好自己运行的上下文,还有一个好处在于,可以方便的通过依赖注入来进行Mock或者测试

使用框架中的上下文环境

虽然我们可以指定协程运行的上下文环境,那是不是意味着要自己创建很多的context呢?非也,非也。框架中也预定义好了很多context,可以直接拿来用,比如Dispatchers.Default,这是Kotlin中的默认线程适合做计算密集类任务;Dispatchers.IO,这适合做IO密集的操作,如文件读写,网络等;Dispatchers.Main,这是Kotlin中的主线程(即main函数运行的线程),UI中的主线程(如Swing和安卓的主线程);等等,当然了,也可以自己创建一个context。

到这里我们可以发现,现代化的并发框架较以前是是非常的完备,从创建,到管理,再到运行环境都考虑的非常全面。比如RxJava或者我们现在正在学习的协程,都是如此。在Java中,其实也有类似的东西,其实就是ExecutorService,它就是异步和并发任务运行的环境。只不过,它的API设计的还是太过原始,你仍然 需要自己去实现一个Executor,并没有像RxJava中的Schedulers以及Kotlin中的Dispatchers一样,有一些功能明确的预定义的对象可以直接使用。

并发性

并发就是代码『同时运行』,当然 有真并发,那就是并行,比如两台电脑同时都在运行不同的或者相同的应用程序,类似于两个人同时都在干活儿,这是并行(真并发);大多数并发都是假的,只不过操作系统以粒度非常小的时间片在不同的代码间来回切换,让人感觉起来好像所有的代码都在同时运行,但真到了CPU的指令周期里面,其实同一个周期只能执行一个命令。当然了,现代处理器都具有多核心,每个核心可以执行一个指令,因此多核心可以真的同时运行多个线程,也可以实现真并发。

并发的前提是要能异步,也就是像我们的launch {…}一样,它很快就执行完了,这样后面可以继续执行,因此,协程是可以实现并发的,也就是让多个协程『同时运行』:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun main() = runBlocking {
    doWorld()
}

// Concurrently executes both sections
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
    launch {
        delay(2000L)
        println(", coroutine #2, comes later!")
    }
    launch {
        delay(1000L)
        println(", coroutine #1, here I am!")
    }
    print("Hello")
}
//Hello, coroutine #2, here I am!
//, coroutine #1, comes later!

注意,我们这里是假并发,我们没有指定线程,两个协程都是运行在主线程里面的,但它们没有相互影响,更没有阻塞发生,它们确实是『同时运行的』。

当然了,在实际开发过程中呢,肯定还是要指定协程的运行线程,以实现真的并发,原因在于真实的软件代码是比较复杂,主线程,以及每个协程都有大量的代码要执行,都去揩主线程的油,肯定 很快就被榨干了,所以必然要上Dispatchers.IO之类的多线程以实现真正的并发。

可控制性

好的并发框架一定是可控的,也就是说对于异步任务来说要能很好的开启等待终止。Kotlin中的协程是可以做到这一点的。前面说到launch和async都可以创建一个协程,那它俩到底 啥区别?我们从前面它们的函数签名可以看出它俩的返回值是不一样的,launch返回一个Job对象,而async返回一个Deferred对象

Job对象可以理解为协程的一个句柄,可以用来控制协程,比如终止它(取消它cancel),『同步等待』它执行完(join())。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
suspend fun doWorld() = coroutineScope {
    val job = launch {
        delay(2000L)
        println(" and coroutine #2")
    }
    launch {
        delay(1000L)
        println("from coroutine #1 !")
    }
    println("Hello")
    job.join()
    println("All jobs done.")
}

fun main() = runBlocking {
    doWorld()
}

上面的例子输出是符合期望的:

1
2
3
4
Hello
from coroutine #1 !
 and coroutine #2
All jobs done.

而如果,把 job.join()去掉的话,因为launch {…}创建的协程是异步执行,很快就返回了,最后的语句println(“All jobs done.”)会得到执行,因为协程都有delay,所以『All jobs done.』要先于协程中的语句输出:

1
2
3
4
Hello
All jobs done?
from coroutine #1 !
 and coroutine #2

而Deferred是Job的一个子类,它特有的功能是取得协程的返回结果,通过其await函数可以『同步的等待』协程结果返回,launch可以通过Job来等待协程执行完成,但是拿不到协程的返回结果,这就是launch与async的最大的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
fun main() = runBlocking {
    val one = async { computeOne() }
    val two = async { computeTwo() }

    println(" Finally we got: ${one.await() + two.await()}")
}

private suspend fun computeOne(): Int {
    return withContext(Dispatchers.IO) {
        print("Coroutine #1: Calculating ...")
        delay(2400)
        val res = 12
        println(", got $res")
        return@withContext res
    }
}

private suspend fun computeTwo(): Int {
    return withContext(Dispatchers.IO) {
        print("Coroutine #2: Calculating ...")
        delay(2200)
        val res = 20
        println(", got $res")
        return@withContext res
    }
}
//Coroutine #1: Calculating ...Coroutine #2: Calculating ..., got 20
//, got 12
// Finally we got: 32

注意,注意:前面说Job#join()和Deferred#await()都可以『同步地等待』协程执行完成,但这里的『同步等待』是非阻塞式的,它只是把当前协程挂起,虽然说join和await后面的语句在协程返回前不会得到执行,但这并不是像join/sleep/wait之于Thread那种阻塞式的。协程的join和await只是挂起,把运行环境中的线程释放,在此期间其他协程是可以得到CPU资源(即线程)继续运行的。

总结

本文主要介绍了Kotlin中的协程基本使用方法:在一个协程作用域中,通过launch/async来创建一个协程,通过context来切换协程的运行上下文(线程环境),并可以通过Job/Deferred对象来控制协程。

到此,我们可以总结出协程的一些特点:

  • 轻量级,它是纯编程语言层面的东西,不涉及操作系统支持的进程和线程的创建,因此它占用的资源非常少,是轻量级的异步和并发利器。
  • 非阻塞式,协程最重要的特点是非阻塞,它的等待虽然会让其后面的语句延迟执行,但此时运行的线程已被释放,其他协程可以得到运行。
  • 设施完备,管理协程的作用域,切换运行环境的context,协程的可控,可以非常优雅的实现结构化并发编程,从而减少出错,并且完全可测。

其实,可以看出协程是一种代码执行上的操作框架,它能让代码挂起,交出真实的CPU控制权(可以想像为一个大的switch语句,在不同的函数之间跳转切换)。进程和线程都是操作系统直接支持的,操作硬件资源的方法,一个运行中的线程必须占有一个CPU核心,线程只能被阻塞,无法挂起,因为操作系统切换线程就意味着让CPU去运行另外一个线程,那么前一个线程就进入了阻塞状态(Blocked),等操作系统再切换回这个线程时,它才得以继续运行,从阻塞状态转为运行状态。而协程是纯的编程语言层面实现的东西,视线程为透明,一旦挂起,就可以去执行另一坨代码,它全靠程序员自己来控制,协程,即一起协作的子例程,这也是协程,作为新一代并发编程范式最大的优势。

书籍推荐

《Kotlin编程实战》是推荐的书籍,这本书比较厚实,把Kotlin的每个特性都论述的十分详细。

实践

强烈推荐官方的一个实战教程,非常适合入门,难度也不大,并且有答案,可以一步一步的学会使用协程,并理解它。

参考资料

Comments