聊聊Node.js中的程序、執行緒、協程與並行模型

2022-07-19 22:00:22

現在已成為構建高並行網路應用服務工具箱中的一員,何以 Node.js 會成為大眾的寵兒?本文將從程序、執行緒、協程、I/O 模型這些基本概念說起,為大家全面介紹關於 Node.js 與並行模型的這些事。

程序

我們一般將某個程式正在執行的範例稱之為程序,它是作業系統進行資源分配和排程的一個基本單元,一般包含以下幾個部分:

  • 程式:即要執行的程式碼,用於描述程序要完成的功能;
  • 資料區域:程序處理的資料空間,包括資料、動態分配的記憶體、處理常式的使用者棧、可修改的程式等資訊;
  • 程序表項:為了實現程序模型,作業系統維護著一張稱為程序表的表格,每個程序佔用一個程序表項(也叫過程控制塊),該表項包含了程式計數器、堆疊指標、記憶體分配情況、所開啟檔案的狀態、排程資訊等重要的程序狀態資訊,從而保證程序掛起後,作業系統能夠正確地重新喚起該程序。

程序具有以下特徵:

  • 動態性:程序的實質是程式在多道程式系統中的一次執行過程,程序是動態產生,動態消亡的;
  • 並行性:任何程序都可以同其他程序一起並行執行;
  • 獨立性:程序是一個能獨立執行的基本單位,同時也是系統分配資源和排程的獨立單位;
  • 非同步性:由於程序間的相互制約,使程序具有執行的間斷性,即程序按各自獨立的、不可預知的速度向前推進。

需要注意的是,如果一個程式執行了兩遍,即便作業系統能夠使它們共用程式碼(即只有一份程式碼副本在記憶體中),也不能改變正在執行的程式的兩個範例是兩個不同的程序的事實。

在程序的執行過程中,由於中斷、CPU 排程等各種原因,程序會在下面幾個狀態中切換:

1.png

  • 執行態:此刻程序正在執行,並佔用了 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;
}

上例中,函數 updateUserNameupdateUserNameAsync 內的邏輯執行順序是:

  • 呼叫函數 getUserById 並將其返回值賦給變數 user
  • 呼叫 userupdateName 方法;
  • 返回 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相關知識,請存取:!

以上就是聊聊Node.js中的程序、執行緒、協程與並行模型的詳細內容,更多請關注TW511.COM其它相關文章!