原文連結:https://github.com/EasyKotlin
在常用的並行存取模型中,多進程、多線程、分布式是最普遍的,不過近些年來逐漸有一些語言以first-class或者library的形式提供對基於協程的並行存取模型的支援。其中比較典型的有Scheme、Lua、Python、Perl、Go等以first-class的方式提供對協程的支援。
同樣地,Kotlin也支援協程。
本章我們主要介紹: 什麼是協程 協程的用法執行個體 掛起函數 通道與管道 協程的實現原理 coroutine庫等 9.1 協程簡介
從硬體發展來看,從最初的單核單CPU,到單核多CPU,多核多CPU,似乎已經到了極限了,但是單核CPU效能卻還在不斷提升。如果將程式分為IO密集型應用和CPU密集型應用,二者的發展曆程大致如下:
IO密集型應用: 多進程->多線程->事件驅動->協程
CPU密集型應用:多進程->多線程
如果說多進程對於多CPU,多線程對應多核CPU,那麼事件驅動和協程則是在充分挖掘不斷提高效能的單核CPU的潛力。
常見的有效能瓶頸的API (例如網路 IO、檔案 IO、CPU 或 GPU 密集型任務等),要求調用者阻塞(blocking)直到它們完成才能進行下一步。後來,我們又使用非同步回調的方式來實現非阻塞,但是非同步回調代碼寫起來並不簡單。
協程提供了一種避免阻塞線程並用更簡單、更可控的操作替代線程阻塞的方法:協程掛起。
協程主要是讓原來要使用“非同步+回調方式”寫出來的複雜代碼, 簡化成可以用看似同步的方式寫出來(對線程的操作進一步抽象)。這樣我們就可以按串列的思維模型去組織原本分散在不同上下文中的代碼邏輯,而不需要去處理複雜的狀態同步問題。
協程最早的描述是由Melvin Conway於1958年給出:“subroutines who act as the master program”(與主程式行為類似的子常式)。此後他又在博士論文中給出了如下定義:
資料在後續調用中始終保持( The values of data local to a coroutine persist between successive calls 協程的局部)
當控制流程程離開時,協程的執行被掛起,此後控制流程程再次進入這個協程時,這個協程只應從上次離開掛起的地方繼續 (The execution of a coroutine is suspended as control leaves it, only to carry on where it left off when control re-enters the coroutine at some later stage)。
協程的實現要維護一組局部狀態,在重新進入協程前,保證這些狀態不被改變,從而能順利定位到之前的位置。
協程可以用來解決很多問題,比如nodejs的嵌套回調,Erlang以及Golang的並行存取模型實現等。
實質上,協程(coroutine)是一種使用者態的輕量級線程。它由協程構建器(launch coroutine builder)啟動。
下面我們通過代碼實踐來學習協程的相關內容。 9.1.1 搭建協程代碼工程
首先,我們來建立一個Kotlin Gradle工程。產生標準gradle工程後,在設定檔build.gradle中,配置kotlinx-coroutines-core依賴:
添加 dependencies :
compile 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.16'
kotlinx-coroutines還提供了下面的模組:
compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-jdk8', version: '0.16'compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-nio', version: '0.16'compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-reactive', version: '0.16'
我們使用Kotlin最新的1.1.3-2 版本:
buildscript { ext.kotlin_version = '1.1.3-2' ... dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" }}
其中,kotlin-gradle-plugin是Kotlin整合Gradle的外掛程式。
另外,配置一下JCenter 的倉庫:
repositories { jcenter()}
9.1.2 簡單協程樣本
下面我們先來看一個簡單的協程樣本。
運行下面的代碼:
fun firstCoroutineDemo0() { launch(CommonPool) { delay(3000L, TimeUnit.MILLISECONDS) println("Hello,") } println("World!") Thread.sleep(5000L)}
你將會發現輸出:
World!Hello,
上面的這段代碼:
launch(CommonPool) { delay(3000L, TimeUnit.MILLISECONDS) println("Hello,")}
等價於:
launch(CommonPool, CoroutineStart.DEFAULT, { delay(3000L, TimeUnit.MILLISECONDS) println("Hello, ")})
9.1.3 launch函數
這個launch函數定義在kotlinx.coroutines.experimental下面。
public fun launch( context: CoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit): Job { val newContext = newCoroutineContext(context) val coroutine = if (start.isLazy) LazyStandaloneCoroutine(newContext, block) else StandaloneCoroutine(newContext, active = true) coroutine.initParentJob(context[Job]) start(block, coroutine, coroutine) return coroutine}
launch函數有3個入參:context、start、block,這些函數參數分別說明如下:
| 參數 |
說明 |
| context |
協程上下文 |
| start |
協程啟動選項 |
| block |
協程真正要執行的代碼塊,必須是suspend修飾的掛起函數 |
這個launch函數返回一個Job類型,Job是協程建立的背景工作的概念,它持有該協程的引用。Job介面實際上繼承自CoroutineContext類型。一個Job有如下三種狀態:
| State |
isActive |
isCompleted |
| New (optional initial state) 建立 (可選的初始狀態) |
false |
false |
| Active (default initial state) 活動中(預設初始狀態) |
true |
false |
| Completed (final state) 已結束(最終狀態) |
false |
true |
也就是說,launch函數它以非阻塞(non-blocking)當前線程的方式,啟動一個新的協程背景工作,並返回一個Job類型的對象作為當前協程的引用。
另外,這裡的delay()函數類似Thread.sleep()的功能,但更好的是:它不會阻塞線程,而只是掛起協程本身。當協程在等待時,線程將返回到池中, 當等待完成時, 協程將在池中的空閑線程上恢複。 9.1.4 CommonPool:共用線程池
我們再來看一下launch(CommonPool) {...}這段代碼。
首先,這個CommonPool是代表共用線程池,它的主要作用是用來調度計算密集型任務的協程的執行。它的實現使用的是java.util.concurrent包下面的API。它首先嘗試建立一個java.util.concurrent.ForkJoinPool (ForkJoinPool是一個可以執行ForkJoinTask的ExcuteService,它採用了work-stealing模式:所有在池中的線程嘗試去執行其他線程建立的子任務,這樣很少有線程處於空閑狀態,更加高效);如果不可用,就使用java.util.concurrent.Executors來建立一個普通的線程池:Executors.newFixedThreadPool。相關代碼在kotlinx/coroutines/experimental/CommonPool.kt中:
private fun createPool(): ExecutorService { val fjpClass = Try { Class.forName("java.util.concurrent.ForkJoinPool") } ?: return createPlainPool() if (!usePrivatePool) { Try { fjpClass.getMethod("commonPool")?.invoke(null) as? ExecutorService } ?.let { return it } } Try { fjpClass.getConstructor(Int::class.java).newInstance(defaultParallelism()) as? ExecutorService } ?. let { return it } return createPlainPool()}private fun createPlainPool(): ExecutorService { val threadId = AtomicInteger() return Executors.newFixedThreadPool(defaultParallelism()) { Thread(it, "CommonPool-worker-${threadId.incrementAndGet()}").apply { isDaemon = true } }}
這個CommonPool對象類是CoroutineContext的子類型。它們的類型整合階層如下:
9.1.5 掛起函數
代碼塊中的delay(3000L, TimeUnit.MILLISECONDS)函數,是一個用suspend關鍵字修飾的函數,我們稱之為掛起函數。掛起函數只能從協程代碼內部調用,普通的非協程的代碼不能調用。
掛起函數只允許由協程或者另外一個掛起函數裡面調用, 例如我們在協程代碼中調用一個掛起函數,程式碼範例如下:
suspend fun runCoroutineDemo() { run(CommonPool) { delay(3000L, TimeUnit.MILLISECONDS) println("suspend,") } println("runCoroutineDemo!") Thread.sleep(5000L)}fun callSuspendFun() { launch(CommonPool) { runCoroutineDemo() }}
如果我們用Java中的Thread類來寫類似功能的代碼,上面的代碼可以寫成這樣:
fun threadDemo0() { Thread({ Thread.sleep(3000L) println("Hello,") }).start() println("World!") Thread.sleep(5000L)}
輸出結果也是:
World!Hello,
另外, 我們不能使用Thread來啟動協程代碼。例如下面的寫法編譯器會報錯:
/** * 錯誤反例:用線程調用協程 error */fun threadCoroutineDemo() { Thread({ delay(3000L, TimeUnit.MILLISECONDS) // error, Suspend functions are only allowed to be called from a coroutine or another suspend function println("Hello,") }) println("World!") Thread.sleep(5000L)}
9.2 橋接 阻塞和非阻塞
上面的例子中,我們給出的是使用非阻塞的delay函數,同時又使用了阻塞的Thread.sleep函數,這樣代碼寫在一起可讀性不是那麼地好。讓我們來使用純的Kotlin的協程代碼來實現上面的 阻塞+非阻塞 的例子(不用Thread)。 9.2.1 runBlocking函數
Kotlin中提供了runBlocking函數來實作類別似主協程的功能:
fun main(args: Array<String>) = runBlocking<Unit> { // 主協程 println("${format(Date())}: T0") // 啟動主協程 launch(CommonPool) { //在common thread pool中建立協程 println("${format(Date())}: T1") delay(3000L) println("${format(Date())}: T2 Hello,") } println("${format(Date())}: T3 World!") // 當子協程被delay,主協程仍然繼續運行 delay(5000L) println("${format(Date())}: T4")}
運行結果:
14:37:59.640: T014:37:59.721: T114:37:59.721: T3 World!14:38:02.763: T2 Hello,14:38:04.738: T4
可以發現,運行結果跟之前的是一樣的,但是我們沒有使用Thread.sleep,我們只使用了非阻塞的delay函數。如果main函數不加 = runBlocking<Unit> , 那麼我們是不能在main函數體內調用delay(5000L)的。
如果這個阻塞的線程被中斷,runBlocking拋出InterruptedException異常。
該runBlocking函數不是用來當作普通協程函數使用的,它的設計主要是用來橋接普通阻塞代碼和掛起風格的(suspending style)的非阻塞代碼的, 例如用在 main 函數中,或者用於測試案例代碼中。
@RunWith(JUnit4::class)class RunBlockingTest { @Test fun testRunBlocking() = runBlocking<Unit> { // 這樣我們就可以在這裡調用任何suspend fun了 launch(CommonPool) { delay(3000L) } delay(5000L) }}
9.3 等待一個任務執行完畢
我們先來看一段代碼:
fun firstCoroutineDemo() { launch(CommonPool) { delay(3000L, TimeUnit.MILLISECONDS) println("[firstCoroutineDemo] Hello, 1") } launch(CommonPool, CoroutineStart.DEFAULT, { delay(3000L, TimeUnit.MILLISECONDS) println("[firstCoroutineDemo] Hello, 2") }) println("[firstCoroutineDemo] World!")}
運行這段代碼,我們會發現只輸出:
[firstCoroutineDemo] World!
這是為什麼。
為了弄清上面的代碼執行的內部過程,我們列印一些日誌看下:
fun testJoinCoroutine() = runBlocking<Unit> { // Start a coroutine val c1 = launch(CommonPool) { println("C1 Thread: ${Thread.currentThread()}") println("C1 Start") delay(3000L) println("C1 World! 1") } val c2 = launch(CommonPool) { println("C2 Thread: ${Thread.currentThread()}") println("C2 Start") delay(5000L) println("C2 World! 2") } println("Main Thread: ${Thread.currentThread()}") println("Hello,") println("Hi,") println("c1 is active: ${c1.isActive} ${c1.isCompleted}") println("c2 is active: ${c2.isActive} ${c2.isCompleted}")}
再次運行:
C1 Thread: Thread[ForkJoinPool.commonPool-worker-1,5,main]C1 StartC2 Thread: Thread[ForkJoinPool.commonPool-worker-2,5,main]C2 StartMain Thread: Thread[main,5,main]Hello,Hi,c1 is active: true falsec2 is active: true false
我們可以看到,這裡的C1、C2代碼也開始執行了,使用的是ForkJoinPool.commonPool-worker線程池中的worker線程。但是,我們在代碼執行到最後列印出這兩個協程的狀態isCompleted都是false,這表明我們的C1、C2的代碼,在Main Thread結束的時刻(此時的運行main函數的Java進程也退出了),還沒有執行完畢,然後就跟著主線程一起退出結束了。
所以我們可以得出結論:運行 main () 函數的主線程, 必須要等到我們的協程完成之前結束 , 否則我們的程式在 列印Hello, 1和Hello, 2之前就直接結束掉了。
我們怎樣讓這兩個協程參與到主線程的時間順序裡呢。我們可以使用join, 讓主線程一直等到當前協程執行完畢再結束, 例如下面的這段代碼
fun testJoinCoroutine() = runBlocking<Unit> { // Start a coroutine val c1 = launch(CommonPool) { println("C1 Thread: ${Thread.currentThread()}") println("C1 Start") delay(3000L) println("C1 World! 1") } val c2 = launch(CommonPool) { println("C2 Thread: ${Thread.currentThread()}") println("C2 Start") delay(5000L) println("C2 World! 2") } println("Main Thread: ${Thread.currentThread()}") println("Hello,") println("c1 is active: ${c1.isActive} isCompleted: ${c1.isCompleted}") println("c2 is active: ${c2.isActive} isCompleted: ${c2.isCompleted}") c1.join() // the main thread will wait until child coroutine completes println("Hi,") println("c1 is active: ${c1.isActive} isCompleted: ${c1.isCompleted}") println("c2 is active: ${c2.isActive} isCompleted: ${c2.isCompleted}") c2.join() // the main thread will wait until child coroutine completes println("c1 is active: ${c1.isActive} isCompleted: ${c1.isCompleted}") println("c2 is active: ${c2.isActive} isCompleted: ${c2.isCompleted}")}
將會輸出:
C1 Thread: Thread[ForkJoinPool.commonPool-worker-1,5,main]C1 StartC2 Thread: Thread[ForkJoinPool.commonPool-worker-2,5,main]C2 StartMain Thread: Thread[main,5,main]Hello,c1 is active: true isCompleted: falsec2 is active: true isCompleted: falseC1 World! 1Hi,c1 is active: false isCompleted: truec2 is active: true isCompleted: falseC2 World! 2c1 is active: false isCompleted: truec2 is active: false isCompleted: true
通常,良好的代碼風格我們會把一個單獨的邏輯放到一個獨立的函數中,我們可以重構上面的代碼如下:
fun testJoinCoroutine2() = runBlocking<Unit> { // Start a coroutine val c1 = launch(CommonPool) { fc1() } val c2 = launch(CommonPool) { fc2() } ...}private suspend fun fc2() { println("C2 Thread: ${Thread.currentThread()}") println("C2 Start") delay(5000L) println("C2 World! 2")}private suspend fun fc1() { println("C1 Thread: ${Thread.currentThread()}") println("C1 Start") delay(3000L) println("C1 World! 1")}
可以看出,我們這裡的fc1, fc2函數是suspend fun。 9.4 協程是輕量級的
直接運行下面的代碼:
fun testThread() { val jobs = List(100_1000) { Thread({ Thread.sleep(1000L) print(".") }) } jobs.forEach { it.start() } jobs.forEach { it.join() }}
我們應該會看到輸出報錯:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread at java.lang.Thread.start0(Native Method) at java.lang.Thread.start(Thread.java:714) at com.easy.kotlin.LightWeightCoroutinesDemo.testThread(LightWeightCoroutinesDemo.kt:30) at com.easy.kotlin.LightWeightCoroutinesDemoKt.main(LightWeightCoroutinesDemo.kt:40)...........................................................................................
我們這裡直接啟動了100,000個線程,並join到一起列印”.”, 不出意外的我們收到了java.lang.OutOfMemoryError。
這個異常問題本質原因是我們建立了太多的線程,而能建立的線程數是有限制的,導致了異常的發生。在Java中, 當我們建立一個線程的時候,虛擬機器會在JVM記憶體建立一個Thread對象同時建立一個作業系統線程,而這個系統線程的記憶體用的不是JVMMemory,而是系統中剩下的記憶體(MaxProcessMemory - JVMMemory - ReservedOsMemory)。 能建立的線程數的具體計算公式如下:
Number of Threads = (MaxProcessMemory - JVMMemory - ReservedOsMemory) / (ThreadStackSize)
其中,參數說明如下:
| 參數 |
說明 |
| MaxProcessMemory |
指的是一個進程的最大記憶體 |
| JVMMemory |
JVM記憶體 |
| ReservedOsMemory |
保留的作業系統記憶體 |
| ThreadStackSize |
線程棧的大小 |
我們通常在最佳化這種問題的時候,要麼是採用減小thread stack的大小的方法,要麼是採用減小heap或permgen初始分配的大小方法等方式來臨時解決問題。
在協程中,情況完全就不一樣了。我們看一下實現上面的邏輯的協程代碼:
fun testLightWeightCoroutine() = runBlocking { val jobs = List(100_000) { // create a lot of coroutines and list their jobs launch(CommonPool) { delay(1000L) print(".") } } jobs.forEach { it.join() } // wait for all jobs to complete}
運行上面的代碼,我們將看到輸出:
START: 21:22:28.913..........................................(100000個).....END: 21:22:30.956
上面的程式在2s左右的時間內正確執行完畢。 9.5 協程 vs 守護線程
在Java中有兩類線程:使用者線程 (User Thread)、守護線程 (Daemon Thread)。
所謂守護線程,是指在程式啟動並執行時候在後台提供一種泛型服務的線程,比如記憶體回收線程就是一個很稱職的守護者,並且這種線程並不屬於程式中不可或缺的部分。因此,當所有的非守護線程結束時,程式也就終止了,同時會殺死進程中的所有守護線程。
我們來看一段Thread的守護線程的代碼:
fun testDaemon2() { val t = Thread({ repeat(100) { i -> println("I'm sleeping $i ...") Thread.sleep(500L) } }) t.isDaemon = true // 必須在啟動線程前調用,否則會報錯:Exception in thread "main" java.lang.IllegalThreadStateException t.start() Thread.sleep(2000L) // just quit after delay}
這段代碼啟動一個線程,並設定為守護線程。線程內部是間隔500ms 重複列印100次輸出。外部主線程睡眠2s。