kotlin協程的掛起suspend

得兒得兒以得兒以得兒得兒發表於2020-12-26

在協程上一篇中我們知道了下面知識點:

  • 協程究竟是什麼
  • 協程到底好在哪裡
  • 協程具體怎麼用

大部分情況下,我們都是用 launch 函式來建立協程,其實還有其他兩個函式也可以用來建立協程:

  • runBlocking
  • async

runBlocking 通常適用於單元測試的場景,而業務開發中不會用到這個函式,因為它是執行緒阻塞的。

接下來我們主要來對比 launch 與 async 這兩個函式。

  • 相同點:它們都可以用來啟動一個協程,返回的都是 Coroutine,我們這裡不需要糾結具體是返回哪個類。

  • 不同點:async 返回的 Coroutine 多實現了 Deferred 介面。

關於 Deferred 更深入的知識就不在這裡過多闡述,它的意思就是延遲,也就是結果稍後才能拿到。

我們呼叫 Deferred.await() 就可以得到結果了。

接下來我們繼續看看 async 是如何使用的,先回憶一下上期中獲取頭像的場景:

?️
coroutineScope.launch(Dispatchers.Main) {
    //                      ?  async 函式啟動新的協程
    val avatar: Deferred = async { api.getAvatar(user) }    // 獲取使用者頭像
    val logo: Deferred = async { api.getCompanyLogo(user) } // 獲取使用者所在公司的 logo
    //            ?          ? 獲取返回值
    show(avatar.await(), logo.await())                     // 更新 UI
}

Kotlin

可以看到 avatar 和 logo 的型別可以宣告為 Deferred ,通過 await 獲取結果並且更新到 UI 上顯示。

await 函式簽名如下:

?️
public suspend fun await(): T

Kotlin

前面有個關鍵字是之前沒有見過的 —— suspend,這個關鍵字就對應了上期最後我們留下的一個問號:協程最核心的那個「非阻塞式」的「掛起」到底是怎麼回事?

所以接下來,我們的核心內容就是來好好說一說這個「掛起」。

「掛起」的本質

協程中「掛起」的物件到底是什麼?掛起執行緒,還是掛起函式?都不對,我們掛起的物件是協程。

還記得協程是什麼嗎?啟動一個協程可以使用 launch 或者 async 函式,協程其實就是這兩個函式中閉包的程式碼塊。

launch ,async 或者其他函式建立的協程,在執行到某一個 suspend 函式的時候,這個協程會被「suspend」,也就是被掛起。

那此時又是從哪裡掛起?從當前執行緒掛起。換句話說,就是這個協程從正在執行它的執行緒上脫離。

注意,不是這個協程停下來了!是脫離,當前執行緒不再管這個協程要去做什麼了。

suspend 是有暫停的意思,但我們在協程中應該理解為:當執行緒執行到協程的 suspend 函式的時候,暫時不繼續執行協程程式碼了。

我們先讓時間靜止,然後兵分兩路,分別看看這兩個互相脫離的執行緒和協程接下來將會發生什麼事情:

執行緒:

前面我們提到,掛起會讓協程從正在執行它的執行緒上脫離,具體到程式碼其實是:

協程的程式碼塊中,執行緒執行到了 suspend 函式這裡的時候,就暫時不再執行剩餘的協程程式碼,跳出協程的程式碼塊。

那執行緒接下來會做什麼呢?

如果它是一個後臺執行緒:

  • 要麼無事可做,被系統回收
  • 要麼繼續執行別的後臺任務

跟 Java 執行緒池裡的執行緒在工作結束之後是完全一樣的:回收或者再利用。

如果這個執行緒它是 Android 的主執行緒,那它接下來就會繼續回去工作:也就是一秒鐘 60 次的介面重新整理任務。

一個常見的場景是,獲取一個圖片,然後顯示出來:

?️
// 主執行緒中
GlobalScope.launch(Dispatchers.Main) {
  val image = suspendingGetImage(imageId)  // 獲取圖片
  avatarIv.setImageBitmap(image)           // 顯示出來
}

suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
  ...
}

Kotlin

這段執行在主執行緒的協程,它實質上會往你的主執行緒 post 一個 Runnable,這個 Runnable 就是你的協程程式碼:

?️
handler.post {
  val image = suspendingGetImage(imageId)
  avatarIv.setImageBitmap(image)
}

Kotlin

當這個協程被掛起的時候,就是主執行緒 post 的這個 Runnable 提前結束,然後繼續執行它介面重新整理的任務。

關於執行緒,我們就看完了。
這個時候你可能會有一個疑問,那 launch 包裹的剩下程式碼怎麼辦?

所以接下來,我們來看看協程這一邊。

協程:

執行緒的程式碼在到達 suspend 函式的時候被掐斷,接下來協程會從這個 suspend 函式開始繼續往下執行,不過是在指定的執行緒

誰指定的?是 suspend 函式指定的,比如我們這個例子中,函式內部的 withContext 傳入的 Dispatchers.IO 所指定的 IO 執行緒。

Dispatchers 排程器,它可以將協程限制在一個特定的執行緒執行,或者將它分派到一個執行緒池,或者讓它不受限制地執行,關於 Dispatchers 這裡先不展開了。

那我們平日裡常用到的排程器有哪些?

常用的 Dispatchers ,有以下三種:

  • Dispatchers.Main:Android 中的主執行緒
  • Dispatchers.IO:針對磁碟和網路 IO 進行了優化,適合 IO 密集型的任務,比如:讀寫檔案,運算元據庫以及網路請求
  • Dispatchers.Default:適合 CPU 密集型的任務,比如計算

回到我們的協程,它從 suspend 函式開始脫離啟動它的執行緒,繼續執行在 Dispatchers 所指定的 IO 執行緒。

緊接著在 suspend 函式執行完成之後,協程為我們做的最爽的事就來了:會自動幫我們把執行緒再切回來

這個「切回來」是什麼意思?

我們的協程原本是執行在主執行緒的,當程式碼遇到 suspend 函式的時候,發生執行緒切換,根據 Dispatchers 切換到了 IO 執行緒;

當這個函式執行完畢後,執行緒又切了回來,「切回來」也就是協程會幫我再 post 一個 Runnable,讓我剩下的程式碼繼續回到主執行緒去執行。

我們從執行緒和協程的兩個角度都分析完成後,終於可以對協程的「掛起」suspend 做一個解釋:

協程在執行到有 suspend 標記的函式的時候,會被 suspend 也就是被掛起,而所謂的被掛起,就是切個執行緒;

不過區別在於,掛起函式在執行完成之後,協程會重新切回它原先的執行緒

再簡單來講,在 Kotlin 中所謂的掛起,就是一個稍後會被自動切回來的執行緒排程操作

這個「切回來」的動作,在 Kotlin 裡叫做 resume,恢復。

通過剛才的分析我們知道:掛起之後是需要恢復。

而恢復這個功能是協程的,如果你不在協程裡面呼叫,恢復這個功能沒法實現,所以也就回答了這個問題:為什麼掛起函式必須在協程或者另一個掛起函式裡被呼叫。

再細想下這個邏輯:一個掛起函式要麼在協程裡被呼叫,要麼在另一個掛起函式裡被呼叫,那麼它其實直接或者間接地,總是會在一個協程裡被呼叫的。

所以,要求 suspend 函式只能在協程裡或者另一個 suspend 函式裡被呼叫,還是為了要讓協程能夠在 suspend 函式切換執行緒之後再切回來。

怎麼就「掛起」了?

我們瞭解到了什麼是「掛起」後,再接著看看這個「掛起」是怎麼做到的。

先隨便寫一個自定義的 suspend 函式:

?️
suspend fun suspendingPrint() {
  println("Thread: ${Thread.currentThread().name}")
}

I/System.out: Thread: main

Kotlin

輸出的結果還是在主執行緒。

為什麼沒切換執行緒?因為它不知道往哪切,需要我們告訴它。

對比之前例子中 suspendingGetImage 函式程式碼:

?️
//                                               ?
suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
  ...
}

Kotlin

我們可以發現不同之處其實在於 withContext 函式。

其實通過 withContext 原始碼可以知道,它本身就是一個掛起函式,它接收一個 Dispatcher 引數,依賴這個 Dispatcher 引數的指示,你的協程被掛起,然後切到別的執行緒。

所以這個 suspend,其實並不是起到把任何把協程掛起,或者說切換執行緒的作用。

真正掛起協程這件事,是 Kotlin 的協程框架幫我們做的。

所以我們想要自己寫一個掛起函式,僅僅只加上 suspend 關鍵字是不行的,還需要函式內部直接或間接地呼叫到 Kotlin 協程框架自帶的 suspend 函式才行。

suspend 的意義?

這個 suspend 關鍵字,既然它並不是真正實現掛起,那它的作用是什麼?

它其實是一個提醒。

函式的建立者對函式的使用者的提醒:我是一個耗時函式,我被我的建立者用掛起的方式放在後臺執行,所以請在協程裡呼叫我。

為什麼 suspend 關鍵字並沒有實際去操作掛起,但 Kotlin 卻把它提供出來?

因為它本來就不是用來操作掛起的。

掛起的操作 —— 也就是切執行緒,依賴的是掛起函式裡面的實際程式碼,而不是這個關鍵字。

所以這個關鍵字,只是一個提醒

還記得剛才我們嘗試自定義掛起函式的方法嗎?

?️
// ? redundant suspend modifier
suspend fun suspendingPrint() {
  println("Thread: ${Thread.currentThread().name}")
}

Kotlin

如果你建立一個 suspend 函式但它內部不包含真正的掛起邏輯,編譯器會給你一個提醒:redundant suspend modifier,告訴你這個 suspend 是多餘的。

因為你這個函式實質上並沒有發生掛起,那你這個 suspend 關鍵字只有一個效果:就是限制這個函式只能在協程裡被呼叫,如果在非協程的程式碼中呼叫,就會編譯不通過。

所以,建立一個 suspend 函式,為了讓它包含真正掛起的邏輯,要在它內部直接或間接呼叫 Kotlin 自帶的 suspend 函式,你的這個 suspend 才是有意義的。

怎麼自定義 suspend 函式?

在瞭解了 suspend 關鍵字的來龍去脈之後,我們就可以進入下一個話題了:怎麼自定義 suspend 函式。

這個「怎麼自定義」其實分為兩個問題:

  • 什麼時候需要自定義 suspend 函式?
  • 具體該怎麼寫呢?

什麼時候需要自定義 suspend 函式

如果你的某個函式比較耗時,也就是要等的操作,那就把它寫成 suspend 函式。這就是原則。

耗時操作一般分為兩類:I/O 操作和 CPU 計算工作。比如檔案的讀寫、網路互動、圖片的模糊處理,都是耗時的,通通可以把它們寫進 suspend 函式裡。

另外這個「耗時」還有一種特殊情況,就是這件事本身做起來並不慢,但它需要等待,比如 5 秒鐘之後再做這個操作。這種也是 suspend 函式的應用場景。

具體該怎麼寫

給函式加上 suspend 關鍵字,然後在 withContext 把函式的內容包住就可以了。

提到用 withContext是因為它在掛起函式裡功能最簡單直接:把執行緒自動切走和切回。

當然並不是只有 withContext 這一個函式來輔助我們實現自定義的 suspend 函式,比如還有一個掛起函式叫 delay,它的作用是等待一段時間後再繼續往下執行程式碼。

使用它就可以實現剛才提到的等待型別的耗時操作:

?️
suspend fun suspendUntilDone() {
  while (!done) {
    delay(5)
  }
}

Kotlin

這些東西,在我們初步使用協程的時候不用立馬接觸,可以先把協程最基本的方法和概念理清楚。

總結

我們今天整個文章其實就在理清一個概念:什麼是掛起?掛起,就是一個稍後會被自動切回來的執行緒排程操作。

好,關於協程中的「掛起」我們就解釋到這裡。

參考:https://kaixue.io/tag/kotlin-coroutines/

相關文章