cocoflow - 併發中的流程控制 原 薦

異步回調:新時代的goto


     回調函數是指將函數(這裏的函數是泛指某一塊可執行代碼的引用,如C++的仿函數或Java中的接口和對象)作爲參數傳遞給另一個函數。回調函數不由函數的實現方直接調用,而是在特定的事件或條件發生時由另外的一方調用的,用於對該事件或條件進行響應。因爲可以把調用者被調用者分開,所以調用者不關心誰是被調用者。它只需知道存在一個具有特定原型和限制條件的被調用函數,以便該函數在處理相似事件的時候可以靈活的使用不同的方法。


      示例1,在C語言標準庫中的qsort快速排序,其函數原型如下:

void qsort( void *buf, size_t num, size_t size,
            int (*compare)(const void *, const void *) );

其中第四個參數 compare 就是回調函數,用於“描述”需要排序的數據的大小比較規則。


      示例2,在C語言標準庫中的signal信號處理,其函數原型如下:

/* The use of sighandler_t is a GNU extension */
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

其中第二個參數handler就是回調函數,用於“註冊”接收到某個信號時執行的內容。


      對比示例1和示例2。示例1中的回調函數是被同步調用的,調用者調用qsort函數,qsort函數調用compare函數,相當於調用者間接調了自己提供的回調函數,此類型的回調稱爲同步回調,調用者明確知道自己實現的回調函數是在什麼時候被調用。示例2中的回調函數是被異步調用的,調用者首先將handler函數傳給系統,系統記住這個函數,這稱爲註冊回調函數,然後當信號產生時由系統調用handler函數進行處理,此類型的回調稱爲異步回調,調用者無法知道自己實現的回調函數是在什麼時候被調用。可以明顯的看出,異步回調有一個註冊的過程,即註冊回調函數的函數本身不會直接(立即)調用回調函數。


      異步回調實際上是把某個(需要等待的)任務後需要執行的內容通過回調函數傳遞下去。在函數編程中有一種編程風格叫Continuation-passing style(簡稱CPS,延續傳遞風格),CPS是回調函數使用上的特例,形式上就是在函數的最後調用回調函數,這樣就好像把函數執行後的結果交給回調函數繼續運行,所以稱作延續傳遞風格。這裏爲何提到CPS呢,因爲CPS中流程控制被顯式傳遞給下一步操作,而大多數異步回調做的是相同的事,即完成X後(回調)執行Y,實質也就是干涉(控制)了流程。


      下面通過實際的C/C++代碼分析一下,異步回調是如何編程的。

A操作執行完成後等待X任務完成後執行B操作:

void callback() {
    B操作;
}

void task() {
    A操作;
    when_X_completed(callback);
}


這裏,B操作作爲等待X任務後需要執行的內容,被傳遞給了when_X_completed函數,即邏輯上的下一步被傳遞了,而when_X_completed函數的實現本身即符合CPS定義。


現在增加條件,A操作需要依賴數據m,B操作同樣需要依賴數據m,m的類型爲Mtype:

void callback(void *data) {
    Mtype *pm = (Mtype *)data;
    B操作;
}

void task() {
    Mtype m;
    A操作;
    when_X_completed(callback, &m);
}


這裏數據m的地址被傳遞進入了callback回調函數,由於when_X_completed的實現者無法知道其回調函數依賴的數據的類型(和個數),只能使用void*泛類型指針來處理, 這也是C/C++語言中回調函數原型幾乎都存在一個void*指針參數的原因。在同步回調中,上述的代碼能夠正常運行,但是在異步回調中上述的程序則是錯誤的,因爲異步回調中你無法知道什麼時候callback被調用了,若callback在task函數退出後才被調用,那麼此時m的地址已經是非法地址了(棧消亡)。這裏需要使用全局變量或堆變量來解決:

void callback(void *data) {
    Mtype *pm = (Mtype *)data;
    B操作;
    delete pm;
}

void task() {
    Mtype *pm = new Mtype();
    A操作;
    when_X_completed(callback, pm);
}


如果B操作依賴的數據不止一個,還得將多個數據組合成一個數據結構(如結構體),再將其傳遞進入when_X_completed函數。在更高級的語言中,解決回調函數中的數據依賴有更簡單的辦法——閉包,閉包(Closure)是詞法閉包(Lexical Closure)的簡稱,是引用了自由變量的函數。這個被引用的自由變量將和這個函數一同存在,即使已經離開了創造它的環境也不例外。閉包,簡單的說就是內部函數可以訪問外部函數的變量,即使外部函數已經退出了也不例外。支持閉包的Javascript語言中將是這麼實現的:

function task () {
    var m;
    A操作;
    when_X_completed(function () { //這是一個匿名函數
        B操作; //這裏直接訪問m變量
    });
}


現在再次增加條件,執行完B操作後需要再執行C操作,C操作也依賴數據m: 

void callback(void *data) {
    Mtype *pm = (Mtype *)data;
    B操作;
    C操作;
    delete pm;
}

void task() {
    Mtype *pm = new Mtype();
    A操作;
    when_X_completed(callback, pm);
}


現在再次增加條件,等待X任務完成和執行B操作只在滿足條件S的情況下才執行: 

void callback(void *data) {
    Mtype *pm = (Mtype *)data;
    B操作;
    C操作; //重複部分0
    delete pm; //重複部分1
}

void task() {
    Mtype *pm = new Mtype();
    A操作;
    if (條件S)
        when_X_completed(callback, pm);
    else {
        C操作; //重複部分0
        delete pm; //重複部分1
    }
}

這裏可以看到相同的代碼被分離在callback和task兩個函數中。


若等待X任務完成的過程可能失敗或錯誤呢?失敗或錯誤應該拋異常,但是這裏的when_X_completed只是註冊了等待X任務完成後執行的回調函數,真正執行X任務的地方是哪兒都不知道,如何拋異常呢?事實上異步回調模式是無法在錯誤時拋異常的,只能將錯誤信息通過參數傳遞給回調函數(注:如果回調函數無視了錯誤,可能會產生不可預知的行爲,但拋異常的情況下,無視異常則會讓程序直接core掉,顯然後者更容易寫出更健壯的程序)。若X任務可能錯誤,且錯誤時需要將B操作換成D操作: 

void callback(int ret, void *data) { //這裏只考慮了返回碼
    Mtype *pm = (Mtype *)data;
    if (ret == 0) //=0表示正確
        B操作;
    else
        D操作;
    C操作; //重複部分0
    delete pm; //重複部分1
}

void main_task() {
    Mtype *pm = new Mtype();
    A操作;
    if (條件S)
        when_X_completed(callback, pm);
    else {
        C操作; //重複部分0
        delete pm; //重複部分1
    }
}


現在最後一次增加條件,無論是否滿足條件S,都需要等待Y任務完成後並執行E操作,等待Y任務和等待X任務之間可以同時進行(即併發),兩者都完成後(或不滿足條件S則只需要Y任務和E操作完成後)再繼續剩下的流程: 

struct Session {
    Mtype m;
    int flag; //0x1表示任務X完成,0x2表示任務Y完成
};

void callback_x(int ret, void *data) {
    Session ps = (Session *)data;
    if (ret == 0) //=0表示正確
        B操作;
    else
        D操作;
    ps->flag |= 0x1; //任務X置完成狀態
    if (ps->flag == (0x1 | 0x2)) {
        C操作; //重複部分0
        delete ps; //重複部分1
    }
}

void callback_y(void *data) {
    Session ps = (Session *)data;
    E操作;
    ps->flag |= 0x2; //任務Y置完成狀態
    if (ps->flag == (0x1 | 0x2)) {
        C操作; //重複部分0
        delete ps; //重複部分1
    }
}

void task() {
    Session *ps = new Session();
    ps->flag = 0x0; //狀態標誌初始化
    A操作;
    if (條件S)
        when_X_completed(callback_x, ps);
    else
        ps->flag |= 0x1; //由於這裏不需要執行X任務,直接置完成位
    when_Y_completed(callback_y, ps);
}


這裏使用了flag表示其運行過程中的狀態機(在異步編程中普遍使用的機制)。 

通過上面的代碼演變可以看出異步回調函數的程序是怎樣一步一步變複雜難以理解的。實際上整個流程的控制信息被深深地耦合進了各個函數中,單看callback_x函數的實現,其中需要判斷任務Y是否完成(即處理X的時候需要知曉Y的存在),當然這裏可以將其判斷完成後執行C操作以及刪除Session的代碼再抽象爲一個函數:

struct Session {
    Mtype m;
    int flag; //0x1表示任務X完成,0x2表示任務Y完成
};

void last_doing(Session *ps) {
    if (ps->flag == (0x1 | 0x2)) {
        C操作;
        delete ps;
    }
}

void callback_x(int ret, void *data) {
    Session ps = (Session *)data;
    if (ret == 0) //=0表示正確
        B操作;
    else
        D操作;
    ps->flag |= 0x1; //任務X置完成狀態
    last_doing(ps);
}

void callback_y(void *data) {
    Session ps = (Session *)data;
    E操作;
    ps->flag |= 0x2; //任務Y置完成狀態
    last_doing(ps);
}

void task() {
    Session *ps = new Session();
    ps->flag = 0x0; //狀態標誌初始化
    A操作;
    if (條件S)
        when_X_completed(callback_x, ps);
    else
        ps->flag |= 0x1; //由於這裏不需要執行X任務,直接置完成位
    when_Y_completed(callback_y, ps);
}


由於異步回調函數將數據和邏輯分解得支離破碎,使得數據結構和程序流程被耦合進各個函數中,並顯示地使用狀態機來表示狀態,讓維護和理解它們變得異常艱難。


      goto是一條可以在許多計算機編程語言中找到的語句,當執行這條語句的時候,它將控制流程無條件地跳轉到另一條語句,它的顯著特徵之一就是人工通過狀態機表達流程。正因爲異步回調和goto都具有深度耦合控制流程和人工維護狀態機的特點,以及它們的代碼都是難以閱讀的,它們都強大卻難以駕馭,所以筆者這裏斗膽將異步回調稱爲了新時代的goto。同樣,和大家對goto的批判一樣,筆者也是反對亂用/濫用異步回調函數的。



異步模式與併發


      異步編程模式是用來解決併發的。由於等待任務完成這個過程可能是阻塞的,如等待IO操作或等待網絡事件。使用異步模式,可以將等待任務完成後執行的內容通過回調函數註冊到事件隊列中,並立即執行後面的流程,直到全部可執行的流程執行完畢,再集中性地等待相應阻塞事件執行完成(大多通過IO複用機制實現),並執行相應的回調函數。

      回看上小節中的整個過程,用最簡單明瞭的過程式邏輯描述如下:

void task() {
    Mtype m;
    A操作;
    if (條件S) {
        await( all_of( X任務, Y任務 ) );
        if (任務X.ret == 0) //=0表示執行成功
            B操作;
        else
            D操作;
        E操作;
    } else {
        await( Y任務 );
        E操作;
    }
    C操作;
}


其中的,await可以理解爲等待某任務執行完畢,all_of可以理解爲多個任務組合成的新的任務(多個任務併發執行且都完成纔算完成)。由於要求並未說明X任務完成後執行的B/D操作和Y任務完成後執行的E操作的順序要求,這裏直接讓E操作後執行。


      雖然goto的功能很強大,但是人們長久的經驗總結,更推薦使用switch/while/for/continue/break/throw/try/catch等來替代goto。(goto不是目的,循環和分支等纔是目的,goto只是完成目的的方式)

      那麼對於異步回調,有其他方式可以替代它並寫出更簡單明瞭的代碼麼?答案是肯定的。筆者根據常見的異步回調函數用法分析,發現其用異步回調函數的目的大多時是爲了表達

  1. 不等待某任務執行完成。
  2. 等待某任務執行完成。
  3. 等待多個任務中的全部執行完成(其間併發)。
  4. 等待多個任務中的任一執行完成(其間併發)。


      那麼根據上述的總結,來抽象一下:

  • 任務:併發流程中最小的控制單元,一段邏輯上可能阻塞的過程視爲任務。(對阻塞及阻塞的相關行爲的抽象)
  • start原語:異步地執行一個任務,該過程不阻塞當前任務的執行,目標任務啓動後立即繼續。(上段中的目的1
  • await原語:同步地執行一個任務,該過程會阻塞當前任務的執行,等待目標任務執行完成後繼續。(上段中的目的2
  • all_of原語:將多個任務組合爲一個新的任務,全部任務執行完成後新的任務視爲執行完成。(await+all_of即是上段中的目的3
  • any_of原語:將多個任務組合爲一個新的任務,任一任務執行完成後新的任務視爲執行完成,同時會取消掉其他未執行完成的任務。(await+any_of即是上段中的目的4


      那麼基於這個模式下的代碼應該如何表達上小節中的程序呢?本小節前文中的最簡單明瞭的過程式邏輯描述就是它的代碼了。


      這裏使用該模式表達一下常見的服務模型,例如這是一個UDP聚合類服務,對於每個請求,你需要向底層併發查詢兩種數據,再聚合後返回(或超時後返回):

task business {
    udp0.send;
    udp1.send;
    await (
        any_of (
            all_of (
                udp0.recv,
                udp1.recv
            ),
            sleep(x ms)
        )
    );
    if (完成的是任務0)
        udp.send; //回成功包
    else
        udp.send; //回超時包
}

task main {
    while(true) {
        await( udp.recv );
        start( business );
    }
}


其中的udp.recv和sleep是原子任務(即不可再分的任務),而business和main則是組合任務(由一系列任務組合而成)。


      可以看出startawaitall_ofany_of它們是如何配合起來描述併發流程的。在這裏任務僅僅表達了邏輯,任務本身並不知道也不關心自己在什麼併發環境下被執行。而原語纔是表達(併發)控制的,再和固有的if、while等配合,控制了整個流程。當然這裏,程序=邏輯+控制,邏輯本身只關注自己,不需要關注外層如何使用自己,比如business可以被await(business),也可以被start(business),使得併發中的控制和邏輯解耦。(注:這裏的business不能使用函數來封裝,只能使用任務來封裝,因爲函數本身就限定了只有調用和被調用的關係,且函數調用本身就是等待函數執行完畢,實際上是耦合了await+任務。)它的數據和邏輯是完整的(不會被分解),服務的邏輯過程是由語言的固有語法(分支和循環)表達出來的,不需要單獨使用狀態機表示它的狀態,狀態信息固有地存在於分支和循環中。這樣任務的實現着只需要關注自己的邏輯,不需要知道自己是否需要和另外的任務併發啊等等,併發間的層次清晰明瞭,讓代碼變得容易閱讀和複用。


      異步編程的目的是解決併發,異步回調函數只是實現這一過程的手段,它並非神聖不可替代的。



異步模式與運行態切換


      在上一小節中已經抽象出了新的模式,但是如何實現呢?(這裏的討論只針對C/C++語言)


      就異步回調函數的實現而言,它只是將某個(需要等待的)任務後需要執行的內容通過回調函數傳遞下去,然後執行其他流程,以到達某個(需要等待的)任務不直接阻塞掉整個線程的目的。那麼如果有辦法保持住當前的運行環境,再切換到其他運行環境,待將來某個(需要等待的)任務完成後再繼續當前的運行環境,則也能同樣達到某個(需要等待的)任務不直接阻塞掉整個線程的目的。對於C/C++語言,一段代碼能夠正常執行的條件僅僅是正確的數據正確的指令,當前運行環境的數據主要在自身的棧中(也有部分數據在堆中或靜態全局區,但這部分數據不受影響)。在X86體系的CPU中,RBP寄存器(32位下是EBP)是棧幀的幀指針,RSP寄存器(32位下是ESP)是棧幀的棧地址,它們兩者即表示了棧,而下一條指令則保存在RIP寄存器(32位下是EIP)中,因此只要寄存器歸位,就意味着數據和指令歸位,則當前運行態就繼續往下執行,完全不知道自己被中斷過。因此保持住當前的運行環境實際上就是保存當前寄存器值(注:棧不能被修改),切換到其他運行環境實際上就是替換其寄存器值,繼續當前的運行環境實際上就是恢復當前運行環境的寄存器值。當然運行態切換不需要寫如此底層的彙編代碼,Linux下glibc中的ucontext和Windows下的Fiber都是基於上述的封裝,直接用它們就可以了。


      有了運行態切換機制,但如何知曉什麼時候應該切換回來呢(切換走當然是觸發阻塞操作的時候)?這裏還需要一個異步事件隊列,將真正的阻塞操作委託給異步事件隊列,那麼對於任意的阻塞操作,可以等價替換爲:註冊事件完成後的回調函數釋放當前控制權待事件完成時觸發回調重獲控制權。整個過程如下圖:


其中,釋放當前控制權是從當前的運行態X切走,這裏往哪兒切不由任務本身決定,而由觸發任務的原語決定(任務本身只表達邏輯,控制信息交給原語負責)。重獲控制權即是切換回當前的運行態X。


      有了上面的等價替換規則後,再爲每個任務增加兩個描述字段即可完成全部原語了。這兩個字段是:

  • block_to阻塞後釋放往哪兒切
  • finish_to完成後往哪兒切


      對於start原語,其邏輯是start的目標任務不阻塞當前任務執行。那麼對於start原語,將其目標任務的block_to指向自己(即阻塞後立即切換回來讓當前任務繼續執行),finish_to指向事件隊列(start只是啓動目標任務,然後兩者就無任何關聯了),然後再切換至目標任務運行態即可。start原語的執行過程如下圖:

      對於await原語,其邏輯是await的目標任務阻塞當前任務執行,且目標任務完成後繼續當前執行當前任務。那麼對於await原語,將目標任務的block_to指向事件隊列(即釋放掉阻塞掉當前運行態),finish_to指向自己(即完成後繼續執行當前運行態),然後再切換至目標任務運行態即可(注:await的意義實際上和函數調用相同,都是等待任務/函數執行完成,真正的實現對於await可以採用複用運行態來優化,等價於函數調用的過程)。await原語的執行過程如下圖:

對於all_of原語,其邏輯是all_of的目標任務間並行,且全部執行完成,all_of本身才執行完成。那麼對於all_of原語,將全部目標任務的block_tofinish_to都指向自己,然後再依次切換至目標任務的運行態以啓動它們,並且全部任務啓動後,循環檢測是否全部執行完畢,若未執行完畢,則視爲被阻塞,即釋放自己,直至全部目標任務執行完成。all_of原語的執行過程如下圖:

(注:這裏假定了任務X先執行完成)


對於any_of原語,其邏輯是any_of的目標任務間並行,且任一執行完成,any_of本身就執行完成。那麼對於any_of原語,將全部目標任務的block_tofinish_to都指向自己,然後再依次切換至目標任務的運行態以啓動它們,並且全部任務啓動後,循環檢測是否任一執行完畢,若均未執行完畢,則視爲被阻塞,即釋放自己,直至任一目標任務執行完成。any_of原語的執行過程如下圖:

(注:這裏假定了任務X先執行完成)


      上面的原語邏輯描述和示意圖採用的是最簡單的情況,實際的處理會比上面的描述略複雜一些,但其本質就是上面描述的那些。談了這麼多,筆者並非紙上談兵,下面則來介紹下筆者自己實現的基於上面模式的框架。


      cocoflow:全稱Concurrency Control Flow,即併發流程控制,是筆者實現的一個開源框架。它是一個基於協程和libuv的C++框架,僅通過startawaitall_ofany_of控制流程。目前被託管在github上(https://github.com/chishaxie/cocoflow)。其協程就是前面提到的運行態切換了(也就是Linux下的ucontext和Windows下的Fiber)。而libuv則是一個高性能跨平臺且功能強大的異步事件庫(NodeJS的核心事件庫)。


      在C++代碼形態上,任何和原語分別如下:

  • 任務:抽象類task(子類需要實現void run();函數,即業務邏輯)
  • start原語:int start(task* target);函數
  • await原語:int await(task& target);函數
  • all_of原語:抽象類task的子類all_of
  • any_of原語:抽象類task的子類any_of


      對於cocoflow,最核心的部分就是上面的任務和控制原語了,但實際情況是,只有原語,無法描述真正的業務。當然cocoflow也會提供一些功能API(即具體實現的某些task類):

  • sleep:休息,休息x毫秒
  • sync:同步,類似信號。一方等待同步(信號),另一發發送。
  • udp::send:發送udp數據包
  • udp::recv:接收udp數據包
  • udp::recv_by_seq:接收指定序列號的udp數據包
  • tcp::accept:接受連接建立請求
  • tcp::connect:發起建立連接請求
  • tcp::send:發送tcp數據包
  • tcp::recv:接收tcp數據包
  • tcp::recv_till:接收tcp數據包直到匹配某模式串/Buffer填滿
  • tcp::recv_by_seq:接收指定序列號的tcp數據包


      有了控制原語和功能API,則就可以寫出優雅又實用的代碼了。更詳盡的文檔請參考:cocoflow wiki頁

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章