第一章 非同步

王工發表於2016-04-30

在 JS 這種非同步程式語言中,有時程式碼的執行順序並非按編寫的順序執行,一部分程式碼會立即(now)執行,而另一部分則會稍後(later)執行,那麼如何處理 “立即” 與“ 稍後 ”的關係,就是非同步程式設計的核心了。

在瀏覽器中的 JS 通常使用 定時器(setTimeout() setInterval())、 Ajax ( XMLHttpRequest() ) 或 事件監聽 ( addEventListener() ) 傳入 回撥函式(callback) 來處理非同步,但 JS 的程式碼越來越複雜,非同步多了就會出現巢狀,尤其在 node.js 中想讓非同步程式碼按順序執行,就會出現層層巢狀的“回撥地獄”,現在急需一種更好的解決方案來解決複雜的非同步程式,這就是本書的主題。

想完全理解 JS 的非同步程式設計,首先就要知道立即( now) 與稍後( later) 到底是什麼關係。

分塊

function foo() {
  return 2;
}

function later() {
  var res = value * 2;
  console.log( res );
}

var value = foo();

setTimeout( later , 1000);

上面這段程式碼在實際執行中就可以分為 now 與 later 兩個部分,雖然這種分離是假想的,因為函式的作用域並沒有變,但這樣有助於我們理解:

Now:

    function foo() { ..  }

    function later() { ..  }

    var value = foo();

    setTimeout( later , 1000);

Later:// 1秒後

     var res = value * 2;
     console.log( res );

now 部分會馬上執行,而 setTimeout 函式可以設定一個事件,讓它(至少)推遲 1秒後再執行。

事件迴圈(event loop)

說出來你可能不信,無論你非同步程式碼寫的多遛,JS 這個語言實際上並沒有非同步的概念。JS 引擎只會根據要求去執行一個程式碼塊,那麼。。。“要求”是誰?誰“要求”的?眾所周知,JS引擎需要一個宿主環境才能執行,以前是瀏覽器,現在又多了伺服器的 Node.js 等。

無論在何種執行環境中,我們通常說的“執行緒”(thread)是有能力處理一個程式中的許多事情,每當一個執行緒去呼叫 JS 引擎執行一段程式碼的過程就叫“事件迴圈”。換句話說,JS 引擎並沒有時間概念,它只管執行程式碼塊,誰呼叫,什麼時候呼叫,是執行環境的事。

可以這樣理解事件迴圈:

var evetLoop = [];
var event;

while ( true ) {
  if (eventLoop.length > 0 ) {
   event = eventLoop.shift()

   try { event() } 
   catch ( err ) { }
 }
} 

事件迴圈就是一個佇列,事件迴圈不斷的像 while 迴圈一樣執行,每一輪迴圈叫做一個“tick”,當呼叫setTimeout 時,並非是 setTimeout 把 callback 放入到 eventLoop 佇列中,而是設定了一個定時器,告訴執行環境(瀏覽器),一段時間後將 callback 放到 eventLoop 佇列的最後,這也就是為什麼 setTimeout 設定的時間並不準確,因為如果 eventLoop 佇列中還有其他 event 正在執行,那一定要等這個 event 執行完成後才會 執行 callback。換句話說,你的程式碼在執行過程中被分成了好多個程式碼塊,排好順序後,放入事件佇列中,之後再按順序執行,執行到一半如果有 setTimeout 這樣的定時器出現,就會召喚瀏覽器幹活,讓它幫著安排後來的 callback 。

PS:目前 ES6 對新的事件迴圈做了規定,因為promise出現需要更精細的操作,所以 JS 引擎也開始處理事件迴圈了? 見 Cooperation 節。

多執行緒(Parallel Thread)

一個普遍的誤解就是關於“非同步”與“多執行緒”,這兩個詞是有很大區別的。非同步只有單一執行緒,它只關心執行的順序,立即或是稍後執行;而多執行緒是由許多共享一塊記憶體區域的執行緒組成,它們的執行是“同時”的。這樣說其實也不是非常準確,應該說 非同步 與 多執行緒 在程式碼執行的粒度上是不同的,下面舉個例子:

var a = 2;

function foo() {
  a = a + 1;
}

function bar() {
 a = a * 2;
} 

functionLikeAjax( 'url1' , foo);
functionLikeAjax( 'url2' , bar);

如果在多執行緒中,假設程式碼會按行(表示式)執行

foo(): 
   f1. a;
   f2. a+1;
   f3. a = a+1;

bar():
  b1. a;
  b2. a*2;
  b3. a = a *2

由於共享一塊記憶體,他們有可能會這樣執行

   f1. a;    // 2
   b1. a;  // 2
   f2. a+1; // 3
   b2. a * 2; // 4 
   f3. a = a+1; // 5
   b3. a = a *2;  // 10

而如果 f3 和 b2 行對調結果就會是 6,f2 和 b2 對調結果就是 8。由於無法確定按行執行的順序,因此還會有許多種可能的結果。多執行緒同時處理可能相互干擾的資料時,是十分麻煩的。

而 JS 的執行是在分塊(函式塊)層面的

塊1:

    var a = 2;

    function foo() {.. }
    function bar() {.. } 

    functionLikeAjax( 'url1' , foo);
    functionLikeAjax( 'url2' , bar);

塊2:

    foo:
      a = a + 1;

塊3:

    bar:
     a = a * 2;

最後的執行順序可能也是不確定的,因為我們不知道兩個 Ajax 誰先返回,這種情況叫“競賽條件”(race condition)。但可能的情況只有 1->2->3 和 1->3->2 兩種,遠比多執行緒的可能情況少,因此我們可以說在同一個分塊層面,非同步程式碼是按指定順序執行的,而且每個分塊的執行必須完成後,才會執行下一個塊,中途不會被任何因素影響( ES6 中的 Generator 還是可以打斷塊的執行,見第四章)

併發(待完善)

以SNS滾動載入timeline為例。多幾個scroll,觸發幾個ajax

所有 scroll 算一個 process ,所有 ajax 算另一個 process

在這個層面它們是交錯的,也就是併發,但在事件迴圈佇列這個層面,就像大家擠公交,就算外面擠成一坨,車門處也只能允許一個一個的上。

互不影響

要是兩個程式互不干擾,那使用併發不要太Happy

互相影響

這種情況很常見,也是我們接下來要慮的。

  1. 競賽條件(race condition)

    有時順序不定,要用程式碼確定先後順序

  2. 城門(gate)

    有時需要全部資源到齊才能執行,這種情況需要全部結果返回後才能開門構建一個

  3. 門閂(latch)

    這種情況與競賽條件類似,但區別就是第一個結果返回之後,門就關閉了,後面再有什麼返回都直接拋棄。

  4. 合作(cooperation)

    有時一個函式可能要處理很長的資料,但是佇列中還有許多事件待處理,這時就需要將這個資料處理分段執行,先處理一點,剩下的插入後面的佇列中再執行。

我們無法明確的控制流程的順序,只能用一些技術手段對返回的結果進行控制,所以如何真正的控制流程是本書接下來要詳細說明的。

在瀏覽器中,我們用可以用 setTimeout() 將一些長處理模擬成非同步的,在 node.js 也有 process.nextTick() 可用,雖然可以設定時間,但是同樣不能100%保證返回的順序與你寫程式碼的順序一致。

工作佇列(Jobs)

在 ES6 中,在 事件迴圈佇列之上新增了一個工作佇列(詳見 第三章),與事件佇列不同,它的意思有點像你告訴祕書:”一會有份檔案要交給王工,但這個檔案正在讓小李寫,等他寫完給你,你就去送給王工。“

這種 插入工作佇列 與 之前的方法不同之處在於,工作佇列的級別比 其他事件高,因此在一個 tick 的結尾會先執行 工作佇列中的事件。

例:

setTimeout(()=> console.log(1),0);

addJob(()=> cosole.log(2));

結果總是 2,1 而不會出現 1,2?

宣告順序

雖然可以寫不包含非同步呼叫的程式碼,那樣看起來像是在按書寫順序執行程式碼,但是JS引擎會事先編譯一遍你的程式碼,沒錯就是編譯(見 第一本 scope & closure ),它怎麼執行也不是很確定,還記得函式提升嗎?觀察會影響JS 的編譯嗎,作者也不知道,那這節他寫個卵?

本章小結

  1. JS將程式碼分為兩個部分,“現在” 和 “稍後”
  2. JS是單執行緒語言,可以通過 UI/IO/定時器 等設定將來要執行回撥函式,並將插入佇列的控制權交由執行環境,JS引擎則依靠順序執行“事件迴圈”佇列中的分塊(函式)來完成非同步呼叫。
  3. JS一次只執行一個事件(分塊),在這個分塊中可以設定後續的事件
  4. JS 中的併發只是以不同順序執行事件,而結果可能會交錯影響外部變數。只是在更大的粒度上看起來像同時執行,就像 非同步 與 多執行緒 的區別一樣,在JS層面並沒有什麼併發,只是 事件的順序不同而已。
  5. 因此對JS的併發協調,就是對事件執行順序的協調和對可能互相影響的變數操作之間的協調

相關文章