一文讀懂JavaScript的併發模型和事件循環機制

咱們知道JS語言是串行執行、阻塞式、事件驅動的,那麼它又是怎麼支持併發處理數據的呢?javascript

"單線程"語言

在瀏覽器實現中,每一個單頁都是一個獨立進程,其中包含了JS引擎、GUI界面渲染、事件觸發、定時觸發器、異步HTTP請求等多個線程。html

進程(Process)是操做系統CPU等資源分配的最小單位,是程序的執行實體,是線程的容器。
線程(Thread)是操做系統可以進行運算調度的最小單位,一條線程指的是進程中一個單一順序的控制流。

所以咱們能夠說JS是"單線程"式的語言,代碼只能按照單一順序進行串行執行,並在執行完成前阻塞其餘代碼。java

JS數據結構

JS數據結構.png

如上圖所示爲JS的幾種重要數據結構:node

  • 棧(Stack):用於JS的函數嵌套調用,後進先出,直到棧被清空。
  • 堆(Heap):用於存儲大塊數據的內存區域,如對象。
  • 隊列(Queue):用於事件循環機制,先進先出,直到隊列爲空。

事件循環

咱們的經驗告訴咱們JS是能夠併發執行的,好比定時任務、併發AJAX請求,那這些是怎麼完成的呢?其實這些都是JS在用單線程模擬多線程完成的。git

事件隊列.png

如上圖所示,JS串行執行主線程任務,當遇到異步任務如定時器時,將其放入事件隊列中,在主線程任務執行完畢後,再去事件隊列中遍歷取出隊首任務進行執行,直至隊列爲空。es6

所有執行完成後,會有主監控進程,持續檢測隊列是否爲空,若是不爲空,則繼續事件循環。github

setTimeout定時任務

定時任務setTimeout(fn, timeout)會先被交給瀏覽器的定時器模塊,等延遲時間到了,再將事件放入到事件隊列裏,等主線程執行結束後,若是隊列中沒有其餘任務,則會被當即處理,而若是還有沒有執行完成的任務,則須要等前面的任務都執行完成纔會被執行。所以setTimeout的第2個參數是最少延遲時間,而非等待時間。c#

當咱們預期到一個操做會很繁重耗時又不想阻塞主線程的執行時,會使用當即執行任務:瀏覽器

setTimeout(fn, 0);

特殊場景1:最小延遲爲1ms

然而考慮這麼一段代碼會怎麼執行:網絡

setTimeout(()=>{console.log(5)},5)
setTimeout(()=>{console.log(4)},4)
setTimeout(()=>{console.log(3)},3)
setTimeout(()=>{console.log(2)},2)
setTimeout(()=>{console.log(1)},1)
setTimeout(()=>{console.log(0)},0)

瞭解完事件隊列機制,你的答案應該是0,1,2,3,4,5,然而答案倒是1,0,2,3,4,5,這個是由於瀏覽器的實現機制是最小間隔爲1ms。

// https://github.com/nodejs/node/blob/v8.9.4/lib/timers.js#L456

if (!(after >= 1 && after <= TIMEOUT_MAX))
  after = 1; // schedule on next tick, follows browser behavior

瀏覽器以32位bit來存儲延時,若是大於 2^32-1 ms(24.8天),致使溢出會馬上執行。

特殊場景2:最小延遲爲4ms

定時器的嵌套調用超過4層時,會致使最小間隔爲4ms:

var i=0;
function cb() {
    console.log(i, new Date().getMilliseconds());
    if (i < 20) setTimeout(cb, 0);
    i++;
}
setTimeout(cb, 0);

能夠看到前4層也不是標準的馬上執行,在第4層後間隔明顯變大到4ms以上:

0 667
1 669
2 670
3 672
4 676
5 681
6 685
Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.

特殊場景3:瀏覽器節流

爲了優化後臺tab的加載佔用資源,瀏覽器對後臺未激活的頁面中定時器延遲限制爲1s。
對追蹤型腳本,如谷歌分析等,在當前頁面,依然是4ms的延時限制,然後臺tabs爲10s。

setInterval定時任務

此時,咱們會知道,setInterval會在每一個定時器延時時間到了後,將一個新的事件fn放入事件隊列,若是前面的任務執行過久,咱們會看到連續的fn事件被執行而感受不到時間預設間隔。

所以,咱們要儘可能避免使用setInterval,改用setTimeout來模擬循環定時任務。

睡眠函數

JS一直缺乏休眠的語法,藉助ES6新的語法,咱們能夠模擬這個功能,可是一樣的這個方法由於藉助了setTimeout也不能保證準確的睡眠延時:

function sleep(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  })
}
// 使用
async function test() {
    await sleep(3000);
}

async await機制

async函數是Generator函數的語法糖,提供更方便的調用和語義,上面的使用能夠替換爲:

function* test() {
    yield sleep(3000);
}
// 使用
var g = test();
test.next();

可是調用使用更加複雜,所以通常咱們使用async函數便可。但JS時如何實現睡眠函數的呢,其實就是提供一種執行時的中間狀態暫停,而後將控制權移交出去,等控制權再次交回時,從上次的斷點處繼續執行。所以營造了一種睡眠的假象,其實JS主線程還能夠在執行其餘的任務。

Generator函數調用後會返回一個內部指針,指向多個異步任務的暫停點,當調用next函數時,從上一個暫停點開始執行。

協程

協程(coroutine)是指多個線程互相協做,完成異步任務的一種多任務異步執行的解決方案。他的運行流程:

  • 協程A開始執行
  • 協程A執行到一半,進入暫停,執行權轉移到協程B
  • 協程B在執行一段時間後,將執行權交換給A
  • 協程A恢復執行

能夠看到這也就是Generator函數的實現方案。

宏任務和微任務

一個JS的任務能夠定義爲:在標準執行機制中,即將被調度執行的全部代碼塊。

咱們上面介紹了JS如何使用單線程完成異步多任務調用,但咱們知道JS的異步任務分不少種,如setTimeout定時器、Promise異步回調任務等,它們的執行優先級又同樣嗎?

答案是不。JS在異步任務上有更細緻的劃分,它分爲兩種:

  • 宏任務(macrotask)包含:

    • 執行的一段JS代碼塊,如控制檯、script元素中包含的內容。
    • 事件綁定的回調函數,如點擊事件。
    • 定時器建立的回調,如setTimeout和setInterval。
  • 微任務(microtask)包含:

    • Promise對象的thenable函數。
    • Nodejs中的process.nextTick函數。
    • JS專用的queueMicrotask()函數。

宏任務、微任務.png

宏任務和微任務都有自身的事件循環機制,也擁有獨立的事件隊列(Event Queue),都會按照隊列的順序依次執行。但宏任務和微任務主要有兩點區別:

  1. 宏任務執行完成,在控制權交還給主線程執行其餘宏任務以前,會將微任務隊列中的全部任務執行完成。
  2. 微任務建立的新的微任務,會在下一個宏任務執行以前被繼續遍歷執行,直到微任務隊列爲空。

瀏覽器的進程和線程

瀏覽器是多進程式的,每一個頁面和插件都是一個獨立的進程,這樣能夠保證單頁面崩潰或者插件崩潰不會影響到其餘頁面和瀏覽器總體的穩定運行。

它主要包括:

  1. 主進程:負責瀏覽器界面顯示和管理,如前進、後退,新增、關閉,網絡資源的下載和管理。
  2. 第三方插件進程:當啓用插件時,每一個插件獨立一個進程。
  3. GPU進程:全局惟一,用於3D圖形繪製。
  4. Renderer渲染進程:每一個頁面一個進程,互不影響,執行事件處理、腳本執行、頁面渲染。

單頁面線程

瀏覽器的單個頁面就是一個進程,指的就是Renderer進程,而進程中又包含有多個線程用於處理不一樣的任務,主要包括:

  1. GUI渲染線程:負責HTML和CSS的構建成DOM樹,渲染頁面,好比重繪。
  2. JS引擎線程:JS內核,如Chrome的V8引擎,負責解析執行JS代碼。
  3. 事件觸發線程:如點擊等事件存在綁定回調時,觸發後會被放入宏任務事件隊列。
  4. 定時觸發器線程:setTimeout和setInterval的定時計數器,在時間到達後放入宏任務事件隊列。
  5. 異步HTTP請求線程:XMLHTTPRequest請求後新開一個線程,等待狀態改變後,若是存在回調函數,就將其放入宏任務隊列。

須要注意的是,GUI渲染進程和JS引擎進程互斥,二者只會同時執行一個。主要的緣由是爲了節流,由於JS的執行會可能屢次改變頁面,頁面的改變也會屢次調用JS,如resize。所以瀏覽器採用的策略是交替執行,每一個宏任務執行完成後,執行GUI渲染,而後執行下一個宏任務。

Webworker線程

由於JS只有一個引擎線程,同時和GUI渲染線程互斥,所以在繁重任務執行時會致使頁面卡住,因此在HTML5中支持了Webworker,它用於向瀏覽器申請一個新的子線程執行任務,並經過postMessage API來和worker線程通訊。因此咱們在繁重任務執行時,能夠選擇新開一個Worker線程來執行,並在執行結束後通訊給主線程,這樣不會影響頁面的正常渲染和使用。

總結

  1. JS是單線程、阻塞式執行語言。
  2. JS經過事件循環機制來完成異步任務併發執行。
  3. JS將任務細分爲宏任務和微任務來提供執行優先級。
  4. 瀏覽器單頁面爲一個進程,包含的JS引擎線程和GUI渲染線程互斥,能夠經過新開Web Worker線程來完成繁重的計算任務。

系統實現 (1).png

最後給你們出一個考題,能夠猜下執行的輸出結果來驗證學習成果:

function sleep(ms) {
    console.log('before first microtask init');
    new Promise(resolve => {
        console.log('first microtask');
        resolve()
    })
    .then(() => {console.log('finish first microtask')});
    console.log('after first microtask init');
    return new Promise(resolve => {
          console.log('second microtask');
        setTimeout(resolve, ms);
    });
}
setTimeout(async () => {
    console.log('start task');
    await sleep(3000);
    console.log('end task');
}, 0);
setTimeout(() => console.log('add event'), 0);
console.log('main thread');

輸出爲:

main thread
start task
before first microtask init
first microtask
after first microtask init
second microtask
finish first microtask
add event
end task

參考資料

  1. 這一次,完全弄懂 JavaScript 執行機制:https://juejin.im/post/59e85e...
  2. 併發模型與事件循環:https://developer.mozilla.org...
  3. http://www.alloyteam.com/2016/05/javascript-timer/
  4. https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers
  5. https://developer.mozilla.org/zh-CN/docs/Web/API/Window/setTimeout
  6. http://es6.ruanyifeng.com/#docs/generator-async#%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5
  7. http://www.javashuo.com/article/p-ykilsgsq-gs.html
相關文章
相關標籤/搜索