精讀Javascript系列(八)事件循環細則 II: NodeJS事件循環相關

前言

本篇也是過渡篇,主要補充NodeJS的重點和難點。

嘛,廢話不多說,正文開始。

NodeJS

首先,理解 NodeJS 有一個很重要的前提,就是知道它究竟是 單線程的,還是 多線程 的?

還是如以往一樣,越是簡單的問題答案就每個人的答案就越是令人迷惑;
實踐是檢驗真理的唯一標準,最後看看 node線程數就知道了——7個,所以它是多線程的。
但是爲何有人說它是 單線程 呢?

單線程這個說法應該被嚴重曲解的,導致出現了一些誤導性; 完整說法應該是 NodeJS運行Javascript代碼只有一個線程, 這個特徵與瀏覽器如出一轍,瀏覽器運行Javascript也是單線程的。然而卻不能說瀏覽器就是單線程,同樣的NodeJS也是這個道理。

所以從根本上來說,NodeJS只有一個運行JS的主線程,它也是負責事件循環的線程; 與其他異步模型一般,它永遠不會阻塞。 Node官網的事件循環,太過簡潔了在細節方面做得不夠詳盡,因此只能參考實現它的組件——libuv但這個細節也太多了)。

NodeJS 的事件循環其實是由 libuv 實現的,所以只有理解了 libuv 的事件循環才能算徹底理解了 nodejs 的事件循環。libuv 實現機制要細說三天三夜也講不完, 所以長話短說——

  1. libuv 負責收集所有事件(它也可能會來自外部、也包括內核的)
  2. 針對這些事件註冊回調函數
  3. 事件發生後被執行回調函數。

其實原理與瀏覽器環境大同小異,都是採用消息傳遞的異步通信方式,並且都是基於事件循環來執行代碼的。不過要注意的是Node 程序處理最多的不是 頁面渲染, 而是 輸入和輸出,統稱作IO, 例如文件讀寫、網絡請求等等,這類操作統一特點都是阻塞(響應時間超長)的,傳統的讀寫事件都是同步的,也就是說在獲取文件全部內容之前,根本無法做任何事情,自然也效率低下,我們的任務就是狠狠的壓榨CPU性能

libuv便是上面問題的一個解決方案,基本思路是將一些阻塞任務(主要是IO,網絡請求.etc)這類阻塞任務分配到工作線程中等待稍後執行,這些工作線程會統一保存在線程池中;然後開啓事件循環一一處理。 至於一次能處理多少事件(可能一次事件循環爆發式接收到500個請求),取決於計算機硬件。

所以,理解 nodejs 事件循環, 還是要從 libuv事件循環開始。
下面是官網給出的事件循環示意圖:
libuv 概覽圖:
在這裏插入圖片描述

理解事件循環難點:

  • 在主模塊代碼結束後,事件註冊纔會完成。一旦有註冊的事件,就必然會產生一個handler,它可以標識任務的上下文(術語:事件句柄、文件描述符)但這時還沒有正式開啓事件循環
  • 當有handler存在,初始化線程池。然後將已經註冊事件的handler分配到線程池的對應workthread中。例如setTimeouthandlers(Timer)會分配到TimersetImmediatehandler(CheckHandler)分配到Check, 其他事件分配到poll中統一處理。
  • 開啓事件循環,然後更新事件循環的handler計數 ; 現在能夠看出,事件循環的每個phase其實都保存在了線程池中。
  • 也正因爲如此,phase並不是嚴守順序的;或是說它是跳躍性的。例如當第一次進行事件循環時,Timer沒有到期,所以不會執行回調。但反過來說一旦到期就一定會執行回調。由於沒有需要異步完成的IO Callback,所以不會進入pending callback
  • 因此,首次事件循環會直接跳躍到poll階段完成IO Callbackpoll階段極其特殊,它有一個阻塞時長,如果Callback在阻塞時長內沒有完成,相應的io callback異步完成,即在下一個事件循環的pending callback處理完所有剩餘的callback。同樣的道理,如果在阻塞時長內存在setImmediate的回調,那麼就會立即進入到check階段(不用在意幾回合事件循環)。
  • 每個事件循環跳躍下一個事件循環前,都會有一個Close Handler過程,它會清理掉完成狀態(done=true)的handler,更新事件循環的引用計數等等。

事件循環的核心,poll階段的總結就是(有點都合主義的味道):

  1. 計算Poll階段的阻塞時間(即Timeout)
  2. 執行已發生事件的回調函數,但還有:
    1. 是否有nextTickmicrotasks? 如果有則執行,否則繼續
    2. 檢查是否有setImmediate回調,如果有執行回調然後進入Check階段。
    3. 若無,繼續。
  3. 在阻塞時間內等待事件發生,如果存在Timer,那麼阻塞時間是最近的Timer到期時間,否則不限制阻塞時間
    1. 若有事件發生,立即執行相應的回調函數,此步驟完全等價於(2)
    2. 若無則繼續等待下去(直至node 停止監聽、或沒有活躍的handler)。
    3. 如果在阻塞時間內仍有未回調完成的任務handler),掛起在下一個事件循環的pending callback階段排隊處理。
  4. 超過阻塞時間後,進入下一個事件循環,執行到期Timer
    1. 此時不再有可用setImmediate回調,因此前移
    2. 阻塞時間是最近的Timer到期時間

特別注意

  • (2)實際上是(3)其中的一個步驟,但是分離出來更容易理解一些。
  • 雖然官網說是繞回,但是我覺得這裏應該是前移到下一個事件循環中更準確一點。這是和nodejs官網矛盾點。
    想要了解libuv事件循環所有細節的,可以直接跳到後面。

setTimeout VS setImmediate

這是很令人興奮的話題,它們到底誰更優先? 事實上它們並沒有優先級關係,因爲事件循環沒辦法用順序去理解,因此不要用誰優先誰就執行原則,而是誰觸發誰就必須執行原則。

注意兩點:

  1. setTimeout是到期立即執行
  2. setImmediate是進入到check階段後執行。

也就是說,TimerCheckHandler是沒有邏輯上的必然關係的。

例如:

setTimeout(()=>{
    var start = Date.now();
    setTimeout(()=>{
        console.log('------------------');
        console.log('timeout 1 = '+(Date.now()-start)+'ms');
    })

    setTimeout(()=>{
        console.log('------------------');
        console.log('timeout 2 = '+(Date.now()-start)+'ms');
    })

    setImmediate(()=>{
        console.log('------------------');
        console.log('check 1 = '+(Date.now()-start)+'ms');
    })

    setImmediate(()=>{
        console.log('------------------');
        console.log('check 2 = '+(Date.now()-start)+'ms');
    })
})

輸出結果:

------------------
check 1 = 9ms
------------------
check 2 = 10ms
------------------
timeout 1 = 11ms
------------------
timeout 2 = 11ms

從結果上看,setImmediate的回調永遠先於setTimeout

5aaaa

簡而言之, 在Timer到期之前就已經觸發了CheckHandler,因此會跳躍到Check。執行完畢後Timer必然到期,因此執行。

但是爲什麼在主模塊setTimeoutsetImmediate這兩個函數是隨機呢?


var start = Date.now()
setImmediate(()=>{
    console.log('------------------');
    console.log('check = '+(Date.now()-start)+'ms');
})

setTimeout(()=>{
    console.log('------------------');
    console.log('timeout = '+(Date.now()-start)+'ms');
})

輸出:

timeout = 10ms
------------------
check = 12ms
------------------
check = 8ms
------------------
timeout = 9ms

說明:

  • 開啓事件循環後,更新now時間,如果Timernow時間到期,那麼就會直接運行。否則不會運行。簡而言之,如果能夠儘快進入事件循環,就不會執行Timer反之就會執行。

這個解釋是在github看到的,感覺很有道理的樣子。


nextTick VS Promise

NodeJS中,也有微任務microtasks,不過它分爲兩種微任務:

  1. nextTick : 即nextTick
  2. otherMicrotasksPromise
    其中nextTick總是會先於Promise執行。無論哪種微任務,它們都能在跳躍到其他phase前執行完畢。

例如:

console.log('start');

Promise.resolve('promise 1').then(console.log)
Promise.resolve('promise 2').then(console.log)

process.nextTick(()=>{
    console.log('ha,ha,ha,ha');
    console.log('im tickObject!!');
})

console.log('end');

驗證輸出:

start
end
ha,ha,ha,ha
im tickObject!!
promise 1
promise 2

然後四個一起,綜合來PK一下(正常版本):


const fs = require('fs')
const start = Date.now()


fs.readFile(__filename,()=>{
    console.log('start');
    setImmediate(()=>{
        console.log('checkhandler 1');
        Promise.resolve('promise 1').then(console.log)
        console.log('-----------------------------------');
    })    
    setTimeout(()=>{
        console.log('timeout 1');
        
    })
    setImmediate(()=>{
        console.log('checkhandler 2');
        Promise.resolve('promise 2').then(console.log)
        process.nextTick(()=>{console.log('nextTick 2');
        })
        console.log('-----------------------------------');
    })  
    process.nextTick(()=>{
        console.log('nextTick 1');
        
    })  
    console.log('end');
})


輸出:

start
end
nextTick 1
checkhandler 1
-----------------------------------
promise 1
checkhandler 2
-----------------------------------
nextTick 2
promise 2
timeout 1

上面的過程如下:

  1. poll階段:調用readFile,開始文件讀寫。
  2. poll / pending_callback 階段: 文件讀寫完成。開始調用回調函數:
    1. 輸出 start
    2. 調用 setImmediate ; checkhandler1活躍狀態
    3. 調用 setTimeout timeout 1被初始化,等待到期。
    4. 調用 setImmediate checkhandler2活躍狀態
    5. 調用nextTick, 產生一個TickQueue
    6. 輸出end
    7. 執行TickQueue中的所有任務,輸出nextTick 1
      存在checkhandlers , 進入check階段
  3. check階段:處理所有的checkhandlers的回調:
    1. 執行checkhander1的回調:
      1. 輸出checkhandler1
      2. Promise.resolve().then , 產生一個promiseList
      3. 輸出橫線
      4. 處理promiseList中所有的任務,輸出promise 1
    2. 執行checkhander2的回調:
      1. 輸出checkhandler2
      2. Promise.resolve().then , 產生一個promiseList
      3. nextTick, 產生一個TickQueue
      4. 輸出橫線
      5. 處理TickQueue所有任務,輸出nextTick 2
      6. 處理promiseList所有任務,輸出promise 2
        Timeout到期,跳躍到Timer
  4. Timer階段: 處理Timeout回調,輸出timeout 1

注意:

  • 每處理完一個回調,就會調用一次nextTickotherMicrotask
  • io callback究竟是在哪個階段完成的,是未確定的

實際上在其他模塊,也會出現隨機順序問題,例如:

const fs = require('fs')


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

    fs.readFile('./file.md', ()=>{
        console.log('file two');
        setTimeout(()=>{
            console.log('timeout two');
        })
    })

})

然後出現以下三種結果(更離譜的代碼就沒寫):666

這也側面證明了IO Callback的隨機性,它的完成與文件大小也是息息相關的。左右兩側的結果是比較容易理解的,中間的是爲什麼?
大概率是因爲file two是在pending callback階段完成的。

  1. poll階段,fileone回調完成,添加一個定時器。
  2. poll階段,但是filetwo回調未完成, 定時器到期,立即回到Timer階段
  3. 執行Timer
  4. pending callback,回調完成,添加一個定時器。

下面是小生看了一遍源代碼得出的結論,大致上補充了點細節。不過因爲linux編程這一塊遺忘了不少,可能未必是正解,這方面還請多提寶貴意見。

同步轉發:
https://juejin.im/post/5ed36676e51d457b3a67ccef

附註:libuv事件循環所有細節

首先要知道幾個術語:

  • handler來標識一個任務,事實上它就是句柄(文件描述符),總之通過它可以引用任務的一切信息:任務的回調函數、回調狀態等等。
  • 一個handler普遍有三種狀態:初始化掛起(即暫停) 、 活躍 。常用後面的兩種。

開始吧:

Bootstrap/Dispatcher:

  • 初始化Node環境
  • 執行同步JS代碼並進行事件分發(註冊)。
  • 初始化線程池

然後正式進入到事件循環中:

UpdateLoop:

  1. 事件循環是否是活躍的(主要檢查是否有活躍的handler
  2. 如果是,更新當前時間(now),正式進入Phase I
  3. 否則終止事件循環,關閉。

Phase I: Timer

  1. 檢查 Timer 是否到期
  2. 如果到期,立即觸發事件然後繼續執行回調。
  • 上面是最簡單的,就是setTimeout/setInterval這類調用。

Phase II:Pending (IO) Callbacks

  1. 檢查 Pending_Queue是否有handler
  2. 如果有,立即從Pending_Queue取出handler進行回調操作。
  3. 如果handler中回調函數代碼中存在CheckHandlers(即setImmediate(...)),那麼處理完成後跳轉到Phase V
  4. 否則繼續。

附註:

  • Pending_Queue用於放置未回調完成的handler的隊列
  • 一般IO Events 會在這個階段完成處理。例如:文件操作時拋出異常、讀取完成然後執行回調等等。
  • CheckHandlers是極其特殊的handler,簡單來說就是setImmediate(...)的回調。不只會在Phase II導致迭代前移(直接跳轉到Phase V),甚至在其他Phases也會如此。

Phase III.a: Idle/Idling

  • 進入空轉監聽階段
  • 它會不斷地監聽handler是否發生事件(此時handler活躍的),如果是進入Phase III.b
  • …(記錄日誌等,與我們無關了……)
  • 達到事先規定好的循環次數後,也未曾監聽到新事件 那麼Idling會被終止;事件循環也會被終止(因爲一直監聽不到事件發生)。

附註:

  • Idling 階段永遠不會停止;也就是說即使事件發生後轉入到Phase III.b時, Idling也是一直在空轉監聽
  • 也正因爲如此,我們才能任何時刻監聽到事件
  • 雖說如此, Idling會有一個極限循環次數,這個次數由硬件決定的。過了這個次數,它就結束了。

Phase III.b: Prepare

  • 爲活躍的handler執行回調前做些準備
  • ……(準備上下文……等)
  • 進入 Phase IV,正式處理回調。

附註:

  • 事實上,瞭解Phase III沒什麼大用,因爲不可能編寫一定處於Phase III階段的代碼。
  • 它是libuv執行的。
  • 只是補充了一點細節

Phase IV Poll:

  1. 計算超時時間(Timeout
  2. 在超時時間內(Timeout):
    • 如果checkhandler存在,執行相應回調
    • 如果發現IO Event,立即執行回調。
    • 如果無事件發生,檢查是否超時
    • 如果超時,立即退出Phase IV。否則回到(2.1)
  3. 超過超時時間後,會有Timer到期,直接前移到下一個事件循環
  4. 會存在未回調的handler,此時它們會在下一個事件循環的pending callbacks完成處理。
    未完待續

附註:

Timeout計算規則:

  • 在以下情形,Timeout=0
    1. 事件循環將被終止(由IdleHandler設置)
    2. 沒有活躍的handler
    3. Idle不再監聽(即IdleHandler終止)
    4. Pending_Queue不爲空.
  • 除卻以上情形, 如果有待處理的Timer,那麼 Timeout是最近的Timer到期時間。
  • 如果沒有待處理的Timer, 那麼Timeout-1 ,可以理解爲 poll阻塞時間爲 無窮大(這塊我忘了,應該是阻塞時間不確定)。

Phase V : Check

  1. 檢查CheckHandlers是否存在(即setImmediate的消息序列)
  2. 若有則處理所有CheckHandlers
  3. 完成, 進入Phase VI

Phase VI : Close handlers

  1. 對處理完的handler進行Close操作,例如 send()/close()/destory()等等。
  2. 調用GC,進行垃圾回收
  3. 轉至UpdateLoop
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章