Node学习4-Buffer

Buffer

Buffer 对象用于表示固定长度的字节序列。

从V3.0开始,该对象就继承自Uint8Array(从V3.0版本加入的特性),且继承时带上了涵盖额外用例的方法。只要支持 Buffer 的地方,Node.js API 都可以接受普通的 Uint8Array

模块结构

Buffer是一个典型的JavaScript与C++结合的模块,它将性能部分用C++实现,将非性能相关的部分用JavaScript实现。

buffer

之前说过,Buffer所占的内存不过是V8分配的,属于堆外内存。由于V8垃圾回收性能的影响,将通用的操作对象用更高效和专用的内存分配回收来管理是个不错的思路。

Buffer内存分配

在最开始的Node版本中,是采用了slab分配机制,并且使用构造函数来新建一个Buffer,如:

1
2
let bf = new Buffer(100)
//新建一个长度为100的Buffer

并且在内部判断是否大于8KB来使用slab算法。

但是自V5.10版本开始时,新增了Buffer.alloc(size)。在V5.12新增了Buffer.allocUnsafe(size)。并且在于V6.0版本废除该API。

在Node启动的时候,Buffer 模块会预分配一个内部的大小为 Buffer.poolSize (默认为8KB)的 Buffer 实例,作为快速分配的内存池,用于使用 Buffer.allocUnsafe() 创建新的 Buffer 实例、或 Buffer.from(array)、或 Buffer.concat()、或弃用的 new Buffer(size) 构造器但仅当 size 小于或等于 Buffer.poolSize >> 1Buffer.poolSize 除以二再向下取整)。

Buffer.alloc(size[, fill[, encoding]])

该API最大的特点是,永远不会使用内部的Buffer池,而是直接分配。我们来查看其源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Creates a new filled Buffer instance.
* alloc(size[, fill[, encoding]])
*/
Buffer.alloc = function alloc(size, fill, encoding) {
assertSize(size);
//有初始化的Buffer,则先申请unsafeBuffer,再填充
if (fill !== undefined && fill !== 0 && size > 0) {
const buf = createUnsafeBuffer(size);
return _fill(buf, fill, 0, buf.length, encoding);
}
//无填充的话直接新建FastBuffer,FastBuffer是Uint8Array的子类
return new FastBuffer(size);
};

可以看到,这里会判断是否有填充,如果没有填充或者size<0,则直接返回一个FastBuffer。这里的FastBufferUint8Array的一个子类,源代码如下:

1
2
3
4
5
6
7
8
class FastBuffer extends Uint8Array {
// Using an explicit constructor here is necessary to avoid relying on
// `Array.prototype[Symbol.iterator]`, which can be mutated by users.
// eslint-disable-next-line no-useless-constructor
constructor(bufferOrLength, byteOffset, length) {
super(bufferOrLength, byteOffset, length);
}
}

我们再看如果有填充并且长度不为0,则调用`createUnsafeBuffer`返回一个未初始化的数组,我们再来看一下其源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// A toggle used to access the zero fill setting of the array buffer allocator
// in C++.
// |zeroFill| can be undefined when running inside an isolate where we
// do not own the ArrayBuffer allocator. Zero fill is always on in that case.
let zeroFill = getZeroFillToggle();
function createUnsafeBuffer(size) {
zeroFill[0] = 0;
try {
return new FastBuffer(size);
} finally {
zeroFill[0] = 1;
}
}

可以看到看到这里的定义是为了解决当运行在一个我们没有一个ArrayBuffer的分配器的独立容器时,zeroFill可能为undefined时,所以调用getZeroFillToggle来直接获取zeroFill,用来解决这个问题。

可以看到整个过程中,没有使用到缓冲池pool的代码。正因如此,这种方式,要比下面的两个API慢得多。

Buffer.allocUnsafe(size)

以这种方式创建的 Buffer 实例的底层内存是未初始化的。 新创建的 Buffer 的内容是未知的,可能包含敏感数据。

该API会在size小于Buffer.poolSize的一半的时使用内部的缓冲池。让我们来看一下该API的代码:

1
2
3
4
5
6
7
8
9
/**
* Equivalent to Buffer(num), by default creates a non-zero-filled Buffer
* instance. If `--zero-fill-buffers` is set, will zero-fill the buffer.
*/
Buffer.allocUnsafe = function allocUnsafe(size) {
assertSize(size);
return allocate(size);
};

可以看到内部先判断大小,然后直接返回`allocate`整个函数的返回值,我们再看`allocate`函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function allocate(size) {
if (size <= 0) {
return new FastBuffer();
}

//如果分配的大小小于4KB
if (size < (Buffer.poolSize >>> 1)) {
//剩下的缓冲池不足,则新建一个缓冲池
if (size > (poolSize - poolOffset))
createPool();
const b = new FastBuffer(allocPool, poolOffset, size);
poolOffset += size;
alignPool();
return b;
}
return createUnsafeBuffer(size);
}

可以看到这里就很明显有一个对于缓冲池的判断,如果小于size < (Buffer.poolSize >>> 1),即默认小于4KB,则再判断缓冲池是否够,不够就新建一个pool然后缓冲池偏移。这里本质上还是一个slab算法,我们后面再讲。

但是当不满足size < (Buffer.poolSize >>> 1)时,会调用createUnsafeBuffer,这个函数即Buffer.alloc()有填充时处理的情况,即直接调用C++内存分配,不从缓冲池分配。

Buffer.allocUnsafeSlow(size)

以这种方式创建的 Buffer 实例的底层内存是未初始化的。 Buffer 的内容是未知的,可能包含敏感数据。

刚才提到了,Buffer.allocUnsafe(size)在分配的内存小于4KB时,会使用内部的缓冲池。 这可以避免垃圾回收机制因创建太多独立的 Buffer 而过度使用。 通过消除跟踪和清理尽可能多的单个 ArrayBuffer 对象的需要,该方法可以提高性能和内存使用率。

但是当我们不想从缓冲池中分配内存,而是直接在内存中保有一小块内存时,就可以使用这个API来创建一个非内存池的Buffer

下面我们来看一下其源代码:

1
2
3
4
5
6
7
8
9
/**
* Equivalent to SlowBuffer(num), by default creates a non-zero-filled
* Buffer instance that is not allocated off the pre-initialized pool.
* If `--zero-fill-buffers` is set, will zero-fill the buffer.
*/
Buffer.allocUnsafeSlow = function allocUnsafeSlow(size) {
assertSize(size);
return createUnsafeBuffer(size);
};

可以看到代码很简单,先判断大小,然后直接调用allocUnsafeSlow,从内存直接分配。

slab算法

当我们使用Buffer.allocUnsafe(size)且内存小于4KB时,就会使用内部的缓冲池,而这个缓冲池的设计就是一个slab算法。所以下面简单介绍一下这个算法。

slab算法是一种动态内存管理机制,最早诞生于SunOS系统中,目前在一些*nix系统中有广泛的应用,如FreeBSD和Linux。

简而言之,slab就是一块申请好的固定大小的内存区域。slab区域具有如下的3种状态:

  1. full:完全分配状态。
  2. partial:部分分配状态。
  3. empty:没有被分配状态。

下面我们看一下创建缓冲池的代码:

1
2
3
4
5
6
7
function createPool() {
poolSize = Buffer.poolSize;
allocPool = createUnsafeBuffer(poolSize).buffer;
markAsUntransferable(allocPool);
poolOffset = 0;
}
createPool();

可以看到实际上就是在定义后立即调用,内部调用了createUnsafeBuffer预先申请一个Buffer.poolSize的缓冲池。并且将poolOffset设置为0,即没被占用。

我们回顾之前的allocate函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function allocate(size) {
if (size <= 0) {
return new FastBuffer();
}

//如果分配的大小小于4KB
if (size < (Buffer.poolSize >>> 1)) {
//剩下的缓冲池不足,则新建一个缓冲池
if (size > (poolSize - poolOffset))
createPool();
const b = new FastBuffer(allocPool, poolOffset, size);
poolOffset += size;
alignPool();
return b;
}
return createUnsafeBuffer(size);
}

会发现,其在分配完缓冲池后,会做两件事:

  1. poolOffset += size:增加偏移量。
  2. alignPool():居中缓冲池。

第一个时间不用详细解释,即将刚才分配的内存添加到全局的poolOffset中取。

而第二个函数,我们看看其源代码:

1
2
3
4
5
6
7
function alignPool() {
// Ensure aligned slices
if (poolOffset & 0x7) {
poolOffset |= 0x7;
poolOffset++;
}
}

这段代码运用了两个位运算,实际上第一个判断是判断poolOffset是否超过了缓冲池的一半,如果没有,则将其定位在缓冲池一半的位置(结合上面判断新建的Buffer的大小是否小于缓冲池的一半)。即实现函数的名字:居中缓冲池。

所以,这种slab算法会造成内存的浪费,因为如果pool的前一部分非常小,只有几个字节,但是由于会进行居中缓冲池的操作,最后这个Buffer仍然会占据Buffer.poolSize >>> 1大小的内存。

(这里我不确定是不是slab算法,因为上面的根据源代码总结出的算法与Linux中slab算法有较大的差距,而很多文章都说Node中Buffer就是slab算法,待我后续确定)

Buffer 与字符编码

Buffer对象可以与字符串之间进行相互转换。

字符串转Buffer

字符串转Buffer主要是通过Buffer.from()函数实现:

这个函数有多个重载函数,可以实现从字符串,数组和对象的到Buffer的转换,具体参见Node官网

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
console.log(Buffer.from('fhqwhgads', 'utf8'));
// 打印: <Buffer 66 68 71 77 68 67 61 64 73>
console.log(Buffer.from('fhqwhgads', 'utf16le'));
// 打印: <Buffer 66 00 68 00 71 00 77 00 68 00 67 00 61 00 64 00 73 00>

const buf1 = Buffer.from('buffer');
const buf2 = Buffer.from(buf1);

buf1[0] = 0x61;

console.log(buf1.toString());
// 打印: auffer
console.log(buf2.toString());
// 打印: buffer

class Foo {
[Symbol.toPrimitive]() {
return 'this is a test';
}
}

const buf = Buffer.from(new Foo(), 'utf8');
// 打印: <Buffer 74 68 69 73 20 69 73 20 61 20 74 65 73 74>

Node.js当前支持的编码如下:

  • 'utf8': 多字节编码的 Unicode 字符。 许多网页和其他文档格式都使用 UTF-8这是默认的字符编码。 当将 Buffer 解码为不专门包含有效 UTF-8 数据的字符串时,则会使用 Unicode 替换字符 U+FFFD � 来表示这些错误。
  • 'utf16le': 多字节编码的 Unicode 字符。 与 'utf8' 不同,字符串中的每个字符都会使用 2 个或 4 个字节进行编码。 Node.js 仅支持 UTF-16小端序变体。
  • 'latin1': Latin-1 代表 ISO-8859-1。 此字符编码仅支持从 U+0000U+00FF 的 Unicode 字符。 每个字符使用单个字节进行编码。 超出该范围的字符会被截断,并映射成该范围内的字符。

使用以上方法之一将 Buffer 转换为字符串,称为解码;将字符串转换为 Buffer,称为编码。

Node.js 还支持以下两种二进制转文本的编码。 对于二进制转文本的编码,其命名约定是相反的:将 Buffer 转换为字符串通常称为编码,而将字符串转换为 Buffer 则称为解码。

  • 'base64': Base64 编码。 当从字符串创建 Buffer 时,此编码也会正确地接受 RFC 4648 第 5 节中指定的 “URL 和文件名安全字母”。 base64 编码的字符串中包含的空格字符(例如空格、制表符和换行)会被忽略。
  • 'hex': 将每个字节编码成两个十六进制的字符。 当解码仅包含有效的十六进制字符的字符串时,可能会发生数据截断。 请参见下面的示例。

还支持以下传统的字符编码:

  • 'ascii': 仅适用于 7 位 ASCII 数据。 当将字符串编码为 Buffer 时,这等效于使用 'latin1'。 当将 Buffer 解码为字符串时,则使用此编码会在解码为 'latin1' 之前额外取消设置每个字节的最高位。 通常,当在编码或解码纯 ASCII 文本时,应该没有理由使用这种编码,因为 'utf8'(或者,如果已知的数据始终为纯 ASCII,则为 'latin1')会是更好的选择。 这仅为传统的兼容性而提供。
  • 'binary': 'latin1' 的别名。 有关此编码的更多背景,请参阅二进制字符串。 该编码的名称可能会引起误解,因为此处列出的所有编码都是在字符串和二进制数据之间转换。 对于在字符串和 Buffer 之间进行转换,通常 'utf-8' 是正确的选择。
  • 'ucs2': 'utf16le' 的别名。 UCS-2 以前是指 UTF-16 的一种变体,该变体不支持代码点大于 U+FFFF 的字符。 在 Node.js 中,始终支持这些代码点。

Buffer不支持的编码

Node目前支持的编码格式依然比较少,包括在中国常用的GBK, GB2312, BIG-5等。为此,Buffer提供了一个Buffer.isEncoding()方法来判断编码是否被Node支持。

对于不支持的编码类型,可以借助Node生态圈中的模块完成转换,iconviconv-lite两个模块都可以支持更多的编码类型的转换,包括Windwos 125系列、ISO-8859系列、IBM/DOS代码页系列、Macintosh系列、KO18系列,以及Latin1、US-ASCII、也支持宽字节编码GBK和GB2312。

iconv-lite使用纯JavaScript实现,iconv则通过C++调用libiconv库完成。前者比后者更轻量无须编译和处理环境依赖直接使用。在性能方面,由于转码都是消耗CPU,在V8的高性能下,少了C++到JavaScript的层次转换,纯JavaScript的性能比C++表现得更好。具体使用参见:

字符串拼接

Buffer在使用中,通常是以一段一段的方式传输。例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
let fs = require('fs')

let rs = fs.createReaderStream('test.md')

let data = ''
rs.on("data", function(chunk){
data += chunk
})

rs.on("end", function(){
console.log(data)
})

上面这段代码常见于国外,用于流读取得师范,data事件中得获取的chunk对象即为Buffer对象。对于初学者而言,容易将Buffer当作字符串来理解,所以在接受上面示例时不会觉得有任何异常。

但是一旦流中出现宽字符时,问题就会暴露出来。如果你在通过Node开发的网站上看到�乱码符号,那么问题多半来自这里:

1
data += chunk

这段代码里隐藏了toString()操作,它等价于下面的代码:

1
data = data.toString() + chunk.toString()

值得注意的是:外国人的语境通常是英文环境,没有宽字符,在他们的场景中,这个toString()不会造成任何问题。但是对于宽字符的中文,却会形成问题。

其主要原因在于,每次data事件发生时,其读取的长度不一定为指定宽字符长度的整倍数,比如UTF-8中,每个字符的长度为3个字节,但是如果我们一次读取的字节为10个字节,那么前两个字符会被正常显示,但是第三个字符只录入了1/3,所以其无法正常显示,最后会被显示为�。

解决办法

setEncoding()

可读流还有一个设置编码的方法setEncoding(),示例如下:

1
readable.setEncoding(encoding)

该方法的作用是让data事件中传递的不再是一个Buffer对象,而是编码后的字符串。为此,我们改进之前的程序:

1
2
let rs = fs.createReadStream('test.md', {highWaterMark: 11})
rs.setEncoding('utf8')

再次执行,即可得到正常的结果,说明输出不再受Buffer大小的影响了。

这里我们可以稍微看一下这个函数的源代码(src/lib/internal/streams/readable.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Backwards compatibility.
Readable.prototype.setEncoding = function(enc) {
if (!StringDecoder)
StringDecoder = require('string_decoder').StringDecoder;
const decoder = new StringDecoder(enc);
this._readableState.decoder = decoder;
// If setEncoding(null), decoder.encoding equals utf8.
this._readableState.encoding = this._readableState.decoder.encoding;

const buffer = this._readableState.buffer;
// Iterate over current buffer to convert already stored Buffers:
let content = '';
for (const data of buffer) {
content += decoder.write(data);
}
buffer.clear();
if (content !== '')
buffer.push(content);
this._readableState.length = content.length;
return this;
};

可以看到,内部新建了一个StringDecoder,每次data事件发生时,decoder对象会将对得到的BufferString的转码,然后传递给调用者。

其中string_decoder之所以能够解决这个问题,是因为其会判断当前编码的单个字符宽度w,当第一次data事件触发的时候,就会只截取前面k*w个字节的长度(k*w < total)多余的(total - k*w)个字符会被保存下来,与下次data事件传过来的字节一直再进行解析。

但是setEncoding的问题在于,其只能支持上述Buffer支持的编码,仍然不能支持GKB等编码。如果遇到这些编码,仍然会出现问题。

数组拼接

我们可以将接收到的字节存储在数组中,然后通过iconv-lite等工具再来进行转码:

示例
1
2
3
4
5
6
7
8
9
10
11
12
let chunks = []
let size = 0
rs.on('data', function(chunk){
chunks.push(chunk)
size += chunk.length
})

rs.on('end', function(){
let buf = Buffer.concat(chunks, size)
let str = iconv.decode(buf, 'GBK')
console.log(str)
})

正常的拼接方法是用一个数组来存储接收到的所有Buffer片段并记录下所有片段的总长度,然后调用Buffer.concat方法生成一个合并的Buffer对象。

Buffer与性能

Buffer在文件I/O和网络I/O时运用广泛,尤其在网络传输中,它的性能举足轻重。在应用中,如果不是特别大的内容,我们都会使用字符串,但一旦在网络中传输,都需要转换为字符串,以二进制数据进行传输。在web应用中,从字符串到Buffer的转换时时刻刻都在发生,所以提高字符串到Buffer的转换效率,可以大幅度提高网络吞吐量。

下面是net模块的makeSyncWrite函数,在实际中,它是最后处理数据的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function makeSyncWrite(fd) {
return function(chunk, enc, cb) {
if (enc !== 'buffer')
chunk = Buffer.from(chunk, enc);

this._handle.bytesWritten += chunk.length;

const ctx = {};
writeBuffer(fd, chunk, 0, chunk.length, null, undefined, ctx);
if (ctx.errno !== undefined) {
const ex = errors.uvException(ctx);
ex.errno = ctx.errno;
return cb(ex);
}
cb();
};
}

可以看到chunk = Buffer.from(chunk, enc);,如果chunk不是Buffer,则将其转换为Buffer

所以,Node底层仍然是使用Buffer.from()来进行字符串到Buffer的转换,所以,如果是动态的字符串,我们提前转换与Node自动转换区别不大,但是如果是静态的字符串,比如各种错误提示,此时将其提前转换为Buffer,在高并发时,将大幅度提高服务器CPU的利用率。

总结

对于经常写JavaScript代码的人,很容易混淆StringBuffer,实际上它们有诸多不同:

  1. String长度不定,而Buffer在定义的时候就规定了长度,之后无法改变(可以使用Buffer.concat()变相的增加长度)。
  2. Buffer存储的是二进制数据,字符串与Buffer之间存在编码问题。
  3. 在V3版本后,Buffer即为Uint8Array的子类,所以其本质是一个数组。
  4. String的内存由V8引擎分配、回收,而Buffer的内存由Node自己负责,不受其内存限制。

引用

本文参考

Powered by Hexo and Hexo-theme-hiker

Copyright © 2019 - 2024 My Wonderland All Rights Reserved.

UV : | PV :