Kotlin Coroutine 原理解析

上一篇文章《Kotlin Coroutine 初探》向大家介紹了 Kotlin Coroutine 的由來、重要概念、用法。為了能讓消除大家對 Kotlin Coroutine 的疑惑,幫助大家更好地理解和使用 Kotlin Coroutine,本篇文章將向大家介紹在 Java 平臺上 Kotlin Coroutine 的實現原理。

下面內容中的示例來源于視頻《KotlinConf 2017 - Deep Dives into Coroutines on JVM》,但有所修改。

一、示例

我們先從一段代碼示例開始,假設我們有如下一段代碼:

fun postItem(item: Item): PostResult {
  val token = requestToken()
  val post = createPost(token, item)
  val postResult = processPost(post)
  return postResult
}

這段代碼的含義我們不必深究,只需關注代碼的形式。這段代碼的形式是我們最為常見的,一個方法,調用若干子方法,最后返回結果。這種風格被稱為 Direct Style,或 Imperative Style(命令式)。這種風格優點在于直觀地反映了業務邏輯,但在執行效率方面存在問題。如果代碼中包含 IO 密集型操作,因為 Direct Style 代碼往往是線程同步執行,因此執行這段代碼的線程就會被阻塞,導致效率不高。當這樣的代碼面對 IO 操作耗時較長,并發量較高的場景時,就會產生問題,進而影響整個系統的表現。

如果想讓代碼更加適合高并發、IO 密集的場景,就需要使用 Callback 風格的代碼:

fun postItem(item: Item) {
  requestToken { token ->
    createPost(token, item) { post ->
      processPost(post) { postResult ->
        handleResult(postResult)
      }
    }
  }
}

但 Callback 風格代碼的問題在于難看難寫難調試。雖然提高了執行效率,但是大大降低了開發效率。這在面對復雜的業務場景是很嚴重的問題。理想的情況是能夠用 Direct Style,編寫出同 Callback 風格一樣高效的代碼。

而 Kotlin Coroutine 的出現為在 Java 平臺上解決上述問提供了一個理想的方案,只需很小的改造,就能得到上面講的理想結果。

▼ 示例1:suspending 方法版本的 postItem(假設 requestToken、createPost 等方法也都是 suspending 方法)

suspend fun postItem(item: Item): PostResult {
  val token = requestToken()
  val post = createPost(token, item)
  val postResult = processPost(post)
  return postResult
}

從上面的示例可以看出,使用 Kotlin Coroutine,只需增加 suspend 關鍵字,就能達到同 Callback 風格相同的效率。

關于 Kotlin Coroutine 的使用,上一篇文章《Kotlin Coroutine 初探》已經有比較詳細的介紹,不再贅述。今天就來談談 Kotlin Coroutine 是如何實現的,原理是什么。

二、原理

suspending 方法是使用 Kotlin Coroutine 的主要形式。suspending 方法的實現依賴于各種提供 Callback 機制的技術,如 JDK8 的 CompletableFuture、Google Guava 的 ListenableFuture、Spring Reactor、Netflix RxJava 等。這也是為什么只有這些技術才能和 Kotlin Coroutine 集成。

接下來解釋 Kotlin Coroutine 是如何基于這些技術實現沒有線程阻塞的執行暫停機制。這需要從 Kotlin Coroutine 的多個概念和原理說起:

  1. suspending 方法與 Continuation
  2. CPS 轉換與 Switch 狀態機
  3. suspendCoroutine 方法
  4. CoroutineBuilder 方法

(一)Suspending 方法變形記

suspending 方法的定義非常簡單,只需在普通方法前面加上 suspend 關鍵字即可。但是 Java 平臺并沒有 suspend 關鍵字,顯然也沒有 suspending 機制,那 suspending 方法是如何運行的呢?

原來 Kotlin 編譯器會對 suspending 方法做特殊處理,對代碼進行轉換,從而實現 suspending 機制。

那 Kotlin 編譯器做了哪些處理?簡單說,主要做了下面這三項處理:

  • 處理一:增加 Continuation 類型入參,返回值變為 Object
  • 處理二:生成 Continuation 類型的匿名內部類
  • 處理三:對 suspending 方法的調用變為 switch 形式的狀態機

接下來詳細介紹一下這三項處理

先來看一下示例1中 suspending 方法編譯之后的樣子,讓大家有一個總體的印象(為方便演示,不使用字節碼)

▼ 示例2:suspending 版本 postItem 方法編譯后的樣子

fun postItem(item: Item, cont: Continuation): Any? {
  val sm = cont as? ThisSM ?: object : ThisSM {
    fun resume(…) {
      postItem(null, this)
    }
  }
 
  switch (sm.label) {
    case 0:
      sm.item = item
      sm.label = 1
      return requestToken(sm)
    case 1:
      val item = sm.item
      val token = sm.result as Token
      sm.label = 2 
      return createPost(token, item, sm)
    case 2:
      val post = sm.result as Post
      sm.label = 3
      return processPost(post, sm)
    case 3:
      return sm.result as PostResult
}

1. Continuation:方法參數和匿名內部類

從上面的代碼可以看出第一、二項提到的變化。

▼ suspending 方法編譯之后增加 Continuation 類型參數

fun postItem(item: Item, cont: Continuation): Any?

▼ suspending 方法編譯之后增加 Continuation 類型的匿名內部類

val sm = cont as? ThisSM ?: object : ThisSM {
  fun resume(…) {
    postItem(null, this)
  }
}

這兩項都提到一個概念 —— Continuation,所以接下來介紹一下。

Continuation 這個名字來源于 CPS(Continuation-Passing-Style)。CPS 指的是一種編程風格。CPS 這個名字看上去很酷炫,但說白了就是 Callback 風格。Continuation 直譯是連續體,意思就是后續的部分。對于 requestToken 方法來說,Continuation 就是 createPostprocessPost 方法。常見的 CPS 中,Continuation 部分會被放在回調接口中實現。

在 Kotlin Coroutine 中,Continuation 還有一個更加具體的含義 —— Continuation 接口。先來看看它的接口定義:

public interface Continuation<in T> {
  public val context: CoroutineContext
  public fun resume(value: T)
  public fun resumeWithException(exception: Throwable)
}

從上面的代碼可以看出,Continuation 定義了一個回調接口。resume 方法用來恢復暫停的 Coroutine 的執行。

如何恢復暫停的 Coroutine 的執行?從上面的示例代碼可以看到,postItem 方法對應的 Continuation 類型的匿名內部類的 resume 方法會去回調 postItem 方法自己(但入參發生了變化,后面會解釋)。并且,在其調用的 suspending 方法的調用中會傳遞這個 Continuation,后續方法可以通過 Continuation 重新回調 postItem 方法。

小結:每個 suspending 方法編譯后會增加一個 Continuation 類型的參數。每個 suspending 方法都有一個回調自己的 Continuation 實現類,并且這個類會被傳遞給這個 suspending 方法所調用的其它 suspending 方法,這些子方法可以通過 Continuation 回調父方法以恢復暫停的程序。

到這里會產生幾個問題:

  1. 暫停是什么?它是如何發生的?
  2. Continuation 回調接口是如何以及何時被調用的?

對于這些問題,后續的章節會給出答案。

2. Switch 狀態機

從示例2中的代碼可以看到,suspending 方法編譯之后,會將原來的方法體變為一個由 switch 語句構成的狀態機:

switch (sm.label) {
  case 0:
    sm.item = item
    sm.label = 1
    return requestToken(sm)
  case 1:
    val item = sm.item
    val token = sm.result as Token
    sm.label = 2 
    return createPost(token, item, sm)
  case 2:
    val post = sm.result as Post
    sm.label = 3
    return processPost(post, sm)
  case 3:
    return sm.result as PostResult

這么做的原因是什么呢?前面說到了,Kotlin Coroutine 的運行依賴于各種 Callback 機制。也就是說,一個 suspending 方法調用到最后,其實就是注冊一個回調。方法的執行結果就是通過這個回調來處理。當回調注冊完畢之后,當前的線程就沒有必要再等待下去了。接下來就是方法返回,結束調用。所以,大家能看到這個 switch 語句中,每個 case 都會返回。

所以,對于上一節中的問題“暫停是什么?它是如何發生的?”答案就是方法返回了。

是不是很簡單呢。但方法返回只是線程執行層面結束了,整個 suspending 方法的功能還沒有完成,后續的方法還是需要調用,執行結果還是需要返回。這些工作都是如何實現呢?

在上面的示例代碼中,每個 case 都有調用 sm.label = N (除了最后一個 case)。這里的 N 表示的是當前 case 的下一個 case(下一步)所對應的 case 的值。這個值被記錄在 sm 實例中,然后 sm 會做為 Continuation 類型的參數傳遞個當前 case 中的子 suspending 方法。

子 suspending 方法(本例中為 requestToken、createPost 等方法)會將 sm 設置進回調接口。當回調發生,并且子 suspending 方法完成執行時,sm 會回調它所對應的 suspending 方法(本例中為 postItem),并根據 label 中的值執行對應 case 中的語句。從而實現程序執行的恢復。

上面這幾段內容解釋了 suspending 方法是如何暫停的,以及又是如何恢復的問題。

接下來逐行解釋示例2的代碼,以幫助大家更全面理解:

case 0

首先,在 case 0 中,通過語句 sm.item = item,將入參 item 保存在狀態機實例 sm (類型為 ThisSM,實現 Continuation 接口)中,以使后續調用能夠通過 Continuaton 獲得入參。

然后通過 sm.label = 1 設置下一步的狀態。從后續的代碼中也可以看到,在每個 case 中,都會將 sm.label 設置為下一個 case 的值,這樣,在通過 Continuation (就是 sm)回調時,就知道下一步要調用哪個方法了。

接下來就是調用 requestToken 方法,可以看到,在編譯之后,requestToken 多了一個 Continuation 類型的入參。

case 1

requestToken 設置的回調被觸發時(對應著 Direct Style 中方法返回),通過 sm 回調 postItem 方法。此時,label=1,因此執行 case 1。

通過調用 val item = sm.item,從 sm 中獲取參數 item。

通過調用 val token = sm.result as Token 獲取 requestToken 方法的返回值 token。

通過調用 sm.label = 2 將 label 設置為下一步的 case。

調用 createPost(token, item, sm)

case 2

同 case 1 的內容類似,略。

case 3

return sm.result as PostResultContinuation 中獲得返回值。

3. Continuation 的父子調用

上一節解釋了 suspending 方法是如何暫停的,以及又是如何恢復的問題。但有一個細節沒有解釋:一個 suspending 方法對應的 Continuation 是如何知道它是應該回調當前的 suspending 方法,還是上一級的 suspending 方法呢?

要解釋這個問題,需要講解一個上面示例隱藏掉的細節。在一個 suspending 方法創建它所對應的 Continuation 時,會將從入參傳入的 Continuation 作為父 Continuation 引入新創建的 Continuation。 因為每個 suspending 方法所創建的 Continuation 是基于 CoroutineImpl 的,所以看一下 CoroutineImpl 的源代碼:

abstract class CoroutineImpl(
    arity: Int,
    @JvmField
    protected var completion: Continuation<Any?>?
) : Lambda(arity), Continuation<Any?> {
  override fun resume(value: Any?) {
    processBareContinuationResume(completion!!) {
      doResume(value, null)
    }
  }
}

fun processBareContinuationResume(completion: Continuation<*>, block: () -> Any?) {
  try {
    val result = block()
    if (result !== COROUTINE_SUSPENDED) {
      @Suppress("UNCHECKED_CAST")
      (completion as Continuation<Any?>).resume(result)
    }
  } catch (t: Throwable) {
    completion.resumeWithException(t)
  }
}

CoroutineImpl 構造函數有一個 Continuation 類型的入參 completion,這個 completion 代表的是父 Continuation。調用 resume 方法是會先調用 processBareContinuationResumeprocessBareContinuationResume 的第一個入參是父 Continuation,第二個入參 block 就是 doResume 方法,也就是對當前 suspending 方法的調用。如果當前 suspending 方法的返回結果不是 COROUTINE_SUSPENDED,即執行成功時,就會通過調用 completion.resume(result) 的方式回調父 Continuation,并返回執行結果。

看一下流程圖:

Kotlin Coroutine Suspending 方法父子調用

4. 小結

Kotlin Coroutine suspending 方法在編譯之后會發生顯著變化:

首先,suspending 方法增加一個 Continuation 類型的入參,用于實現回調。返回值變為 Object 類型,既可以表示真實的結果,也可表示 Coroutine 的執行狀態。

然后,編譯器會為這個 suspending 方法生產一個類型為 Continuation 的匿名內部類(擴展 CoroutineImpl),用于對這個 suspending 方法自身的回調,并可以在這個 suspending 方法執行完畢之后,回調這個 suspending 方法上一級的父方法。

最后,這個 suspending 方法如果調用其它 suspending 方法,會將這些調用轉換為一個 switch 形式的狀態機,每個 case 表示對一個 suspending 子方法的調用或最后的 return。同時,生成的 Continuation 匿名內部類會保存下一步需要調用的 suspending 方法的 label 值,表示應該執行 switch 中的哪個 case,從而串聯起整個調用過程。

(二)suspendCoroutine 方法

前面的內容解釋了 suspending 方法是如何實現沒有線程阻塞的執行暫停,這是介紹了 Kotlin Coroutine 主干部分 —— suspending 方法的實現原理。但方法調用有頭有尾,suspending 方法調用結束在哪里呢?

因為前面說到了,Kotlin Coroutine 還是基于 Callback 機制。所以,suspending 方法調用到最后,就應當是將 Kotlin Coroutine 自己的回調接口 Continuation 注冊到某種 Future 技術的回調接口中。

但在普通的 Suspending 方法中壓根訪問不到 Continuation,那該如何做呢?

方法就是通過一個特殊的 suspending 方法 —— suspendCoroutine 實現。suspendCoroutine 方法是 Kotlin 標準庫的一部分,它可以在 kotlin-stdlib 模塊中的 CoroutinesLibrary.kt 中被找到。

suspendCoroutine 方法的簽名如下:

suspend fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T

suspendCoroutine 的入參是一個名稱為 block 的 Lambda。這個 Lambda 可以有一個類型為 Continuation 的入參。能夠拿到 Continuation,就可以將其注冊到某種 Future 機制中了。

看一個 Kotlin Coroutine 官方文檔中的示例,演示了如何使用 suspendCoroutine 使 CompletableFuture 同 Kotlin Coroutine 集成:

suspend fun <T> CompletableFuture<T>.await(): T =
    suspendCoroutine<T> { cont: Continuation<T> ->
      whenComplete { result, exception ->
        if (exception == null) // the future has been completed normally
          cont.resume(result)
        else // the future has completed with an exception
          cont.resumeWithException(exception)
      }
    }

注意:上面的這段代碼只是一個演示 suspendCoroutine 以及如何與 Future 技術集成的的示例。雖然原理相同,但真實的代碼會更為復雜。

從上面的代碼可以看出,正是因為 suspendCoroutine 的入參 block Lambda 擁有一個 Continuation 類型的入參,使得可以使用 suspendCoroutine 方法與各種 Future 機制集成。

進一步觀察 suspendCoroutine 的實現原理,suspendCoroutine 調用了 suspendCoroutineOrReturn 方法,但直接觀察源碼無法了解 suspendCoroutineOrReture 的實現:

inline suspend fun <T> suspendCoroutineOrReturn(crossinline block: (Continuation<T>) -> Any?): T =
    throw NotImplementedError("Implementation is intrinsic")

suspendCoroutineOrReturn 只起到一個標記的作用,實現細節隱藏在了編譯階段。但它的實現方式又和普通的 suspending 方法不同,所以要定義一個特殊方法,以區別對待。

(三)Coroutine Builder 方法

suspendCoroutine 方法可以看做是 Kotlin Coroutine 調用的終點,接下來要討論的是 Kotlin Coroutine 調用的起點。因為 suspending 方法不能直接被普通方法調用。如果普通方法要調用 suspending 方法,就必須通過 Coroutine Builder。

Kotlin Coroutine 核心和擴展模塊提供了多種 Coroutine Builder。這些 Coroutine Builder 有著不同的作用。例如,runBlocking 能夠掛起當前線程、mono 可以將 Coroutine 轉換為 Spring Reactor Project 中的 Mono 類型。這些不同 Coroutine Builder 的作用不在本文的范圍(后續文章將會介紹),而是介紹這些 Coroutine Builder 公共的部分 —— suspending Lambda。

mono 為例:

fun <T> mono(
    context: CoroutineContext = DefaultDispatcher,
    parent: Job? = null,
    block: suspend CoroutineScope.() -> T?
)

最后一個入參 block 是一個 suspending Lambda。同 suspending 方法一樣,suspending Lambda 在編譯之后,其主體部分也會被轉換為 switch 形式的狀態機。不同于對 suspending 方法的處理,編譯器并沒有為 suspending Lambda 生產類型為 Continuation 的匿名內部類,而是 Lambda 自己作為 Continuation 實現(每個 Lambda 在編譯之后會生成一個匿名內部類)。

除了對 suspending Lambda 的處理以外,Coroutine Builder 另外一個比較通用的處理是通過調用 createCoroutineUnchecked 方法創建一個新的 Coroutine。

三、總結

到這里 Kotlin Coroutine 的主要的實現原理已經介紹完畢。但還有很多其它的細節,大家可以 Kotlin Coroutine 官方文檔(地址:https://github.com/Kotlin/kotlin-coroutines/blob/master/kotlin-coroutines-informal.md#implementation-details)和視頻《KotlinConf 2017 - Deep Dives into Coroutines on JVM》(地址:https://www.youtube.com/watch?v=YrrUCSi72E8)了解。

從業內的發展趨勢看,反應式編程是 Java 社區應對高并發場景的主要選擇,但直接使用反應式編程技術(Spring Reactor、RxJava)還是有很多不方便的地方(在上一篇文章《Kotlin Coroutine 初探》中已經介紹過)。所以 Kotlin Coroutine 的出現及時有效地解決了這些問題。

因此,可以預見,Kotlin Coroutine 將會越來越多地出現在 Java 服務器端和 Android 等領域的應用中。所以,理解 Kotlin Coroutine 實現原理很有意義。

另外,Coroutine 并不是 Kotlin 的發明,很多其它語言都有 Coroutine 這個概念,比如 LISP、Python、Javascript 等。Kotlin 的實現原理也借鑒了很多其它的語言。所以,理解 Kotlin Coroutine 的原理,也能夠幫助理解其它語言的 Coroutine 技術的底層原理。

本篇介紹 Kotlin Coroutine 實現原理的文章就到這里。后續 Kotlin Coroutine 相關的文章將會介紹 Kotlin Coroutine 與 Spring Reactor 項目的整合、Kotlin Coroutine 與 Quasar、Alibaba JDK 等技術方案的對比,等等。盡請關注。

我的技術公眾號“編走編想”
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容