Buffer
Buffer
对象用于表示固定长度的字节序列。
从V3.0开始,该对象就继承自Uint8Array(从V3.0版本加入的特性),且继承时带上了涵盖额外用例的方法。只要支持 Buffer
的地方,Node.js API 都可以接受普通的 Uint8Array
模块结构
Buffer是一个典型的JavaScript与C++结合的模块,它将性能部分用C++实现,将非性能相关的部分用JavaScript实现。
之前说过,Buffer所占的内存不过是V8分配的,属于堆外内存。由于V8垃圾回收性能的影响,将通用的操作对象用更高效和专用的内存分配回收来管理是个不错的思路。
Buffer内存分配
在最开始的Node版本中,是采用了slab分配机制,并且使用构造函数来新建一个Buffer,如:
1 | let bf = new Buffer(100) |
并且在内部判断是否大于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 >> 1
(Buffer.poolSize
除以二再向下取整)。
Buffer.alloc(size[, fill[, encoding]])
该API最大的特点是,永远不会使用内部的Buffer
池,而是直接分配。我们来查看其源代码:
1 | /** |
可以看到,这里会判断是否有填充,如果没有填充或者size<0,则直接返回一个FastBuffer
。这里的FastBuffer
是Uint8Array
的一个子类,源代码如下:
1 | class FastBuffer extends Uint8Array { |
我们再看如果有填充并且长度不为0,则调用`createUnsafeBuffer`返回一个未初始化的数组,我们再来看一下其源代码:
1 | // A toggle used to access the zero fill setting of the array buffer allocator |
可以看到看到这里的定义是为了解决当运行在一个我们没有一个ArrayBuffer的分配器的独立容器时,zeroFill
可能为undefined
时,所以调用getZeroFillToggle
来直接获取zeroFill
,用来解决这个问题。
可以看到整个过程中,没有使用到缓冲池pool
的代码。正因如此,这种方式,要比下面的两个API慢得多。
Buffer.allocUnsafe(size)
以这种方式创建的 Buffer
实例的底层内存是未初始化的。 新创建的 Buffer
的内容是未知的,可能包含敏感数据。
该API会在size
小于Buffer.poolSize
的一半的时使用内部的缓冲池。让我们来看一下该API的代码:
1 | /** |
可以看到内部先判断大小,然后直接返回`allocate`整个函数的返回值,我们再看`allocate`函数。
1 | function allocate(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 | /** |
可以看到代码很简单,先判断大小,然后直接调用allocUnsafeSlow,从内存直接分配。
slab算法
当我们使用Buffer.allocUnsafe(size)
且内存小于4KB时,就会使用内部的缓冲池,而这个缓冲池的设计就是一个slab
算法。所以下面简单介绍一下这个算法。
slab算法是一种动态内存管理机制,最早诞生于SunOS系统中,目前在一些*nix系统中有广泛的应用,如FreeBSD和Linux。
简而言之,slab就是一块申请好的固定大小的内存区域。slab区域具有如下的3种状态:
- full:完全分配状态。
- partial:部分分配状态。
- empty:没有被分配状态。
下面我们看一下创建缓冲池的代码:
1 | function createPool() { |
可以看到实际上就是在定义后立即调用,内部调用了createUnsafeBuffer
预先申请一个Buffer.poolSize
的缓冲池。并且将poolOffset
设置为0,即没被占用。
我们回顾之前的allocate函数:
1 | function allocate(size) { |
会发现,其在分配完缓冲池后,会做两件事:
- poolOffset += size:增加偏移量。
- alignPool():居中缓冲池。
第一个时间不用详细解释,即将刚才分配的内存添加到全局的poolOffset
中取。
而第二个函数,我们看看其源代码:
1 | function alignPool() { |
这段代码运用了两个位运算,实际上第一个判断是判断poolOffset
是否超过了缓冲池的一半,如果没有,则将其定位在缓冲池一半的位置(结合上面判断新建的Buffer的大小是否小于缓冲池的一半)。即实现函数的名字:居中缓冲池。
所以,这种slab算法会造成内存的浪费,因为如果pool的前一部分非常小,只有几个字节,但是由于会进行居中缓冲池的操作,最后这个Buffer仍然会占据Buffer.poolSize >>> 1
大小的内存。
(这里我不确定是不是slab算法,因为上面的根据源代码总结出的算法与Linux中slab算法有较大的差距,而很多文章都说Node中Buffer就是slab算法,待我后续确定)
Buffer 与字符编码
Buffer对象可以与字符串之间进行相互转换。
字符串转Buffer
字符串转Buffer主要是通过Buffer.from()
函数实现:
这个函数有多个重载函数,可以实现从字符串,数组和对象的到Buffer的转换,具体参见Node官网
示例
1 | console.log(Buffer.from('fhqwhgads', 'utf8')); |
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+0000
到U+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生态圈中的模块完成转换,iconv
和iconv-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 | let fs = require('fs') |
上面这段代码常见于国外,用于流读取得师范,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 | let rs = fs.createReadStream('test.md', {highWaterMark: 11}) |
再次执行,即可得到正常的结果,说明输出不再受Buffer大小的影响了。
这里我们可以稍微看一下这个函数的源代码(src/lib/internal/streams/readable.js
):
1 | // Backwards compatibility. |
可以看到,内部新建了一个StringDecoder
,每次data事件发生时,decoder
对象会将对得到的Buffer
到String
的转码,然后传递给调用者。
其中string_decoder
之所以能够解决这个问题,是因为其会判断当前编码的单个字符宽度w,当第一次data
事件触发的时候,就会只截取前面k*w个字节的长度(k*w < total)多余的(total - k*w)个字符会被保存下来,与下次data
事件传过来的字节一直再进行解析。
但是setEncoding
的问题在于,其只能支持上述Buffer支持的编码,仍然不能支持GKB等编码。如果遇到这些编码,仍然会出现问题。
数组拼接
我们可以将接收到的字节存储在数组中,然后通过iconv-lite
等工具再来进行转码:
示例
1 | let chunks = [] |
正常的拼接方法是用一个数组来存储接收到的所有Buffer片段并记录下所有片段的总长度,然后调用Buffer.concat
方法生成一个合并的Buffer对象。
Buffer与性能
Buffer在文件I/O和网络I/O时运用广泛,尤其在网络传输中,它的性能举足轻重。在应用中,如果不是特别大的内容,我们都会使用字符串,但一旦在网络中传输,都需要转换为字符串,以二进制数据进行传输。在web应用中,从字符串到Buffer的转换时时刻刻都在发生,所以提高字符串到Buffer的转换效率,可以大幅度提高网络吞吐量。
下面是net模块的makeSyncWrite
函数,在实际中,它是最后处理数据的:
1 | function makeSyncWrite(fd) { |
可以看到chunk = Buffer.from(chunk, enc);
,如果chunk
不是Buffer
,则将其转换为Buffer
。
所以,Node底层仍然是使用Buffer.from()
来进行字符串到Buffer
的转换,所以,如果是动态的字符串,我们提前转换与Node自动转换区别不大,但是如果是静态的字符串,比如各种错误提示,此时将其提前转换为Buffer
,在高并发时,将大幅度提高服务器CPU的利用率。
总结
对于经常写JavaScript代码的人,很容易混淆String
与Buffer
,实际上它们有诸多不同:
String
长度不定,而Buffer
在定义的时候就规定了长度,之后无法改变(可以使用Buffer.concat()
变相的增加长度)。Buffer
存储的是二进制数据,字符串与Buffer
之间存在编码问题。- 在V3版本后,
Buffer
即为Uint8Array
的子类,所以其本质是一个数组。 String
的内存由V8引擎分配、回收,而Buffer
的内存由Node自己负责,不受其内存限制。
引用
本文参考