Kotlin Vocabulary | 揭祕協程中的 suspend 修飾符

Kotlin 協程把 suspend 修飾符引入到了咱們 Android 開發者的平常開發中。您是否好奇它的底層工做原理呢?編譯器是如何轉換咱們的代碼,使其可以掛起和恢復協程操做的呢?

瞭解這些將會幫您更好地理解掛起函數 (suspend function) 爲何只會在全部工做完成後纔會返回,以及如何在不阻塞線程的狀況下掛起代碼。html

本文概要: Kotlin 編譯器將會爲每一個掛起函數建立一個狀態機,這個狀態機將爲咱們管理協程的操做!android

📚 若是您是 Android 平臺上協程的初學者,請查閱下面這些協程 codelab:git

協程 101

協程簡化了 Android 平臺的異步操做。正如官方文檔《利用 Kotlin 協程提高應用性能》所介紹的,咱們可使用協程管理那些以往可能阻塞主線程或者讓應用卡死的異步任務。api

協程也能夠幫咱們用命令式代碼替換那些基於回調的 API。例如,下面這段使用了回調的異步代碼:bash

// 簡化的只考慮了基礎功能的代碼
fun loginUser(userId: String, password: String, userResult: Callback<User>) {
  // 異步回調
  userRemoteDataSource.logUserIn { user ->
    // 成功的網絡請求
    userLocalDataSource.logUserIn(user) { userDb ->
      // 保存結果到數據庫
      userResult.success(userDb)
    }
  }
}
複製代碼

上面的回調能夠經過使用協程轉換爲順序調用:網絡

suspend fun loginUser(userId: String, password: String): User {
  val user = userRemoteDataSource.logUserIn(userId, password)
  val userDb = userLocalDataSource.logUserIn(user)
  return userDb
}
複製代碼

在後面這段代碼中,咱們爲函數添加了suspend 修飾符,它能夠告訴編譯器,該函數須要在協程中執行。做爲開發者,您能夠把掛起函數看做是普通函數,只不過它可能會在某些時刻掛起和恢復而已。異步

不一樣於回調,協程提供了一種簡單的方式來實現線程間的切換以及對異常的處理。可是,在咱們把一個函數寫成掛起函數時,編譯器在內部究竟作了什麼事呢?jvm

Suspend 的工做原理

回到 loginUser 掛起函數,注意它調用的另外一個函數也是掛起函數:

suspend fun loginUser(userId: String, password: String): User {
  val user = userRemoteDataSource.logUserIn(userId, password)
  val userDb = userLocalDataSource.logUserIn(user)
  return userDb
}

// UserRemoteDataSource.kt
suspend fun logUserIn(userId: String, password: String): User

// UserLocalDataSource.kt
suspend fun logUserIn(userId: String): UserDb
複製代碼

簡而言之,Kotlin 編譯器會把掛起函數使用有限狀態機 (稍後講到) 轉換爲一種優化版回調。也就是說,編譯器會幫您實現這些回調!

Continuation 接口

掛起函數經過 Continuation 對象在方法間互相通訊。Continuation 其實只是一個具備泛型參數和一些額外信息的回調接口,稍後咱們會看到,它會實例化掛起函數所生成的狀態機。

咱們先來看看它的聲明:

interface Continuation<in T> {
  public val context: CoroutineContext
  public fun resumeWith(value: Result<T>)
}
複製代碼
  • context 是 Continuation 將會使用的 CoroutineContext;
  • resumeWith 會恢復協程的執行,同時傳入一個 Result 參數,Result 中會包含致使掛起的計算結果或者是一個異常。

注意: 從 Kotlin 1.3 開始,您也可使用 resumeWith 對應的擴展函數: resume (value: T) 和 resumeWithException (exception: Throwable)。

編譯器將會在函數簽名中使用額外的 completion 參數 (Continuation 類型) 來代替 suspend 修飾符。而該參數將會被用於向調用該掛起函數的協程返回結果:

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
  val user = userRemoteDataSource.logUserIn(userId, password)
  val userDb = userLocalDataSource.logUserIn(user)
  completion.resume(userDb)
}
複製代碼

爲了簡化起見,咱們的例子將會返回一個 Unit 而不是 User。User 對象將會在被加入的 Continuation 參數中 "返回"。

其實,掛起函數在字節碼中返回的是 Any。由於它是由 T | COROUTINE_SUSPENDED 構成的組合類型。這種實現可使函數在可能的狀況下同步返回。

注意: 若是您使用 suspend 修飾符標記了一個函數,而該函數又沒有調用其它掛起函數,那麼編譯器會添加一個額外的 Continuation 參數可是不會用它作任何事,函數體的字節碼則會看起來和通常的函數同樣。

您也會在其餘地方看到 Continuation 接口:

  • 當使用 suspendCoroutinesuspendCancellableCoroutine (首選使用) 來將基於回調的 API 轉化爲協程時,會直接與一個 Continuation 對象進行交互。它會用於恢復那些執行了參數代碼塊後掛起的協程;
  • 您能夠在一個掛起函數上使用 startCoroutine 擴展函數,它會接收一個 Continuation 對象做爲參數,並會在新的協程結束時調用它,不管其運行結果是成功仍是異常。

使用不一樣的 Dispatcher

您能夠在不一樣的 Dispatcher 間切換,從而作到在不一樣的線程中執行計算。那麼 Kotlin 是如何知道從哪裏開始恢復掛起的計算的呢?

Continuation 有一個子類叫 DispatchedContinuation,它的 resume 函數會執行一次調度調用,並會調度至 CoroutineContext 包含的 Dispatcher 中。除了那些將 isDispatchNeeded 方法 (會在調度前調用) 重寫爲始終返回 false 的 Dispatcher.Unconfined,其餘全部的 Dispatcher 都會調用 dispatch 方法。

生成狀態機

特殊說明: 本文接下來所展現的,並非與編譯器生成的字節碼徹底相同的代碼,而是足夠精確的,可以確保您理解其內部發生了什麼的 Kotlin 代碼。這些聲明由版本爲 1.3.3 的協程庫生成,可能會在其將來的版本中做出修改。

Kotlin 編譯器會肯定函數什麼時候能夠在內部掛起,每一個掛起點都會被聲明爲有限狀態機的一個狀態,每一個狀態又會被編譯器用標籤表示:

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
  // Label 0 -> 第一次執行
  val user = userRemoteDataSource.logUserIn(userId, password)
  // Label 1 -> 從 userRemoteDataSource 恢復
  val userDb = userLocalDataSource.logUserIn(user)
  // Label 2 -> 從 userLocalDataSource 恢復
  completion.resume(userDb)
}
複製代碼

爲了更好地聲明狀態機,編譯器會使用 when 語句來實現不一樣的狀態:

fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
  when(label) {
              // Label 0 -> 第一次執行
        userRemoteDataSource.logUserIn(userId, password)
    }
              // Label 1 -> 從 userRemoteDataSource 恢復
        userLocalDataSource.logUserIn(user)
    }
              // Label 2 -> 從 userLocalDataSource 恢復
        completion.resume(userDb)
    }
    else -> throw IllegalStateException(...)
  }
}
複製代碼

這時候的代碼還不完整,由於各個狀態之間沒法共享信息。編譯器會使用同一個 Continuation 對象在方法中共享信息,這也是爲何 Continuation 的泛型參數是 Any,而不是原函數的返回類型 (即 User)。

接下來,編譯器會建立一個私有類,它會:

  1. 保存必要的數據;
  2. 遞歸調用 loginUser 函數來恢復執行。

您能夠查看下面提供的編譯器生成類的近似版本。

特別說明: 註釋不是由編譯器生成的,而是由做者添加的。添加它們是爲了解釋這些代碼的做用,也能讓後面的代碼更加容易理解。

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) { 
class LoginUserStateMachine(
    // completion 參數是調用了 loginUser 的函數的回調
    completion: Continuation<Any?>
  ): CoroutineImpl(completion) {

    // suspend 的本地變量
    var user: User? = null
    var userDb: UserDb? = null

    // 全部 CoroutineImpls 都包含的通用對象
    var result: Any? = null
    var label: Int = 0

    // 這個方法再一次調用了 loginUser 來切換
    // 狀態機 (標籤會已經處於下一個狀態)
    // result 將會是前一個狀態的計算結果
    override fun invokeSuspend(result: Any?) {
      this.result = result
      loginUser(null, null, this)
    }
  }
  ...
}
複製代碼

因爲 invokeSuspend 函數將會再次調用 loginUser 函數,而且只會傳入 Continuation 對象,因此 loginUser 函數簽名中的其餘參數變成了可空類型。此時,編譯器只須要添加如何在狀態之間切換的信息。

首先須要知道的是:

  1. 函數是第一次被調用;
  2. 函數已經從前一個狀態中恢復。

作到這些須要檢查 Contunuation 對象傳遞的是不是 LoginUserStateMachine 類型:

fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
  ...

  val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)

  ...
}
複製代碼

若是是第一次調用,它將建立一個新的 LoginUserStateMachine 實例,並將 completion 實例做爲參數接收,以便它記得如何恢復調用當前函數的函數。若是不是第一次調用,它將繼續執行狀態機 (掛起函數)。

如今,咱們來看看編譯器生成的用於在狀態間切換並分享信息的代碼:

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
    ...

    val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)

    when(continuation.label) {
        0 -> {
            // 錯誤檢查
            throwOnFailure(continuation.result)
            // 下次 continuation 被調用時, 它應當直接去到狀態 1
            continuation.label = 1
            // Continuation 對象被傳入 logUserIn 函數,從而能夠在結束時恢復 
            // 當前狀態機的執行
            userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
        }
        1 -> {
            // 檢查錯誤
            throwOnFailure(continuation.result)
            // 得到前一個狀態的結果
            continuation.user = continuation.result as User
            // 下次這 continuation 被調用時, 它應當直接去到狀態 2
            continuation.label = 2
            // Continuation 對象被傳入 logUserIn 函數,從而能夠在結束時恢復 
            // 當前狀態機的執行
            userLocalDataSource.logUserIn(continuation.user, continuation)
        }

        ... // 故意遺漏了最後一個狀態
    }
}
複製代碼

花一些時間瀏覽上面的代碼,看看您是否能注意到與以前代碼之間的差別。下面咱們來看看編譯器生成了什麼:

  1. when 語句的參數是 LoginUserStateMachine 實例內的 label;
  2. 每一次處理新的狀態時,爲了防止函數被掛起時運行失敗,都會進行一次檢查;
  3. 在調用下一個掛起函數 (即 logUserIn) 前,LoginUserStateMachine 的 label 都會更新到下一個狀態;
  4. 在當前的狀態機中調用另外一個掛起函數時,continuation 的實例 (LoginUserStateMachine 類型) 會被做爲參數傳遞過去。而即將被調用的掛起函數也一樣被編譯器轉換成一個類似的狀態機,而且接收一個 continuation 對象做爲參數。當被調用的掛起函數的狀態機運行結束時,它將恢復當前狀態機的執行。

最後一個狀態與其餘幾個不一樣,由於它必須恢復調用它的方法的執行。如您將在下面代碼中所見,它將調用 LoginUserStateMachine 中存儲的 cont 變量的 resume 函數:

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
    ...

    val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)

    when(continuation.label) {
        ...
        2 -> {
            // 錯誤檢查
            throwOnFailure(continuation.result)
            // 獲取前一個狀態的結果
            continuation.userDb = continuation.result as UserDb
            // 恢復調用了當前函數的函數的執行
            continuation.cont.resume(continuation.userDb)
        }
        else -> throw IllegalStateException(...)
    }
}
複製代碼

如您所見,Kotlin 編譯器幫咱們作了不少工做!例如示例中的掛起函數:

suspend fun loginUser(userId: String, password: String): User {
  val user = userRemoteDataSource.logUserIn(userId, password)
  val userDb = userLocalDataSource.logUserIn(user)
  return userDb
}
複製代碼

編譯器爲咱們生成了下面這些代碼:

/* Copyright 2019 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {

    class LoginUserStateMachine(
        // completion 參數是調用了 loginUser 的函數的回調
        completion: Continuation<Any?>
    ): CoroutineImpl(completion) {
        // 要在整個掛起函數中存儲的對象
        var user: User? = null
        var userDb: UserDb? = null
        // 全部 CoroutineImpls 都包含的通用對象
        var result: Any? = null
        var label: Int = 0
        // 這個函數再一次調用了 loginUser 來切換
        // 狀態機 (標籤會已經處於下一個狀態) 
        // result 將會是前一個狀態的計算結果
        override fun invokeSuspend(result: Any?) {
            this.result = result
            loginUser(null, null, this)
        }
    }

    val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)

    when(continuation.label) {
        0 -> {
            // 錯誤檢查
            throwOnFailure(continuation.result)
            // 下次 continuation 被調用時, 它應當直接去到狀態 1
            continuation.label = 1
            // Continuation 對象被傳入 logUserIn 函數,從而能夠在結束時恢復 
            // 當前狀態機的執行
            userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
        }
        1 -> {
            // 檢查錯誤
            throwOnFailure(continuation.result)
            // 得到前一個狀態的結果
            continuation.user = continuation.result as User
            // 下次這 continuation 被調用時, 它應當直接去到狀態 2
            continuation.label = 2
            // Continuation 對象被傳入 logUserIn 方法,從而能夠在結束時恢復 
            // 當前狀態機的執行
            userLocalDataSource.logUserIn(continuation.user, continuation)
        }
        2 -> {
            // 錯誤檢查
            throwOnFailure(continuation.result)
            // 獲取前一個狀態的結果
            continuation.userDb = continuation.result as UserDb
            // 恢復調用了當前函數的執行
            continuation.cont.resume(continuation.userDb)
        }
        else -> throw IllegalStateException(...)
    }
}
複製代碼

Kotlin 編譯器將每一個掛起函數轉換爲一個狀態機,在每次函數須要掛起時使用回調並進行優化。

瞭解了編譯器在底層所作的工做後,您能夠更好地理解爲何掛起函數會在完成全部它啓動的工做後才返回結果。同時,您也能知道 suspend 是如何作到不阻塞線程的: 當方法被恢復時,須要被執行的信息所有被存在了 Continuation 對象之中!

點擊這裏查看 Android 官方中文文檔 —— 利用 Kotlin 協程提高應用性能

相關文章
相關標籤/搜索