
Node.js 現在已成為建立高並發網頁應用程式服務工具箱中的一員,何以Node.js 會成為大眾的寵兒?本文將從進程、執行緒、協程、I/O 模型這些基本概念說起,為大家全面介紹關於Node.js 與並發模型的這些事。
我們一般將某個程式正在運行的實例稱之為進程,它是作業系統進行資源分配和調度的一個基本單元,一般包含以下幾個部分:
进程表的表格,每個進程佔用一個进程表项(也叫进程控制块),該表項包含了程式計數器、堆疊指標、記憶體分配情況、所開啟檔案的狀態、調度資訊等重要的進程狀態訊息,從而保證進程掛起後,作業系統能夠正確地重新喚起該進程。進程具有以下特徵:
需要注意的是,如果一個程式運行了兩遍,即便作業系統能夠使它們共享程式碼(即只有一份程式碼副本在記憶體中),也不能改變正在運行的程式的兩個實例是兩個不同的進程的事實。
在行程的執行過程中,由於中斷、CPU 調度等各種原因,進程會在下面幾個狀態切換:

透過上面的進程狀態切換圖可知,進程可以從運行態切換成就緒態和阻塞態,但只有就緒態才能直接切換成運行態,這是因為:
執行緒有些時候,我們需要使用執行緒來解決以下問題:
隨著進程數量的增加,進程之間切換的成本將越來越大,CPU 的關於線程,我們需要知道以下幾點:
了解了線程的基本特徵,下面我們來聊聊常見的幾種線程類型。
內核態線程是直接由作業系統支援的線程,其主要特點如下:
用戶態線程是完全建立在用戶空間的線程,其主要特徵如下:
輕量級進程(LWP)是建立在核心之上並由核心支援的用戶線程,其主要特點如下:
用戶空間只能透過輕量級進程(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的執行過程中,同樣按照前文所述的邏輯順序依次執行;在updateUserNameAsync的執行過程中,同樣按照前文所述的邏輯順序依次執行;在函數updateUserNameAsync的執行過程中,同樣按照前文所述的邏輯順序依序執行,只不過在遇到await時, updateUserNameAsync將會被掛起並保存掛起位置當前的程式狀態,直到await後面的程式片段返回後,才會再次喚醒updateUserNameAsync並恢復掛起前的程式狀態,然後繼續下一段程序。透過上面的分析我們可以大膽猜測:協程要解決的並非是進程、執行緒要解決的程式並發問題,而是要解決處理非同步任務時所遇到的問題(例如檔案操作、網路請求等);在在async/await之前,我們只能透過回呼函數來處理非同步任務,這很容易使我們陷入回调地狱,生產出一坨屎一般難以維護的程式碼,透過協程,我們便可以實現非同步程式碼同步化的目的。
需要牢記的是:協程的核心能力是能夠將某段程序掛起並維護程序掛起位置的狀態,並在未來某個時刻在掛起的位置恢復,並繼續執行掛起位置後的下一段程式.
一個完整的I/O操作需要經歷以下階段:
I/O操作請求;I/O操作請求進行處理(分為準備階段和實際執行階段),並將處理結果傳回使用者進(線)程。我們可將I/O操作大致分為阻塞I/O 、非阻塞I/O 、同步I/O 、异步I/O四種類型,在討論這些類型之前,我們先熟悉下以下兩組概念(此處假設服務A 呼叫了服務B):
阻塞/非阻塞:
阻塞调用呼叫;非阻塞调用。同步/异步:
同步的;回调的方式將執行結果通知給A,那麼服務B 就是异步的。許多人常將阻塞/非阻塞與同步/异步搞搞混淆,故需要特別注意:
阻塞/非阻塞调用者同步/异步針對於服務的被调用者而言。了解了阻塞/非阻塞與同步/异步,我們來看特定的I/O 模型。
定義:用戶進(線)程發起I/O系統呼叫後,用戶進(線)程會被立即阻塞,直到整個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操作的執行結果複製到用戶進(線)程,這個過程不會阻塞用戶進(線)程。Node.js 採用的是單執行緒、基於事件驅動的非同步I/O模型,個人認為之所以選擇該模型的原因在於:
I/O密集的,在保證高並發的情況下,如何合理、有效率地管理多執行緒資源相對於單執行緒資源的管理更加複雜。總之,本著簡單、高效的目的,Node.js 採用了單執行緒、基於事件驅動的非同步I/O模型,並透過主執行緒的EventLoop 和輔助的Worker 執行緒來實現其模型:
需要注意的是,Node.js 並不適合執行CPU 密集型(即需要大量計算)任務;這是因為EventLoop 與JavaScript 程式碼(非非同步事件任務程式碼)運行在同一執行緒(即主執行緒),它們中任何一個如果運行時間過長,都可能導致主執行緒阻塞,如果應用程式中包含大量需要長時間執行的任務,將會降低伺服器的吞吐量,甚至可能導致伺服器無法回應。
Node.js 是前端開發人員現在甚至未來不得不面對的技術,然而大多數前端開發人員對Node.js 的認知僅停留在表面,為了讓大家更好地理解Node.js 的並發模型,本文先介紹了進程、執行緒、協程,接著介紹了不同的I/O模型,最後對Node.js 的並發模型進行了簡單介紹。雖然介紹Node.js 並發模型的篇幅不多,但筆者相信萬變不離其宗,掌握了相關基礎,再深入理解Node.js 的設計與實現必將事半功倍。
最後,本文若有紕漏之處,也望大家能指正,祝大家快樂編碼每一天。