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

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

同步和異步

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

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。所以這里代碼的執行順序跟我們的書寫順序是一致,他是同步代碼:

再來看個異步例子:

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

上述代碼的輸出是:

可以看到我們中間調用的asyncFunc里面的2卻是最后輸出的,這是因為setTimeout是一個異步方法。他的作用是設置一個定時器,等定時器時間到了再執行回調里面的代碼。所以異步就相當于做一件事,但是并不是馬上做,而是你先給別人打了個招呼,說xxx條件滿足的時候就干什么什么。就像你晚上睡覺前在手機上設置了一個第二天早上7天的鬧鐘,就相當于給了手機一個異步事件,觸發條件是時間到達早上7點。使用異步的好處是你只需要設置好異步的觸發條件就可以去干別的事情了,所以異步不會阻塞主干上事件的執行。特別是對于JS這種只有一個線程的語言,如果都像我們第一個例子那樣去while(true),那瀏覽器就只有一直卡死了,只有等這個循環運行完才會有響應

JS異步是怎么實現的

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

上圖只是一個概括分類,意思是Chrome有這幾類的進程和線程,并不是每種只有一個,比如渲染進程就有多個,每個選項卡都有自己的渲染進程。有時候我們使用Chrome會遇到某個選項卡崩潰或者沒有響應的情況,這個選項卡對應的渲染進程可能就崩潰了,但是其他選項卡并沒有用這個渲染進程,他們有自己的渲染進程,所以其他選項卡并不會受影響。這也是Chrome單個頁面崩潰并不會導致瀏覽器崩潰的原因,而不是像老IE那樣,一個頁面卡了導致整個瀏覽器都卡。

對于前端工程師來說,主要關心的還是渲染進程,下面來分別看下里面每個線程是做什么的。

GUI線程

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

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

事件循環就是一個循環,是各個異步線程用來通訊和協同執行的機制。各個線程為了交換消息,還有一個公用的數據區,這就是事件隊列。各個異步線程執行完后,通過事件觸發線程將回調事件放到事件隊列,主線程每次干完手上的活兒就來看看這個隊列有沒有新活兒注册送28体验金的游戏平台,有的話就取出來執行。畫成一個流程圖就是這樣:

流程講解如下:

  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);

執行結果如下:

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

引入微任務

前面的流程圖我為了便于理解,簡化了事件隊列注册送28体验金的游戏平台,其實事件隊列里面的事件還可以分兩類:宏任務和微任務。微任務擁有更高的優先級,當事件循環遍歷隊列時,先檢查微任務隊列,如果里面有任務,就全部拿來執行,執行完之后再執行一個宏任務。執行每個宏任務之前都要檢查下微任務隊列是否有任務,如果有,優先執行微任務隊列。所以完整的流程圖如下:

上圖需要注意以下幾點:

  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是分階段的,如下圖所示:

  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但是有定時器到期,他會繞回去執行定時器階段:

setImmediatesetTimeout

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

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

上述代碼運行如下:

和我們前面講的一樣,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');
});

我們來運行下看看效果:

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

怎么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也是這樣的注册送28体验金的游戏平台,比如:

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');
    });
});

這段代碼的打印如下:

我們還是來理一下流程:

  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')
})

代碼運行結果如下:

總結

本文從異步基本概念出發一直講到了瀏覽器和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項目地址:

作者掘金文章匯總:

原文鏈接:

上一篇:webpack Parser對JS表達式語句的解析計算
下一篇:流媒體服務器前端展示框架vue封裝api接口流程介紹

相關推薦

  • 面試題之Event Loop終極篇

    先上一道常見的筆試題 大家可以先配合下面這個圖片思考一下輸出順序及這么運行的原因 圖片描述(https://img.javascriptcn.com/104c69f0f75d4c2b7f366...

    9 個月前
  • 那些年,那些坑--swiper loop:true引發綁定dom的click事件無效及解決方案

    對于 ,只要做過輪播圖的童鞋應該都再熟悉不過了。這是一個很強大的圖片輪播插件,本身無任何第三方庫依賴,即插即用。api 文檔很清晰,所以很快能夠上手。但是,再好的插件也會出現令人不愉快的地方,當然,今...

    2 年前
  • 踩了pointer-events:none的坑

    bug出現: 一前端同事跟我說:你把這段加到全局的css里面 然后,悲劇了,上線后發現我的圖片都不能點擊了。 bug解決: 情急之下,在所有需要點擊的img標簽外邊都包了一層div,把點擊事件...

    2 年前
  • 談談React事件機制和未來(react-events)

    (https://img.javascriptcn.com/cadc1922d817cf3302e243762f169c2d) 當我們在組件上設置事件處理器時,React并不會在該DOM元素上直接綁...

    8 個月前
  • 詳細剖析 event 幾個基本點

    事件流 當在某個DOM元素上觸發事件以后,DOM是如何派發事件并尋找監聽器的呢?這個派發過程就是事件流。 事件從Document(有的瀏覽器是window,總之是最外層根元素)開始,按照物理層級結構,...

    1 個月前
  • 譯文:JS事件循環機制(event loop)之宏任務、微任務

    原文標題:《Tasks, microtasks, queues and schedules》 這是一篇谷歌大神文章,寫得非常精彩。譯者想借這次翻譯深入學習一下,由于水平有限,英文好的同學建議直接閱...

    2 年前
  • 讓在Vue中使用的EventBus也有生命周期

    最近遇到了vue項目中的性能問題,整個項目不斷的進行操作五分鐘左右,頁面已經很卡,查看頁面占用了1.5G內存,經過排查一部分原因,是自己模塊使用的eventBus在離開頁面未進行off掉。

    2 年前
  • 讓事件偵聽器連接到節點使用addEventListener

    CommunityTyilo(https://stackoverflow.com/users/1/community)提出了一個問題:Get event listeners attached to n...

    2 年前
  • 被忽略掉的composition event

    最近在學習別人是如何使用Vue來造輪子,因此選擇在Vue的 UI框架 Element UI。elinput中有這么一段代碼 element在處理輸入框的input事件的時候,首先進行了一次判斷...

    3 個月前
  • 簡潔明了探索瀏覽器event loop

    前段時間我對于瀏覽器Event loop中的MacroTask和MicroTask哪個先執行有所困惑,苦于搜索也沒有發現很明確的答案,于是決定深入探索瀏覽器Event loop,現有所愚見,想與大家分...

    1 年前

官方社區

掃碼加入 JavaScript 社區