CPS 與 Kotlin coroutine

有風度開荒隊發表於2019-03-07

Continuation Passing Style

在非同步程式設計中,由於無法拿到實時的結果,我們往往會通過設定回撥的方式來處理執行的結果。

fun doSomethingAsync(param1: Int, param2: Any, callback: (Any?) -> Unit) {
    // ...
    // when execution is done
    callback.invoke(result)
}
複製程式碼

假設我們約定一種程式設計規範,所有的函式都按照上述的方式來定義,即所有的函式都直接返回結果值,而是在引數列表最後的位置傳入一個 callback 函式引數,並在函式執行完成時通過這個 callback 來處理結果,這種程式碼風格被稱為延續傳遞風格(Continuation Passing Style)。這裡的回撥函式(callback)被稱為是延續(continuation),即這個回撥函式決定了程式接下來的行為,整個程式的邏輯就是通過一個個的延續而拼接在一起。

In functional programming, continuation-passing style (CPS) is a style of programming in which control is passed explicitly in the form of a continuation. (from Wikipedia.)

CPS 的優點

我們在實現非同步邏輯的時候會自然而然的採用類似 CPS 的方式,這是因為我們不知道什麼時候可以處理方法的結果,所以把控制邏輯傳給了要呼叫的方法,讓該方法自己在執行完成後去主動呼叫。我們原本必須遵守順序執行的控制邏輯,但是 CPS 給了我們一個機會可以去自定義控制邏輯。那麼自定義控制邏輯可以做哪些事情呢?讓我們來看一個例子。

構建單執行緒事件迴圈模型

我們來增加一點規則:每次呼叫函式並傳入 callback 後,先將 callback 轉換成 EventCallback。EventCallback 會將 callback 放入一個單執行緒執行緒池中去執行,示例程式碼如下所示。

val singleThreadExecutor = Executors.newSingleThreadExecutor()

fun ((Any?) -> Unit).toEventCallback(): ((Any?) -> Unit) {
    return fun(result: Any?) {
        singleThreadExecutor.submit {
            this.invoke(result)
        }
    }
}

fun doSomething(param1: Any, callback: (Any?) -> Unit) {
    var result: Any? = null
    // ...
    // when execution is done
    callback.toEventCallback().invoke(result)
}
複製程式碼

對於一些需要耗時等待的操作(例如 IO 操作),我們可以定義一些特殊的函式,在這些函式裡具體邏輯被放到一個特定的執行緒池中去執行,待操作完成後再返回事件執行緒,這樣可以保證我們的事件執行緒不被阻塞。

val IOThreadPool = Executors.newCachedThreadPool()

fun doSomethingWithIO(param1: Any, callback: (Any?) -> Unit) {
    IOThreadPool.submit {
        var result: Any? = null
        // ...
        // when io operation is done
        callback.toEventCallback().invoke(result)
    }
}
複製程式碼

這樣我們實際建立了一個與 Node.js 類似的單事件迴圈+非同步IO的執行模型,可以看到通過使用 CPS 的方式我們可以更靈活的處理返回值,例如選擇恰當的時機或者是做攔截操作。

CPS 的缺點

Callback Hell

在普通的執行模型中,如果我們需要多個前提值來計算一個最終結果,那麼我們只需要順序計算每個值,然後在計算結果,每個前提值的計算過程都是平級的,但是在 CPS 中,執行順序是通過回撥傳遞的,所以我們不得不每個值的計算作為一個回撥巢狀到另一個值的計算過程中,這就是所謂的 Callback Hell,這樣的程式碼會導致難以閱讀。

// Normal
val a = calculateA()
val b = calculateB()
val c = calculateC()
// ...
val result = calculateResult(a, b, c/*, ...*/)

// CPS
fun calculateResult(callback: (Any?) -> Unit) {
    calculateA(fun(a: Any?) {
        calculateB(fun(b: Any?) {
            calculateC(fun(c: Any?) {
                //...
                callback.invoke(calculate(a, b, c/*, ...*/)
            }
        }
    }
}
複製程式碼

棧空間佔用問題

在類似 C 和 Java 這樣的語言裡,每次函式呼叫會為該函式分配對應的棧空間,用來存放函式引數,返回值和區域性變數的資訊,然後在函式返回之後再釋放這部分空間。而在 CPS 模型中,我們可以看到,回撥是在函式執行完成前被呼叫的,所以在進入回撥函式之後外面的函式的棧空間並不會被釋放,這樣程式很容易出現棧空間溢位的問題。

CPS 中的回撥其實具有一些特殊性,即總是作為函式執行的最後一個步驟(代替普通流程中的返回值),所以這個時候外層函式的值並不會再被訪問,這種情況其實是尾遞迴呼叫的一種表現。在絕大多數的函式式語言中,系統都會對尾遞迴進行優化,回收外層函式的棧空間。但是在 C 和 Java 中並沒有這樣的優化。

Kotlin coroutine

Kotlin coroutine 本質上就是利用 CPS 來實現對過程的控制,並解決了一些用 CPS 時會產生的問題。

suspend 關鍵字

Kotlin 中 suspend 函式的寫法與普通函式基本一致,但是編譯器會對標有 suspend 關鍵字的函式做 CPS 變換,這解決了我們提到的 callback hell 的問題:我們依然可以按照普通的順序執行流程來寫程式碼,而編譯器會自動將其變為 CPS 的等價形式。

另外,為了避免棧空間過大的問題,kotlin 編譯器實際上並沒有把程式碼轉換成函式回撥的形式,而是利用了狀態機模型。Kotlin 把每次呼叫 suspend 函式的地方稱為一個 suspension point,在做編譯期 CPS 變換的時候,每兩個 suspension point 之間可以視為一個狀態,每次進入狀態機的時候會有一個當前的狀態,然後會執行該狀態對應的程式碼,如果這時程式執行完畢,則返回結果值,否則返回一個特殊的標記,表示從這個狀態退出並等待下次進入。這樣相當於實現了一個可複用的回撥,每次都使用這個回撥然後根據狀態的不同執行不同的程式碼。

流程控制

同我們上面控制回撥執行的例子一樣,kotlin 也可以對 suspend 函式進行控制,實現的方式是通過 CoroutineContext 類。在每個 suspend 函式執行的地方都會有一個對應的 CoroutineContext,CoroutineContext 是一個類似單向連結串列的結構,系統回去遍歷這個連結串列並根據每個元素對 suspend 函式執行的流程進行控制,例如我們可以通過 Dispatcher 類控制函式執行的執行緒,或者通過 Job 類來 cancel 當前的函式執行。我們可以使用 coroutine 來重寫一下我們上面定義的模型:

class SingleLoopEnv: CoroutineScope {
		
override val coroutineContext: CoroutineContext = 
        Executors.newSingleThreadExecutor().asCoroutineDispatcher()

    suspend fun doSomething(param1: Any?): Any? {
        var result: Any? = null
        // ...
        // when execution is done
        return result
    }

    fun doSomethingWithIO(param1: Any?): Deferred<Any?> = 
            GlobalScope(Dispatchers.IO).async {
        var result: Any? = null
        // ...
        // when io operation is done
        return result
    }

    fun main() = launch {
        val result = doSomething(null)
        // handle result
        // ...

        val ioResult = doSomethingWithIO(null).await()
        // handle io result
        // ...
    }
}
複製程式碼

總結

像 Kotlin 提供一些其它機制一樣,coroutine 其實也是一種語法糖,但是這是一種比較高階的語法糖,它改變了我們程式碼的執行邏輯,使得我們可以更好的利用 CPS 這一函數語言程式設計的思想,去解決複雜的非同步程式設計問題。

Article by Orab

相關文章