JavaScript 事件迴圈及非同步原理(完全指北)

鬼鬼鬼發表於2018-10-13


引言

最近面試被問到,JS 既然是單執行緒的,為什麼可以執行非同步操作? 當時腦子蒙了,思維一直被困在 單執行緒 這個問題上,一直在思考單執行緒為什麼可以額外執行任務,其實在我很早以前寫的部落格裡面有寫相關的內容,只不過時間太長給忘了,所以要經常溫習啊:(淺談 Generator 和 Promise 的原理及實現)

  1. JS 是單執行緒的,只有一個主執行緒
  2. 函式內的程式碼從上到下順序執行,遇到被呼叫的函式先進入被呼叫函式執行,待完成後繼續執行
  3. 遇到非同步事件,瀏覽器另開一個執行緒,主執行緒繼續執行,待結果返回後,執行回撥函式

其實 JS 這個語言是執行在宿主環境中,比如 瀏覽器環境nodeJs環境

  • 在瀏覽器中,瀏覽器負責提供這個額外的執行緒
  • Node 中,Node.js 藉助 libuv 來作為抽象封裝層, 從而遮蔽不同作業系統的差異,Node可以藉助libuv來實現多執行緒。

而這個非同步執行緒又分為 微任務巨集任務,本篇文章就來探究一下 JS 的非同步原理以及其事件迴圈機制

為什麼 JavaScript 是單執行緒的

JavaScript 語言的一大特點就是單執行緒,也就是說,同一個時間只能做一件事。這樣設計的方案主要源於其語言特性,因為 JavaScript 是瀏覽器指令碼語言,它可以操縱 DOM ,可以渲染動畫,可以與使用者進行互動,如果是多執行緒的話,執行順序無法預知,而且操作以哪個執行緒為準也是個難題。

所以,為了避免複雜性,從一誕生,JavaScript就是單執行緒,這已經成了這門語言的核心特徵,將來也不會改變。

HTML5 時代,瀏覽器為了充分發揮 CPU 效能優勢,允許 JavaScript 建立多個執行緒,但是即使能額外建立執行緒,這些子執行緒仍然是受到主執行緒控制,而且不得操作 DOM,類似於開闢一個執行緒來運算複雜性任務,運算好了通知主執行緒運算完畢,結果給你,這類似於非同步的處理方式,所以本質上並沒有改變 JavaScript 單執行緒的本質。

函式呼叫棧與任務佇列

函式呼叫棧

JavaScript 只有一個主執行緒和一個呼叫棧(call stack),那什麼是呼叫棧呢?

這類似於一個乒乓球桶,第一個放進去的乒乓球會最後一個拿出來。

舉個栗子:

function a() {  
  console.log("I'm a!");
};

function b() {  
  a();
  console.log("I'm b!");
};

b();
複製程式碼

執行過程如下所示:

  • 第一步,執行這個檔案,此檔案會被壓入呼叫棧(例如此檔名為 main.js

    call stack

    main.js
  • 第二步,遇到 b() 語法,呼叫 b() 方法,此時呼叫棧會壓入此方法進行呼叫:

    call stack

    b()
    main.js
  • 第三步:呼叫 b() 函式時,內部呼叫的 a() ,此時 a() 將壓入呼叫棧:

    call stack

    a()
    b()
    main.js
  • 第四步:a() 呼叫完畢輸出 I'm a!,呼叫棧將 a() 彈出,就變成如下:

    call stack

    b()
    main.js
  • 第五步:b()呼叫完畢輸出I'm b!,呼叫棧將 b() 彈出,變成如下:

    call stack

    main.js
  • 第六步:main.js 這個檔案執行完畢,呼叫棧將 b() 彈出,變成一個空棧,等待下一個任務執行:

    call stack

這就是一個簡單的呼叫棧,在呼叫棧中,前一個函式在執行的時候,下面的函式全部需要等待前一個任務執行完畢,才能執行。

但是,有很多工需要很長時間才能完成,如果一直都在等待的話,呼叫棧的效率極其低下,這時,JavaScript 語言設計者意識到,這些任務主執行緒根本不需要等待,只要將這些任務掛起,先運算後面的任務,等到執行完畢了,再回頭將此任務進行下去,於是就有了 任務佇列 的概念。

任務佇列

所有任務可以分成兩種,一種是 同步任務(synchronous),另一種是 非同步任務(asynchronous)

同步任務指的是,在主執行緒上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務。

非同步任務指的是,不進入主執行緒、而進入"任務佇列"(task queue)的任務,只有 "任務佇列"通知主執行緒,某個非同步任務可以執行了,該任務才會進入主執行緒執行。

所以,當在執行過程中遇到一些類似於 setTimeout 等非同步操作的時候,會交給瀏覽器的其他模組進行處理,當到達 setTimeout 指定的延時執行的時間之後,回撥函式會放入到任務佇列之中。

當然,一般不同的非同步任務的回撥函式會放入不同的任務佇列之中。等到呼叫棧中所有任務執行完畢之後,接著去執行任務佇列之中的回撥函式。

用一張圖來表示就是:

image-20181011232324823

上圖中,呼叫棧先進行順序呼叫,一旦發現非同步操作的時候就會交給瀏覽器核心的其他模組進行處理,對於 Chrome 瀏覽器來說,這個模組就是 webcore 模組,上面提到的非同步API,webcore 分別提供了 DOM Bindingnetworktimer 模組進行處理。等到這些模組處理完這些操作的時候將回撥函式放入任務佇列中,之後等棧中的任務執行完之後再去執行任務佇列之中的回撥函式。

我們先來看一個有意思的現象,我執行一段程式碼,大家覺得輸出的順序是什麼:

  setTimeout(() => {
    console.log('setTimeout')
  }, 22)
  for (let i = 0; i++ < 2;) {
    i === 1 && console.log('1')
  }
  setTimeout(() => {
    console.log('set2')
  }, 20)
  for (let i = 0; i++ < 100000000;) {
    i === 99999999 && console.log('2')
  }
複製程式碼

沒錯!結果很量子化:

QQ20181012-101019-HD

那麼這實際上是一個什麼過程呢?那我就拿上面的一個過程解析一下:

  • 首先,檔案入棧

    image-20181012102607896

  • 開始執行檔案,讀取到第一行程式碼,當遇到 setTimeout 的時候,執行引擎將其新增到棧中。(由於字型太細我調粗了一點。。。)

    image-20181012103026018

  • 呼叫棧發現 setTimeoutWebapis中的 API,因此將其交給瀏覽器的 timer 模組進行處理,同時處理下一個任務。

image-20181012134531903

  • 第二個 setTimeout 入棧

    image-20181012134755318

  • 同上所示,非同步請求被放入 非同步API 進行處理,同時進行下一個入棧操作:

    image-20181012135048171

  • 在進行非同步的同時,app.js 檔案呼叫完畢,彈出呼叫棧,非同步執行完畢後,會將回撥函式放入任務佇列:

    image-20181012140221038

  • 任務佇列通知呼叫棧,我這邊有任務還沒有執行,呼叫棧則會執行任務佇列裡的任務:

    image-20181012140632679

    image-20181012140723756

上面的流程解釋了瀏覽器遇到 setTimeout 之後究竟如何執行的,其實總結下來就是以下幾點:

  1. 呼叫棧順序呼叫任務
  2. 當呼叫棧發現非同步任務時,將非同步任務交給其他模組處理,自己繼續進行下面的呼叫
  3. 非同步執行完畢,非同步模組將任務推入任務佇列,並通知呼叫棧
  4. 呼叫棧在執行完當前任務後,將執行任務佇列裡的任務
  5. 呼叫棧執行完任務佇列裡的任務之後,繼續執行其他任務

這一整個流程就叫做 事件迴圈(Event Loop)

那麼,瞭解了這麼多,小夥伴們能從事件迴圈上面來解析下面程式碼的輸出嗎?

  for (var i = 0; i < 10; i++) {
    setTimeout(() => {
      console.log(i)
    }, 1000)
  }
  console.log(i)
複製程式碼

解析:

  • 首先由於 var 的變數提升,i 在全域性作用域都有效
  • 再次,程式碼遇到 setTimeout 之後,將該函式交給其他模組處理,自己繼續執行 console.log(i) ,由於變數提升,i 已經迴圈10次,此時 i 的值為 10 ,即,輸出 10
  • 之後,非同步模組處理好函式之後,將回撥推入任務佇列,並通知呼叫棧
  • 1秒之後,呼叫棧順序執行回撥函式,由於此時 i 已經變成 10 ,即輸出10次 10

用下圖示意:

image-20181012152514598

現在小夥伴們是否已經恍然大悟,從底層瞭解了為什麼這個程式碼會輸出這個內容吧:

image-20181012152640173

那麼問題又來了,我們看下面的程式碼:

  setTimeout(() => {
    console.log(4)
  }, 0);
  new Promise((resolve) =>{
    console.log(1);
    for (var i = 0; i < 10000000; i++) {
      i === 9999999 && resolve();
    }
    console.log(2);
  }).then(() => {
    console.log(5);
  });
  console.log(3);
複製程式碼

大家覺得這個輸出是多少呢?

有小夥伴就開始分析了,promise 也是非同步,先執行裡面函式的內容,輸出 12,然後執行下面的函式,輸出 3 ,但 Promise 裡面需要迴圈999萬次,setTimeout 卻是0毫秒執行,setTimeout 應該立即推入執行棧, Promise 後推入執行棧,結果應該是下圖:

image-20181012161137385

實際上答案是 1,2,3,5,4 噢,這是為什麼呢?這就涉及到任務佇列的內部,巨集任務和微任務。

巨集任務和微任務

什麼是巨集任務和微任務

任務佇列又分為 macro-task(巨集任務)micro-task(微任務) ,在最新標準中,它們被分別稱為 taskjobs

  • macro-task(巨集任務)大概包括:script(整體程式碼), setTimeout, setInterval, setImmediate(NodeJs), I/O, UI rendering
  • micro-task(微任務)大概包括: process.nextTick(NodeJs), Promise, Object.observe(已廢棄), MutationObserver(html5新特性)
  • 來自不同任務源的任務會進入到不同的任務佇列。其中 setTimeoutsetInterval 是同源的。

事實上,事件迴圈決定了程式碼的執行順序,從全域性上下文進入函式呼叫棧開始,直到呼叫棧清空,然後執行所有的micro-task(微任務),當所有的micro-task(微任務)執行完畢之後,再執行macro-task(巨集任務),其中一個macro-task(巨集任務)的任務佇列執行完畢(例如setTimeout 佇列),再次執行所有的micro-task(微任務),一直迴圈直至執行完畢。

解析

現在我就開始解析上面的程式碼。

  • 第一步,整體程式碼 script 入棧,並執行 setTimeout 後,執行 Promise

    image-20181013144141327

  • 第二步,執行時遇到 Promise 例項,Promise 建構函式中的第一個引數,是在new的時候執行,因此不會進入任何其他的佇列,而是直接在當前任務直接執行了,而後續的.then則會被分發到micro-taskPromise佇列中去。

    image-20181013144638756

    image-20181013144902587

  • 第三步,呼叫棧繼續執行巨集任務 app.js,輸出3並彈出呼叫棧,app.js 執行完畢彈出呼叫棧:

    image-20181013145222565

    image-20181013145713234

  • 第四步,這時,macro-task(巨集任務)中的 script 佇列執行完畢,事件迴圈開始執行所有的 micro-task(微任務)

    image-20181013150040555

  • 第五步,呼叫棧發現所有的 micro-task(微任務) 都已經執行完畢,又跑去macro-task(巨集任務)呼叫 setTimeout 佇列:

    image-20181013150354612

  • 第六步,macro-task(巨集任務) setTimeout 佇列執行完畢,呼叫棧又跑去微任務進行查詢是否有未執行的微任務,發現沒有就跑去巨集任務執行下一個佇列,發現巨集任務也沒有佇列執行,此次呼叫結束,輸出內容1,2,3,5,4

那麼上面這個例子的輸出結果就顯而易見。大家可以自行嘗試體會。

總結

  1. 不同的任務會放進不同的任務佇列之中。
  2. 先執行macro-task,等到函式呼叫棧清空之後再執行所有在佇列之中的micro-task
  3. 等到所有micro-task執行完之後再從macro-task中的一個任務佇列開始執行,就這樣一直迴圈。
  4. 巨集任務和微任務的佇列執行順序排列如下:
  5. macro-task(巨集任務)script(整體程式碼), setTimeout, setInterval, setImmediate(NodeJs), I/O, UI rendering
  6. micro-task(微任務): process.nextTick(NodeJs), Promise, Object.observe(已廢棄), MutationObserver(html5新特性)

進階舉例

那麼,我再來一些有意思一點的程式碼:

<script>
  setTimeout(() => {
    console.log(4)
  }, 0);
  new Promise((resolve) => {
    console.log(1);
    for (var i = 0; i < 10000000; i++) {
      i === 9999999 && resolve();
    }
    console.log(2);
  }).then(() => {
    console.log(5);
  });
  console.log(3);
</script>
<script>
  console.log(6)
  new Promise((resolve) => {
    resolve()
  }).then(() => {
    console.log(7);
  });
</script>
複製程式碼

這一段程式碼輸出的順序是什麼呢?

其實,看明白上面流程的同學應該知道整個流程,為了防止一些同學不明白,我再簡單分析一下:

  • 首先,script1 進入任務佇列(為了方便起見,我把兩塊script 命名為script1script2):

    image-20181013152218883

  • 第二步,script1 進行呼叫並彈出呼叫棧:

    image-20181013152506563

  • 第三步,script1執行完畢,呼叫棧清空後,直接調取所有微任務:

    image-20181013152844991

  • 第四步,所有微任務執行完畢之後,呼叫棧會繼續呼叫巨集任務佇列:

    image-20181013153031374

  • 第五步,執行 script2,並彈出:

    image-20181013153426912

  • 第六步,呼叫棧開始執行微任務:

    image-20181013153503105

  • 第七步,呼叫棧呼叫完所有微任務,又跑去執行巨集任務:

    image-20181013153654938

至此,所有任務執行完畢,輸出 1,2,3,5,6,7,4

瞭解了上面的內容,我覺得再複雜一點非同步呼叫關係你也能搞定:

setImmediate(() => {
    console.log(1);
},0);
setTimeout(() => {
    console.log(2);
},0);
new Promise((resolve) => {
    console.log(3);
    resolve();
    console.log(4);
}).then(() => {
    console.log(5);
});
console.log(6);
process.nextTick(()=> {
    console.log(7);
});
console.log(8);
//輸出結果是3 4 6 8 7 5 2 1
複製程式碼

image-20181013163225154

終極測試

setTimeout(() => {
    console.log('to1');
    process.nextTick(() => {
        console.log('to1_nT');
    })
    new Promise((resolve) => {
        console.log('to1_p');
        setTimeout(() => {
          console.log('to1_p_to')
        })
        resolve();
    }).then(() => {
        console.log('to1_then')
    })
})

setImmediate(() => {
    console.log('imm1');
    process.nextTick(() => {
        console.log('imm1_nT');
    })
    new Promise((resolve) => {
        console.log('imm1_p');
        resolve();
    }).then(() => {
        console.log('imm1_then')
    })
})

process.nextTick(() => {
    console.log('nT1');
})
new Promise((resolve) => {
    console.log('p1');
    resolve();
}).then(() => {
    console.log('then1')
})

setTimeout(() => {
    console.log('to2');
    process.nextTick(() => {
        console.log('to2_nT');
    })
    new Promise((resolve) => {
        console.log('to2_p');
        resolve();
    }).then(() => {
        console.log('to2_then')
    })
})

process.nextTick(() => {
    console.log('nT2');
})

new Promise((resolve) => {
    console.log('p2');
    resolve();
}).then(() => {
    console.log('then2')
})

setImmediate(() => {
    console.log('imm2');
    process.nextTick(() => {
        console.log('imm2_nT');
    })
    new Promise((resolve) => {
        console.log('imm2_p');
        resolve();
    }).then(() => {
        console.log('imm2_then')
    })
})
// 輸出結果是:?
複製程式碼

大家可以在評論裡留言結果喲~


  • 2018年10月15日更新

P.S. 有同學問我 ajax 是屬於哪種任務,我發現我忘了寫了,ajax 屬於 巨集任務 ,具體排序為

script > DOM(onclick,onscroll...) > ajax > setTimeout > setInterval > setImmediate(NodeJs)> I/O > UI rendering

相關文章