一篇文章理解Kotlin協(xié)程

這篇文章大部分內(nèi)容來自:https://github.com/Kotlin/kotlinx.coroutines/blob/master/coroutines-guide.md

這篇教程基于一系列的例子來講解kotlinx.coroutines的核心特性
筆者使用的kotlin版本為1.2.51,協(xié)程核心庫的版本為0.23.4
注意:協(xié)程庫還處于實驗階段,API是不穩(wěn)定的,謹慎用于生產(chǎn)環(huán)境

簡介&安裝

作為一個語言,kotlin僅在標準庫里提供最少的底層API,從而讓其他庫能利用協(xié)程。不像其他有相似能力的語言,asyncawait不是kotlin的關(guān)鍵字,甚至不是標準庫的一部分。

kotlinx.coroutines是一個非常豐富的庫,包含若干高層協(xié)程啟動機制(launch,async等)。你需要添加kotlinx-coroutines-core模塊的依賴才能在你的項目中使用這些機制。

<!-- 筆者寫這篇文章時,最新的kotlin版本為1.2.51 -->
<properties>
    <kotlin.version>1.2.51</kotlin.version>
</properties>

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>0.23.4</version>
</dependency>

基本概念

這個章節(jié)覆蓋了協(xié)程的基本概念。

你的第一個協(xié)程

運行下面的代碼:

fun main(args: Array<String>) {
    launch { // 在后臺啟動一個新的協(xié)程,然后繼續(xù)執(zhí)行
        delay(1000L) // 不阻塞的延遲1s
        println("World!") // 延遲后打印
    }
    println("Hello,") // 當協(xié)程延遲時,主線程還在跑
    Thread.sleep(2000L) // 阻塞主線程2s,為了讓jvm不掛掉
}

運行結(jié)果:

Hello,
World!

本質(zhì)上,協(xié)程是輕量級的線程。可以使用launch協(xié)程建造器啟動。你可以將launch { ... }替換為thread { ... }delay(...)替換為Thread.sleep(...)以達到相同的效果。試試看。

如果你只把launch替換為thread,編譯器會產(chǎn)生如下錯誤:

Suspend functions are only allowed to be called from a coroutine or another suspend function

這是因為delay是一個特殊的函數(shù),這里暫且稱之為掛起函數(shù),它不會阻塞線程,但是會掛起協(xié)程,而且它只能在協(xié)程中使用。

連接阻塞和非阻塞世界

第一個例子在同一塊代碼中混合了非阻塞delay(...)和阻塞的Thread.sleep(...),很容易就搞暈了哪個是阻塞的,哪個是非阻塞的。下面,我們使用runBlocking協(xié)程建造器,明確指明阻塞:

fun main(args: Array<String>) { 
    launch { // 在后臺啟動一個新的協(xié)程,然后繼續(xù)執(zhí)行
        delay(1000L)
        println("World!")
    }
    println("Hello,") // 主線程立即繼續(xù)跑
    runBlocking {     // 這塊阻塞了主線程
        delay(2000L)  // 延遲2s,讓jvm不掛掉
    } 
}

結(jié)果還是一樣的,但是這代碼只用了非阻塞的dalay。主線程調(diào)用了runBlocking,然后一直被阻塞,一直到runBlocking執(zhí)行完成。

這個例子可以改得更符合語言習慣些,用runBlocking包裝主函數(shù)的執(zhí)行:

fun main(args: Array<String>) = runBlocking<Unit> { // 開始主協(xié)程
    launch { // 在后臺啟動一個新的協(xié)程,然后繼續(xù)執(zhí)行
        delay(1000L)
        println("World!")
    }
    println("Hello,") // 主協(xié)程立即繼續(xù)跑
    delay(2000L)      // 延遲2s,讓jvm不掛掉
}

這里runBlocking<Unit> { ... }的作用像一個適配器,用來啟動頂層的主協(xié)程。明確指定是Unit返回類型,是因為一個格式良好的kotlin主函數(shù)必須返回Unit

下面是為掛起函數(shù)寫單元測試的方法:

class MyTest {
    @Test
    fun testMySuspendingFunction() = runBlocking<Unit> {
        // 這里我們可以通過任何我們喜歡的斷言風格使用掛起函數(shù)
    }
}
等待任務(wù)(job)

當另一個協(xié)程在運行時,延遲一段時間并不是一個好辦法。讓我們明確的等待(非阻塞的方式),直到我們啟動的后臺任務(wù)完成:

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch { // 啟動一個新協(xié)程,并創(chuàng)建一個對其任務(wù)的引用
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    job.join() // 等到子協(xié)程完成
}

結(jié)果還是一樣的,但是主協(xié)程和后臺任務(wù)沒有用后臺任務(wù)的執(zhí)行時間聯(lián)系在一起。好多了。

提取函數(shù)重構(gòu)

讓我們來提取出launch { ... }塊中的代碼到另一個函數(shù)中。當你用“提取函數(shù)”重構(gòu)這塊代碼時,你會得到一個用suspend修飾的新函數(shù)。這是你一個掛起函數(shù)。掛起函數(shù)可用于協(xié)程中,就像使用普通函數(shù)一樣,但是它們有額外的特性——可以調(diào)用其他的掛起函數(shù)去掛起協(xié)程的執(zhí)行,像這個例子中的delay

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch { doWorld() }
    println("Hello,")
    job.join()
}

// 這是你第一個掛起函數(shù)
suspend fun doWorld() {
    delay(1000L)
    println("World!")
}
協(xié)程是輕量級的

運行下面的代碼:

fun main(args: Array<String>) = runBlocking<Unit> {
    val jobs = List(100_000) { // 啟動大量的協(xié)程,并返回它們的任務(wù)
        launch {
            delay(1000L)
            print(".")
        }
    }
    jobs.forEach { it.join() } // 等待其他全部的任務(wù)完成
}

這里啟動了十萬個協(xié)程,一秒之后,每個協(xié)程打印了一個點。你用線程試試?(很有可能就OOM了)

協(xié)程像守護線程

下面的代碼啟動了一個長時間運行的協(xié)程,一秒打印兩次"I'm sleeping",然后延遲一段后,從主函數(shù)返回:

fun main(args: Array<String>) = runBlocking<Unit> {
    launch {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L) // 延遲后就退出
}

你運行看看,打印了三行,然后就結(jié)束了:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...

活躍的協(xié)程并不會保活進程,所以它更像守護線程。

取消和超時

這個章節(jié)包含了協(xié)程的取消和超時。

取消協(xié)程執(zhí)行

在小應用中,從主函數(shù)返回看起來是個結(jié)束所有協(xié)程的好辦法。在更大的、長時間運行的應用中,需要更細粒度的控制。launch函數(shù)返回了一個可以取消協(xié)程執(zhí)行的Job

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L) // 延遲一小會
    println("main: I'm tired of waiting!")
    job.cancel() // 取消任務(wù)
    job.join() // 等待任務(wù)結(jié)束
    println("main: Now I can quit.")
}

運行,產(chǎn)生如下輸出:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

在調(diào)用job.cancel不久后,因為協(xié)程被取消掉了,所以看不到任何輸出了。Job的擴展函數(shù)cancelAndJoin結(jié)合了canceljoin的作用。

取消是需要配合的

協(xié)程的取消是需要配合的,協(xié)程的代碼必須可配合取消。所有kotlinx.coroutines中的掛起函數(shù)都是可取消的,這些掛起函數(shù)會檢查協(xié)程的取消狀態(tài),若已取消則拋出CancellationException。然而,如果協(xié)程正處于運算中,沒有檢查取消狀態(tài),那么其不可被取消,如下所示:

fun main(args: Array<String>) = runBlocking<Unit> {
    val startTime = System.currentTimeMillis()
    val job = launch {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) { // 浪費CPU的循環(huán)運算
            // 2秒打印一個消息
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // 延遲一會
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // 取消任務(wù),并等待其結(jié)束
    println("main: Now I can quit.")
}

運行看看。結(jié)果是,在取消之后,其持續(xù)打印"I'm sleeping",直到循環(huán)5次之后,任務(wù)自己結(jié)束。

使運算代碼可取消

兩種方式使運算代碼可取消。

  1. 周期執(zhí)行掛起函數(shù),檢查取消狀態(tài)。yield函數(shù)是達到這個目的的好辦法。
  2. 顯式的檢查取消狀態(tài)。

我們來嘗試下第二種方式:

fun main(args: Array<String>) = runBlocking<Unit> {
    val startTime = System.currentTimeMillis()
    val job = launch {
        var nextPrintTime = startTime
        var i = 0
        while (isActive) { // 可取消的運算
            // 一秒打印兩次消息
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // 延遲一會
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // 取消任務(wù),并等待其結(jié)束
    println("main: Now I can quit.")
}

現(xiàn)在,循環(huán)就是可取消的了。isActive是協(xié)程內(nèi)CoroutineScope對象的的一個屬性。

用finally釋放資源

可取消的掛起函數(shù)在取消時會拋出CancellationException,通常的方式就可以處理了。例如,try {...} finally {...}表達式或Kotlinuseuse api)函數(shù),會在協(xié)程取消時,執(zhí)行結(jié)束動作。

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            println("I'm running finally")
        }
    }
    delay(1300L) // 延遲一會
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // 取消任務(wù),并等待其結(jié)束
    println("main: Now I can quit.")
}

joincancelAndJoin都會等所有結(jié)束動作完成,因此以上代碼的輸出如下:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
main: I'm tired of waiting!
I'm running finally
main: Now I can quit.

運行不可取消的代碼塊

任何嘗試在finally塊中使用掛起函數(shù)均會產(chǎn)生CancellationException,因為運行代碼的協(xié)程已經(jīng)被取消了。通常,這不是個問題,因為所有有良好實現(xiàn)的關(guān)閉操作(關(guān)閉文件,取消任務(wù),或關(guān)閉任何種類的溝通通道)通常是非阻塞的,并不需要掛起函數(shù)參與。但是,在很少的情況下,你需要在取消的協(xié)程中進行掛起操作,那么你可以將相應代碼的使用withContext(NonCancellable) {...}包裝,這里使用了withContext函數(shù)和NonCancellable上下文,如下所示:

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            withContext(NonCancellable) {
                println("I'm running finally")
                delay(1000L)
                println("And I've just delayed for 1 sec because I'm non-cancellable")
            }
        }
    }
    delay(1300L) // 延遲一會
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // 取消任務(wù),并等待其結(jié)束
    println("main: Now I can quit.")
}
超時

超時,是實際應用中取消協(xié)程執(zhí)行最顯而易見的原因,因為其執(zhí)行時間超時了。你還在用手動記錄相應任務(wù)的引用,然后啟動另一個協(xié)程在延遲一段時間后取消記錄的那個協(xié)程?不用那么麻煩啦,這里有個`withTimeout``函數(shù),幫你做了這些工作。看看吧:

fun main(args: Array<String>) = runBlocking<Unit> {
    withTimeout(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
}

輸出如下:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Exception in thread "main" kotlinx.coroutines.experimental.TimeoutCancellationException: Timed out waiting for 1300 MILLISECONDS
    at kotlinx.coroutines.experimental.ScheduledKt.TimeoutCancellationException(Scheduled.kt:202)
    at kotlinx.coroutines.experimental.TimeoutCoroutine.run(Scheduled.kt:100)
    at kotlinx.coroutines.experimental.EventLoopBase$DelayedRunnableTask.run(EventLoop.kt:322)
    at kotlinx.coroutines.experimental.EventLoopBase.processNextEvent(EventLoop.kt:148)
    at kotlinx.coroutines.experimental.BlockingCoroutine.joinBlocking(Builders.kt:82)
    at kotlinx.coroutines.experimental.BuildersKt__BuildersKt.runBlocking(Builders.kt:58)
    ...

TimeoutCancellationException是由withTimeout拋出的CancellationException的子類。之前,我們沒有在控制臺看到過異常堆棧信息,因為在一個取消了的協(xié)程中,CancellationException通常是一個結(jié)束協(xié)程的正常原因。然而,這個例子中,我們正好在main函數(shù)中使用了withTimeout
因為取消是一個異常,因此所有的資源將要被正常的關(guān)閉。如果你需要針對超時做一些額外的處理,可以將代碼用try {...} catch (e: TimeoutCancellationException) {...}包裝,或者使用與withTimeout類似的withTimeoutOrNull,后者返回null而不是拋出異常:

fun main(args: Array<String>) = runBlocking<Unit> {
    val result = withTimeoutOrNull(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
        "Done" // will get cancelled before it produces this result
    }
    println("Result is $result")
}

這次就沒有異常了:

I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null

組合掛起函數(shù)

這個章節(jié)覆蓋了組合掛起函數(shù)的多種方式。

默認是順序的

假設(shè)我們有倆定義好有用的掛起函數(shù),例如遠程服務(wù)調(diào)用,或者計算。這里,我們先假設(shè)這倆有用,實際上就是延遲一小會:

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // 假裝有一波騷操作
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // 假裝有一波騷操作
    return 29
}

如果我們要順序執(zhí)行他們,先執(zhí)行doSomethingUsefulOne,再執(zhí)行doSomethingUsefulTwo,然后其計算結(jié)果之和,怎么搞?實際使用中,需要用第一個函數(shù)的返回值來判斷是否需要調(diào)用第二個函數(shù)或如何去調(diào),才會這么做。
我們用順序調(diào)用就可以了,因為協(xié)程中的代碼和普通的代碼一樣,默認是順序執(zhí)行的。下面的例子通過測量倆掛起函數(shù)總的執(zhí)行時間來演示:

fun main(args: Array<String>) = runBlocking<Unit> {
    val time = measureTimeMillis {
        val one = doSomethingUsefulOne()
        val two = doSomethingUsefulTwo()
        println("The answer is ${one + two}")
    }
    println("Completed in $time ms")
}

結(jié)果近似如下:

The answer is 42
Completed in 2017 ms

用async來并發(fā)

如果doSomethingUsefulOnedoSomethingUsefulTwo的執(zhí)行沒有依賴關(guān)系,我們想通過并發(fā)來更快的獲取到結(jié)果,那該怎么做呢?async就是干這茬的。
概念上來講,async就跟launch類似。其啟動了一個與其他協(xié)程并發(fā)運行單獨協(xié)程(輕量級線程)。區(qū)別是,launch返回了一個不攜帶任何結(jié)果的Job,但是async返回了一個Deferred,一個輕量級非阻塞的future,表示一會就會返回結(jié)果的承諾。你可以在一個延期的值(deferred value)使用.await()來獲取最終的結(jié)果,但Deferred也是個Job,因此,需要的話,你也可以取消掉。

fun main(args: Array<String>) = 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")
}

產(chǎn)生如下輸出:

The answer is 42
Completed in 1017 ms

快了兩倍,因為用了兩個協(xié)程并發(fā)執(zhí)行。注意,協(xié)程的并發(fā)性總是明確的(多個協(xié)程同時運行,那么肯定是并發(fā)的)。

懶啟動async

async有個懶加載選項,配置其可選參數(shù)start,值設(shè)置為CoroutineStart.LAZY。只在值被await需要時,或start函數(shù)被調(diào)用時才啟動協(xié)程。運行下面的例子,跟前面的例子就多了個選項:

fun main(args: Array<String>) = runBlocking<Unit> {
    val time = measureTimeMillis {
        val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
        val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
        println("The answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}

產(chǎn)生如下輸出:

The answer is 42
Completed in 2017 ms

好吧,又回到了順序執(zhí)行,首先我們啟動并等待one,然后啟動并等待two。 這并不是懶執(zhí)行的預期場景。這個設(shè)計是用來替換標準的lazy函數(shù),如果其計算涉及了掛起函數(shù)。

異步風格的函數(shù)

我們可以用async協(xié)程建造器定義異步風格的函數(shù),異步的調(diào)用doSomethingUsefulOnedoSomethingUsefulTwo。給這些函數(shù)加上Async后綴是一個很好的風格,強調(diào)了他們是異步計算的,需要用延期的值來獲取結(jié)果。

//  somethingUsefulOneAsync 的結(jié)果是 Deferred<Int> 類型
fun somethingUsefulOneAsync() = async {
    doSomethingUsefulOne()
}

// somethingUsefulTwoAsync 的結(jié)果是 Deferred<Int> 類型
fun somethingUsefulTwoAsync() = async {
    doSomethingUsefulTwo()
}

注意,這些xxxAsync函數(shù)不是掛起函數(shù),它們隨處均可使用。但是它們的使用總是意味著其行為是異步(也相當于并發(fā))執(zhí)行的。
下面的例子展示了在協(xié)程之外的使用:

// 注意,這里沒有用runBlocking
fun main(args: Array<String>) {
    val time = measureTimeMillis {
        // 我們可以在協(xié)程外部初始化異步操作
        val one = somethingUsefulOneAsync()
        val two = somethingUsefulTwoAsync()
        // 但是等待結(jié)果必須涉及掛起或阻塞
        // 這里,我們用`runBlocking { ... }`阻塞主線程來獲取結(jié)果
        runBlocking {
            println("The answer is ${one.await() + two.await()}")
        }
    }
    println("Completed in $time ms")
}

結(jié)果如下:

The answer is 42
Completed in 1128 ms

協(xié)程的上下文(context)和調(diào)度器(dispatchers)

協(xié)程總是在上下文中執(zhí)行,上下文代表的值是CoroutineContext,定義在Kotlin標準庫中。
協(xié)程上下文是一系列的元素,主要的元素包括我們之前看到過的協(xié)程的Job,還有調(diào)度器,這個章節(jié)會介紹。

調(diào)度器和線程

協(xié)程上下文包括了一個決定相應協(xié)程在哪個或哪些線程執(zhí)行的協(xié)程調(diào)度器(參見 CoroutineDispatcher)。協(xié)程調(diào)度器可以限制協(xié)程在具體的線程中執(zhí)行,或調(diào)度到一個線程池,或者無限制運行。

所有像launchasync一樣的協(xié)程建造器都接受一個可選的CoroutineContext參數(shù),這個參數(shù)可以用來顯式指定調(diào)度器和其他上下文元素。

嘗試下面的例子:

fun main(args: Array<String>) = runBlocking<Unit> {
    val jobs = arrayListOf<Job>()
    jobs += launch(Unconfined) { // 沒有限制 - 將在主線程執(zhí)行
        println("      'Unconfined': I'm working in thread ${Thread.currentThread().name}")
    }
    jobs += launch(coroutineContext) { // 父協(xié)程的上下文,runBlocking 協(xié)程
        println("'coroutineContext': I'm working in thread ${Thread.currentThread().name}")
    }
    jobs += launch(CommonPool) { // 將會調(diào)度到ForkJoinPool.commonPool(或等價的地方)
        println("      'CommonPool': I'm working in thread ${Thread.currentThread().name}")
    }
    jobs += launch(newSingleThreadContext("MyOwnThread")) { // 新線程
        println("          'newSTC': I'm working in thread ${Thread.currentThread().name}")
    }
    jobs.forEach { it.join() }
}

產(chǎn)生如下輸出(可能順序不同):

      'Unconfined': I'm working in thread main
      'CommonPool': I'm working in thread ForkJoinPool.commonPool-worker-1
          'newSTC': I'm working in thread MyOwnThread
'coroutineContext': I'm working in thread main

之前章節(jié)中使用的默認調(diào)度器是DefaultDispatcher,當前的實現(xiàn)中等同于CommonPool。因此,launch { ... }==launch(DefaultDispatcher) { ... }==launch(CommonPool) { ... }
coroutineContextUnconfined上下文的區(qū)別一會看。

注意,newSingleThreadContext創(chuàng)建了一個新的線程,這是非常昂貴的資源。在實際應用中,要么用完之后就用close函數(shù)回收,要么就存儲在頂層變量中,在應用中到處復用。

非限制(Unconfined) VS 限制(confined) 調(diào)度器

Unconfined協(xié)程調(diào)度器在調(diào)用線程啟動協(xié)程,但直到第一個掛起點之前。在掛起之后在什么線程恢復全權(quán)由之前調(diào)用的掛起函數(shù)決定。Unconfined調(diào)度器適合在協(xié)程不消耗CPU時間或不更新任何限制于特定線程共享數(shù)據(jù)(類似UI)的場景。

再說coroutineContext屬性,它在任何協(xié)程中均可用,引用當前協(xié)程的上下文。通過這種方式,父上下文可以被繼承。特別的,runBlocking創(chuàng)建的協(xié)程默認調(diào)度器限定到調(diào)用者線程,因此,繼承runBlocking的上下文就有了使用可預測的先進先出調(diào)度限制在這個線程內(nèi)執(zhí)行的作用。

fun main(args: Array<String>) = runBlocking<Unit> {
    val jobs = arrayListOf<Job>()
    jobs += launch(Unconfined) { // 沒有限制 -- 在主線程運行
        println("      'Unconfined': I'm working in thread ${Thread.currentThread().name}")
        delay(500)
        println("      'Unconfined': After delay in thread ${Thread.currentThread().name}")
    }
    jobs += launch(coroutineContext) { // 父(runBlocking協(xié)程)上下文,
        println("'coroutineContext': I'm working in thread ${Thread.currentThread().name}")
        delay(1000)
        println("'coroutineContext': After delay in thread ${Thread.currentThread().name}")
    }
    jobs.forEach { it.join() }
}

輸出結(jié)果:

      'Unconfined': I'm working in thread main
'coroutineContext': I'm working in thread main
      'Unconfined': After delay in thread kotlinx.coroutines.DefaultExecutor
'coroutineContext': After delay in thread main

因此,繼承了runBlocking {...}coroutineContext的協(xié)程繼續(xù)在main線程執(zhí)行,而沒有限制的協(xié)程在delay函數(shù)使用的默認線程池線程中恢復。

調(diào)試協(xié)程和線程

協(xié)程用Unconfined或默認的多線程調(diào)度器可以從一個線程掛起,從另一個線程恢復。即使是用單線程的調(diào)度器,也很難知道協(xié)程在什么地方,什么時候在干什么。在多線程應用中,在日志中打印出線程的名字是一個通常的做法。一般的日志框架也是支持這個特性的。但當使用協(xié)程時,僅線程名稱對上下文的描述不夠充分,因此,kotlinx.coroutines包含的設(shè)施讓調(diào)試更容易。
給JVM參數(shù)加上-Dkotlinx.coroutines.debug,然后運行下面的代碼:

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main(args: Array<String>) = runBlocking<Unit> {
    val a = async(coroutineContext) {
        log("I'm computing a piece of the answer")
        6
    }
    val b = async(coroutineContext) {
        log("I'm computing another piece of the answer")
        7
    }
    log("The answer is ${a.await() * b.await()}")
}

三個協(xié)程:
主協(xié)程(#1) - runBlocking創(chuàng)建的協(xié)程
a(#2)、b(#3) - 兩個計算延遲返回值的協(xié)程
都在runBlocking的上下文限定在主線程中執(zhí)行,輸出如下:

[main @coroutine#2] I'm computing a piece of the answer
[main @coroutine#3] I'm computing another piece of the answer
[main @coroutine#1] The answer is 42

log函數(shù)在方括號中打印出線程名稱和當前執(zhí)行的協(xié)程標識,調(diào)試模式開啟的時候,這個標識會連續(xù)的賦值給創(chuàng)建的協(xié)程。

線程間切換

給JVM參數(shù)加上-Dkotlinx.coroutines.debug,然后運行下面的代碼:

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main(args: Array<String>) {
    newSingleThreadContext("Ctx1").use { ctx1 ->
        newSingleThreadContext("Ctx2").use { ctx2 ->
            runBlocking(ctx1) {
                log("Started in ctx1")
                withContext(ctx2) {
                    log("Working in ctx2")
                }
                log("Back to ctx1")
            }
        }
    }
}

這個例子演示了幾種新技術(shù)。一是使用runBlocking時,指定了特定的上下文;二是使用withContext函數(shù)切換協(xié)程的上下文,但依然是在相同的協(xié)程中執(zhí)行。輸出如下:

[Ctx1 @coroutine#1] Started in ctx1
[Ctx2 @coroutine#1] Working in ctx2
[Ctx1 @coroutine#1] Back to ctx1

注意:這里使用了kotlin標準庫里的use函數(shù),用于當newSingleThreadContext創(chuàng)建的線程不再被需要時,將其釋放。

上下文中的任務(wù)(Job)

協(xié)程的任務(wù)是上下文的一部分。協(xié)程可以取出自己上下文的任務(wù),用coroutineContext[Job]表達式:

fun main(args: Array<String>) = runBlocking<Unit> {
    println("My job is ${coroutineContext[Job]}")
}

在調(diào)試模式下,輸出如下:

My job is "coroutine#1":BlockingCoroutine{Active}@6d311334

因此,CoroutineScope中的isActivecoroutineContext[Job]?.isActive == true的便捷寫法。

協(xié)程的父子關(guān)系

當協(xié)程的coroutineContext用來啟動另一個協(xié)程,那么新協(xié)程的Job就成了父協(xié)程Job的兒子。想父協(xié)程取消的時候,所有的子協(xié)程也會遞歸取消。

fun main(args: Array<String>) = runBlocking<Unit> {
    // 啟動一個協(xié)程來處理請求
    val request = launch {
        // 生成兩個任務(wù),一個有自己的上下文
        val job1 = launch {
            println("job1: I have my own context and execute independently!")
            delay(1000)
            println("job1: I am not affected by cancellation of the request")
        }
        // 另一個繼承父上下文
        val job2 = launch(coroutineContext) {
            delay(100)
            println("job2: I am a child of the request coroutine")
            delay(1000)
            println("job2: I will not execute this line if my parent request is cancelled")
        }
        // 當子任務(wù)完成,請求才算完成
        job1.join()
        job2.join()
    }
    delay(500)
    request.cancel() // 取消請求
    delay(1000) // 延遲1s,看看會發(fā)生什么
    println("main: Who has survived request cancellation?")
}

輸出如下:

job1: I have my own context and execute independently!
job2: I am a child of the request coroutine
job1: I am not affected by cancellation of the request
main: Who has survived request cancellation?

結(jié)合上下文

協(xié)程上下文可以用+操作符結(jié)合。右手邊的上下文替換掉左手邊上下文相關(guān)的條目。例如,協(xié)程的Job可以被繼承,但調(diào)度器會被替換。

fun main(args: Array<String>) = runBlocking<Unit> {
    // 啟動一個協(xié)程處理請求
    val request = launch(coroutineContext) { // 使用 `runBlocking` 的上下文
        // 在CommonPool中創(chuàng)建CPU密集型的任務(wù)
        val job = launch(coroutineContext + CommonPool) {
            println("job: I am a child of the request coroutine, but with a different dispatcher")
            delay(1000)
            println("job: I will not execute this line if my parent request is cancelled")
        }
        job.join() // 子任務(wù)完成時,請求完成
    }
    delay(500)
    request.cancel() // 取消請求的處理
    delay(1000) // 延遲1s看看有啥發(fā)生
    println("main: Who has survived request cancellation?")
}

預期結(jié)果如下:

job: I am a child of the request coroutine, but with a different dispatcher
main: Who has survived request cancellation?

當?shù)呢熑?/h5>

父協(xié)程總是會等所有的子協(xié)程執(zhí)行完成。父協(xié)程不必顯式的記錄所有其啟動的子協(xié)程,也不必使用Job.join等待其子協(xié)程執(zhí)行完成。

fun main(args: Array<String>) = runBlocking<Unit> {
    // 啟動一個協(xié)程處理請求
    val request = launch {
        repeat(3) { i -> // 啟動幾個子協(xié)程
            launch(coroutineContext)  {
                delay((i + 1) * 200L) // 可變延遲 200ms, 400ms, 600ms
                println("Coroutine $i is done")
            }
        }
        println("request: I'm done and I don't explicitly join my children that are still active")
    }
    request.join() // 等待請求完成,也包括其子協(xié)程
    println("Now processing of the request is complete")
}

結(jié)果如下:

request: I'm done and I don't explicitly join my children that are still active
Coroutine 0 is done
Coroutine 1 is done
Coroutine 2 is done
Now processing of the request is complete

給協(xié)程命名方便調(diào)試

當協(xié)程日志很頻繁或者你只想關(guān)聯(lián)相同協(xié)程產(chǎn)生的日志記錄時,自動生成id是挺好的。然而,當協(xié)程固定的處理一個特別的請求,或者處理特定的后臺任務(wù),為了調(diào)試,還是命名比較好。CoroutineName上下文元素與線程名稱的功能一致,在調(diào)試默認打開的時,執(zhí)行協(xié)程的線程名稱將會展示為CoroutineName
下面的例子展示了這個理念:

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main(args: Array<String>) = runBlocking(CoroutineName("main")) {
    log("Started main coroutine")
    // 啟動兩個后臺計算
    val v1 = async(CoroutineName("v1coroutine")) {
        delay(500)
        log("Computing v1")
        252
    }
    val v2 = async(CoroutineName("v2coroutine")) {
        delay(1000)
        log("Computing v2")
        6
    }
    log("The answer for v1 / v2 = ${v1.await() / v2.await()}")
}

當有JVM參數(shù)-Dkotlinx.coroutines.debug時,產(chǎn)生如下結(jié)果:

[main @main#1] Started main coroutine
[ForkJoinPool.commonPool-worker-1 @v1coroutine#2] Computing v1
[ForkJoinPool.commonPool-worker-2 @v2coroutine#3] Computing v2
[main @main#1] The answer for v1 / v2 = 42

通過指定任務(wù)取消執(zhí)行

現(xiàn)在,我們已經(jīng)了解了上下文,父子關(guān)系和任務(wù),讓我們把這些玩意兒放一塊耍耍。假設(shè)我們的應用有一個有生命周期的對象,這個對象不是協(xié)程。例如,我們寫一個Android應用時,在Android Activity上下文中啟動了各種各樣的協(xié)程用于異步獲取數(shù)據(jù)和動畫計算。當activity銷毀時,所有的協(xié)程都得取消掉,避免內(nèi)存泄漏。

我們一個創(chuàng)建一個跟activity綁定的Job實例,用于管理我們的協(xié)程。Job實例由Job()工廠創(chuàng)建,等會例子會演示。為了方便理解,我們可以launch(coroutineContext, parent = job)這樣寫,說明用了父job,而不是用launch(coroutineContext + job)表達式。

現(xiàn)在,一個Job.cancel調(diào)用,將會所有我們啟動的所有協(xié)程。此外,Job.join等待所有子協(xié)程完成,因此在下面的例子中我們也可以用cancelAndJoin:

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = Job() // 創(chuàng)建一個Job來專利我們自己協(xié)程的生命周期
    // 為了演示,啟動10個協(xié)程,每個運行不同的時間
    val coroutines = List(10) { i ->
        // 都是我們job對象的兒子
        launch(coroutineContext, parent = job) { // 使用runBlocking的上下文,但是用我們自己的job
            delay((i + 1) * 200L) // 花樣等待
            println("Coroutine $i is done")
        }
    }
    println("Launched ${coroutines.size} coroutines")
    delay(500L) // 延遲500ms
    println("Cancelling the job!")
    job.cancelAndJoin() // 取消所有的任務(wù),并等待其完成
}

輸出如下:

Launched 10 coroutines
Coroutine 0 is done
Coroutine 1 is done
Cancelling the job!

如你所見,只有前兩個協(xié)程打印了消息,其他都被一單個job.cancelAndJoin()給取消掉了。所以,在我們假想的Android應用中,需要做的,就是在activity創(chuàng)建的時候創(chuàng)建一個父job,然后在子協(xié)程創(chuàng)建的時候使用這個job,最后,在activity銷毀時取消掉這個job即可。在Android生命周期中,我們不能join它們,因為是同步的。在建造后端服務(wù)時,join用于保證有限的資源訪問是很有用的。

通道(Channels)

延期值(Deferred values)提供了一個在協(xié)程中轉(zhuǎn)移單個值的便捷方式。通道提供了一個方式來轉(zhuǎn)移數(shù)據(jù)流。

通道基礎(chǔ)

Channel在概念上與BlockingQueue非常相似。不同之處是前者用可掛起的send替代后者阻塞的put操作,前者用可掛起的receive替代后者是阻塞的take操作。

fun main(args: Array<String>) = runBlocking<Unit> {
    val channel = Channel<Int>()
    launch {
        // 這里可能是重度消耗CPU的計算,或是異步邏輯,這里我們就發(fā)送幾個平方數(shù)
        for (x in 1..5) channel.send(x * x)
    }
    // 這里打印5個收到的整數(shù)
    repeat(5) { println(channel.receive()) }
    println("Done!")
}

結(jié)果如下:

1
4
9
16
25
Done!

關(guān)閉和遍歷通道

不像隊列,通道可以關(guān)閉用于表明沒有更多的元素會過來了。在接收端,用for循環(huán)可以很方便的接受通道中傳來的元素。
概念上來說,close就像給通道傳遞一個特殊的關(guān)閉令牌。在接受到關(guān)閉令牌之時,迭代就會停止,因此,這里保證了關(guān)閉之前發(fā)送的元素都被接收到了:

fun main(args: Array<String>) = runBlocking<Unit> {
    val channel = Channel<Int>()
    launch {
        for (x in 1..5) channel.send(x * x)
        channel.close() // 發(fā)完了
    }
    // 用for循環(huán)打印接收值 (在通過關(guān)閉之前)
    for (y in channel) println(y)
    println("Done!")
}
建造通道生產(chǎn)者

協(xié)程生產(chǎn)一序列元素的模式是比較常見的。這是在并發(fā)代碼中經(jīng)常可以發(fā)現(xiàn)的生產(chǎn)者-消費者模式的一部分。你可以將生產(chǎn)者抽象為一個以通道為參數(shù)的函數(shù),但與常識相背的是,結(jié)果需要被函數(shù)返回。

fun produceSquares() = produce<Int> {
    for (x in 1..5) send(x * x)
}

fun main(args: Array<String>) = runBlocking<Unit> {
    val squares = produceSquares()
    squares.consumeEach { println(it) }
    println("Done!")
}
流水線

流水線,一種協(xié)程可產(chǎn)生無限數(shù)據(jù)流的模式。

fun produceNumbers() = produce<Int> {
    var x = 1
    while (true) send(x++) // 從1開始的無限整數(shù)流
}

另一個協(xié)程或多個協(xié)程會消費這個流,做一些處理,然后產(chǎn)出結(jié)果。下面的例子中,這些數(shù)會被平方:

fun square(numbers: ReceiveChannel<Int>) = produce<Int> {
    for (x in numbers) send(x * x)
}

下面的代碼啟動然后連接整個流水線:

fun main(args: Array<String>) = runBlocking<Unit> {
    val numbers = produceNumbers() // 產(chǎn)生從1開始的整數(shù)流
    val squares = square(numbers) // 平方整數(shù)
    for (i in 1..5) println(squares.receive()) // 打印前五個
    println("Done!") 
    squares.cancel() // 在大型應用中,需要關(guān)閉這些協(xié)程
    numbers.cancel()
}

在上面這個例子中,我們不用取消掉這些協(xié)程,因為協(xié)程就像守護線程一樣。但是在大點的應用中,如果我們不需要了,那就要停止掉流水線。或者,我們可以將流水線協(xié)程作為主協(xié)程的兒子,接下來會演示。

流水線獲取素數(shù)

下面舉例將流水線應用到極致——用流水線協(xié)程生成素數(shù)。首先,生成一個無限的整數(shù)序列。這次我們傳如一個context參數(shù),并將這個參數(shù)傳遞給produce建造器,因此,調(diào)用方可以控制協(xié)程在哪跑:

fun numbersFrom(context: CoroutineContext, start: Int) = produce<Int>(context) {
    var x = start
    while (true) send(x++) // 從start開始的無限整數(shù)流
}

下面的流水線過濾掉了所有不能被給定素數(shù)除盡的數(shù):

fun filter(context: CoroutineContext, numbers: ReceiveChannel<Int>, prime: Int) = produce<Int>(context) {
    for (x in numbers) if (x % prime != 0) send(x)
}

現(xiàn)在,我們建立一個從2開始的整數(shù)流,從當前的通道獲取素數(shù),然后為找到的素數(shù)開啟新的通道:

numbersFrom(2) -> filter(2) -> filter(3) -> filter(5) -> filter(7) ...

下面的例子打印了前十個素數(shù),在主線程的上下文中運行了整個流水線。因為所有的協(xié)程是作為runBlocking協(xié)程上下文的兒子啟動的,所以我們不必將所有我們啟動的協(xié)程列下來,我們用cancelChildren擴展函數(shù)取消所有的子協(xié)程。

fun main(args: Array<String>) = runBlocking<Unit> {
    var cur = numbersFrom(coroutineContext, 2)
    for (i in 1..10) {
        val prime = cur.receive()
        println(prime)
        cur = filter(coroutineContext, cur, prime)
    }
    coroutineContext.cancelChildren() // 取消所有的子協(xié)程,從而讓主線程退出
}

輸出如下:

2
3
5
7
11
13
17
19
23
29

注意,你可以用標準庫中的buildIterator協(xié)程建造器建造相同的流水線。將producebuildIterator替換,sendyield替換,receivenext替換
ReceiveChannelIterator替換,并去掉上下文。你也不用runBlocking了。然而,上面展示的流水線使用通道的好處,就是能夠充分利用多核CPU(如果在CommonPool上下文運行)。

扇出

多個協(xié)程可以從同一個通道接收,任務(wù)散布于多個協(xié)程之間。讓我們從一個周期產(chǎn)生整數(shù)(1秒10個數(shù))的生產(chǎn)者協(xié)程開始:

fun produceNumbers() = produce<Int> {
    var x = 1 // 從1開始
    while (true) {
        send(x++) // 生產(chǎn)下一個
        delay(100) // 等1s
    }
}

我們可以有多個處理者協(xié)程,在這個例子中,就只打印他們的id和接收到的數(shù)字:

fun launchProcessor(id: Int, channel: ReceiveChannel<Int>) = launch {
    for (msg in channel) {
        println("Processor #$id received $msg")
    }    
}

讓我們啟動5個處理者,讓它們運行將近1秒,看看會發(fā)生什么:

fun main(args: Array<String>) = runBlocking<Unit> {
    val producer = produceNumbers()
    repeat(5) { launchProcessor(it, producer) }
    delay(950)
    producer.cancel() // 取消掉生產(chǎn)者協(xié)程,這樣就能將所有的協(xié)程取消
}

輸出與下面的結(jié)果類似,盡管接收每個特定整數(shù)的處理器ID可能不同。

Processor #2 received 1
Processor #4 received 2
Processor #0 received 3
Processor #1 received 4
Processor #3 received 5
Processor #2 received 6
Processor #4 received 7
Processor #0 received 8
Processor #1 received 9
Processor #3 received 10

注意,取消生產(chǎn)者協(xié)程關(guān)閉其通道,最終會結(jié)束處理者協(xié)程遍歷通道。
同樣的,注意我們在launchProcessor代碼中如何用for循環(huán)遍歷通道實現(xiàn)扇出的。不像consumeEachfor循環(huán)風格在多協(xié)程使用時是妥妥安全的。如果,其中的一個協(xié)程掛掉了,其他的協(xié)程還會繼續(xù)處理通道。而當處理者用consumeEach遍歷時,正常或非正常結(jié)束都會將通道給取消掉。

扇入

多個協(xié)程可以向相同通道發(fā)送。例如,我們有一個字符串通道,一個有特定延遲周期發(fā)送特定字符串到通道的掛起函數(shù)。

suspend fun sendString(channel: SendChannel<String>, s: String, time: Long) {
    while (true) {
        delay(time)
        channel.send(s)
    }
}

現(xiàn)在,來看看如果啟動兩個協(xié)程發(fā)送字符串會怎么樣(這個例子中,我們在主線程上下文中啟動它們):

fun main(args: Array<String>) = runBlocking<Unit> {
    val channel = Channel<String>()
    launch(coroutineContext) { sendString(channel, "foo", 200L) }
    launch(coroutineContext) { sendString(channel, "BAR!", 500L) }
    repeat(6) { // 接收頭6個
        println(channel.receive())
    }
    coroutineContext.cancelChildren() // 取消所有的子協(xié)程,讓主線程結(jié)束
}

輸出是:

foo
foo
BAR!
foo
foo
BAR!

帶緩沖區(qū)的通道

前面展示的通道都是不帶緩沖的。沒有緩沖區(qū)的通道只有在發(fā)送者和接收者見到了彼此才傳遞元素。如果發(fā)送先調(diào)用了,那么它將掛起直到接收被調(diào)用;如果接收先調(diào)用了,那么它將掛起直到發(fā)送被調(diào)用。
Channel()工廠函數(shù)和produce建造器接收一個可選的用來指定緩沖區(qū)大小capacity參數(shù),緩沖區(qū)可以讓發(fā)送者在掛起之前發(fā)送多個元素,跟指定容量的BlockingQueue類似,在緩沖區(qū)滿了之后阻塞。
看看下面的代碼會有啥效果:

fun main(args: Array<String>) = runBlocking<Unit> {
    val channel = Channel<Int>(4) // 創(chuàng)建帶緩沖區(qū)的通道
    val sender = launch(coroutineContext) { // 啟動發(fā)送者協(xié)程
        repeat(10) {
            println("Sending $it") // 在發(fā)送之前先打印
            channel.send(it) // 將會在緩沖區(qū)滿的時候掛起
        }
    }
    // 什么都不做,等著
    delay(1000)
    sender.cancel() // 取消發(fā)送者協(xié)程
}

用緩沖區(qū)大小為4的通道,打印了5次。

Sending 0
Sending 1
Sending 2
Sending 3
Sending 4

頭4個元素加入到了緩沖區(qū),當試圖加入第五個的時候,發(fā)送者被掛起了。

時鐘通道

時鐘通道是一種特別的單緩沖區(qū)通道,每次自上次從此通道消費后,在給定時間后會生產(chǎn)一個Unit。單獨使用看起來像沒什么用,但是在構(gòu)建復雜的基于時間的生產(chǎn)流水線,然后操作者做一些窗口和其他基于時間的處理時特別有用。

ticker工廠方法創(chuàng)建時鐘通道,后續(xù)元素不再需要時,用ReceiveChannel.cancel取消掉。

看看實際中如何應用:

fun main(args: Array<String>) = runBlocking<Unit> {
    val tickerChannel = ticker(delay = 100, initialDelay = 0) // 創(chuàng)建時鐘通道
    var nextElement = withTimeoutOrNull(1) { tickerChannel.receive() }
    println("Initial element is available immediately: $nextElement") // 最初的延遲還沒結(jié)束

    nextElement = withTimeoutOrNull(50) { tickerChannel.receive() } // 后續(xù)的元素都有100ms延遲
    println("Next element is not ready in 50 ms: $nextElement")

    nextElement = withTimeoutOrNull(60) { tickerChannel.receive() }
    println("Next element is ready in 100 ms: $nextElement")

    // 模擬長時間消費延遲
    println("Consumer pauses for 150ms")
    delay(150)
    // 下個元素立即可用
    nextElement = withTimeoutOrNull(1) { tickerChannel.receive() }
    println("Next element is available immediately after large consumer delay: $nextElement")
    // receive方法調(diào)用間的暫停也算進去了,下一個元素會更快收到
    nextElement = withTimeoutOrNull(60) { tickerChannel.receive() } 
    println("Next element is ready in 50ms after consumer pause in 150ms: $nextElement")

    tickerChannel.cancel() // 后面的不要了
}

會打印下面幾行:

Initial element is available immediately: kotlin.Unit
Next element is not ready in 50 ms: null
Next element is ready in 100 ms: kotlin.Unit
Consumer pauses for 150ms
Next element is available immediately after large consumer delay: kotlin.Unit
Next element is ready in 50ms after consumer pause in 150ms: kotlin.Unit

ticker知道消費者停頓,如果有停頓,默認調(diào)整下次產(chǎn)生產(chǎn)生元素的延遲,嘗試維護產(chǎn)生元素的固定速率。

一個可選的參數(shù)mode,如果指定為TickerMode.FIXED_PERIOD,那么ticker會維護一個元素間固定延遲。默認是TickerMode.FIXED_DELAY

這里多講講兩個模式的區(qū)別,后面再舉個例子說明區(qū)別。
TickerMode.FIXED_PERIOD: 為了保持產(chǎn)生元素的速率,會調(diào)整下一個元素產(chǎn)生的延遲。
TickerMode.FIXED_DELAY: 固定的延遲產(chǎn)生元素。

區(qū)分兩個模式的例子。

fun log(msg: String) {
   println("[${Date()}] $msg")
}

fun main(args: Array<String>) = runBlocking<Unit> {
   val tickerChannel = ticker(delay = 5000, initialDelay = 0, mode = TickerMode.FIXED_DELAY)

   var i = 0
   for (item in tickerChannel) {
       log("receive $item")
       val time = if (i++ % 2 == 0) 4000 else 6000 // 切換使用4s/6s延遲
       delay(time)
   }
}

如果用TickerMode.FIXED_DELAY模式:

[Sun Jul 22 16:36:17 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:36:22 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:36:28 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:36:33 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:36:39 CST 2018] receive kotlin.Unit

如果用TickerMode.FIXED_PERIOD模式:

[Sun Jul 22 16:43:52 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:43:57 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:44:03 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:44:07 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:44:13 CST 2018] receive kotlin.Unit
[Sun Jul 22 16:44:17 CST 2018] receive kotlin.Unit

第一次延遲都是5s,后面的區(qū)別是FIXED_DELAY延遲在5/6s間切換;FIXED_PERIOD的延遲在4/6s間切換。相信大家已經(jīng)能夠區(qū)分了。

通道是公平的

對于從多個協(xié)程調(diào)用通道的順序,向通道發(fā)送和接收操作是公平的。按照先進先出的順序進出通道,例如,第一個調(diào)用receive的協(xié)程獲得元素。下面的例子中,兩個協(xié)程("ping"和"pong")從同一個通道"table"接收"ball"對象。

data class Ball(var hits: Int)

fun main(args: Array<String>) = runBlocking<Unit> {
    val table = Channel<Ball>() // 公用一張桌子
    launch(coroutineContext) { player("ping", table) }
    launch(coroutineContext) { player("pong", table) }
    table.send(Ball(0)) // 發(fā)球
    delay(1000) // 延遲一秒
    coroutineContext.cancelChildren() // 游戲結(jié)束,取消它們
}

suspend fun player(name: String, table: Channel<Ball>) {
    for (ball in table) { // 在循環(huán)中接球
        ball.hits++
        println("$name $ball")
        delay(300) // 等一會
        table.send(ball) // 將球擊回
    }
}

"ping"協(xié)程先開始的,所以,它最先收到球。即使"ping"協(xié)程在將球擊回桌面后立即再次開始接球,但球還是給"pong"協(xié)程接到了,因為"pong"早等著在了:

ping Ball(hits=1)
pong Ball(hits=2)
ping Ball(hits=3)
pong Ball(hits=4)

有時候,通道可能會產(chǎn)生看起不公平的執(zhí)行,是因協(xié)程使用到的線程池所致。

共享可變狀態(tài)和并發(fā)

協(xié)程可以用多線程的調(diào)度器(例如默認的CommonPool)并發(fā)執(zhí)行。那么并發(fā)問題也接踵而至。主要問題是同步訪問共享可變狀態(tài)。在協(xié)程領(lǐng)域里,這個問題的解決方案有些與多線程領(lǐng)域中類似,但是有些則截然不同。

問題

讓我們啟動1000個協(xié)程,做同樣的事情1000次(一共一百萬次執(zhí)行)。為了一會做比較,我們記錄下執(zhí)行時間:

suspend fun massiveRun(context: CoroutineContext, action: suspend () -> Unit) {
    val n = 1000 // 啟動協(xié)程的數(shù)量
    val k = 1000 // 每個協(xié)程執(zhí)行動作的次數(shù)
    val time = measureTimeMillis {
        val jobs = List(n) {
            launch(context) {
                repeat(k) { action() }
            }
        }
        jobs.forEach { it.join() }
    }
    println("Completed ${n * k} actions in $time ms")    
}

我們以一個非常簡單的動作,在多線程的CommonPool上下文下,累加一個共享的變量。

var counter = 0

fun main(args: Array<String>) = runBlocking<Unit> {
    massiveRun(CommonPool) {
        counter++
    }
    println("Counter = $counter")
}

最終會打印出個啥?應該不會是"Counter = 1000000",因為1000個協(xié)程在多個線程同步的累加counter而沒有進行同步。

注意:如果你的計算機只有2核或更少,那么你會一直得到1000000,因為CommonPool在這種情況下是單線程的。為了“造成”這個問題,需要把代碼改改:

val mtContext = newFixedThreadPoolContext(2, "mtPool") // 定義一個2線程的上下文
var counter = 0

fun main(args: Array<String>) = runBlocking<Unit> {
    massiveRun(mtContext) { // 替代上面例子的CommonPool
        counter++
    }
    println("Counter = $counter")
}
volatile也愛莫能助

有個常見的誤解,以為volatile可以解決并發(fā)問題。試試看:

@Volatile // kotlin 中, volatile 是個注解 
var counter = 0

fun main(args: Array<String>) = runBlocking<Unit> {
    massiveRun(CommonPool) {
        counter++
    }
    println("Counter = $counter")
}

代碼跑得更慢了,但最終也沒能得到"Counter = 1000000",因為volatile保證了順序的讀和寫,但是對大的操作(這里是累加)不保證原子性。

線程安全的數(shù)據(jù)結(jié)構(gòu)

對協(xié)程和線程都通用的解決方案是利用一個線程安全的數(shù)據(jù)結(jié)構(gòu),這個數(shù)據(jù)結(jié)構(gòu)提供對共享狀態(tài)必要的同步操作。在上面的例子中,我們可以用AtomicInteger類,它有個incrementAndGet方法:

var counter = AtomicInteger()

fun main(args: Array<String>) = runBlocking<Unit> {
    massiveRun(CommonPool) {
        counter.incrementAndGet()
    }
    println("Counter = ${counter.get()}")
}

這是針對這個問題最快的解決方法。這種解決方法適用于普通計數(shù)器,集合,隊列和其他標準數(shù)據(jù)結(jié)構(gòu)以及它們的基本操作。但是不容易擴展到復雜狀態(tài)或沒有現(xiàn)成的線程安全實現(xiàn)的復雜操作。

細粒度線程限制

線程限制一種解決共享可變狀態(tài)的方式,它將共享變量的訪問限制到單個線程上。在UI應用中很適用,因為UI狀態(tài)一般都限制于事件派發(fā)或應用線程。在協(xié)程中用單線程上下文很容易實現(xiàn):

val counterContext = newSingleThreadContext("CounterContext")
var counter = 0

fun main(args: Array<String>) = runBlocking<Unit> {
    massiveRun(CommonPool) { // 在CommonPool中運行每個協(xié)程
        withContext(counterContext) { // 但是在單個線程中累加
            counter++
        }
    }
    println("Counter = $counter")
}

這個代碼跑的很慢,因為做到了細粒度的線程限制。每個獨立的累加都利用withContext塊從多線程CommonPool上下文切換到單線程的上下文。

粗粒度線程限制

實際應用中,線程限制是在大塊中執(zhí)行的。例如,一大塊更新狀態(tài)的業(yè)務(wù)邏輯限制于單個線程。例如下面這個例子,在單線程的上下文中運行每個協(xié)程:

val counterContext = newSingleThreadContext("CounterContext")
var counter = 0

fun main(args: Array<String>) = runBlocking<Unit> {
    massiveRun(counterContext) { // 在單線程的上下文中運行每個協(xié)程
        counter++
    }
    println("Counter = $counter")
}

這就快多了,而且結(jié)果是對的。

互斥操作

互斥,利用一個用不會并發(fā)執(zhí)行的臨界區(qū),保護對共享狀態(tài)的修改。在阻塞的世界里,通常用synchronizedReentrantLock達到互斥。協(xié)程中的替代品叫做Mutex。它用lockunlock方法界定臨界區(qū)。關(guān)鍵不同之處是,Mutex.lock()是掛起函數(shù),不會阻塞線程。
有一個擴展函數(shù)withLock,方便的幫你做了mutex.lock(); try { ... } finally { mutex.unlock() }這事:

val mutex = Mutex()
var counter = 0

fun main(args: Array<String>) = runBlocking<Unit> {
    massiveRun(CommonPool) {
        mutex.withLock {
            counter++        
        }
    }
    println("Counter = $counter")
}

這個例子中的鎖是細粒度的,因此,是有代價的。然而,在有些你必須周期修改共享狀態(tài)的情況下,鎖是個好選擇。同時,這個狀態(tài)沒有限制到某個線程上。

Actors

actor是由協(xié)程、限制并包含于此協(xié)程的狀態(tài)、與其他協(xié)程交流的通道組成。一個簡單的actor可以寫成一個函數(shù),但是復雜狀態(tài)的actor更適合寫成一個類。

有個actor協(xié)程建造器,它可以方便地將actor的郵箱通道組合到其范圍內(nèi),以便從發(fā)送通道接收消息并將發(fā)送通道組合到生成的job對象中。因此,單個actor的引用就攜帶了上面所有的東西。

使用actor的第一步是定義一個actor要處理的消息類。Kotlin的密封類(sealed class)非常適合這個目的。首先定一個CounterMsg密封類,子類IncCounter作為增加計數(shù)器的消息,GetCounter作為獲取計數(shù)器值的消,后者需要發(fā)送回復。CompletableDeferred表示將來已知的單個值,此處用于發(fā)送回復。

// counterActor的消息類型
sealed class CounterMsg
object IncCounter : CounterMsg() // 增加計數(shù)器的單向消息
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg() // 帶回復的請求

然后我們定義一個使用actor協(xié)程建造器啟動actor的函數(shù):

// 此函數(shù)啟動一個新的計數(shù)器actor
fun counterActor() = actor<CounterMsg> {
    var counter = 0 // actor 狀態(tài)
    for (msg in channel) { // 遍歷進來的消息
        when (msg) {
            is IncCounter -> counter++
            is GetCounter -> msg.response.complete(counter)
        }
    }
}

主要代碼很簡單:

fun main(args: Array<String>) = runBlocking<Unit> {
    val counter = counterActor() // 創(chuàng)建actor
    massiveRun(CommonPool) {
        counter.send(IncCounter)
    }
    // 發(fā)送一個消息,用于從actor中獲取計算器的值
    val response = CompletableDeferred<Int>()
    counter.send(GetCounter(response))
    println("Counter = ${response.await()}")
    counter.close() // 關(guān)閉actor
}

actor自身執(zhí)行的上下文無關(guān)緊要。一個actor是一個協(xié)程,協(xié)程是順序執(zhí)行的,因此,將狀態(tài)限制在特定協(xié)程中可以解決共享可變狀態(tài)的問題。實際上,actor可以修改自己的私有狀態(tài),但只能通過消息相互影響(避免任何鎖定)。

actor比在負載下鎖定更有效,因為在這種情況下它總是有工作要做,而且根本不需要切換到不同的上下文。

注意,actor協(xié)程構(gòu)建器是produce協(xié)程構(gòu)建器的雙重構(gòu)件。actor與它接收消息的通道相關(guān)聯(lián),produce與它發(fā)送元素的通道相關(guān)聯(lián)。

Select表達式

Select表達式可同時讓多個掛起函數(shù)等待,并選擇第一個使其激活。

從通道中選擇

讓我們先創(chuàng)建兩個生產(chǎn)字符串的生產(chǎn)者:fizzbuzzfizz每300ms產(chǎn)生一個"Fizz"字符串:

fun fizz(context: CoroutineContext) = produce<String>(context) {
    while (true) { // 每300ms發(fā)送一個"Fizz"
        delay(300)
        send("Fizz")
    }
}

buzz每500ms產(chǎn)生一個"Buzz"字符串:

fun buzz(context: CoroutineContext) = produce<String>(context) {
    while (true) { // 每500ms發(fā)送一個"Buzz!"
        delay(500)
        send("Buzz!")
    }
}

使用receive掛起函數(shù),我們可以從一個通道或另一個通道接收。但select表達式允許我們使用其onReceive子句同時從兩者接收:

suspend fun selectFizzBuzz(fizz: ReceiveChannel<String>, buzz: ReceiveChannel<String>) {
    select<Unit> { // <Unit> 意味著select表達式?jīng)]有返回 
        fizz.onReceive { value ->  // 第一個子句
            println("fizz -> '$value'")
        }
        buzz.onReceive { value ->  // 第二個子句
            println("buzz -> '$value'")
        }
    }
}

跑七次這個函數(shù):

fun main(args: Array<String>) = runBlocking<Unit> {
    val fizz = fizz(coroutineContext)
    val buzz = buzz(coroutineContext)
    repeat(7) {
        selectFizzBuzz(fizz, buzz)
    }
    coroutineContext.cancelChildren() // 取消倆協(xié)程
}

結(jié)果如下:

fizz -> 'Fizz'
buzz -> 'Buzz!'
fizz -> 'Fizz'
fizz -> 'Fizz'
buzz -> 'Buzz!'
fizz -> 'Fizz'
buzz -> 'Buzz!'

選擇關(guān)閉

當通道關(guān)閉時,select中的onReceive子句報錯,導致相應的select拋出異常。我們可以使用onReceiveOrNull子句在關(guān)閉通道時執(zhí)行特定操作。以下示例還顯示select是一個返回其選擇好的子句結(jié)果的表達式:

suspend fun selectAorB(a: ReceiveChannel<String>, b: ReceiveChannel<String>): String =
    select<String> {
        a.onReceiveOrNull { value -> 
            if (value == null) 
                "Channel 'a' is closed" 
            else 
                "a -> '$value'"
        }
        b.onReceiveOrNull { value -> 
            if (value == null) 
                "Channel 'b' is closed"
            else    
                "b -> '$value'"
        }
    }

讓我們來使用這個函數(shù),傳入產(chǎn)生"Hello"字符串四次的通道a和產(chǎn)生"World"四次的頻道b

fun main(args: Array<String>) = runBlocking<Unit> {
    // 為了可預測結(jié)果,我們使用主線程上下文
    val a = produce<String>(coroutineContext) {
        repeat(4) { send("Hello $it") }
    }
    val b = produce<String>(coroutineContext) {
        repeat(4) { send("World $it") }
    }
    repeat(8) { // 打印頭8個結(jié)果
        println(selectAorB(a, b))
    }
    coroutineContext.cancelChildren()    
}

觀察得處下列結(jié)論:
首先,select傾向于第一個子句。當多個子句同時可選時,它們中的第一個被選擇。這里,兩個通道都不斷的產(chǎn)生字符串,a作為第一個,勝。然后,因為我們用的是不帶緩沖池的通道,a在調(diào)用send時不時會掛起,就給了機會讓b也來一發(fā)。

選擇發(fā)送

select表達式具有onSend子句,可以與選擇的偏見性結(jié)合使用。

讓我們寫一個整數(shù)生產(chǎn)者的例子,當主通道上的消費者無法跟上它時,它會將其值發(fā)送到side通道。

fun produceNumbers(context: CoroutineContext, side: SendChannel<Int>) = produce<Int>(context) {
    for (num in 1..10) { // 產(chǎn)生10個數(shù)字
        delay(100) // 每個延遲 100 ms
        select<Unit> {
            onSend(num) {} // 發(fā)送給主通道
            side.onSend(num) {} // or to the side channel     
        }
    }
}

消費者將會非常緩慢,需要250毫秒才能處理每個數(shù)字:

fun main(args: Array<String>) = runBlocking<Unit> {
    val side = Channel<Int>() // 分配side通道
    launch(coroutineContext) { // 給side通道一個快速的消費者
        side.consumeEach { println("Side channel has $it") }
    }
    produceNumbers(coroutineContext, side).consumeEach { 
        println("Consuming $it")
        delay(250) // 慢慢消費,不著急
    }
    println("Done consuming")
    coroutineContext.cancelChildren()    
}

看看發(fā)生什么:

Consuming 1
Side channel has 2
Side channel has 3
Consuming 4
Side channel has 5
Side channel has 6
Consuming 7
Side channel has 8
Side channel has 9
Consuming 10
Done consuming

選擇延期值

可以使用onAwait子句選擇延遲值。讓我們從一個異步函數(shù)開始,該函數(shù)在隨機延遲后返回一個延遲字符串值:

fun asyncString(time: Int) = async {
    delay(time.toLong())
    "Waited for $time ms"
}

讓我們隨機延遲開始十幾個。

fun asyncStringsList(): List<Deferred<String>> {
    val random = Random(3)
    return List(12) { asyncString(random.nextInt(1000)) }
}

現(xiàn)在,主函數(shù)等待第一個函數(shù)完成并計算仍處于活動狀態(tài)的延遲值的數(shù)量。注意,我們在這里使用了select表達式是Kotlin DSL的事實,因此我們可以使用任意代碼為它提供子句。在這個例子中,我們遍歷一個延遲值列表,為每個延遲值提供onAwait子句。

fun main(args: Array<String>) = runBlocking<Unit> {
    val list = asyncStringsList()
    val result = select<String> {
        list.withIndex().forEach { (index, deferred) ->
            deferred.onAwait { answer ->
                "Deferred $index produced answer '$answer'"
            }
        }
    }
    println(result)
    val countActive = list.count { it.isActive }
    println("$countActive coroutines are still active")
}

輸出如下:

Deferred 4 produced answer 'Waited for 128 ms'
11 coroutines are still active

切換延遲值的通道

讓我們編寫一個消費延遲字符串值通道的通道建造器函數(shù),直到下一個延遲值結(jié)束或通道關(guān)閉之前,等待每個接收到的延遲值。此示例將同一select中的onReceiveOrNullonAwait子句放在一起:

fun switchMapDeferreds(input: ReceiveChannel<Deferred<String>>) = produce<String> {
    var current = input.receive() // 從第一個收到的延遲值開始
    while (isActive) { // 當通道沒取消時循環(huán)
        val next = select<Deferred<String>?> { // 從此select返回下一個延遲值或null
            input.onReceiveOrNull { update ->
                update // 換下一個值等
            }
            current.onAwait { value ->  
                send(value) // 發(fā)送當前延遲值產(chǎn)生的數(shù)據(jù)
                input.receiveOrNull() // 使用輸入通道的下一個延遲值
            }
        }
        if (next == null) {
            println("Channel was closed")
            break // 退出循環(huán)
        } else {
            current = next
        }
    }
}

為了測試它,我們將使用一個簡單的異步函數(shù),它在指定的時間后返回指定的字符串:

fun asyncString(str: String, time: Long) = async {
    delay(time)
    str
}

main函數(shù)只是啟動一個協(xié)同程序來打印switchMapDeferreds的結(jié)果并向它發(fā)送一些測試數(shù)據(jù):

fun main(args: Array<String>) = runBlocking<Unit> {
    val chan = Channel<Deferred<String>>() // 測試通道
    launch(coroutineContext) { // 開啟打印協(xié)程
        for (s in switchMapDeferreds(chan)) 
            println(s) // 打印收到的字符串
    }
    chan.send(asyncString("BEGIN", 100))
    delay(200) // 夠"BEGIN"生產(chǎn)出來了
    chan.send(asyncString("Slow", 500))
    delay(100) // 不夠生產(chǎn)"Slow"的時間
    chan.send(asyncString("Replace", 100))
    delay(500) // 發(fā)送最后的字符串之前給點時間
    chan.send(asyncString("END", 500))
    delay(1000) // 給時間運行
    chan.close() // 關(guān)閉通道
    delay(500) // 等運行完
}

結(jié)果是:

BEGIN
Replace
END
Channel was closed

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,732評論 6 539
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,214評論 3 426
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,781評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,588評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,315評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,699評論 1 327
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,698評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,882評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,441評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,189評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,388評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,933評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,613評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,023評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,310評論 1 293
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,112評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,334評論 2 377

推薦閱讀更多精彩內(nèi)容