Kotlin中的并发

并发

在Android系统上,应用程序可能不会执行许多的计算,反而会花费大量时间用来等待——无论是等待从服务器获取天气数据,等待从数据库检索用户数据,还是等待电子邮件到达。同时,阻塞主线程会冻结UI并在一段时间后导致异常发生,从而导致非常严重的用户体验。

首先有几个极为容易混淆的术语:并发、并行、多任务、多线程。

  • 并发本身只说明程序的不同部分,可能无序运行而不影响最终结果,并不意味着并行执行。在单核计算机上,同样可以运行并发程序,其任务在时间上重叠,因为处理器时间会在进程(和线程)之间共享。并发是正确运行并行程序的必要条件,若并行运行不能并发的程序,会得到不可预测的结果。
  • 本文中并行多任务是同一个意思,都是指同时实际并行执行多个计算任务。有时候并行执行也称为并发执行,这也是会被混淆的原因。
  • 多线程是实现(并行)多任务的一种特殊方式,即通过线程功能来实现。多个线程可以存在于同一个进程并共享其地址空间和内存。由于这些线程可以在多个处理器或内核上独立运行,因此可以实现并行。
  • 异步的重要特点是可以在等待结果的同时不阻塞主线程。本文中异步和非阻塞同义。

使用现实生活中的例子来解释的话,可以将:并行想象成一群正在进行网球表演的人,每个人都在进行各自的表演:把球扔到地面使其弹跳、或把他扔到空中等等。并发则是一个人在表演的同时玩四个球的杂耍,他一次只关注一个球,而这就是分时。异步则可以表现为一个人在做饭的同时等待其他人洗完衣服,由于这两件事互不冲突,所以他们是非阻塞的。

以下是这些术语的简要概括:

  • 并发:代码单元无序执行(无论并行还是分时),并且仍能获得正确结果的能力
  • 并行:同时执行多个代码单元
  • 多任务:与并行相同
  • 多线程:使用线程来实现的并行
  • 异步:主线程以外的非阻塞操作

普遍难题

并非所有程序都是并发(即无序还能获得正确结果)执行的,通常情况下程序执行的顺序十分重要,尤其是当并发单元会共享可变状态的时候,例如线程。两个线程可以读取同一个变量,也可以同时进行修改,而后修改的那个线程,往往会覆盖掉前一个线程的修改。

用来描述普遍难题的经典例子就是哲学家就餐问题。五位哲学家围坐在圆桌,五根筷子分布在这些哲学家的中间,每位哲学家都拥有一碗米饭,左手和右手各有一根筷子。哲学家会处于“吃饭”或“思考”的状态,想要吃饭就需要同时拿起各自左边和右边的筷子。

在此场景中,“哲学家”是等待调用资源的进程或线程,“筷子”是共享资源,而“吃饭”是调用共享资源的操作,相反地“思考”则是未调用。对于每根筷子来说,都会有两位哲学家对其进行竞争,而这与并行线程不谋而合。

我们设定每个哲学家先拿起自己左手的筷子,再拿起自己右手的筷子,接着进行吃饭。若每个哲学家在吃饭时统一拿起自己左手的筷子,同时等待自己右手的筷子,那么所有哲学家都没饭吃,因为此时所有哲学家此时右手都是没筷子的,此时会陷入无休止等待右手筷子的情况,这个问题便是十分经典的死锁问题。每当并行单元由于循环依赖而不能继续执行的时候,就会出现死锁,此时便要设定更多的条件(被称为Coffman条件),其中之一便是互斥。互斥通常用来防止出现其他同步问题,落实到哲学家问题上便是要求每个哲学家不能同时使用同一根筷子,即每个进程不能同时使用同一个资源。

当我们通过对同一资源(筷子)采用互斥访问来避免死锁问题时,一次就只能有一个线程(哲学家)访问这一资源(同一根筷子),通常可以通过(哲学家拿住筷子,也就锁住了其他人访问的权限)来实现这一操作。

除了并发和并行这类经典问题,在Android中还需要考虑一些时间上的问题。例如,当我们要终止一个异步调用时,只能在发布该调用的Acitivity被销毁并重新创建之后,才能终止。因此,保持对已经销毁的Activity的引用,会阻止该Activity被回收,从而导致内存泄漏。此外,尝试对已经销毁的Activity进行访问,也会导致App崩溃。

对于这类问题,感知到Activity处于什么样的生命周期是至关重要的,上述情况都是在错误的时候做了错误的事情,此时可以通过能感知生命周期的组件来解决这类问题。

另一个问题便是线程本质上是开销昂贵的对象,因此开发者应谨慎创建并对已有线程进行复用。而kotlin的协程则是解决了这一问题,这是因为启动协程的开销要比线程低一个数量级。

最先进的解决方案

在多核处理器的世界中,多任务是提高并行算法性能的主要手段,最常见的做法就是使用多线程。在通常情况下,每个进程中运行执行多个线程,因此这些线程可以共享该进程中的地址空间和内存,这样可以允许线程之间进行高效的通讯,但同时也带来了前文提到的同步问题。

对于异步调用,一种常见的方法是使用回调。回调是传递给另一个函数f的函数c,并且一旦函数f的结果可用、或发生另一个事件,函数c就会执行。例如通过请求API来获得天气数据(函数f的结果可用),并使用回调函数,来将新的天气报告数据在UI上进行更新(回调函数函数c执行)。

但在实际操作中,往往希望一个操作之后,执行另一个操作,完成之后再跟着另一个,然后回调的问题就会变成随着每个嵌套操作,代码的缩进级别不断增加,导致代码变得高度嵌套且不可读,用于异常处理的附加回调也会增加代码的复杂度,回调地狱就是形容这样的场景。

//1-异步回调
fetchUser(userId) { user ->
    fetchLocation(user) { location ->
        fetchWeather(location) {
            updateUI(weatherData)
        }
    }
}

示例中这种带有回调的编程风格也被称为延续性传递风格(CPS)。在CPS中,延续的内容总是显式传递的,并会将上一个的计算结果作为下一个计算的参数。

异步的另一个用法是future,它拥有诸多名字,如CompletableFuture、Call、Deferred或Promise,但这最终都代表异步计算的一个单位,也意味着它假定了稍后会返回一个值或一个表达式到future。future们可以避免嵌套回调的问题,并且通常会提供组合器来对结果执行额外的计算,例如thenApply、thenAccept,顾名思义,便是接收到结果后,对该结果进行操作。

//2-异步Future
fetchUser(userId) 
    .thenCompose { user        -> fetchLocation(user) }
    .thenCompose { location    -> fetchWeather(location) }
    .thenAccept  { weatherData -> updateUI(weatherData) }

这些组合器使编程风格和传递异常更为流畅,但压力给到了我们自己,因为我们需要记住这些组合器的名称,同时他们的实现方式并不一致。而与kotlin相比,虽然future相较回调做出了诸多改进,但kotlin的协程仍然可读性更高,也无需使用组合器。 

编写异步代码的第三种方法时使用async-await语言结构,这种语言结构在C#、Python中有类似的表达。其中async和await都是语言关键字,前者用于执行异步操作的函数,后者则用于显式地等待异步操作完成。

//3-async-await模式异步
async Tak<Location> FetchLocation(User user) {...}
var location = await FetchLocation(user)//需要从await的值来获取location

亦可以理解为await函数将其拉回到同步的世界中,在此等待操作完成后,才能进行下一步操作。而与kotlin相比,kotlin的挂起函数概念包括了async-await模式,但没有引入关键字,简化了这一操作。

Kotlin中的协程

协程从概念上可以理解为非常轻量级的线程,但他不是线程,而一个线程可以同时运行数以百万计的协程。

此外协程并不会绑定到某一特定的线程上,例如协程可以在主线程上启动、暂停,然后到后台线程上恢复,甚至可以定义如何来调度线程。这对于Andriod和其他UI框架上分配UI线程和后台线程之间的操作十分有用。

协程与线程类似,都有属于自己的生命周期:被创建、开始,并且可以被暂停和恢复。

与线程一样,可以使用协程来实现并行,可以加入协程以等待他们完成,也可以显式的取消协程。

另一个区别是线程使用抢占式多任务处理,而协程使用协作式多任务处理。这意味着线程之间通过调度器共享处理器时钟,而每个协程都可以控制其他协程,没有独立的调度员来介入其中。

引入协程

需要在build.gradle文件中的dependencies部分引入核心依赖。

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:0.25.3"
//对于Andriod还需要添加特定的Android依赖项
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:0.25.3"

协程与.kts尚不兼容,因此本文示例都会使用main函数作为入口。

基本概念

示例1、2中的代码可以使用协程的方式进行编写,如下所示。

//4-使用协程来处理异步
val user = fetchUser(userId)
val location = fetchLocation(user)
val weatherData = fetchWeather(location)
updateUi(weatherData)

示例中,看起来就像被阻塞了一样,例如第一行代码,会被认为当前线程会被阻塞到接收到user信息为止,以免该线程在此期间执行其他任务。但这类函数实际上属于挂起函数,也就是说这些函数可以以非阻塞方式暂停执行。 

挂起函数(suspend)

协程与挂起函数的关系是,挂起函数只能在协程代码内部调用,非协程代码不能被调用。

suspending函数在被明确定义处以非阻塞方式暂停代码的执行,也就是说,当前正在执行suspending函数的协程与其他正在运行的协程分离,然后一直处于等待状态,直到该函数恢复执行(很可能在另一个线程上),在此期间,线程可以自由执行其他任务。

//5-声明suspend函数
suspend fun fetchUser(): User {...}

//6-回调及future的函数签名,对比suspend
fun fetchUser(callback: (User) -> Unit) {...}    //回调
fun fetchUser(): Future<User> {...}              //Future

suspend关键字将普通函数转变为挂起函数,对比回调和future,他的函数签名十分普通。

除非函数中有需要挂起的位置,否则suspend关键字是多余的,而除了挂起位置,该函数在运行时是无法暂停的。为了引入这样的挂起点,可以以调用另一个挂起函数的方式实现,kotlin中的所有挂起函数都适用这类操作,因为所有的此类函数都最终会调用低级别函数suspendCoroutine,也可以直接使用这个低级别函数。

//7-将协程来用于异步
suspend fun updateWeather(){
    val user = fetchUser(userId)              //挂起点1
    val location = fetchLocation(user)        //挂起点2
    val weatherData = fetchWeather(location)  //挂起点3
    updateUi(weatherData)
}

在Android Studio中,会在挂起点提示,与示例注释类似。

updateWeather函数可以在每个挂起处,并且仅在这些挂起处,以非阻塞的方式执行挂起控制。

默认情况下三个挂起函数按顺序执行,这与诸如C#等编程语言中的异步函数行为有所不同。C#中的默认异步行为是顺序运行的,但这一行为要通过使用await函数来实现。 

kotlin开发团队决定将顺序行为作为默认行为,且鼓励开发人员始终明确使用异步。例如基于future的fetchUser函数,更好的命名应该是fetchUserAsync或asyncFetchUser,这也说明异步函数在被调用的时候是显式调用的。

//8-改进异步函数命名
fun fetchUserAsync(): Future<User> {...}

//调用该异步函数的地方
val user = fetchfetchUserAsync()

挂起函数具有强大的力量,可以在不阻塞线程的情况下将执行挂起。但main函数和onCreate函数仅仅是普通的函数,并不支持这样强大的功能,因此也就不能调用挂起函数。

挂起函数只能从另一个挂起函数中、可挂起Lambda表达式,协程或者内联到协程的Lambda表达式中调用(因为它可以从协程中有效调用)。

实际上,协程至少需要一个挂起函数才能启动,因此每个协程都会在创建时提供挂起函数作为其任务,通常采用可挂起Lambda的形式将其传到协程构建器中以启动新的协程。

协程构建器

协程构建器是启动新协程并发出异步请求的起点。如上所述,协程构建器接受一个提供协程任务的suspending Lambda表达式,kotlin提供了三种不同的协程构建器:

  1. launch用于没有返回值的即用即弃操作
  2. async用于有返回结果(或异常)的操作
  3. runBlocking用于桥接阻塞与非阻塞的世界

协程中的Job任务

上面介绍了三种不同的协程构建器,而他们在创建并启动协程之后,会返回一个Job类型的任务,这个Job是协程创建的后台任务,持有协程的引用,因此他代表当前协程的对象。

获取Job类型的任务,作用是判断当前任务的状态。Job任务的状态有三种:New(新建的任务)、Active(活动中的任务)、Completed(已结束的任务)。Job任务有两个字段:isActive(是否在活动中)和isCompleted(是否停止),通过这两个字段的值可以判断当前任务的状态。

Job状态isActiveisCompleted
New(新建)falsefalse
Active(活动中)truefalse
Completed(已结束)falsetrue
fun main() {
    val job: Job = launch {
        delay(1000)
        println("hello")
    }
    println("主线程睡眠前:isActive=${job.isActive} isCompleted=${job.isCompleted}")

    println("world")
    Thread.sleep(2000)
    println("主线程睡眠后:isActive=${job.isActive} isCompleted=${job.isCompleted}")
}

 

睡眠后该任务已是结束状态。

协程中的join函数

若删除上一示例主线程中的“Thread.sleep(2000)”,运行结果只会输出world字符串,不会输出hello,这是因为协程中启动的是一个守护线程,当主线程完成后,协程就会被销毁掉,因此协程中的代码就不会再执行。

如果想输出hello字符串,除了使用示例中的让主线程睡眠,还可以使用Java线程中的join函数,该函数表示的是当前任务执行完成后再执行其他操作。

fun main(): Unit = runBlocking {
    val job: Job = launch {
        delay(1000)
        println("hello")
    }
    println("主线程睡眠前:isActive=${job.isActive} isCompleted=${job.isCompleted}")

    println("world")
    //Thread.sleep(2000)
    job.join()//协程执行完后再执行其他操作
    println("主线程睡眠后:isActive=${job.isActive} isCompleted=${job.isCompleted}")
}

将示例中的主线程睡眠替换为join函数,由于join同时是一个挂起函数,还需要把main方法设置为主协程,即添加runBlocking,关于runBlocking的内容在下一节。

使用runBlocking桥接阻塞与非阻塞的世界

前面我们认识到了,在非挂起函数中不能调用suspending函数,如下所示。

fun main(){
    updateWeather()
    //Error:挂起函数必须从协程或其他挂起函数中被调用
}

如上所示,main函数只是普通的函数,不具备使用功能强大的挂起函数的能力,若要在main函数中调用,可以使用runBlocking,下面进行修改。

fun main(){
    runBlocking{
        //使用runBlocking后,可以在其中调用挂起函数
        updateWeather()
    }

}

runBlocking作为协程构建器,他会创建并启动一个新的协程。runBlocking顾名思义,他会阻塞当前的线程,直到传递进来的代码块(在实例中是updateWeather方法)执行完成。

该线程仍然可以被其他线程中断,但不能执行任何其他工作,这也是为什么永远不应该从协程中调用runBlocking的原因,因为协程应该是始终非阻塞的。

需要注意的是,runBlocking只是一个函数,而非关键字,这一点与其他协程构建器是一样的。而由于runBlocking函数的签名限制,传入协程构建器的Lambda表达式应当是挂起Lambda类型。

//runBlocking(简化)签名
fun <T> runBlocking(block: suspend() -> T)//阻塞线程,直到协程执行完毕
//可见此处传入的是有suspend关键字的挂起lambda表达式

在函数类型前添加suspend关键字,以此方式将lambda参数定义为挂起函数,这称为挂起函数类型,也称挂起lambda表达式。suspend关键字使得传入的lambda表达式被挂起,以便其他挂起函数可以在runBlocking中调用它。

在实际使用中,runBlocking主要用于main方法和单元测试中。

//runBlocking的习惯用法
//在main函数中
fun main() = runBlocking<Unit> {//直接通过“=”指定函数体
    updateWeather()
}

//在单元测试中
@Test fun testUpdateWeather() = runBlocking { ... updateWeather() ... }

在main函数中,通常必须使用runBlocking<Unit>的格式来显示指定Unit作为返回类型。当lambda表达式的返回值不是Unit时,这样的写法就是必须的,因为main函数必须接受Unit类型的返回值。而为了避免出错,通常会使用runBlocking<Unit>这一格式,以确保返回值永远正确。

注:绝对不要在协程中使用runBlocking,只能在常规代码中使用挂起函数。

使用launch构建器的即发即弃协程

launch协程构建器用于创建那些独立于主程序而执行的即发即弃协程。

他与线程相似,通常用于引入并行(同时执行多个代码单元),而协程中未捕获的异常将会打印到stderr,并导致程序崩溃。

//launch创建新的协程
launch {
    println("Coroutine started")
    delay(1000)//在协程中调用挂起函数
    println("Coroutine finished")
}

println("Script continues")
Thread.sleep(1500)
println("Script finished")

示例中,launch创建并启动了一个新的协程,其中的delay函数是标准库中的挂起函数,此处也变成了一个挂起点。通过运行结果可知,即使是delay之前的print语句(Coroutine started),也会在launch以外的print语句(Script continues)执行之后,才会执行打印。

这揭示了在kotlin中,协程是依附到目标线程上的,并且会在目标线程之后执行,即延迟执行。

这与JavaScript中的行为相似,与之不同的是,C#会直接执行每个异步函数,直到他的第一个挂起点出现,如果是以这种方式执行,“Coroutine started”会在“Script continues”之前被打印。

注:

        协程构建器的默认“目标线程”,是由DefaultDispatcher对象来决定的。

        自Java8开始,默认的DefaultDispatcher对象是CommonPool,它是一个使用ForkJoinPool.commonPool的共享线程池;而线程池是一种多线程的处理方式,里面放入了许多等待被调用的线程。

        如果CommonPool不可用,会尝试创建一个新的ForkJoinPool实例(自Java7可用);

        如果再次失败,则会使用Executors.newFixedThreadPool来创建线程池。

        不过CommonPool已成弃子,在阅读本文时,他可能不再是默认的调度器了。

        但不论如何,默认的调度器依然用于那些受到CPU限制操作的线程池。因此线程池的大小与CPU的核数相同。该线程池不应用于网络请求或I/O操作。为网络请求和I/O操作使用独立的线程池是一种很好的时间,而在访问UI时,协程与所有人一样,都必须使用主线程。

 对比可知,delay是非阻塞的,而Thread.sleep是阻塞的,通过runBlocking函数可以将Thread.sleep替换成delay函数,从而保持代码一致。但是无论是delay还是Thread.sleep,都是通过设置固定时间来控制代码执行,从而等待并行任务,这实际上不是一种好的实践方式。因此,我们应当使用launch返回Job对象来显式等待任务完成。

下一示例展示了这些改进内容,需要注意的点是,等待是必须的,因为处于活动状态的协程是不会阻止程序退出的。此外,join是一个挂起函数,因此需要使用runBlocking。

//等待协程
runBlocking {//在调用挂起函数join时,runBlocking是必要的
    val job = launch { //launch返回一个Job
        println("Coroutine started")
        delay(1000)
        println("Coroutine finished")
    }

    println("Script continues")
    job.join()//等待协程完成(非阻塞)
    println("Script finished")
}

到目前为止,以上这些操作实际都可以用线程来完成。kotlin甚至为线程的创建提供了协程构建器类似的语法thread{...}。但是使用协程可以轻松创建100000个任务以实现100000个工人并行工作的场景。

runBlocking {
    val jobs = List(100_000) {//启动100000个协程
        launch {
            delay(1000)
            print("+")
        }
    }
    jobs.forEach { it.join() }
}

示例中,打印100000次“+”而不出现内存溢出的异常。launch返回的是Job类型的值,因此最后jobs列表中的每一个值都是Job类型的。

runBlocking {
    val jobs = launch {
        repeat(10){
            delay(300)
            println("${it + 1} of 10...")//只会打印前三个
        }
    }

    delay(1000)
    println("main(): No more time")
    jobs.cancel()
    jobs.join()
    println("main(): Now ready to quit")
}

调用job.cancel()之后,协程会接收到一个CancellationException表示的取消执行的信号。虽然说这是个异常,但他是所预期的协程停止的方式,因此不会打印到stderr。

协程必须明确地支持取消,取消可以通过定期调用kotlin标准库中的任意一个挂起函数来实现,因为这些挂起函数都是可取消的。另一种方式是在协程中定期检查可用的isActive属性。

runBlocking{
    val job = launch {
        repeat(10){
            Thread.sleep(300)            //非合作线程
            println("${it + 1} of 10...")//10个iterations都会执行
        }
    }

    delay(1000)
    job.cancelAndJoin()//cancel会被忽略
}

在此处使用了非合作的Thread.sleep,而非Kotlin中属于合作的delay函数。这也是为什么即使在执行一秒后使用job.cancelAndJoin方法去取消,协程仍会完整执行,同时保持线程处于繁忙状态。此处使用的是协程中的cancel函数和join函数合并后的cancelAndJoin函数,这个函数用于取消协程。

runBlocking {
    val job = launch {
        repeat(10) {
            if (isActive) {
                Thread.sleep(300)
                println("${it + 1} of 10...")
            }
        }
    }

    delay(1000)
    job.cancelAndJoin()
}

示例中,展示了以另一种不需要从标准库调用挂起函数的方法:手动检查isActive。当每次检查isActive时,这都是一个可以取消协程的点,在示例中即是10个。

取消协程会使得isActive的值变为false,每次判断isActive的值时,如果值为false,协程会直接跳过剩余的迭代内容并终止执行。因此,想要实现协程取消,只需要确保能够经常对isActive进行检验,并且当isActive的值为false时就可以取消协程。

使用async返回值的异步调用

async用于创建会返回结果或异常的异步调用,这对于需要提取数据的操作非常有用。

如果传递给async的lambda返回类型T,则调用async会返回Deferred<T>,这是来自kotlin协程库中的轻量级future实现。若要获得实际的返回值,则必须对其调用await函数。

与C#一样,await会非阻塞的等到async任务完成,以便将结果返回。与之不同的是,kotlin中的await不是关键字,而是一个函数。

fun fetchFirstAsync() = async {     //返回值类型:Deferred<Int>
    delay(1000)    
    294                             //返回lambda表达式的值,类型为Int
}

fun fetchSecondAsync() = async {    //返回值类型:Deferred<Int>
    delay(1000)
    7                               //返回lambda表达式的值,类型为Int
}

fun main() {
    runBlocking {
        val first = fetchFirstAsync()
        val second = fetchSecondAsync()
        println("first的值是$first,\n类型是${first.javaClass},\nawait之后的值是${first.await()}")
        println("\nsecond$second,\n类型是${second.javaClass},\nawait之后的值是${second.await()}")
    }
}

需要注意的是,在使用async时要注意需要带有Async后缀函数的命名的规则,这样就可以明确异步的调用位置。

示例中,这两个异步函数会启动一个新的协程来模拟网络调用,每个调用需要1分钟(delay(1000))并返回一个数值。因为传入async的Lambda表达式是会返回Int类型的变量,所以这两个异步函数返回的类型将会是Deferred<Int>。

在使用await之前,直接获取first或是second的值将会是Deferred值。这是因为Deferred值只是包装了可以使用的潜在对象,若想要获得数据,需要使用await方法等待。而这些数据可能存在也可能不存在。在示例中、我们指定了fetch的返回值,所以说await是能够等待到返回值的。

当代码运行到first.await()会将运行挂起,直到fetchFirstAsync函数的返回值可用,这对于变量second来说也是一样的。

runBlocking{
    val first = fetchFirstAsync().await()
    val second = fetchSecondAsync().await()
    ...
}

本示例是对上一示例的修改。在默认情况下,挂起函数是顺序执行的。示例中对变量first、second初始化的同时等待async方法的值,此操作的结果是在执行第二个异步调用之前,会在first上先调用await函数,进而才轮到second的具体值获取。

相比于修改前的示例中两个await同时运行,这样的同步运行异步函数会使得运行时间延长一倍。以上两个异步函数并不是kotlin代码中的惯用用法。kotlin鼓励将顺序执行的行为作为默认行为,而异步只是作为可选项加入其中。然而在本示例中,异步是默认执行的,且因为同时调用了await方法,这样做强制执行了同步。

suspend fun fetchFirst(): Int {
    delay(1000)
    return 294
}

suspend fun fetchSecond(): Int {
    delay(1000)
    return 7
}

fun main() {
    runBlocking {
        val a = async { fetchFirst() }
        val b = async { fetchSecond() }
        println("Result: ${a.await() / b.await()}")//运行结果:Result: 42
    }
}

示例中,两个fetch属于挂起函数,拥有标准类型的返回值,且不使用async后缀。这两个函数默认顺序执行,在使用的时候使用async包装起来,以此实现异步。

相较于launch方式,更应该记住async方式启动的线程,因为这种方式可以带有结果或异常。

runBlocking {
    val deferred: Deferred<Int> = async { throw Exception("Failed...") }

    try {
        deferred.await()//尝试获得async调用的结果,抛出异常
    } catch (e: Exception) {
        //处理失败情况
    }
}

示例中,展示了如何处理错误的情况,其中await实现了两个功能:若操作执行成功,则返回结果:若出现异常,则抛出该异常,因此他被包装到try-catch中。

同时还可以使用isCompleted属性和isCompletedException属性来直接检查Deferred的状态,但后者结果如果为false,并不代表操作成功完成,可能依然是活动的状态。除此之外,还有一个deferred.invokeOnCompletion回调,在该回调中,可以使用deferred.getCompletion Exception OrNull来访问抛出的异常。但在大多数情况,示例中的方法足矣。

注意:launch与join、async与await之间的类似关系。

launch和async都用于启动一个并行执行工作的新协程。在launch中可以使用join来等待执行完成,而在async中通常使用await来等待结果。

launch返回Job类型,而async返回Deferred类型。但Deferred实现了Job接口,所以同样可以对Deferred使用cancel和join,尽管这种情形不太常见。

协程的上下文(CoroutineContext)

对于所有协程构建器,都会接收一个CoroutineContext,里面包含了一组诸如协程名称,协程调度器以及协程任务详细信息的索引元素。由于协程太过于轻量级,以致如果需要修改上下文的话,直接新建一个线程就可以了。

协程上下文的两个最重要的元素,一个是用来决定协程在哪个线程运行的CoroutineDispatcher,另一个是提供了有关执行详细信息的Job,Job可用于生成子线程。除此之外,上下文还包含了CoroutineName和CoroutineExceptionHandler。

协程调度器

当线程需要恢复时,其调度器决定将该协程在哪个线程上恢复。

launch(UI) {
//UI调度器由Android特定的coroutines包提供,来自于gradle的Android依赖项
    updateUi(weatherData)
}

所有主流的UI框架,如Android、Swing和JavaFX都拥有这样的UI调度器。Android UI调度器被定义为顶级的公共属性,它是指位于主线程中的Android主looper。

//在Android中定义UI上下文
val ui = HandlerContext(Handler(Looper.getMainLooper()), "UI")

在恢复协程之前,CoroutineDispatcher有机会去改变该协程所要恢复的位置。带有默认调度器的协程,始终会在CommonPool线程池中的某一个线程上进行恢复,因此每次挂起都会在线程之间进行跳转。而所有UI调度器都确保始终在UI线程上进行恢复。与之相对,具有默认上下文的协程,因为使用了CommonPool,所以可以在此线程池中的任意一个后台线程上进行恢复。

//默认协程调度器
launch {...}
launch(DefaultDispatcher) {...}
launch(CommonPool) {...}

示例中展示了三条等效的launch语句,前文提到协程构建器的“目标线程”是由DefaultDispatcher决定的(第二行用法),而在java8之后,默认的DefaultDispatcher是CommonPool(第三行),省略不写(第一行)则是会直接使用默认的DefaultDispatcher。

另一种可能是在新的专用线程中运行协程。而这需要重新创建线程,引入了线程创建的成本,也因此抵消了轻量级的优势。

//调度到新线程,在新线程中运行协程
launch(newSingleThreadContext("MyNewThread")) {...}

示例中,使用newSingleThreadContext来在新线程中运行协程,浙江创建一个线程池,也可以使用newFixedThreadPoolContext创建具有多个线程的线程池。

如果希望协程完全不受任何线程和CommonPool的约束,可以使用Unconfined调度器。

//unconfined调度器
launch(Unconfined) {...}

这会在当前的调用框架中启动协程,然后再恢复挂起函数运行的位置将其恢复。基本上,unconfined调度器绝不会对恢复进行拦截,只是让恢复协程的挂起函数来决定在哪个线程上运行。

最后一个coroutineContext,实际上不是调度器,是它本身包含一个调度器。他是一个顶级属性,可以在每个协程中使用并代表其上下文。通过协程的上下文来启动另一个协程,被启动的协程就会变成当前协程的子协程。

准确的说,这两协程父子关系并不是由调度员引起的,而是由作为上下文一部分的job引起的。而取消父作业将以递归的方式取消其所有子作业。

//创建子协程
launch(coroutineContext) {...}

下面的示例中,使用到了所有调度器,以便比较他们在挂起前后调度到的线程。

fun main() {
    runBlocking {
        val jobs = mutableListOf<Job>()

        jobs += launch {
            println("Default: In thread ${Thread.currentThread().name} before delay")
            delay(500)
            println("Default: In thread ${Thread.currentThread().name} after delay")
        }

        jobs += launch(newSingleThreadContext("New Thread")) {
            println("New Thread: In thread ${Thread.currentThread().name} before delay")
            delay(500)
            println("New Thread: In thread ${Thread.currentThread().name} after delay")
        }

        jobs += launch(Unconfined) {
            println("Unconfined: In thread ${Thread.currentThread().name} before delay")
            delay(500)
            println("Unconfined: In thread ${Thread.currentThread().name} after delay")
        }

        jobs += launch(coroutineContext) {
            println("coroutineContext: In thread ${Thread.currentThread().name} before delay")
            delay(500)
            println("coroutineContext: In thread ${Thread.currentThread().name} after delay")
        }

        jobs.forEach { it.join() }
    }
}

示例中,在runBlocking中启动了4个协程,涵盖了所有讨论过的调度器,结论如下:

协程调度器delay前delay后
默认调度器(CommonPool)在ForkJoinPool.commonPool-worker-1在ForkJoinPool.commonPool-worker-1
单线程上下文在新线程中在新线程中
Unconfined在主线程中在kotlinx.coroutines.DefaultExecutor
coroutineContext在主线程中在主线程中

默认调度器:协程总是会调度到线程池中某个工作线程内。使用java8作为编译环境时会使用ForkJoinPool.commonPool;

单线程上下文:协程始终在特定的线程内执行;

Unconfined:重点是确认当前线程为主线程,因为Unconfined不会再任何位置主动进行调度,所以协程只能在当前的主线程中启动。而在挂起结束之后,Unconfined可以在挂起函数指定的位置进行恢复,由于delay使用了kotlin.coroutine包中的DefaultExecutor,因此会在此地恢复;

coroutinesContext:coroutinesContext继承了runBlocking的上下文,所以它同样要在主线程中启动。而因为coroutinesContext总是在其父类继承的主线程中恢复。

异步调用的库函数

很多时候希望协程的不同部分分别运行在不同的线程上,一个典型的场景就是进行异步调用后在后台线程上获取数据,然后在ui可用的时候,将其显示在ui上。

//在协程内切换上下文
suspend fun updateWeather(userId: Int) {
    val user = fetchUser(userId)
    val location = fetchLocation(user)
    val weatherData = fetchWeather(location)

    withContext(UI) {
        updateWeather(weatherData)
    }
}

launch { updateWeather() }

示例中使用到了withContext,要想将协程的部分内容运行在特定的上下文中,就可以使用它。此时,只有当代码涉及ui才会在ui线程上执行。同时,挂起函数可以在线程池中启动以执行异步调用,而不会影响ui线程,直到最终结果可用。

当写成在同一个线程中,协程之间上下文切换的开销,远比线程之间上下文切换的开销要小得多。但是,如果协程分别位于不同线程中的话,切换协程上下文需要昂贵的线程切换开销。

注:

        withContext函数对于在Android和其他ui框架中,轻松切换后台线程和ui线程至关重要。这是因为所有ui更新必须在ui线程中完成。

        除非需要并行运行多个异步调用,否则当希望从挂起函数中返回结果时,withContext通常是比async-await更好的方法。例如在执行数据库操作时:

        

suspend fun loaduser(id: Int): User = withContext(DB) {...}

        前文学习可知,async的返回值是deferred,且需要使用await等待。用withContext的方式,就可以使用普通的返回类型,并确保数据库操作时只在专用的DB上下文中运行。

另一个方便的异步调用库函数是withTimeout。它可以方便的指定调用超时时间。当当前协程超时时,将会自动取消并抛出异常。

fun main() {
    runBlocking {
        withTimeout(1200) {
            repeat(10) {
                delay(500)
                println("${it + 1} of 10")
            }
        }
    }
}

示例中,超时之前会执行两次print语句。与launch构建起的job.cancel类似,该超时由TimeoutCancelllationException指示,但与CancellationException不同的是,TimeoutCancelllationException会打印到stderr并导致程序再未处理捕获异常时终止。

fun main() {
    runBlocking {
        try {
            withTimeout(1200) {
                repeat(10) {
                    delay(500)
                    println("${it + 1} of 10")
                }
            }
        } catch (e: TimeoutCancellationException) {
            println("Time is up!")
        } finally {
            println("Cleaning up open connections...")
        }
    }
}

示例中,展示了一种安全的方法来调用withTimeout,其中finally代码块可用于关闭协程打开的所有连接。

生成器

生成器是一个可以按需要惰性地生成值的迭代器。虽然它的实现看起来像是连续的,但再请求下一个值之前,其执行将被挂起。它使用yield函数将值发射出去。

另一种更理论地看待生成器的方式是认为,生成器不是通用协程,因为协程可以控制其他协程,而生成器总是控制着它的调用者。

fun main() {
    val fibonacci = buildSequence {
        yield(1)
        var a = 0
        var b = 1
        while (true) {
            val next = a + b
            yield(next)
            a = b
            b = next
        }
    }
    
    fibonacci.take(10).forEach { println(it) }
}

与协程构建器类似,buildSequence接收一个挂起Lambda,但他本身不是挂起函数,而是使用协程来计算新的值。yield是一个挂起函数,它的作用是将函数中值加入到Sequence中,在示例中,他的操作是将next加入到了fibonacci中。yield将会执行挂起,直到下一个元素被请求。因此每次调用next都会使生成器运行,直到执行下一个yield。

buildSequence与generateSequence类似,但他使用挂起函数和yield,而generateSequence每次调用提供给它的函数来生成下一个值,并且不会使用协程。

注:

        生成器不会引入异步,其执行完全受到调用者控制,并且与调用者同步执行。这是同步挂起函数的一个示例,并且演示了挂起计算并不一定意味着异步。你可以将其视为除了非调用者调用它,否则在生成器内部不会做任何事情。换句话说,yield是一个挂起函数,而非异步函数。

        这也解释了为什么kotlin使用suspend作为关键字,而非C#那样使用async来作为关键字。请记住,在kotlin中,你可以选择是否使用异步,而不是仅靠用suspend来标识。

actors和channels

简介

管道(channel)提供了一种输出的价值流,在概念上与BlockingQueue(阻塞队列)非常类似,但区别在于,管道不是一个阻塞put操作,而是一个暂停发送操作,不是一个阻塞take操作,而是一个暂停接受操作。阻塞队列会阻塞线程,而管道则不会。

并发编程中的actor模型中,actor表示与其他actor分离的并发计算单元。actor不会直接共享可变状态,他们之间只通过传递消息来通信。每个actor都附加了一个消息通道,以便能够接收消息。actor基于接收到的消息来决定接下来做什么,是生产更多的actor,还是发送消息,抑或是操纵其私有状态。由于不会存在竞争条件,所以在没有共享状态时不需要使用锁。

注:

        在actor模型的原始描述中并没有提到channel,在actor之间直接进行通信,而channel可以被认为是另一种独立的actor。在该模型中,一切皆actor。

通常来说,actor可以和channel建立多对多的关系,以便单个actor可以读取多个channel的信息。同样多个actor可以从同一个channel中读取消息以分发工作。虽然这是一个并发模型,但actor自身是按照顺序执行的。如果一个actor接收到三条消息,他会按照顺序对他们进行处理。该模型的力量来源于这种actor并行工作的机制。并发由这种仅通过消息传递来进行通信的方式实现。

在kotlin中可以使用协程来实现actor,管道中有两个方法,分别是send和receive,这两个方法分别用于发送和接收数据。

//一个简单的actor
fun main() {
    val actor = actor<String> {
        val message = channel.receive()
        println(message)
    }

    runBlocking {
        actor.send("Hello World")//发送一个元素到actor的channel
        actor.close()            //关闭channel因为actor不再被需要
    }
}

示例中,在主线程中,一个字符串被发到actor的channel中,然后channel将被关闭。kotlin为创建actor提供了actor函数,该函数实际上是另一种协程构建器,这是因为actor在kotlin中是协程,所以创建一个actor意味着创建一个协程。actor尝试从其channel接收消息,该消息可以在lambda中从其接受其类型ActorScope来访问。这里有以下几点需要注意:

  • send和receive都是挂起函数。当channel已满时send会暂停执行,而当channel为空时receive会暂停执行。
  • 因此,main函数必须使用runBlocking来进行包装,以便能够调用挂起的send。
  • 调用close不会立即停止actor协程。相反,close会发送特殊的消息“close token”到channel中,channel仍然会按照先进先出(FIFO)的方式读取消息队列,所以该特殊消息之前所有的消息都会在实际停止之前处理。
  • 在channel关闭之后再向起发送消息,会导致ClosedSendChannelException。
//在其channel中保持读取行为的actor
fun main() {
    val actor = actor<String> {
        for (value in channel) {
            println(value)
        }
    }

    runBlocking {
        actor.send("Hello")
        actor.send("World")
        actor.close()
    }
}

示例中,for循环部分是整个代码的关键,它用来保证actor存货知道channel被关闭。现在,你可以向actor发送任意数量的消息,然后再不需要actor的时候调用close。

因为actor函数返回一个SendChannel,所以只能向actor发送消息,而不能从channel接收消息(只有actor才应该这样用)。在actor lambda中,channel具有Channel<E>类型,并实现了SendChannel<E>和ReceiveChannel<E>。因此,可以在lambda中调用send和receive。

但当给自己发送消息时,actor必须确保channel未满,否则他将在等待receive时被无限期的挂起并且产生死锁。所以应当先使用channel.isFull来检查channel是否已满,并且确保给channel设置容量至少为一个元素的缓冲区,通过设置capacity来实现。

注:

        kotlin的channel遵循FIFO模式,优先接收那些先发送的元素,公平对待所有类型元素。

        actor函数作为协程构建器,与之前学到的构建器非常相似。它接受一个CoroutineContext、一个CoroutineStart、一个显式的父Job和定义其行为的挂起Lambda。这些工作方式与之前都一样,一旦元素首次发送到其channel中,懒惰启动的actor就会变成活动状态。

除了这些参数以外,actor还有一个用来定义其channel能够缓冲多少个元素的capacity参数。默认情况下capacity为0,意味着send必须暂停直到receive执行,反之亦然,这种方式被称之为RendezvousChannel,即发送方和接收方必须在某一个时间点"会面"。

对于capacity,默认值味RENDEZVOUS,代表容量为0,除此之外,他的参数还有:

  • CONFLATED:代表容量为1,新的数据会代替旧的数据
  • UNLIMTED:代表无限容量
  • BUFFERED:代表具备一定缓存的容量,默认情况下是64,具体容量由参数kotlinx.coroutines.channels.defaultBuffer决定
runBlocking {
    val actor = actor<String>(capacity = Channel.CONFLATED) {
        for (value in channel) { println(value) }
    }

    actor.send(“Hello”)
    actor.send(“World”)
    delay(500)
    actor.close()
}

示例中,receive在for循环中被隐式调用,并且在有新的元素发送到其channel之前,都会被挂起。同时capacity的参数被设置为Channel.CONFLATED,以便在发出receive之前,不再将send挂起。而因为新接收的值会覆盖旧的值,所以receive检索到的值都是最新的值。

需要注意的是,不设置delay延迟的话,代码很可能不会打印任何东西。因为当actor第一次接收到消息的时候,close token是最新接受到的元素。

除此之外,可以使用Channel.UNLITIMED创建具有无限容量的channel(使用LinkedListChannel),或使用actor(capacity = n)来使用缓冲区大小为n的channel(使用ArrayChannel)。前者使用链表来缓冲元素,后者使用数组。当channel被设置为无限容量时,发起者永远不会被挂起。

接下来可以创建多个channel的actor协程,并从首先具有元素的channel接受一条消息。

//多channel的actor
runBlocking {
    val channel1 = Channel<Int>()
    val channel2 = Channel<Int>()

    launch {//不需要actor的协程构建器
        while(true){
            select<Unit> {//在这里提供任意数量的替代数据源
                channel1.onReceive{ println(“From channel 1: $it”)}
                channel2.onReceive{ println(“From channel 2: $it”)}
            }
        }
    }
    channel1.send(17)//发送17到channel1,这样这个资源会被首先选择
    channel2.send(42)//发送42到channel2,使得channel2在接下来被选择
    channel1.close(); channel2.close()
}

示例中,实现了一个actor,他使用select语句同时从两个channel读取数据。他显式地创建了两个channel,因此此时不需要actor的协程构建器(actor的协程构建器可以参考示例"一个简单的actor"和"在其channel中保持读取行为的actor"中actor的初始化)。从两个channel中选择的所有协程,都可以在此处作为actor。

由于receive一次只允许从一个channel中读取消息,所以在示例中使用了onReceive方法。而在select中还可以使用onSend、onAwait、onLock、onTimeout和onReceiveOrNull等方法。所有这些方法,在内部都实现了SelectClause接口,因此可以在select中进行任意组合一边使用可用的值执行某些操作,无论这些值是延迟结果、超时、获取锁定还是在channel中的新消息。换句话说,无论发生什么事件,这些时间都将被"选中"。

注:

        select函数更新倾向于执行第一条语句,也就是说,同时有多条子句可供选择的时候,select将选择第一条子句。可以利用这一特点,使得当主channel未满时将消息发送到主channel,否则发送到辅助channel。

        如果不希望出现这样的行为,可以使用selectUnbiased,这会使得每次选择时执行随机选择,使选择变得更加公平。

 同样的,可以将多个actor发送到同一个channel。

//同一个channle上的多个actor
runblocking {
    val channel = Channel<String>()
    repeat(3) { n->
        launch {
            while(true) {
                channel.send(“Message from actor $n”)
            }
        }
    }
    channel.take(10).consumeEach { println(it) }
    channel.close()
}

在前面的示例中,有几个协程表现出来的行为与传统意义上的actor行为不符,这是因为他们与传入的channel无关,而与传出的channel息息相关,我们称之为生产者。示例中,创建了三个生产者,对局部变量channel"生产"(channel.send)消息。所有生产者都在尝试向channel重复发送消息,并且只接受者出现之前,他们都会被挂起。消费者方面,consumeEach替代了显式的for循环,而从同一个channel进行读取操作的多个协程可以以相同的方式实现,只需对他们提供读取channel对象的权限即可。

直观上来看,生产者是actor的对立面,因为他拥有附加的传出channel,而kotlin提供了更为简便的方法来创建这类生产者。

生产者与消费者

生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。

而这个阻塞队列,就是用来给生产者消费者来解耦的,纵观大多数设计模式,都会找出一个第三者出来解耦,而在kotlin中,他是协程。在本节开始时,提到了协程与阻塞队列的不同之处。

//创建一个生产者
runBlocking {
    val producer = produce {
        var next = 1
        while(true) {
            send(next)
            next += 2
        }
    }    
    producer.take(10).consumeEach { println(it) }
}

示例中,produce协程构建器创建了一个生产者,并返回一个ReceiveChannel。需要注意的是,例子中的生产者会不断发送数值,他发送的操作只受他所使用的RendezvousChannel的管控,所以在另一端出现接收器(或者说消费者)之前send都会被挂起。如果没有take(10)的限制,程序将会保持两倍的打印速率直到整数溢出。

要想使用比Int或String更为有趣的消息类型,可以使用其他预定义类型或者自定义的消息类型。密封类非常适合构建消息类型受限的类层次结构。

//定义消息类型的actor
sealed class Transaction//使用.kt文件类型来声明一个密封类
data class Deposit(val amount: Int) : Transaction()
data class Withdrawal(val amount: Int) : Transaction()

fun newAccount(startBalance: Int) = actor<Transacation>(capacity = 10) {
    var balance = startBalance
    for (tx in channel) {
        when(tx) {
            is Deposit    -> { balance += tx.amount; println(“New balance: $balance”) }
            is Withdrawal -> { balance -= tx.amount; println(“New balance: $balanc”) }

        }
    }
}

fun main() = runBlocking<Unit> {
    val bankAccount = newAccount(1000)
    bankAccount.send(Deposit(500))
    bankAccount.send(Withdrawal(1700))
    bankAccount.send(Deposit(4400))
}

示例中,actor作为银行账户来接受交易信息,而交易使用密封类的方式来定义,在示例中是存款和取款两种方式。账户的缓冲区可以缓冲10笔交易(capacity = 10),交易若超出这一范围将会被挂起,直到账户处理能够赶上进度为止。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值