setTimeout和setImmediate到底誰先執行,本文讓你完全理解Event Loop

筆者之前面試的時候常常遇到寫一堆setTimeout,setImmediate來問哪一個先執行。本文主要就是來說這個問題的,可是不是簡單的講講哪一個先,哪一個後。籠統的知道setImmediatesetTimeout(fn, 0)先執行是不夠的,由於有些狀況下setTimeout(fn, 0)是會比setImmediate先執行的。要完全搞明白這個問題,咱們須要系統的學習JS的異步機制和底層原理。本文就會從異步基本概念出發,一直講到Event Loop的底層原理,讓你完全搞懂setTimeout,setImmediatePromise, process.nextTick誰先誰後這一類問題。javascript

同步和異步

同步異步簡單理解就是,同步的代碼都是按照書寫順序執行的,異步的代碼可能跟書寫順序不同,寫在後面的可能先執行。下面來看個例子:html

const syncFunc = () => {
  const time = new Date().getTime();
  while(true) {
    if(new Date().getTime() - time > 2000) {
      break;
    }
  }
  console.log(2);
}

console.log(1);
syncFunc();
console.log(3);

上述代碼會先打印出1,而後調用syncFuncsyncFunc裏面while循環會運行2秒,而後打印出2,最後打印出3。因此這裏代碼的執行順序跟咱們的書寫順序是一致,他是同步代碼:前端

image-20200320144654281

再來看個異步例子:java

const asyncFunc = () => {
  setTimeout(() => {
    console.log(2);
  }, 2000);
}

console.log(1);
asyncFunc();
console.log(3);

上述代碼的輸出是:node

image-20200320145012565

能夠看到咱們中間調用的asyncFunc裏面的2倒是最後輸出的,這是由於setTimeout是一個異步方法。他的做用是設置一個定時器,等定時器時間到了再執行回調裏面的代碼。因此異步就至關於作一件事,可是並非立刻作,而是你先給別人打了個招呼,說xxx條件知足的時候就幹什麼什麼。就像你晚上睡覺前在手機上設置了一個次日早上7天的鬧鐘,就至關於給了手機一個異步事件,觸發條件是時間到達早上7點。使用異步的好處是你只須要設置好異步的觸發條件就能夠去幹別的事情了,因此異步不會阻塞主幹上事件的執行。特別是對於JS這種只有一個線程的語言,若是都像咱們第一個例子那樣去while(true),那瀏覽器就只有一直卡死了,只有等這個循環運行完纔會有響應git

JS異步是怎麼實現的

咱們都知道JS是單線程的,那單線程是怎麼實現異步的呢?事實上所謂的"JS是單線程的"只是指JS的主運行線程只有一個,而不是整個運行環境都是單線程。JS的運行環境主要是瀏覽器,以你們都很熟悉的Chrome的內核爲例,他不只是多線程的,並且是多進程的:github

image-20200320151227013

上圖只是一個歸納分類,意思是Chrome有這幾類的進程和線程,並非每種只有一個,好比渲染進程就有多個,每一個選項卡都有本身的渲染進程。有時候咱們使用Chrome會遇到某個選項卡崩潰或者沒有響應的狀況,這個選項卡對應的渲染進程可能就崩潰了,可是其餘選項卡並無用這個渲染進程,他們有本身的渲染進程,因此其餘選項卡並不會受影響。這也是Chrome單個頁面崩潰並不會致使瀏覽器崩潰的緣由,而不是像老IE那樣,一個頁面卡了致使整個瀏覽器都卡。面試

對於前端工程師來講,主要關心的仍是渲染進程,下面來分別看下里面每一個線程是作什麼的。ajax

GUI線程

GUI線程就是渲染頁面的,他解析HTML和CSS,而後將他們構建成DOM樹和渲染樹就是這個線程負責的。api

JS引擎線程

這個線程就是負責執行JS的主線程,前面說的"JS是單線程的"就是指的這個線程。大名鼎鼎的Chrome V8引擎就是在這個線程運行的。須要注意的是,這個線程跟GUI線程是互斥的。互斥的緣由是JS也能夠操做DOM,若是JS線程和GUI線程同時操做DOM,結果就混亂了,不知道到底渲染哪一個結果。這帶來的後果就是若是JS長時間運行,GUI線程就不能執行,整個頁面就感受卡死了。因此咱們最開始例子的while(true)這樣長時間的同步代碼在真正開發時是絕對不容許的

定時器線程

前面異步例子的setTimeout其實就運行在這裏,他跟JS主線程根本不在同一個地方,因此「單線程的JS」可以實現異步。JS的定時器方法還有setInterval,也是在這個線程。

事件觸發線程

定時器線程其實只是一個計時的做用,他並不會真正執行時間到了的回調,真正執行這個回調的仍是JS主線程。因此當時間到了定時器線程會將這個回調事件給到事件觸發線程,而後事件觸發線程將它加到事件隊列裏面去。最終JS主線程從事件隊列取出這個回調執行。事件觸發線程不只會將定時器事件放入任務隊列,其餘知足條件的事件也是他負責放進任務隊列。

異步HTTP請求線程

這個線程負責處理異步的ajax請求,當請求完成後,他也會通知事件觸發線程,而後事件觸發線程將這個事件放入事件隊列給主線程執行。

因此JS異步的實現靠的就是瀏覽器的多線程,當他遇到異步API時,就將這個任務交給對應的線程,當這個異步API知足回調條件時,對應的線程又經過事件觸發線程將這個事件放入任務隊列,而後主線程從任務隊列取出事件繼續執行。這個流程咱們屢次提到了任務隊列,這其實就是Event Loop,下面咱們詳細來說解下。

Event Loop

所謂Event Loop,就是事件循環,其實就是JS管理事件執行的一個流程,具體的管理辦法由他具體的運行環境肯定。目前JS的主要運行環境有兩個,瀏覽器和Node.js。這兩個環境的Event Loop還有點區別,咱們會分開來說。

瀏覽器的Event Loop

事件循環就是一個循環,是各個異步線程用來通信和協同執行的機制。各個線程爲了交換消息,還有一個公用的數據區,這就是事件隊列。各個異步線程執行完後,經過事件觸發線程將回調事件放到事件隊列,主線程每次幹完手上的活兒就來看看這個隊列有沒有新活兒,有的話就取出來執行。畫成一個流程圖就是這樣:

image-20200320161732238

流程講解以下:

  1. 主線程每次執行時,先看看要執行的是同步任務,仍是異步的API
  2. 同步任務就繼續執行,一直執行完
  3. 遇到異步API就將它交給對應的異步線程,本身繼續執行同步任務
  4. 異步線程執行異步API,執行完後,將異步回調事件放入事件隊列上
  5. 主線程手上的同步任務幹完後就來事件隊列看看有沒有任務
  6. 主線程發現事件隊列有任務,就取出裏面的任務執行
  7. 主線程不斷循環上述流程

定時器不許

Event Loop的這個流程裏面其實仍是隱藏了一些坑的,最典型的問題就是老是先執行同步任務,而後再執行事件隊列裏面的回調。這個特性就直接影響了定時器的執行,咱們想一想咱們開始那個2秒定時器的執行流程:

  1. 主線程執行同步代碼
  2. 遇到setTimeout,將它交給定時器線程
  3. 定時器線程開始計時,2秒到了通知事件觸發線程
  4. 事件觸發線程將定時器回調放入事件隊列,異步流程到此結束
  5. 主線程若是有空,將定時器回調拿出來執行,若是沒空這個回調就一直放在隊列裏。

上述流程咱們能夠看出,若是主線程長時間被阻塞,定時器回調就沒機會執行,即便執行了,那時間也不許了,咱們將開頭那兩個例子結合起來就能夠看出這個效果:

const syncFunc = (startTime) => {
  const time = new Date().getTime();
  while(true) {
    if(new Date().getTime() - time > 5000) {
      break;
    }
  }
  const offset = new Date().getTime() - startTime;
  console.log(`syncFunc run, time offset: ${offset}`);
}

const asyncFunc = (startTime) => {
  setTimeout(() => {
    const offset = new Date().getTime() - startTime;
    console.log(`asyncFunc run, time offset: ${offset}`);
  }, 2000);
}

const startTime = new Date().getTime();

asyncFunc(startTime);

syncFunc(startTime);

執行結果以下:

image-20200320163640760

經過結果能夠看出,雖然咱們先調用的asyncFunc,雖然asyncFunc寫的是2秒後執行,可是syncFunc的執行時間太長,達到了5秒,asyncFunc雖然在2秒的時候就已經進入了事件隊列,可是主線程一直在執行同步代碼,一直沒空,因此也要等到5秒後,同步代碼執行完畢纔有機會執行這個定時器回調。因此再次強調,寫代碼時必定不要長時間佔用主線程

引入微任務

前面的流程圖我爲了便於理解,簡化了事件隊列,其實事件隊列裏面的事件還能夠分兩類:宏任務和微任務。微任務擁有更高的優先級,當事件循環遍歷隊列時,先檢查微任務隊列,若是裏面有任務,就所有拿來執行,執行完以後再執行一個宏任務。執行每一個宏任務以前都要檢查下微任務隊列是否有任務,若是有,優先執行微任務隊列。因此完整的流程圖以下:

image-20200322201434386

上圖須要注意如下幾點:

  1. 一個Event Loop能夠有一個或多個事件隊列,可是隻有一個微任務隊列。
  2. 微任務隊列所有執行完會從新渲染一次
  3. 每一個宏任務執行完都會從新渲染一次
  4. requestAnimationFrame處於渲染階段,不在微任務隊列,也不在宏任務隊列

因此想要知道一個異步API在哪一個階段執行,咱們得知道他是宏任務仍是微任務。

常見宏任務有:

  1. script (能夠理解爲外層同步代碼)
  2. setTimeout/setInterval
  3. setImmediate(Node.js)
  4. I/O
  5. UI事件
  6. postMessage

常見微任務有:

  1. Promise
  2. process.nextTick(Node.js)
  3. Object.observe
  4. MutaionObserver

上面這些事件類型中要注意Promise,他是微任務,也就是說他會在定時器前面運行,咱們來看個例子:

console.log('1');
setTimeout(() => {
  console.log('2');
},0);
Promise.resolve().then(() => {
  console.log('5');
})
new Promise((resolve) => {
  console.log('3');
  resolve();
}).then(() => {
  console.log('4');
})

上述代碼的輸出是1,3,5,4,2。由於:

  1. 先輸出1,這個沒什麼說的,同步代碼最早執行
  2. console.log('2');setTimeout裏面,setTimeout是宏任務,「2」進入宏任務隊列
  3. console.log('5');Promise.then裏面,進入微任務隊列
  4. console.log('3');在Promise構造函數的參數裏面,這實際上是同步代碼,直接輸出
  5. console.log('4');在then裏面,他會進入微任務隊列,檢查事件隊列時先執行微任務
  6. 同步代碼運行結果是「1,3」
  7. 而後檢查微任務隊列,輸出「5,4」
  8. 最後執行宏任務隊列,輸出「2」

Node.js的Event Loop

Node.js是運行在服務端的js,雖然他也用到了V8引擎,可是他的服務目的和環境不一樣,致使了他API與原生JS有些區別,他的Event Loop還要處理一些I/O,好比新的網絡鏈接等,因此與瀏覽器Event Loop也是不同的。Node的Event Loop是分階段的,以下圖所示:

image-20200322203318743

  1. timers: 執行setTimeoutsetInterval的回調
  2. pending callbacks: 執行延遲到下一個循環迭代的 I/O 回調
  3. idle, prepare: 僅系統內部使用
  4. poll: 檢索新的 I/O 事件;執行與 I/O 相關的回調。事實上除了其餘幾個階段處理的事情,其餘幾乎全部的異步都在這個階段處理。
  5. check: setImmediate在這裏執行
  6. close callbacks: 一些關閉的回調函數,如:socket.on('close', ...)

每一個階段都有一個本身的先進先出的隊列,只有當這個隊列的事件執行完或者達到該階段的上限時,纔會進入下一個階段。在每次事件循環之間,Node.js都會檢查它是否在等待任何一個I/O或者定時器,若是沒有的話,程序就關閉退出了。咱們的直觀感覺就是,若是一個Node程序只有同步代碼,你在控制檯運行完後,他就本身退出了。

還有個須要注意的是poll階段,他後面並不必定每次都是check階段,poll隊列執行完後,若是沒有setImmediate可是有定時器到期,他會繞回去執行定時器階段:

image-20200322205308151

setImmediatesetTimeout

上面的這個流程說簡單點就是在一個異步流程裏,setImmediate會比定時器先執行,咱們寫點代碼來試試:

console.log('outer');

setTimeout(() => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
  });
}, 0);

上述代碼運行以下:

image-20200322210304757

和咱們前面講的同樣,setImmediate先執行了。咱們來理一下這個流程:

  1. 外層是一個setTimeout,因此執行他的回調的時候已經在timers階段了
  2. 處理裏面的setTimeout,由於本次循環的timers正在執行,因此他的回調其實加到了下個timers階段
  3. 處理裏面的setImmediate,將它的回調加入check階段的隊列
  4. 外層timers階段執行完,進入pending callbacksidle, preparepoll,這幾個隊列都是空的,因此繼續往下
  5. 到了check階段,發現了setImmediate的回調,拿出來執行
  6. 而後是close callbacks,隊列時空的,跳過
  7. 又是timers階段,執行咱們的console

可是請注意咱們上面console.log('setTimeout')console.log('setImmediate')都包在了一個setTimeout裏面,若是直接寫在最外層會怎麼樣呢?代碼改寫以下:

console.log('outer');

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

setImmediate(() => {
  console.log('setImmediate');
});

咱們來運行下看看效果:

image-20200322214105295

好像是setTimeout先輸出來,咱們多運行幾回看看:

image-20200322214148090

怎麼setImmediate又先出來了,這代碼是見鬼了仍是啥?這個世界上是沒有鬼怪的,因此事情都有緣由的,咱們順着以前的Event Loop再來理一下。在理以前,須要告訴你們一件事情,node.js裏面setTimeout(fn, 0)會被強制改成setTimeout(fn, 1),這在官方文檔中有說明。(說到這裏順便提下,HTML 5裏面setTimeout最小的時間限制是4ms)。原理咱們都有了,咱們來理一下流程:

  1. 外層同步代碼一次性所有執行完,遇到異步API就塞到對應的階段
  2. 遇到setTimeout,雖然設置的是0毫秒觸發,可是被node.js強制改成1毫秒,塞入times階段
  3. 遇到setImmediate塞入check階段
  4. 同步代碼執行完畢,進入Event Loop
  5. 先進入times階段,檢查當前時間過去了1毫秒沒有,若是過了1毫秒,知足setTimeout條件,執行回調,若是沒過1毫秒,跳過
  6. 跳過空的階段,進入check階段,執行setImmediate回調

經過上述流程的梳理,咱們發現關鍵就在這個1毫秒,若是同步代碼執行時間較長,進入Event Loop的時候1毫秒已通過了,setTimeout執行,若是1毫秒還沒到,就先執行了setImmediate。每次咱們運行腳本時,機器狀態可能不同,致使運行時有1毫秒的差距,一下子setTimeout先執行,一下子setImmediate先執行。可是這種狀況只會發生在還沒進入timers階段的時候。像咱們第一個例子那樣,由於已經在timers階段,因此裏面的setTimeout只能等下個循環了,因此setImmediate確定先執行。同理的還有其餘poll階段的API也是這樣的,好比:

var fs = require('fs')

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('setTimeout');
    }, 0);
    setImmediate(() => {
        console.log('setImmediate');
    });
});

這裏setTimeoutsetImmediatereadFile的回調裏面,因爲readFile回調是I/O操做,他自己就在poll階段,因此他裏面的定時器只能進入下個timers階段,可是setImmediate卻能夠在接下來的check階段運行,因此setImmediate確定先運行,他運行完後,去檢查timers,纔會運行setTimeout

相似的,咱們再來看一段代碼,若是他們兩個不是在最外層,而是在setImmediate的回調裏面,其實狀況跟外層同樣,結果也是隨緣的,看下面代碼:

console.log('outer');

setImmediate(() => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
  });
});

緣由跟寫在最外層差很少,由於setImmediate已經在check階段了,裏面的循環會從timers階段開始,會先看setTimeout的回調,若是這時候已通過了1毫秒,就執行他,若是沒過就執行setImmediate

process.nextTick()

process.nextTick()是一個特殊的異步API,他不屬於任何的Event Loop階段。事實上Node在遇到這個API時,Event Loop根本就不會繼續進行,會立刻停下來執行process.nextTick(),這個執行完後纔會繼續Event Loop。咱們寫個例子來看下:

var fs = require('fs')

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('setTimeout');
    }, 0);

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

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

這段代碼的打印以下:

image-20200322221221927

咱們仍是來理一下流程:

  1. 咱們代碼基本都在readFile回調裏面,他本身執行時,已經在poll階段
  2. 遇到setTimeout(fn, 0),實際上是setTimeout(fn, 1),塞入後面的timers階段
  3. 遇到setImmediate,塞入後面的check階段
  4. 遇到nextTick,立馬執行,輸出'nextTick 1'
  5. 到了check階段,輸出'setImmediate',又遇到個nextTick,立馬輸出'nextTick 2'
  6. 到了下個timers階段,輸出'setTimeout'

這種機制其實相似於咱們前面講的微任務,可是並不徹底同樣,好比同時有nextTickPromise的時候,確定是nextTick先執行,緣由是nextTick的隊列比Promise隊列優先級更高。來看個例子:

const promise = Promise.resolve()
setImmediate(() => {
  console.log('setImmediate');
});
promise.then(()=>{
    console.log('promise')
})
process.nextTick(()=>{
    console.log('nextTick')
})

代碼運行結果以下:

image-20200323094907234

總結

本文從異步基本概念出發一直講到了瀏覽器和Node.js的Event Loop,如今咱們再來總結一下:

  1. JS所謂的「單線程」只是指主線程只有一個,並非整個運行環境都是單線程
  2. JS的異步靠底層的多線程實現
  3. 不一樣的異步API對應不一樣的實現線程
  4. 異步線程與主線程通信靠的是Event Loop
  5. 異步線程完成任務後將其放入任務隊列
  6. 主線程不斷輪詢任務隊列,拿出任務執行
  7. 任務隊列有宏任務隊列和微任務隊列的區別
  8. 微任務隊列的優先級更高,全部微任務處理完後纔會處理宏任務
  9. Promise是微任務
  10. Node.js的Event Loop跟瀏覽器的Event Loop不同,他是分階段的
  11. setImmediatesetTimeout(fn, 0)哪一個回調先執行,須要看他們自己在哪一個階段註冊的,若是在定時器回調或者I/O回調裏面,setImmediate確定先執行。若是在最外層或者setImmediate回調裏面,哪一個先執行取決於當時機器情況。
  12. process.nextTick不在Event Loop的任何階段,他是一個特殊API,他會當即執行,而後纔會繼續執行Event Loop

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

做者博文GitHub項目地址: https://github.com/dennis-jiang/Front-End-Knowledges

做者掘金文章彙總:https://juejin.im/post/5e3ffc85518825494e2772fd

相關文章
相關標籤/搜索