非同步與協程

雪飛鴻發表於2021-07-17

前段時間有同事問了一個問題: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 多執行緒網路模型全面揭祕

相關文章