深入理解nodejs的非同步IO與事件模組機制

他鄉踏雪發表於2022-04-02
  • node為什麼要使用非同步I/O
  • 非同步I/O的技術方案:輪詢技術
  • node的非同步I/O
  • nodejs事件環

 一、node為什麼要使用非同步I/O

非同步最先誕生於作業系統的底層,在底層系統中,非同步通過訊號量、訊息等方式有廣泛的應用。但在大多數高階程式語言中,非同步並不多見,這是因為編寫非同步的程式不符合人習慣的思維邏輯。

比如在PHP中它對呼叫層不僅遮蔽非同步,甚至連多執行緒都不提供,從頭到尾的同步阻塞方式執行非常有利於程式設計師按照順序編寫程式碼。但它的缺點在小規模建站中基本不存在,在複雜的網路應用中,阻塞就會導致它併發不友好。

1.1非同步為什麼在node中如此重要

在其他程式語言中,儘管可能存在非同步API,但程式設計師還是習慣同步方式編寫應用。在眾多高階程式語言中,將非同步作為主要程式設計方式和設計理念,Node是首個。Ryan Dahl基於非同步I/O、事件驅動、單執行緒設計因素,期望設計出一個高效能的web伺服器,後來演變為可以基於它構建各種高速、可伸縮網路應用的平臺。與node非同步I/O、事件驅動設計理念類似的產品Nginx採用純C編寫,效能表現的非常優秀。Nginx具備面向客戶端管理連結的強大能力,但它背後依然受限於各種同步方式的程式語言。但Node是全方位的,既能作為服務端處理客戶端大量的併發,也能作為客戶端向網路中的各個應用進行併發請求。

關於非同步I/O為什麼在Node裡如此重要,這與Node面向的網路設計息息相關。web應用已經不再是單臺服務就能勝任的時代,再誇網路的結構下,併發已經是現代程式設計中的標準配備,具體到實處就是使用者體驗和資源分配兩方面的問題:

使用者體驗:

由於瀏覽器執行UI和響應是處於停滯狀態,如果指令碼執行的時間超過100毫秒,使用者就會感到卡頓,以為網頁停止響應。

資源分配:

排除使用者體驗因素,從資源分配層面分析非同步I/O的必要性。計算機在發展過程中將元件進行了抽象,分為I/O裝置和計算裝置。假設業務場景有一組互不相關的任務需要完成,現行的主流方法有以下兩種情況:

1.單執行緒序列依次執行
2.多執行緒並行完成

單執行緒的閉端:同步執行時一個略慢的任務會導致後續執行程式碼被阻塞,通常I/O與CPU計算之間可以並行進行。但同步的程式設計模組導致I/O的進行會讓後續任務等待,造成計算資源不能更好的被利用。

多執行緒的閉端:建立執行緒和執行期執行緒上下文切換的開銷較大,在複雜的業務中,多執行緒經常面臨鎖、狀態同步等問題,但多執行緒在多核CUP上能有效的提升CPU的利用效率。

雖然作業系統會將CPU的時間片段分配給其餘程式,通過啟動多個工作程式來提供服務,但對於一組任務而言它不會分發任務到多個程式上,所以依然無法高效的利用資源。

綜合以上的問題,nodejs在給出的方案是:利用單執行緒遠離多執行緒死鎖和狀態同步等問題;利用非同步I/O,讓單執行緒遠離阻塞,更好的利用CPU。為了彌補單執行緒無法利用多核CPU的缺點,Node提供了類似前端瀏覽器中的web Workers的子程式,通過工作程式程式高效的利用CPU和I/O。(子程式在後面的Node程式管理相關部落格中會有詳細的解析

 二、非同步I/O的技術方案:輪詢技術

當我們談及nodejs時,往往會說非同步、非阻塞、回撥、事件這些詞語,其中非同步與非阻塞聽起來似乎是同一件事,實際上同步非同步和阻塞/非阻塞是兩回事。

同步非同步是指程式碼的執行順序,同步是按照程式碼的編寫順序序列執行,非同步則反之。雖然這樣的表達並不完全準確,但這也基本能解答同步非同步是什麼的問題。

阻塞與非阻塞是指在作業系統中,核心對I/O的兩種方式:

在呼叫阻塞I/O時,應用程式需要等待I/O完成才返回結果,並且後面的程式也需要等待這個結果返回以後才會繼續執行,簡單的說就是這個I/O任務會阻塞後面的程式執行;

在呼叫非阻塞I/O在應用程式中不會等待I/O完全返回結果,作業系統對計算機進行了抽象,將所有輸入輸出裝置抽象為檔案。核心進行I/O操作時,通過檔案描述符進行管理,I/O不會阻塞後面的程式的執行,CPU的時間片段會用來處理其他事務。這時候就有一個問題,I/O什麼時候完成操作是不確定的,程式就要重複呼叫I/O操作來確定是否完成,這種重複的判斷操作是否完成的技術叫做輪詢,關於輪詢的實現技術有很多種,各自也都採用不同的策略。

2.1read

通過重複呼叫來檢查I/O的狀態來確認資料是否完全讀取,這種主動詢問的方式最原始、效能最低,這是因為需要消耗大量的資源來重複進行狀態檢查。

2.2select

它與read一樣,依然採用重複呼叫檢查I/O的狀態來確認事件狀態,不同之處是select採用一個1024個長度的陣列儲存檔案狀態,所以它一次最多可以同時檢查1024個檔案描述符,相比read的一次檢查一個檔案描述符一種改進方案。

2.3poll

該方法較select有所改進,採用連結串列的方式替換陣列,避免陣列的長度限制,其次能避免不需要的檢查。但當檔案描述較多時,它的效能會十分低下。

2.4epoll

該方案是Linux下效率最高的I/O事件通知機制,在進入輪詢時沒有檢查到I/O事件,將會休眠,直到事件將它喚醒。它是真正利用了事件通知、執行回撥的方式,而不是遍歷查詢,所以不會浪費CPU,執行效率高。

2.5kqueue:

該方案的實現方式與epoll類似,不過它僅在FreeBSD系統下存在。

2.6合理的非阻塞非同步I/O與個平臺的最終實現

需要注意的是,儘管epoll、kqueue實現了非阻塞I/O確保獲取完整的資料,但對於引用程式而言這依然是同步,因為應用程式依然需要等待I/O完全返回。等待期間要麼用於遍歷檔案描述符的狀態,要麼用於休眠等待事件發生。

也就是合理的非同步非阻塞I/O應該是由應用程發起非阻塞呼叫,無需通過遍歷或者事件喚醒等待輪詢的方式,而是可以直接處理下一個任務,只需要在I/O完成後通過訊號或回撥將資料傳遞給應用程式即可。

在Linux下實現的AIO就是通過訊號或回撥來傳遞資料的,但它存在還有缺陷就是AIO僅支援核心的I/O中的O_DIRECT方式讀取,導致無法利用系統快取。

在windows下實現的IOCP具備呼叫非同步方法、I/O完成通知、執行回撥,甚至輪詢都由系統核心的執行緒池接手管理,這在一定程度上提供了理想的非同步I/O。

在Nodejs中通過libuv作為系統的I/O抽象層,使得所有平臺的相容性都在這一層完成。為了解決Linux的系統快取nodejs基於非同步I/O庫libeio,在這個基礎上實現了自定義執行緒池。

需要注意的是,I/O不僅僅只限於磁碟讀寫,*nix將磁碟、硬體、套位元組等幾乎所有計算資源都被抽象為了檔案,因此這裡描述的阻塞和非阻塞同樣適應於套位元組等。

 三、node的非同步I/O

在nodejs中的js層面事件核心模組是Events,在這個模組中有一個非常重要的類EventEmitter類,nodejs通過EventEmitter類實現事件的統一管理。但實際業務開發中單獨引入這個模組的場景並不多,因為nodejs本身就是基於事件驅動實現的非同步非阻塞I/O,從js層面來看他就是Events模組。

而其他核心模組需要進行非同步操作的API就是繼承這個模組的EventEmitter類實現的(例如:fs、net、http等),這些非同步操作本身就具備了Events模組定義相關的事件機制和功能,所以也就不需要單獨的引入和使用了,我們只需要知道nodejs是基於事件驅動的非同步操作架構,內建模組是Events模組。

3.1Events模組

在Events模組上有四個基本的API:on、emit、once、off。

//on:新增當事件被觸發時呼叫的回撥函式
//emit:觸發事件,按註冊的順序同步呼叫每個事件監聽器
//once:新增當事件在註冊之後首次被觸發時呼叫的回撥函式,呼叫之後該回撥就會被刪除
//off:移除特定的監聽器

這四個API的應用非常的簡單,就不過多的贅述它們如何應用了,直接上一段測試程式碼:

 1 const EventEmitter = require('events'); //匯入事件模組
 2 const ev = new EventEmitter();          //建立一個事件物件
 3 ev.on('事件1',()=>{                     //向事件物件的監聽器新增事件回撥
 4     console.log('事件1執行了');
 5 });
 6 function fun(){
 7     console.log("事件1執行了----fun");
 8 }
 9 ev.on('事件1',fun);
10 ev.once('事件1',()=>{
11     console.log("事件1執行了----once回撥任務");
12 });
13 ev.emit('事件1');                       //觸發事件物件的監聽器(注意這裡是同步觸發),所以只能觸發前面三個回撥任務,並且會把once註冊的回撥任務在觸發後刪除
14 ev.on('事件1',()=>{                     //這個事件回撥不會被前面的emit觸發
15     console.log("事件1執行了----4");
16 });
17 ev.emit('事件1');                       //這個觸發的監聽器會呼叫到“事件1執行了----4”,但前面once註冊的任務不會觸發了。
18 ev.off("事件1",fun);                    //刪除ev事件物件上“事件1”註冊的fun回撥
19 ev.emit('事件1');                       //這裡能觸發的除once和off刪除之外的回撥

通過上面這段示例程式碼可以看到需要注意的點,就是在示例程式碼中的emit()的觸發是同步的,最直觀的就是第13行程式碼它不會觸發“事件1執行了----4”這個回撥任務。

這是因為呼叫觸發事件物件監聽器的ev.emit()是在當前主執行緒上,也就是說它是由主執行緒同步觸發的。而在nodejs中基於Events實現的fs、net、http這些模組的非同步操作(這些模組也有同步操作)是由其他I/O執行緒以非同步的方式呼叫觸發emit的,所以如果你是非同步觸發emit的化,那“事件1執行了----4”就會被執行,比如下面這段程式碼:

const EventEmitter = require('events'); //匯入事件模組
const ev = new EventEmitter();          //建立一個事件物件
ev.on('ev1',()=>{
    console.log(1);
});
setTimeout(()=>{                        //使用定時器實現非同步觸發ev.emit
    ev.emit('ev1');
});
ev.on('ev1',()=>{
    console.log(2);
});
//測試結果
1
2

nodejs中給事件回撥任務傳參:

const EventEmitter = require('events'); //匯入事件模組
const ev = new EventEmitter();          //建立一個事件物件
ev.on('ev1',(a,b,c)=>{
    console.log(a);
    console.log(b);
    console.log(c);
});
ev.on('ev1',(...arg)=>{
    console.log(arg);
});
ev.emit('ev1',1,2,3);
//列印結果
1
2
3
[ 1, 2, 3 ]

關於nodejs中的事件回撥任務傳參,其與瀏覽器有一些差別,在瀏覽器事件中會有事件源物件和一些其他固定的引數,不能直接給回撥任務傳參。

nodejs中的事件回撥任務this指向:

 1 console.log(this);      //指向一個空物件{}
 2 ev.on('ev1',()=>{
 3     console.log(this);  //指向一個空物件{}
 4 });
 5 function fun(){
 6     console.log(this);  //指向事件物件本身
 7 }
 8 ev.on('ev1',fun);
 9 let obj = {
10     f:function(){
11         console.log(this);  //指向事件物件本身
12     }
13 };
14 ev.on('ev1',obj.f);
15 let obj2 = {
16     f:()=>{
17         console.log(this);  //指向一個空物件
18     }
19 };
20 ev.on('ev1',obj2.f);
21 ev.emit('ev1');

在nodejs中函式表示式指向事件物件本身這與DOM上的事件回撥函式this指向DOM本身有一些類似,但也還是有區別的。箭頭函式指向與瀏覽器中的規則一致,都是指向箭頭函式所在包裹它的作用域的this,在前面的示例中這個表現的不明顯,上面的箭頭函式都是指向包裹它的作用的this(即全域性作用域,而nodejs的全域性作用this指向就是一個空物件)。

 1 const EventEmitter = require('events'); //匯入事件模組
 2 const ev = new EventEmitter();          //建立一個事件物件
 3 let obj = {
 4     f:function(){
 5         ev.on('ev1',()=>{
 6             console.log(this);    //這個this指向包裹箭頭函式的作用域f的this,而f的this指向obj
 7         });
 8     }
 9 };
10 
11 obj.f();
12 ev.emit('ev1');

從nodejs的Evets模組的設計模式角度來看是釋出訂閱者模式,但這僅僅是Events模組的事件註冊與觸發的角度來看待。而在nodejs的非同步事件總體設計角度來看,它的核心還是在非同步I/O上,而底層的非同步I/O是觀察者模式。從總體的nodejs非同步I/O設計角度就是基於釋出訂閱+觀察者設計模式實現的,這是兩個部分組成從的一個系統性設計,為了更好的理解整體的nodejs的非同步I/O,接下來先從nodejs的底層非同步I/O角度來分析,然後再在這個基礎上來分析Events模組機制。

關於觀察者模式、釋出訂閱模式可以參考這篇部落格:https://www.cnblogs.com/onepixel/p/10806891.html

3.2事件迴圈與觀察者模式

在程式啟動時,node便會建立一個類似while(true)的迴圈,每執行一次迴圈體的過程體通常被稱為Tick。每個Tick的過程就是檢視是否有事件待處理,如果有就會取出事件及其相關回撥函式執行,然後進入下一個迴圈,這種判斷是否有事件需要處理的設計模式就是觀察者模式。

 

在整個事件迴圈過程中,單個非同步I/O的具體執行過程:

1.Js層Events模組呼叫底層I/O的非同步任務介面,這個非同步任務介面由libuv模組提供
2.libuv建立一個任務物件,向下開啟一個非同步I/O的核心操作,向上將任務物件交給事件迴圈池中管理
3.底層的I/O執行緒處理I/O任務,JS主執行緒繼續往下執行
4.當底層I/O執行緒處理完任務後,通過訊息的方式通知事件迴圈池,並將資料交給任務物件
5.事件迴圈Tick觀察到有需要處理的事件訊息,將資料和任務物件中的回撥任務交給主執行緒處理

組成一個完整的nodejs非同步I/O模型有四個基本要素:事件迴圈、觀察者、請求物件(任務物件)、I/O執行緒池。而在Nodejs除了fs、net、http這些I/O非同步還包含一些非I/O非同步,定時器、工作執行緒非同步事件,這些非同步任務都統一交給事件程式來管理,關於程式管理內容後面會有詳細的解析部落格,這裡先不做解析。這裡要關注的是nodejs非同步事件驅動模式有哪些優勢,通常所說的nodejs高效能伺服器又是如何體現出來的。

3.3事件驅動與高效能

上面是基於Nodejs構建的web伺服器的流程圖,下面先來回顧以下其他幾種經典的伺服器模型,然後來對比它們的優缺點:

同步方式:一次只能處理一個請求,並且其餘請求都處於等待狀態。
每程式/每請求:為每個請求啟動一個程式,這樣可以處理多個請求,但是它不具備擴充套件性,因為系統資源只有那麼多。
每執行緒/每請求:為每個請求啟動一個執行緒來處理,儘管執行緒比程式要輕量,但由於每個執行緒都佔用一定記憶體,當大併發請求到來時,記憶體將會很快用光,導致伺服器緩慢。

每執行緒/每請求的方式目前Apache所採用,相比nodejs通過事件驅動的方式處理請求無需為每個請求建立額外的對應執行緒,可以省掉建立執行緒和銷燬執行緒的開銷,同時作業系統在排程任務時因為執行緒較少,上下文切換的代價也很低。這使得node服務即使在大連結的情況下,也不受執行緒上下文切換開銷的影響,這是Node高效能的原因。

事件驅動帶來的高效已經逐漸開始為業界所重視,知名伺服器Nginx也採用了事件驅動。如今Nginx大有取代Apache之勢。Node與Nginx都是事件驅動,但由於Nginx採用純C編寫,效能較高,但它僅適合做web伺服器,用於反向代理或負載均衡等服務,在處理具體業務方面較為欠缺。Nodejs則是一套高效能平臺,可以利用它構建與Nginx相同的功能,也可以處理各種具體的業務,而且與背後的網路保持非同步通暢。兩者相比:

Nodejs:應用場景適應性更大,自身效能也不錯。

Nginx:作為服務非常專業。

除了nodejs基於事件驅動構建的平臺以外,還有基於Ruby構建的Event Machine平臺、基於Perl構建的AnyEvent平臺、基於Python構建的Twisted。

3.3釋出訂閱模式與模擬實現Events模組

關於釋出訂閱模式可以參考這篇部落格:javaScript設計模式:釋出訂閱模式

關於這一部分也沒有太多需要解析的,如果你瞭解釋出訂閱模式就明白nodejs在Events模組的JS實現,所以這裡我直接貼上模組程式碼:

 1 //模擬實現Events
 2 function MyEvents(){
 3     //準備一個資料結構用於快取訂閱者資訊
 4     this._events = Object.create(null);
 5 }
 6 MyEvents.prototype.on = function(type, callback){   //on相當於訂閱者
 7     //判斷當前次的事件是否已經存在,然後再決定如何做快取
 8     if(this._events[type]){
 9         this._events[type].push(callback);
10     }else{
11         this._events[type] = [callback];
12     }
13 };
14 MyEvents.prototype.emit = function(type, ...arg){   //emit相當於是釋出者
15     if(this._events && this._events[type].length){
16         this._events[type].forEach(callback =>{
17             callback.call(this, ...arg);
18         });
19     }
20 };
21 
22 MyEvents.prototype.off = function(type,callback){   //實現取消事件監聽任務
23     //判斷當前type事件監聽是否存在,如果存在則取消指定的監聽
24     if(this._events && this._events[type]){
25         this._events[type] = this._events[type].filter(item=>{
26             return item !== callback && item !== callback.link;
27         });
28     }
29 };
30 MyEvents.prototype.once = function(type, callback){   //實現新增只觸發一次的監聽任務
31     let foo = function(...args){
32         callback.call(this, ...arg);
33         this.off(type,foo);
34     };
35     foo.link = callback;
36     this.on(type,foo);
37 };

 四、nodejs事件環

在瞭解這部分內容之前,建議先了解瀏覽器UI多執行緒與JavaScript單執行緒的原理機制,可以參考這篇部落格: 瀏覽器UI多執行緒及JavaScript單執行緒執行機制的理解

4.1瀏覽器中的事件環

在瀏覽器中談到事件環一般首先談到的就是UI多執行緒,在ES3之前JavaScript自身沒有發起非同步請求的能力,所以在此之前的所有關於UI多執行緒涉及的非同步都是巨集任務,這些非同步巨集任務都統一要等待JavaScript主執行緒執行完以後才會開始按照UI佇列的先後順序被觸發,包括DOM事件、定時器。

當JavaScript發展到ES5中引入了Promise,HTML5標準引入了worker、MutaionObserver,JavaScript自身就具備了發起非同步任務的能力,雖然同為非同步任務,但它們卻有執行先後的區別,而不再是統一由UI佇列的現後順序執行那麼簡單,為了區分這些非同步任務的差異,就引入了巨集任務和微任務的概念。

瀏覽器中的巨集任務:DOM事件(UI事件)、定時器、worker相關的事件。

瀏覽器中的微任務:Promise的非同步任務、MutaionObserver。

 下面簡單的描述一下瀏覽器中的JS主線與與事件環的執行過程,但需要注意這裡並不涉及解析UI渲染執行緒與JS引擎主執行緒的互斥問題,這是兩個問題不能混淆,這裡解析的是JS主執行緒與非同步任務的事件環之間的執行關係。

1.JS主執行緒執行同步任務
2.同步執行過程中遇到巨集任務與微任務新增至相應的佇列
3.同步程式碼執行完以後,如果事件環中的微任務佇列中有相應的非同步執行結果,傳遞給JS主執行緒並在JS主執行緒上執行相關聯的回撥任務
4.如果事件環中沒有微任務或者微任務執行完了,再執行巨集任務(如果有巨集任務)
5.如果巨集任務執行完了,再立即檢查微任務佇列是否又有新的微任務,如果有立即執行
6.迴圈事件環操作

結合上面的解析來看兩個示例:

 1 let ev = console.log('start')
 2 setTimeout(() => {
 3   console.log('setTimeout')
 4 }, 0)
 5 new Promise((resolve) => {
 6   console.log('promise')
 7   resolve()
 8 })
 9   .then(() => {
10     console.log('then1')
11   })
12   .then(() => {
13     console.log('then2')
14   })
15 console.log('end')
16 //執行結果:start 、promise 、end、then1、then2、setTimeout
深入理解nodejs的非同步IO與事件模組機制
 1 //示例二
 2 setTimeout(()=>{
 3     console.log('s1');
 4     Promise.resolve().then(()=>{
 5         console.log('p1');
 6     });
 7     Promise.resolve().then(()=>{
 8         console.log('p2');
 9     });
10 });
11 setTimeout(()=>{
12     console.log('s2');
13     Promise.resolve().then(()=>{
14         console.log('p3');
15     });
16     Promise.resolve().then(()=>{
17         console.log('p4');
18     });
19 });
20 //執行結果:s1、p1、p1、s2、p3、p4
示例二

4.2Nodejs中的事件環

在nodejs中與瀏覽器有類似的事件環機制,也同樣有巨集任務和微任務的概念,但在具體表現上有一些差異:

--nodejs中微任務佇列中有兩個種不同的優先順序:

process.nextTick的回撥任務
promise相關非同步任務

--nodejs中巨集任務隊不像瀏覽器中的巨集任務只有一個佇列,而是有六個:

timers:setTimout與setInterval的回撥任務
pending callbacks:執行系統操作的回撥,例如tcp、udp
idle,prepare:只在系統內部使用(也就是說這兩個個佇列的任務不是傳遞給JS主執行緒的,而是傳遞給系統處理的回撥)
poll:執行與I/O相關的回撥
check:setImmediate的回撥任務
close callbacks:執行close事件的回撥

根瀏覽器的事件環機制一樣,nodejs中的事件環機制也是先執行微任務,然後執行巨集任務,巨集任務執行完以後在檢查微任務佇列這樣的一個迴圈機制,這是總體的事件環執行機制。然後微任務中按照優先順序依次執行,巨集任務中的六個任務佇列也一樣依次執行,具體順序參考下面的示圖(前面的列舉順序其實就是它們的優先順序和執行順序):

關於nodejs微任務的優先順序,這在nodejs全域性物件簡析中的2.9中有詳細的說明,這裡在做簡單介紹,process.nextTick優先於promise的非同步任務,但要注意還有一個queueMicrotask()方法是在主執行緒的末尾處新增一個堆疊,而不是非同步任務,但從某種角度上來說它有些類似非同步回撥,但從它並沒有被新增到事件佇列中。

最後需要注意的問題是,由於setTimoutsetInterval、setImmediate是基於延時非同步回撥,但即便傳入指定的執行時間或者不傳時間都不能保證其精度,所以當不傳入時間時從某種意義上來說它們是一種隨機狀態,比如你將它們在同步相鄰的執行緒上定義了,它們的執行現後順序是不確定的,比如你可以通過多次測試下面這個程式碼,就有很大的機率出現列印結果的順序不一致:

setTimeout(()=>{
    console.log("s1");
});
setImmediate(()=>{
    console.log("s2");
});

發生這種問題的原因就是因為它們載新增到事件佇列中之前都會底層模組進行一個延時處理,即便沒有設定延時它們也都必須執行這個過程,這個過程就會導致他不能像程式碼在同步堆疊上定義的那樣,而是都會經過底層的非同步操作過後再被新增到各自的事件佇列中,而底層的非同步操作這個過程你是無法預測它們的執行時間,也正是因為這個事件導致它們新增到任務佇列中的時機不確定,而且事件環還在迴圈執行各個任務佇列的位置也是不確定的,所以它們這種情況就可以看作是不確定的隨機觸發。

相關文章