kotlin協程suspend背後的邏輯和狀態機思想
一:前置知識
1:狀態機是什麼
狀態機state machine是什麼?它就是用來表示一個物件處於什麼狀態然後決定正確狀態下做正確的事。
比如說:飲料機在沒有掃碼的時候處於0狀態,就不能呼叫開門這個方法,而掃碼之後,飲料機的狀態改變,才可以開門,但是掃了碼就不能再掃了,狀態不同,做的事也不同,狀態之間因為做了一些事情而不斷轉移。示例中狀態在變化,行為也在變化。
fun main() { val stateMachine = StateMachine() repeat(3) { when (stateMachine.state){ 0 -> { println("狀態0做事") stateMachine.state = 1 } 1 -> { println("狀態1做事") stateMachine.state = 33 } else -> { println("其他狀態做事") } } } } class StateMachine { var state = 0 } 控制檯: 狀態0做事 狀態1做事 其他狀態做事
2:回撥函式是什麼
回撥函式就是一個命令。
fun function1(callback: () -> Unit) { do some thing... do some thing... callback() }
上面的callback就是一個回撥,我們傳”唱歌“進去那最後就會唱歌,傳”跳舞“就會跳舞。不過大多數情況像okhttp中呢,一般會需要傳一個類,類中有成功的回撥也有失敗的回撥,我們要實現兩個,我寫的只是想表達這麼一個概念。
3:回撥函式+狀態機
我們稍後會看到,suspend函式的背後,就是編譯器compile把我們的suspend函式變成一個回撥函式+狀態機
fun main() { myFunction(null) } interface Callback { 一個回撥介面 fun callback() { } } fun myFunction(Callback: Callback?) { class MyFunctionStateMachine : Callback { 實現回撥介面並加上狀態 var state = 0 override fun callback() { myFunction(this)//呼叫本函式 } } val machine = if (Callback == null) MyFunctionStateMachine() else Callback as MyFunctionStateMachine when (machine.state) { 0 -> { println("狀態0做事") machine.state = 1 machine.callback() } 1 -> { println("狀態1做事") machine.state = 33 machine.callback() } else -> { println("其他狀態做事") machine.state = 0 把狀態又置於0,迴圈執行 machine.callback() } } }
我們定義了這樣一個函式,這個函式接收一個回撥作為引數,我們在類內部實現了這個回撥介面,並加上狀態,並在回撥中呼叫了函式本身,並把更新過後的自己傳了進去。在第一次執行時,因為是null,所以會建立,後續就不會建立了。在這個自己呼叫自己的過程之中,每一次呼叫自己,狀態都不一樣。
二:kotlin中的回撥介面
public interface Continuation<in T> { .... public fun resumeWith(result: Result<T>) }
不過就是把類名和函式名換了而已。這個引數是什麼呢?這個Result類可以當成是包含了:一個value, 一個boolean表示成功還是失敗。所以我們知道了協程中有這樣一個介面。
三:suspend函式變為位元組碼之後
1:函式簽名和返回值的改變
對於suspend標記的函式,在轉為Java位元組碼之後,會在函式簽名中加一個引數:
Continuation $completion
比如:
suspend fun work1_1() { ... } 變為 public static final Object work1_1(@NotNull Continuation $completion) { ... }
suspend fun func(para1: Type1, para2: Type2) 變為 func(Type1 para1, Type2 para2, Continuation $completion)
另外,除了函式簽名變化之外,函式的返回型別也會變為Any?(表現在Java位元組碼中則為Object),這是因為如果suspend函式裡面呼叫了delay之類的函式導致suspended發生的話,函式會返回一個enum型別:COROUTINE_SUSPENDED
internal enum class CoroutineSingletons { COROUTINE_SUSPENDED, UNDECIDED, RESUMED }
所以用Any?作返回型別的話就比較合適。
2:函式體的改變
函式簽名中接收的是父suspend函式,即呼叫當前suspend函式的suspend函式傳下來的狀態機。
比如suspend function1呼叫了suspend function2,那麼function2簽名中收到的就是function1中定義的狀態機。
2.1:加入狀態機類
每個suspend函式內都會有一個狀態機類,比如
suspend fun work1(): String { println("1") work1_1() return "1" } suspend fun work1_1() { println("1_1") } 那麼work1的位元組碼大概是:
public static final Object work1(Continuation parentContinuation) { class Work1Continuation(val parentContinuation: Continuation):Continuation{
val label = 0
val result = null
fun callback(result: Result){
this.result = result
work1(this)//自己呼叫自己
}
}
.......
}
2.2:加入是否已建立的判斷
然後,如果是第一次呼叫的話,傳下來的是父的continuation,因此會有一個判斷,判斷continuation是否是當前類的continuation,因為以後呼叫自己,就是自己的那個continuation了。
public static final Object work1(Continuation parentContinuation) { 內部continuation類: val continuation = parentContinuation as? Work1Continuation ?: Work1Continuation(parentContinuation) ....... }
如果是第一次進這個函式,那麼
parentContinuation as? Work1Continuation == null
就會建立,如果不是第一次進入,前面我們看到,會把自己傳入,所以可以成功轉換。
2.3:加入狀態判斷
public static final Object work1(Continuation parentContinuation) { 內部continuation類: val continuation = parentContinuation as? Work1Continuation ?: Work1Continuation(parentContinuation) when(continuation的label){ 0 -> 第一次進入 println("1") continuation.label = 1 val result = work1_1() continuation.callback(result) 1-> 第二次進入 continuation.parentContinuation.callback("1") else -> 報錯 } }
一個suspend函式裡面有4個suspend函式的話,就會有5種狀態,分別是
- 0 第一次進入
- 1 從第一個suspend中恢復
- 2從第二個suspend中恢復
- 3從第三個suspend中恢復
- 4從第四個suspend中恢復,在這裡的最後,呼叫了父的continuation的回撥,因此父又呼叫自己本身,就回到上一層
2.4:總結
suspend函式func結構:
1: 這個函式的Continuation匿名類 2:檢驗是否是第一次進入,第一次進入就建立Continuation,並把上層傳進來的Continuation包在本層的Continuation裡 3:狀態機 case 第一次進來 設定標誌位為:第二次進來
呼叫第一個suspend函式
呼叫這個函式的continuation的callback
case 第二次進來 。。。。 case 最後一次進來 呼叫上層函式的Continuation的callback(本層函式的Continuation的result)
所以,協程框架為我們順序執行的程式碼,轉換成了回撥的形式。父函式裡面呼叫子函式,子函式假如掛起了,是一個delay,那麼當delay時間過去之後,因為delay也是個掛起函式,它結束了,就呼叫父continuation的回撥(我們的函式不是把自己的continuation傳給delay了嗎?),結果父函式的continuation呼叫父函式,再次進來的時候狀態變了,就不會再走之前走過的程式碼了。
這樣,我們通過label,就相當於儲存了掛起點。
四:為什麼協程知道從哪裡恢復
接上面的話,我們呼叫了delay之後,協程被掛起,執行緒清空當前函式呼叫棧,轉去做其他事情,那麼協程是如何知道從哪裡恢復執行的呢?其實上面已經說了,就是利用continuation呼叫自己的這個特性。
- 呼叫棧如何恢復?呼叫哪個函式,由continuation本身就可以由呼叫自己的這個特性來恢復
- 函式內的區域性變數如何恢復?函式內的區域性變數不是清空了麼?其實也都變成continuation的欄位了,我上面沒說。
- 函式內執行到哪裡如何恢復?由label變數來恢復
在這裡,不得不感慨太妙了,通過子continuation引用父continuation
最上層Continuation(這一層的區域性變數,label) <----被引用---- 第二層Continuation(這一層的區域性變數,label) <-----被引用------- 第三層Continuation(這一層的區域性變數,label)第三層的函式結束之後,會呼叫第二層的callback, 在callback裡面第二層繼續呼叫自己,第二層結束之後呼叫第一層的callback
這樣來實現了一個疊疊樂。
這就是協程的掛起與恢復機制了。
五:異常的捕獲
根據這個恢復機制還有它儲存子函式的Result這些機制,我們可以明白,一個異常:
throw IllegalStateException之類(非CancellationCoroutineException,因為它會特殊處理,不應該主動丟擲這個)
要麼,在丟擲的那個所處的協程體中丟擲時就捕獲,否則一直會向上傳遞直到根協程的ExceptionHandler處理它。
詳情可參考https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c
六:總結
- 掛起函式就像狀態機,在函式開始和每次掛起函式呼叫之後都有一個可能的狀態。
- 標識狀態的標籤和本地資料都儲存在當前函式的Continuation中。
- 一個函式的continuation引用了另一個函式的continuation,因此,所有這些continuation都代表了我們恢復時使用的呼叫堆疊
本文首發於我的部落格園:https://www.cnblogs.com/--here--gold--you--want/
本文參考了我的偶像Manuel,即部落格封面那個藍人的文章:https://medium.com/androiddevelopers/the-suspend-modifier-under-the-hood-b7ce46af624f
以及kotlin專家的文章:https://kt.academy/article/cc-under-the-hood#definition-3