夯實基礎-JavaScript異步編程

異步編程

JavaScript中異步編程問題能夠說是基礎中的重點,也是比較難理解的地方。首先要弄懂的是什麼叫異步?javascript

咱們的代碼在執行的時候是從上到下按順序執行,一段代碼執行了以後纔會執行下一段代碼,這種方式叫同步(synchronous)執行,也是咱們最容易理解的方式。可是在某些場景下:java

  1. 網絡請求:常見的ajax
  2. IO操做:好比readFile
  3. 定時器:setTimeout

上面這些場景可能很是耗時,並且時間不定長,這時候這些代碼就不該該同步執行了,先執行能夠執行的代碼,在將來的某個時間再來執行他們的handler,這就是異步。node

經過這篇文章咱們來了解幾個知識點:git

  1. 進程線程區別
  2. 消息隊列與事件循環
  3. JavaScript處理異步的幾種方法
  4. generator與async/await的關係

基礎知識

先作些準備工做,補一補一些很是重要的前置的概念。es6

進程與線程

一個程序(program)至少包含一個進程(process),一個進程至少包含一個線程(thread)。github

進程有如下特色:web

  1. 一個進程能夠包含一個或多個線程。
  2. 進程在執行過程當中擁有獨立的內存單元。
  3. 一個進程能夠建立和撤銷另外一個進程,這個進程是父進程,被建立的進程稱爲子進程。

線程有如下特色:面試

  1. 線程不能獨立運行,必須依賴進程空間。
  2. 線程本身基本上不擁有系統資源,只擁有一點在運行中必不可少的資源(如程序計數器,一組寄存器和棧),可是它可與同屬一個進程的其餘的線程共享進程所擁有的所有資源。
  3. 一個線程能夠建立和撤銷另外一個線程;同一個進程中的多個線程之間能夠併發執行。
從邏輯角度來看,多線程的意義在於一個應用程序中,有多個執行部分能夠同時執行。但操做系統並無將多個線程看作多個獨立的應用,來實現進程的調度和管理以及資源分配。 這就是進程和線程的重要區別。

畫張圖來簡單描述下:
clipboard.png
全部的程序都要交給CPU實現計算任務,可是CPU一個時間點只能處理一個任務。這時若是多個程序在運行,就涉及到了《操做系統原理》中重要的線程調度算法,線程是CPU輪轉的最小單位,其餘上下文信息用所在進程中的。ajax

進程是資源的分配單位,線程是CPU在進程內切換的單位。

JavaScript單線程

瀏覽器內核是多線程,在內核控制下各線程相互配合以保持同步,一個瀏覽器一般由如下常駐線程組成:算法

  1. GUI 渲染線程
  2. JavaScript引擎線程
  3. 定時觸發器線程
  4. 事件觸發線程
  5. 異步http請求線程

Javascript是單線程的,那麼爲何Javascript要是單線程的?

這是由於Javascript這門腳本語言誕生的使命所致:JavaScript爲處理頁面中用戶的交互,以及操做DOM樹、CSS樣式樹來給用戶呈現一份動態而豐富的交互體驗和服務器邏輯的交互處理。若是JavaScript是多線程的方式來操做這些UI DOM,則可能出現UI操做的衝突; 若是Javascript是多線程的話,在多線程的交互下,處於UI中的DOM節點就可能成爲一個臨界資源,假設存在兩個線程同時操做一個DOM,一個負責修改一個負責刪除,那麼這個時候就須要瀏覽器來裁決如何生效哪一個線程的執行結果。固然咱們能夠經過鎖來解決上面的問題。但爲了不由於引入了鎖而帶來更大的複雜性,Javascript在最初就選擇了單線程執行。

阻塞和非阻塞

這時候再理解阻塞非阻塞就好理解了,對於異步任務,單線程的JavaScript若是什麼也不幹等待異步任務結束,這種狀態就是阻塞的;若是將異步消息放到一邊,過會再處理,就是非阻塞的。

請求不能當即獲得應答,須要等待,那就是阻塞;不然能夠理解爲非阻塞。

生活中這種場景太常見了,上廁所排隊就是阻塞,沒人直接上就是非阻塞。

事件循環(event-loop)

由於JavaScript是單線程的,每一個時刻都只能一個事件,因此JavaScript中的同步和異步事件就有了一個奇妙的執行順序。

JavaScript在運行時(runtime)會產生一個函數調用棧,先入棧的函數先被執行。可是有一些任務是不須要進入調用棧的,這些任務被加入到消息隊列中。當函數調用棧被清空時候,就會執行消息隊列中的任務(任務總會關聯一個函數,並加入到調用棧),依次執行直至全部任務被清空。因爲JavaScript是事件驅動,當用戶觸發事件JavaScript再次運行直至清空全部任務,這就是事件循環。

函數調用棧中的任務永遠優先執行,調用棧無任務時候,遍歷消息隊列中的任務。消息隊列中的任務關聯的函數(通常就是callback)放入調用棧中執行。

舉兩個例子:異步請求

function ajax (url, callback){
    var req = new XMLHttpRequest();

    req.onloadend = callback;
    req.open('GET', url, true);
    req.send();
};

console.log(1);
ajax('/api/xxxx', function(res){
    console.log(res);
});
console.log(2);

一個開發常常遇到的業務場景,異步請求一個數據,上述過程用圖表示:
clipboard.png
圖中三條線分別表示函數執行的調用棧,異步消息隊列,以及請求所依賴的網絡請求線程(瀏覽器自帶)。執行順序:

  1. 調用棧執行console.log(1);
  2. 調用棧執行ajax方法,方法裏面配置XMLHttpRequest的回調函數,並交由線程執行異步請求。
  3. 調用棧繼續執行console.log(2);
  4. 調用棧被清空,消息隊列中並沒有任務,JavaScript線程中止,事件循環結束。
  5. 不肯定的時間點請求返回,將設定好的回調函數放入消息隊列。
  6. 事件循環再次啓動,調用棧中無函數,執行消息隊列中的任務function(res){console.log(res);}

定時器任務:

console.log(1);
setTimeout(function(){
    console.log(2);
}, 100);
setTimeout(function(){
    console.log(3);
}, 10);
console.log(4);

// 1
// 4
// 3
// 2

跟上面的例子很像,只不過異步請求變成了定時器,上述代碼的指向過程圖:
clipboard.png
執行順序以下:

  1. 調用棧執行console.log(1);
  2. 執行setTimeout向消息隊列添加一個定時器任務1。
  3. 執行setTimeout向消息隊列添加一個定時器任務2。
  4. 調用棧執行console.log(4);
  5. 調用棧執行完畢執行消息隊列任務1。
  6. 調用棧執行完畢執行消息隊列任務2。
  7. 消息隊列任務2執行完畢調用回調函數console.log(3);
  8. 消息隊列任務1執行完畢調用回調函數console.log(2);

經過上面例子能夠很好理解,就像工做中你正在作一件事情,這時候領導給你安排一個不着急的任務,你停下來跟領導說'等我忙完手裏的活就去幹',而後把手裏的活幹完去幹領導安排的任務。全部任務完成至關於完成了一個事件循環。

macrotasks 和 microtasks

macrotask 和 microtask 都是屬於上述的異步任務中的一種,分別是一下 API :

  • macrotasks: setTimeout, setInterval, setImmediate, I/O, UI rendering
  • microtasks: process.nextTick(node), Promises, Object.observe(廢棄), MutationObserver

setTimeout 的 macrotask ,和 Promise 的 microtask 有什麼不一樣呢:

console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
console.log('script end');

// "script start"
// "script end"
// "promise1"
// "promise2"
// "setTimeout"

這裏的運行結果是Promise的當即返回的異步任務會優先於setTimeout延時爲0的任務執行。

緣由是任務隊列分爲 macrotasks 和 microtasks,而Promise中的then方法的函數會被推入 microtasks 隊列,而setTimeout的任務會被推入 macrotasks 隊列。在每一次事件循環中,macrotask 只會提取一個執行,而 microtask 會一直提取,直到 microtasks 隊列清空

因此上面實現循環的順序:

  1. 執行函數調用棧中的任務。
  2. 函數調用棧清空以後,執行microtasks隊列任務至清空。
  3. 執行microtask隊列任務至清空。

併發(Concurrency)

併發咱們應該常常聽過,跟他相似的一個詞叫並行。

併發:多個進程在一臺處理機上同時運行,一個時間段內處理多件事情,宏觀上比如一我的邊唱邊跳,微觀上這我的唱一句跳一步。(能夠類比時間片輪轉法,多個線程同時佔用一個CPU,外部看來能夠併發處理多個線程)

並行:多態擁有相同處理能力的處理機在同時處理不一樣的任務,比如廣場上多個大媽同時再調廣場舞。(多個CPU同時處理多個線程任務)

在JavaScript中,由於其是單線程的緣由,因此決定了其每時刻只能幹一件事情,事件循環是併發在JavaScript單線程中的一種處理方式。

可是在平常開發中咱們確定見過,同時發送多個請求。這種狀況下多個網絡線程和js線程共同佔用一個CPU,就是併發。

異步解決方法

雖然已經理解了JavaScript中運行異步任務的過程,可是這樣顯然對開發不友好,由於咱們一般並不知道異步任務在什麼時候結束。因此前人開發了多種處理異步的方法。每種方法咱們都從三個角度考慮其優缺點:

  1. 單個異步寫法是否簡便。
  2. 多個異步按順序執行。
  3. 多個異步併發執行。

回調函數 (callback)

一種最多見的處理異步問題的方法,將異步任務結束時候要乾的事情(回調函數)做爲參數傳給他,等任務結束時候運行回調函數。咱們經常使用的$.ajax()setTimeout都屬於這種方式,可是這樣的問題很明顯:多個異步任務按順序執行很是恐怖。

// 著名的回調金字塔
asyncEvent1(()=>{
    asyncEvent2(()=>{
        asyncEvent3(()=>{
            asyncEvent4(()=>{
                ....
            });    
        });
    });
});

上面這種狀況很是難以維護,在早期Node項目中常常出現這種狀況,有人對上面小改動:

function asyncEvent1CB (){
    asyncEvent2(asyncEvent2CB);
}

function asyncEvent2CB (){
    asyncEvent3(asyncEvent3CB);
}

function asyncEvent3CB (){
    asyncEvent4(asyncEvent4CB);
}

function asyncEvent4CB () {
    // ...
}

asyncEvent1(asyncEvent1CB);

這樣講回調函數分離出來,邏輯清晰了一些,可是仍是很明顯:方法調用順序是硬編碼,耦合性仍是很高。並且一旦同時發送多個請求,這多個請求的回調函數執行順序很難保證,維護起來很是麻煩。

這就是回調函數的弊端

  1. 雖然簡單,可是不利於閱讀維護。
  2. 多層回調順序執行耦合性很高。
  3. 請求併發回調函數執行順序沒法肯定。
  4. 每次只能指定一個回調函數,出現錯誤程序中斷易崩潰。

雖然回調函數這種方式問題不少,可是不能否認的是在ES6以前,他就是處理異步問題廣泛較好的方式,並且後面不少方式仍然基於回調函數。

事件監聽(litenter)

JavaScript是事件驅動,任務的執行不取決代碼的順序,而取決於某一個事件是否發生。DOM中有大量事件如onclickonloadonerror等等。

$('.element1').on('click', function(){
    console.log(1);
});

$('#element2').on('click', function(){
    console.log(2);
});

document.getElementById('#element3').addEventListener('click', function(){
    console.log(3);
}, false);

例如上面這段代碼 你沒法預知輸出結果,由於事件觸發沒法被預知。跟這個很像的還有訂閱者發佈者模式:

github上有個有意思的小demo。註冊在發佈者裏面的回調函數什麼時候被觸發取決於發佈者什麼時候發佈事件,這個不少時候也是不可預知的。

回調函數與事件監聽的區別:

  1. 回調函數可能是一對一的關係,事件監聽能夠是多對一。
  2. 運行異步函數,在一個不肯定的時間段以後運行回調函數;不肯定什麼時候觸發事件,可是觸發事件同步響應事件的回調。
  3. 事件監聽相對於回調函數,可配置的監聽(可增可減)關係減小了耦合性。

不過事件監聽也存在問題:

  1. 多對多的監聽組成了一個複雜的事件網絡,單個節點一般監聽了多個事件,維護成本很大。
  2. 多個異步事件仍然仍是回調的形式。

Promise

promise出場了,當年理解promise花了我很多功夫。Promise確實跟前二者很不同,簡單說下promise。

  1. Promise中文能夠翻譯成承諾,如今與將來的一種關係,我承諾我會調用你的函數。
  2. Promise三種狀態:pending(進行中),fulfilled(已成功),rejected(已失敗),其狀態只能從進行中到成功或者是失敗,不可逆。
  3. 已成功和已失敗能夠承接不一樣的回調函數。
  4. 支持.then鏈式調用,將異步的寫法改爲同步。
  5. 原生支持了race, all等方法,方便適用常見開發場景。

promise更詳細的內容能夠看阮一峯老師的文章

Promise對於異步處理已經十分友好,大多生產環境已經在使用,不過仍有些缺點:

  1. Promise一旦運行,不能終止掉。
  2. 利用Promise處理一個異步的後續處理十分簡便,可是處理多個請求按順序執行仍然很不方便。

Generator

中文翻譯成'生成器',ES6中提供的一種異步編程解決方案,語法行爲與傳統函數徹底不一樣。簡單來講,我能夠聲明一個生成器,生成器能夠在執行的時候暫停,交出函數執行權給其餘函數,而後其餘函數能夠在須要的時候讓該函數再次運行。這與以前的JavaScript聽起來徹底不一樣。

詳細的內容參考阮一峯老師的文章,這裏咱們來據幾個例子,正常的ajax調用寫法看起來以下:

// 使用setTimeout模擬異步
function ajax (url, cb){
    setTimeout(function(){
        cb('result');
    }, 100);
}

ajax('/api/a', function(result){
    console.log(result);
});

// 'result'

一旦咱們想要多個異步按順序執行,簡直是噩夢。這裏使用generator處理異步函數利用了一個特色:調用next()函數就會繼續執行下去,因此利用這個特色咱們處理異步原理:

  1. 將異步邏輯封裝成一個生成器。
  2. 將生成器的異步部分yield出去。
  3. 在異步的回調部分調用next()將生成器繼續進行下去。
  4. 這樣同步,異步,回調分離,處理異步寫起來很是簡便。

咱們對上面的例子加以改進:

// 使用setTimeout模擬異步
function ajax (url, cb){
    setTimeout(function(){
        cb(url + ' result.');
    }, 100);
}

function ajaxCallback(result){
    console.log(result);
    it.next(result);
}

function* ajaxGen (){
    var aResult = yield ajax('/api/a', ajaxCallback); 
    console.log('aResult: ' + aResult);
    var bResult = yield ajax('/api/b', ajaxCallback); 
    console.log('bResult: ' + bResult);
}

var it = ajaxGen();
it.next();

// /api/a result.
// aResult: /api/a result.
// /api/b result.
// bResult: /api/b result.

運行下上面代碼,能夠看到控制檯輸出結果竟然跟咱們書寫的順序同樣!咱們稍加改動:

// 使用setTimeout模擬異步
function ajax (url, cb){
    setTimeout(function(){
        cb(url + ' result.');
    }, 100);
}

function run (generator) {
    var it = generator(ajaxCallback);
    
    function ajaxCallback(result){
        console.log(result);
        it.next(result);
    }
    
    it.next();
};

run(function* (cb){
    var aResult = yield ajax('/api/a', cb); 
    console.log('aResult: ' + aResult);
    var bResult = yield ajax('/api/b', cb); 
    console.log('bResult: ' + bResult);
});

簡單幾下改造即可以生成一個自執行的生成器函數,同時也完成了異步場景同步化寫法。generator的核心在於:同步,異步,回調三者分離,遇到異步交出函數執行權,再利用回調控制程序生成器繼續進行。上面的run函數只是一個簡單的實現,業界已經有CO這樣成熟的工具。實際上開發過程當中一般使用generator搭配Promise實現,再來修改上面的例子:

// 使用setTimeout模擬異步
function ajax (url){
    return new Promise(function(resolve, reject){
        setTimeout(function(){
            resolve(url + ' result.');
        }, 100);
    });
}

function run (generator) {
    var it = generator();
    
    function next(result){
        var result = it.next(result);
        if (result.done) return result.value;
        result.value.then(function(data){
            console.log(data);
              next(data);
        });
    }
    
    next();
};

run(function* (){
    var aResult = yield ajax('/api/a'); 
    console.log('aResult: ' + aResult);
    var bResult = yield ajax('/api/b'); 
    console.log('bResult: ' + bResult);
});

使用Promise來代替callback,理解上花費點時間,大大提升了效率。上面是一種常見,以前我用過generator實現多張圖片併發上傳,這種狀況下利用generator控制上傳上傳數量,達到斷斷續續上傳的效果。

進化到generator這一步能夠說是至關智能了,不管是單個異步,多個按順序異步,併發異步處理都十分友好,可是也有幾個問題:

  1. ES6瀏覽器支持問題,須要polyfill和babel的支持。
  2. 須要藉助CO這樣的工具來完成,流程上理解起來須要必定時間。

有沒有更簡便的方法?

async/await

理解了上面的generator,再來理解async/await就簡單多了。

ES2017 標準引入了 async 函數,使得異步操做變得更加方便。async 函數是什麼?一句話,它就是 Generator 函數的語法糖。

再看一遍上面的例子,而後修改上面的例子用async/await:

// 使用setTimeout模擬異步
function ajax (url){
    return new Promise(function(resolve, reject){
        setTimeout(function(){
            console.log(url + ' result.');
            resolve(url + ' result.');
        }, 100);
    });
}

async function ajaxAsync () {
    var aResult = await ajax('/api/a'); 
    console.log('aResult: ' + aResult);
    var bResult = await ajax('/api/b'); 
    console.log('bResult: ' + bResult);
}

ajaxAsync();

能夠明顯的看到,async/await寫法跟generator最後一個例子很像,基本上就是使用async/await關鍵字封裝了一個自執行的run方法。

async函數對 Generator 函數的改進,體如今如下四點。

  1. 內置執行器:Generator 函數的執行必須靠執行器,因此纔有了co模塊,而async函數自帶執行器。也就是說,async函數的執行,與普通函數如出一轍,只要一行。
  2. 更好的語義:async和await,比起星號和yield,語義更清楚了。async表示函數裏有異步操做,await表示緊跟在後面的表達式須要等待結果。
  3. 更廣的適用性:co模塊約定,yield命令後面只能是 Thunk 函數或 Promise 對象,而async函數的await命令後面,能夠是 Promise 對象和原始類型的值(數值、字符串和布爾值,但這時等同於同步操做)。
  4. 返回值是 Promise:async函數的返回值是 Promise 對象,這比 Generator 函數的返回值是 Iterator 對象方便多了。你能夠用then方法指定下一步的操做。

這裏async/await不作深刻介紹,詳情移步阮一峯老師的博客

Web worker

一個很不經常使用的api,可是是一個異步編程的方法,跟以上幾種又不太同樣。

你可能會遇到一個很是耗時的計算任務,若是在js線程裏運行會形成頁面卡頓,這時使用web worker,將計算任務丟到裏面去,等計算完成再以事件監聽的方式通知主線程處理,這是一個web work的應用場景。在這時候,瀏覽器中是有多個線程在處理js的,worker同時能夠在建立子線程,實現js'多線程'。web worker的文檔。實戰的話看這篇

與前面幾種方法不一樣的是,咱們絞盡腦汁想把異步事件同步化,可是web worker卻反其道而行,將同步的代碼放到異步的線程中。

目前,web worker一般用於頁面優化的一種手段,使用場景:

  1. 使用專用線程進行數學運算:Web Worker最簡單的應用就是用來作後臺計算,而這種計算並不會中斷前臺用戶的操做。
  2. 圖像處理:經過使用從<canvas>或者<video>元素中獲取的數據,能夠把圖像分割成幾個不一樣的區域而且把它們推送給並行的不一樣Workers來作計算。
  3. 大量數據的檢索:當須要在調用 ajax後處理大量的數據,若是處理這些數據所需的時間長短很是重要,能夠在Web Worker中來作這些,避免凍結UI線程。
  4. 背景數據分析:因爲在使用Web Worker的時候,咱們有更多潛在的CPU可用時間,咱們如今能夠考慮一下JavaScript中的新應用場景。例如,咱們能夠想像在不影響UI體驗的狀況下實時處理用戶輸入。利用這樣一種可能,咱們能夠想像一個像Word(Office Web Apps 套裝)同樣的應用:當用戶打字時後臺在詞典中進行查找,幫助用戶自動糾錯等等。

總結

JavaScript中的異步編程方式目前來講大體這些,其中回調函數這種方式是最簡單最多見的,Promise是目前最受歡迎的方式。前四種方式讓異步編碼模式使咱們可以編寫更高效的代碼,而最後一種web worker則讓性能更優。這裏主要是對異步編程流程梳理,前提知識點的補充,而對於真正的異步編程方式則是以思考分析爲主,使用沒有過多介紹。最後補充一個鏈接:JavaScript異步編程常見面試題,幫助理解。

參考

  1. 《你所不知道JavaScript》
  2. 《JavaScript高級程序設計》
  3. 瀏覽器進程?線程?傻傻分不清楚!
  4. 線程和進程的區別是什麼?
  5. 併發模型與事件循環
  6. 理解 JavaScript 中的 macrotask 和 microtask
  7. 【轉向Javascript系列】深刻理解Web Worker
相關文章
相關標籤/搜索