Node.js 中的緩衝區(Buffer)究竟是什麼?

五月君發表於2019-07-25

圖片描述

多數人都擁有自己不瞭解的能力和機會,都有可能做到未曾夢想的事情。 ——戴爾·卡耐基

從前端轉入 Node.js 的童鞋對這一部分內容會比較陌生,因為在前端中一些簡單的字串操作已經滿足基本的業務需求,有時可能也會覺得 Buffer、Stream 這些會很神祕。回到服務端,如果你不想只做一名普通的 Node.js 開發工程師,你應該深入去學習一下 Buffer 揭開這一層神祕的面紗,同時也會讓你對 Node.js 的理解提升一個水平。

作者簡介:五月君,Nodejs Developer,熱愛技術、喜歡分享的 90 後青年,公眾號 “Nodejs技術棧”,Github 開源專案 www.nodejs.red

Buffer初識

在引入 TypedArray 之前,JavaScript 語言沒有用於讀取或操作二進位制資料流的機制。 Buffer 類是作為 Node.js API 的一部分引入的,用於在 TCP 流、檔案系統操作、以及其他上下文中與八位位元組流進行互動。這是來自 Node.js 官網的一段描述,比較晦澀難懂,總結起來一句話 Node.js 可以用來處理二進位制流資料或者與之進行互動

Buffer 用於讀取或操作二進位制資料流,做為 Node.js API 的一部分使用時無需 require,用於操作網路協議、資料庫、圖片和檔案 I/O 等一些需要大量二進位制資料的場景。Buffer 在建立時大小已經被確定且是無法調整的,在記憶體分配這塊 Buffer 是由 C++ 層面提供而不是 V8 具體後面會講解。

在這裡不知道你是否認為這是很簡單的?但是上面提到的一些關鍵詞二進位制流(Stream)緩衝區(Buffer),這些又都是什麼呢?下面嘗試做一些簡單的介紹。

什麼是二進位制資料?

談到二進位制我們大腦可能會浮想到就是 010101 這種程式碼命令,如下圖所示:

圖片描述

正如上圖所示,二進位制資料使用 0 和 1 兩個數碼來表示的資料,為了儲存或展示一些資料,計算機需要先將這些資料轉換為二進位制來表示。例如,我想儲存 66 這個數字,計算機會先將數字 66 轉化為二進位制 01000010 表示,印象中第一次接觸這個是在大學期間 C 語言課程中,轉換公式如下所示:

128 64 32 16 8 4 2 1
0 1 0 0 0 0 1 0

上面用數字舉了一個示例,我們知道數字只是資料型別之一,其它的還有字串、影像、檔案等。例如我們對一個英文 M 操作,在 JavaScript 裡通過 'M'.charCodeAt() 取到對應的 ASCII 碼之後(通過以上的步驟)會轉為二進位制表示。

什麼是 Stream?

流,英文 Stream 是對輸入輸出裝置的抽象,這裡的裝置可以是檔案、網路、記憶體等。

流是有方向性的,當程式從某個資料來源讀入資料,會開啟一個輸入流,這裡的資料來源可以是檔案或者網路等,例如我們從 a.txt 檔案讀入資料。相反的當我們的程式需要寫出資料到指定資料來源(檔案、網路等)時,則開啟一個輸出流。當有一些大檔案操作時,我們就需要 Stream 像管道一樣,一點一點的將資料流出。

舉個例子

我們現在有一大罐水需要澆一片菜地,如果我們將水罐的水一下全部倒入菜地,首先得需要有多麼大的力氣(這裡的力氣好比計算機中的硬體效能)才可搬得動。如果,我們拿來了水管將水一點一點流入我們的菜地,這個時候不要這麼大力氣就可完成。

圖片描述

通過上面的講解進一步的理解了 Stream 是什麼?那麼 Stream 和 Buffer 之間又是什麼關係呢?看以下介紹,關於 Stream 本身也有很多知識點,歡迎關注公眾號「Nodejs技術棧」,之後會單獨進行介紹。

什麼是 Buffer?

通過以上 Stream 的講解,我們已經看到資料是從一端流向另一端,那麼他們是如何流動的呢?

通常,資料的移動是為了處理或者讀取它,並根據它進行決策。伴隨著時間的推移,每一個過程都會有一個最小或最大資料量。如果資料到達的速度比程式消耗的速度快,那麼少數早到達的資料會處於等待區等候被處理。反之,如果資料到達的速度比程式消耗的資料慢,那麼早先到達的資料需要等待一定量的資料到達之後才能被處理。

這裡的等待區就指的緩衝區(Buffer),它是計算機中的一個小物理單位,通常位於計算機的 RAM 中。這些概念可能會很難理解,不要擔心下面通過一個例子進一步說明。

公共汽車站乘車例子

舉一個公共汽車站乘車的例子,通常公共汽車會每隔幾十分鐘一趟,在這個時間到達之前就算乘客已經滿了,車輛也不會提前發車,早到的乘客就需要先在車站進行等待。假設到達的乘客過多,後到的一部分則需要在公共汽車站等待下一趟車駛來。

圖片描述

在上面例子中的等待區公共汽車站,對應到我們的 Node.js 中也就是緩衝區(Buffer),另外乘客到達的速度是我們不能控制的,我們能控制的也只有何時發車,對應到我們的程式中就是我們無法控制資料流到達的時間,可以做的是能決定何時傳送資料。

Buffer基本使用

瞭解了 Buffer 的一些概念之後,我們來看下 Buffer 的一些基本使用,這裡並不會列舉所有的 API 使用,僅列舉一部分常用的,更詳細的可參考 Node.js 中文網

建立Buffer

在 6.0.0 之前的 Node.js 版本中, Buffer 例項是使用 Buffer 建構函式建立的,該函式根據提供的引數以不同方式分配返回的 Buffer new Buffer()

現在可以通過 Buffer.from()、Buffer.alloc() 與 Buffer.allocUnsafe() 三種方式來建立

Buffer.from()

const b1 = Buffer.from('10');
const b2 = Buffer.from('10', 'utf8');
const b3 = Buffer.from([10]);
const b4 = Buffer.from(b3);

console.log(b1, b2, b3, b4); // <Buffer 31 30> <Buffer 31 30> <Buffer 0a> <Buffer 0a>
複製程式碼

Buffer.alloc

返回一個已初始化的 Buffer,可以保證新建立的 Buffer 永遠不會包含舊資料。

const bAlloc1 = Buffer.alloc(10); // 建立一個大小為 10 個位元組的緩衝區

console.log(bAlloc1); // <Buffer 00 00 00 00 00 00 00 00 00 00>
複製程式碼

Buffer.allocUnsafe

建立一個大小為 size 位元組的新的未初始化的 Buffer,由於 Buffer 是未初始化的,因此分配的記憶體片段可能包含敏感的舊資料。在 Buffer 內容可讀情況下,則可能會洩露它的舊資料,這個是不安全的,使用時要謹慎。

const bAllocUnsafe1 = Buffer.allocUnsafe(10);

console.log(bAllocUnsafe1); // <Buffer 49 ae c9 cd 49 1d 00 00 11 4f>
複製程式碼

Buffer 字元編碼

通過使用字元編碼,可實現 Buffer 例項與 JavaScript 字串之間的相互轉換,目前所支援的字元編碼如下所示:

  • 'ascii' - 僅適用於 7 位 ASCII 資料。此編碼速度很快,如果設定則會剝離高位。
  • 'utf8' - 多位元組編碼的 Unicode 字元。許多網頁和其他文件格式都使用 UTF-8。
  • 'utf16le' - 2 或 4 個位元組,小端序編碼的 Unicode 字元。支援代理對(U+10000 至 U+10FFFF)。
  • 'ucs2' - 'utf16le' 的別名。
  • 'base64' - Base64 編碼。當從字串建立 Buffer 時,此編碼也會正確地接受 RFC 4648 第 5 節中指定的 “URL 和檔名安全字母”。
  • 'latin1' - 一種將 Buffer 編碼成單位元組編碼字串的方法(由 RFC 1345 中的 IANA 定義,第 63 頁,作為 Latin-1 的補充塊和 C0/C1 控制碼)。
  • 'binary' - 'latin1' 的別名。
  • 'hex' - 將每個位元組編碼成兩個十六進位制的字元。
const buf = Buffer.from('hello world', 'ascii');
console.log(buf.toString('hex')); // 68656c6c6f20776f726c64
複製程式碼

字串與 Buffer 型別互轉

字串轉 Buffer

這個相信不會陌生了,通過上面講解的 Buffer.form() 實現,如果不傳遞 encoding 預設按照 UTF-8 格式轉換儲存

const buf = Buffer.from('Node.js 技術棧', 'UTF-8');

console.log(buf); // <Buffer 4e 6f 64 65 2e 6a 73 20 e6 8a 80 e6 9c af e6 a0 88>
console.log(buf.length); // 17
複製程式碼

Buffer 轉換為字串

Buffer 轉換為字串也很簡單,使用 toString([encoding], [start], [end]) 方法,預設編碼仍為 UTF-8,如果不傳 start、end 可實現全部轉換,傳了 start、end 可實現部分轉換(這裡要小心了)

const buf = Buffer.from('Node.js 技術棧', 'UTF-8');

console.log(buf); // <Buffer 4e 6f 64 65 2e 6a 73 20 e6 8a 80 e6 9c af e6 a0 88>
console.log(buf.length); // 17
console.log(buf.toString('UTF-8', 0, 9)); // Node.js �
複製程式碼

執行檢視,可以看到以上輸出結果為 Node.js � 出現了亂碼,為什麼?

轉換過程中為什麼出現亂碼?

首先以上示例中使用的預設編碼方式 UTF-8,問題就出在這裡一箇中文在 UTF-8 下佔用 3 個位元組, 這個字在 buf 中對應的位元組為 8a 80 e6 而我們的設定的範圍為 0~9 因此只輸出了 8a,這個時候就會造成字元被截斷出現亂碼。

下面我們改下示例的擷取範圍:

const buf = Buffer.from('Node.js 技術棧', 'UTF-8');

console.log(buf); // <Buffer 4e 6f 64 65 2e 6a 73 20 e6 8a 80 e6 9c af e6 a0 88>
console.log(buf.length); // 17
console.log(buf.toString('UTF-8', 0, 11)); // Node.js 技
複製程式碼

可以看到已經正常輸出了

Buffer記憶體機制

Nodejs 中的 記憶體管理和 V8 垃圾回收機制 一節主要講解了在 Node.js 的垃圾回收中主要使用 V8 來管理,但是並沒有提到 Buffer 型別的資料是如何回收的,下面讓我們來了解 Buffer 的記憶體回收機制。

由於 Buffer 需要處理的是大量的二進位制資料,假如用一點就向系統去申請,則會造成頻繁的向系統申請記憶體呼叫,所以 Buffer 所佔用的記憶體不再由 V8 分配,而是在 Node.js 的 C++ 層面完成申請,在 JavaScript 中進行記憶體分配。因此,這部分記憶體我們稱之為堆外記憶體

注意:以下使用到的 buffer.js 原始碼為 Node.js v10.x 版本,地址:github.com/nodejs/node…

Buffer記憶體分配原理

Node.js 採用了 slab 機制進行預先申請、事後分配,是一種動態的管理機制。

使用 Buffer.alloc(size) 傳入一個指定的 size 就會申請一塊固定大小的記憶體區域,slab 具有如下三種狀態:

  • full:完全分配狀態
  • partial:部分分配狀態
  • empty:沒有被分配狀態

8KB 限制

Node.js 以 8KB 為界限來區分是小物件還是大物件,在 buffer.js 中可以看到以下程式碼

Buffer.poolSize = 8 * 1024; // 102 行,Node.js 版本為 v10.x
複製程式碼

Buffer 初識 一節裡有提到過 Buffer 在建立時大小已經被確定且是無法調整的 到這裡應該就明白了。

Buffer 物件分配

以下程式碼示例,在載入時直接呼叫了 createPool() 相當於直接初始化了一個 8 KB 的記憶體空間,這樣在第一次進行記憶體分配時也會變得更高效。另外在初始化的同時還初始化了一個新的變數 poolOffset = 0 這個變數會記錄已經使用了多少位元組。

Buffer.poolSize = 8 * 1024;
var poolSize, poolOffset, allocPool;

... // 中間程式碼省略

function createPool() {
  poolSize = Buffer.poolSize;
  allocPool = createUnsafeArrayBuffer(poolSize);
  poolOffset = 0;
}
createPool(); // 129 行
複製程式碼

此時,新構造的 slab 如下所示:

圖片描述

現在讓我們來嘗試分配一個大小為 2048 的 Buffer 物件,程式碼如下所示:

Buffer.alloc(2 * 1024)
複製程式碼

現在讓我們先看下當前的 slab 記憶體是怎麼樣的?如下所示:

圖片描述

那麼這個分配過程是怎樣的呢?讓我們再看 buffer.js 另外一個核心的方法 allocate(size)

// https://github.com/nodejs/node/blob/v10.x/lib/buffer.js#L318
function allocate(size) {
  if (size <= 0) {
    return new FastBuffer();
  }

  // 當分配的空間小於 Buffer.poolSize 向右移位,這裡得出來的結果為 4KB
  if (size < (Buffer.poolSize >>> 1)) {
    if (size > (poolSize - poolOffset))
      createPool();
    var b = new FastBuffer(allocPool, poolOffset, size);
    poolOffset += size; // 已使用空間累加
    alignPool(); // 8 位元組記憶體對齊處理
    return b;
  } else { // C++ 層面申請
    return createUnsafeBuffer(size);
  }
}
複製程式碼

讀完上面的程式碼,已經很清晰的可以看到何時會分配小 Buffer 物件,又何時會去分配大 Buffer 物件。

Buffer 記憶體分配總結

這塊內容著實難理解,翻了幾本 Node.js 相關書籍,樸靈大佬的「深入淺出 Node.js」Buffer 一節還是講解的挺詳細的,推薦大家去閱讀下。

  1. 在初次載入時就會初始化 1 個 8KB 的記憶體空間,buffer.js 原始碼有體現
  2. 根據申請的記憶體大小分為 小 Buffer 物件大 Buffer 物件
  3. 小 Buffer 情況,會繼續判斷這個 slab 空間是否足夠
    • 如果空間足夠就去使用剩餘空間同時更新 slab 分配狀態,偏移量會增加
    • 如果空間不足,slab 空間不足,就會去建立一個新的 slab 空間用來分配
  4. 大 Buffer 情況,則會直接走 createUnsafeBuffer(size) 函式
  5. 不論是小 Buffer 物件還是大 Buffer 物件,記憶體分配是在 C++ 層面完成,記憶體管理在 JavaScript 層面,最終還是可以被 V8 的垃圾回收標記所回收。

Buffer應用場景

以下列舉一些 Buffer 在實際業務中的應用場景,也歡迎大家在評論區補充!

I/O 操作

關於 I/O 可以是檔案或網路 I/O,以下為通過流的方式將 input.txt 的資訊讀取出來之後寫入到 output.txt 檔案,關於 Stream 與 Buffer 的關係不明白的在回頭看下 Buffer 初識 一節講解的 什麼是 Stream?什麼是 Buffer?

const fs = require('fs');

const inputStream = fs.createReadStream('input.txt'); // 建立可讀流
const outputStream = fs.createWriteStream('output.txt'); // 建立可寫流

inputStream.pipe(outputStream); // 管道讀寫
複製程式碼

在 Stream 中我們是不需要手動去建立自己的緩衝區,在 Node.js 的流中將會自動建立

zlib.js

zlib.js 為 Node.js 的核心庫之一,其利用了緩衝區(Buffer)的功能來操作二進位制資料流,提供了壓縮或解壓功能。參考原始碼 zlib.js 原始碼

加解密

在一些加解密演算法中會遇到使用 Buffer,例如 crypto.createCipheriv 的第二個引數 key 為 String 或 Buffer 型別,如果是 Buffer 型別,就用到了本篇我們講解的內容,以下做了一個簡單的加密示例,重點使用了 Buffer.alloc() 初始化一個例項(這個上面有介紹),之後使用了 fill 方法做了填充,這裡重點在看下這個方法的使用。

buf.fill(value[, offset[, end]][, encoding])

  • value: 第一個引數為要填充的內容
  • offset: 偏移量,填充的起始位置
  • end: 結束填充 buf 的偏移量
  • encoding: 編碼集

以下為 Cipher 的對稱加密 Demo

const crypto = require('crypto');
const [key, iv, algorithm, encoding, cipherEncoding] = [
    'a123456789', '', 'aes-128-ecb', 'utf8', 'base64'
];

const handleKey = key => {
    const bytes = Buffer.alloc(16); // 初始化一個 Buffer 例項,每一項都用 00 填充
    console.log(bytes); // <Buffer 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00>
    bytes.fill(key, 0, 10) // 填充
    console.log(bytes); // <Buffer 61 31 32 33 34 35 36 37 38 39 00 00 00 00 00 00>

    return bytes;
}

let cipher = crypto.createCipheriv(algorithm, handleKey(key), iv);
let crypted = cipher.update('Node.js 技術棧', encoding, cipherEncoding);
    crypted += cipher.final(cipherEncoding);

console.log(crypted) // jE0ODwuKN6iaKFKqd3RF4xFZkOpasy8WfIDl8tRC5t0=
複製程式碼

Buffer VS Cache

緩衝(Buffer)與快取(Cache)的區別?

緩衝(Buffer)

緩衝(Buffer)是用於處理二進位制流資料,將資料緩衝起來,它是臨時性的,對於流式資料,會採用緩衝區將資料臨時儲存起來,等緩衝到一定的大小之後在存入硬碟中。視訊播放器就是一個經典的例子,有時你會看到一個緩衝的圖示,這意味著此時這一組緩衝區並未填滿,當資料到達填滿緩衝區並且被處理之後,此時緩衝圖示消失,你可以看到一些影像資料。

快取(Cache)

快取(Cache)我們可以看作是一箇中間層,它可以是永久性的將熱點資料進行快取,使得訪問速度更快,例如我們通過 Memory、Redis 等將資料從硬碟或其它第三方介面中請求過來進行快取,目的就是將資料存於記憶體的快取區中,這樣對同一個資源進行訪問,速度會更快,也是效能優化一個重要的點。

來自知乎的一個討論,點選 more 檢視

Buffer VS String

通過壓力測試來看看 String 和 Buffer 兩者的效能如何?

const http = require('http');
let s = '';
for (let i=0; i<1024*10; i++) {
    s+='a'
}

const str = s;
const bufStr = Buffer.from(s);
const server = http.createServer((req, res) => {
    console.log(req.url);

    if (req.url === '/buffer') {
        res.end(bufStr);
    } else if (req.url === '/string') {
        res.end(str);
    }
});

server.listen(3000);
複製程式碼

以上例項我放在虛擬機器裡進行測試,你也可以在本地電腦測試,使用 AB 測試工具。

測試 string

看以下幾個重要的引數指標,之後通過 buffer 傳輸進行對比

  • Complete requests: 21815
  • Requests per second: 363.58 [#/sec] (mean)
  • Transfer rate: 3662.39 [Kbytes/sec] received
$ ab -c 200 -t 60 http://192.168.6.131:3000/string
複製程式碼

圖片描述

測試 buffer

可以看到通過 buffer 傳輸總共的請求數為 50000、QPS 達到了兩倍多的提高、每秒傳輸的位元組為 9138.82 KB,從這些資料上可以證明提前將資料轉換為 Buffer 的方式,可以使效能得到近一倍的提升。

  • Complete requests: 50000
  • Requests per second: 907.24 [#/sec] (mean)
  • Transfer rate: 9138.82 [Kbytes/sec] received
$ ab -c 200 -t 60 http://192.168.6.131:3000/buffer
複製程式碼

圖片描述

在 HTTP 傳輸中傳輸的是二進位制資料,上面例子中的 /string 介面直接返回的字串,這時候 HTTP 在傳輸之前會先將字串轉換為 Buffer 型別,以二進位制資料傳輸,通過流(Stream)的方式一點點返回到客戶端。但是直接返回 Buffer 型別,則少了每次的轉換操作,對於效能也是有提升的。

在一些 Web 應用中,對於靜態資料可以預先轉為 Buffer 進行傳輸,可以有效減少 CPU 的重複使用(重複的字串轉 Buffer 操作)。

Reference

歡迎大家關注「Nodejs技術棧」公眾號,掃描關注我哦!

Node.js 中的緩衝區(Buffer)究竟是什麼?

相關文章