【譯】kotlin 協程官方文件(6)-通道(Channels)

葉志陳發表於2020-03-24

最近一直在瞭解關於kotlin協程的知識,那最好的學習資料自然是官方提供的學習文件了,看了看後我就萌生了翻譯官方文件的想法。前後花了要接近一個月時間,一共九篇文章,在這裡也分享出來,希望對讀者有所幫助。個人知識所限,有些翻譯得不是太順暢,也希望讀者能提出意見

協程官方文件:coroutines-guide

協程官方文件中文翻譯:coroutines-cn-guide

協程官方文件中文譯者:leavesC

[TOC]

Deferred 值提供了在協程之間傳遞單個值的方便方法,而通道(Channels)提供了一種傳輸值流的方法

一、通道基礎(Channel basics)

通道在概念上非常類似於 BlockingQueue,它們之間的一個關鍵區別是:通道有一個掛起的 send 函式和一個掛起的 receive 函式,而不是一個阻塞的 put 操作和一個阻塞的 take 操作

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
//sampleStart
    val channel = Channel<Int>()
    launch {
        // this might be heavy CPU-consuming computation or async logic, we'll just send five squares
        for (x in 1..5) channel.send(x * x)
    }
    // here we print five received integers:
    repeat(5) { println(channel.receive()) }
    println("Done!")
//sampleEnd
}
複製程式碼

輸出結果是:

1
4
9
16
25
Done!
複製程式碼

二、關閉和迭代通道(Closing and iteration over channels)

與佇列不同,通道可以關閉,以此來表明元素已傳送完成。在接收方,使用常規的 for 迴圈從通道接收元素是比較方便的

從概念上講,close 類似於向通道傳送一個特殊的 cloase 標記。一旦接收到這個 close 標記,迭代就會停止,因此可以保證接收到 close 之前傳送的所有元素:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
//sampleStart
    val channel = Channel<Int>()
    launch {
        for (x in 1..5) channel.send(x * x)
        channel.close() // we're done sending
    }
    // here we print received values using `for` loop (until the channel is closed)
    for (y in channel) println(y)
    println("Done!")
//sampleEnd
}
複製程式碼

三、構建通道生產者(Building channel producers)

協程生成元素序列(sequence )的模式非常常見。這是可以經常在併發程式設計中發現的生產者-消費者模式的一部分。你可以將這樣一個生產者抽象為一個以 channel 為引數的函式,但這與必須從函式返回結果的常識相反

有一個方便的名為 product 的協程構造器,它使得在 producer 端執行該操作變得很容易;還有一個擴充套件函式 consumerEach,它替換了consumer 端的 for 迴圈:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

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

fun main() = runBlocking {
//sampleStart
    val squares = produceSquares()
    squares.consumeEach { println(it) }
    println("Done!")
//sampleEnd
}
複製程式碼

四、管道(Pipelines)

管道是一種模式,是一個協程正在生成的可能是無窮多個元素的值流

fun CoroutineScope.produceNumbers() = produce<Int> {
    var x = 1
    while (true) send(x++) // infinite stream of integers starting from 1
}
複製程式碼

存在一個或多個協程對值流進行取值,進行一些處理併產生一些其它結果。在下面的示例中,每個返回值也是入參值(數字)的平方值

fun CoroutineScope.square(numbers: ReceiveChannel<Int>): ReceiveChannel<Int> = produce {
    for (x in numbers) send(x * x)
}
複製程式碼

啟動並連線整個管道:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
//sampleStart
    val numbers = produceNumbers() // produces integers from 1 and on
    val squares = square(numbers) // squares integers
    repeat(5) {
        println(squares.receive()) // print first five
    }
    println("Done!") // we are done
    coroutineContext.cancelChildren() // cancel children coroutines
//sampleEnd
}

fun CoroutineScope.produceNumbers() = produce<Int> {
    var x = 1
    while (true) send(x++) // infinite stream of integers starting from 1
}

fun CoroutineScope.square(numbers: ReceiveChannel<Int>): ReceiveChannel<Int> = produce {
    for (x in numbers) send(x * x)
}
複製程式碼

建立協程的所有函式都被定義為 CoroutineScope 的擴充套件,因此我們可以依賴結構化併發來確保應用程式中沒有延遲的全域性協程

五、使用管道的素數(Prime numbers with pipeline)

讓我們以一個使用協程管道生成素數的例子,將管道發揮到極致。我們從一個無限的數字序列開始

fun CoroutineScope.numbersFrom(start: Int) = produce<Int> {
    var x = start
    while (true) send(x++) // infinite stream of integers from start
}
複製程式碼

以下管道過濾傳入的數字流,刪除所有可被給定素數整除的數字:

fun CoroutineScope.filter(numbers: ReceiveChannel<Int>, prime: Int) = produce<Int> {
    for (x in numbers) if (x % prime != 0) send(x)
}
複製程式碼

現在,我們通過從2開始一個數字流,從當前通道獲取一個質數,併為找到的每個質數啟動新的管道:

numbersFrom(2) -> filter(2) -> filter(3) -> filter(5) -> filter(7) ... 
複製程式碼

下面的示例程式碼列印了前十個質數,在主執行緒的上下文中執行整個管道。因為所有的協程都是在主 runBlocking 協程的範圍內啟動的,所以我們不必保留所有已啟動的協程的顯式引用。我們使用擴充套件函式 cancelChildren 來取消列印前十個質數後的所有子協程

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
//sampleStart
    var cur = numbersFrom(2)
    repeat(10) {
        val prime = cur.receive()
        println(prime)
        cur = filter(cur, prime)
    }
    coroutineContext.cancelChildren() // cancel all children to let main finish
//sampleEnd    
}

fun CoroutineScope.numbersFrom(start: Int) = produce<Int> {
    var x = start
    while (true) send(x++) // infinite stream of integers from start
}

fun CoroutineScope.filter(numbers: ReceiveChannel<Int>, prime: Int) = produce<Int> {
    for (x in numbers) if (x % prime != 0) send(x)
}
複製程式碼

執行結果:

2
3
5
7
11
13
17
19
23
29
複製程式碼

注意,你可以使用標準庫中的 iterator 協程構造器來構建相同的管道。將 product 替換為 iterator,send 替換為 yield,receive 替換為 next,ReceiveChannel 替換為 iterator,並去掉協程作用域。你也不需要再使用 runBlocking 。但是,使用如上所示的通道的管道的好處是,如果在 Dispatchers.Default 上下文中執行它,它實際上可以利用多個 CPU 來執行程式碼

但無論如何,如上所述的替代方案也是一個非常不切實際的來尋找素數的方法。實際上,管道確實涉及一些其他掛起呼叫(如對遠端服務的非同步呼叫),並且這些管道不能使用 sequence/iterator 來構建,因為它們不允許任意掛起,而 product 是完全非同步的

六、扇出(Fan-out)

多個協程可以從同一個通道接收資料,在它們之間分配任務。讓我們從一個週期性地生成整數(每秒10個數)的 producer 協程開始:

fun CoroutineScope.produceNumbers() = produce<Int> {
    var x = 1 // start from 1
    while (true) {
        send(x++) // produce next
        delay(100) // wait 0.1s
    }
}
複製程式碼

然後我們可以有多個處理器(processor)協程。在本例中,他們只需列印他們的 id 和接收的數字:

fun CoroutineScope.launchProcessor(id: Int, channel: ReceiveChannel<Int>) = launch {
    for (msg in channel) {
        println("Processor #$id received $msg")
    }    
}
複製程式碼

現在讓我們啟動5個處理器,讓它們工作幾乎一秒鐘。看看會發生什麼:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking<Unit> {
//sampleStart
    val producer = produceNumbers()
    repeat(5) { launchProcessor(it, producer) }
    delay(950)
    producer.cancel() // cancel producer coroutine and thus kill them all
//sampleEnd
}

fun CoroutineScope.produceNumbers() = produce<Int> {
    var x = 1 // start from 1
    while (true) {
        send(x++) // produce next
        delay(100) // wait 0.1s
    }
}

fun CoroutineScope.launchProcessor(id: Int, channel: ReceiveChannel<Int>) = launch {
    for (msg in channel) {
        println("Processor #$id received $msg")
    }    
}
複製程式碼

儘管接收每個特定整數的處理器 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
複製程式碼

請注意,取消 producer 協程會關閉其通道,從而最終終止 processor 協程正在執行的通道上的迭代

另外,請注意我們如何使用 for 迴圈在通道上顯式迭代以在 launchProcessor 程式碼中執行 fan-out。與 consumeEach 不同,這個 for 迴圈模式在多個協程中使用是完全安全的。如果其中一個 processor 協程失敗,則其他處理器仍將處理通道,而通過 consumeEach 寫入的處理器總是在正常或異常完成時消費(取消)底層通道

七、扇入(Fan-in)

多個協程可以傳送到同一個通道。例如,有一個字串通道和一個掛起函式,函式以指定的延遲將指定的字串重複傳送到此通道:

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

現在,讓我們看看如果啟動兩個協程來傳送字串會發生什麼情況(在本例中,我們將它們作為主協程的子協程,在主執行緒的上下文中啟動):

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
//sampleStart
    val channel = Channel<String>()
    launch { sendString(channel, "foo", 200L) }
    launch { sendString(channel, "BAR!", 500L) }
    repeat(6) { // receive first six
        println(channel.receive())
    }
    coroutineContext.cancelChildren() // cancel all children to let main finish
//sampleEnd
}

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

執行結果:

foo
foo
BAR!
foo
foo
BAR!
複製程式碼

八、帶緩衝的通道(Buffered channels)

到目前為止顯示的通道都沒有緩衝區。無緩衝通道在傳送方和接收方同時呼叫傳送和接收操作時傳輸元素。如果先呼叫 send,則在呼叫 receive 之前會將其掛起;如果先呼叫 receive ,則在呼叫 send 之前會將其掛起

Channel() 工廠函式和 produce 構建器都採用可選的引數 capacity 來指定緩衝區大小。 緩衝用於允許傳送者在掛起之前傳送多個元素,類似於具有指定容量的 BlockingQueue,它在緩衝區已滿時才阻塞

檢視以下程式碼的效果:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking<Unit> {
//sampleStart
    val channel = Channel<Int>(4) // create buffered channel
    val sender = launch { // launch sender coroutine
        repeat(10) {
            println("Sending $it") // print before sending each element
            channel.send(it) // will suspend when buffer is full
        }
    }
    // don't receive anything... just wait....
    delay(1000)
    sender.cancel() // cancel sender coroutine
//sampleEnd    
}
複製程式碼

使用了容量為4的緩衝通道,所以將列印五次:

Sending 0
Sending 1
Sending 2
Sending 3
Sending 4
複製程式碼

前四個元素被新增到緩衝區內,sender 在嘗試傳送第五個元素時掛起

九、通道是公平的(Channels are fair)

對通道的傳送和接收操作,對於從多個協程呼叫它們的順序是公平的。它們按先入先出的順序提供,例如,先呼叫 receive 的協程先獲取到元素。在下面的示例中,兩個協程 “ping” 和 “pong” 從共享的 “table” 通道接收 “ball” 物件

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

//sampleStart
data class Ball(var hits: Int)

fun main() = runBlocking {
    val table = Channel<Ball>() // a shared table
    launch { player("ping", table) }
    launch { player("pong", table) }
    table.send(Ball(0)) // serve the ball
    delay(1000) // delay 1 second
    coroutineContext.cancelChildren() // game over, cancel them
}

suspend fun player(name: String, table: Channel<Ball>) {
    for (ball in table) { // receive the ball in a loop
        ball.hits++
        println("$name $ball")
        delay(300) // wait a bit
        table.send(ball) // send the ball back
    }
}
//sampleEnd
複製程式碼

“ping” 協程首先開始執行,所以它是第一個接收到 ball 的。即使 “ping” 協程在將 ball 重新送回給 table 後又立即開始進行 receive,但 ball 還是會被 “pong” 接收到,因為它已經先在等待接收了:

ping Ball(hits=1)
pong Ball(hits=2)
ping Ball(hits=3)
pong Ball(hits=4)
複製程式碼

請注意,有時由於所使用的執行者的性質,通道可能會產生看起來不公平的執行效果。有關詳細資訊,請參閱此 issue

十、計時器通道(Ticker channels)

計時器通道是一種特殊的會合(rendezvous)通道,自該通道的最後一次消耗以來,每次給定的延遲時間結束後都將返回 Unit 值。儘管它看起來是無用處的,但它是一個有用的構建塊,可以建立複雜的基於時間的 produce 管道和進行視窗化操作以及其它時間相關的處理。計時器通道可用於 select 執行 “on tick” 操作

要建立這樣的通道,請使用工廠方法 ticker。如果不需要通道傳送更多元素了,請對其使用 ReceiveChannel.cancel 取消傳送

現在讓我們看看它在實踐中是如何工作的:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking<Unit> {
    val tickerChannel = ticker(delayMillis = 100, initialDelayMillis = 0) // create ticker channel
    var nextElement = withTimeoutOrNull(1) { tickerChannel.receive() }
    println("Initial element is available immediately: $nextElement") // initial delay hasn't passed yet

    nextElement = withTimeoutOrNull(50) { tickerChannel.receive() } // all subsequent elements has 100ms delay
    println("Next element is not ready in 50 ms: $nextElement")

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

    // Emulate large consumption delays
    println("Consumer pauses for 150ms")
    delay(150)
    // Next element is available immediately
    nextElement = withTimeoutOrNull(1) { tickerChannel.receive() }
    println("Next element is available immediately after large consumer delay: $nextElement")
    // Note that the pause between `receive` calls is taken into account and next element arrives faster
    nextElement = withTimeoutOrNull(60) { tickerChannel.receive() } 
    println("Next element is ready in 50ms after consumer pause in 150ms: $nextElement")

    tickerChannel.cancel() // indicate that no more elements are needed
}
複製程式碼

執行結果:

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 能感知到消費端可能處於暫停狀態,並且在預設的情況下,如果發生暫停,將會延遲下一個元素的生成,嘗試保持生成元素的固定速率

可選的,ticker 函式的 mode 引數可以指定為 TickerMode.FIXED_DELAY,以保證元素之間的固定延遲

相關文章