Node.js 與並發模型的詳細介紹
前言:
Node.js 現在已成為構建高並發網絡應用服務工具箱中的一員,何以 Node.js 會成為大眾的寵兒?本文將從進程、線程、協程、I/O 模型這些基本概念說起,為大傢全面介紹關於 Node.js 與並發模型的這些事。
進程
我們一般將某個程序正在運行的實例稱之為進程,它是操作系統進行資源分配和調度的一個基本單元,一般包含以下幾個部分:
- 程序:即要執行的代碼,用於描述進程要完成的功能;
- 數據區域:進程處理的數據空間,包括數據、動態分配的內存、處理函數的用戶棧、可修改的程序等信息;
- 進程表項:為瞭實現進程模型,操作系統維護著一張稱為
進程表
的表格,每個進程占用一個進程表項
(也叫進程控制塊
),該表項包含瞭程序計數器、堆棧指針、內存分配情況、所打開文件的狀態、調度信息等重要的進程狀態信息,從而保證進程掛起後,操作系統能夠正確地重新喚起該進程。
進程具有以下特征:
- 動態性:進程的實質是程序在多道程序系統中的一次執行過程,進程是動態產生,動態消亡的;
- 並發性:任何進程都可以同其他進程一起並發執行;
- 獨立性:進程是一個能獨立運行的基本單位,同時也是系統分配資源和調度的獨立單位;
- 異步性:由於進程間的相互制約,使進程具有執行的間斷性,即進程按各自獨立的、不可預知的速度向前推進。
需要註意的是,如果一個程序運行瞭兩遍,即便操作系統能夠使它們共享代碼(即隻有一份代碼副本在內存中),也不能改變正在運行的程序的兩個實例是兩個不同的進程的事實。
在進程的執行過程中,由於中斷、CPU 調度等各種原因,進程會在下面幾個狀態中切換:
- 運行態:此刻進程正在運行,並占用瞭 CPU;
- 就緒態:此刻進程已準備就緒,隨時可以運行,但因為其它進程正在運行而被暫時停止;
- 阻塞態:此刻進程處於阻塞狀態,除非某個外部事件(比如鍵盤輸入的數據已到達)發生,否則進程將不能運行。
通過上面的進程狀態切換圖可知,進程可以從運行態切換成就緒態和阻塞態,但隻有就緒態才能直接切換成運行態,這是因為:
- 從運行態切換成就緒態是由進程調度程序引起的,因為系統認為當前進程已經占用瞭過多的 CPU 時間,決定讓其它進程使用 CPU 時間;並且進程調度程序是操作系統的一部分,進程甚至感覺不到調度程序的存在;
- 從運行態切換成阻塞態是由進程自身原因(比如等待用戶的鍵盤輸入)導致進程無法繼續執行,隻能掛起等待某個事件(比如鍵盤輸入的數據已到達)發生;當相關事件發生時,進程先轉換為就緒態,如果此時沒有其它進程運行,則立刻轉換為運行態,否則進程將維持就緒態,等待進程調度程序的調度。
線程
有些時候,我們需要使用線程來解決以下問題:
- 隨著進程數量的增加,進程之間切換的成本將越來越大,CPU 的有效使用率也會越來越低,嚴重情況下可能造成系統假死等現象;
- 每個進程都有自己獨立的內存空間,且各個進程之間的內存空間是相互隔離的,而某些任務之間可能需要共享一些數據,多個進程之間的數據同步就過於繁瑣。
關於線程,我們需要知道以下幾點:
- 線程是程序執行中的一個單一順序控制流,是操作系統能夠進行運算調度的最小單位,它包含在進程之中,是進程中的實際運行單位;
- 一個進程中可以包含多個線程,每個線程並行執行不同的任務;
- 一個進程中的所有線程共享進程的內存空間(包括代碼、數據、堆等)以及一些資源信息(比如打開的文件和系統信號);
- 一個進程中的線程在其它進程中不可見。
瞭解瞭線程的基本特征,下面我們來聊一下常見的幾種線程類型。
內核態線程
內核態線程是直接由操作系統支持的線程,其主要特點如下:
- 線程的創建、調度、同步、銷毀由系統內核完成,但其開銷較為昂貴;
- 內核可將內核態線程映射到各個處理器上,能夠輕松做到一個處理器核心對應一個內核線程,從而充分地競爭與利用 CPU 資源;
- 僅能訪問內核的代碼和數據;
- 資源同步與數據共享效率低於進程的資源同步與數據共享效率。
用戶態線程
用戶態線程是完全建立在用戶空間的線程,其主要特點如下:
- 線程的創建、調度、同步、銷毀由用戶空間完成,其開銷非常低;
- 由於用戶態線程由用戶空間維護,內核根本感知不到用戶態線程的存在,因此內核僅對其所屬的進程做調度及資源分配,而進程中線程的調度及資源分配由程序自行處理,這很可能造成一個用戶態線程被阻塞在系統調用中,則整個進程都將會阻塞的風險;
- 能夠訪問所屬進程的所有共享地址空間和系統資源;
- 資源同步與數據共享效率較高。
輕量級進程(LWP)
輕量級進程(LWP)是建立在內核之上並由內核支持的用戶線程,其主要特點如下:
- 用戶空間隻能通過輕量級進程(LWP)來使用內核線程,可看作是用戶態線程與內核線程的橋接器,因此隻有先支持內核線程,才能有輕量級進程(LWP);
- 大多數輕量級進程(LWP)的操作,都需要用戶態空間發起系統調用,此系統調用的代價相對較高(需要在用戶態與內核態之間進行切換);
- 每個輕量級進程(LWP)都需要與一個特定的內核線程關聯,因此:
- 與內核線程一樣,可在全系統范圍內充分地競爭與利用 CPU 資源;
- 每個輕量級進程(LWP)都是一個獨立的線程調度單元,這樣即使有一個輕量級進程(LWP)在系統調用中被阻塞,也不影響整個進程的執行;
- 輕量級進程(LWP)需要消耗內核資源(主要指內核線程的棧空間),這樣導致系統中不可能支持大量的輕量級進程(LWP);
- 能夠訪問所屬進程的所有共享地址空間和系統資源。
小結
上文我們對常見的線程類型(內核態線程、用戶態線程、輕量級進程)進行瞭簡單介紹,它們各自有各自的適用范圍,在實際的使用中可根據自己的需要自由地對其進行組合使用,比如常見的一對一、多對一、多對多等模型,由於篇幅限制,本文對此不做過多介紹,感興趣的同學可自行研究。
協程
協程(Coroutine),也叫纖程(Fiber),是一種建立在線程之上,由開發者自行管理執行調度、狀態維護等行為的一種程序運行機制,其特點主要有:
- 因執行調度無需上下文切換,故具有良好的執行效率;
- 因運行在同一線程,故不存在線程通信中的同步問題;
- 方便切換控制流,簡化編程模型。
在 JavaScript 中,我們經常用到的 async/await
便是協程的一種實現,
比如下面的例子:
function updateUserName(id, name) { const user = getUserById(id); user.updateName(name); return true; } async function updateUserNameAsync(id, name) { const user = await getUserById(id); await user.updateName(name); return true; }
上例中,函數 updateUserName
和 updateUserNameAsync
內的邏輯執行順序是:
- 調用函數
getUserById
並將其返回值賦給變量user
; - 調用
user
的updateName
方法; - 返回
true
給調用者。
兩者的主要區別在於其實際運行過程中的狀態控制:
- 在函數
updateUserName
的執行過程中,按照前文所述的邏輯順序依次執行; - 在函數
updateUserNameAsync
的執行過程中,同樣按照前文所述的邏輯順序依次執行,隻不過在遇到await
時,updateUserNameAsync
將會被掛起並保存掛起位置當前的程序狀態,直到await
後面的程序片段返回後,才會再次喚醒updateUserNameAsync
並恢復掛起前的程序狀態,然後繼續執行下一段程序。
通過上面的分析我們可以大膽猜測:協程要解決的並非是進程、線程要解決的程序並發問題,而是要解決處理異步任務時所遇到的問題(比如文件操作、網絡請求等);在 async/await
之前,我們隻能通過回調函數來處理異步任務,這很容易使我們陷入回調地獄
,生產出一坨坨屎一般難以維護的代碼,通過協程,我們便可以實現異步代碼同步化的目的。
需要牢記的是:協程的核心能力是能夠將某段程序掛起並維護程序掛起位置的狀態,並在未來某個時刻在掛起的位置恢復,並繼續執行掛起位置後的下一段程序。
I/O 模型
一個完整的 I/O
操作需要經歷以下階段:
- 用戶進(線)程通過系統調用向內核發起
I/O
操作請求; - 內核對
I/O
操作請求進行處理(分為準備階段和實際執行階段),並將處理結果返回給用戶進(線)程。
我們可將 I/O
操作大致分為阻塞 I/O
、非阻塞 I/O
、同步 I/O
、異步 I/O
四種類型,在討論這些類型之前,我們先熟悉下以下兩組概念(此處假設服務 A 調用瞭服務 B):
阻塞/非阻塞:
- 如果 A 隻有在接收到 B 的響應之後才返回,那麼該調用為
阻塞調用
; - 如果 A 調用 B 後立即返回(即無需等待 B 執行完畢),那麼該調用為
非阻塞調用
。
同步/異步:
- 如果 B 隻有在執行完之後再通知 A,那麼服務 B 是
同步
的; - 如果 A 調用 B 後,B 立刻給 A 一個請求已接收的通知,然後在執行完之後通過
回調
的方式將執行結果通知給 A,那麼服務 B 就是異步
的。
很多人經常將阻塞/非阻塞
與同步/異步
搞混淆,故需要特別註意:
阻塞/非阻塞
針對於服務的調用者
而言;同步/異步
針對於服務的被調用者
而言。
瞭解瞭阻塞/非阻塞
與同步/異步
,我們來看具體的 I/O 模型
。
阻塞 I/O
定義:用戶進(線)程發起 I/O
系統調用後,用戶進(線)程會被立即阻塞
,直到整個 I/O
操作處理完畢並將結果返回給用戶進(線)程後,用戶進(線)程才能解除阻塞
狀態,繼續執行後續操作。
特點:
- 由於該模型會阻塞用戶進(線)程,因此該模型不占用 CPU 資源;
- 在執行
I/O
操作的時候,用戶進(線)程不能進行其它操作; - 該模型僅適用於並發量小的應用,這是因為一個
I/O
請求就能阻塞進(線)程,所以為瞭能夠及時響應I/O
請求,需要為每個請求分配一個進(線)程,這樣會造成巨大的資源占用,並且對於長連接請求來說,由於進(線)程資源長期得不到釋放,如果後續有新的請求,將會產生嚴重的性能瓶頸。
非阻塞 I/O
定義:
- 用戶進(線)程發起
I/O
系統調用後,如果該I/O
操作未準備就緒,該I/O
調用將會返回一個錯誤,用戶進(線)程也無需等待,而是通過輪詢的方式來檢測該I/O
操作是否就緒; - 操作就緒後,實際的
I/O
操作會阻塞用戶進(線)程直到執行結果返回給用戶進(線)程。
特點:
- 由於該模型需要用戶進(線)程不斷地詢問
I/O
操作就緒狀態(一般使用while
循環),因此該模型需占用 CPU,消耗 CPU 資源; - 在
I/O
操作就緒前,用戶進(線)程不會阻塞,等到I/O
操作就緒後,後續實際的I/O
操作將阻塞用戶進(線)程; - 該模型僅適用於並發量小,且不需要及時響應的應用。
同(異)步 I/O
用戶進(線)程發起 I/O
系統調用後,如果該 I/O
調用會導致用戶進(線)程阻塞,那麼該 I/O
調用便為同步 I/O
,否則為 異步 I/O
。
判斷 I/O
操作同步
或異步
的標準是用戶進(線)程與 I/O
操作的通信機制,其中:
同步
情況下用戶進(線)程與I/O
的交互是通過內核緩沖區進行同步的,即內核會將I/O
操作的執行結果同步到緩沖區,然後再將緩沖區的數據復制到用戶進(線)程,這個過程會阻塞用戶進(線)程,直到I/O
操作完成;異步
情況下用戶進(線)程與I/O
的交互是直接通過內核進行同步的,即內核會直接將I/O
操作的執行結果復制到用戶進(線)程,這個過程不會阻塞用戶進(線)程。
Node.js 的並發模型
Node.js 采用的是單線程、基於事件驅動的異步 I/O
模型,個人認為之所以選擇該模型的原因在於:
- JavaScript 在 V8 下以單線程模式運行,為其實現多線程極其困難;
- 絕大多數網絡應用都是
I/O
密集型的,在保證高並發的情況下,如何合理、高效地管理多線程資源相對於單線程資源的管理更加復雜。
總之,本著簡單、高效的目的,Node.js 采用瞭單線程、基於事件驅動的異步 I/O
模型,並通過主線程的 EventLoop 和輔助的 Worker 線程來實現其模型:
- Node.js 進程啟動後,Node.js 主線程會創建一個 EventLoop,EventLoop 的主要作用是註冊事件的回調函數並在未來的某個事件循環中執行;
- Worker 線程用來執行具體的事件任務(在主線程之外的其它線程中以同步方式執行),然後將執行結果返回到主線程的 EventLoop 中,以便 EventLoop 執行相關事件的回調函數。
需要註意的是,Node.js 並不適合執行 CPU 密集型(即需要大量計算)任務;這是因為 EventLoop 與 JavaScript 代碼(非異步事件任務代碼)運行在同一線程(即主線程),它們中任何一個如果運行時間過長,都可能導致主線程阻塞,如果應用程序中包含大量需要長時間執行的任務,將會降低服務器的吞吐量,甚至可能導致服務器無法響應。
總結
Node.js 是前端開發人員現在乃至未來不得不面對的技術,然而大多數前端開發人員對 Node.js 的認知僅停留在表面,為瞭讓大傢更好地理解 Node.js 的並發模型,本文先介紹瞭進程、線程、協程,接著介紹瞭不同的 I/O
模型,最後對 Node.js 的並發模型進行瞭簡單介紹。雖然介紹 Node.js 並發模型的篇幅不多,但筆者相信萬變不離其宗,掌握瞭相關基礎,再深入理解 Node.js 的設計與實現必將事半功倍。
到此這篇關於Node.js 與並發模型的詳細介紹的文章就介紹到這瞭,更多相關Node.js 並發模型內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- Node.js中的異步生成器與異步迭代詳解
- Javascript中異步等待的深入理解
- JavaScript中的異步能省掉await嗎?
- Kotlin協程的啟動方式介紹
- node.js express和koa中間件機制和錯誤處理機制