Node.js中Buffer的一些实现原理

1.前言

在ES6之前,JavaScript无法直接处理二进制数据,Node.js为了弥补这个不足引入了 Buffer,其是Node.js的核心模块之一,底层实现基于C++。本文将从 Node.js v14.20.0 的源码分析 Buffer 的一些实现原理。

2.ArrayBuffer

在介绍 Buffer 之前,必须花一点时间介绍 ArrayBuffer。

2.1.简介

根据 ECMAScript 6 入门 中的描述,ArrayBuffer 对象、TypedArray 视图和 DataView 视图是 JavaScript 操作二进制数据的一个接口。其设计目的与WebGL有关,为了满足 JavaScript 与显卡之间大量的、实时的数据交换,它们之间的数据通信必须是二进制的,如果不采用二进制,则 JavaScript 脚本与显卡都要进行格式转化,将非常耗时。
二进制数组由三类对象组成:

  • ArrayBuffer对象:代表内存之中的一段二进制数据,可以通过“视图”进行操作。“视图”部署了数组接口,这意味着,可以用数组的方法操作内存。
  • TypedArray视图:共包括 9 种类型的视图,比如Uint8Array(无符号 8 位整数)数组视图, Int16Array(16 位整数)数组视图, Float32Array(32 位浮点数)数组视图等等。
  • DataView视图:可以自定义复合格式的视图,比如第一个字节是 Uint8(无符号 8 位整数)、第二、三个字节是 Int16(16 位整数)、第四个字节开始是 Float32(32 位浮点数)等等,此外还可以自定义字节序。

简单来说,ArrayBuffer对象代表原始的二进制数据,TypedArray视图用来读写简单类型的二进制数据,DataView视图用来读写复杂类型的二进制数据。

2.2.使用

ArrayBuffer有两种使用方式——通过DataVIew 和 通过 TypedArray。

2.2.1.通过DataVIew

通过 DataVIew 使用 ArrayBuffer 参考以下代码:

const buf = new ArrayBuffer(32)
const dataView = new DataView(buf)
dataView.getUint8(0) // 0

2.2.1.通过TypedArray

通过 TypedArray使用 ArrayBuffer 参考以下代码:

const buf1 = new ArrayBuffer(10)
const x1 = new Uint8Array(buf1)
x1[0]  = 123

TypedArray的构造函数主要有两种:

  1. TypedArray(buffer, byteOffset = 0, length?):在同一个ArrayBuffer对象之上,可以根据不同的数据类型,建立多个视图,其中三个参数分别是:视图对应的底层ArrayBuffer对象;视图开始的字节序号,默认从 0 开始;视图包含的数据个数,默认直到本段内存区域结束。
  2. TypedArray(length):不通过ArrayBuffer对象,直接分配内存而生成。

如果已经有了一个 ArrayBuffer 对象,使用第一个构造函数明显会比较高效一些,请记住这点,后续会用到。

3.Buffer

3.1.简介

Buffer 实例也是 Uint8Array 实例, Uint8Array则是 TypedArray 的子类。 因此,所有 TypedArray 的方法在 Buffer 上也可用。 但是 Buffer 的 API 和 TypedArray 的 API 之间存在细微的不兼容。
Buffer和ArrayBuffer都用于处理二进制数据,但它们有以下区别:

  • 实现方式不同:Buffer是Node.js的核心模块,实现方式基于C++,性能非常高;而ArrayBuffer是JavaScript的内置对象,由JavaScript虚拟机提供支持。
  • 可读性不同:在通过控制台打印时,Buffer打印的是十六进制格式的数据,不够直观;而ArrayBuffer则更容易理解。
  • 支持功能不同:Buffer提供了一系列API用于操作二进制数据,如截取、转化、比较、拷贝等;而ArrayBuffer提供的功能相对简单,需要借助TypedArray和DataView来进行操作。

3.2.实例化

在 Node.js v6 之前都是通过调用构造函数的方式实例化 Buffer,根据参数返回不同结果。处于安全性原因,这种方式在 v6 后的版本中已经被废除,现在提供了四个职责清晰的函数处理实例化 Buffer 的工作。
实例化buffer涉及到多个函数和类,其之间的调用关系如图:

Node.js中Buffer的一些实现原理

注意,上图只是调用关系,而非实际流程,比如 Buffer.alloc 方法调用 createUnsafeBuffer 之后还会对创建的 Buffer 对象进行填充。

3.2.1.Buffer.from

Buffer.from 支持四种参数类型:

  • Buffer.from(string [, encoding]):返回一个包含给定字符串的 Buffer。
  • Buffer.from(buffer):返回给定 Buffer 的一个副本 Buffer。
  • Buffer.from(array):返回一个内容包含所提供的字节副本的 Buffer,数组中每一项是一个表示八位字节的数字,所以值必须在 0 ~ 255 之间,否则会取模。
  • Buffer.from(arrayBuffer):返回一个与给定的 ArrayBuffer 共享内存的新 Buffer。
  • Buffer.from(object[, offsetOrEncoding[, length]]):取 object 的 valueOf 或 Symbol.toPrimitive 初始化 Buffer。

具体细节参考 Buffer静态方法
例如:

const buf1 = Buffer.from('test', 'utf-8'); // <Buffer 74 65 73 74>
const buf3 = Buffer.from([256, 2, 3]); // <Buffer 00 02 03>

当输入一个大于255或小于0时,会进行mod 256运算,mod 256 表示将一个数除以256后取余数的操作,因为单个字节的取值范围是 0 ~ 255,因此可以利用 mod 256 运算,将其转化为 0 ~ 255 的范围内的值。
对应源码:

// lib/buffer.js
Buffer.from = function from(value, encodingOrOffset, length) {
  if (typeof value === 'string')
    return fromString(value, encodingOrOffset);

  if (typeof value === 'object' && value !== null) {
    if (isAnyArrayBuffer(value))
      return fromArrayBuffer(value, encodingOrOffset, length);

    const valueOf = value.valueOf && value.valueOf();
    if (valueOf != null &&
        valueOf !== value &&
        (typeof valueOf === 'string' || typeof valueOf === 'object')) {
      return from(valueOf, encodingOrOffset, length);
    }

    const b = fromObject(value);
    if (b)
      return b;

    if (typeof value[SymbolToPrimitive] === 'function') {
      const primitive = value[SymbolToPrimitive]('string');
      if (typeof primitive === 'string') {
        return fromString(primitive, encodingOrOffset);
      }
    }
  }

  throw new ERR_INVALID_ARG_TYPE(
    'first argument',
    ['string', 'Buffer', 'ArrayBuffer', 'Array', 'Array-like Object'],
    value
  );
};

各种from函数最终都是调用FastBuffer,FastBuffer 继承自 Uint8Array,FastBuffer 会在后续介绍。

3.2.2.Buffer.alloc

使用 Buffer.alloc(size [, fill [, encoding]]) 分配一个大小为 size 字节的新 Buffer,如果 fill 为 undefined,则用 0 填充 Buffer,参数含义如下:

  • size:integer,表示新 Buffer 的所需长度。
  • fill:string | Buffer | Uint8Array | integer,用于预填充新 Buffer 的值,默认值: 0。
  • encoding:string,如果 fill 是一个字符串,则这是它的字符编码,默认值: utf8。

例如:

const buf1 = Buffer.alloc(5);
console.log('buf1', buf1); // <Buffer 00 00 00 00 00>

const buf2 = Buffer.alloc(5, 'a');
console.log('buf2', buf2); // <Buffer 61 61 61 61 61>

对应源码:

// lib/buffer.js
Buffer.alloc = function alloc(size, fill, encoding) {
  assertSize(size);
  if (fill !== undefined && fill !== 0 && size > 0) {
    const buf = createUnsafeBuffer(size);
    return _fill(buf, fill, 0, buf.length, encoding); // _fill方法用于填充 Buffer
  }
  return new FastBuffer(size);
};

关于 createUnsafeBuffer 的具体细节,也会在后面介绍。

3.2.3.Buffer.allocUnsafe(size)

分配一个大小为 size 字节的新 Buffer,allocUnsafe 执行速度比 alloc 快,因为理想情况下 allocUnsafe 是从已有的ArrayBuffer中分配内存而不是直接创建Buffer对象,但由于它不会清空所分配的内存,可能包含敏感数据的残留,例如:

// allocUnsafe不安全示例
const buf3 = Buffer.allocUnsafe(20);
buf3.write('my secret information!');
console.log('buf3', buf3);

const buf4 = Buffer.allocUnsafe(20);
console.log('buf4', buf4); // buf4可能包含了一些数据

对应源码:

// lib/buffer.js
Buffer.allocUnsafe = function allocUnsafe(size) {
  assertSize(size);
  return allocate(size);
};

关于 allocate 函数的具体细节,也会在后面介绍。

3.2.4.Buffer.allocUnsafeSlow(size)

allocUnsafeSlow 是直接通过 createUnsafeBuffer 先创建的内存区域,对应源码:

Buffer.allocUnsafeSlow = function allocUnsafeSlow(size) {
  assertSize(size);
  return createUnsafeBuffer(size);
};

3.3.关键实现

上文遗留了三个关键的类/方法:FastBuffer类、createUnsafeBuffer方法、allocate方法,本节将介绍这三个关键类/方法的实现。

3.3.1.FastBuffer

FastBuffer 继承自 Uint8Array,也就是说FastBuffer 也是 TypedArray:

// lib/internal/buffer.js
class FastBuffer extends Uint8Array {
  constructor(bufferOrLength, byteOffset, length) {
    super(bufferOrLength, byteOffset, length);
  }
}

3.3.2.createUnsafeBuffer

上面实例化的几种方式或多或少都用到了 createUnsafeBuffer,其源码如下:

// lib/buffer.js
const zeroFill = bindingZeroFill || [0];

function createUnsafeBuffer(size) {
  zeroFill[0] = 0;
  try {
    return new FastBuffer(size);
  } finally {
    zeroFill[0] = 1;
  }
}

createUnsafeBuffe r内部在实例化 FastBuffer 之前,将 zeroFill[0] 设置为 0, createUnsafeBuffer 与直接实例化一个 FastBuffer 对象的区别就在于此。
zeroFill[0] 是控制零填充行为的开关,zeroFill[0] 为 0 时关闭零填充, zeroFill[0] 为 1 时打开零填充。当开关关闭时,不对新实例化的 Buffer 对象进行默认填充 0,因此可能包含敏感数据的残留,所以说 createUnsafeBuffer 是不安全的。

3.3.3.allocate

allocate源码如下:

// lib/buffer.js
Buffer.poolSize = 8 * 1024;

// allocPool实际就是一个ArrayBuffer对象,当满足一定条件时,会通过这个ArrayBuffer生成新的FastBuffer
// 这三个变量分别表示 分配池的大小、分配池的位移量、分配池本身
let poolSize, poolOffset, allocPool; 

function createPool() {
  poolSize = Buffer.poolSize;
  // createUnsafeBuffer返回一个FastBuffer实例,也就是一个TypedArray实例
  // TypedArray实例的buffer属性指向原始的ArrayBuffer对象
  // 也就是说allocPool是一个ArrayBuffer对象
  allocPool = createUnsafeBuffer(poolSize).buffer;
  markAsUntransferable(allocPool);
  poolOffset = 0;
}

// lib/buffer.js加载时就会申请一个 8KB 的内存空间
createPool();

// ...

function allocate(size) {
  if (size <= 0) {
    return new FastBuffer(); // size小于0是非法情况,返回一个空的FastBuffer对象避免程序出错
  }
  
  // >>> 是一个无符号右移运算符,右移一位相当于除以2
  // 这里是判断需要分配的内存大小是否小于对象池大小的一半,即是否小于4kb
  if (size < (Buffer.poolSize >>> 1)) {
    // 这里判断需要分配的内存大小是否大于对象池中剩余空间的大小
    // 当大于成立时,对象池已经没有足够的空间存储 size 大小的数据,会调用 createPool() 函数创建一个新的分配池
    if (size > (poolSize - poolOffset))
      createPool(); // createPool()执行完成之后,allocPool指向新的分配池
    const b = new FastBuffer(allocPool, poolOffset, size); // 如果没有创建新的分配池,则会复用之前的分配池来创建新的Buffer
    poolOffset += size; // 记录偏移量
    alignPool(); // 将池的偏移量对齐到 8 字节的倍数
    return b;
  }
  
  // 如果需要分配的内存大小不小于分配池大小的一半,则直接使用 createUnsafeBuffer() 函数创建一个新的非安全 Buffer 对象
  // 这样做的原因是:如果分配的内存比对象池中剩余的内存块还要大,就需要额外分配一块新的内存来创建 Buffer 对象。如果这类操作频繁发生,就会导致内存的浪费
  return createUnsafeBuffer(size);
}

allocate 方法的核心思想是 Slab 分配机制。

3.3.3.1.Slab 分配机制

allocate使用allocPool的动机是想更快的创建Buffer对象,为此,Node采用了 Slab 分配机制分配内存,Slab 是一种动态内存管理机制,其基本原理是先申请好一块固定大小的内存区域,当需要分配内存时,就从这个实现申请好的内存区域中划分一块区域出来存储对应的数据,这做可以避免频繁申请内存造成的开销。
具体到Node中,就是基于已有的ArrayBuffer对象(allocPool)创建TypedArray对象(FastBuffer),但是具体的实现要复杂一些。
理想情况下,allocPool应该得到充分利用,即allocPool中保存的Buffer对象都紧密的挨在一起,将8kb的内存空间完全利用,但实际情况是需要创建的Buffer对象有大有小,经过多次分配之后,allocPool的剩余空间不足以再保存新的Buffer对象,此时就需要申请新的内存空间(创建新的ArrayBuffer对象),原来的剩余空间就被浪费掉了,如果申请新空间的操作越频繁,被浪费的空间就越多,并且申请新空间是需要开销的。
为解决这个问题,在 allocate 中以4kb为界,当待分配内存小于4kb时,allocUnsafe 并没有直接创建新的Buffer,而是从allocPool中分配一块区域来保存Buffer,如果剩余空间不够,再开辟一块新的空间用来保存Buffer;但当分配的内存大于等于4kb时,allocUnsafe会直接返回FastBuffer,而不是基于已有的allocPool。
借用《深入浅出Node.js》中的话:

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

4.参考

原文链接:https://juejin.cn/post/7236682185964191801 作者:wopelo

(0)
上一篇 2023年5月25日 上午10:44
下一篇 2023年5月25日 上午10:56

相关推荐

发表回复

登录后才能评论