buffer.concat引出的bug(真可以打开,走传送门吧)
发布于 12 年前 作者 DoubleSpout 5352 次浏览 最后一次编辑是 8 年前

最近有位rrestjs框架的使用者YanQ报告给我这样一个错误,跟我说在用户post很多内容的文章时会crash进程然后报如下错误:(热心的老雷帮我解决了问题)

buffer.js:523
    throw new RangeError('targetStart out of bounds');

具体报错的位置在:

var buf =  Buffer.concat(rec_ary, rec_ary.length);

其中rec_ary就是存放buffer的数组。 当然这个bug是具有一定隐蔽性的,因为这个bug被报告为本地测试环境没有问题,而线上会有。所以一开始一直以为是node版本兼容性,系统兼容性等等的问题。

我们先看下官方api里buffer.concat方法的说明吧:

Class Method: Buffer.concat(list, [totalLength])#
list Array List of Buffer objects to concat
totalLength Number Total length of the buffers when concatenated
...
If totalLength is not provided, it is read from the buffers in the list. However, this adds an additional loop to the function, so it is faster to provide the length explicitly.

我个人觉得很具有迷惑性,total length of the buffers 这句话怎么理解? 我理解为list的长度,需要连接的buffers的数量,但是真实的情况却是这样的,下面代码是node v0.10.2 /lib/buffer.js中的片段:

Buffer.concat = function(list, length) {
... //省略

if (typeof length !== 'number') {
    length = 0;
    for (var i = 0; i < list.length; i++) {
      var buf = list[i];
      length += buf.length;
    }
  }
...//省略
}

原来这句话的正确理解是,需要连接的所有buffers的长度,也就是buffers的总长度,理解出现偏差造成了这个bug。正确的buffer.concat代码如下:

var bufAry = []
var bufLen = 0;
for(var i=0;i<5;i++){
	bufAry[i] = new Buffer("str is "+ i + '\n');
	bufLen += bufAry[i].length
}
console.log(bufLen)
var newBuf = Buffer.concat(bufAry,bufLen)
console.log(newBuf.toString())

将输出:

45
str is 0
str is 1
str is 2
str is 3
str is 4

所以希望今后大家用到buffer.concat方法时要注意以下,第二个参数务必传递buffers的总长度,当然你也可以偷懒省略第二个参数,这样也不会有问题。

这次顺便也看了看buffer.js的源码,发现挺有意思, SlowBuffer这个类是直接调用c++的接口类,所以我们尽量不要去调用这个类,除非我们想自己维护一个独立buffer池。 我们看如下代码:

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

分配buffer池的函数会将模块变量pool赋值为slowbuffer的实例,同时默认大小为8KB,那这个8KB对我们有什么意义呢?其实8KB只是一个存储的空间,如果我们有很多小的buffer,将会共用这8KB的存储空间,我们看代码如下:

function Buffer(subject, encoding, offset) { //buffer类
	... //省略以上是一些参数的初始化,switch判断等等

     if (this.length > Buffer.poolSize) {  //this.length就是之前初始化的本buffer实例需要分配的内存bytes空间,跟8KB进行比较
      // Big buffer, just alloc one.
      this.parent = new SlowBuffer(this.length); //如果大于8KB则使用c++的api重新为它分配存储空间,并且将返回的实例赋值给this.parent,这样我们就知道了此buffer实例保存在了哪块内存中
      this.offset = 0; //因为这块内存是此buffer实例第一个独享的,所以记录其偏移为0

    } else if (this.length > 0) { //如果此buffer实例大于0但是小于8KB ,node认为是小的buffer
      // Small buffer.
      if (!pool || pool.length - pool.used < this.length) allocPool();  //如果当前8KB内存池不够存储了,则重新分配一个slowbuffer让此buffer实例存储
      this.parent = pool;  //将当前分配的内存buffer池赋值给 this.parent 方便之后的读取和剪切等
      this.offset = pool.used; //将偏移赋值给此buffer实例
      pool.used += this.length; //并且更新此buffer池的使用byte
      if (pool.used & 7) pool.used = (pool.used + 8) & ~7;
//将pool.used按位与7,如果pool.used是8或8的倍数,则表达式pool.used & 7是false,否则为true
// (pool.used + 8) & ~7; 这里将返回大于pool.used最近的8的倍数
//比如我们pool.used的大小为9byte,则执行这行代码之后,pool.used会调整为16,6byte的内存浪费了,这也是很多文章建议我们申请小于8KB的内存的时候最好是8的倍数,避免造成浪费

    } else { //如果是0长度的buffer,所以所有长度0的buffer都是 var zeroBuffer = new SlowBuffer(0);
      // Zero-length buffer
      this.parent = zeroBuffer;
      this.offset = 0;
    }
   ... //处理一些数据,包括调用wrtie将buffer写入
 SlowBuffer.makeFastBuffer(this.parent, this, this.offset, this.length); 
//最后将这些参数通过slowbuffer的makeFastBuffer将内存地址和js的buffer实例做好关联引用
}

打开node_buffer.h,我们看到声明了Buffer类的static静态成员函数

static v8::Handle<v8::Value> MakeFastBuffer(const v8::Arguments &args);

打开node_buffer.cc,找到MakeFastBuffer的实现:

Handle<Value> Buffer::MakeFastBuffer(const Arguments &args) { 
  HandleScope scope;

  if (!Buffer::HasInstance(args[0])) {
    return ThrowTypeError("First argument must be a Buffer"); //判断第一个函数是否是buffer类的实例
  }

  Buffer *buffer = ObjectWrap::Unwrap<Buffer>(args[0]->ToObject());  //创建一个Buffer指针指向this.parent
  Local<Object> fast_buffer = args[1]->ToObject();; //定义V8的local<object>类型的fast_buffer指向this
  uint32_t offset = args[2]->Uint32Value(); //将偏移量和this.length保存成C++的uint32
  uint32_t length = args[3]->Uint32Value();

  if (offset > buffer->length_) {  //如果偏移量比this.parent总长度还要长,则抛出异常
    return ThrowRangeError("offset out of range");
  }

  if (offset + length > buffer->length_) { //如果偏移量加上本buffer的长度大于this.parent的总长度,则抛出异常
    return ThrowRangeError("length out of range");
  }

  // Check for wraparound. Safe because offset and length are unsigned.
  if (offset + length < offset) { //如果偏移量或者length加起来小于偏移量,则抛出异常,一般不会出现
    return ThrowRangeError("offset or length out of range");
  }

  fast_buffer->SetIndexedPropertiesToExternalArrayData(buffer->data_ + offset,
                                                       kExternalUnsignedByteArray,
                                                       length);
   //最后调用v8接口的SetIndexedPropertiesToExternalArrayData方法将数据与js的对象建立起引用关系

  
  return Undefined();
}

node是通过new char[length]来申请内存空间保存buffer的,调用v8的SetIndexedPropertiesToExternalArrayData来建立引用关系,因为一块8KB的内存可能公用,所以要根据buffer->data_ 和偏移量取得指定的内存地址。C++中buffer的data_属性就是指向这块slowbuffer的指针。 值得注意的是V8的手册上有这样的说明:

Note: The embedding program still owns the data and needs to ensure that the backing store is preserved while V8 has a reference.

总结一下: 1、大家盛传的8KB内存空间池是小buffer的载体,所以如果很悲催的每次保存都是4097byte(略大于4096byte),那么内存可能就会造成很大的浪费,每个4097byte的块都将占用8KB。 2、slowbuffer并不是如字面意思那样,不是慢速的buffer,只是大于8KB的或者当buffer池不够用的情况下会用slowbuffer来申请空间,大于8KB或者直接用new slowbuffer出来的这个空间将是独享的,小于8KB的buffer将会共享内存空间。 3、一定要确保buffer的正确释放,不然可能存在内存泄露。

最后我们做个简单的实验,模拟一个比较严重的内存泄露情况:

var os = require('os');
var leak_buf_ary = [];
var show_memory_usage = function(){ //打印系统空闲内存
	console.log('free mem : ' + Math.ceil(os.freemem()/(1024*1024)) + 'mb');
}

var do_buf_leak = function(){
	var leak_char = 'l'; //泄露的几byte字符
	var loop = 100000;//10万次
	var buf1_ary = []
	while(loop--){
		buf1_ary.push(new Buffer(4096)); //申请buf1,占用4096byte空间,会得到自动释放

		//申请buf2,占用几byte空间,将其引用保存在外部数据,不会自动释放
		//*******
		leak_buf_ary.push(new Buffer(loop+leak_char));
		//*******
	}
	console.log("before gc")
	show_memory_usage();
	buf1_ary = null;
	return;
}


console.log("process start")
show_memory_usage()

do_buf_leak();

var j =10000;
setInterval(function(){
	console.log("after gc")
	show_memory_usage()
},1000*60)

第一次我们将内存泄漏点那行代码注释掉,运行4分钟后,得到如下打印信息,V8已经自动把我分配的内存释放掉了,free men又回到了开始的数值,很遗憾我们无法手动去对buffer进行gc

process start
free mem : 5362mb
before gc
free mem : 5141mb
after gc
free mem : 5163mb
after gc
free mem : 5151mb
after gc
free mem : 5148mb
after gc
free mem : 5556mb

第二次我们将泄漏点那行代码放开,让全局变量 leak_buf_ary 始终引用着buffer,同样执行10分钟我们看结果:

process start
free mem : 5692mb
before gc
free mem : 4882mb
after gc
free mem : 4848mb
after gc
free mem : 4842mb
after gc
free mem : 4843mb
after gc
free mem : 4816mb
after gc
free mem : 4822mb
after gc
free mem : 4816mb
after gc
free mem : 4809mb
after gc
free mem : 4810mb
after gc
free mem : 4831mb
after gc
free mem : 4830mb

虽然我们释放了4096byte的buffer,但是由于那几byte的字节没有释放掉,将会造成整个8KB的内存都无法释放,如果继续执行循环最终我们的系统内存将耗尽,程序将crash。同样由于我们是依次循环分配 4096+几 byte内存的,所以每块8KB的内存空间都将浪费409Xbyte,在执行循环之后,我们明显发现第二次的内存占用比第一次要大很多。这里我们将近多出了300MB左右的内存消耗。

上个贴个图,发现和我一样理解错误的人不在少数,希望博主看见尽快修改,不要误导大众,呵呵 地址:http://blog.csdn.net/jklfjsdj79hiofo/article/details/7873065

6 回复

这难道也是buffer.concat引出的bug。。。呵呵

发了,也挂了,估计是markdown的问题

本地测试木有问题,可能是因为本地网速较快,数据一次就接收完毕了,没有触发多个data事件。

hoho~这个就不清楚啦,我的那篇文章写到cnode里,直接打不开了,不知道什么原因啊

@snoopy 你是发Markdown还是HTML?

回到顶部