《大前端進階 Node.js》系列 必知必會必問(面試高頻)

接水怪發表於2020-03-16

前言

codeing 應當是一生的事業,而不僅僅是 30 歲的青春飯
本文已收錄 Github https://github.com/ponkans/F2E,歡迎 Star,持續更新

這篇是 Node.js 核心基礎,接水怪覺得一定要掌握哦~


每篇文章都希望你能收穫到東西,這篇是圍繞 Node.js 的核心架構與基礎進行分析,希望你看完,能夠有這些收穫:

  • Node.js 架構中各個層的含義及關係
  • Node.js 如何與底層作業系統互動呢,比如讀取一個檔案的時候,都發生了些啥
  • Node.js 是如何處理高併發請求的,如何使得伺服器效能被很好的利用的
  • 事件驅動的優勢,以及實現方式

很多前端初學者,特別是在校大學生,遇到的第一個技術瓶頸就是今天要講的 Node.js,其實主要還是一些重要的概念沒有理解,一些基礎的知識沒有掌握,比如編譯原理。

PS:前端小夥伴也要重視計算機基礎哦,等你工作越久,體會應該就會越深啦~ 很多看似很複雜的東西,其實追溯到底層,也就是計算機的那點事兒。

比如,現在很流行的跨端框架,其實核心就是 AST 抽象語法樹的一個轉換~

好了步入正題,下面有請怪怪來跟大家擺擺那些關於 Node.js 的龍門陣~

架構

相信只要你是一名前端,或多或少都能說出一些你對 Node.js 的理解與看法。

我們先來看看瀏覽器與 Node 的一個對比,畢竟很多前端初學者可能還沒有接觸過 Node,只是在瀏覽器裡面跑專案。

左圖是瀏覽器的一個簡單架構,我們平時寫的前端專案無非就是 3 個部分嘛。

HTML 跟 CSS 交給 WebKit 引擎去處理,經過一系列的轉換處理,最終呈現到我們的螢幕上,之前有看過 Chrome 團隊 Steve Kobes 的一個分享,從最底層出發分析了瀏覽器的一個渲染過程,後面找時間再跟大家分享。

JavaScript 交給 V8 引擎去處理,解析,關於引擎本文暫時不多講。

再往下看到中間層,Chrome 中的中間層能力是有限的,因為被限制在了瀏覽器中,比如我們想在瀏覽器中操作一些本地的檔案, 早些時候是很難的一件事情,不過隨著 HTML5 的普及,已經可以實現部分功能了,但是跟 Node 中間層的能力比起來,還差很多。

我們把左圖中的紅色部分去掉,其實也就是一個簡單的 Node 架構了,在 Node 中,我們可以隨意的操控檔案,甚至搭建各種服務,雖然 Node 不處理 UI 層,但是卻與瀏覽器以相同的機制和原理執行,並且在中間層這裡有著自己更加強大的功能。

順著這個思路,我們再想想,如果我們把 WebKit 引擎也進行抽離,然後再加上 Node,是不是就可以脫離瀏覽器開發帶有 UI 處理的 Node 專案了?想必你已經知道怪怪要說啥了,Electron 其實就是這樣做的,也不是啥特別神奇的東西~

所以,簡單直觀的來講 Node 就是脫離了瀏覽器的,但仍然基於 Chrome V8 引擎的一個 JavaScript 的執行環境。

從官網的介紹中也可以看到,其 輕量、高效、事件驅動、非阻塞 I/O 是 Node 幾個很重要的特性,接下來,我們將從 Node 的執行機制作為切入點,一步步帶大家剖析 Node 單執行緒如何實現高併發,又是如何充分利用伺服器資源的。

上面的 Node 架構圖比較簡易,下面看看比較完整的。

基礎架構可以大致分為下面三層~

上層

這一層是 Node 標準庫,其實簡單理解就是 JavaScript 程式碼,可以在編寫程式碼時直接呼叫相關 API,Node 提供了很多很強大的 API 供我們實現,具體可多在實踐中去使用深入,舉個很簡單的例子,我們可以用 Node 寫一個定時指令碼,定時給女朋友郵件推送你想對她說的話,女朋友開心了,你也學到技術了,完美~

中層

Node bindings(由 c++ 實現),這一層說白了就是個媒人,牽線搭橋,讓 JavaScript 小哥哥能夠與下層的一堆小姐姐進行交往,Node 之所有這麼強,這一層起了十分關鍵的作用。

下層

這一層,是 Node.js 執行時的關鍵,這就有點東西了!我們挨個來說說~

V8,可以簡單粗暴歸納為,目前業界最牛?的 JavaScrpt 引擎。雖然有人嘗試使用 V8 的替代品,比如 node-chakracore 專案 以及 spidernode 專案,但 Node.js 依然預設使用 V8 引擎。

C-ares,一個由 C 語言實現的非同步 DNS 請求庫;

http_parser、OpenSSL、zlib 等,提供一些其他的基礎能力。

libuv 是一個高效能的,事件驅動的 I/O 庫,並且提供了跨平臺(如 Windows、Linux)的API。它強制使用非同步的,事件驅動的程式設計風格,核心工作就是提供一個 event loop,還有基於 I/O 和其它事件通知的回撥函式。並且還提供了一些核心工具,例如定時器,非阻塞的網路支援,非同步檔案系統訪問,子程式等。

有層次

有層次

Node 寫一封情書的底層運作

這裡參照《深入淺出 Node.js》書中的示例來進行說明

假設我們需要開啟一個本地 txt 的檔案來給女朋友寫封情書,那程式碼可以寫成這樣:

let fs = require('fs');
fs.open('./情書.txt'"w"function(err, fd{
    // Vows of eternal love, never separated(海誓山盟,永不分離)
});
複製程式碼

fs.open() 的作用是根據指定路徑和引數去開啟一個檔案,返回一個檔案描述符

我們進去 lib/fs.js ,看看底層原始碼:

async function open(path, flags, mode{
  mode = modeNum(mode, 0o666);
  path = getPathFromURL(path);
  validatePath(path);
  validateUint32(mode, 'mode');
  return new FileHandle(
    await binding.openFileHandle(pathModule.toNamespacedPath(path),
             stringToFlags(flags),
             mode, kUsePromises));
}
複製程式碼

JavaScript 程式碼通過呼叫 C++ 核心模組進行下層操作,其呼叫過程可表示為

從 JavaScript 呼叫 Node.js 標準庫,再由標準庫呼叫 C++ 模組,C++ 模組再通過 libuv 進行系統呼叫,這一流程即為 Node 中最為常見的呼叫方式。同時 libuv 還提供了 *UNIX 和 Windows 兩個平臺的實現,賦予了 Node.js 跨平臺的能力。

就這樣子,情書搞定,你們感情更上一層樓,從此過上了幸福滴生活~

終究是單執行緒,我需要一個解釋

怪怪我正在寫基於 Node.js 的高併發口罩秒殺系統結構分析的文章哦~

解決了第一個問題,我們來看看第二個,Node 既然是單執行緒,那麼是如何應對高併發場景的呢?

其實,Node 除了 JavaScript 的部分是單執行緒外,很多地方都是多執行緒的。

從上面寫情書的例子就可以看到,Node 的 I/O 操作實際上是交給 libuv 來做的,而 libuv 提供了完整的執行緒池實現。所以,除了使用者的 JavaScript 程式碼無法並行執行以外,所有的 I/O 操作都是可以並行的。

對什麼執行緒池之類不熟悉的小夥伴,老實跟怪怪說,大學是不是就忙著給女朋友寫情書去啦!!!

實際上,作業系統中對於 I/O 只有兩種處理方式,即阻塞和非阻塞。

阻塞 I/O 即為呼叫之後需要等待完成所有操作後,呼叫才結束,這就造成了 CPU 一直在等待 I/O 結束,處理能力得不到充分利用。

舉個例子,你現在變成了一塊 CPU,現在你要做兩件事,第一件事就是給正在外面逛街的女朋友發訊息詢問是否回來吃飯(因為你要做飯,哈哈哈),第二件事就是打掃房間。

同步 I/O 的做法:給女朋友發訊息,然後一直線上等,直到一個小時之後,女朋友終於回訊息了。然後,你再去打掃房間,女朋友回到家,看到你為啥這麼久才開始打掃房間,然後 everybody 在你頭上暴扣~~

非同步 I/O 的做法:給女朋友發完訊息,然後直接開始打掃房間,等女朋友回訊息之後,房間已經打掃完畢,並且飯也做好了,豈不是美滋滋?~

回到作業系統來講就是,作業系統提供了非阻塞 I/O 的方法,在呼叫之後會立即返回,之後 CPU 可以去處理其他事務。但由於 I/O 並沒有完成,立即返回的僅僅是呼叫的狀態,為了獲取最終結果,應用程式需要充分呼叫判斷操作是否完成,即輪詢。

目前常見的輪詢技術主要有這麼幾種:

  1. read

    這是最原始的一種,通過重複呼叫來讀取最終結果,在得到結果之前,CPU 會一直消耗在等待上。

  2. select

    read 基礎上做了改進,通過對檔案描述符上的事件狀態來進行判斷,當使用者程式呼叫了select,那麼整個程式會被block,而同時,kernel會「監視」所有select負責的socket,當任何一個socket中的資料準備好了,select就會返回,這個時候使用者程式再呼叫read操作,將資料從kernel拷貝到使用者程式。select 和之後的 pollepoll 也稱為 I/O 多路複用。select 採用一個長度為 1024 的陣列來儲存狀態,所以最多可以同時檢查 1024 個檔案描述符。

  3. poll

    poll 採用連結串列方式避免陣列長度的限制,效能有所改善。

  4. epoll

    這是 Linux 下效率最高的 I/O 事件通知機制,在進入輪詢時如果沒有檢查到 I/O 事件,將會進行休眠,直到事件發生將它喚醒,不會浪費 CPU。

  5. kqueue

    實現方式與 epoll 類似,僅在 BSD 系統下存在。

上面這些個輪詢的名詞,其實就是不同的輪詢機制而已,不要被嚇到了~~

輪詢技術雖然能夠實現非阻塞,但實際上還是一種同步呼叫,Linux 下存在一種方式提供原生的非同步 I/O,但應用範圍較小,所以,Node 選擇了另一種方式來實現完整的非同步 I/O。

因此,所謂的 Node 單執行緒其實只是一個 JavaScript 主執行緒,那些耗時的非同步操作還是執行緒池完成的,Node 將這些耗時的操作都扔到執行緒池去處理了,而 Node 自己只需要往返排程,並沒有真正的 I/O 操作。

單執行緒與 CPU 密集

單執行緒帶來了不需要在意狀態同步問題的好處,同時也帶了幾個弱點

  • 無法利用多核 CPU
  • 出現錯誤會導致整個應用退出
  • CPU 密集型任務會導致非同步I/O失效

Node.js 中用來解決單執行緒中 CPU 密集任務的方法很粗暴,那就是直接開子程式,通過 child_process 將計算任務分發給子程式,再通過程式之間的事件訊息來傳遞結果,也就是程式間通訊。(Node 中是採用管道的方式進行通訊的哦~)

啥,作業系統又不熟悉,小夥伴看來真的需要去補補基礎了哦~ 國慶節就不要出去玩啦,哈哈哈~~

事件驅動

事件驅動的實質就是通過主迴圈加事件觸發的方式來執行程式。

事件迴圈的職責,就是不斷得等待事件的發生,然後將這個事件的所有處理器,以它們訂閱這個事件的時間順序,依次執行。當這個事件的所有處理器都被執行完畢之後,事件迴圈就會開始繼續等待下一個事件的觸發,不斷往復

事件迴圈

Node 的事件迴圈採用了 libuv 的預設事件迴圈,相應程式碼可在 src/node.cc 中看到。

建立 Node 執行環境

Environment* env = CreateEnvironment(
        node_isolate,
        uv_default_loop(),
        context,
        argc,
        argv,
        exec_argc,
        exec_argv);
複製程式碼

啟動事件迴圈

bool more;
do {
  more = uv_run(env->event_loop(), UV_RUN_ONCE);
  if (more == false) {
    EmitBeforeExit(env);
    // Emit `beforeExit` if the loop became alive either after emitting
    // event, or after running some callbacks.
    more = uv_loop_alive(env->event_loop());
    if (uv_run(env->event_loop(), UV_RUN_NOWAIT) != 0)
      more = true;
  }
while (more == true);
code = EmitExit(env);
RunAtExit(env);
複製程式碼

more 用來標識是否進行下一輪迴圈。接下來 Node.js 會根據 more 的情況決定下一步操作

  • 如果moretrue,則繼續執行下一輪loop
  • 如果morefalse,說明已經沒有等待處理的事件了,EmitBeforeExit(env); 觸發程式的 beforeExit 事件,檢查並處理相應的處理函式,完成後直接跳出迴圈。

最後觸發 exit 事件,執行相應的回撥函式,Node.js 執行結束,後面會進行一些資源釋放操作。

觀察者

每個事件迴圈中都會有觀察者,判斷是否有要處理的事件就是向這些觀察者詢問。在 Node.js 中,事件來源主要有網路請求,檔案 I/O 等,這些事件都會對應不同的觀察者。

請求物件

想啥呢? 不是那個物件,是這個物件!!

請求物件是 Node 發起呼叫到核心執行完成 I/O 操作的過渡過程中,產生的一種中間產物。例如,libuv 呼叫檔案 I/O 時,就會立即返回 FSReqWrap 請求物件,JavaScript 傳入的引數和當前的方法都被封裝在這個請求物件中,同時這個物件也會被推送給核心等待執行。

事件驅動的優勢

事件迴圈、觀察者、請求物件、I/O 執行緒池共同構成了 Node 的事件驅動非同步 I/O 模型

Apache 採用每個請求啟動一個執行緒的方式來處理請求,雖然執行緒比較輕量,但仍需要佔用一定記憶體,當大併發請求來臨時,記憶體佔用會非常高,導致伺服器緩慢。

Node.js 採用事件驅動的方式處理請求,無須為每個請求建立執行緒,可以省去很多執行緒建立、銷燬和系統上下文切換的開銷,即使在大併發條件下,也能提供良好的效能。Nginx 也和 Node 採用了相同的事件驅動模型,藉助優異的效能,Nginx 也在逐漸取代 Apache 成為 Web 伺服器的主流。

總結

本文已收錄 Github https://github.com/ponkans/F2E,歡迎 Star,持續更新?

怪怪上面講的很多地方也不是很深入,但大致框架跟結構大同小異,想深入瞭解的小夥伴,可以參考《深入淺出Node.js》。

狼叔在 cnode 社群的文章中提到,先把《了不起的Node.js》看 5 遍,也不是不行。但我覺得這個主要還是看個人的學習方式,有的人可能更願意通過實踐去學習,那其實也沒必要先看 5 遍了~~~

Node 生態日益增強,前端開發小夥伴每天都會打交道的,一些文章中講到的最基礎的架構,概念應該是你必須掌握的。

近期會針對 Node.js 寫一個系列,同系列傳送門:


喜歡的小夥伴加個關注,點個贊哦,感恩??

聯絡我 / 公眾號

微信搜尋【接水怪】或掃描下面二維碼回覆”加群“,我會拉你進技術交流群。講真的,在這個群,哪怕您不說話,光看聊天記錄也是一種成長。(阿里技術專家、敖丙作者、Java3y、蘑菇街資深前端、螞蟻金服安全專家、各路大牛都在)。

接水怪也會定期原創,定期跟小夥伴進行經驗交流或幫忙看簡歷。加關注,不迷路,有機會一起跑個步? ↓↓↓

相關文章