ES6 Generators並發


  ES6 Generators系列:

  1. ES6 Generators基本概念
  2. 深入研究ES6 Generators
  3. ES6 Generators的異步應用
  4. ES6 Generators並發

  如果你已經讀過這個系列的前三篇文章,那么你肯定對ES6 generators非常了解了。希望你能從中有所收獲並讓generator發揮它真正的作用。最后我們要探討的這個主題可能會讓你血脈噴張,讓你絞盡腦汁(說實話,寫這篇文章讓我很費腦子)。花點時間看下文章中的這些例子,相信對你還是很有幫助的。在學習上的投資會讓你將來受益無窮。我完全相信,在未來,JS中那些復雜的異步能力將起源於我這里的一些想法。

 

CSP(Communicating Sequential Processes)

  首先,我寫這一系列文章完全是受Nolen @swannodette出色工作的啟發。說真的,他寫的所有文章都值得去讀一讀。我這里有一些鏈接可以分享給你:

  好了,讓我們正式開始對這個主題的探討。我不是一個從具有Clojure(Clojure是一種運行在Java平台上的 Lisp 方言)背景轉投到JS陣營的程序員,而且我也沒有任何Go或者ClojureScript的經驗。我發現自己在讀這些文章的時候很快就會失去興趣,因此我不得不做很多的實驗並從中了解到一些有用的東西。

  在這個過程中,我覺得我已經有了一些相同的思想,並追求同樣的目標,而這些都源自於一個不那么古板的思維方式。

  我嘗試創建了一個更簡單的Go風格的CSP(以及ClojureScript core.async)APIs,同時我希望能保留大部分的底層功能。也許有大神會看到我文章中遺漏的地方,這完全有可能。如果真是這樣的話,我希望我的探索能夠得到進一步的發展和演變,而我也將和大家一起來分享這個過程!

 

詳解CSP原理(一點點)

  到底什么是CSP?說它是"communicating","Sequential","processes"到底是什么意思呢?

  首先,CSP一詞源自於Tony Hoare所著的“Communicating Sequential Processes”一書。里面全是有關CS的理論,如果你對學術方面的東西感興趣的話,這本書絕對值得一讀。我決不打算以一種讓人難以理解的,深奧的,計算機科學的方式來闡述這個主題,而是會以一種輕松的非正式的方式來進行。

  那我們就從"Sequential"開始吧!這部分你應該已經很熟悉了。這是另外一種談論有關單線程和ES6 generators異步風格代碼的方式。我們來回憶一下generators的語法:

function *main() {
    var x = yield 1;
    var y = yield x;
    var z = yield (y * 2);
}

  上面代碼中的每一條語句都會按順序一個一個地執行。Yield關鍵字標明了代碼中被阻塞的點(只能被generator函數自己阻塞,外部代碼不能阻塞generator函數的執行),但是不會改變*main()函數中代碼的執行順序。這段代碼很簡單!

  接下來我們來討論一下"processes"。這個是什么呢?

  基本上,generator函數有點像一個虛擬的"process",它是我們程序的一個獨立的部分,如果JavaScript允許,它完全可以與程序的其它部分並行執行。這聽起來似乎有點兒荒唐!如果generator函數訪問共享內存(即,如果它訪問除了自己內部定義的局部變量之外的“自由變量”),那么它就不是一個獨立的部分。現在我們假設有一個不訪問外部變量的generator函數(在FP(Functional Programming函數式編程)的理論中我們將它稱之為一個"combinator"),因此從理論上來說它可以在自己的process中運行,或者說作為自己的process來運行。

  但是我們說的是"processes",注意這個單詞用的是復數,這是因為會存在兩個或多個process在同一時間運行。換句話說,兩個或多個generators函數會被放到一起來協同工作,通常是為了完成一項較大的任務。

  為什么要用多個單獨的generator函數,而不是把它們都放到一個generator函數里呢?一個最重要的原因就是:功能和關注點的分離。對於一個任務XYZ來說,如果你將它分解成子任務X,Y和Z,那么在每個子任務自己的generator函數中來實現功能將會使代碼更容易理解和維護。這和將函數XYZ()拆分成X()Y(),和Z(),然后在X()中調用Y(),在Y()中調用Z()是一樣的道理。我們將函數分解成一個個獨立的子函數,降低代碼的耦合度,從而使程序更加容易維護。

對於多個generators函數來說我們也可以做到這一點

  這就要說到"communicating"了。這個又是什么呢?就是合作。如果我們將多個generators函數放在一些協同工作,它們彼此之間需要一個通信信道(不僅僅是訪問共享的作用域,而是一個真正的可以被它們訪問的獨占式共享通信信道)。這個通信信道是什么呢?不管你發送什么內容(數字,字符串等),事實上你都不需要通過信道發送消息來進行通信。通信會像合作那樣簡單,就像將程序的控制權從一個地方轉移到另外一個地方。

  為什么需要轉移控制?這主要是因為JS是單線程的,意思是說在任意給定的一個時間片段內只會有一個程序在運行,而其它程序都處在暫停狀態。也就是說其它程序都處在它們各自任務的中間狀態,不過只是被暫停執行,必要時會恢復並繼續運行。

  任意獨立的"processes"之間可以神奇地進行通信和合作,這聽起來有點不靠譜。這種解耦的想法是好的,但是有點不切實際。相反,似乎任何一個成功的CSP的實現都是對那些問題領域中已存在的、眾所周知的邏輯集的有意分解,其中每個部分都被特殊設計過從而使得各部分之間都能良好工作。

  或許我的理解完全是錯的,但是我還沒有看到任何一個切實可行的方法,能夠讓兩個隨機給定的generator函數可以以某種方式輕易地聚合在一起形成CSP對。它們都需要被設計成能夠與其它部分一起工作,需要遵照彼此間的通信協議等等。

 

JS中的CSP

  在將CSP的理論應用到JS中,有一些非常有趣的探索。前面提到的David Nolen,他有幾個很有趣的項目,包括Om,以及core.asyncKoa庫(node.js)主要通過它的use(..)方法體現了這一點。而另外一個對core.async/Go CSP API十分忠實的庫是js-csp

  你確實應該去看看這些偉大的項目,看看其中的各種方法和例子,了解它們是如何在JS中實現CSP的。

 

異步的runner(..):設計CSP

  因為我一直在努力探索將並行的CSP模式應用到我自己的JS代碼中,所以對於使用CSP來擴展我自己的異步流程控制庫asynquence來說就是一件順理成章的事。我寫過的runner(..)插件(看上一篇文章:ES6 Generators的異步應用)就是用來處理generators函數的異步運行的,我發現它可以很容易被擴展用來處理多generators函數在同一時間運行,就像CSP的方式那樣

  我要解決的第一個設計問題是:如何才能知道哪個generator函數將獲得下一個控制權?

  要解決各個generators函數之間的消息或控制權的傳遞,每個generator函數都必須擁有一個能讓其它generators函數知道的ID,這看起來似乎過於笨拙。經過各種嘗試,我設定了一個簡單的循環調度方法。如果你匹配了三個generators函數A,B和C,那么A將先獲得控制權,當A yield時B將接管A的控制權,然后當B yield時C將接管B,然后又是A,以此類推。

  但是如何才能實際轉移generator函數的控制權呢?應該有一個顯式的API嗎?我再次進行了各種嘗試,然后設定了一個更加隱式的方法,看起來和Koa有點類似(完全是以外):每個generator函數都獲得一個共享"token"的引用,當yield時就表示要將控制權進行轉移。

  另一個問題是消息通道應該長什么樣。一種是非常正式的通信API如core.async和js-csp(put(..)take(..))。但是在我經過各種嘗試之后,我比較傾向於另一種不太正式的方法(甚至都談不上API,而只是一個共享的數據結構,例如數組),它看起來似乎是比較靠譜的。

  我決定使用數組(稱之為消息),你可以根據需要決定如何填充和清空數組的內容。你可以push()消息到數組中,從數組中pop()消息,按照約定將不同的消息存放到數組中特定的位置,並在這些位置存放更復雜的數據結構等。

  我的疑惑是有些任務需要傳遞簡單的消息,而有些則需要傳遞復雜的消息,因此不要在一些簡單的情況下強制這種復雜度,我選擇不拘泥於消息通道的形式而使用數組(除數組本身外這里沒有任何API)。在某些情況下它很容易在額外的形式上對消息傳遞機制進行分層,這對我們來說很有用(參見下面的狀態機示例)。

  最終,我發現這些generator "processes"仍然得益於那些獨立的generators可以使用的異步功能。也就是說,如果不yield控制token,而yield一個Promise(或者一個異步隊列),則runner(..)的確會暫停以等待返回值,但不會轉移控制權,它會將結果返回給當前的process(generator)而保留控制權。

  最后一點也許是最有爭議或與本文中其它庫差別最大的(如果我解釋正確的話)。也許真正的CSP對這些方法不屑一顧,但是我發現我的選擇還是很有用的。

 

一個愚蠢的FooBar示例

  好了,理論的東西講得差不多了。我們來看看具體的代碼:

// 注意:為了簡潔,省略了虛構的`multBy20(..)`和`addTo2(..)`異步數學函數

function *foo(token) {
    // 從通道的頂部獲取消息
    var value = token.messages.pop(); // 2

    // 將另一個消息存入通道
    // `multBy20(..)`是一個promise-generating函數,它會延遲返回給定值乘以`20`的計算結果
    token.messages.push( yield multBy20( value ) );

    // 轉移控制權
    yield token;

    // 從CSP運行中的最后的消息
    yield "meaning of life: " + token.messages[0];
}

function *bar(token) {
    // 從通道的頂部獲取消息
    var value = token.messages.pop(); // 40

    // 將另一個消息存入通道
    // `addTo2(..)` 是一個promise-generating函數,它會延遲返回給定值加上`2`的計算結果
    token.messages.push( yield addTo2( value ) );

    // 轉移控制權
    yield token;
}

  上面的代碼中有兩個generator "processes",*foo()*bar()。它們都接收並處理一個令牌(當然,如果你願意你可以隨意叫什么都行)。令牌上的屬性messages就是我們的共享消息通道,當CSP運行時它會獲取初始化傳入的消息值進行填充(后面會講到)。

  yield token顯式地將控制權轉移到“下一個”generator函數(循環順序)。但是,yield multBy20(value)yield addTo2(value)都是yield一個promises(從這兩個虛構的延遲計算函數中返回的),這表示generator函數此時是處於暫停狀態直到promise完成。一旦promise完成,當前處於控制中的generator函數會恢復並繼續運行。

  無論最終yield會返回什么,上面的例子中yield返回的是一個表達式,都表示我們的CSP運行完成的消息(見下文)。

  現在我們有兩個CSP process generators,我們來看看如何運行它們?使用asynquence:

// 開始一個sequence,初始message的值是2
ASQ( 2 )

// 將兩個CSP processes進行配對一起運行
.runner(
    foo,
    bar
)

// 無論接收到的message是什么,都將它傳入sequence中的下一步
.val( function(msg){
    console.log( msg ); // 最終返回42
} );

  這只是一個很簡單的例子,但我覺得它能很好地用來解釋上面的這些概念。你可以嘗試一下(試着改變一些值),這有助於你理解這些概念並自己動手編寫代碼!

 

另一個例子Toy Demo

  讓我們來看一個經典的CSP例子,但只是從我們目前已有的一些簡單的發現開始,而不是從我們通常所說的純粹學術的角度來展開討論。

  Ping-pong。一個很有趣的游戲,對嗎?也是我最喜歡的運動。

  讓我們來想象一下你已經完成了這個乒乓球游戲的代碼,你通過一個循環來運行游戲,然后有兩部分代碼(例如在ifswitch語句中的分支),每一部分代表一個對應的玩家。代碼運行正常,你的游戲運行起來就像是一個乒乓球冠軍!

  但是按照我們上面討論過的,CSP在這里起到了什么樣的作用呢?就是功能和關注點的分離。那么具體到我們的乒乓球游戲中,這個分離指的就是兩個不同的玩家

  那么,我們可以在一個非常高的層面上用兩個"processes"(generators)來模擬我們的游戲,每個玩家一個"process"。當我們實現代碼細節的時候,我們會發現在兩個玩家之家存在控制的切換,我們稱之為"glue code"(膠水代碼(譯:在計算機編程領域,膠水代碼也叫粘合代碼,用途是粘合那些可能不兼容的代碼。可以使用與膠合在一起的代碼相同的語言編寫,也可以用單獨的膠水語言編寫。膠水代碼不實現程序要求的任何功能,它通常出現在代碼中,使現有的庫或者程序在外部函數接口(如Java本地接口)中進行互操作。膠水代碼在快速原型開發環境中非常高效,可以讓幾個組件被快速集成到單個語言或者框架中。)),這個任務本身可能需要第三個generator的代碼,我們可以將它模擬成游戲的裁判

  我們打算跳過各種特定領域的問題,如計分、游戲機制、物理原理、游戲策略、人工智能、操作控制等。這里我們唯一需要關心的部分就是模擬打乒乓球的往復過程(這實際上也代表了我們CSP的控制轉移)。

  想看demo的話可以在這里運行(注意:在支持ES6 JavaScript的最新版的FireFox nightly或Chrome中查看generators是如何工作的)。現在,讓我們一起來看看代碼。首先,來看看asynquence sequence長什么樣?

ASQ(
    ["ping","pong"], // 玩家姓名
    { hits: 0 } //
)
.runner(
    referee,
    player,
    player
)
.val( function(msg){
    message( "referee", msg );

  我們初始化了一個messages sequence:["ping", "pong"]{hits: 0}。一會兒會用到。然后,我們設置了一個包含3個processes運行的CSP(相互協同工作):一個*referee()和兩個*player()實例。在游戲結束時最終的message會被傳遞給sequence中的下一步,作為referee的輸出message。下面是referee的實現代碼:

function *referee(table){
    var alarm = false;

    // referee通過秒表(10秒)為游戲設置了一個計時器
    setTimeout( function(){ alarm = true; }, 10000 );

    // 當計時器警報響起時游戲停止
    while (!alarm) {
        // 玩家繼續游戲
        yield table;
    }

    // 通知玩家游戲已結束
    table.messages[2] = "CLOSED";

    // 裁判宣布時間到了
    yield "Time's up!";
}
} );

  這里我們用table來模擬控制令牌以解決我們上面說的那些特定領域的問題,這樣就能很好地來描述當一個玩家將球打回去的時候控制權被yield給另一個玩家。*referee()中的while循環表示只要秒表沒有停,程序就會一直yield table(將控制權轉移給另一個玩家)。當計時器結束時退出while循環,referee將會接管控制權並宣布"Time's up!"游戲結束了。

  再來看看*player() generator的實現代碼(我們使用兩個實例):

function *player(table) {
    var name = table.messages[0].shift();
    var ball = table.messages[1];

    while (table.messages[2] !== "CLOSED") {
        // 擊球
        ball.hits++;
        message( name, ball.hits );

        // 模擬將球打回給另一個玩家中間的延遲
        yield ASQ.after( 500 );

        // 游戲繼續?
        if (table.messages[2] !== "CLOSED") {
            // 球現在回到另一個玩家那里
            yield table;
        }
    }

    message( name, "Game over!" );
}

  第一個玩家將他的名字從message數組的第一個元素中移除("ping"),然后第二個玩家取他的名字("pong"),以便他們都能正確地識別自己(譯:注意這里是兩個*player()的實例,在兩個不同的實例中,通過table.messages[0].shift()可以獲取各自不同的玩家名字)。同時兩個玩家都保持對共享球的引用(使用hits計數器)。

  當玩家還沒有聽到裁判說結束,就“擊球”並累加計數器(並輸出一個message來通知它),然后等待500毫秒(假設球以光速運行不占用任何時間)。如果游戲還在繼續,他們就yield table到另一個玩家那里。就是這樣。

  在這里可以查看完整代碼,從而了解代碼的各部分是如何工作的。

 

狀態機:Generator協同程序

  最后一個例子:將一個狀態機定義為由一個簡單的helper驅動的一組generator協同程序。Demo(注意:在支持ES6 JavaScript的最新版的FireFox nightly或Chrome中查看generators是如何工作的)。

  首先,我們定義一個helper來控制有限的狀態處理程序。

function state(val,handler) {
    // 管理狀態的協同處理程序(包裝器)
    return function*(token) {
        // 狀態轉換處理程序
        function transition(to) {
            token.messages[0] = to;
        }

        // 默認初始狀態(如果還沒有設置)
        if (token.messages.length < 1) {
            token.messages[0] = val;
        }

        // 繼續運行直到最終的狀態為true
        while (token.messages[0] !== false) {
            // 判斷當前狀態是否和處理程序匹配
            if (token.messages[0] === val) {
                // 委托給狀態處理程序
                yield *handler( transition );
            }

            // 將控制權轉移給另一個狀態處理程序
            if (token.messages[0] !== false) {
                yield token;
            }
        }
    };
}

  state(..) helper為特定的狀態值創建了一個delegating-generator包裝器,這個包裝器會自動運行狀態機,並在每個狀態切換時轉移控制權。

  依照慣例,我決定使用共享token.messages[0]的位置來保存我們狀態機的當前狀態。這意味着你可以通過從序列中前一步傳入的message來設定初始狀態。但是如果沒有傳入初始值的話,我們會簡單地將第一個狀態作為默認的初始值。同樣,依照慣例,最終的狀態會被假設為false。這很容易修改以適合你自己的需要。

  狀態值可以是任何你想要的值:numbersstrings等。只要該值可以被===運算符嚴格測試通過,你就可以使用它作為你的狀態。

  在下面的示例中,我展示了一個狀態機,它可以按照特定的順序在四個數值狀態間進行轉換:1->4->3->2。為了演示,這里使用了一個計數器,因此可以實現多次循環轉換。當我們的generator狀態機到達最終狀態時(false),asynquence序列就會像你所期望的那樣移動到下一步。

// 計數器(僅用作演示)
var counter = 0;

ASQ( /* 可選:初始狀態值 */ )

// 運行狀態機,轉換順序:1 -> 4 -> 3 -> 2
.runner(

    // 狀態`1`處理程序
    state( 1, function*(transition){
        console.log( "in state 1" );
        yield ASQ.after( 1000 ); // 暫停1s
        yield transition( 4 ); // 跳到狀態`4`
    } ),

    // 狀態`2`處理程序
    state( 2, function*(transition){
        console.log( "in state 2" );
        yield ASQ.after( 1000 ); // 暫停1s

        // 僅用作演示,在狀態循環中保持運行
        if (++counter < 2) {
            yield transition( 1 ); // 跳轉到狀態`1`
        }
        // 全部完成!
        else {
            yield "That's all folks!";
            yield transition( false ); // 跳轉到最終狀態
        }
    } ),

    // 狀態`3`處理程序
    state( 3, function*(transition){
        console.log( "in state 3" );
        yield ASQ.after( 1000 ); // 暫停1s
        yield transition( 2 ); // 跳轉到狀態`2`
    } ),

    // 狀態`4`處理程序
    state( 4, function*(transition){
        console.log( "in state 4" );
        yield ASQ.after( 1000 ); // 暫停1s
        yield transition( 3 ); // 跳轉到狀態`3`
    } )

)

// 狀態機完成,移動到下一步
.val(function(msg){
    console.log( msg );
});

  應該很容易地跟蹤上面的代碼來查看到底發生了什么。yield ASQ.after(1000)顯示了這些generators可以根據需要做任何類型的基於promise/sequence的異步工作,就像我們在前面所看到的一樣。yield transition(...)表示如何轉換到一個新的狀態。上面代碼中的state(..) helper完成了處理yield* delegation和狀態轉換的主要工作,然后整個程序的主要流程看起來十分簡單,表述也很清晰流暢。

 

總結

  CSP的關鍵是將兩個或更多的generator "processes"連接在一起,給它們一個共享的通信信道,以及一種可以在彼此間傳輸控制的方法。

  JS中有很多的庫都或多或少地采用了相當正式的方法來與Go和Clojure/ClojureScript APIs或語義相匹配。這些庫的背后都有着非常棒的開發者,對於進一步探索CSP來說他們都是非常好的資源。

  asynquence試圖采用一種不太正式而又希望仍然能夠保留主要結構的方法。如果沒有別的 ,asynquence的runner(..) 可以作為你實驗和學習CSP-like generators的入門。

  最好的部分是asynquence CSP與其它異步功能(promises,generators,流程控制等)在一起工作。如此一來,你便可以掌控一切,使用任何你手頭上合適的工具來完成任務,而所有的這一切都只在一個小小的lib中。

  現在我們已經在這四篇文章中詳細探討了generators,我希望你能夠從中受益並獲得靈感以探索如何革新自己的異步JS代碼!你將用generators來創造什么呢?

 

原文地址:https://davidwalsh.name/es6-generators


免責聲明!

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



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