棧編程和函數控制流: 從 continuation 與 CPS 講到 call/cc 與協程


原標題:尾遞歸優化 快速排序優化 CPS 變換 call/cc setjmp/longjmp coroutine 協程 棧編程和控制流 講解

本文為部分函數式編程的擴展及最近接觸編程語言控制流的學習和思考,主題是棧編程和控制流相關,涉及內容有 堆棧編程總結, 函數式語言的CPS變換,python 如何實現尾遞歸優化裝飾器及其思想方法的總結應用,快速排序的算法導論寫法的一種視角/分析,C 語言setjmp/longjmp 函數的作用和實現分析,如何實現 C/C++ 下的協程庫編寫的一些思路和要點分析。雖然是大雜燴,但是主要內容都與棧和控制流相關。主要服務於不久后進行的網絡編程項目中協程庫部分的前情提要,另外也為將來進一步學習魔法預備。


遞歸與棧

首先理解棧程序模型,其函數調用是依據壓棧進行的,這里給一副圖加深印象:
RISC-V 調用約定
函數調用最后返回的時候 callee 需要從棧恢復 callee save 寄存器然后返回到 caller,caller 再恢復 caller save 寄存器,這一步就是占用內存的消費。


尾遞歸

我們是否能夠把遞歸變成 \(O(1)\) 空間的呢?實際上是可以的,但是這對程序有條件,就是尾遞歸。下面來看幾個程序:

int fib(int n){                        int sum(int n) {
    if(n < 2) return 1;                    if(n<1) return 0;
    return fib(n-1) + fib(n-2);            return sum(n-1) + n;
}                                      }

int sum2(int n, int acc){              int fact(int n, int acc){
    if(n<1) return acc;                    if(n<1) return acc;
    return sum2(n-1, acc + n);             return fact(n-1, acc * n);
}                                      }

這里一共有四個程序,我們很容易理解只有下面的兩個可以 \(O(1)\) 返回,這是因為中間的運算是無必要的,函數調用者不需要在遞歸調用返回后進行其他運算再返回,這樣我們匯編層面不需要壓棧保存寄存器從而直接 jmp 到函數調用就行了。一直運行到最后一個函數調用就能 a0 作為返回值返回結束函數。上面的階層函數寫成偽代碼就是這樣:

fact:
let register a0 as n, a1 as acc
label 1: if (n<1) a0 = acc, ret
label 2: acc = acc * n
label 3: n = n-1
label 4: goto label 1

這種就是尾遞歸和尾遞歸優化。


Python 尾遞歸優化

我們在 Python 里可以寫一個裝飾器讓通用的尾遞歸函數可以進行優化從而去掉運行的堆棧。廢話少說,直接上代碼:

class TailRecurseException:
    def __init__(self, args, kwargs):
        self.args = args
        self.kwargs = kwargs
def tail_call_optimized(g):
    def func(*args, **kwargs):
        f = sys._getframe()
        if f.f_back and f.f_back.f_back \
            and f.f_back.f_back.f_code == f.f_code:
            # 拋出異常
            raise TailRecurseException(args, kwargs)
        else:
            while 1:
                try:
                    return g(*args, **kwargs)
                except TailRecurseException, e:
                    args = e.args
                    kwargs = e.kwargs
    func.__doc__ = g.__doc__
    return func
@tail_call_optimized
def factorial(n, acc=1):
    "calculate a factorial"
    if n == 0:
        return acc
    return factorial(n-1, n*acc)

你看懂了嗎?一開始看可能有點一頭霧水,當然我們不了解 Python 的運行堆棧的概念就無法理解。結合 decorator 的編程方法,我們可以做這樣的思想實驗:我們先偽裝一個函數把參數控制權給偷過來(裝飾器內自定義函數即中間人),注意裝飾器一旦裝飾,其原函數(factorial)內部遞歸將轉接到裝飾器內的自定義函數(func)中。然后我們知道是遞歸的時候就清空棧,然后重新調用一個原函數傳入新的參數。

具體實現涉及一些小技巧,比較魔幻,下面講解:

  • 裝飾器啟動后,我們調用 factorial 100,此時實際調用的是 func 100,然后 func 100 會調用 g 100
  • g 99 進入原來的 factorial 99,然后 return func 98(注意這里遞歸調用是被掉包了)
  • func 98 將再次進入 func,此時的堆棧是: func 100 -> g 100 -> func 99
  • func 發現發生了遞歸,於是其拋出一個異常,並且把這個最新遞歸調用的參數給異常帶走了
  • 一旦異常發生,棧將直接清空直到回到第一次調用產生的地方,那就是第一個 funcwhile 1 中的抓異常的地方
  • 然后我們完成了清空棧的目的,然后再調用 g 99 ,就等價於尾遞歸控制權直接給下一任返回

感覺這里是不是好像 DNS 查詢里的迭代查詢?就本來建立了一條查詢鏈的 local 向服務器 A 發起 “查詢” 調用,服務器 A 向 B 發起又一次 “查詢” 調用,直到返回后再返回給 local。這樣效率不好而且涉及占用服務器資源,連接必須等待。改進則變成了 local 向 A 查詢,A 說你去向 B 查詢,A 又向 B 查詢。這里的 A 只是計算出 B,即上文中的 “參數”,在這里,函數調用是 “查詢” 函數,而參數則是服務器的名字。


快排遞歸優化版

(本節和后續主題內容無關)對巨量數據進行快速排序真正用起來是會爆棧的(這也是為什么 stl 的std:sort 會采用快排分段后檢測數據量和遞歸深度,子段落決定采用插入排序或是堆排序等 \(O(1)\) 空間算法的策略),但是我們清楚的認識到快速排序的代碼並不算尾遞歸,所以很明顯他無法進行尾遞歸優化,但是其實我們還是可以優化一部分寄存器的保存和恢復的操作的,那就是進行剪枝,當然這種寫法很難說不算尾遞歸優化 idea 的啟發。算法導論永遠的神。

    public int patition(int[]nums, int l, int r){
        int pivot = nums[r-1];
        while(l<r-1){
            while(l<r-1&&nums[l]<=pivot)l++;
            if(l<r-1) nums[--r] = nums[l];
            while(l<r-1&&nums[r-1]>pivot) r--;
            if(l<r-1)nums[l++] = nums[r-1];
        }
        nums[l] = pivot;
        return l;
    }
    public void qs(int[]nums, int l, int r){
        int sep;
        while(l<r){
            sep = patition(nums, l, r);
            qs(nums, l, sep);
            l = sep + 1;
        }
    }
    public void qs_v2(int[]nums, int l, int r){
        int sep;
        while(l<r){
            sep = patition(nums, l, r);
            qs_v2(nums, sep+1, r);
            r = sep;
        }
    }

分析我就不做了,很容易理解的本來是對左右子樹分別遞歸下去,但是由於我們知道樹的左邊界或者右邊界是固定的,很容易想到消除一部分遞歸調用,即左樹,左樹的左樹,左樹的左樹的左樹都在 while 循環里面搞定了,即減少了一半的寄存器壓棧出棧,最后結果就是節約一點點空間。說這是一種尾遞歸優化是因為他的思想和尾遞歸優化是一致的。
優化節約空間


尾調用

但是其實尾遞歸優化是沒有意義的,這是因為我們很容易就能把尾遞歸程序寫成循環的形式,像 Java (Java 8)這種純 OO 語言編譯器本身就不提供尾遞歸優化,因為程序員總是能在希望提高性能時手動完成尾遞歸到循環的轉換,而 C/C++ 的編譯器如 gcc/g++ 就提供該優化,具體實現方案則正如上文提到的那樣是不進行寄存器的保存和恢復直接 jmp

但是我們知道 CS 的發展歷程很多時候一個 idea 並不總是在本地方有用的,尾遞歸程序員本身可以寫成循環,我們遵循唯物主義的教導從特殊到一般分析,對於尾遞歸的更一般的情況尾調用來說,程序員是無法在不改變 Coupling 情況下優化的,這時候這個尾調用優化不進行寄存器的保存和恢復就大顯身手。然而 Java 出於某些堆棧計數依賴的原因並不提供,希望編寫高性能()的 Java 程序時留意。

所以為什么有尾調用的需求,正常不是我們返回值然后調用新的函數不就行了嗎?這其實很好理解,比如有一個應用——異步編程,我們在 C# 和 javascript 編程里面已經見的多了,那就是大名鼎鼎的異步編程,此時這個尾調用實際上是一個回調函數,這樣實際上尾調用會延遲在另一個核心里運行,我並不需要等待他的返回就行執行其他的程序流。接下來就要具體講解這種尾調用的編程風格


閉包與柯里化

前面 Python 的曲線尾遞歸優化的 中間插一腿 的思路能幫助我們理解 Continuation Passing Style 編程。雖然這個東西理論上好像很復雜,搞魔法的可能可以用邏輯學/形式邏輯里程序等價於證明的 Curry-howard Correspondence 理論相關的東西來講。我這里比較低級,只做一些簡單的筆記,我們先從講解函數式語言里容易理解的 閉包(closure) 概念開始。

函數式編程里面函數是一等公民,除了參數就就沒有變量了,因為變量會存在 state ,這個東西會導致函數調用有副作用,所以要實現循環都用遞歸來實現的,參數傳值。

我們來看一段 Common Lisp 代碼:

(define f (lambda (x) (lambda (t) (+ x t))))

這里的 (lambda (t) (+ x t)) 就是一個閉包函數,他能夠讀取到 (lambda (x) ...) 函數的內部變量 x 的值並且用來做計算,但是這個變量對閉包函數來說是其外部的。

閉包的概念即這個函數把他的外部給包了起來,這么說,當定義的時候,閉包函數的外部是某個函數,而當他作為高階函數返回給別人的時候,他就出到另外的環境了,函數的 Scope 變了但是他還能訪問到之前的那個外部環境,這就是因為他是把之前定義的地方的外部狀態給封閉打包了。

如果還是不理解閉的包是外部的包,請想象成你想辦法把房子打包了然后吃下去了,當你到別的地方的時候,你又還是住在你自己的房子里活動。

當然 OOP 里的類這種封裝實際上也有閉包,我們可以認為成員變量的 properties 就是內部變量,而 selector 就是閉包函數能夠給外部人一種手段訪問內部變量,當然這里的成員變量是 mutable 的,FP 里的是 immutable 的。

Curry-ing 就是一種多參數函數的閉包改寫方法,λx.λy.x+y 即是 (lambda (x y) (+ x y) 的閉包改寫。接下來我們會講 CPS 和 CPS 變換,這兩者的關系就和 閉包與柯里化 的關系差不多。


回調

閉包函數能夠把內部變量通過某些控制后暴露給外部,那么接下來講回調。Callback 實際上很常用,比如異步常常就是用 callback 實現的,callback 本質上是一種 CPS 模式。

算了不講了,回調很容易理解,就是異步編程里面另一個線程去執行的尾調用而已。

如果程序有連續的太多的異步調用,代碼里面就會引發回調地獄,這一點不好看,就像 Lisp 那種括號語言,偷到的代碼最后一頁肯定全是括號。


CPS 和 CPS 變換

CPS 是一種 style,式如其名,就是函數的調用參數里面有一個 continuation,而程序不會直接返回而是隨着這個 continuation 運行下去,所以說這是一個控制流上的 style。

CPS 變換則是完成這種風格轉換的變換,當然我們可以通過人工來變換,但是實際上熟悉 FP 中 everything is data 的概念,我們順理成章地可以通過操縱程序數據用程序完成 CPS 變換。

為了緩解枯燥 () 這里插播一個魔法知識點:

「CPST 就是 Gödel–Gentzen 變換的 Curry–Howard 像而已,這有什么難理解的?」 CPS 變換有什么作用? - Belleve的回答

這句話的理解可能得搞魔法的那群人才看得懂,這里 Curry-Howard Correspondence 是一個理論說命題的證明和程序是同構的,然后 Gödel–Gentzen 變換 不過是邏輯學里的一個定理:對於任意的經典邏輯下的證明,我們可以把它轉換為一個直覺主義邏輯下證明而不損失任何證明能力,對於經典邏輯和自覺邏輯的區別請有興趣的同學自行學習魔法。所以 CPS 變換的一個程序其實是哥德爾-根岑變換這個定理的證明通過 Curry-Howard 同像理論在程序集中的一個對應。

回來講 CPS,前面我們都是從尾調用的角度講的,這樣其實這個時候對 CPS 的印象已經很清楚了,應該就是一個函數指針的回調而已唄。下面再從非尾調用的函數的角度來看一下 CPS 的樣子。

對於尾遞歸本身是一個 \(O(n)\) 空間的遞歸程序,可以優化到 \(O(1)\) 並且還能避免數據過大導致的 Stack Overflow 問題,但是對於普通的遞歸來說,\(O(n)\) 的空間消耗的必須的,因為他一定要保存狀態回退/回溯。 如果能把遞歸轉換成尾調用的編程風格,即CPS 變換也就能優化一個棧溢出的問題,把棧空間消耗放到堆上去而已。

當然我們其實知道人工用 數據結構 + 循環 完全模擬遞歸調用也可以達到目的,比如下面的快速排序程序(頭條校招面試題):

    public void qsort(int[]nums, int l, int r){
        Deque<int[]> s = new ArrayDeque<int[]>();
        s.addLast(new int[]{l, r});
        int count = 0;
        while(!s.isEmpty()){
            int[] temp = s.pollFirst();
            if(temp[0]>=temp[1])continue;
            l = temp[0]; r = temp[1];
            int sep = patition(nums, l, r); //經典分區代碼略
            s.addLast(new int[]{l, sep});
            s.addLast(new int[]{sep+1, r});
        }
    }

樹形遞歸其實有點超綱了(其實只是為了復習一下題目),我們還是看回比較正常的遞歸吧:

 int sum(int n) {               int sum2(int n, int acc){     
     if(n<1) return 0;              if(n<1) return acc;       
     return sum(n-1) + n;           return sum2(n-1, acc + n);
 }                              }                             

sum 來說需要先計算出 sum(n-1) 才能計算出 sum,而 sum2 這種也不是 CPS 風格(這是依賴特定程序結構而人工才能改寫的),下面給出 CPS 風格的編程 (請回顧將函數結果作為參數持續運行下去):

#如果理不清請多看幾遍:
def sum_cps(n, c):
  if n == 0:
    return c(0)
  else:
    return sum_cps(n-1, lambda x: c(n + x))
sum_cps(10000, lambda x:x)

我們可以看見,這種情況下的 CPS 變換(盡管是人工) 是一種奇技淫巧,因為他不是用 棧/隊列 等數據結構來實現調用的模擬,而是用閉包來實現的,我們復習 FP 里面閉包是可以通過參數實現局部變量(成員對象放堆上)的(這里涉及 FP 語言的編譯器和解釋器,就和我們當時用編寫 scheme 解釋器那種,值得注意的是一般還涉及 GC),所以可以用尾遞歸形式配合編譯器把爆棧問題解決掉。

實際的運行好像就不算回溯的版了,而是一個類似自底向上的鏈式求解過程:sum 100 -> sum 99 ->... 而關鍵的棧部分或者說 acc 部分已經通過閉包的形式存在了這個 lambda 匿名函數中去了!

結論是 CPS 編程本質是基於閉包的無棧編程(當然其解釋器如何工作則另說)。

當然程序上的 CPS 變換 太過於復雜,我目前還是先略過,附上論文地址供將來學習魔法的時候異步回調學習。。。。Representing Control: A study of the CPS transformation 以及一個 PL 課程:PL


setjmp 與 longjmp

那么如果所有的函數都 CPS 變換后,就能用簡單的方法實現一個名為call/cc的在 FP 中實現控制流的函數,這個函數本身是無法用 FP 語言定義的,為了能夠理解 call/cc 與 CPS 變換的關系,我們想要知道 call/cc 干什么,在那之前我們先學習 C 語言中的一個簡化版的 call/cc

對這個的學習也是 C 語言異常控制的一個思路。但是 jmp 系列只能保存寄存器通過恢復PC來實現跳轉,共用一個棧,所以協程還是老實用 ucontext 好了(jmp 系列也能實現,到時候看一下),不過 CPS 的 style 對於實現 協程 yield 不是很友好嗎,畢竟協程就是為了用戶態實現 sequential 運行的迷你線程。

#include <setjmp.h>
int setjmp(jmp_buf env);
// 返回值:若直接調用則返回0,若從 longjmp 調用返回則返回非0值
void longjmp(jmp_buf env, int val);

jmp 系列函數內容特別簡單,甚至能讓人推出他的匯編實現。當然這里還有一些要點值得注意的,第一點,我們已經說了他是共享棧的,所以千萬不能讓某個函數 return 之后再跳過來,到時候由於 sp 指針對應的棧很有可能已經被新程序用過了,此時引用內存變量涉及編譯器對棧的使用,馬上會導致未定義行為

結論是如果想要實現協程,我們應當編寫 while 循環,或不使用棧上的變量而只使用堆的,或者人工編寫另一套棧,這就變成了無棧編程,反而更難受。ucontext 的結構體內容中就包含棧空間的指針,這個實現可能和內核的 signal handler 注冊的時候能指定棧的實現相關,想實現協程的可以再深入學習。不過感覺就是 6.s081 里用戶線程的 lab thread 那種感覺?我覺得很有道理,畢竟 xv6 里面 user proc 是不會分散到 multicore 上運行的,所以這個 lab thread 本質就是協程啊哈哈哈(對於協程的理解本文是采用構建不並行的最小化程序目的理解的,然后協程分為無棧協程和有棧協程),雖然目的是方便上面(user space)調度,但是協程實際上使用,也可以保留能並行的功能,但是我主要理解是依據協程是另一種意義上的基於 continuation 的控制流。

所以這里再附送一個 lab thread 里面的 thread 結構體代碼如下,也許這個就是 ucontext 的簡化版吧。

lab thread


call/cc

講完 setjmplongjmp ,再來講 lisp 里面的 call/cc。這個有點復雜,全程是 call with current continuation

continuation 的概念上文已經很熟悉了,這里再指定在 lisp 中的概念,continuation 是當 call/cc 被調用時創建的當前調用 call/cc 上下文中的 continuation。第二點是發生什么,具體來說就是 call/cc 打包當前的 continuation 然后傳給其參數(打包后類似與 jmp 系列中的 jmp_env),當這個 continuation 被調用的時候,其參數會被返回。下面看一個具體例子:

(+ 1 (call/cc (lambda (k) (k (+ 2 3)))))

可能你很難理解,我們拆開來分析吧!當前我們正處於 (call/cc ...) 的語境下,馬上可以分析出其 continuation 是:(+ 1 ...) 請留意這個 ...,因為他是我們調用 (continuation return-value)return-value 返回的地方(即取代 call/cc 的函數調用)!

然后我們再看 call/cc 的參數,這個東西將會被調用,即控制流轉到這個 lambda (k) (k (+ 2 3)) 函數中去,而 k 將會被 call/cc 傳 continuation 進來!其具體流程用 python 講解:

#原代碼等價於:
  f = lambda k: k(2+3)
  plus(1, call/cc(f))
#call/cc 執行時等價於:
  def continuation(x):
    plus(1, x)
    f(continuation)
#結果:
    f(continuation) 
  = continuation(2+3)
  = plus(1, 2+3)
  = 6           

在分析具體流程的時候是不是很容易有一種 CPS 的感覺?這種 CPS 的感覺實際上就是 continuation 作為了一個間接的參數實現了即我們本來要做一件事情,中間插一腳給 call/cc 調用的函數,之后再 continuation 回來到 call/cc 上,只不過 CPS 編程中吧 continuation 放到了參數里而已。我們可以發現如果寫程序的時候我們完全用 CPS 來寫,call/cc 的實現將輕而易舉(call/cc 此時本身也用 CPS 寫了)!比如下面這段 javascript 代碼(代碼來自知乎):

function callcc(f, k) {
  return f(k);
}

callcc(function(x) {return x(4 * 3);}, 
       function(y) {return 1 + y;});

所以這也解釋了為什么能實現 CPS 變換之后 call/cc 的實現就變簡單了(直接有 continuation 不用打包),因為 call/cc 就是 CPS 編程的不用參數版!

(define call/cc (lambda (f k) (f (lambda (v k0) (k v)) k)))

當然要完整學習 CPS 變換太長時間了了,我得留個坑,這個東西涉及 PL 的魔法,凡人避免走火入魔。


coroutine

本來感覺協程和本文的內容好像也沒有什么關系?非也,coroutine 可以把本來的多次回調變成一個連續的過程,協程就是 continuation 的帶調度管理器的版本,不如說是擴展化的 continuation。下面講解協程怎么去實現。

第一點是,我們希望協程是 sequential 運行的而不是 parallel 的,這樣才能有效避免使用各種並發控制手段如鎖,並且因為程序本身知道所有同步信息,能夠最大效率排列協程的運行,而不存在鎖與輪詢的浪費空轉

協程分有棧和無棧的,對於棧的處理這個我們上面講 jmp 的時候講過了。

我這里再提一個點,如果你想實現自己的協程庫,要考慮的處理棧的處理,還有一個關鍵是對阻塞 I/O 的處理。為什么要關心這個呢?這是因為我們用 協程 主要是網絡引用下寫的,所以必然涉及到 read 和 write 等阻塞式系統調用,這時候整個線程都會阻塞,我們必須解決讓協程掛起,一種方案是通過單獨的線程去完成阻塞調用,一種是 hook 系統調用。hook 的原理也很簡單,我們曾經學習過程序員自我修養鏈接裝載與庫,很容易想到通過鏈接時的同名函數覆蓋即可實現 hook,當然涉及hook中調用原來函數則要使用dlsym 去查詢 so,具體的很多內容我忘記了,我們之后會重新復習鏈接裝載與庫這本書(當然也配合APUE里面還是齊全的)再來編寫協程庫的博客。

I/O復用模式(事件驅動,Linux下的select、poll 和 epoll 負責將底層 socket 的時分(理論上是包封)復用給封裝出來)下本來是異步的,這個另說,接下來我們將會系統學習網絡編程和 Unix 高級編程。(待補充...)

建議學習的協程庫開源項目:libgo


總結

...(留個坑)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM