深入理解 Kotlin coroutine (二)

原文鏈接:https://github.com/enbandari/Kotlin-Tutorials

上周我們把 Kotlin Coroutine 的基本 API 挨個講了一下,也給出了一些簡單的封裝。

真是不太給力,就在前幾天發布的 1.1 Beta 2 當中,所有協程的 API 包名后面都加了一個 experimental,這意味著 Kotlin 官方在 1.1 當中還是傾向于將 Coroutine 作為一個實驗性質的特性的,不過,這也沒關系,我們學習的心不以外界的變化而變化不是?

這一篇我們基于前面的基礎來了解一下 Kotlinx.coroutines 這個庫的使用,如果大家對它的實現原理有興趣,可以再讀一讀上一篇文章,我們也可以在后面繼續寫一些文章來給深入地大家介紹。

1. 準備工作

就像前面我們說到的,1.1 Beta 2 當中協程相關的基礎庫的包名都增加了 experimental,所以我們在選擇 kotlinx.coroutines 的版本的時候也一定要對應好編譯器的版本,不然...你自己想哈哈。

我們強調一下,kotlin 的版本選擇 1.1.0-beta-38,kotlinx.coroutines 的版本選擇 0.6-beta,如果你恰好使用 gradle,那么告訴你一個好消息,我會直接告訴你怎么配置:

buildscript { 
    ext.kotlin_version = '1.1.0-beta-38' 
  
    repositories { 
        jcenter() 
  
        maven { 
            url "http://dl.bintray.com/kotlin/kotlin-eap-1.1" 
        } 
    } 
  
    ... 
} 
  
repositories { 
    jcenter() 
  
    maven { 
        url "http://dl.bintray.com/kotlin/kotlin-eap-1.1" 
    } 
} 
  
kotlin { 
    experimental { 
        coroutines 'enable' 
    } 
} 
  
dependencies { 
    compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 
    compile 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.6-beta' 
} 

2. 一個基本的協程的例子

這個例子是 kotlinx.coroutines 的第一個小例子。

fun main(args: Array<String>) { 
    launch(CommonPool) { // create new coroutine in common thread pool 
        delay(1000L) // non-blocking delay for 1 second (default time unit is ms) 
        println("World!") // print after delay 
    } 
    println("Hello,") // main function continues while coroutine is delayed 
    Thread.sleep(2000L) // block main thread for 2 seconds to keep JVM alive 
} 

這個例子的運行結果是:

Hello, 
World! 

其實有了上一篇文章的基礎我們很容易知道,launch 方法啟動了一個協程,CommonPool 是一個有線程池的上下文,它可以負責把協程的執行分配到合適的線程上。所以從線程的角度來看,打印的這兩句是在不同的線程上的。

20170206-063015.015 [main] Hello, 
20170206-063016.016 [ForkJoinPool.commonPool-worker-1] World! 

這段代碼的執行效果與線程的版本看上去是一樣的:

thread(name = "MyThread") {  
    Thread.sleep(1000L)  
    log("World!")  
} 
log("Hello,")  
Thread.sleep(2000L)  

3. 主線程上的協程

我們剛才通過 launch 創建的協程是在 CommonPool 的線程池上面的,所以協程的運行并不在主線程。如果我們希望直接在主線程上面創建協程,那怎么辦?

fun main(args: Array<String>) = runBlocking<Unit> {  
    launch(CommonPool) {  
        delay(1000L) 
        println("World!") 
    } 
    println("Hello,")  
    delay(2000L)  
} 

這個還是 kotlinx.coroutines 的例子,我們來分析一下。runBlocking 實際上也跟 launch 一樣,啟動一個協程,只不過它傳入的 context 不會進行線程切換,也就是說,由它創建的協程會直接運行在當前線程上。

在 runBlocking 當中通過 launch 再創建一個協程,顯然,這段代碼的運行結果與上一個例子是完全一樣的。需要注意的是,盡管我們可以在協程中通過 launch 這樣的方法創建協程,但不要再協程當中通過 runBlocking 再來創建協程,因為這樣做雖然一般來說不會導致程序異常,不過,這樣的程序也沒有多大意義:

fun main(args: Array<String>) = runBlocking<Unit> { 
    runBlocking { 
        delay(1000L) 
        println("World!") 
    } 
    println("Hello,") 
} 

運行結果:

World! 
Hello, 

大家看到了,嵌套的 runBlocking 實際上仍然只是一段順序代碼而已。

那么,讓我們再仔細看看前面的例子,不知道大家有沒有問題:如果我在 launch 創建的協程當中多磨嘰一會兒,主線程上的協程 delay(2000L) 好像也沒多大用啊。有沒有什么方法保證協程執行完?

4. 外部控制協程

我們在上一篇文章當中只是對內置的基礎 API 進行了簡單的封裝,而 kotlinx.coroutines 卻為我們做了非常多的事情。比如,每一個協程都看做一個 Job,我們在一個協程的外部也可以控制它的運行。

fun main(args: Array<String>) = runBlocking<Unit> { 
    val job = launch(CommonPool) {  
        delay(1000L) 
        println("World!") 
    } 
    println("Hello,") 
    job.join()  
} 

job.join 其實就是要求當前協程等待 job 執行完成之后再繼續執行。

其實,我們還可以取消協程,讓他直接停止執行:

fun main(args: Array<String>) = runBlocking<Unit> { 
    val job = launch(CommonPool) {  
        delay(1000L) 
        println("World!") 
    } 
    println("Hello,") 
    job.cancel()  
} 

job.cancel 會直接終止 job 的執行。如果 job 已經執行完畢,那么 job.cancel 的執行時沒有意義的。我們也可以根據 cancel 的返回值來判斷是否取消成功。

另外,cancel 還可以提供原因:

job.cancel(IllegalAccessException("World!")) 

如果我們提供了這個原因,那么被取消的協程會將它打印出來。

Hello, 
Exception in thread "main" java.lang.IllegalAccessException: World! 
    at example13.Example_13Kt$main$1.doResume(example-13.kt:14) 
    at kotlin.coroutines.experimental.jvm.internal.CoroutineImpl.resume(CoroutineImpl.kt:53) 
    at kotlinx.coroutines.experimental.DispatchedContinuation$resume$1.run(CoroutineDispatcher.kt:57) 

其實,如果你自己做過對線程任務的取消,你大概會知道除非被取消的線程自己去檢查取消的標志位,或者被 interrupt,否則取消是無法實現的,這有點兒像一個人執意要做一件事兒,另一個人說你別做啦,結果人家壓根兒沒聽見,你說他能停下來嗎?那么我們前面的取消到底是誰去監聽了這個 cancel 操作呢?

當然是 delay 這個操作了。其實所有 kotlinx.coroutines 當中定義的操作都可以做到這一點,我們對代碼稍加改動,你就會發現異常來自何處了:

val job = launch(CommonPool) {  
    try { 
        delay(1000L) 
        println("World!") 
    } catch(e: Exception) { 
        e.printStackTrace() 
    }finally { 
        println("finally....") 
    } 
} 
println("Hello,") 
job.cancel(IllegalAccessException("World!"))  

是的,你沒看錯,我們居然可以在協程里面對 cancel 進行捕獲,如果你愿意的話,你甚至可以繼續在這個協程里面運行代碼,但請不要這樣做,下面的示例破壞了 cancel 的設計本意,所以請勿模仿:

val job = launch(CommonPool) {  
    try { 
        ... 
    }finally { 
        println("finally....") 
    } 
    println("I'm an EVIL!!! Hahahaha") 
} 

說這個是什么意思呢?在協程被 cancel 掉的時候,我們應該做的其實是把戰場打掃干凈,比如:

val job = launch(CommonPool) { 
    val inputStream = ...
    try {
        ...
    }finally {
        inputStream.close()
    }
}

我們再來考慮下面的情形:

fun main(args: Array<String>) = runBlocking<Unit> { 
    val job = launch(CommonPool) { 
        var nextPrintTime = 0L 
        var i = 0 
        while (true) { // computation loop 
            val currentTime = System.currentTimeMillis() 
            if (currentTime >= nextPrintTime) { 
                println("I'm sleeping ${i++} ...") 
                nextPrintTime = currentTime + 500L 
            } 
        } 
    } 
    delay(1300L) // delay a bit 
    println("main: I'm tired of waiting!") 
    job.cancel() // cancels the job 
    delay(1300L) // delay a bit to see if it was cancelled.... 
    println("main: Now I can quit.") 
} 

不得不說,kotlinx.coroutines 在幾天前剛剛更新的文檔和示例非常的棒。我們看到這個例子,while(true) 會讓這個協程不斷運行來模擬耗時計算,盡管外部調用了 job.cancel(),但由于內部并沒有 care 自己是否被 cancel,所以這個 cancel 顯然有點兒失敗。如果你想要在類似這種耗時計算當中檢測當前協程是否被取消的話,你可以這么寫:

... 
while (isActive) { // computation loop 
   ... 
} 
...       

isActive 會在 cancel 之后被置為 false。

其實,通過這幾個示例大家就會發現協程的取消,與我們通常取消線程操作的思路非常類似,只不過人家封裝的比較好,而我們呢,每次還得自己搞一個 CancelableTask 來實現 Runnable 接口去承載自己的異步操作,想想也是夠原始呢。

5. 輕量級線程

協程時輕量級的,它擁有自己的運行狀態,但它對資源的消耗卻非常的小。其實能做到這一點的本質原因,我們已經在上一篇文章當中提到過,一臺服務器開 1k 線程和 1k 協程來響應服務,前者對資源的消耗必然很大,而后者可能只是基于很少的幾個或幾十個線程來工作的,隨著請求數量的增加,協程的優勢可能會體現的更加明顯。

我們來看個比較簡單的例子:

fun main(args: Array<String>) = runBlocking<Unit> { 
    val jobs = List(100_000) {  
        launch(CommonPool) { 
            delay(1000L) 
            print(".") 
        } 
    } 
    jobs.forEach { it.join() } //這里不能用 jobs.forEach(Job::join),因為 Job.join 是 suspend 方法 
} 

通過 List 這個方法,我們可以瞬間創建出很多對象放入返回的 List,注意到這里的 jobs 其實就是協程的一個 List。

運行上面的代碼,我們發現 CommonPool 當中的線程池的線程數量基本上維持在三四個就足夠了,如果我們用線程來寫上面的代碼會是什么感覺?

fun main(args: Array<String>) = runBlocking<Unit> { 
    val jobs = List(100_000) {  
        thread { 
            Thread.sleep(1000L) 
            log(".") 
        } 
    } 
    jobs.forEach(Thread::join) // Thread::join 說起來也是 1.1 的新特性呢! 
}  

運行時,在創建了 1k 多個線程之后,就拋出了異常:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread 
    at java.lang.Thread.start0(Native Method) 

嗯,又多了一個用協程的理由,對不對?

6. 攜帶值的 Job

我們前面說了,通過攜程返回的 Job,我們可以控制攜程的運行。可有時候我們更關注協程運行的結果,比如從網絡加載一張圖片:

suspend fun loadImage(url: String): Bitmap { 
    ... 
    return ... 
} 

沒錯,我們更關注它的結果,這種情況我們該怎么辦呢?如果 loadImage 不是 suspend 方法,那么我們在非 UI 線程當中直接獲取他們:

val imageA = loadImage(urlA) 
val imageB = loadImage(urlB) 
onImageGet(imageA, imageB) 

這樣的操作有什么問題?順序獲取兩張圖片,耗時,不經濟。所以傳統的做法就是開兩個線程做這件事兒,這意味著你會看到兩個回調,并且還要同步這兩個回調,想想都頭疼。

不過我們現在有更好的辦法:

val imageA = defer(CommonPool) { loadImage(urlA) } 
val imageB = defer(CommonPool) { loadImage(urlB) } 
onImageGet(imageA.await(),imageB.await()) 

代碼量幾乎沒有增加,不過我們卻做到了兩張圖片異步獲取,并同時傳給 onImageGet 以便繼續后面的操作。

defer 到底是個什么東西?其實大家大可不必看到新詞就感到恐慌,這東西用法幾乎跟 launch 一樣,只不過它返回的 Deferred 功能比 Job 多了一樣:攜帶返回值。我們前面看到的 imageA 其實就是一個 Deferred 實例,而它的 await 方法返回的則是 Bitmap 類型,也即 loadImage(urlA) 的返回值。

所以如果你對協程運行的結果感興趣,直接使用 defer 來替換你的 launch 就可以了。需要注意的是,即便你不調用 await,defer 啟動的協程也會立即運行,如果你希望你的協程能夠按需啟動,例如只有你調用 await 之后再啟動,那么你可以用 lazyDefer:

val imageA = lazyDefer(CommonPool) { loadImage(urlA) } 
val imageB = lazyDefer(CommonPool) { loadImage(urlB) } 
onImageGet(imageA.await(),imageB.await()) //這時候才開始真正去加載圖片 

7. 生成器

不知道大家對 python 的生成器有沒有了解,這個感覺就好似延遲計算一樣。

假設我們要計算 fibonacci 數列,這個大家都知道,也非常容易寫,你可能分分鐘寫出一個遞歸的函數來求得這個序列,不過你應該知道遞歸的層級越多,stackOverflow 的可能性越大吧?另外,如果我們只是用到其中的幾個,那么遞歸的函數一下子都給求出來,而且每次調用也沒有記憶性導致同一個值計算多次,非常不經濟。大家看一個 python 的例子:

def fibonacci(): 
    yield 1 # 直接返回 1, 并且在此處暫停 
    first = 1 
    second = 1 
    while True: 
        yield first 
        first, second = first + second, first 
        
a = fibonacci() 
for x in a: 
    print x 
    if x > 100: break 

前面給出的這種計算方法,fibonacci 函數返回一個可迭代的對象,這個對象其實就是生成器,只有我們在迭代它的時候,它才會去真正執行計算,只要遇到 yield,那么這一次迭代到的值就是 yield 后面的值,比如,我們第一次調用 fibonacci 這個函數的時候,得到的值就是 1,后面依次類推。

Kotlin 在添加了協程這個功能之后,也可以這么搞了:

val fibonacci = buildSequence { 
    yield(1) // first Fibonacci number 
    var cur = 1 
    var next = 1 
    while (true) { 
        yield(next) // next Fibonacci number 
        val tmp = cur + next 
        cur = next 
        next = tmp 
    } 
} 
... 
for (i in fibonacci){ 
    println(i) 
    if(i > 100) break //大于100就停止循環 
} 

可以這么說,這段代碼與前面的 python 版本功能是完全相同的,在 yield 方法調用時,傳入的值就是本次迭代的值。

fibonacci 這個變量的類型如下:

public interface Sequence<out T> { 
   public operator fun iterator(): Iterator<T> 
} 

既然有 iterator 方法,那么我們可以直接對 fibonacci 進行迭代也就沒什么大驚小怪的了。這個 iterator 保證每次迭代的時候去執行 buildSequence 后面的 Lambda 的代碼,從上一個 yield 之后開始到下一個 yield 結束,yield 傳入的值就是 iterator 的 next 的返回值。

有了這個特性,我們就可以構造許多“懶”序列,只有在用到的時候才去真正計算每一個元素的值,而且運算狀態可以保存,每次計算的結果都不會浪費。

注:這個特性是被 Kotlin 標準庫收錄了的,并不存在于 kotlinx.coroutines 當中,不過這也沒關系啦,kotlinx.coroutines 的 API 會不會在不久的將來也作為 Kotlin 標準庫的內容出現呢?

8. 小結

這一篇的內容其實相對上一篇要簡單一些,面對 kotlinx.coroutines 這樣的框架,我們直接通過分析案例,將 coroutine 這么理論化的東西投入實際場景,讓大家從感性上對其有個更加深入的認識。

當然,我們并沒有深入其中了解其原理,原因就是上一篇我們為此做了足夠的準備 —— kotlinx.coroutines 作為官方的框架,自然要實現得完善一些,但也是萬變不離其宗。

寫到這里,我想,我們還是需要有一篇文章再來介紹一些協程使用的一些注意事項,那么我們下一篇再見吧。

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

推薦閱讀更多精彩內容