為什么要用 Node.js
這是一個移動端工程師涉足前端和后端開發的學習筆記,如有錯誤或理解不到位的地方,萬望指正。
Node.js 是什么
傳統意義上的 JavaScript 運行在瀏覽器上,這是因為瀏覽器內核實際上分為兩個部分:渲染引擎和 JavaScript 引擎。前者負責渲染 HTML + CSS,后者則負責運行 JavaScript。Chrome 使用的 JavaScript 引擎是 V8,它的速度非常快。
Node.js 是一個運行在服務端的框架,它的底層就使用了 V8 引擎。我們知道 Apache + PHP 以及 Java 的 Servlet 都可以用來開發動態網頁,Node.js 的作用與他們類似,只不過是使用 JavaScript 來開發。
從定義上介紹完后,舉一個簡單的例子,新建一個 app.js 文件并輸入以下內容:
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 即可運行,隨后訪問 便會看到輸出結果。
為什么要用 Node.js
面對一個新技術,多問幾個為什么總是好的。既然 PHP、Python、Java 都可以用來進行后端開發,為什么還要去學習 Node.js?至少我們應該知道在什么場景下,選擇 Node.js 更合適。
總的來說,Node.js 適合以下場景:
- 實時性應用,比如在線多人協作工具,網頁聊天應用等。
- 以 I/O 為主的高并發應用,比如為客戶端提供 API,讀取數據庫。
- 流式應用,比如客戶端經常上傳文件。
- 前后端分離。
實際上前兩者可以歸結為一種,即客戶端廣泛使用長連接,雖然并發數較高,但其中大部分是空閑連接。
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,我們常說的 select 、 epoll 就是用來輪詢所有 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 是單線程的,然而這樣的說法并不嚴謹,甚至可以說很不負責,因為我們至少會想到以下幾個問題:
- Node.js 在一個線程中如何處理并發請求?
- Node.js 在一個線程中如何進行文件的異步 I/O?
- 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。當緩沖區滿后就執行回調函數,對這一小塊數據進行處理,從而避免出現積壓。
實際上 request 和 fs 模塊的文件讀取都是一個可讀數據流:
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) 開發,同時也是一個后端服務器,因此為前后端分離提供了一個良好的思路。我會在下一篇文章中對此進行分析。
參考資料
- Concurrent tasks on node.js
- 利用 Nginx 為 Nodejs 添加負載均衡
- Understanding the node.js event loop
- The Node.js Event Loop
- The Basics of Node.js Streams
來自:https://bestswifter.com/nodejs/