【碼上開學】Kotlin 協程的掛起好神奇好難懂?今天我把它的皮給扒了

扔物線發表於2019-10-17

本期作者:

視訊:扔物線(朱凱)

文章:Hugo(謝晨成)

大家好,我是扔物線朱凱,我回來啦。今天我們接著講協程。

在上一期裡,我介紹了 Kotlin 的協程到底是什麼——它就是個執行緒框架。沒什麼說不清的,就這麼簡單,它就是個執行緒框架,只不過這個執行緒框架比較方便——另外呢,上期也講了一下協程的基本用法,但到最後也留下了一個大問號:協程最核心的那個「非阻塞式」的「掛起」到底是怎麼回事?今天,我們的核心內容就是來說一說這個「掛起」。

老規矩,全國最硬核的 Android 視訊播主為你帶來最硬核的視訊:

不過因為我一直不知道怎麼在掘金髮視訊,所以你可以點選 這裡 去嗶哩嗶哩看視訊,可以點選 這裡 去 YouTube 看。

以下內容來自文章作者 Hugo

上期回顧

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

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

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

  • runBlocking
  • async

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

接下來我們主要來對比 launchasync 這兩個函式。

  • 相同點:它們都可以用來啟動一個協程,返回的都是 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
}
複製程式碼

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

await 函式簽名如下:

?️
public suspend fun await(): T
複製程式碼

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

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

「掛起」的本質

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

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

launchasync 或者其他函式建立的協程,在執行到某一個 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) {
  ...
}
複製程式碼

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

?️
handler.post {
  val image = suspendingGetImage(imageId)
  avatarIv.setImageBitmap(image)
}
複製程式碼

當這個協程被掛起的時候,就是主執行緒 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
複製程式碼

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

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

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

?️
//                                               ?
suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
  ...
}
複製程式碼

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

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

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

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

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

suspend 的意義?

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

它其實是一個提醒。

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

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

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

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

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

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

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

如果你建立一個 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)
  }
}
複製程式碼

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

總結

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

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

可能你心中還會存在一些疑惑:

  • 協程中掛起的「非阻塞式」到底是怎麼回事?
  • 協程和 RxJava 在切換執行緒方面功能是一樣的,都能讓你寫出避免巢狀回撥的複雜併發程式碼,那協程還有哪些優勢,或者讓開發者使用協程的理由?

這些疑惑的答案,我們都會在下一篇中全部揭曉。

練習題

使用協程下載一張圖,並行進行兩次切割

  • 一次切成大小相同的 4 份,取其中的第一份
  • 一次切成大小相同的 9 份,取其中的最後一份

得到結果後,將它們展示在兩個 ImageView 上。

作者介紹

視訊作者

扔物線(朱凱)
  • 碼上開學創始人、專案管理人、內容模組規劃者和視訊內容作者。
  • Android GDE( Google 認證 Android 開發專家),前 Flipboard Android 工程師。
  • GitHub 全球 Java 排名第 92 位,在 GitHub 上有 6.6k followers 和 9.9k stars。
  • 個人的 Android 開源庫 MaterialEditText 被全球多個專案引用,其中包括在全球擁有 5 億使用者的新聞閱讀軟體 Flipboard 。
  • 曾多次在 Google Developer Group Beijing 線下分享會中擔任 Android 部分的講師。
  • 個人技術文章《給 Android 開發者的 RxJava 詳解》釋出後,在國內多個公司和團隊內部被轉發分享和作為團隊技術會議的主要資料來源,以及逆向傳播到了美國一些如 Google 、 Uber 等公司的部分華人團隊。
  • 創辦的 Android 高階進階教學網站 HenCoder 在全球華人 Android 開發社群享有相當的影響力。
  • 之後創辦 Android 高階開發教學課程 HenCoder Plus ,學員遍佈全球,有來自阿里、頭條、華為、騰訊等知名一線網際網路公司,也有來自台灣、日本、美國等地區的資深軟體工程師。

文章作者

Hugo(謝晨成)

Hugo(謝晨成),即刻 Android 工程師。2017 年加入即刻,參與了即刻 3.0 到 6.0 版本的架構設計和產品迭代。多年 Android 開發經驗,現在負責即刻客戶端中臺基礎建設。

相關文章