上周我們把 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 作為官方的框架,自然要實現得完善一些,但也是萬變不離其宗。
寫到這里,我想,我們還是需要有一篇文章再來介紹一些協程使用的一些注意事項,那么我們下一篇再見吧。