nodejs深入学(7)理解Buffer

前言

因为在node中需要处理网络协议、操作数据库、处理图片、接受上传文件,因此,需要大量操作二进制数据,虽然js对于字符串支持良好,但是由于需要对于字符串进行序列化,因此,就有必要了解一下Buffer,对,没错,Buffer其实是二进制数据模块。

另外,本章将不是ES的范畴,本章定义的内容都源于commonjs(二进制部分)。因此,这也不是前端开发工程师所涉及过的场景,因此,由前端转到node的工程师很有必要来看看这一章。

buffer结构

buffer与array很像,主要用于操作字节。

buffer模块结构

buffer是一个典型的js与c++结合的模块,将性能相关的部分用c++实现,将非性能相关的部分用js实现。同时buffer也是node的核心模块,可以直接使用,并且,第五章我们已经知道buffer属于堆外内存,可以通过自己管理其垃圾回收。当然,buffer对象的管理还是在堆内,再由这个对象去管理堆外的内存。

buffer的分工

buffer对象

buffer对象类似于数组,他的元素都是16进制的两位数,即0~255的数值,我们看一下示例代码:

var str = "深入浅出node.js";
var buf = new Buffer(str, 'utf-8');
console.log(buf);
// => <Buffer e6 b7 b1 e5 85 a5 e6 b5 85 e5 87 ba 6e 6f 64 65 2e 6a 73>

不同编码的字符串,占用的元素个数也不相同,中文字在UTF-8下占用3个元素,字母和半角标点符号占用1个元素。同时,我们还可以调用length属性,得到buffer对象的长度,还可以通过下标访问元素。

var buf = new Buffer(100);
console.log(buf.length); // => 100
console.log(buf[10]);

//我们给buffer元素赋值
buf[10] = 100;
console.log(buf[10]); // => 100
buf[20] = -100;
console.log(buf[20]); // 156
buf[21] = 300;
console.log(buf[21]); // 44
buf[22] = 3.1415;
console.log(buf[22]); // 3

我们看到,给元素的赋值如果小于0,就将该值逐次加256,直到得到一个0255之间的整数,如果得到赋值大于255,就逐次减256,直到得到0255区间内的数值。如果是小数,则舍弃小数部分,只保留整数部分。

buffer内存分配

buffer不同v8申请内存,它通过node的c++模块申请内存。因此,buffer的内存策略是由c++申请内存,然后,在js中分配内存。因为,处理大量的字节数据不能采用需要一点内存就向操作系统申请一点内存的方式,这可能造成大量的内存申请的系统调用,对操作系统有一定压力。

node采用了slab的分配机制,slab其实就是一块申请好的固定内存区域,它有3种状态:

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

当我们需要一个buffer对象时,可以通过:new Buffer(size);来申请内存和内存的大小,另外还有大内存和小内存的区分,例如,以buffer.poolsize = 8 *1024来分配,这样就得到了一个8kb的内存。node其实就是以8KB为界限来区分Buffer是大对象还是小对象的。底层的代码是Buffer.poolSize = 8 * 1024;

这个8kb的值也是每个slab的大小值,在js层面以他作为单位单元进行内存分配。

1.如果指定的buffer的大小小于8kb,node会按照小对象的方式进行分配。buffer的分配过程中主要使用一个局部变量pool作为中间处理对象,处于分配状态的slab单元都会指向他,以下是分配一个全新的slab单元的操作,他会将新申请的SlowBuffer对象指向它:

var pool;
function allocPool() {
pool = new SlowBuffer(Buffer.poolSize);
pool.used = 0;
}

我们看个图,来看看这段代码都干了什么(这段代码其实就是为一个新构造的slab单元分配了空间和指针)

新构造的slab单元示例

此时,这个slab处于empty状态,然后,我们构造一个小buffer对象,也就是小于8kb的buffer。构造小buffer对象的代码为new Buffer(1024)

这次构造会去检查pool对象,如果pool没有被创建,将会创建一个新的slab单元指向它

if (!pool || pool.length - pool.used < this.length) allocPool();

同时,当前buffer对象的parent属性指向该slab,并记录下是从这个slab的哪个位置(offset)开始使用的,slab对象自身也记录被使用了多少字节,代码如下:

this.parent = pool;
this.offset = pool.used;
pool.used += this.length;
if (pool.used & 7) pool.used = (pool.used + 8) & ~7;

我们来看看上边代码都做了什么,看一下示意图:从一个新的slab单元中初次分配一个Buffer对象

从一个新的slab单元中初次分配一个Buffer对象

这个时候的slab状态为partial,当再次创建一个Buffer对象时,构造过程中将会判断这个slab的剩余空间是否足够,如果足够,使用剩余空间,并更新slab的分配状态,例如new Buffer(3000),就会再次引起slab分配:我们看一下示意图

从slab单元中,再次分配一个Buffer对象

如果slab的剩余空间不够本次分配,则会构造一个新的slab,原slab中剩余的空间将会造成浪费。例如:

new Buffer(1);
new Buffer(8192);

此时将会创建两个slab空间,第一个slab空间的8kb会被1个字节Buffer对象独占。因此,需要注意这种浪费的发生。

2.分配大Buffer对象

大于8kb的buffer对象,会被分配一个SlowBuffer对象作为slab单元,这个slab单元将被这个大的Buffer对象独占。

// Big buffer, just alloc one
this.parent = new SlowBuffer(this.length);
this.offset = 0;

这里的SlowBuffer类是在C++中定义的,虽然引用buffer模块可以访问到它,但是不推荐直接操纵它,而是用buffer替代。上面提到的buffer对象都是js层面的,能够被v8标记回收,但是其内部的parent属性指向的SlowBuffer对象却来自Node的c++模块,是c++层面的buffer对象,所用的这部分内存不在v8的堆中。

综上所述,真正的buffer内存是在node的c++层面提供的,js层面只是使用它。当进行小而频繁的buffer操作时,采用slab的机制进行预先申请和事后分配,使得js到操作系统之间不必有过多的内存申请方面的系统调用。对于大块的buffer而言,直接使用c++层面提供的内存,无需频繁的分配操作。

Buffer的转换

Buffer对象可以和字符串进行相互转换,支持的编码类型有:ASCII、UTF-8、UTF-16LE/UCS-2、Base64、Binary、Hex

字符串转Buffer

通过构造函数来完成,new Buffer(str,[encoding]);encoding默认为utf-8类型的编码和存储。同时,因此buffer记录的都是一个一个的二进制元素,或者说是汇编码,因此,可以将不同类型的buffer写入到buffer对象内,但是,因为是不同类型转为的buffer,因此,再转回字符串的时候也需要使用相同的编码规范,否则就会出现乱码的情况,因此,不建议将不同类型的编码写入到一个buffer对象中。(写入的方法是:buf.write(string,[offset],[length],[encoding])

Buffer转字符串

只需要toString()即可。

buf.toString([encoding], [start], [end])

start和end是转换时候的起始位置,之前通过写入不同编码的那种方式写入到buffer对象里的二进制元素,就可以通过这个方式重新读出了,不过,还是不要这样使用为好。

Buffer不支持的编码类型

我们可以通过调用Buffer.isEncoding(encoding)来看是否支持某种编码。对于不支持的编码格式,可以使用iconv和iconv-lite来解决。

其中,iconv-lite采用纯js实现,iconv通过c++调用libiconv库实现。在性能方面,iconv-lite由于少了c++到js层次的转换,因此,消耗更少的cpu,效率更高一点。我们卡一下例子:

var iconv = require('iconv-lite');
// Buffer转字符串
var str = iconv.decode(buf, 'win1251');
// 字符串转Buffer
var buf = iconv.encode("Sample input string", 'win1251');

对于无法转换的内容,iconv和iconv-lite会有不同的处理。iconv-lite对于无法转换的单字节输出?,多字节输出■(里边应该有个问号的,可是我不会打....)

iconv则由三级降级策略,会尝试翻译无法转换的内存,或者忽略这些内容,如果不设置忽略,iconv可能会报EILSEQ异常。如下是iconv的示例代码,我们来感受一下

var iconv = new Iconv('UTF-8', 'ASCII');
iconv.convert('ça va'); // throws EILSEQ
var iconv = new Iconv('UTF-8', 'ASCII//IGNORE');
iconv.convert('ça va'); // returns "a va"
var iconv = new Iconv('UTF-8', 'ASCII//TRANSLIT');
iconv.convert('ça va'); // "ca va"
var iconv = new Iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE');
iconv.convert('ça va Ȧ '); // "ca va "

Buffer的拼接

buffer的使用场景,很多是一段一段的从流中读取内容:

var fs = require('fs');
var rs = fs.createReadStream('test.md');
var data = '';
rs.on("data", function (chunk){
data += chunk;
});
rs.on("end", function () {
console.log(data);
});

data事件中获取的chunk对象其实就是buffer对象。这里需要注意的是data += chunk;这句话,也就拼接buffer。其实质是data = data.toString() + chunk.toString();。这里其实对于中文的支持就会存在问题。因为,英语环境下,不需要转码,直接拼接转换就行。对于宽字符的中文,就很有问题了,例如李白的静夜思进行读取和转换:

var rs = fs.createReadStream('test.md', {highWaterMark: 11});

我们限定可读流的每次读取的buffer长度限制为11,则很有可能出现如下情况:

乱码了

这个乱码为啥产生呢?我们来看看我们读的过程:这首诗原始的buffer应该是这样的

<Buffer e5 ba 8a e5 89 8d e6 98 8e e6 9c 88 e5 85 89 ef bc 8c e7 96 91 e6 98 af e5 9c b0 e4 b8 8a e9
9c 9c ef bc 9b e4 b8 be e5 a4 b4 e6 9c 9b e6 98 8e e6 9c 88 ...>

由于,我们限定了长度,因此,每个buffer对象的长度都为11,7次读完,每次的结果就变成了这样:

<Buffer e5 ba 8a e5 89 8d e6 98 8e e6 9c>
<Buffer 88 e5 85 89 ef bc 8c e7 96 91 e6>
...

因为,默认为utf-8的读取,因此,第四个字,只能显示一半。也就造成了乱码的产生。这个问题值得注意。

setEncoding()和string_decoder()

为了解决上文中的乱码问题,我们应该设置一些编解码格式:

readable.setEncoding(encoding)
var rs = fs.createReadStream('test.md', { highWaterMark: 11});
rs.setEncoding('utf8');

通过这个方法,我们传递的不再是buffer对象,而是编码后的字符串了,这样做之后就可以得到正确的输出了:

正确输出

这个过程中,也就是调用setEncoding(),可读流在内部设置了decoder对象,这个对象来自于string_decoder模块的StringDecoder对象实例,我们来感受一下:

var StringDecoder = require('string_decoder').StringDecoder;
var decoder = new StringDecoder('utf8');
var buf1 = new Buffer([0xE5, 0xBA, 0x8A, 0xE5, 0x89, 0x8D, 0xE6, 0x98, 0x8E, 0xE6, 0x9C]);
console.log(decoder.write(buf1));
// =>床前明
var buf2 = new Buffer([0x88, 0xE5, 0x85, 0x89, 0xEF, 0xBC, 0x8C, 0xE7, 0x96, 0x91, 0xE6]);
console.log(decoder.write(buf2));
// => 月光,疑

这个过程中,因为基于StringDecoder得到的编码,直到utf-8的宽字符是3个字节,因此会将前3个汉字先输出,也就是先输出9个字节,然后将月字的前两个字节保留在StringDecoder实例内部,再和后续的字节进行拼接。它目前支持utf-8、base64、ucs-2、utf-16le等,其他的没有支持的编解码格式,还是需要字节手工控制。

正确拼接Buffer

我们来看一下这个例子,它对拼接buffer做了改进:

var chunks = [];
var size = 0;
res.on('data', function (chunk) {
    chunks.push(chunk);
    size += chunk.length;
});
res.on('end', function () {
    var buf = Buffer.concat(chunks, size);
    var str = iconv.decode(buf, 'utf8');
    console.log(str);
});

正确的拼接方式,是用一个数组来存储接收到的所以buffer片段,然后调用buffer.concat()合成一个buffer对象。concat还实现了从小对象buffer向大对象buffer复制的过程,我们来看一下源代码:

Buffer.concat = function (list, length) {
    if (!Array.isArray(list)) {
        throw new Error('Usage: Buffer.concat(list, [length])');
    }
    if (list.length === 0) {
        return new Buffer(0);
    } else if (list.length === 1) {
        return list[0];
    }
    if (typeof length !== 'number') {
        length = 0;
        for (var i = 0; i < list.length; i++) {
            var buf = list[i];
            length += buf.length;
        }
    }
    var buffer = new Buffer(length);
    var pos = 0;
    for (var i = 0; i < list.length; i++) {
        var buf = list[i];
        buf.copy(buffer, pos);
        pos += buf.length;
    }
    return buffer;
};

Buffer与性能

buffer在文件io和网络io中具有广泛应用,不管是什么对象,一旦进入到网络传输中,都需要转换为buffer,然后以二进制进行数据传输。因此,提供io效率,可以从buffer转换入手。

var http = require('http');
var helloworld = "";
for (var i = 0; i < 1024 * 10; i++) {
    helloworld += "a";
}
// helloworld = new Buffer(helloworld);
http.createServer(function (req, res) {
    res.writeHead(200);
    res.end(helloworld);
}).listen(8001);

我们用ab发起200个并发

ab -c 200 -t 100 http://127.0.0.1:8001/

测试结果如下:

测试结果

这里QPS(每秒查询次数)是2527.64,传输率是25370.16kb/s

然后,我们取消掉注释,也就是不进行转换了,直接发送buffer

var http = require('http');
var helloworld = "";
for (var i = 0; i < 1024 * 10; i++) {
    helloworld += "a";
}
helloworld = new Buffer(helloworld);
http.createServer(function (req, res) {
    res.writeHead(200);
    res.end(helloworld);
}).listen(8001);

我们再来测试:

测试结果,这次没有转换

我们看到qps提升了进1倍。这个问题也是我们为啥要做动静分离的原因。

在构建web服务时,将页面的动态内容和静态内容进行分离,静态内容可以通过先转换为buffer的方式,提升传输性能。

接下来,我们再看看文件读取:

文件读取

文件读取时需要设置好highWaterMark参数。也就是我们在fs.createReadStream(path,opts)时,可以传入一些参数:

{
flags: 'r',
encoding: null,
fd: null,
mode: 0666,
highWaterMark: 64 * 1024
}

还可以设置start和end来指定读取文件的位置范围:

{start: 90, end: 99}

fs.createReadStream()的工作方式是在内存中准备一段buffer,然后,通过fs.read()读取时,逐步从磁盘中将字节复制到buffer中。完成一次读取,则从这个buffer中通过slice()取出部分数据作为一个小buffer对象,再通过data事件传递给调用方。如果buffer用完,则重新分配一个,如果还有剩余,则继续使用。

var pool;
function allocNewPool(poolSize) {
pool = new Buffer(poolSize);
pool.used = 0;
}

在理想状态下,每次读取的长度都是用户指定的highWaterMark,剩余的还可分配给下一次。pool是常驻内存的,只有当pool单元神域数量小于128(kMinPoolSpace)字节时,才会重新分配一个buffer对象,我们来看一下源代码:

if (!pool || pool.length - pool.used < kMinPoolSpace) {
// discard the old pool
pool = null;
allocNewPool(this._readableState.highWaterMark);
}

此处注意两点:

1.highWaterMark设置对buffer内存的分配和使用有一定影响
2.highWaterMark设置过小,可能导致系统调用次数过多

文件读取基于buffer分配,buffer基于Slowbuffer分配,如果文件过小,则可能造成slab的浪费。

另外,fs.createReadStream()内部使用了fs.read()实现,会多次调用系统磁盘,如果文件过大的话,highWaterMark将会决定出发系统调用的次数和data事件的次数。

以下是node自带的基准测试:在benchmark/fs/read-stream-throughput.js下:

function runTest() {
    assert(fs.statSync(filename).size === filesize);
    var rs = fs.createReadStream(filename, {
        highWaterMark: size,
        encoding: encoding
    });
    rs.on('open', function () {
        bench.start();
    });
    var bytes = 0;
    rs.on('data', function (chunk) {
        bytes += chunk.length;
    });
    rs.on('end', function () {
        try { fs.unlinkSync(filename); } catch (e) { }
        // MB/sec
        bench.end(bytes / (1024 * 1024));
    });
}

//执行结果
fs/read-stream-throughput.js type=buf size=1024: 46.284
fs/read-stream-throughput.js type=buf size=4096: 139.62
fs/read-stream-throughput.js type=buf size=65535: 681.88
fs/read-stream-throughput.js type=buf size=1048576: 857.98

我们可以看出,highWaterMark的值越大,读取速度越快。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容

  • 在NODE中,应用需要处理网络协议、操作系统数据库、处理图片、接受上传文件等,在网络流和文件的操作中,需要处理大量...
    TaoGeNet阅读 2,017评论 0 2
  • Buffer 对象可以与字符串之间相互转换。目前支持的字符串编码类型有: ASCII UTF-8 UTF-16LE...
    杰哥长得帅阅读 20,853评论 0 4
  • 前言 因为node绝大多数时间都是运行在后端的服务器程序,因此,需要精确控制内存。在以前,js程序员不需要控制内存...
    白昔月阅读 5,528评论 5 11
  • Buffer结构 类似Array,为16进制的两位数,即占一个字节 js与c++结合的模块,内存由c++申请,js...
    wmtcore阅读 1,028评论 1 1
  • Buffer 是一个 Javascript 与 C++ 结合的模块,它将性能相关部分用 C++ 实现,将非性能相关...
    杰哥长得帅阅读 2,462评论 0 2