爲何要使用 Node.js

這是一個移動端工程師涉足前端和後端開發的學習筆記,若有錯誤或理解不到位的地方,萬望指正。php

Node.js 是什麼

傳統意義上的 JavaScript 運行在瀏覽器上,這是由於瀏覽器內核實際上分爲兩個部分:渲染引擎和 JavaScript 引擎。前者負責渲染 HTML + CSS,後者則負責運行 JavaScript。Chrome 使用的 JavaScript 引擎是 V8,它的速度很是快。html

Node.js 是一個運行在服務端的框架,它的底層就使用了 V8 引擎。咱們知道 Apache + PHP 以及 Java 的 Servlet 均可以用來開發動態網頁,Node.js 的做用與他們相似,只不過是使用 JavaScript 來開發。前端

從定義上介紹完後,舉一個簡單的例子,新建一個 app.js 文件並輸入如下內容:node

var http = require('http');
http.createServer(function (request, response) {
    response.writeHead(200, {'Content-Type': 'text/plain'}); // HTTP Response 頭部
    response.end('Hello World\n'); // 返回數據 「Hello World」
}).listen(8888); // 監聽 8888 端口
// 終端打印以下信息
console.log('Server running at http://127.0.0.1:8888/');複製代碼

這樣,一個簡單的 HTTP Server 就算是寫完了,輸入 node app.js 便可運行,隨後訪問 便會看到輸出結果。 git

爲何要用 Node.js

面對一個新技術,多問幾個爲何老是好的。既然 PHP、Python、Java 均可以用來進行後端開發,爲何還要去學習 Node.js?至少咱們應該知道在什麼場景下,選擇 Node.js 更合適。程序員

總的來講,Node.js 適合如下場景:github

  1. 實時性應用,好比在線多人協做工具,網頁聊天應用等。
  2. 以 I/O 爲主的高併發應用,好比爲客戶端提供 API,讀取數據庫。
  3. 流式應用,好比客戶端常常上傳文件。
  4. 先後端分離。

實際上前二者能夠歸結爲一種,即客戶端普遍使用長鏈接,雖然併發數較高,但其中大部分是空閒鏈接。web

Node.js 也有它的侷限性,它並不適合 CPU 密集型的任務,好比人工智能方面的計算,視頻、圖片的處理等。數據庫

固然,以上缺點不是信口開河,或者死記硬背,更不是人云亦云,須要咱們對 Node.js 的原理有必定的瞭解,才能作出正確的判斷。編程

基礎概念

在介紹 Node.js 以前,理清楚一些基本概念有助於更深刻的理解 Node.js 。

併發

與客戶端不一樣,服務端開發者很是關心的一項數據是併發數,也就是這臺服務器最多能支持多少個客戶端的併發請求。早年的 C10K 問題就是討論如何利用單臺服務器支持 10K 併發數。固然隨着軟硬件性能的提升,目前 C10K 已經再也不是問題,咱們開始嘗試解決 C10M 問題,即單臺服務器如何處理百萬級的併發。

在 C10K 提出時,咱們還在使用 Apache 服務器,它的工做原理是每當有一個網絡請求到達,就 fork 出一個子進程並在子進程中運行 PHP 腳本。執行完腳本後再把結果發回客戶端。

這樣能夠確保不一樣進程之間互不干擾,即便一個進程出問題也不影響整個服務器,可是缺點也很明顯:進程是一個比較重的概念,擁有本身的堆和棧,佔用內存較多,一臺服務器能運行的進程數量有上限,大約也就在幾千左右。

雖然 Apache 後來使用了 FastCGI,但本質上只是一個進程池,它減小了建立進程的開銷,但沒法有效提升併發數。

Java 的 Servlet 使用了線程池,即每一個 Servlet 運行在一個線程上。線程雖然比進程輕量,但也是相對的。有人測試過,每一個線程獨享的棧的大小是 1M,依然不夠高效。除此之外,多線程編程會帶來各類麻煩,這一點想必程序員們都深有體會。

若是不使用線程,還有兩種解決方案,分別是使用協程(coroutine)和非阻塞 I/O。協程比線程更加輕量,多個協程能夠運行在同一個線程中,並由程序員本身負責調度,這種技術在 Go 語言中被普遍使用。而非阻塞 I/O 則被 Node.js 用來處理高併發的場景。

非阻塞 I/O

這裏所說的 I/O 能夠分爲兩種: 網絡 I/O 和文件 I/O,實際上二者高度相似。 I/O 能夠分爲兩個步驟,首先把文件(網絡)中的內容拷貝到緩衝區,這個緩衝區位於操做系統獨佔的內存區域中。隨後再把緩衝區中的內容拷貝到用戶程序的內存區域中。

對於阻塞 I/O 來講,從發起讀請求,到緩衝區就緒,再到用戶進程獲取數據,這兩個步驟都是阻塞的。

非阻塞 I/O 其實是向內核輪詢,緩衝區是否就緒,若是沒有則繼續執行其餘操做。當緩衝區就緒時,講緩衝區內容拷貝到用戶進程,這一步實際上仍是阻塞的。

I/O 多路複用技術是指利用單個線程處理多個網絡 I/O,咱們常說的 selectepoll 就是用來輪詢全部 socket 的函數。好比 Apache 採用了前者,而 Nginx 和 Node.js 使用了後者,區別在於後者效率更高。因爲 I/O 多路複用實際上仍是單線程的輪詢,所以它也是一種非阻塞 I/O 的方案。

異步 I/O 是最理想的 I/O 模型,然而惋惜的是真正的異步 I/O 並不存在。 Linux 上的 AIO 經過信號和回調來傳遞數據,可是存在缺陷。現有的 libeio 以及 Windows 上的 IOCP,本質上都是利用線程池與阻塞 I/O 來模擬異步 I/O。

Node.js 線程模型

不少文章都提到 Node.js 是單線程的,然而這樣的說法並不嚴謹,甚至能夠說很不負責,由於咱們至少會想到如下幾個問題:

  1. Node.js 在一個線程中如何處理併發請求?
  2. Node.js 在一個線程中如何進行文件的異步 I/O?
  3. Node.js 如何重複利用服務器上的多個 CPU 的處理能力?

網絡 I/O

Node.js 確實能夠在單線程中處理大量的併發請求,但這須要必定的編程技巧。咱們回顧一下文章開頭的代碼,執行了 app.js 文件後控制檯馬上就會有輸出,而在咱們訪問網頁時纔會看到 「Hello,World」。

這是由於 Node.js 是事件驅動的,也就是說只有網絡請求這一事件發生時,它的回調函數纔會執行。當有多個請求到來時,他們會排成一個隊列,依次等待執行。

這看上去理所固然,然而若是沒有深入認識到 Node.js 運行在單線程上,並且回調函數是同步執行,同時還按照傳統的模式來開發程序,就會致使嚴重的問題。舉個簡單的例子,這裏的 「Hello World」 字符串多是其餘某個模塊的運行結果。假設 「Hello World」 的生成很是耗時,就會阻塞當前網絡請求的回調,致使下一次網絡請求也沒法被響應。

解決方法很簡單,採用異步回調機制便可。咱們能夠把用來產生輸出結果的 response 參數傳遞給其餘模塊,並用異步的方式生成輸出結果,最後在回調函數中執行真正的輸出。這樣的好處是,http.createServer 的回調函數不會阻塞,所以不會出現請求無響應的狀況。

舉個例子,咱們改造一下 server 的入口,實際上若是要本身完成路由,大約也是這個思路:

var http = require('http');
var output = require('./string') // 一個第三方模塊
http.createServer(function (request, response) {
    output.output(response); // 調用第三方模塊進行輸出
}).listen(8888);複製代碼

第三方模塊:

function sleep(milliSeconds) {  // 模擬卡頓
    var startTime = new Date().getTime();
    while (new Date().getTime() < startTime + milliSeconds);
}

function outputString(response) {
    sleep(10000);  // 阻塞 10s 
    response.end('Hello World\n'); // 先執行耗時操做,再輸出
}

exports.output = outputString;複製代碼

總之,在利用 Node.js 編程時,任何耗時操做必定要使用異步來完成,避免阻塞當前函數。由於你在爲客戶端提供服務,而全部代碼老是單線程、順序執行。

若是初學者看到這裏仍是沒法理解,建議閱讀 「Nodejs 入門」 這本書,或者閱讀下文關於事件循環的章節。

文件 I/O

我在以前的文章中也強調過,異步是爲了優化體驗,避免卡頓。而真正節省處理時間,利用 CPU 多核性能,仍是要靠多線程並行處理。

實際上 Node.js 在底層維護了一個線程池。以前在基礎概念部分也提到過,不存在真正的異步文件 I/O,一般是經過線程池來模擬。線程池中默認有四個線程,用來進行文件 I/O。

須要注意的是,咱們沒法直接操做底層的線程池,實際上也不須要關心它們的存在。線程池的做用僅僅是完成 I/O 操做,而非用來執行 CPU 密集型的操做,好比圖像、視頻處理,大規模計算等。

若是有少許 CPU 密集型的任務須要處理,咱們能夠啓動多個 Node.js 進程並利用 IPC 機制進行進程間通信,或者調用外部的 C++/Java 程序。若是有大量 CPU 密集型任務,那隻能說明選擇 Node.js 是一個錯誤的決定。

榨乾 CPU

到目前爲止,咱們知道了 Node.js 採用 I/O 多路複用技術,利用單線程處理網絡 I/O,利用線程池和少許線程模擬異步文件 I/O。那在一個 32 核 CPU 上,Node.js 的單線程是否顯得雞肋呢?

答案是否認的,咱們能夠啓動多個 Node.js 進程。不一樣於上一節的是,進程之間不須要通信,它們各自監聽一個端口,同時在最外層利用 Nginx 作負載均衡。

Nginx 負載均衡很是容易實現,只要編輯配置文件便可:

http{
    upstream sampleapp {
        // 可選配置項,如 least_conn,ip_hash
        server 127.0.0.1:3000;
        server 127.0.0.1:3001;
        // ... 監聽更多端口
    }
    ....
    server{
       listen 80;
       ...
       location / {
          proxy_pass http://sampleapp; // 監聽 80 端口,而後轉發
       } 
    }複製代碼

默認的負載均衡規則是把網絡請求依次分配到不一樣的端口,咱們能夠用 least_conn 標誌把網絡請求轉發到鏈接數最少的 Node.js 進程,也能夠用 ip_hash 保證同一個 ip 的請求必定由同一個 Node.js 進程處理。

多個 Node.js 進程能夠充分發揮多核 CPU 的處理能力,也具備很強大的拓展能力。

事件循環

在 Node.js 中存在一個事件循環(Event Loop),有過 iOS 開發經驗的同窗可能會以爲眼熟。沒錯,它和 Runloop 在必定程度上是相似的。

一次完整的 Event Loop 也能夠分爲多個階段(phase),依次是 poll、check、close callbacks、timers、I/O callbacks 、Idle。

因爲 Node.js 是事件驅動的,每一個事件的回調函數會被註冊到 Event Loop 的不一樣階段。好比 fs.readFile 的回調函數被添加到 I/O callbacks,setImmediate 的回調被添加到下一次 Loop 的 poll 階段結束後,process.nextTick() 的回調被添加到當前 phase 結束後,下一個 phase 開始前。

不一樣異步方法的回調會在不一樣的 phase 被執行,掌握這一點很重要,不然就會由於調用順序問題產生邏輯錯誤。

Event Loop 不斷的循環,每個階段內都會同步執行全部在該階段註冊的回調函數。這也正是爲何我在網絡 I/O 部分提到,不要在回調函數中調用阻塞方法,老是用異步的思想來進行耗時操做。一個耗時過久的回調函數可能會讓 Event Loop 卡在某個階段好久,新來的網絡請求就沒法被及時響應。

因爲本文的目的是對 Node.js 有一個初步的,全面的認識。就不詳細介紹 Event Loop 的每一個階段了,具體細節能夠查看官方文檔

能夠看出 Event Loop 仍是比較偏底層的,爲了方便的使用事件驅動的思想,Node.js 封裝了 EventEmitter 這個類:

var EventEmitter = require('events');
var util = require('util');

function MyThing() {
    EventEmitter.call(this);

    setImmediate(function (self) {
        self.emit('thing1');
    }, this);
    process.nextTick(function (self) {
        self.emit('thing2');
    }, this);
}
util.inherits(MyThing, EventEmitter);

var mt = new MyThing();

mt.on('thing1', function onThing1() {
    console.log("Thing1 emitted");
});

mt.on('thing2', function onThing1() {
    console.log("Thing2 emitted");
});複製代碼

根據輸出結果可知,self.emit(thing2) 雖而後定義,但先被執行,這也徹底符合 Event Loop 的調用規則。

Node.js 中不少模塊都繼承自 EventEmitter,好比下一節中提到的 fs.readStream,它用來建立一個可讀文件流, 打開文件、讀取數據、讀取完成時都會拋出相應的事件。

數據流

使用數據流的好處很明顯,生活中也有真實寫照。舉個例子,老師佈置了暑假做業,若是學生天天都作一點(做業流),就能夠比較輕鬆的完成任務。若是積壓在一塊兒,到了最後一天,面對堆成小山的做業本,就會感到力不從心。

Server 開發也是這樣,假設用戶上傳 1G 文件,或者讀取本地 1G 的文件。若是沒有數據流的概念,咱們須要開闢 1G 大小的緩衝區,而後在緩衝區滿後一次性集中處理。

若是是採用數據流的方式,咱們能夠定義很小的一塊緩衝區,好比大小是 1Mb。當緩衝區滿後就執行回調函數,對這一小塊數據進行處理,從而避免出現積壓。

實際上 requestfs 模塊的文件讀取都是一個可讀數據流:

var fs = require('fs');
var readableStream = fs.createReadStream('file.txt');
var data = '';

readableStream.setEncoding('utf8');
// 每次緩衝區滿,處理一小塊數據 chunk
readableStream.on('data', function(chunk) {
    data+=chunk;
});
// 文件流所有讀取完成
readableStream.on('end', function() {
    console.log(data);
});複製代碼

利用管道技術,能夠把一個流中的內容寫入到另外一個流中:

var fs = require('fs');
var readableStream = fs.createReadStream('file1.txt');
var writableStream = fs.createWriteStream('file2.txt');

readableStream.pipe(writableStream);複製代碼

不一樣的流還能夠串聯(Chain)起來,好比讀取一個壓縮文件,一邊讀取一邊解壓,並把解壓內容寫入到文件中:

var fs = require('fs');
var zlib = require('zlib');

fs.createReadStream('input.txt.gz')
  .pipe(zlib.createGunzip())
  .pipe(fs.createWriteStream('output.txt'));複製代碼

Node.js 提供了很是簡潔的數據流操做,以上就是簡單的使用介紹。

總結

對於高併發的長鏈接,事件驅動模型比線程輕量得多,多個 Node.js 進程配合負載均衡能夠方便的進行拓展。所以 Node.js 很是適合爲 I/O 密集型應用提供服務。但這種方式的缺陷就是不擅長處理 CPU 密集型任務。

Node.js 中一般以流的方式來描述數據,也對此提供了很好的封裝。

Node.js 使用前端語言(JavaScript) 開發,同時也是一個後端服務器,所以爲先後端分離提供了一個良好的思路。我會在下一篇文章中對此進行分析。

參考資料

  1. Concurrent tasks on node.js
  2. 利用 Nginx 爲 Nodejs 添加負載均衡
  3. Understanding the node.js event loop
  4. The Node.js Event Loop
  5. The Basics of Node.js Streams
相關文章
相關標籤/搜索