由setTimeout深刻JavaScript執行環境的異步機制

問題背景

在一次開發任務中,須要實現以下一個餅狀圖動畫,基於canvas進行繪圖,但因爲對於JS運行環境中異步機制的不瞭解,因此遇到了一個棘手的問題,始終沒法解決,以後在與同事交流以後才恍然大悟。問題的根節在於經典的JS定時器異步問題,因此在解決問題以後,又經過了大量的資料閱讀擴展和一段時間的實戰總結,如今對JS運行環境中異步機制作一個較爲深刻的分析。html

setTimeout.gif-55.9kB

上圖中爲最終想要實現的效果,使得各扇形部分能夠同時畫出並閉合圓形。點擊此處查看代碼清單。以前遇到的問題是沒有將myLoop做爲一個函數抽離出來,而將其中的全部邏輯,包括定時器都寫在了for循環中,這樣雖然扇形角度、哨兵變量等的計算均正確,但圓形始終沒法閉合,非常鬱悶。這裏我只是想借此問題來引入JS運行環境中對於異步機制理解的重要性,大可沒必要關心canvas畫圖的實現過程,讓你們明白對異步的理解會牽扯到業務邏輯執行的準確性,並不是只是用於浮於紙面的面試題之上。至於爲何將定時器的邏輯放在一個函數中就執行正常,而直接寫入for循環就沒法達到預期,看過下文的詳細分析後,這個問題便會迎刃而解。node


深刻異步

關於異步的深刻,這裏基於現有的知識水平作儘量詳盡準確的分析。你們能夠從一篇博客進一步瞭解牛人之間對於異步理解的爭論。一位是技術博客紅人阮一峯老師,一位是國內Node技術的開山鼻祖樸靈老師,都是我持續關注的兩位偶像。事情發生的比較早了,這裏只給出一個文章連接,其中在阮老師的博文中附帶了大量樸靈老師的批註,讀過以後定會受益不淺,也會激發出你對技術外的一些思考。面試

同步與異步

首先來講明同步與異步兩個概念。ajax

f1()
f2()

對於JavaScript語言的執行方式,執行環境會支持兩種模式,一種是同步執行,一種是異步執行。如上面兩個方法,同步執行就是調用f1以後,等待返回結果,再執行f2。異步是調用f1後,經過一系列其餘的操做才能夠獲得預期的結果,好比網絡IO、磁盤IO等,在線程執行這些其餘操做的同時,程序還能夠往下執行,繼續調用f2,不用等待f1的結果返回再執行f2。編程

咱們知道,大部分的腳本和編程語言都是同步編程,開發者對於同步編程的執行邏輯也比較容易理解。那麼爲何對於JS的執行要常常用到異步編程,這應該要追溯到最初JS適用的宿主環境--瀏覽器。canvas

因爲用於瀏覽器,因此操做DOM的JS只能使用單線程,不然沒法保證DOM操做的安全性(好比一個線程將另外一個線程正在使用的某個DOM刪掉)。又由於使用單線程,同步執行代碼的話,若是遇到耗時較長的操做,那麼瀏覽器將會長時間失去響應,用戶體驗及其很差。但若是將耗時較長的任務,好比ajax請求異步執行,那麼客戶端的渲染便不會受到耗時任務的阻塞。vim

對於服務器端,JS異步執行更爲重要,由於執行環境是單線程的,若是同步執行全部併發請求,那麼對於客戶端的響應將會極其遲鈍,服務器性能急劇降低,這時必須使用異步模式來處理大量併發請求,不像Java、PHP等語言是經過多線程來解決併發問題。這點在如今高併發司空見慣的網絡環境中,反而成爲了JS的優點,使得Node在短期內進入主流視野,成爲DIRT應用1的最佳解決方案。瀏覽器

實現異步的機制

在說實現異步的機制以前,首先須要搞清楚兩個概念,分別是JavaScript的執行引擎執行環境。咱們常說Google的V8虛擬機即是JavaScript的執行引擎,除此以外Safari的JavaScript Core、FireFox的SpiderMonckey都屬於Engine。而上述的瀏覽器和Node等便屬於JavaScript的執行環境,是Runtime。前者Engine是去實現ECMAScript標準,後者Runtime是去實現異步的具體機制。因此咱們今天講的JS異步機制都是在說JS執行環境的異步機制,與V8這樣的執行引擎並沒有關係,主要是由各大瀏覽器廠商去作實現。安全

關於實現異步的方式,有咱們接下來要詳細介紹的Event Loop,還有輪詢、事件等。所謂輪詢,就是你在收銀臺付款以後,不停的問服務員你的飯菜作好了嗎。所謂事件,就是你在付款以後,不用不停的問服務員,服務員在作好飯菜以後會主動告訴你。而大部分的執行環境都是經過Event Loop去實現異步機制,因此下面重點來說解Event Loop。服務器

Event Loop

Event Loop的實現邏輯以下圖。每當程序啓動後,內存會被分爲堆(heap)和棧(stack)兩部分,其中棧中即是主線程的執行邏輯所需內存,咱們根據這塊內存的特殊做用,抽象的將其叫作執行棧。在棧中的代碼會調用各類WebAPI,好比對DOM的操做,ajax請求,建立定時器等。這些操做會產生一些事件,而事件又會關聯相應的handle(也就是註冊時的callback),將須要執行的handle按照隊列的結構放入callback queue(event queue)中。當執行棧中的代碼執行完畢後,主線程會讀取callback queue,依次執行其中的回調函數,而後進入下一輪的事件循環,執行清空新產生的事件回調函數。因而可知,在執行棧中的代碼老是在callback queue以前執行

bg2014100802.png-22.4kB

圖片轉引自Philip Roberts的演講《Help, I'm stuck in an event-loop》

setTimeout()和setInterval()兩個定時器中回調的執行邏輯即是典型的Event Loop機制。類似的,程序在跑完執行棧中的代碼後,事件循環會不停的檢查系統時間是否到達預設的時間點,每當到達預設的時間點時,就會產生一個timeout事件,並將其放入callback queue,等待下輪Event loop執行。但在實際應用中,有可能執行棧中的代碼耗時過長,這樣在執行完執行棧中的代碼後,再去執行callback queue中由setTimeout()產生的回調時就不能保證在預期的時間點執行,因此JS中的定時器並不總能保證其精準性。而在詳細瞭解其特性原理後,咱們能夠在編程應用層面作一些優化,儘可能使定時器中回調函數的執行時間點與咱們預期保持一致。因爲setTimeout()與setInterval()在本質上是一致的,因此在下面的實例分析一節中咱們將會以setTimeout()來作關於異步機制的分析。

異步編程

關於異步編程個人理解是,在JS執行環境所提供的異步機制之上,在應用編碼層面上實現總體流程控制的異步風格。具體地,咱們能夠用相似setTimeout()中的回調函數的形式進行異步編程,或者用相似事件驅動的發佈/訂閱模式,或者用ES6爲咱們提供的異步編程的統一接口Promise實現,再或者能夠嘗試最新最酷的ES7中Async/Await方案,還有一些像Node社區提供的異步流控庫Step等。這裏只是爲你們明確異步編程這個概念範疇,具體用法再也不深刻。


實例分析

這一節中我將會舉出多例來分析,請你們結合上述理論細細體會JS中的同步與異步。首先咱們從一個經典的JS異步面試題開始,而後逐漸深刻。

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}
 
console.log(new Date, i);

上述代碼片斷的運行結果應該是,先當即輸出一個5,而後在1秒之後同時輸出五個5。程序開始執行後,首先執行執行棧中的同步代碼,幾乎同時建立了5個定時器,而後繼續執行第7行的同步代碼。這樣,首先在控制檯輸出一個5,而後在1s之後,5個定時器同時產生5個timeout事件放入callback queue,Event loop依次執行隊列中的回調函數,這裏由於閉包的特性,每個定時器的回調都與其定義上下文,for循環中的i變量作了綁定,而i的值已變爲5,因此同時輸出五個5。

若是如今提出一個新需求,要求程序運行後,先當即輸出一個5,而後在1s之後同時輸出0,1,2,3,4,如何改造上述代碼?

//方法一
for (var i = 0; i < 5; i++) {
    (function(j) {  
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000);
    })(i);
}
 
console.log(new Date, i);

//方法二
function output (i) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
};
 
for (var i = 0; i < 5; i++) {
    output(i);  
}
 
console.log(new Date, i);

上面給出的兩種方法其實都是一種思路,都是利用JS中,函數做用域做爲一個獨立的做用域,來保存一個局部的上下文環境,並經過閉包的特性使其與setTimeout中的回調函數作綁定。只不過第一種方法是利用IIFE2來實現,第二種方法是經過定義一個函數,再來逐個調用實現。看到這裏,應該想到對於篇首問題背景一節中所提到的問題便與此處一模一樣。

接下來咱們進一步深刻,提出一個新的需求。如何在代碼執行時,當即輸出 0,以後每隔1s依次輸出 1,2,3,4,循環結束後在大概第5秒的時候輸出5?

由於前邊每隔1s輸出的0,1,2,3,4是五個定時器輸出的,也就是五個異步操做,那麼咱們是否是能夠把此次的需求抽象爲:在一系列異步操做完成(每次循環都產生了 1 個異步操做)以後,再作其餘的事情。如今熟悉ES6的同窗應該想到了Promise。

const tasks = []; // 這裏存放異步操做的 Promise
const output = (i) => new Promise((resolve) => {
    setTimeout(() => {
        console.log(new Date, i);
        resolve();
    }, 1000 * i);
});
 
// 生成所有的異步操做
for (var i = 0; i < 5; i++) {
    tasks.push(output(i));
}
 
// 異步操做完成以後,輸出最後的 i
Promise.all(tasks).then(() => {
    setTimeout(() => {
        console.log(new Date, i);
    }, 1000);
});

若是你熟悉ES7中的Async/Await,那麼也能夠嘗試用這種方案解決。

// 模擬其餘語言中的 sleep,實際上能夠是任何異步操做
const sleep = (timeountMS) => new Promise((resolve) => {
    setTimeout(resolve, timeountMS);
});
 
(async () => {  // 聲明即執行的 async 函數表達式
    for (var i = 0; i < 5; i++) {
        await sleep(1000);
        console.log(new Date, i);
    }
 
    await sleep(1000);
    console.log(new Date, i);
})();

這裏須要着重注意的是瀏覽器對Async/Await標準的支持,若是你的瀏覽器不在如下所支持版本當中,那麼能夠升級瀏覽器或使用babel轉譯處理。

此處輸入圖片的描述

能把上邊這一系列的實例理解到位,相信對JS中異步的這個概念會一些新的體會。下面這個實例會更加細化的考察一下異步代碼中回調的執行時機。

let a = new Promise(
  function(resolve, reject) {
    console.log(1)
    setTimeout(() => console.log(2), 0)
    console.log(3)
    console.log(4)
    resolve(true)
  }
)
a.then(v => {
  console.log(8)
})
 
let b = new Promise(
  function() {
    console.log(5)
    setTimeout(() => console.log(6), 0)
  }
)
 
console.log(7)

這裏首先來明確一點,Promise是ES6中爲異步編程所提供的一套API標準,其自己是同步的。因此咱們在new一個Promise對象的時候,其所執行的構造器中的邏輯是同步的。由此得知,上述代碼片斷先從上到下依次執行同步代碼,輸出1,3,4,5,7。而後是先執行then中的異步代碼仍是先執行setTimeout中的回調代碼?這裏須要記住前者要比後者先進入執行棧執行,因此後邊輸出8,2,6。這是由於當即resolved的Promise是在本輪事件循環的末尾執行,相似於node中的process.nextTick方法,它能夠在當前"執行棧"的尾部,下一次Event Loop(主線程讀取"任務隊列")以前,觸發回調函數。setTimeout(fn, 0)則是在當前"任務隊列"的尾部添加事件,也就是說,它指定的任務老是在下一輪次Event Loop時執行,這與node中的setImmediate方法很像。

最後咱們來講一個關於setInterval優化的例子。咱們知道setTimeout中的回調觸發是不許確的,主要緣由是因爲在須要執行回調時,可能執行棧中的代碼尚未執行完,沒法將CPU資源及時的調度給callback queue中的回調執行。而setInterval也會存在一些問題,好比時間間隔可能會跳過,
時間間隔可能小於定時器設定的時間。發生這類狀況其實也是因爲其餘的程序佔用長時間的CPU時間片引發,如下面代碼片斷爲例:

function click() { 
    // code block1... 
    setInterval(function() { 
        // process ... 
    }, 200); 
    // code block2 ...
}

若是process中的代碼執行時間過長,佔用了超過400ms,那麼此時JS執行環境就會跳過中間一次時間間隔,由於callback queue中只容許有一份process代碼存在,因此也會產生觸發時機不精準的狀況。

爲了不這種狀況的出現,咱們能夠利用遞歸的方式進行優化處理,如下提供兩種寫法,可是建議使用第一種寫法。由於第二種寫法中,在嚴格模式下,第5版 ECMAScript (ES5) 禁止使用 arguments.callee()。當一個函數必須調用自身的時候, 避免使用 arguments.callee(), 經過要麼給函數表達式一個名字,要麼使用一個函數聲明參見MDN解釋

// 寫法一
    setTimeout(function bar (){ 
        // processing
        foo = setTimeout(bar, 1000); 
    }, 1000);
    
    // 寫法二
    setTimeout(function(){ 
        // processing 
        foo = setTimeout(arguments.callee, interval); 
    }, interval);
    
    clearTimeout(foo) // 中止循環

  1. Data-Intensive Real-Time 這裏指數據密集、實時交互類應用。
  2. Immediately Invoked Function Expression:聲明即執行的函數表達式。
相關文章
相關標籤/搜索