畢業到入職騰訊已經差不多一年的時光了,接觸了很多項目,也積累了很多實踐經驗,在處理問題的方式方法上有很大的提升。随着時間的增加,愈加發現基礎知識的重要性,很多開發過程中遇到的問題都是由最基礎的知識點遺忘造成,基礎不牢,地動山搖。是以,就再次回歸基礎知識,重新學習NodeJs相關内容,加深對NodeJs本質的了解。日知其所亡,身為有追求的程式員,理應不斷學習,不斷拓展自己的知識邊界。本系列文章是在此階段産生的積累,以記錄下以往沒有關注的核心知識點,供後續查閱之用。
前言:
2017
05/27
Node保持了JavaScript在浏覽器中單線程的特點。而且在Node中,JavaScript與其餘線程是無法共享任何狀态的。單線程的最大好處是不用像多線程程式設計那樣處處在意狀态的同步問題,這裡沒有死鎖的存在,也沒有線程上下文交換所帶來的性能上的開銷。
同樣,單線程也有它自身的弱點,這些弱點是學習Node的過程中必須要面對的。積極面對這些弱點,可以享受到Node帶來的好處,也能避免潛在的問題,使其得以高效利用。單線程的弱點具體有以下3方面。
- 無法利用多核CPU。
- 錯誤會引起整個應用退出,應用的健壯性值得考驗。
- 大量計算占用CPU導緻無法繼續調用異步I/O。
Node采用了與Web Workers相同的思路來解決單線程中大計算量的問題:child_process 。 子程序的出現,意味着Node可以從容地應對單線程在健壯性和無法利用多核CPU方面的問題。通過将計算分發到各個子程序,可以将大量計算分解掉,然後再通過程序之間的事件消息來傳遞結果,這可以很好地保持應用模型的簡單和低依賴。通過Master-Worker的管理方式,也可以很好地管理各個工作程序,以達到更高的健壯性。
05/30
應用場景:
I/O密集型:I/O密集的優勢主要在于Node利用事件循環的處理能力,而不是啟動每一個線程為每一個請求服務,資源占用極少。
CPU密集型:關于CPU密集型應用,Node的異步I/O已經解決了在單線程上CPU與I/O之間阻塞無法重疊利用的問題,I/O阻塞造成的性能浪費遠比CPU的影響小。對于長時間運作的計算,如果它的耗時超過普通阻塞I/O的耗時,那麼應用場景就需要重新評估,因為這類計算比阻塞I/O還影響效率,甚至說就是一個純計算的場景,根本沒有I/O。此類應用場景或許應當采用多線程的方式進行計算。
與遺留系統和平共處 :在Web端,過去大多都是同步的方式編寫的程式,這種串行調用下層應用資料的過程中充斥着串行的等待時間。 采用Node來完成Web端的開發,使得前端工程師在HTTP協定棧的兩端能夠高效靈活地開發。并行I/O,有效利用穩定接口提升Web渲染能力。
分布式應用 :Node高效利用并行I/O。
06/02
在Node中引入子產品,需要經曆如下3個步驟。
- 路徑分析
- 檔案定位
- 編譯執行
在Node中,子產品分為兩類:一類是Node提供的子產品,稱為核心子產品;另一類是使用者編寫的模
塊,稱為檔案子產品。
核心子產品部分在Node源代碼的編譯過程中,編譯進了二進制執行檔案。在Node程序啟動時,部分核心子產品就被直接加載進記憶體中,是以這部分核心子產品引入時,檔案定位和編譯執行這兩個步驟可以省略掉,并且在路徑分析中優先判斷,是以它的加載速度是最 快的。
檔案子產品則是在運作時動态加載,需要完整的路徑分析、檔案定位、編譯執行過程,速度比核心子產品慢。
06/04
JavaScript子產品的編譯 :在編譯的過程中,Node對擷取的JavaScript檔案内容進行了頭尾包裝。在頭部添加了(function (exports, require, module, __filename, __dirname) {\n,在尾部添加了\n});。 在執行之後,子產品的exports屬性被傳回給了調用方。exports屬性上的任何方法和屬性都可以被外部調用到,但是子產品中的其餘變量或屬性則不可直接被調用。
JavaScript的一個典型弱點就是位運算。JavaScript的位運算參照Java的位運算實作,但是Java位運算是在int型數字的基礎上進行的,而JavaScript中隻有double型的資料類型,在進行位運算的過程中,需要将double 型轉換為 int型,然後再進行。是以,在JavaScript層面上做位運算的效率不高。
06/05
PHP對調用層不僅屏蔽了異步,甚至連多線程都不提供。PHP語言從頭到腳都是以同步阻塞的方式來執行的。它的優點十分明顯,利于程式員順序編寫業務邏輯;它的缺點在小規模站點中基本不存在,但是在複雜的網絡應用中,阻塞導緻它無法更好地并發。
伴随着異步I/O的還有事件驅動和單線程,它們構成Node的基調,Ryan Dahl正是基于這幾個因素設計了Node。
與Node的事件驅動、異步I/O設計理念比較相近的一個知名産品為Nginx。Nginx采用純C編寫,性能表現非常優異。它們的差別在于,Nginx具備面向用戶端管理連接配接的強大能力,但是它的背後依然受限于各種同步方式的程式設計語言。但Node卻是全方位的,既可以作為伺服器端去處理用戶端帶來的大量并發請求,也能作為用戶端向網絡中的各個應用進行并發請求。
I/O是昂貴的,分布式I/O是更昂貴的 。
Node在兩者之間給出了它的方案:利用單線程,遠離多線程死鎖、狀态同步等問題;利用異步I/O,讓單線程遠離阻塞,以更好地使用CPU。
作業系統對計算機進行了抽象,将所有輸入輸出裝置抽象為檔案。核心在進行檔案I/O操作時,通過檔案描述符進行管理,而檔案描述符類似于應用程式與系統核心之間的憑證。應用程式如果需要進行I/O調用,需要先打開檔案描述符,然後再根據檔案描述符去實作檔案的資料讀寫。此處非阻塞I/O與阻塞I/O的差別在于阻塞I/O完成整個擷取資料的過程,而非阻塞I/O則不帶資料直接傳回,要擷取資料,還需要通過檔案描述符再次讀取。 由于完整的I/O并沒有完成,立即傳回的并不是業務層期望的資料,而僅僅是目前調用的狀态。為了擷取完整的資料,應用程式需要重複調用I/O操作來确認是否完成。
epoll。該方案是Linux下效率最高的I/O事件通知機制,在進入輪詢的時候如果沒有檢查到I/O事件,将會進行休眠,直到事件發生将它喚醒。它是真實利用了事件通知、執行回調的方式,而不是周遊查詢,是以不會浪費CPU,執行效率較高。
另一個需要強調的地方在于我們時常提到Node是單線程的,這裡的單線程僅僅隻是JavaScript執行在單線程中罷了。在Node中,無論是*nix還是Windows平台,内部完成I/O任務的另有線程池。
06/06
請求對象是異步I/O過程中的重要中間産物,所有的狀态都儲存在這個對象中,包括送入線程等待執行以及I/O操作完畢後的回調處理。
事件循環、觀察者、請求對象、I/O線程池這四者共同構成了Node異步I/O模型的基本要素。 Windows下主要通過IOCP來向系統核心發送I/O調用和從核心擷取已完成的I/O操作,配以事件循環,以此完成異步I/O的過程。在Linux下通過epoll實作這個過程,FreeBSD下通過kqueue實作,Solaris下通過Event ports實作。不同的是線程池在Windows下由核心(IOCP)直接提供,*nix系列下由libuv自行實作。
每次調用 process.nextTick()方法,隻會将回調函數放入隊列中,在下一輪Tick時取出執行。定時器中采用紅黑樹的操作時間複雜度為O(lg(n)) , nextTick()的時間複雜度為O(1)。相較之下,process.nextTick() 更高效。
process.nextTick()中的回調函數執行的優先級要高于setImmediate()。這裡的原因在于事件循環對觀察者的檢查是有先後順序的,process.nextTick()屬于idle觀察者,setImmediate()屬于check觀察者。在每一個輪循環檢查中,idle觀察者先于I/O觀察者,I/O觀察者先于check觀察者。
06/09
Node帶來的最大特性莫過于基于事件驅動的非阻塞I/O模型,這是它的靈魂所在。非阻塞I/O可以使CPU與I/O并不互相依賴等待,讓資源得到更好的利用。對于網絡應用而言,并行帶來的想象空間更大,延展而開的是分布式和雲。并行使得各個單點之間能夠更有效地組織起來,這也是Node在雲計算廠商中廣受青睐的原因。
流程控制:
事件釋出/訂閱模式相對算是一種較為原始的方式,Promise/Deferred模式貢獻了一個非常不錯的異步任務模型的抽象。而上述的這些異步流程控制方案與Promise/Deferred模式的思路不同,Promise/Deferred的重頭在于封裝異步的調用部分,流程控制庫則顯得沒有模式,将處理重點放置在回調函數的注入上。從自由度上來講,async、Step這類流控庫要相對靈活得多。EventProxy庫則主要借鑒事件釋出/訂閱模式和流程控制庫通過高階函數生成回調函數的方式實作。
06/10
在一般的後端開發語言中,在基本的記憶體使用上沒有什麼限制,然而在Node中通過JavaScript使用記憶體時就會發現隻能使用部分記憶體(64位系統下約為1.4 GB,32位系統下約為0.7 GB)。至于V8為何要限制堆的大小,表層原因為V8最初為浏覽器而設計,不太可能遇到用大量記憶體的場景。對于網頁來說,V8的限制值已經綽綽有餘。深層原因是V8的垃圾回收機制的限制。按官方的說法,以1.5 GB的垃圾回收堆記憶體為例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。這是垃圾回收中引起JavaScript線程暫停執行的時間,在這樣的時間花銷下,應用的性能和響應能力都會直線下降。這樣的情況不僅僅後端服務無法接受,前端浏覽器也無法接受。是以,在當時的考慮下直接限制堆記憶體是一個好的選擇。
V8記憶體管理:
在V8中,主要将記憶體分為新生代和老生代兩代。新生代中的對象為存活時間較短的對象,老生代中的對象為存活時間較長或常駐記憶體的對象。
在分代的基礎上,新生代中的對象主要通過Scavenge算法進行垃圾回收。在Scavenge的具體實作中,主要采用了Cheney算法。 Cheney算法是一種采用複制的方式實作的垃圾回收算法。它将堆記憶體一分為二,每一部分空間稱為semispace。在這兩個semispace空間中,隻有一個處于使用中,另一個處于閑置狀态。處于使用狀态的semispace空間稱為From空間,處于閑置狀态的空間稱為To空間。當我們配置設定對象時,先是在From空間中進行配置設定。當開始進行垃圾回收時,會檢查From空間中的存活對象,這些存活對象将被複制到To空間中,而非存活對象占用的空間将會被釋放。完成複制後,From空間和To空間的角色發生對換。簡而言之,在垃圾回收的過程中,就是通過将存活對象在兩個semispace空間之間進行複制。
對象晉升的條件主要有兩個,一個是對象是否經曆過Scavenge回收,一個是To空間的記憶體占用比超過限制。
對于老生代中的對象,由于存活對象占較大比重,再采用Scavenge的方式會有兩個問題:一個是存活對象較多,複制存活對象的效率将會很低;另一個問題依然是浪費一半空間的問題。這兩個問題導緻應對生命周期較長的對象時Scavenge會顯得捉襟見肘。為此,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相結合的方式進行垃圾回收。
Mark-Sweep是标記清除的意思,它分為标記和清除兩個階段。Mark-Sweep在标記階段周遊堆中的所有對象,并标記活着的對象,在随後的清除階段中,隻清除沒有被标記的對象。活對象在新生代中隻占較小部分,死對象在老生代中隻占較小部分,這是兩種回收方式能高效處理的原因。
Mark-Compact是标記整理的意思,是在Mark-Sweep的基礎上演變而來的。它們的差别在于對象在标記為死亡後,在整理的過程中,将活着的對象往一端移動,移動完成後,直接清理掉邊界外的記憶體。
為了避免出現JavaScript應用邏輯與垃圾回收器看到的不一緻的情況,垃圾回收的3種基本算法都需要将應用邏輯暫停下來,待執行完垃圾回收後再恢複執行應用邏輯,這種行為被稱為“全停頓”(stop-the-world)。在V8的分代式垃圾回收中,一次小垃圾回收隻收集新生代,由于新生代預設配置得較小,且其中存活對象通常較少,是以即便它是全停頓的影響也不大。但V8的老生代通常配置得較大,且存活對象較多,需要較多的時間。為了降低全堆垃圾回收帶來的停頓時間,V8先從标記階段入手,将原本要一口氣停頓完成的動作改為增量标記(incremental marking),也就是拆分為許多小“步進”,每做完一“步進”就讓JavaScript應用邏輯執行一小會兒,垃圾回收與應用邏輯交替執行直到标記階段完成。 V8後續還引入了延遲清理(lazy sweeping)與增量式整理(incremental compaction),讓清理與整理動作也變成增量式的。同時還計劃引入并行标記與并行清理,進一步利用多核性能降低每次停頓的時間。
如果變量是全局變量(不通過var聲明或定義在global變量上),由于全局作用域需要直到程序退出才能釋放,此時将導緻引用的對象常駐記憶體(常駐在老生代中)。如果需要釋放常駐記憶體的對象,可以通過delete操作來删除引用關系。或者将變量重新指派,讓舊的對象脫離引用關系。在接下來的老生代記憶體清除和整理的過程中,會被回收釋放。
通常,造成記憶體洩漏的原因有如下幾個。 緩存。 隊列消費不及時。 作用域未釋放。
直接将記憶體作為緩存的方案要十分慎重。外部的緩存軟體有着良好的緩存過期淘汰政策以及自有的記憶體管理,不影響Node程序的性能。 将緩存轉移到外部,減少常駐記憶體的對象的數量,讓垃圾回收更高效。 程序之間可以共享緩存。
06/11
upgrade事件:當用戶端要求更新連接配接的協定時,需要和伺服器端協商,用戶端會在請求頭中帶上Upgrade字段,伺服器端會在接收到這樣的請求時觸發該事件。這在後文的WebSocket部分有詳細流程的介紹。如果不監聽該事件,發起該請求的連接配接将會關閉。
除此之外,WebSocket與傳統HTTP有如下好處。 用戶端與伺服器端隻建立一個TCP連接配接,可以使用更少的連接配接。 WebSocket伺服器端可以推送資料到用戶端,這遠比HTTP請求響應模式更靈活、更高效。 有更輕量級的協定頭,減少資料傳送量。
06/13
SSL作為一種安全協定,它在傳輸層提供對網絡連接配接加密的功能。
Node在網絡安全上提供了3個子產品,分别為crypto 、 tls 、 https 。
Node基于事件驅動和非阻塞設計,在分布式環境中尤其能發揮出它的特長,基于事件驅動可以實作與大量的用戶端進行連接配接,非阻塞設計則讓它可以更好地提升網絡的響應吞吐。Node提供了相對底層的網絡調用,以及基于事件的程式設計接口,使得開發者在這些子產品上十分輕松地建構網絡應用。
06/14
采用第三方緩存來存儲Session引起的一個問題是會引起網絡通路。理論上來說通路網絡中的資料要比通路本地磁盤中的資料速度要慢,因為涉及到握手、傳輸以及網絡終端自身的磁盤I/O等,盡管如此但依然會采用這些高速緩存的理由有以下幾條: Node與緩存服務保持長連接配接,而非頻繁的短連接配接,握手導緻的延遲隻影響初始化。 高速緩存直接在記憶體中進行資料存儲和通路。 緩存服務通常與Node程序運作在相同的機器上或者相同的機房裡,網絡速度受到的影響較小。
06/15
模闆引擎 :
文法分解。 處理表達式。将标簽表達式轉換成普通的語言表達式。 生成待執行的語句。 與資料一起執行,生成最終字元串。
模闆編譯:
為了能夠最終與資料一起執行生成字元串,我們需要将原始的模闆字元串轉換成一個函數對象。生成的中間函數隻與模闆字元串相關,與具體的資料無關。
一些模闆引擎的優化步驟,主要有如下幾種。 緩存模闆檔案。 緩存模闆檔案編譯後的函數。 優化模闆中的執行表達式 。
06/18
為了解決高并發問題,基于事件驅動的服務模型出現了,像Node與Nginx均是基于事件驅動的方式實作的,采用單線程避免了不必要的記憶體開銷和上下文切換開銷。 在PHP中沒有線程的支援。它的健壯性是由它給每個請求都建立獨立的上下文來實作的。
由于所有處理都在單線程上進行,影響事件驅動服務模型性能的點在于CPU的計算能力,它的上限決定這類服務模型的性能上限,但它不受多程序或多線程模式中資源上限的影響,可伸縮性遠比前兩者高。如果解決掉多核CPU的利用問題,帶來的性能上提升是可觀的。
06/20
IPC的全稱是Inter-Process Communication,即程序間通信。程序間通信的目的是為了讓不同的程序能夠互相通路資源并進行協調工作。實作程序間通信的技術有很多,如命名管道、匿名管道、socket、信号量、共享記憶體、消息隊列、Domain Socket等。
Node中實作IPC通道的是管道(pipe)技術。但此管道非彼管道,在Node中管道是個抽象層面的稱呼,具體細節實作由libuv提供,在Windows下由命名管道(named pipe)實作,*nix系統則采用Unix Domain Socket實作。
子程序根據message.type建立對應TCP伺服器對象,然後監聽到檔案描述符上。由于底層細節不被應用層感覺,是以在子程序中,開發者會有一種伺服器就是從父程序中直接傳遞過來的錯覺。值得注意的是,Node程序之間隻有消息傳遞,不會真正地傳遞對象,這種錯覺是抽象封裝的結果。
獨立啟動的程序中,TCP伺服器端socket套接字的檔案描述符并不相同,導緻監聽到相同的端口時會抛出異常。 但對于send()發送的句柄還原出來的服務而言,它們的檔案描述符是相同的,是以監聽相同端口不會引起異常。 多個應用監聽相同端口時,檔案描述符同一時間隻能被某個程序所用。
06/23
Node預設提供的機制是采用作業系統的搶占式政策。所謂的搶占式就是在一堆工作程序中,閑着的程序對到來的請求進行争搶,誰搶到誰服務。
06/27
Node産品的性能與許多因素相關,這裡我們将範疇縮減到Web應用中來,隻評估一些常見的提升性能的方法。對于Web應用而言,最直接有效的莫過于動靜分離、多程序架構、分布式,其中涉及的幾個拆分原則如下所示。 做專一的事。 讓擅長的工具做擅長的事情。 将模型簡化。 将風險分離。 除此之外,緩存也能帶來很大的性能提升。
如果程序中存在記憶體洩漏,又一時沒有排查解決,有一種方案可以解決這種狀況。這種方案應用于多程序架構的服務叢集,讓每個工作程序指定服務多少次請求,達到請求數之後程序就不再服務新的連接配接,主程序啟動新的工作程序來服務客戶,舊的程序等所有連接配接斷開後就退出。這樣即使存在記憶體洩漏的風險,也能有效地規避記憶體洩漏帶來的影響。但這屬于規避問題,隻解決了問題的表象,不推薦使用。
資料冰冷的,但我們要讓資料溫暖起來,改變我們的生活!