最後一次搞懂 Event Loop

YanceyOfficial發表於2019-04-21

Event Loop 是 JavaScript 非同步程式設計的核心思想,也是前端進階必須跨越的一關。同時,它又是面試的必考點,特別是在 Promise 出現之後,各種各樣的面試題層出不窮,花樣百出。這篇文章從現實生活中的例子入手,讓你徹底理解 Event Loop 的原理和機制,並能遊刃有餘的解決此類面試題。

宇宙條那道爛大街的筆試題鎮樓

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
async1();
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2');
});
console.log('script end');
複製程式碼

為什麼 JavaScript 是單執行緒的?

我們都知道 JavaScript 是一門 單執行緒 語言,也就是說同一時間只能做一件事。這是因為 JavaScript 生來作為瀏覽器指令碼語言,主要用來處理與使用者的互動、網路以及操作 DOM。這就決定了它只能是單執行緒的,否則會帶來很複雜的同步問題。

假設 JavaScript 有兩個執行緒,一個執行緒在某個 DOM 節點上新增內容,另一個執行緒刪除了這個節點,這時瀏覽器應該以哪個執行緒為準?

既然 Javascript 是單執行緒的,它就像是隻有一個視窗的銀行,客戶不得不排隊一個一個的等待辦理。同理 JavaScript 的任務也要一個接一個的執行,如果某個任務(比如載入高清圖片)是個耗時任務,那瀏覽器豈不得一直卡著?為了防止主執行緒的阻塞,JavaScript 有了 同步非同步 的概念。

同步和非同步

同步

如果在一個函式返回的時候,呼叫者就能夠得到預期結果,那麼這個函式就是同步的。也就是說同步方法呼叫一旦開始,呼叫者必須等到該函式呼叫返回後,才能繼續後續的行為。下面這段段程式碼首先會彈出 alert 框,如果你不點選 確定 按鈕,所有的頁面互動都被鎖死,並且後續的 console 語句不會被列印出來。

alert('Yancey');
console.log('is');
console.log('the');
console.log('best');
複製程式碼

非同步

如果在函式返回的時候,呼叫者還不能夠得到預期結果,而是需要在將來通過一定的手段得到,那麼這個函式就是非同步的。比如說發一個網路請求,我們告訴主程式等到接收到資料後再通知我,然後我們就可以去做其他的事情了。當非同步完成後,會通知到我們,但是此時可能程式正在做其他的事情,所以即使非同步完成了也需要在一旁等待,等到程式空閒下來才有時間去看哪些非同步已經完成了,再去執行。

這也就是定時器並不能精確在指定時間後輸出回撥函式結果的原因。

setTimeout(() => {
  console.log('yancey');
}, 1000);

for (let i = 0; i < 100000000; i += 1) {
  // todo
}
複製程式碼

執行棧和任務佇列

複習下資料結構吧

  • 棧 (stack): 後進先出,儲存基本資料型別和物件的指標,有 push() 和 pop() 這兩個方法

  • 佇列 (stack): 後進後出,有 shift() 和 unshift() 這兩個方法

  • 堆 (heap): 儲存物件

棧/佇列

如上圖所示,JavaScript 中的記憶體分為 堆記憶體 (heap)棧記憶體 (stack),

JavaScript 中引用型別值的大小是不固定的,因此它們會被儲存到 堆記憶體 中。JavaScript 不允許直接訪問堆記憶體中的位置,因此我們不能直接操作物件的堆記憶體空間,而是操作 物件的引用

而 JavaScript 中的基礎資料型別都有固定的大小,因此它們被儲存到 棧記憶體 中,由系統自動分配儲存空間。我們可以直接操作儲存在棧記憶體空間的值,因此基礎資料型別都是 按值訪問。此外,棧記憶體還會儲存 物件的引用 (指標) 以及 函式執行時的執行空間

下面比較一下兩種儲存方式的不同。

棧記憶體 堆記憶體
儲存基礎資料型別 儲存引用資料型別
按值訪問 按引用訪問
儲存的值大小固定 儲存的值大小不定,可動態調整
由系統自動分配記憶體空間 由程式設計師通過程式碼進行分配
主要用來執行程式 主要用來存放物件
空間小,執行效率高 空間大,但是執行效率相對較低
先進後出,後進先出 無序儲存,可根據引用直接獲取

執行棧

當我們呼叫一個方法的時候,JavaScript 會生成一個與這個方法對應的執行環境,又叫執行上下文(context)。這個執行環境中儲存著該方法的私有作用域、上層作用域(作用域鏈)、方法的引數,以及這個作用域中定義的變數和 this 的指向,而當一系列方法被依次呼叫的時候。由於 JavaScript 是單執行緒的,這些方法就會按順序被排列在一個單獨的地方,這個地方就是所謂執行棧。

任務佇列

事件佇列是一個儲存著 非同步任務 的佇列,其中的任務嚴格按照時間先後順序執行,排在隊頭的任務將會率先執行,而排在隊尾的任務會最後執行。事件佇列每次僅執行一個任務,在該任務執行完畢之後,再執行下一個任務。執行棧則是一個類似於函式呼叫棧的執行容器,當執行棧為空時,JS 引擎便檢查事件佇列,如果不為空的話,事件佇列便將第一個任務壓入執行棧中執行。

事件迴圈

我們注意到,在非同步程式碼完成後仍有可能要在一旁等待,因為此時程式可能在做其他的事情,等到程式空閒下來才有時間去看哪些非同步已經完成了。所以 JavaScript 有一套機制去處理同步和非同步操作,那就是事件迴圈 (Event Loop)。

下面就是事件迴圈的示意圖。

事件迴圈示意圖

用文字描述的話,大致是這樣的:

  • 所有同步任務都在主執行緒上執行,形成一個執行棧 (Execution Context Stack)。

  • 而非同步任務會被放置到 Task Table,也就是上圖中的非同步處理模組,當非同步任務有了執行結果,就將該函式移入任務佇列。

  • 一旦執行棧中的所有同步任務執行完畢,引擎就會讀取任務佇列,然後將任務佇列中的第一個任務壓入執行棧中執行。

主執行緒不斷重複第三步,也就是 只要主執行緒空了,就會去讀取任務佇列,該過程不斷重複,這就是所謂的 事件迴圈

巨集任務和微任務

微任務、巨集任務與 Event-Loop 這篇文章用了很有趣的例子來解釋巨集任務和微任務,下面 copy 一下。

還是以去銀行辦業務為例,當 5 號視窗櫃員處理完當前客戶後,開始叫號來接待下一位客戶,我們將每個客戶比作 巨集任務接待下一位客戶 的過程也就是讓下一個 巨集任務 進入到執行棧。

所以該視窗所有的客戶都被放入了一個 任務佇列 中。任務佇列中的都是 已經完成的非同步操作的,而不是註冊一個非同步任務就會被放在這個任務佇列中(它會被放到 Task Table 中)。就像在銀行中排號,如果叫到你的時候你不在,那麼你當前的號牌就作廢了,櫃員會選擇直接跳過進行下一個客戶的業務處理,等你回來以後還需要重新取號。

在執行巨集任務時,是可以穿插一些微任務進去。比如你大爺在辦完業務之後,順便問了下櫃員:“最近 P2P 暴雷很嚴重啊,有沒有其他穩妥的投資方式”。櫃員暗爽:“又有傻子上鉤了”,然後嘰裡咕嚕說了一堆。

我們分析一下這個過程,雖然大爺已經辦完正常的業務,但又諮詢了一下理財資訊,這時候櫃員肯定不能說:“您再上後邊取個號去,重新排隊”。所欲只要是櫃員能夠處理的,都會在響應下一個巨集任務之前來做,我們可以把這些任務理解成是 微任務

大爺聽罷,揚起 45 度微笑,說:“我就問問。”

櫃員 OS:“艹...”

這個例子就說明了:你大爺永遠是你大爺 在當前微任務沒有執行完成時,是不會執行下一個巨集任務的!

總結一下,非同步任務分為 巨集任務(macrotask)微任務 (microtask)。巨集任務會進入一個佇列,而微任務會進入到另一個不同的佇列,且微任務要優於巨集任務執行。

常見的巨集任務和微任務

巨集任務:script(整體程式碼)、setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js)

微任務:Promise.then、 MutaionObserver、process.nextTick (Node.js)

來做幾道題

看看下面這道題你能不能做出來。

setTimeout(() => {
  console.log('A');
}, 0);
var obj = {
  func: function() {
    setTimeout(function() {
      console.log('B');
    }, 0);
    return new Promise(function(resolve) {
      console.log('C');
      resolve();
    });
  },
};
obj.func().then(function() {
  console.log('D');
});
console.log('E');
複製程式碼
  • 第一個 setTimeout 放到巨集任務佇列,此時巨集任務佇列為 ['A']

  • 接著執行 obj 的 func 方法,將 setTimeout 放到巨集任務佇列,此時巨集任務佇列為 ['A', 'B']

  • 函式返回一個 Promise,因為這個一個同步操作,所以先列印出 'C'

  • 接著將 then 放到微任務佇列,此時微任務佇列為 ['D']

  • 接著執行同步任務 console.log('E');,列印出 'E'

  • 因為微任務優先執行,所以先輸出 'D'

  • 最後依次輸出 'A''B'

再來看一道阮一峰老師出的題目,其實也不難。

let p = new Promise(resolve => {
  resolve(1);
  Promise.resolve().then(() => console.log(2));
  console.log(4);
}).then(t => console.log(t));
console.log(3);
複製程式碼
  • 首先將 Promise.resolve() 的 then() 方法放到微任務佇列,此時微任務佇列為 ['2']

  • 接著將 p 的 then() 方法放到微任務佇列,此時微任務佇列為 ['2', '1']

  • 然後列印出同步任務 43

  • 最後依次列印微任務 21

當 Event Loop 遇到 async/await

我們知道,async/await 僅僅是生成器的語法糖,所以不要怕,只要把它轉換成 Promise 的形式即可。下面這段程式碼是 async/await 函式的經典形式。

async function foo() {
  // await 前面的程式碼
  await bar();
  // await 後面的程式碼
}

async function bar() {
  // do something...
}

foo();
複製程式碼

其中 await 前面的程式碼 是同步的,呼叫此函式時會直接執行;而 await bar(); 這句可以被轉換成 Promise.resolve(bar())await 後面的程式碼 則會被放到 Promise 的 then() 方法裡。因此上面的程式碼可以被轉換成如下形式,這樣是不是就很清晰了?

function foo() {
  // await 前面的程式碼
  Promise.resolve(bar()).then(() => {
    // await 後面的程式碼
  });
}

function bar() {
  // do something...
}

foo();
複製程式碼

回到開篇宇宙條那道爛大街的題目,我們"重構"一下程式碼,再做解析,是不是很輕鬆了?

function async1() {
  console.log('async1 start'); // 2

  Promise.resolve(async2()).then(() => {
    console.log('async1 end'); // 6
  });
}

function async2() {
  console.log('async2'); // 3
}

console.log('script start'); // 1

setTimeout(function() {
  console.log('settimeout'); // 8
}, 0);

async1();

new Promise(function(resolve) {
  console.log('promise1'); // 4
  resolve();
}).then(function() {
  console.log('promise2'); // 7
});
console.log('script end'); // 5
複製程式碼
  • 首先列印出 script start

  • 接著將 settimeout 新增到巨集任務佇列,此時巨集任務佇列為 ['settimeout']

  • 然後執行函式 async1,先列印出 async1 start,又因為 Promise.resolve(async2()) 是同步任務,所以列印出 async2,接著將 async1 end 新增到微任務佇列,,此時微任務佇列為 ['async1 end']

  • 接著列印出 promise1,將 promise2 新增到微任務佇列,,此時微任務佇列為 ['async1 end', promise2]

  • 列印出 script end

  • 因為微任務優先順序高於巨集任務,所以先依次列印出 async1 endpromise2

  • 最後列印出巨集任務 settimeout

Node.js 與 瀏覽器環境下事件迴圈的區別

Node.js 在升級到 11.x 後,Event Loop 執行原理髮生了變化,一旦執行一個階段裡的一個巨集任務(setTimeout,setInterval 和 setImmediate) 就立刻執行微任務佇列,這點就跟瀏覽器端一致。

關於 11.x 版本之前 Node.js 與 瀏覽器環境下事件迴圈的區別,可以參考 @浪裡行舟 大佬的 《瀏覽器與 Node 的事件迴圈(Event Loop)有何區別?》,這裡就不多廢話了。

淺談 Web Workers

需要強調的是,Worker 是瀏覽器 (即宿主環境) 的功能,實際上和 JavaScript 語言本身機會沒有什麼關係。也就是說,JavaScript 當前並沒有任何支援多執行緒執行的功能。

所以,JavaScript 是一門單執行緒的語言!JavaScript 是一門單執行緒的語言!JavaScript 是一門單執行緒的語言!

瀏覽器可以提供多個 JavaScript 引擎例項,各自執行在自己的執行緒上,這樣你可以在每個執行緒上執行不同的程式。程式中每一個這樣的的獨立的多執行緒部分被稱為一個 Worker。這種型別的並行化被稱為 任務並行,因為其重點在於把程式劃分為多個塊來併發執行。下面是 Worker 的運作流圖。

Web Worker 機制

Web Worker 例項

下面用一個階乘的例子淺談 Worker 的用法。

計算階乘的例項

首先新建一個 index.html ,直接上程式碼:

<body>
  <fieldset>
    <legend>計算階乘</legend>
    <input id="input" type="number" placeholder="請輸入一個正整數" />
    <button id="btn">計算</button>
    <p>計算結果:<span id="result"></span></p>
  </fieldset>
  <legend></legend>

  <script>
    const input = document.getElementById('input');
    const btn = document.getElementById('btn');
    const result = document.getElementById('result');

    btn.addEventListener('click', () => {
      const worker = new Worker('./worker.js');

      // 向 Worker 傳送訊息
      worker.postMessage(input.value);

      // 接收來自 Worker 的訊息
      worker.addEventListener('message', e => {
        result.innerHTML = e.data;

        // 使用完 Worker 後記得關閉
        worker.terminate();
      });
    });
  </script>
</body>
複製程式碼

在同目錄下新建一個 work.js,內容如下:

function memorize(f) {
  const cache = {};
  return function() {
    const key = Array.prototype.join.call(arguments, ',');
    if (key in cache) {
      return cache[key];
    } else {
      return (cache[key] = f.apply(this, arguments));
    }
  };
}

const factorial = memorize(n => {
  return n <= 1 ? 1 : n * factorial(n - 1);
});

// 監聽主執行緒發過來的訊息
self.addEventListener(
  'message',
  function(e) {
    // 響應主執行緒
    self.postMessage(factorial(e.data));
  },
  false,
);
複製程式碼

以兩道題收尾

下面的兩道題來自 @小美娜娜 的文章 Eventloop 不可怕,可怕的是遇上 Promise。抄一下不會打我吧,嗯。

第一道題

const p1 = new Promise((resolve, reject) => {
  console.log('promise1');
  resolve();
})
  .then(() => {
    console.log('then11');
    new Promise((resolve, reject) => {
      console.log('promise2');
      resolve();
    })
      .then(() => {
        console.log('then21');
      })
      .then(() => {
        console.log('then23');
      });
  })
  .then(() => {
    console.log('then12');
  });

const p2 = new Promise((resolve, reject) => {
  console.log('promise3');
  resolve();
}).then(() => {
  console.log('then31');
});
複製程式碼
  • 首先列印出 promise1

  • 接著將 then11promise2 新增到微任務佇列,此時微任務佇列為 ['then11', 'promise2']

  • 列印出 promise3,將 then31 新增到微任務佇列,此時微任務佇列為 ['then11', 'promise2', 'then31']

  • 依次列印出 then11promise2then31,此時微任務佇列為空

  • then21then12 新增到微任務佇列,此時微任務佇列為 ['then21', 'then12']

  • 依次列印出 then21then12,此時微任務佇列為空

  • then23 新增到微任務佇列,此時微任務佇列為 ['then23']

  • 列印出 then23

第二道題

這道題實際在考察 Promise 的用法,當在 then() 方法中返回一個 Promise,p1 的第二個完成處理函式就會掛在返回的這個 Promise 的 then() 方法下,因此輸出順序如下。

const p1 = new Promise((resolve, reject) => {
  console.log('promise1'); // 1
  resolve();
})
  .then(() => {
    console.log('then11'); // 2
    return new Promise((resolve, reject) => {
      console.log('promise2'); // 3
      resolve();
    })
      .then(() => {
        console.log('then21'); // 4
      })
      .then(() => {
        console.log('then23'); // 5
      });
  })
  .then(() => {
    console.log('then12'); //6
  });
複製程式碼

最後

歡迎關注我的微信公眾號:進擊的前端

進擊的前端

參考

《你不知道的 JavaScript (中卷)》—— Kyle Simpson

這一次,徹底弄懂 JavaScript 執行機制

從一道題淺說 JavaScript 的事件迴圈

微任務、巨集任務與 Event-Loop

前端基礎進階:詳細圖解 JavaScript 記憶體空間

詳解 JavaScript 中的 Event Loop(事件迴圈)機制

Eventloop 不可怕,可怕的是遇上 Promise

圖解搞懂 JavaScript 引擎 Event Loop

JavaScript 執行緒機制與事件機制

相關文章