天天看點

Kotlin中的挂起函數如何工作的

作者:不秃頭程式員
Kotlin中的挂起函數如何工作的

挂起能力是建構所有其他Kotlin協程概念的最基本特性。

簡而言之:挂起協程意味着可以在中途停止它。

類比: 首先通過一些現實世界中的例子來了解挂起的真正含義:

玩視訊遊戲:

  • 你玩得很好(假設)。
  • 到達一個檢查點。
  • 儲存目前的位置。
  • 關掉遊戲,你和你的電腦現在都專注于做不同的事情。
  • 完成任務回來并從儲存的地方繼續。

想象一下你在廚房中多任務處理:

  • 開始一個食譜(協程啟動):通過切菜開始一個食譜(協程任務1)。
  • 暫停并切換任務(協程挂起):烤箱需要預熱(協程任務2)。暫停切菜(挂起任務1)并設定烤箱(完成任務2)。
  • 繼續第一個任務(協程恢複):一旦烤箱預熱或打開,回到切菜(恢複任務1)的地方繼續。
  • 根據需要重複(多個協程):你可以在等待其他事情的同時切換任務(協程),比如檢查水是否沸騰(另一個協程任務)。

現在,這些示例是協程挂起最好的類比。 一個協程可以開始執行函數,它可以挂起(儲存狀态并為他人留出線程),然後一旦任何挂起任務(如網絡調用)執行完畢後恢複。

看一個挂起函數的實際操作:

suspend fun main() {
  println("Before")
  println("After")
}
// 列印 "Before"
// 列印 "After"           

這是一個簡單的程式,将列印“Before”和“After”。如果我們在這兩個列印之間挂起會發生什麼呢?為此,可以使用标準Kotlin庫提供的suspendCoroutine函數。

suspend fun main() {
  println("Before")
  suspendCoroutine<Unit> { }
  println("After")
}
// 列印 "Before"           

如果調用上述代碼,将不會看到“After”,并且代碼不會停止運作(因為main函數從未完成)。協程在“Before”之後被挂起。我們的遊戲被停止且未恢複。那麼,如何恢複呢?

這個suspendCoroutine調用以一個lambda表達式({ })結束。作為參數傳遞的函數将在挂起之前被調用。這個函數獲得一個continuation作為參數。

可以使用這個continuation來恢複我們的協程。這個lambda用于将這個continuation存儲在某處或計劃是否恢複它。可以用它來立即恢複:

suspend fun main() {
  println("Before")
  suspendCoroutine<Unit> { continuation ->
    continuation.resume(Unit)
  }
  println("After")
}
// 列印 "Before"
// 列印 "After"           

你可能會想到,這裡我們挂起并立即恢複。這是一個好的直覺,但事實是,存在一種優化,可以防止如果立即恢複則不挂起。

使用“delay”函數來挂起協程特定的時間然後恢複它。

suspend fun main() {
  println("Before")
  delay(1000)
  println("After")
}
// 列印 "Before"
// (挂起1秒)
// 列印 "After"           

需要強調的一點是,我們挂起的是協程,而不是函數。挂起函數不是協程,隻是可以挂起協程的函數。

注意,這與線程非常不同,線程不能被儲存,隻能被阻塞。

協程要強大得多。當挂起時,

  • 它不消耗任何資源。
  • 協程可以在不同的線程上恢複
  • continuation可以被序列化,反序列化,然後恢複。

内部原理:

在Kotlin中,挂起函數是使用Continuation傳遞風格實作的。這意味着continuations作為參數從函數傳遞到函數(就像Jetpack Compose中的Composer)。

suspend fun getUser(): User?
suspend fun setUser(user: User)
suspend fun checkAvailability(flight: Flight): Boolean
// 内部看起來像
fun getUser(continuation: Continuation<*>): Any?
fun setUser(user: User, continuation: Continuation<*>): Any
fun checkAvailability(
flight: Flight,
continuation: Continuation<*>): Any           

傳回類型變更為Any,因為除了定義的傳回類型之外,挂起函數還可以傳回一個“COROUTINE_SUSPENDED”。

看一個簡單的挂起函數:

suspend fun myFunction() {
  println("Before")
  delay(1000) // 挂起
  println("After")
}           

接下來,這個函數需要它的continuation來記住它的狀态。讓我們稱它為“MyFunctionContinuation”。

函數可以從兩個地方開始:要麼從開始(如果是首次調用),要麼從挂起點之後(如果是從continuation恢複)。為了識别目前狀态,使用了一個稱為label的字段。在開始時,它是0,是以函數将從開始處開始。然而,在每個挂起點之前,它被設定為下一個狀态,以便在恢複後我們從挂起點之後開始。

// A simplified picture of how myFunction looks under the hood
fun myFunction(continuation: Continuation<Unit>): Any {
  if (continuation.label == 0) { //Starting point
    println("Before")
    continuation.label = 1 //Update just before suspension
    if (delay(1000, continuation) == COROUTINE_SUSPENDED){
      return COROUTINE_SUSPENDED
    }
  }
  //Point after suspension
  if (continuation.label == 1) {
    println("After")
    return Unit
  }
  error("Impossible")
}           

當延遲調用時,傳回COROUTINE_SUSPENDED,然後myFunction傳回COROUTINE_SUSPENDED;調用它的函數、調用該函數的函數以及直到調用堆棧頂部的所有其他函數都會執行相同的操作。這就是挂起結束所有這些函數并使線程可供其他可運作對象(包括協程)使用的方式。

如果這個“延遲”調用沒有傳回 COROUTINE_SUSPENDED 會發生什麼?如果它隻是傳回機關怎麼辦?請注意,如果延遲僅傳回一個 Unit,我們将移至下一個狀态,并且該函數的行為将與其他狀态一樣。

并且,内部延續類看起來像這樣

cont = object : ContinuationImpl(continuation) {
  var result: Any? = null
  var label = 0
  override fun invokeSuspend(`$result`: Any?): Any? {
    this.result = `$result`;
    return myFunction(this);
  }
};           

如果你想存儲狀态:

suspend fun myFunction() {
  println("Before")
  var counter = 0 //local state
  delay(1000) // suspending
  counter++
  println("Counter: $counter")
  println("After")
}           

這裡需要在兩種狀态下使用計數器(對于等于0和1的标簽),是以需要将其保留在延續中。它将在暫停之前存儲。恢複這些類型的屬性發生在函數的開頭。這就是函數的内部結構:

fun myFunction(continuation: Continuation<Unit>): Any {
  var counter = continuation.counter //restoring the value at start
  if (continuation.label == 0) {
    println("Before")
    counter = 0 //user-defined
    continuation.counter = counter //saving the value just before suspension
    continuation.label = 1
    if (delay(1000, continuation) == COROUTINE_SUSPENDED){
      return COROUTINE_SUSPENDED
    }
  }
  if (continuation.label == 1) {
    counter = (counter as Int) + 1 //user-defined
    println("Counter: $counter")
    println("After")
    return Unit
  }
  error("Impossible")
}

//Continuation object internal working
class MyFunctionContinuation(val completion: Continuation<Unit>) : Continuation<Unit> {
  override val context: CoroutineContext
    get() = completion.context
  
  var result: Result<Unit>? = null
  var label = 0
  var counter = 0 //save like a state

  override fun resumeWith(result: Result<Unit>) {
    this.result = result
    val res = try {
      val r = myFunction(this)
      if (r == COROUTINE_SUSPENDED) return
      Result.success(r as Unit)
    } catch (e: Throwable) {
      Result.failure(e)
    }
    completion.resumeWith(res)
  }
}           

對于最常見的用例(例如進行 API 調用).

suspend fun printUser(token: String) {
  println("Before")
  val userId = getUserId(token) // suspending network call
  println("Got userId: $userId")
  val userName = getUserName(userId, token) // suspending network call
  println(User(userId, userName))
  println("After")
}           

在這種情況下的不同之處在于,

  • 存儲函數的結果,如下所示......
...
val res = getUserId(token, continuation)
if (res == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
result = Result.success(res)//store the success when when function didn't suspend
...           
  • 恢複函數開始時的狀态,就像我們對“counter”變量所做的那樣:
var counter = continuation.counter// In previous example

var result: Result<Any>? = continuation.result// In this example(when suspend function returns a value)           

這裡需要注意的一件事是,Continuations 将充當我們的調用堆棧并存儲函數的狀态(标簽、參數、變量)。

如果 A 和 B 是兩個挂起函數,并且 A 在其中調用 B。然後B也會将A的Continuation存儲為它的Completion狀态。

BContinuation(
  i = 4,
  label = 1,
  completion = AContinuation(
    i = 4,
    label = 1,
    completion = ...           

在内部,為了優化以看起來更複雜的方式實作這些東西。用循環而不是遞歸來實作的。

  • 挂起函數就像狀态機,在函數開始處和每次挂起函數調用之後都有一個可能的狀态。
  • 辨別狀态的标簽和本地資料都儲存在延續對象中。
  • 一個函數的繼續可以裝飾其調用者函數的繼續;是以,所有這些繼續表示當我們恢複或恢複的函數完成時使用的調用棧。

繼續閱讀