深入理解nodejs的非同步IO與事件模組機制
- 2022 年 4 月 2 日
- 筆記
- javaScript源碼解析, NodeJs
- 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模組機制。
關於觀察者模式、發布訂閱模式可以參考這篇部落格://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
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()方法是在主執行緒的末尾處添加一個堆棧,而不是非同步任務,但從某種角度上來說它有些類似非同步回調,但從它並沒有被添加到事件隊列中。
最後需要注意的問題是,由於setTimout、setInterval、setImmediate是基於延時非同步回調,但即便傳入指定的執行時間或者不傳時間都不能保證其精度,所以當不傳入時間時從某種意義上來說它們是一種隨機狀態,比如你將它們在同步相鄰的執行緒上定義了,它們的執行現後順序是不確定的,比如你可以通過多次測試下面這個程式碼,就有很大的機率出現列印結果的順序不一致:
setTimeout(()=>{ console.log("s1"); }); setImmediate(()=>{ console.log("s2"); });
發生這種問題的原因就是因為它們載添加到事件隊列中之前都會底層模組進行一個延時處理,即便沒有設置延時它們也都必須執行這個過程,這個過程就會導致他不能像程式碼在同步堆棧上定義的那樣,而是都會經過底層的非同步操作過後再被添加到各自的事件隊列中,而底層的非同步操作這個過程你是無法預測它們的執行時間,也正是因為這個事件導致它們添加到任務隊列中的時機不確定,而且事件環還在循環執行各個任務隊列的位置也是不確定的,所以它們這種情況就可以看作是不確定的隨機觸發。