【譯】用Node.js編寫內存效率高的應用程序

一座被設計爲能避開氣流的建築 (https://pixelz.cc)

軟件應用程序在計算機的主存儲器中運行,咱們稱之爲隨機存取存儲器(RAM)。JavaScript,尤爲是 NodeJS (服務端 JS)容許咱們爲終端用戶編寫從小型到大型的軟件項目。處理程序的內存老是一個棘手的問題,由於糟糕的實現可能會阻塞在給定服務器或系統上運行的全部其餘應用程序。C 和 C++ 程序員確實關心內存管理,由於隱藏在代碼的每一個角落都有可能出現可怕的內存泄漏。可是對於 JS 開發者來講,你真的有關心過這個問題嗎?javascript

因爲 JS 開發人員一般在專用的高容量服務器上進行 web 服務器編程,他們可能不會察覺多任務處理的延遲。比方說在開發 web 服務器的狀況下,咱們也會運行多個應用程序,如數據庫服務器( MySQL )、緩存服務器( Redis )和其餘須要的應用。咱們須要知道它們也會消耗可用的主內存。若是咱們隨意地編寫應用程序,極可能會下降其餘進程的性能,甚至讓內存徹底拒絕對它們的分配。在本文中,咱們經過解決一個問題來了解 NodeJS 的流、緩衝區和管道等結構,並瞭解它們分別如何支持編寫內存有效的應用程序。java

咱們使用 NodeJS v8.12.0 來運行這些程序,全部代碼示例都放在這裏:node

narenaryan/node-backpressure-internalsgit

原文連接: Writing memory efficient software applications in Node.js

問題:大文件複製

若是有人被要求用 NodeJS 寫一段文件複製的程序,那麼他會迅速寫出下面這段代碼:程序員

const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

fs.readFile(fileName, (err, data) => {
    if (err) throw err;

    fs.writeFile(destPath || 'output', data, (err) => {
        if (err) throw err;
    });
    
    console.log('New file has been created!');
});

這段代碼簡單地根據輸入的文件名和路徑,在嘗試對文件讀取後把它寫入目標路徑,這對於小文件來講是不成問題的。github

如今假設咱們有一個大文件(大於4 GB)須要用這段程序來進行備份。就以個人一個達 7.4G 的超高清4K 電影爲例子好了,我用上述的程序代碼把它從當前目錄複製到別的目錄。web

$ node basic_copy.js cartoonMovie.mkv ~/Documents/bigMovie.mkv

而後在 Ubuntu(Linux )系統下我獲得了這段報錯:數據庫

/home/shobarani/Workspace/basic_copy.js:7
    if (err) throw err;
             ^
RangeError: File size is greater than possible Buffer: 0x7fffffff bytes
    at FSReqWrap.readFileAfterStat [as oncomplete] (fs.js:453:11)

正如你看到的那樣,因爲 NodeJS 最大隻容許寫入 2GB 的數據到它的緩衝區,致使了錯誤發生在讀取文件的過程當中。爲了解決這個問題,當你在進行 I/O 密集操做的時候(複製、處理、壓縮等),最好考慮一下內存的狀況。編程

NodeJS 中的 Streams 和 Buffers

爲了解決上述問題,咱們須要一個辦法把大文件切成許多文件塊,同時須要一個數據結構去存放這些文件塊。一個 buffer 就是用來存儲二進制數據的結構。接下來,咱們須要一個讀寫文件塊的方法,而 Streams 則提供了這部分能力。數組

Buffers(緩衝區)

咱們可以利用 Buffer 對象輕鬆地建立一個 buffer。

let buffer = new Buffer(10); # 10 爲 buffer 的體積
console.log(buffer); # prints <Buffer 00 00 00 00 00 00 00 00 00 00>

在新版本的 NodeJS (>8)中,你也能夠這樣寫。

let buffer = new Buffer.alloc(10);
console.log(buffer); # prints <Buffer 00 00 00 00 00 00 00 00 00 00>

若是咱們已經有了一些數據,好比數組或者別的數據集,咱們能夠爲它們建立一個 buffer。

let name = 'Node JS DEV';
let buffer = Buffer.from(name);
console.log(buffer) # prints <Buffer 4e 6f 64 65 20 4a 53 20 44 45 5>

Buffers 有一些如 buffer.toString()buffer.toJSON() 之類的重要方法,可以深刻到其所存儲的數據當中去。

咱們不會爲了優化代碼而去直接建立原始 buffer。NodeJS 和 V8 引擎在處理 streams 和網絡 socket 的時候就已經在建立內部緩衝區(隊列)中實現了這一點。

Streams(流)

簡單來講,流就像 NodeJS 對象上的任意門。在計算機網絡中,入口是一個輸入動做,出口是一個輸出動做。咱們接下來將繼續使用這些術語。

流的類型總共有四種:

  • 可讀流(用於讀取數據)
  • 可寫流(用於寫入數據)
  • 雙工流(同時可用於讀寫)
  • 轉換流(一種用於處理數據的自定義雙工流,如壓縮,檢查數據等)

下面這句話能夠清晰地闡述爲何咱們應該使用流。

Stream API (尤爲是 stream.pipe() 方法)的一個重要目標是將數據緩衝限制在可接受的水平,這樣不一樣速度的源和目標就不會阻塞可用內存。

咱們須要一些辦法去完成任務而不至於壓垮系統。這也是咱們在文章開頭就已經提到過的。

image

上面的示意圖中咱們有兩個類型的流,分別是可讀流和可寫流。.pipe() 方法是一個很是基本的方法,用於鏈接可讀流和可寫流。若是你不明白上面的示意圖,也不要緊,在看完咱們的例子之後,你能夠回到示意圖這裏來,那個時候一切都會顯得理所固然。管道是一種引人注目的機制,下面咱們用兩個例子來講明它。

解法1(簡單地使用流來複制文件)

讓咱們設計一種解法來解決前文中大文件複製的問題。首先咱們要建立兩個流,而後執行接下來的幾個步驟。

  1. 監聽來自可讀流的數據塊
  2. 把數據塊寫進可寫流
  3. 跟蹤文件複製的進度

咱們把這段代碼命名爲 streams_copy_basic.js

/*
    A file copy with streams and events - Author: Naren Arya
*/

const stream = require('stream');
const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

const readabale = fs.createReadStream(fileName);
const writeable = fs.createWriteStream(destPath || "output");

fs.stat(fileName, (err, stats) => {
    this.fileSize = stats.size;
    this.counter = 1;
    this.fileArray = fileName.split('.');
    
    try {
        this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1];
    } catch(e) {
        console.exception('File name is invalid! please pass the proper one');
    }
    
    process.stdout.write(`File: ${this.duplicate} is being created:`);
    
    readabale.on('data', (chunk)=> {
        let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write(`${Math.round(percentageCopied)}%`);
        writeable.write(chunk);
        this.counter += 1;
    });
    
    readabale.on('end', (e) => {
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write("Successfully finished the operation");
        return;
    });
    
    readabale.on('error', (e) => {
        console.log("Some error occured: ", e);
    });
    
    writeable.on('finish', () => {
        console.log("Successfully created the file copy!");
    });
    
});

在這段程序中,咱們接收用戶傳入的兩個文件路徑(源文件和目標文件),而後建立了兩個流,用於把數據塊從可讀流運到可寫流。而後咱們定義了一些變量去追蹤文件複製的進度,而後輸出到控制檯(此處爲 console)。與此同時咱們還訂閱了一些事件:

data:當一個數據塊被讀取時觸發

end:當一個數據塊被可讀流所讀取完的時候觸發

error:當讀取數據塊的時候出錯時觸發

運行這段程序,咱們能夠成功地完成一個大文件(此處爲7.4 G)的複製任務。

$ time node streams_copy_basic.js cartoonMovie.mkv ~/Documents/4kdemo.mkv

然而,當咱們經過任務管理器觀察程序在運行過程當中的內存情況時,依舊有一個問題。

image

4.6GB?咱們的程序在運行時所消耗的內存,在這裏是講不通的,以及它頗有可能會卡死其餘的應用程序。

發生了什麼?

若是你有仔細觀察上圖中的讀寫率,你會發現一些端倪。

Disk Read: 53.4 MiB/s

Disk Write: 14.8 MiB/s

這意味着生產者正在以更快的速度生產,而消費者沒法跟上這個速度。計算機爲了保存讀取的數據塊,將多餘的數據存儲到機器的RAM中。這就是RAM出現峯值的緣由。

上述代碼在個人機器上運行了3分16秒……

17.16s user 25.06s system 21% cpu 3:16.61 total

解法2(基於流和自動背壓的文件複製)

爲了克服上述問題,咱們能夠修改程序來自動調整磁盤的讀寫速度。這個機制就是背壓。咱們不須要作太多,只需將可讀流導入可寫流便可,NodeJS 會負責背壓的工做。

讓咱們將這個程序命名爲 streams_copy_efficient.js

/*
    A file copy with streams and piping - Author: Naren Arya
*/

const stream = require('stream');
const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

const readabale = fs.createReadStream(fileName);
const writeable = fs.createWriteStream(destPath || "output");

fs.stat(fileName, (err, stats) => {
    this.fileSize = stats.size;
    this.counter = 1;
    this.fileArray = fileName.split('.');
    
    try {
        this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1];
    } catch(e) {
        console.exception('File name is invalid! please pass the proper one');
    }
    
    process.stdout.write(`File: ${this.duplicate} is being created:`);
    
    readabale.on('data', (chunk) => {
        let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write(`${Math.round(percentageCopied)}%`);
        this.counter += 1;
    });
    
    readabale.pipe(writeable); // Auto pilot ON!
    
    // In case if we have an interruption while copying
    writeable.on('unpipe', (e) => {
        process.stdout.write("Copy has failed!");
    });
    
});

在這個例子中,咱們用一句代碼替換了以前的數據塊寫入操做。

readabale.pipe(writeable); // Auto pilot ON!

這裏的 pipe 就是全部魔法發生的緣由。它控制了磁盤讀寫的速度以致於不會阻塞內存(RAM)。

運行一下。

$ time node streams_copy_efficient.js cartoonMovie.mkv ~/Documents/4kdemo.mkv

咱們複製了同一個大文件(7.4 GB),讓咱們來看看內存利用率。

image

震驚!如今 Node 程序僅僅佔用了61.9 MiB 的內存。若是你觀察到讀寫速率的話:

Disk Read: 35.5 MiB/s

Disk Write: 35.5 MiB/s

在任意給定的時間內,由於背壓的存在,讀寫速率得以保持一致。更讓人驚喜的是,這段優化後的程序代碼整整比以前的快了13秒。

12.13s user 28.50s system 22% cpu 3:03.35 total
因爲 NodeJS 流和管道,內存負載減小了98.68%,執行時間也減小了。這就是爲何管道是一個強大的存在。

61.9 MiB 是由可讀流建立的緩衝區大小。咱們還可使用可讀流上的 read 方法爲緩衝塊分配自定義大小。

const readabale = fs.createReadStream(fileName);
readable.read(no_of_bytes_size);

除了本地文件的複製之外,這個技術還能夠用於優化許多 I/O 操做的問題:

  • 處理從卡夫卡到數據庫的數據流
  • 處理來自文件系統的數據流,動態壓縮並寫入磁盤
  • 更多……

源碼(Git)

你能夠在個人倉庫底下找到全部的例子並在本身的機器上測試。
narenaryan/node-backpressure-internals

結論

我寫這篇文章的動機,主要是爲了說明即便 NodeJS 提供了很好的 API,咱們也可能會一不留神就寫出性能不好的代碼。若是咱們能更多地關注其內置的工具,咱們即可以更好地優化程序的運行方式。

你在此能夠找到更多關於「背壓」的資料:
backpressuring-in-streams

完。

相關文章
相關標籤/搜索