I/O模型、Libuv和Eventloop

WilliamCao發表於2019-04-19

一、I/O模型

①常見的IO模型:Linux(UNIX)作業系統中的網路IO模型為例

  1. Blocking I/O 同步阻塞IO
  2. Non-blocking I/O 同步非阻塞IO
  3. I/O Multiplexing IO多路複用
  4. Signal-blocking I/O 訊號驅動IO
  5. Asynchronous I/O 非同步IO

②基本概念的定義:

IO 指的是輸入輸出,通常指資料在內部儲存器和外部儲存器或其他周邊裝置之間的輸入和輸出。簡而言之,從硬碟中讀寫資料或者從網路上收發資料,都屬於IO行為。

  • IO:記憶體IO、網路IO和磁碟IO,通常我們說的IO指的是後兩者。
  • 阻塞和非阻塞:在呼叫結果在返回之前,當前執行緒是否掛起,即發起IO請求是否會被阻塞。
  • 同步和非同步:如果做阻塞I/O呼叫,應用程式等待呼叫的完成的過程就是一種同步狀況。相反,I/O為非阻塞模式時,應用程式則是非同步的。

③完成一次IO的過程: 以讀一個檔案為例,一個IO讀過程是檔案資料從磁碟→核心緩衝區→使用者記憶體的過程。

同步與非同步的區別主要在於資料從核心緩衝區→使用者記憶體這個過程需不需要使用者(應用)程式等待,即實際的IO讀寫是否阻塞請求程式。(網路IO可把磁碟換做網路卡)


1、同步阻塞IO

I/O模型、Libuv和Eventloop

阻塞 I/O是最簡單的 I/O 模型,一般表現為程式或執行緒等待某個條件,如果條件不滿足,則一直等下去。條件滿足,則進行下一步操作。

應用程式通過系統呼叫 recvfrom 接收資料,但由於核心還未準備好資料包,應用程式就會阻塞住,直到核心準備好資料包,recvfrom 完成資料包復制工作,應用程式才能結束阻塞狀態。


2、同步非阻塞IO

I/O模型、Libuv和Eventloop

應用程式通過 recvfrom 呼叫不停的去和核心互動,直到核心準備好資料。如果沒有準備好,核心會返回 error ,應用程式在得到 error 後,過一段時間再傳送 recvfrom 請求。如果某一次輪詢發現資料已經準備好了,那就把資料拷貝到使用者空間中。在傳送請求的時間間隔中,程式可以先做別的事情。


3、IO多路複用

I/O模型、Libuv和Eventloop

IO多路複用是多了一個select函式,多個程式的IO可以註冊到同一個select上,當使用者程式呼叫該selectselect會監聽所有註冊好的IO,如果所有被監聽的IO需要的資料都沒有準備好時,select呼叫程式會阻塞。當任意一個IO所需的資料準備好之後,select呼叫就會返回,然後程式在通過recvfrom來進行資料拷貝。

這裡的IO複用模型,並沒有向核心註冊訊號處理函式,所以,他並不是非阻塞的。程式在發出select後,要等到select監聽的所有IO操作中至少有一個需要的資料準備好,才會有返回,並且也需要再次傳送請求去進行檔案的拷貝。


4、訊號驅動IO

I/O模型、Libuv和Eventloop

應用程式預先向核心註冊一個訊號處理函式,然後使用者程式返回,並且不阻塞,當核心資料準備就緒時會傳送一個訊號給程式,使用者程式便在訊號處理函式中開始把資料拷貝的使用者空間中。


5、非同步IO

I/O模型、Libuv和Eventloop

應用程式發起aio_read操作之後,給核心傳遞描述符、緩衝區指標、緩衝區大小等,告訴核心當整個操作完成時,如何通知程式,然後就立刻去做其他事情了。當核心收到aio_read後,會立刻返回,然後核心開始等待資料準備,資料準備好以後,直接把資料拷貝到使用者控制元件,然後再通知程式本次IO已經完成。


6、五種IO模型對比

I/O模型、Libuv和Eventloop

阻塞IO模型、非阻塞IO模型、IO多路複用和訊號驅動IO模型都是同步的IO模型,因為無論以上那種模型,真正的資料拷貝過程,都是同步進行的。


二、Libuv

libuv是一個高效能事件驅動庫,遮蔽了各種作業系統的差異從而提供了統一的API。libuv嚴格使用非同步、事件驅動的程式設計風格。其核心工作是提供事件迴圈及 基於I/O 或其他活動事件的回撥機制。libuv庫包含了諸如計時器、非阻塞網路支援、非同步檔案系統訪問、執行緒建立、子程式等核心工具。

I/O模型、Libuv和Eventloop

1、 控制程式碼和請求

libuv給使用者提供了兩種方式與event loop一起協同工作,一個是控制程式碼(handle)一個是請求(request)。

控制程式碼(handle)代表了一個長期存在的物件,這些物件當處於活躍狀態的時候能夠執行特定的操作。例如:一個準備(prepare)控制程式碼在活躍的時候可以在每個迴圈中呼叫它的回撥一次。一個TCP伺服器的控制程式碼在每次有新的連線的時候都會呼叫它的連線回撥函式。

請求(request)一般代表短時操作。這些操作能用作用於控制程式碼之上。寫請求用於在控制程式碼上寫資料;還有一些例外,比如說getaddrinfo請求不需要控制程式碼而是直接在迴圈中執行。

2、 I/O迴圈

I/O迴圈或者叫做事件迴圈是整個libuv的核心部分。I/O迴圈建立了所有IO操作的執行環境,I/O迴圈會被繫結在一個執行緒之上。我們可以執行多個事件迴圈,只要每一個都執行在不同的執行緒之上。libuv事件迴圈不是執行緒安全的,所以所有包含事件迴圈的API及控制程式碼都不是執行緒安全的。

事件迴圈遵循最普遍的單執行緒非同步I/O方法:所有I/O或者網路操作在非阻塞的socket上執行,這個socket會使用基於平臺的組好的poll機制:在linux上使用epoll,在OSX和其他BSD平臺上使用kqueue,在sunOS上使用event ports,在windows上使用IOCP。作為迴圈迭代的一部分,迴圈會阻塞以等待socket上的I/O活動,這些活動已經被加到socket的觸發實踐中了,一旦這些條件滿足,那麼socket的狀態就會發生變化,從而迴圈不再阻塞,而且控制程式碼也可以讀、寫及執行其他期望的I/O操作。

更好的理解事件迴圈操作如何進行,下圖展示了一個迴圈迭代的所有階段。

I/O模型、Libuv和Eventloop

檔案 I/O 與網路 I/O 不同 ,並不存在 libuv 可以依靠的各特定平臺下的檔案 I/O 基礎函式,所以目前的實現是線上程中執行阻塞的檔案 I/O 操作來模擬非同步。

注意:libuv利用執行緒池技術使得非同步檔案I/O操作稱為可能,但是對於網路IO只能執行在一個單一執行緒中,即loop的執行緒中。


三、Event Loop

任務佇列

非同步任務分為task(巨集任務,也可稱為macroTask)和microtask(微任務)兩類。 當滿足執行條件時,task和microtask會被放入各自的佇列中等待放入主執行緒執行,我們把這兩個佇列稱為Task Queue(Macrotask Queue)和Microtask Queue。

MacroTask(巨集任務)

script程式碼setTimeoutsetIntervalsetImmediate(瀏覽器IE10)MessageChannelI/OUI-Rendering

MicroTask(微任務)

Process.nextTick(Node獨有)PromiseMutationObserverObject.observe(廢棄)

1、 瀏覽器 E-L

JS呼叫棧採用的是先進後出原則,當函式執行的時候,會被新增到棧的頂部,當執行棧執行完成後,就會從棧頂移出,直到棧內被清空。

  • 執行棧在執行完同步任務後 ,檢視執行棧是否為空,如果執行棧為空,就會去檢查微任務microTask佇列是否為空,如果為空的話,就執行Task(巨集任務),否則執行微任務。
  • 每當單個巨集任務執行完畢後 ,檢查microTask佇列是否為空,如果不為空,會按照 先入先出 原則全部執行microTask佇列,設定microTask佇列為null,然後再執行巨集任務,如此反覆。
console.log('script start');

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

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
console.log('script end');

// script start、script end、promise1、promise2、setTimeout
複製程式碼

Another One

console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end') 
}
async1()

setTimeout(function() {
  console.log('setTimeout')
}, 0)

new Promise(resolve => {
  console.log('Promise')
  resolve()
}).then(function() {
    console.log('promise1')
}).then(function() {
    console.log('promise2')
})

console.log('script end')
複製程式碼
  • async/await 在底層轉換成了 promisethen 回撥函式。
  • 每次我們使用 await, 直譯器都建立一個 promise 物件,然後把剩下的 async 函式中程式碼的操作放到 then 回撥函式中。

關於Chrome73以下版本和73版本的區別

  • 在老版本版本以下,先執行promise1promise2,再執行async1。 script start、async2 end、Promise、script end、promise1、promise2、async1 end、setTimeout

  • 在73版中,先執行async1再執行promise1promise2。 script start、async2 end、Promise、script end、async1 end、promise1、promise2、setTimeout

主要原因是因為在谷歌73版本中更改了規範

2、 Node E-L

I/O模型、Libuv和Eventloop

在Node中事件每一輪迴圈按照順序分為6個階段,來自libuv的實現:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製程式碼
timers:執行滿足條件的setTimeout、setInterval回撥。
I/O callbacks:是否有已完成的I/O操作的回撥函式,來自上一輪的poll殘留。
idle,prepare:可忽略
poll:等待還沒完成的I/O事件,會因timers和超時時間等結束等待。
check:執行setImmediate的回撥。
close callbacks:關閉所有的closing handles,一些onclose事件。
複製程式碼

我們需要重點關心的是timerspollcheck這三個階段。

1. timers 執行setTimeoutsetInterval中到期的callback,執行這兩者回撥需要設定一個毫秒數,理論上來說,應該是時間一到就立即執行callback回撥,但是由於system的排程可能會延時,達不到預期時間。

2. poll 執行I/O回撥 和 處理輪詢佇列中的事件。

① 如果 poll 佇列不是空的,event loop 就會依次執行佇列裡的回撥函式,直到佇列被清空或者到達 poll 階段的時間上限。

② 如果 poll 佇列是空的,就會:

  1. 有 setImmediate 任務,event loop 就結束 poll 階段去往 check 階段。
  2. 沒有 setImmediate 任務,event loop 就會等待新的回撥函式進入 poll 佇列,並立即執行它。

3. check 此階段允許人員在poll階段完成後立即執行回撥。

setImmediate()實際上是一個特殊的計時器,它在事件迴圈的一個單獨階段執行。它是通過 libuv 裡一個能將回撥安排在 poll 階段之後執行的 API 實現的。

在poll佇列是空的 且有 setImmediate 任務的情況下,event loop 就結束 poll 階段去往 check 階段執行任務。

console.log('start')
setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)
Promise.resolve().then(function() {
  console.log('promise3')
})
console.log('end')
複製程式碼

如果node版本為v11.x, 其結果與瀏覽器一致:

start
end
promise3
timer1
promise1
timer2
promise2
複製程式碼

v10如果time2定時器已經在執行佇列中結果為:

start
end
promise3
timer1
timer2
promise1
promise2
複製程式碼

否則和第一個結果一致。

瞭解瀏覽器的eventloop可能就知道,瀏覽器的巨集任務佇列執行了一個,就會執行微任務。

簡單的說,可以把瀏覽器的巨集任務和node10timers比較,就是node10只有全部執行了timers階段佇列的全部任務才執行微任務佇列,而瀏覽器只要執行了一個巨集任務就會執行微任務佇列。

node11保持和瀏覽器相同。


1. setImmediate && setTimeout

setImmediate和setTimeout是相似的,但根據它們被呼叫的時間以不同的方式表現。

setImmediate()設計用於在當前poll階段完成後check階段執行指令碼 。 setTimeout()為最小(ms)後執行的指令碼,在timers階段執行。

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

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

// timeout,immediate
// immediate,timeout
複製程式碼
const fs = require('fs');

fs.readFile('../file.txt', () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
// immediate,timeout
複製程式碼

2. Process.nextTick

process.nextTick()雖然它是非同步API的一部分,但從技術上講,它不是事件迴圈的一部分。

process.nextTick()方法將 callback 新增到next tick佇列。 一旦當前事件輪詢佇列的任務全部完成,在next tick佇列中的所有callbacks會被依次呼叫。

當每個階段完成後,如果存在 nextTick 佇列,就會清空佇列中的所有回撥函式,並且優先於其他 microtask 執行。

  Promise.resolve().then(() => console.log('Promise'));
  process.nextTick(() => console.log('nextTick'));
  // nextTick
  // Promise
複製程式碼
setImmediate(() => {
  console.log('setImmediate1');
  setTimeout(() => {
    console.log('setTimeout1');
  }, 0);
});

setTimeout(() => {
  process.nextTick(() => console.log('nextTick'));
  console.log('setTimeout2');
  setImmediate(() => {
    console.log('setImmediate2');
  });
}, 0);

//結果1
// setImmediate1
// setTimeout2
// setTimeout1
// nextTick
// setImmediate2

// 結果2
// setTimeout2
// nextTick
// setImmediate1
// setImmediate2
// setTimeout1
複製程式碼

JavaScript是單執行緒的,但Node本身其實是多執行緒的,除了使用者程式碼無法並行執行外,所有的I/O請求是可以並行執行的。 事件迴圈是Node非同步I/O實現的核心,Node通過事件驅動的方式處理請求,使得其無須為每個請求建立額外的執行緒,省掉了建立和銷燬執行緒的開銷。同時也因為執行緒數較少,不受執行緒上下文切換的影響,維持了Node的高效能。 Node非同步IO、非阻塞的特性,使它非常適用於IO密集、高併發的應用場景。

相關文章