- 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

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"); });
發生這種問題的原因就是因為它們載添加到事件隊列中之前都會底層模塊進行一個延時處理,即便沒有設置延時它們也都必須執行這個過程,這個過程就會導致他不能像代碼在同步堆棧上定義的那樣,而是都會經過底層的異步操作過后再被添加到各自的事件隊列中,而底層的異步操作這個過程你是無法預測它們的執行時間,也正是因為這個事件導致它們添加到任務隊列中的時機不確定,而且事件環還在循環執行各個任務隊列的位置也是不確定的,所以它們這種情況就可以看作是不確定的隨機觸發。