Kotlin Coroutine(協程): 二、初識協程

孟老闆發表於2021-07-13

@

前言

你還在用 Hanlder + Message? 或者 AsyncTask? 你還在用 Rxjava?
有人說RxjavaCoroutine是從不同維度解決非同步, 並且Rxjava的強大不止於非同步問題.
好吧, 管它呢. 讓我們擁抱 Coroutine(協程) 吧.


協程概念:

概念? 那些抽象的話術我們就不提了. 有人說協程是輕量級的執行緒. 有人說它是一種執行緒管理框架. 而博主更傾向於後者. 博主認為協程的工作是: 手握執行緒池, 拆分程式碼塊, 掛起與恢復, 規劃與排程, 確保任務按照預期執行 .


嘚瑟


那協程會解決什麼問題呢?:

  • 非同步任務, 例如延時執行. 子執行緒執行任務等
  • 並行任務, 同步結果;
  • 解決"回撥地獄", 看起來就像寫同步程式碼

導包:

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3"
//for Android
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3'

提示:以下是本篇文章正文內容,下面案例可供參考. 由於篇幅有限, 如想要更詳細的測試例子, 請看官網

一、初識協程

我們列印帶上執行緒別名.
概念難懂怎麼辦? 快把程式碼敲起來! 敲的多, 懂得快

//列印程式碼
fun letUsPrintln(title: String){
    println("$title Thread_name:${Thread.currentThread().name}")
}

1.runBlocking: 阻塞協程

fun main() {
    letUsPrintln("Hello,")
    runBlocking {               // 這個表示式阻塞了主執行緒
        letUsPrintln("World!")
        delay(2000L)  // 等待2秒
        letUsPrintln("end!")
    }
    letUsPrintln("我被阻塞了沒!")
}

列印結果如下:

Hello, Thread_name:main
World! Thread_name:main
end! Thread_name:main
我被阻塞了沒! Thread_name:main

可以發現:

  • delay: 一個特殊的 掛起函式 ,它不會造成執行緒阻塞
  • 所有列印均在主執行緒; 實際是在啟動它的執行緒執行.
  • runBlocking{..} 之後的程式碼, 在runBlocking執行完畢後 才執行
  • 所以: runBlocking 是阻塞協程, 它會阻塞主執行緒, 直到協程執行完畢. 類似於讓執行緒 Thread.sleep(2000)

疑問: runBlocking {} 阻塞的是 主執行緒? 還是啟動它的執行緒?

我們讓 runBlocking {..} 在子執行緒啟動. 並把它們放入 Activity的onCreate函式中. 如果頁面不能正常操作, 則表示阻塞了主執行緒.

runBlocking 不是重點, 我們只帖核心程式碼, 有興趣的可以自行測試.

Thread{ runBlocking {
	Log...
	delay(10000L)  // 我們延遲 10 秒來看 UI是否可以操作.
	Log...
}}.start()

結論:

  • 主執行緒UI可以正常操作, 所以 runBlocking {} 並非狙擊主執行緒, 而是阻塞啟動它的執行緒
  • runBlocking{} 是個頂層協程, 不應放入 launch, async 或 suspend 函式中.
  • 因為 runBlocking{} 會阻塞執行緒, 這樣還不如直接執行耗時程式碼呢. 即便它是為了套子協程, 在Android中阻塞主執行緒是非常危險的, 所以一般不會直接使用.

2.launch: 建立協程

上程式碼:

GlobalScope.launch { // 在後臺啟動一個新的協程並繼續; 
    delay(1000L)
    letUsPrintln("World!")
}
letUsPrintln("Hello,") //launch 不是阻塞協程, 後面主執行緒中的程式碼會立即執行
runBlocking {     
    delay(2000L)  // 我們阻塞主執行緒 2 秒來保證 JVM 的存活
    letUsPrintln("end!")
}

列印結果:

Hello, Thread_name:main
World! Thread_name:DefaultDispatcher-worker-1
end! Thread_name:main

可以看出:

  • World! 是從子執行緒列印. GlobalScope.launch 預設是子執行緒執行.
  • launch 並沒有阻塞主執行緒.

因為 launch 不阻塞執行緒, 它不會阻止JVM退出. 我們用了 runBlocking {} 阻止JVM退出.
而協程有個機制, 父協程會等待所有子協程執行完畢,才會退出.
在協程中建立的協程, 都運算元協程. 除了 GlobalScope.launch, 它是頂級協程.

所以我們從 runBlocking 中建立子協程, 等待其執行完畢.

fun main() = runBlocking {
    launch { // 啟動一個新協程, 這是 this.launch
        letUsPrintln("World!")
        delay(1000L)
        letUsPrintln("end!")
    }
    letUsPrintln("Hello,")
}

列印結果:

Hello, Thread_name:main
World! Thread_name:main
end! Thread_name:main

我們發現, "World!" 也是在主執行緒列印, 但是先列印了 Hello, 這是因為此處 launch程式碼塊 在主執行緒執行, 它需要等待主執行緒空閒. 博主猜測,協程是將要執行的程式碼以類似訊息的方式, 傳送到 執行緒的任務佇列中. 類似於 Handler 訊息機制.

3.Job

launch 會返回一個 Job物件

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {

它的方法有:

函式 用法
join() 掛起當前協程, 等待 job 協程執行結束
cancel() 取消協程
cancelAndJoin() 取消協程並等待結束. 協程被取消, 但不一定立即結束, 或許還有收尾工作

它的引數有:

引數 意義
isActive 知否正在執行
isCompleted 是否執行完成
isCancelled 是否已取消

它的生命週期, 及引數狀態對照如下:

 * | **State**                        | [isActive] | [isCompleted] | [isCancelled] |
 * | -------------------------------- | ---------- | ------------- | ------------- |
 * | _New_ (optional initial state)   | `false`    | `false`       | `false`       |
 * | _Active_ (default initial state) | `true`     | `false`       | `false`       |
 * | _Completing_ (transient state)   | `true`     | `false`       | `false`       |
 * | _Cancelling_ (transient state)   | `false`    | `false`       | `true`        |
 * | _Cancelled_ (final state)        | `false`    | `true`        | `true`        |
 * | _Completed_ (final state)        | `false`    | `true`        | `false`       |

4.coroutineScope

它會建立一個協程作用域並且在所有已啟動子協程執行完畢之前不會結束;
下面是一個官方示例

runBlocking{
    launch {
        delay(200L)
        letUsPrintln("Task from runBlocking")   // 2. 200 delay  launch 不阻塞
    }

    coroutineScope {	// 建立一個協程作用域
        launch {
            delay(500L)
            letUsPrintln("Task from nested launch")
        }
        delay(100L)
        letUsPrintln("Task from coroutine scope") // 1. 100 delay  launch 不阻塞
    }

    letUsPrintln("Coroutine scope is over") // 4. 500 delay  coroutineScope
}

執行結果:

Task from coroutine scope Thread_name:main
Task from runBlocking Thread_name:main
Task from nested launch Thread_name:main
Coroutine scope is over Thread_name:main

如果把 coroutineScope 換成 launch. 在100ms之前,上方協程都會因 delay() 進入掛起狀態. 所以末尾 over 會最早執行. 但 coroutineScope 會等待作用域及其所有子協程執行結束. 相當於job.join()了, 並且當它內部異常時, 作用域內其他子協程將被取消

5.協程取消

我們已知 job.cancel(), job.cancelAndJoin() 可以取消協程執行. 這樣協程會立刻結束嗎?
還記得 Thread 類的 stop(), interrupt() 函式嗎; 協程終止是否也需要程式碼配合呢?

下面是一個官方例子:

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) { // this: CoroutineScope
    var nextPrintTime = startTime
    var i = 0
    while (i < 5) { // 一個執行計算的迴圈,只是為了佔用 CPU
        // 每秒列印訊息兩次
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // 等待一段時間
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消一個作業並且等待它結束
println("main: Now I can quit.")

列印結果如下:

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...
main: Now I can quit.

可以看到, cancel() 執行後, 帶有 while 迴圈的協程仍在執行.

那我們應當如何停止協程呢? 有兩種方式:

  • 1.定期呼叫掛起函式來檢查取消。 例如: delay(), yield(), job.join() 等
  • 2.顯式的檢查取消狀態。

我們先看第二種: 使用 isActive
只需將前一個例子中的 while (i < 5) 替換為 while (isActive) 並重新執行。列印結果如下:

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

isActive: 它是 CoroutineScope 的擴充套件屬性, 等同於 coroutineContext[Job]?.isActive

public val CoroutineScope.isActive: Boolean
    get() = coroutineContext[Job]?.isActive ?: true

再看第一種: 迴圈中使用 delay();

runBlocking {
    val job = launch {
        repeat(1000) { i ->
            delay(500L)
            letUsPrintln("job: I'm sleeping $i ...")
        }
    }
    delay(1300L) // 延遲一段時間
    letUsPrintln("main: I'm tired of waiting!")
    job.cancelAndJoin() // 取消並等待結束
    letUsPrintln("main: Now I can quit.")
}

列印結果如下:

job: I'm sleeping 0 ... name:main
job: I'm sleeping 1 ... name:main
main: I'm tired of waiting! name:main
main: Now I can quit. name:main

所有 kotlinx.coroutines 中的掛起函式都是可被取消的 。它們檢查協程的取消,並在取消時丟擲 CancellationException。

所以: 自己寫的 suspend 函式是不行的..;

有的時候, 協程結束時, 我們需要釋放資源:

通常我們用 try {…} finally {…} 來捕獲異常;

val job = launch {
    try {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    } finally {
        println("job: I'm running finally")
    }
}
delay(1300L) // 延遲一段時間
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消該作業並且等待它結束
println("main: Now I can quit.")

結果如下:

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
main: Now I can quit.

6.協程超時

在實踐中絕大多數取消一個協程的理由是它有可能超時。

withTimeout(1300L){...}

withTimeout 是一個掛起函式, 需要在協程中執行. 超時會丟擲 TimeoutCancellationException 異常, 它是 CancellationException 的子類。 CancellationException 被認為是協程執行結束的正常原因。因此沒有列印堆疊跟蹤資訊.

val result = withTimeoutOrNull(1300L)

withTimeoutOrNull 當超時時會返回 null, 來進行超時操作,從而替代丟擲一個異常;

7.async 並行任務

async: 啟動一個協程. 它的返回值是Deferred物件, 繼承自 Job; 比Job多了函式 await(), 等待執行結果. 結果及異常資訊會包裝在 Deferred 物件中

先來兩個掛起函式:

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // 假設我們在這裡做了一些有用的事
    return 13
}
suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // 假設我們在這裡也做了一些有用的事
    return 29
}

如果這兩個任務先後執行, 將會消耗至少2秒的時間. 此時 async 派上了用場.

runBlocking {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    println("The answer is ${one.await() + two.await()}")
}

此時, 總的執行時間約為 1秒.

結構化併發:

suspend fun concurrentSum(): Int = coroutineScope {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    one.await() + two.await()
}

這種情況下,如果在 concurrentSum 函式內部發生了錯誤,並且它丟擲了一個異常, 所有在作用域中啟動的協程都會被取消。

8.排程器

所有的協程構建器諸如 launch 和 async 接收一個可選的 CoroutineContext 引數,它可以被用來顯式的為一個新協程或其它上下文元素指定一個排程器。

幾種排程器如下:

排程器 意義
不指定 它從啟動了它的 CoroutineScope 中承襲了上下文
Dispatchers.Main 用於Android. 在UI執行緒中執行
Dispatchers.IO 子執行緒, 適合執行磁碟或網路 I/O操作
Dispatchers.Default 子執行緒,適合 執行 cpu 密集型的工作
Dispatchers.Unconfined 從當前執行緒直接執行, 直到第一個掛起點

還記得這段程式碼嗎:

runBlocking {
    launch { // 啟動一個新協程, 這是 this.launch
        letUsPrintln("World!")
        delay(1000L)
        letUsPrintln("end!")
    }
    letUsPrintln("Hello,")
}

Hello 的列印 早於 World; 原因是 launch 程式碼塊需要等待執行緒空閒下來才能執行. 假設這裡是個耗時任務, 那 launch 也必須得等. 我們改用排程器 Dispatchers.Unconfined

//其他程式碼一致
launch(Dispatchers.Unconfined) {...}

列印結果如下:

World! Thread_name:main
Hello, Thread_name:main
end! Thread_name:kotlinx.coroutines.DefaultExecutor

Dispatchers.Unconfined: 可以理解為, 我在開啟協程前, 把前面一段程式碼先執行掉. 前面一段就是指的從開始到第一個協程掛起點. 博主想, 那我乾脆寫協程外面不行嗎? 好像是可以. 所以 Unconfined 的使用場景是?

9.withContext

不建立新的協程,在當前協程上執行程式碼塊並返回結果. 一般用來切換執行執行緒.

runBlocking {
    letUsPrintln("start!-主執行緒")
    withContext(Dispatchers.IO) { // 啟動一個新協程, 這是 this.launch
        delay(1000L)
        letUsPrintln("111-子執行緒!")
    }
    letUsPrintln("end!-主執行緒")
}

執行結果如下:

start!-主執行緒 Thread_name:main
111-子執行緒! Thread_name:DefaultDispatcher-worker-1
end!-主執行緒 Thread_name:main

它只改變程式碼塊的執行執行緒, 完事還會切換回來.


總結

先彙總下注意點:

  • GlobalScope 生命週期受整個程式限制, 程式退出才會自動結束. 它不會使程式保活, 像一個守護執行緒
  • 一個執行緒可以有多個等待執行的協程, 它們不像多執行緒爭搶cpu那樣, 它們是排隊執行.
  • 當然也只有主執行緒會出現這種情況. 子執行緒不夠用,則可能擴充執行緒池了

博主想象的協程樣子:

  1. 手握執行緒池: 它就像一個工頭, 手底下一堆人, 誰閒著了就安排上活, 人不夠我們還可以招.
  2. 拆分程式碼塊. 怎麼拆? 按掛起點拆, 前後的程式碼, 必定由單個執行緒一口氣完成. 所以我們就按掛起點拆.
  3. 掛起與恢復. 程式碼塊已經拆好了, 第一塊已經排到了執行緒任務佇列了, 那我就等著唄(掛起), 等它執行完了, 再把下一塊安排上, 如果有delay, 那我就定個鬧鈴眯一覺, 完事再安排(恢復)
  4. 規劃與排程. 給執行緒池搖人兒啊, 搖到人就安排上活.

上一篇: Kotlin Coroutine(協程): 一、樣例
下一篇: Kotlin Coroutine(協程): 三、瞭解協程

相關文章