Kotlin Vocabulary | 揭秘協(xié)程中的 suspend 修飾符
Kotlin 協(xié)程把 suspend 修飾符引入到了我們 Android 開發(fā)者的日常開發(fā)中。您是否好奇它的底層工作原理呢?編譯器是如何轉(zhuǎn)換我們的代碼,使其能夠掛起和恢復(fù)協(xié)程操作的呢?
了解這些將會幫您更好地理解掛起函數(shù) (suspend function) 為什么只會在所有工作完成后才會返回,以及如何在不阻塞線程的情況下掛起代碼。
本文概要: Kotlin 編譯器將會為每個掛起函數(shù)創(chuàng)建一個狀態(tài)機(jī),這個狀態(tài)機(jī)將為我們管理協(xié)程的操作!
? 如果您是 Android 平臺上協(xié)程的初學(xué)者,請查閱下面這些協(xié)程 codelab:
- 在 Android 應(yīng)用中使用協(xié)程https://codelabs.developers.google.com/codelabs/kotlin-coroutines/#0
- 協(xié)程的進(jìn)階使用: Kotlin Flow 和 Live Datahttps://codelabs.developers.google.com/codelabs/advanced-kotlin-coroutines/#0
協(xié)程 101
協(xié)程簡化了 Android 平臺的異步操作。正如官方文檔《利用 Kotlin 協(xié)程提升應(yīng)用性能》所介紹的,我們可以使用協(xié)程管理那些以往可能阻塞主線程或者讓應(yīng)用卡死的異步任務(wù)。
《利用 Kotlin 協(xié)程提升應(yīng)用性能》
https://developer.android.google.cn/kotlin/coroutines
協(xié)程也可以幫我們用命令式代碼替換那些基于回調(diào)的 API。例如,下面這段使用了回調(diào)的異步代碼:
// 簡化的只考慮了基礎(chǔ)功能的代碼fun?loginUser(userId:?String,?password:?String,?userResult:?Callback<User>)?{// 異步回調(diào)??userRemoteDataSource.logUserIn?{?user?->// 成功的網(wǎng)絡(luò)請求????userLocalDataSource.logUserIn(user)?{?userDb?->// 保存結(jié)果到數(shù)據(jù)庫userResult.success(userDb)}}}
suspend fun loginUser(userId: String, password: String): User {val user = userRemoteDataSource.logUserIn(userId, password)val userDb = userLocalDataSource.logUserIn(user)return userDb}
不同于回調(diào),協(xié)程提供了一種簡單的方式來實(shí)現(xiàn)線程間的切換以及對異常的處理。但是,在我們把一個函數(shù)寫成掛起函數(shù)時,編譯器在內(nèi)部究竟做了什么事呢?
Suspend 的工作原理
suspend fun loginUser(userId: String, password: String): User {val user = userRemoteDataSource.logUserIn(userId, password)val userDb = userLocalDataSource.logUserIn(user)return userDb}// UserRemoteDataSource.ktsuspend fun logUserIn(userId: String, password: String): User// UserLocalDataSource.ktsuspend fun logUserIn(userId: String): UserDb
簡而言之,Kotlin 編譯器會把掛起函數(shù)使用有限狀態(tài)機(jī) (稍后講到) 轉(zhuǎn)換為一種優(yōu)化版回調(diào)。也就是說,編譯器會幫您實(shí)現(xiàn)這些回調(diào)!
有限狀態(tài)機(jī)
https://en.wikipedia.org/wiki/Finite-state_machine
Continuation 接口
掛起函數(shù)通過 Continuation 對象在方法間互相通信。Continuation 其實(shí)只是一個具有泛型參數(shù)和一些額外信息的回調(diào)接口,稍后我們會看到,它會實(shí)例化掛起函數(shù)所生成的狀態(tài)機(jī)。
Continuation
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/-continuation/index.html
Continuation
https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/coroutines/Continuation.kt
我們先來看看它的聲明:
interface Continuation<in T> {public val context: CoroutineContextpublic fun resumeWith(value: Result<T>)}
context 是 Continuation 將會使用的 CoroutineContext;
resumeWith 會恢復(fù)協(xié)程的執(zhí)行,同時傳入一個 Result 參數(shù),Result 中會包含導(dǎo)致掛起的計算結(jié)果或者是一個異常。
Result
https://github.com/Kotlin/kotlinx.coroutines/blob/master/stdlib-stubs/src/Result.kt
注意:?從 Kotlin 1.3 開始,您也可以使用 resumeWith 對應(yīng)的擴(kuò)展函數(shù): resume?(value: T) 和 resumeWithException?(exception: Throwable)。
resume
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/resume.html
resumeWithException
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/resume-with-exception.html
fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {val user = userRemoteDataSource.logUserIn(userId, password)val userDb = userLocalDataSource.logUserIn(user)completion.resume(userDb)}
其實(shí),掛起函數(shù)在字節(jié)碼中返回的是 Any。因?yàn)樗怯?T | COROUTINE_SUSPENDED 構(gòu)成的組合類型。這種實(shí)現(xiàn)可以使函數(shù)在可能的情況下同步返回。
注意:?如果您使用 suspend 修飾符標(biāo)記了一個函數(shù),而該函數(shù)又沒有調(diào)用其它掛起函數(shù),那么編譯器會添加一個額外的 Continuation 參數(shù)但是不會用它做任何事,函數(shù)體的字節(jié)碼則會看起來和一般的函數(shù)一樣。?您也會在其他地方看到 Continuation 接口:
- 當(dāng)使用 suspendCoroutine 或 suspendCancellableCoroutine ?(首選使用) 來將基于回調(diào)的 API 轉(zhuǎn)化為協(xié)程時,會直接與一個 Continuation 對象進(jìn)行交互。它會用于恢復(fù)那些執(zhí)行了參數(shù)代碼塊后掛起的協(xié)程;
- 您可以在一個掛起函數(shù)上使用 startCoroutine 擴(kuò)展函數(shù),它會接收一個 Continuation 對象作為參數(shù),并會在新的協(xié)程結(jié)束時調(diào)用它,無論其運(yùn)行結(jié)果是成功還是異常。
- suspendCoroutine?https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/suspend-coroutine.html
- suspendCancellableCoroutinehttps://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/suspend-cancellable-coroutine.html
- startCoroutinehttps://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/start-coroutine.html
使用不同的 Dispatcher
您可以在不同的 Dispatcher 間切換,從而做到在不同的線程中執(zhí)行計算。那么 Kotlin 是如何知道從哪里開始恢復(fù)掛起的計算的呢?Continuation 有一個子類叫 DispatchedContinuation,它的 resume 函數(shù)會執(zhí)行一次調(diào)度調(diào)用,并會調(diào)度至 CoroutineContext 包含的 Dispatcher 中。除了那些將 isDispatchNeeded 方法 (會在調(diào)度前調(diào)用) 重寫為始終返回 false 的 Dispatcher.Unconfined,其他所有的 Dispatcher 都會調(diào)用 dispatch 方法。
- DispatchedContinuationhttps://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/common/src/internal/DispatchedContinuation.kt?
生成狀態(tài)機(jī)
特殊說明:?本文接下來所展示的,并不是與編譯器生成的字節(jié)碼完全相同的代碼,而是足夠精確的,能夠確保您理解其內(nèi)部發(fā)生了什么的 Kotlin 代碼。這些聲明由版本為 1.3.3 的協(xié)程庫生成,可能會在其未來的版本中作出修改。
Kotlin 編譯器會確定函數(shù)何時可以在內(nèi)部掛起,每個掛起點(diǎn)都會被聲明為有限狀態(tài)機(jī)的一個狀態(tài),每個狀態(tài)又會被編譯器用標(biāo)簽表示:
fun?loginUser(userId:?String,?password:?String,?completion:?Continuation<Any?>)?{// Label 0 -> 第一次執(zhí)行val user = userRemoteDataSource.logUserIn(userId, password)// Label 1 -> 從 userRemoteDataSource 恢復(fù)val userDb = userLocalDataSource.logUserIn(user)// Label 2 -> 從 userLocalDataSource 恢復(fù)completion.resume(userDb)}
fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {??when(label)?{// Label 0 -> 第一次執(zhí)行userRemoteDataSource.logUserIn(userId, password)????}// Label 1 -> 從 userRemoteDataSource 恢復(fù)userLocalDataSource.logUserIn(user)????}// Label 2 -> 從 userLocalDataSource 恢復(fù)completion.resume(userDb)}else -> throw IllegalStateException(...)}}
接下來,編譯器會創(chuàng)建一個私有類,它會:
保存必要的數(shù)據(jù);
遞歸調(diào)用 loginUser 函數(shù)來恢復(fù)執(zhí)行。
您可以查看下面提供的編譯器生成類的近似版本。
特別說明: 注釋不是由編譯器生成的,而是由作者添加的。添加它們是為了解釋這些代碼的作用,也能讓后面的代碼更加容易理解。
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {class?LoginUserStateMachine(// completion 參數(shù)是調(diào)用了 loginUser 的函數(shù)的回調(diào)completion: Continuation): CoroutineImpl(completion) {// suspend 的本地變量var user: User? = nullvar userDb: UserDb? = null// 所有 CoroutineImpls 都包含的通用對象var result: Any? = nullvar label: Int = 0// 這個方法再一次調(diào)用了 loginUser 來切換// 狀態(tài)機(jī) (標(biāo)簽會已經(jīng)處于下一個狀態(tài))// result 將會是前一個狀態(tài)的計算結(jié)果override fun invokeSuspend(result: Any?) {this.result = resultloginUser(null, null, this)}}...}
由于 invokeSuspend 函數(shù)將會再次調(diào)用 loginUser 函數(shù),并且只會傳入 Continuation 對象,所以 loginUser 函數(shù)簽名中的其他參數(shù)變成了可空類型。此時,編譯器只需要添加如何在狀態(tài)之間切換的信息。
首先需要知道的是:
- 函數(shù)是第一次被調(diào)用;
- 函數(shù)已經(jīng)從前一個狀態(tài)中恢復(fù)。
做到這些需要檢查 Contunuation 對象傳遞的是否是 LoginUserStateMachine 類型:
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {...val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)...}
如果是第一次調(diào)用,它將創(chuàng)建一個新的 LoginUserStateMachine 實(shí)例,并將 completion 實(shí)例作為參數(shù)接收,以便它記得如何恢復(fù)調(diào)用當(dāng)前函數(shù)的函數(shù)。如果不是第一次調(diào)用,它將繼續(xù)執(zhí)行狀態(tài)機(jī) (掛起函數(shù))。
現(xiàn)在,我們來看看編譯器生成的用于在狀態(tài)間切換并分享信息的代碼:
/* 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 被調(diào)用時, 它應(yīng)當(dāng)直接去到狀態(tài) 1????????????continuation.label?=?1// Continuation 對象被傳入 logUserIn 函數(shù),從而可以在結(jié)束時恢復(fù)// 當(dāng)前狀態(tài)機(jī)的執(zhí)行userRemoteDataSource.logUserIn(userId!!, password!!, continuation)}????????1?->?{// 檢查錯誤????????????throwOnFailure(continuation.result)// 獲得前一個狀態(tài)的結(jié)果????????????continuation.user?=?continuation.result?as?User// 下次這 continuation 被調(diào)用時, 它應(yīng)當(dāng)直接去到狀態(tài) 2????????????continuation.label?=?2// Continuation 對象被傳入 logUserIn 函數(shù),從而可以在結(jié)束時恢復(fù)// 當(dāng)前狀態(tài)機(jī)的執(zhí)行userLocalDataSource.logUserIn(continuation.user, continuation)????????}... // 故意遺漏了最后一個狀態(tài)}}
花一些時間瀏覽上面的代碼,看看您是否能注意到與之前代碼之間的差異。下面我們來看看編譯器生成了什么:
- when 語句的參數(shù)是 LoginUserStateMachine 實(shí)例內(nèi)的 label;
- 每一次處理新的狀態(tài)時,為了防止函數(shù)被掛起時運(yùn)行失敗,都會進(jìn)行一次檢查;
- 在調(diào)用下一個掛起函數(shù) (即 logUserIn) 前,LoginUserStateMachine 的 label 都會更新到下一個狀態(tài);
- 在當(dāng)前的狀態(tài)機(jī)中調(diào)用另一個掛起函數(shù)時,continuation 的實(shí)例 (LoginUserStateMachine 類型) 會被作為參數(shù)傳遞過去。而即將被調(diào)用的掛起函數(shù)也同樣被編譯器轉(zhuǎn)換成一個相似的狀態(tài)機(jī),并且接收一個 continuation 對象作為參數(shù)。當(dāng)被調(diào)用的掛起函數(shù)的狀態(tài)機(jī)運(yùn)行結(jié)束時,它將恢復(fù)當(dāng)前狀態(tài)機(jī)的執(zhí)行。
最后一個狀態(tài)與其他幾個不同,因?yàn)樗仨毣謴?fù)調(diào)用它的方法的執(zhí)行。如您將在下面代碼中所見,它將調(diào)用 LoginUserStateMachine 中存儲的 cont 變量的 resume 函數(shù):
/* 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)// 獲取前一個狀態(tài)的結(jié)果????????????continuation.userDb?=?continuation.result?as?UserDb// 恢復(fù)調(diào)用了當(dāng)前函數(shù)的函數(shù)的執(zhí)行continuation.cont.resume(continuation.userDb)}else -> throw IllegalStateException(...)}}
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 參數(shù)是調(diào)用了 loginUser 的函數(shù)的回調(diào)completion: Continuation????):?CoroutineImpl(completion)?{// 要在整個掛起函數(shù)中存儲的對象var user: User? = nullvar userDb: UserDb? = null// 所有 CoroutineImpls 都包含的通用對象var result: Any? = nullvar label: Int = 0// 這個函數(shù)再一次調(diào)用了 loginUser 來切換// 狀態(tài)機(jī) (標(biāo)簽會已經(jīng)處于下一個狀態(tài))// result 將會是前一個狀態(tài)的計算結(jié)果override fun invokeSuspend(result: Any?) {this.result = resultloginUser(null, null, this)}}val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)when(continuation.label) {????????0?->?{// 錯誤檢查????????????throwOnFailure(continuation.result)// 下次 continuation 被調(diào)用時, 它應(yīng)當(dāng)直接去到狀態(tài) 1????????????continuation.label?=?1// Continuation 對象被傳入 logUserIn 函數(shù),從而可以在結(jié)束時恢復(fù)// 當(dāng)前狀態(tài)機(jī)的執(zhí)行userRemoteDataSource.logUserIn(userId!!, password!!, continuation)}????????1?->?{// 檢查錯誤????????????throwOnFailure(continuation.result)// 獲得前一個狀態(tài)的結(jié)果????????????continuation.user?=?continuation.result?as?User// 下次這 continuation 被調(diào)用時, 它應(yīng)當(dāng)直接去到狀態(tài) 2????????????continuation.label?=?2// Continuation 對象被傳入 logUserIn 方法,從而可以在結(jié)束時恢復(fù)// 當(dāng)前狀態(tài)機(jī)的執(zhí)行userLocalDataSource.logUserIn(continuation.user, continuation)}????????2?->?{// 錯誤檢查????????????throwOnFailure(continuation.result)// 獲取前一個狀態(tài)的結(jié)果????????????continuation.userDb?=?continuation.result?as?UserDb// 恢復(fù)調(diào)用了當(dāng)前函數(shù)的執(zhí)行continuation.cont.resume(continuation.userDb)}else -> throw IllegalStateException(...)}}
Kotlin 編譯器將每個掛起函數(shù)轉(zhuǎn)換為一個狀態(tài)機(jī),在每次函數(shù)需要掛起時使用回調(diào)并進(jìn)行優(yōu)化。
了解了編譯器在底層所做的工作后,您可以更好地理解為什么掛起函數(shù)會在完成所有它啟動的工作后才返回結(jié)果。同時,您也能知道 suspend 是如何做到不阻塞線程的: 當(dāng)方法被恢復(fù)時,需要被執(zhí)行的信息全部被存在了 Continuation 對象之中!
