這篇文章大部分內(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é)程。不像其他有相似能力的語言,async
和await
不是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é)合了cancel
和join
的作用。
取消是需要配合的
協(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é)束。
使運算代碼可取消
兩種方式使運算代碼可取消。
- 周期執(zhí)行掛起函數(shù),檢查取消狀態(tài)。
yield
函數(shù)是達到這個目的的好辦法。 - 顯式的檢查取消狀態(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 {...}
表達式或Kotlinuse
(use 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.")
}
join
和cancelAndJoin
都會等所有結(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ā)
如果doSomethingUsefulOne
和doSomethingUsefulTwo
的執(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)用doSomethingUsefulOne
和 doSomethingUsefulTwo
。給這些函數(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)度到一個線程池,或者無限制運行。
所有像launch
或async
一樣的協(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) { ... }
。
父coroutineContext
和Unconfined
上下文的區(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
中的isActive
是coroutineContext[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é)程建造器建造相同的流水線。將produce
用buildIterator
替換,send
用yield
替換,receive
用next
替換
,ReceiveChannel
用Iterator
替換,并去掉上下文。你也不用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)扇出的。不像consumeEach
,for
循環(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)的修改。在阻塞的世界里,通常用synchronized
或ReentrantLock
達到互斥。協(xié)程中的替代品叫做Mutex
。它用lock
和unlock
方法界定臨界區(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)者:fizz
和buzz
。fizz
每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
中的onReceiveOrNull
和onAwait
子句放在一起:
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