天天看點

事件循環

浏覽器

消息隊列

我們之前在這篇文章中講到過關于浏覽器線程和程序的知識。這些知識在本文中将繼續被用到。浏覽器上的任務主要執行在一條線程上,我們稱這條線程為浏覽器主線程。在它上面執行這衆多的任務:包括界面繪制,排版,使用者手勢/滑鼠動作,處理滾動,使用者輸入,以及執行我們編寫的腳本。浏覽器之是以采用了單線程的一個重要原因是因為浏覽器的特性導緻的:每個使用者行為都有先後順序,這些任務必須按照特定的順序被執行,否則界面上的dom元素變化就會出現“怪異的現象”。為了合理地安排這些任務的執行,浏覽器采取了消息隊列的方式對這些任務進行管理。所謂隊列,就是一個先進先出(first in first out)的線性結構表資料結構,上面說到的任務都是按照其産生的先後順序被推入到這個消息隊列中去執行的,而這些任務就是我們稱之為宏任務。

事件循環
長任務(long task):長任務是指執行時間超過50ms的宏任務,通常長任務是由于我們編寫的糟糕的代碼引起的,例如腳本執行時間過長,或者javascript腳本操作dom的導緻重繪或者重排的時間過長。它也受計算機硬體裝置影響。

延遲隊列

延遲隊列就是值暫時不放入消息隊列中的函數所在的隊列。延遲隊列中都存儲這一些異步的調用函數,他們等待定時事件耗盡,或者其他程序的IPC通知,再把對應的函數推入消息隊列中。我們編寫的異步代碼,如ajax請求,settimeout和setinterval中的回調函數,就是存在延遲隊列中的,知道定時器耗盡事件,或者網絡經常通知渲染程序,這些函數才被推入消息隊列。消息隊列其實嚴格來講是一種優先級隊列,也就是說出隊和入隊的順序不是他們被的時間順序,而是等待時間的順序。消息隊列本質上是一個優先隊列,因為需要判斷時間先後,使用的是堆這種資料結構。

事件循環

事件循環

宏任務按照時間順序被添加到消息隊列中,主線程從消息隊列中不停地執行這些任務,按照先進先出的原則,将最舊(oldest)的任務提出并且執行,完成之後再取隊列中的任務繼續執行。整個過程,就像是一個大的滾輪不停的滾動執行,而實際上v8的底層也是通過一個while循環來執行的。我們稱這樣的一個執行過程為事件循環。

while(true){
  ...//    
}
      
事件循環
v8是google團隊開發出來的javascript運作虛拟機。它本身隻是對ECMAScript标準規範的實作,它的運作需要借助宿主環境。在浏覽器中,渲染程序中的主線程就是v8用來編譯和解析javascript的。

調用棧

調用棧指的是v8引擎用來管理javascript執行上下文的資料結構。v8在執行js代碼的時候是把每個函數按照執行順序推入一個棧中的(call stack),棧的特點與消息隊列的一樣,隻不過它是LIFO (last in first out)後進先出的原則,也就是說最後進入棧中的函數,會最先被pop出來。由于調用棧在這種資料結構保持了記憶體上的連續性,是以在切換出棧和入棧的時候保持了比堆要好的效率。不過也正是因為棧對連續記憶體的要求比較高,是以一般函數的調用棧空間都有記憶體限制,超過了這個限制,浏覽器就會報錯, “Max call stack”。

當一個函數被調用,v8就會為該函數建立一個函數執行上線文(Function Execution Context),并将它推入到調用棧中,然後執行。如果這個函數調用了另外一個函數,v8會為調用的函數建立函數執行上下文,然後繼續講其推入到調用棧中,一直如此,直到最後一個被調用的函數完成,接着按照後入先出的原則,一次講執行上下文出棧。

執行上下文是指一段函數執行時的環境:包括變量環境、詞法環境、外部環境,還有this。

下面我用代碼示範一下調用棧的工作方式:

function puppy() {
  kitty()
}

function kitty() {
  sing()  
}

function sing() {
  console.log("miwo");
}

puppy();//miwo
      

以上是三個獨立的函數,調用關系分别是puppy>kitty>sing,按照執行順序是限執行puppy,再執行kitty,然後是sing函數,最後列印miwo。它們按照代碼的執行的時間順序被壓入到調用棧中,然後按照後進先出的方式被彈出調用棧,如下圖所示:

事件循環
調用棧中的情況可以通過chrome工具觀察,你隻需要在某一處打上斷點(break point),當浏覽器定位到斷點時,檢視開發者工具的右側欄Call Stack欄,就可以看到每個函數的調用棧狀态。

微任務

現在我們知道了消息隊列,事件循環以及調用棧這些概念,我們才好繼續了解微任務。為什麼我們有了宏任務,還需要微任務呢?

早期的浏覽器并沒有區分宏任務和微任務,所有的任務統一都是宏任務。但是随着浏覽器的發展,很多業務的複雜度上升,對性能就有所要求。但是如果假定任務數量不變,我們是在本質上是無法做到減少時間的,是以我們就需要将某一些優先任務進行細分,對不同的任務進行優先級排隊。

優先任務:在一個網頁時,dom操作和使用者互動優先程度是最高的,這樣才不會讓使用者有卡頓的感覺,是以,我們把dom變化作為一個優先任務考慮。

我們來舉一個例子,來說明為什麼需要微任務。早期的浏覽器為了監聽dom的變化,我們有兩種方式

  1. 用setTimeout輪訓,判斷元素是否變化。
  2. 使用Mutation Event,判斷元素的變化。

這兩個方法都有各自的缺點,第一種我們無法判斷dom變化的速率,如果間隔時間設定過快,毫無以為會浪費性能;而如果過慢則無法實時監聽到dom的變化。而第二種雖然采用了異步的方式監聽dom的變化,但是沒有解決如果前面的任務執行過久的問題。而且dom的頻繁變動會造成大量頻繁的操作。為了解決這些問題,浏覽器映入了映入了一個新的api:MutationObserver來監聽dom變化,把以上兩個問題都解決,第一,利用微任務将dom處理的優先級提升,第二,一次性收集多個dom變化一起處理。現在我們就來看看,浏覽器是如何提升微任務的執行優先級的呢?我用下面的一張圖來做說明:

事件循環

消息隊列中有很多個宏任務等待被執行,然後每個宏任務的隊尾都有一個微任務隊列,當執行某個宏任務的過程中有微任務(如MutationObserver監聽到的dom變化,promise.resolve等)v8會把産生的任務加入到目前宏任務的微任務隊列中,當這個宏任務執行完成,v8會去檢查目前的任務的微任務隊列是否為空(我們稱這個時間點為檢查點checpoint),如果為空,則繼續下一個宏任務,如果不為空則去執行對應的微任務。可以想見,如果沒有微任務的這種機制,那麼我們新産生的任務就會被派到消息隊列的最頂部分,等待其他的宏任務完成,再執行這些變化,這毫無疑問會影響dom改變的時間,從影響到客戶的體驗。

如果微任務中産生了新的微任務,那麼下一個宏任務依舊要等待這個微任務被執行完成。

浏覽器中哪些操作會産生微任務呢?

1.MutationObserver監聽的dom變化時會回調函數會被作為微任務處理,因為dom的變化響應要非常及時,不能被其他的宏任務插隊。

2.Promise.reslove也會産生微任務,詳情我在之前的博文中已經提到過,有興趣的可以過去檢視。

Node.js

libuv和v8

  • V8: V8是一套由google團隊開發的高效運作js的虛拟機。負責v8的解析和編譯工作,得益與google團隊的開發,它編譯js代碼是變得異常的快速。nodejs在文法上使用了javascript,同樣在底層虛拟機上實作也是由v8引擎對js進行編譯的。
  • libuv:是用C語言實作的一套異步功能庫,nodejs高效的異步程式設計模型很大程度上歸功于libuv的實作,我們的這裡所講的循環就是在libuv的基礎上實作的。
事件循環

網絡IO:node采用的是單線程的,并且會基于不同的機器采用不一樣的機制,通過封裝不同平台的模型而确定node的IO運作機制。但檔案IO和DNS操作以及使用者代碼則是運作線上程池中的。libuv内部預設啟用的是4個線程池對上述IO進行處理。當代碼層傳遞給 libuv 一個操作任務時,libuv 會把這個任務加到隊列中。

我們在上面已經提到過了浏覽器的事件循環,下面我們來看node的事件循環。我們都知道node是一種基于事件驅動的非阻塞IO程式設計模型,node本身的IO操作非常頻繁,在浏覽器中我們也許經常隻是使用網絡IO就能滿足我們日常的需求了。這使得我們的同步操作可以不用等待異步結束而先執行其他操作,等到異步結束,再通知主線程進行回調操作。nodejs的事件循環主要用來處理這種非阻塞的IO操作的。nodejs的事件循環總結有兩個特點:

  • 循環機制并非一直都是開啟狀态
If the loop is alive an iteration is started, otherwise the loop will exit immediately. So, when is a loop considered to be alive? If a loop has active and ref’d handles, active requests or closing handles it’s considered to be alive.

這是libuv官方文檔上的一段原文,大概意思就是如果不存在異步IO操作或者回調未處理的情況,那麼事件循環将終止。與浏覽器的事件循環機制不同,node在同步代碼運作過後如果發現沒有正在執行的相關的操作,便不會啟用事件循環。以下的操作會啟用事件循環的:

  • setimeout和setinterval

這兩者屬于定時器,它們的回調會在事件循環的timer階段被執行。

  • setImmediate

setImmediate方法的回調會在事件循環的checker階段被執行。

  • promise

promise和浏覽器中的事件循環一樣産生了微任務,它處在每個階段的前面,每次階段切換都需要執行

  • process.nextTick

nexttick産生的并非微任務,但它的優先級比微任務都要高,會在微任務隊列之前執行。

  • 異步IO

磁盤操作,系統操作,DNS操作等異步io是事件循環處理的主要對象,他們的回調函數在poll階段執行

網絡IO

以上node操作,如果還未回調完成,那麼nodejs就會處在一個循環中,總得來說它一般是用timer階段為其實階段開始輪訓。

  • 事件循環有可能暫停并且重新開機的順序會改變

事件循環有可能暫停,如果異步IO未結束,而且其他階段中的執行隊列都是空的,事件循環會進入休眠狀态等待poll階段的回調函數,一旦完成IO,事件循環從poll階段重新開始循環。這一點與浏覽器的時間循環不同。下面我們用一張圖來說明事件循環的各個階段以及他們是怎麼樣運作的:

事件循環

每個階段有存在一個任務執行隊列,一旦定時器過期或者IO結束,回調函數都會被推入執行隊列中,等待下一次循環到該階段時被取出調用。我們下面列出了一段代碼,然後解釋一下node是如何執行代碼的:

console.log("welcome to node's world!!");
setImmediate(() => console.log('immediate')); //checker
setTimeout(() => console.log('time-out'), 0); //timer
Promise.resolve('promise').then(console.log)// miro task
process.nextTick(() => console.log('next-tick')); // tick task
fs.readFile('./a.txt', {encoding: 'utf-8'}, () => { // poll
    setImmediate(() => console.log('immediate in next tick')); //checker
        setTimeout(() => console.log('time-out in tick'), 0); //timer
    console.log("done")
})
console.log('end');
      
  • node
  1. 初始化 node 環境。
  2. 執行同步代碼。line 1
  3. 執行 process.nextTick 回調。line 5
  4. 執行 microtasks。line 4
  5. 判斷事件循環是否可以執行
  • timer
  1. 執行timer的消息隊列中過期的定時器。line 3 ... line 8
  2. 執行process.nextTick隊列中的回調任務。empty
  3. 執行micro task隊列中的微任務。empty
  4. 進入下一個階段。
  • IO callbacks
  1. 檢查是否有 pending 的 I/O 回調。如果有,執行回調。如果沒有,退出該階段。
  2. 執行process.nextTick隊列中的回調任務。no
  3. 執行micro task隊列中的微任務。no
  4. 進入下一個階段
  • idle,prepare
  1. 此處為内部調用的系統方法,一些node底層的其他任務執行在此處。開發者無法介入,暫時略過。
  • ** poll**
  1. 檢查是否有消息隊列,是否異步完成。line 9
  2. 如果沒有,則判斷checker和定時器是否完成,如果有,則進入下一個階段,如果沒有,則暫停在此階段,停止輪詢,直到異步IO完成,進入下階段。
  • check
  1. 檢查是否有setimmediate回調,如果有則執行。line 7
  • closing
  1. 檢查一些socket等執行情況,例如exit等
  2. 檢查是否有存貨的handler(定時器,IO等)如果有責進入下一輪循環,如果沒有,退出循環。

這段代碼執行的的結果如下所示:

welcome to node's world!!
end
next-tick
promise
immediate/time-out
done
immediate in next tick
time-out in tick
      

現在,你應該知道循環到底是做什麼用的了。但如果你仔細觀察輸出結果,會發現immediate和time-out的輸出順序會有變化。要弄明白這個問題,我們先看一下代碼:

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
 int timeout;
 int r;
 int ran_pending;
 r = uv__loop_alive(loop);
 if (!r)
 uv__update_time(loop);
 while (r != 0 && loop->stop_flag == 0) {
 uv__update_time(loop);
 uv__run_timers(loop);
 ran_pending = uv__run_pending(loop);
 uv__run_idle(loop);
 uv__run_prepare(loop);
 ……
 uv__io_poll(loop, timeout);
 uv__run_check(loop);
 uv__run_closing_handles(loop);
 …………
      

這是libuv庫中實作循環的主體代碼。你可以看到,所有的事件循環階段都對應着這裡面函數,那個巨大的while便是循環的主體,裡面的函數從名字就能看出是哪個循環的那個階段。其中有一個uv__update_time函數,根據libuv官方文檔的解釋如下:

The loop concept of ‘now’ is updated. The event loop caches the current time at the start of the event loop tick in order to reduce the number of time-related system calls.

意思就是用來緩存一個系統時間作為時間循環的開始時間。這個uv__update_time函數就是用來取這個時間的。函數裡面調用的是另外一個uv__hrtime函數,具體實作如下代碼所示:

UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
 */
 loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}
      

這個函數計算出來的是clock_gettime,因為clock_gettime的時間非常非常小(十億分之一秒),我們必須對其做轉換,轉換成毫秒,在代碼裡可以看到将它放慢了一百萬倍。同時也正是因為它的精确性,是以它會受到到其他正在運作的程序的影響。

有一點需要注意,在浏覽器中設定定時器的延遲時間為0,那麼實際的延遲時間大概是4.4ms-6ms,而在node中,盡管我們給定時器設定的是0ms的延遲時間,實際上在内部會被轉換成1ms的時間。因而我們最終是用clock_gettime取的系統時間與這個1ms對比大小:

void uv__run_timers(uv_loop_t* loop) {
 …
 for (;;) {
 ….
 handle = container_of(heap_node, uv_timer_t, heap_node);
 if (handle->timeout > loop->time) // 比較循環開始時間和定時器定時的時間(1ms)
 break;
 ….
 uv_timer_again(handle);
 handle->timer_cb(handle);
 }

      

當這個時間被取出來的之後,timer階段中的回調函數便開始執行。是以如果開始時間要大于1ms,那麼就會循環進入下一個階段,如果大于1ms,那麼settimeout就會被正常執行。那麼如果我們把代碼寫成下面這樣呢?

fs.readFile('./a.txt', {encoding: 'utf-8'}, () => { // poll
    setImmediate(() => console.log('immediate')); //checker
        setTimeout(() => console.log('time-out'), 0); //timer
});
      

總結

參考文檔

  • Libuv Design Overview
  • Node Event Loop
  • Deterministic Order of Execution

繼續閱讀