非同步與協程

前段時間有同事問了一個問題:JavaScript是單執行緒運行程式碼,那麼如下程式碼片段中,同樣是執行func1func2,為什麼只用 Promise.all 相比於直接執行 await func1();await func2(); 速度更快:

async function func1() {
    await new Promise(resolve => {
        setTimeout(resolve, 3000);
    });
    return 100;
}
​
async function func2() {
    await new Promise(resolve => {
        setTimeout(resolve, 3000);
    });
    return 200;
}
​
(async function () {
    let result = [];
    // 約3秒返回結果
    result = await Promise.all([func1(), func2()]);
    // 約6秒返回結果
    // result[0] = await func1();
    // result[1] = await func2();
    console.log('result', result);
})();

 

當時並不能很好的回答這個問題,便查閱了相關資料整理如下:

並發模型

JavaScript使用基於事件循環的並發模型,這裡並髮指事件循環處理任務隊列中回調函數的能力。該模型三大特點:單執行緒、非同步、非阻塞

單執行緒是指執行用戶程式碼(或者說事件循環)的時候只有一個執行緒,即主執行緒。但JavaScript的Runtime不是單執行緒的。非同步指主執行緒不用等待任務結果返回。非阻塞指任務執行過程不會導致事件循環停止,這裡的非阻塞更多的是指I/O操作。JavaScript並發模型簡化圖示如下:

 

 

與此類似Node執行用戶程式碼也是用單執行緒,但Node內部不是單執行緒。下面是網上找的一張Node架構圖,原圖地址:Node.js event loop architecture。可以看到Node中可能阻塞事件循環的任務,如:未提供非同步API的I/O操作及CPU密集型任務會委託給worker thread pool來處理,不會影響到事件循環。

 

 

 

Node event loop vs Browser event loop vs JavaScript event loop

不同的宿主環境有著各自的事件循環實現,下面一段摘錄自JavaScript Event Loop vs Node JS Event Loop,介紹了v8、瀏覽器、Node三者事件循環區別:

Both the browser and NodeJS implements an asynchronous event-driven pattern with JavaScript. However, the 「Events」, in a browser』s context, are user interactions on web pages (e.g, clicks, mouse movements, keyboard events etc.), but in Node』s context, events are asynchronous server-side operations (e.g, File I/O access, Network I/O etc.). Due to this difference of needs, Chrome and Node have different Event Loop implementations, though they share the same V8 JavaScript engine to run JavaScript.

Since 「the event loop」 is nothing but a programming pattern, V8 allows the ability to plug-in an external event loop implementation to work with its JavaScript runtime. Using this flexibility, the Chrome browser uses libevent as its event loop implementation, and NodeJS uses libuv to implement the event loop. Therefore, chrome』s event loop and NodeJS』s event loop are based on two different libraries and which have differences, but they also share the similarities of the common 「Event Loop」 programming pattern.

協程

JavaScript非同步編程大致經歷了如下幾個階段:Callback、Promise、async/await。

Callback大家都比較熟悉了,如:SetTimeoutXMLHttpRequest等API中使用回調來進行非同步處理。

回調函數使用相對簡單,但存在回調地獄問題,因此在ES6中引入了Promise來解決該問題。但如果處理流程比較複雜的話,使用Promise程式碼中會用到大量的then分發,語義不清晰。

在ES7中引入了await/async,讓我們可以用同步的方式來編寫非同步程式碼。一個async函數會隱式返回一個Promise對象,遇到await表達式怎會暫停函數執行,待await表達式計算完成後再恢複函數的執行(生成器中使用的yield也有相似功能),通過生成器來實現非同步編程可以參考開源項目:co

await表達式分為兩種情況:

  • 如果await後面是Promise對象,則當Promise對象的狀態為fulfill/reject時, await表達式結束等待,await後面的程式碼將被執行

  • 如果await後面不是Promise對象,則隱式轉換為狀態為fulfill的Promise對象

程式碼的暫停和恢復執行用到了協程(Coroutine),async函數是有協程負責執行的,在遇到await時便暫停當前協程,等到await表達式計算完成再恢復。注意這裡只是暫停協程,並不妨礙主執行緒執行其它程式碼。

最早接觸協程的概念是在go中,後來發現好多語言都有,還是要多看多了解不能局限於一種語言。協程通常解釋為輕量級執行緒,一個執行緒上可以存在多個協程,但每次只能執行一個協程。協程的調度不牽涉到執行緒上下文的切換,不存在執行緒安全問題、相比執行緒有著更好的性能。

實現Pomise.all

了解了非同步方法調度原理,針對文章開頭的場景,自己實現一個簡化版的PromiseAll

async function PromiseAll(values) {
    // console.log('call promise all');
    let result = [];
    for (let i = 0; i < values.length; i++) {
        await Promise.resolve(values[i]).then(value => {
            let index = i;
            result[index] = value;
        });
    }
​
    // console.log('waiting result');
    if (result.length == values.length) {
        // console.log('promise all result', result);
        return result;
    }
}

 

使用PromiseAll來執行之前的非同步函數:

(async function () {
    console.log('before await');
    let result = [];
    // 不阻塞主執行緒
    result = await PromiseAll([func1(), func2()]);
    console.log('after await');
    console.log('result', result);
})();
console.log('end...');
​
// 輸出如下:
// before await
// end...
// 間隔約3秒後輸出
// after await
// result [ 100, 200 ]

 

PromiseAll執行流程如下:

 

 

使用串列await執行程式碼:

(async function () {
    console.log('before await');
    let result = [];
    result[0] = await func1();
    result[1] = await func2();
    console.log('after await');
    console.log('result', result);
})();
console.log('end...');
​
// 輸出如下:
// before await
// end...
// 間隔約6秒後輸出
// after await
// result [ 100, 200 ]

 

串列await執行流程如下:

 

 

從流程圖中可以比較清晰的看到,PromiseAll之所以會更快的得到結果,是因為沒有func1func2近似並行執行。

對比其它語言中的非同步

其它編程平台如:.NET、Python也提供了async/await特性。在.NET中默認基於執行緒池來執行非同步方法,Python則和JavaScript一樣使用了協程。

Python中使用async/await需要導入asyncio包,從包的名字可以感受到,asyncio主要針對的就是I/O場景。非同步I/O操作最終會委託作業系統來完成工作,不會阻塞應用執行緒從而提升應用響應能力。與JavaScript類似,asyncio通過事件循環機制+協程+task來實現非同步編程。此外,Python程式碼主流程也是有單執行緒執行,在實際運行中也可能會有多執行緒操作,但因為GIL的存在,Python中即使使用多執行緒也不會並行執行程式碼,想要並行需使用多進程方式。

JavaScript、.NET、Python的非同步編程在經歷了不斷演化後,最終都提供了async/await特性,算是殊途同歸。

參考文章

Node.js event loop architecture

Javascript — single threaded, non-blocking, asynchronous, concurrent language

Concurrency model and Event Loop

JavaScript Event Loop vs Node JS Event Loop

What code runs on the Worker Pool?

Redis 多執行緒網路模型全面揭秘