Erlang数据类型的表示和实现——binary
Posted erlang collect
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Erlang数据类型的表示和实现——binary相关的知识,希望对你有一定的参考价值。
binary 是 Erlang 中一个具有特色的数据结构,用于处理大块的“原始的”字节块。如果没有 binary 这种数据类型,在 Erlang 中处理字节流的话可能还需要像列表或元组这样的数据结构。根据之前对这些数据结构 Eterm 的描述,数据块中的每一个字节都需要一个或两个机器字来表达,明显空间利用率低,因此 binary 是一种空间高效的表示形式。
在 binary 对字节序列处理能力的基础上,Erlang 进一步泛化 binary 的功能,提供了 bitstring 数据结构,让开发者能打破字节的边界,能在 bit 层面上操作原始数据块。bitstring 的 bit 层次的模式匹配功能特别适用于网络编程中网络协议数据包的解析和文件解析等操作。
本文从实际的需求出发,从简单到复杂,逐步讨论 Erlang 中 binary 和 bitstring 的实现及优化。本文会介绍 binary 相关数据结构的 Eterm 以及在 Erlang 虚拟机内部的表达形式,并结合具体的示例程序和编译器生成的 beam 字节码及对应的虚拟机代码讨论 Erlang 对 binary 和 bitstring 的操作所做的优化。
下面首先讨论最简单的 binary —— heap binary。
heap binary
heap binary 是直接放在进程堆中的 binary,也就是说整个 binary 的数据都在进程堆中,就好像其他 boxed 数据结构一样。下图展示了 heap binary 在堆中的表现形式。
由于 heap binary 直接放在堆中,属于“小”数据,进程间要发送这种 binary 消息的时候会涉及到复制,因此目前 Erlang 虚拟机代码中将 heap binary 大小限定为 64 字节。在创建 binary 的时候,如果事先确定 binary 中数据字节数小于等于 64 字节,那么 Erlang 虚拟机就会选择在堆中直接创建 heap binary。
如果在创建 binary 的时候,确定 binary 中数据的字节数大于 64 字节,那么 Erlang 虚拟机就会创建 refc binary。
refc binary
refc binary 是保存在 Erlang 虚拟机内存中,所有 Erlang 进程堆之外的区域中的 binary。Erlang 进程之间可以共享这种 binary。当一个 Erlang 进程给另一个 Erlang 进程发送这种 binary 的时候,理想情况下只需要发送一个引用即可,因此可以避免复制的开销和内存的开销。由于这种 binary 是可以被多个进程共享的,因此为了跟踪这种 binary 的使用,Erlang 虚拟机采用了引用计数的方式,因此这种 binary 得名为 refc binary,即 reference-counted binary 的意思。
由于 refc binary 是共享的,所以需要通过两个部分描述,一个部分是 binary 数据本身,另一个部分是对 binary 数据的引用,即 Erlang 进程堆中的 ProcBin 对象。下图展示了 ProcBin 和 Binary 对象之间的关系。
从图中可以看出,ProcBin 对象是在进程堆中的 boxed 对象,并且通过 next 指针串联起来了。进程控制块中有一个 ErlOffHeap 数据,这个数据是进程所有 off-heap(即“堆外”)数据的链表的头,first 指向第一个 ProcBin。目前 Erlang 进程只有 refc binary 这一种 off-heap 数据,不过以后有可能会有更多类型的 off-heap 数据类型,因为 off-heap 这种名称看上去很 general。ErlOffHeap 数据还有一个字段 overhead,这个字段记录了所有 off-heap 数据大小的总和,这个值会用于垃圾回收,如果这个 overhead 超过了进程的 vheap(虚拟堆)限制,则会进行垃圾回收。vheap 也是一个 general 的概念,尽管目前仅用于 binary。有关 vheap 的初始化、增长和对垃圾回收的控制,请参阅这篇博文[注5]。另外提一下,在 Erlang 虚拟机的代码中,有一个宏 MSO,经常能看到类似 MSO(c_p).overhead 和 MSO(c_p).first 这样的调用,MSO(c_p) 宏实际上获得的就是当前进程 c_p 的 ErlOffHeap 数据。估计 MSO 是 memory shared object 的简写吧,共享内存对象和 off-heap 对象应该是同一个意思。
ProcBin 对象的第一个字就是标准的 boxed 对象头。接下来的 size 表示 ProcBin 指向的 Binary 对象的实际字节数,next 指向进程中的下一个 ProcBin,val 指向共享内存区域中的实际 Binary 对象,bytes 则指向实际 Binary 对象中的真正的数据块。flags 是 ProcBin 相关的标志位,和 binary 操作的优化有关,后面会详细解释。
再来看 Binary 对象。Binary 对象完整地包含了 binary 实际要表达的数据,因此是一个可变大小的对象,实际数据在 orig_bytes 数组中。orig_size 表示 orig_bytes 数组中的字节数。refc 则是引用计数,初始化一个 refc binary 的时候对应的 Binary 的 refc 初始化为 1。refc 降为 0 的时候表示可以回收。由于 Binary 可以被多个进程访问,因此 refc 在 SMP 版本的 Erlang 虚拟机上是一个原子变量。Binary 的标志位 flags 主要由 Erlang 虚拟机中其他部分使用(标记 Binary 本身的不同类型,可以将 Binary 理解为“基类”,其他类型的 Binary 可能会添加一些特殊的功能,例如 ErtsMagicBinary 中添加了特殊的“析构函数”。),和 binary 本身操作无关,因此本文不详细讨论。
上面我们讨论了两种包含真实数据的 binary:heap 和 refc binary,这两种 binary 都是容器,分别对应了 boxed 对象的 header 标签 1000 和 1001。除此之外,我们还可以在 header 标签的列表中看到另外两种和 binary 相关的 header:表示 binary 匹配状态的 0001,以及表示 sub binary 的 1010。这两种 binary 对象本身并不包含实际的 binary 数据,而是引用其他 binary 中的部分数据。下面我们先看 sub binary。此外,bitstring 的实现也和 sub binary 有关。
sub binary 和 bitstring
sub binary 就是子 binary,表示 binary 中的部分内容。比如我们调用 BIF split_binary/2 的时候,如果参数正确,会将原来的 binary 分割为两个部分,得到两个 binary。这个 BIF 调用当然可以创建两个新的 heap 或 refc binary,然后分别将对应的数据复制到两个新的 binary。由于 Erlang 中的变量都是 immutable 的,所以我们可以认为一个 binary 在创建了之后不会被修改。因此,这种创建新 binary 并复制的操作显然是低效且浪费空间的。
为了在分割 binary 的时候能复用原有的数据,Erlang 虚拟机内部引入了 sub binary 类型(Erlang 程序员在 Erlang 语言的层面感知不到)。Erlang 的 split_binary/2 调用生成的就是两个 sub binary,然后将这两个 sub binary 放在一个二元组中返回给调用者。下图展示了表示 sub binary 的 boxed 对象的结构,即图中的 ErlSubBin 部分,并展示了 split_binary/2 对一个 refc binary 操作之后的示例结果。
我们首先看 sub binary 的结构,在 Erlang 虚拟机代码中定义如下:
1 typedef struct erl_sub_bin {
2 Eterm thing_word; /* Subtag SUB_BINARY_SUBTAG. */
3 Uint size; /* Binary size in bytes. */
4 Uint offs; /* Offset into original binary. */
5 byte bitsize;
6 byte bitoffs;
7 byte is_writable; /* The underlying binary is writable */
8 Eterm orig; /* Original binary (REFC or HEAP binary). */
9 } ErlSubBin;
和其他 boxed 对象的结构体一样,thing_word 是 boxed 对象的 header。size 表示这个 sub binary 引用的数据的大小,offs 表示这个 sub binary 引用的具体 binary 中的偏移值。bitsize 和 bitoffs 和 bistring 有关,后面会详细描述。is_writable 表示引用的那个 binary 是否可写,这个值和 binary 的拼接优化有关,也会在后面详细描述。orig 则指向原始的那个带有具体数据的 binary(既可以是 heap binary 也可以是 refc binary)。
在上面的图中,我们可以看到新生成的两个 sub binary,这两个 sub binary 的 offs 分别指向 被引用的 refc binary 的偏移位置 0 和 pos 处,两段的大小分别为 size 1 和 size2,然后这两个的 orig 均指向堆中原来的那个 refc。值得注意的是,我们可以看到底层 Binary 中的数据块被分为 3 段,还有一个大小为 size3 的段没有被引用,但是 Binary 自己的 orig_size 则记录了这个大小。这说明底层 Binary 本身的大小可能会大于 ProcBin 中记录的大小,因为底层的 Binary 可以执行类似“预分配”的优化,后面会详细讨论这种优化。
sub binary 还有一个重要的用途,就是支持 bitstring。bitstring 在 Erlang 虚拟机内部实际上也是靠 binary 支撑的,即实际数据都保存在 Binary 对象(或 heap binary)中。对于 Erlang 程序员来说,在操作 binary 的时候,实际上也不必太操心 binary 里面的位元数是否能被 8 整除,Erlang 虚拟机能在后台处理好各种情况。比如说 X = <<A:2, B:6>> 和 Y = <<A:3, B:6>>,A 和 B 都是整数,那么 X 是一个 binary,而 Y 称作是一个 bitstring。这两个对象在 Erlang 虚拟机内部表示是不同的。对于 Y 来说,为了能显示出“额外的”那个位元,Erlang 虚拟机内部就要使用 sub binary 了。下面通过例子来看两者的区别。先看能被 8 整除的情况:
1 bs_creator_bytes(X, Y) ->
2 <<X:500, Y:20>>.
为了让例子更复杂一些,我们这里选择创建 refc binary,520 个位刚好是 65 字节,Erlang 虚拟机会选择创建 refc binary。
然后再看不能被 8 整除的情况:
1 bs_creator_bits(X, Y) ->
2 <<X:500, Y:13>>.
这段代码要创建的是一个包含 513 个位的 binary,513 不能被 8 整除,所以这段代码创建的实际上是 bitstring。
下面我们从 Erlang 汇编代码[注6]的角度来看编译器对这两种情况的处理:
上图中的上下两块 beam 汇编代码分别是编译器为 bs_creator_bytes 和 bs_creator_bits 生成的代码(不用担心不能理解这里汇编代码的细节)。可以看出这两个函数实际上有两类步骤:第一步是初始化一个用于创建 binary 或 bitstring 的上下文,第二步是在之前创建的上下文中填入传入函数的整数。
两个函数代码的区别在于第一步的指令:bs_init2 和 bs_init_bits,从名字也能看出来,后面这条指令和 bit 有关。
先看 bs_init2 指令,从上图中可以看到这个指令接受的第二个参数 65 表示要创建的字节数。erts/emulator/beam/beam_emu.c 文件的 process_main() 函数是 Erlang 虚拟机的代码执行逻辑,从中可以看到这条指令执行的操作:
在共享内存中创建一个新的 Binary 对象,将 Binary 对象缓冲区大小设置为传入的大小,即 65
在进程的堆上创建一个新的 ProcBin 对象,新的 ProcBin 指向刚创建的 Binary,大小也设置为 65
返回对应刚创建 ProcBin 对象的 Eterm(上图中的例子将结果 Eterm 放在寄存器 x[2] 中)
接下来再看 bs_init_bits 指令,这条指令接受的第二个参数 513 表示要创建的 bitstring 的位元数,的执行逻辑:
根据传入指令的所需位数,计算出保存这么多位元所需要的字节数,例如 513 个位元需要占用 65 个字节
由于所需字节数超过 64,所以要创建 refc binary。即先在共享内存中创建一个 Binary 对象,缓冲区大小设置为上一步计算出来所需的字节数
在进程的堆上创建对应的 ProcBin
在进程的堆上创建一个 ErlSubBin,这一步是区分 binary 和 bitstring 的关键,结合之前列出的 ErlSubBin 字段描述,下面是各个字段的取值:
size:Binary 所需字节数 - 1,因为在底层的 Binary 中最后一个字节并不是完整的,只需要使用其中的几个位。在这个例子中为 64 字节,第 65 字节只使用 1 个位
offs:0,因为这是新创建的 sub binary
bitsize:Binary 中最后那个字节中使用到的位元数,在这个例子中为 1
bitoffs:依然是 0
返回对应刚创建的 ErlSubBin 对象的 Eterm(在上图中的例子中将结果 Eterm 放在寄存器 x[2] 中)
从上面的描述中,我们可以看出 binary 和 bitstring 在内部表达上的区别了,即 binary 可以直接通过 heap binary 或 refc binary 表示,而 bitstring 则需要通过 sub binary 来表示。在 Erlang 语言的层面看不出这个区别,这只是底层 Erlang 虚拟机在实现上的区别。Erlang 虚拟机的代码在处理 binary 的时候,会首先根据代表这些不同类型的 binary 的 Eterm(即 boxed header)来判断具体的类型并采取相应的操作。
不论是 binary 还是 bitstring,底层的 Binary 对象都能保证有足够大的缓冲区能支持后续的填写操作。因此第二个步骤就简单了。我们可以看出两个函数的第二个步骤都是两条 bs_put_integer 指令。bs_put_integer 这条指令主要看第二个参数(例如 {integer,500}) 和最后一个参数(例如 {x,0})。前一个参数告诉这条指令要在当前上下文中填入一个整数,这个整数长度为 500 位元。后一个参数告诉这条指令要填写的整数来源于 x[0] 寄存器。
从图中可以看出,两个函数第二个步骤的两条指令除了参数之外都是一模一样的,说明 bs_put_integer(以及其他类似功能的 bs_put_xxx 指令)都不用管写入的是 binary 还是 bitstring,这些指令都假设之前已经创建好并准备好了可以写入的上下文。
前面也多次提到了这个“上下文”,那么这个上下文到底是什么?后面在讨论匹配的时候也会提到这个“上下文”。上下文其实就是一个包含各种全局状态的环境,我们仔细看上面图中的汇编码,可以发现尽管第一步的指令,例如 bs_init_bits 最后将结果 ErlSubBin 对应的 Eterm 放在寄存器 x[2] 中返回,但是后面的 bs_put_integer 指令并没有用到这个寄存器作为参数。此外,bs_input_integer 指令也没有传入任何表示要在哪里开始写入的参数。从这两点我们可以看出,针对新创建的这个 bitstring 或 binary,至少一组全局状态用于表示缓冲区的位置以及当前写入的位置等,就好像文件描述符这样的东西。
事实上,的确有这样一组全局状态,也就是很多文档里面提到的“上下文”,在 Erlang 虚拟机中用 struct erl_bits_state 表示这个上下文(定义在 erts/emulator/beam/erl_bits.h 头文件中)。在 SMP 的虚拟机中,每一个调度器线程都有一个私有的这样的全局状态。bs_init2 和 bs_init_bits 指令都会初始化这个状态,然后之后的 bs_put_xxx 之类的指令可以通过这个全局状态得到缓冲区的位置以及要写入的位置,写完之后更新其中表示偏移量的字段。偏移量字段以位元为粒度。
上面已经介绍了 3 种和 binary 相关的数据结构:heap binary、refc binary 和 sub binary,还介绍了通过类似 <<Z:8, S:16, Y:20>> 这样的语法构造 binary 或 bitstring Erlang 虚拟机内部进行的操作。
binary 还可以通过向一个已有 binary 追加其他数据的方式进行构造。下面介绍 Erlang 虚拟机对这种构造方式的优化。
binary 追加构造的优化
通过类似 B1 = <<B0/binary, 1, 2>> 的语句,可以在 binary B0 之后追加 1,2 两个字节构造新的 binary 并保存在 B1 中。Erlang 虚拟机实现这种追加构造的最简单方法是先创建出有足够空间的 B1,然后将 B0 的数据和 1,2 两个字节一起复制到新创建的 B1。在 Erlang 中,如果编写类似 list_to_binary 的函数,每次处理 list 中的一个元素并追加到结果 binary 的尾部,那么这种模式的函数在上述机制下(每一次都要复制并创建新的 binary)效率会非常低下。结合之前描述的 refc binary 和 sub binary 的结构,我们自然会想到聪明的 Erlang 必然会有针对追加构造 binary 进行的优化。
Erlang 果然不会辜负我们的期望,在“效率指南”中关于 binary 构造和匹配的部分[注7]介绍了 Erlang 虚拟机对 binary 追加构造进行的优化。
我们先看一下效率指南中的例子:
1 Bin0 = <<0>>, %% 1
2 Bin1 = <<Bin0/binary,1,2,3>>, %% 2
3 Bin2 = <<Bin1/binary,4,5,6>>, %% 3
4 Bin3 = <<Bin2/binary,7,8,9>>, %% 4
5 Bin4 = <<Bin1/binary,17>>, %% 5 !!!
6 {Bin4,Bin3} %% 6
根据效率指南的描述,第 1 行创建一个 heap binary。第 2 行是 Bin0 第一次被追加内容,所以会创建一个新的 refc binary,并且将 Bin0 的内容复制到其中,不仅如此,新的 refc binary 底层的 Binary 对象还预留了 256 字节的空间。然后字节 1,2,3 会被追加到后面,得到 Bin1。第 3 行的时候就可以利用上面这个优化,直接把 4,5,6 字节放在预留的空间中。第 4 行是和第 3 行是一样的,7,8,9 字节放在以上预留空间的 4,5,6 之后。到第 5 行的时候就要注意了,Erlang 虚拟机肯定不能直接把 17 字节放在 Bin1 的后面,要不然 Bin2 里面的内容就要被覆盖了,虚拟机里面再怎么优化,也不能破坏语言本身提供给用户的语义,因此虚拟机能够通过某种机制发现这一点,将 Bin1 复制到新的 refc binary 中,然后剩下的过程就和上面的优化过程是一样的了。
尽管在语言层面这些优化都是透明的,但是下面通过简单的实验可以看出一点这种优化的迹象:
1 do_append_test_verbose() ->
2 io:format("in do_append_test_verbose~n"),
3 Bin0 = <<0>>,
4 io:format("Bin0 = ~p, ~p~n", [Bin0, erts_debug:get_internal_state({binary_info, Bin0})]),
5 append_test_verbose(Bin0).
6
7 append_test_verbose(Bin0) ->
8 io:format("in append_test_verbose~n"),
9 io:format("Bin0 = ~p, ~p~n", [Bin0, erts_debug:get_internal_state({binary_info, Bin0})]),
10 Bin1 = <<Bin0/binary,1,2,3>>,
11 io:format("Bin0 = ~p, ~p~n", [Bin0, erts_debug:get_internal_state({binary_info, Bin0})]),
12 io:format("Bin1 = ~p, ~p~n", [Bin1, erts_debug:get_internal_state({binary_info, Bin1})]),
13 {Bin0,Bin1}.
这段程序很简单,append_test_verbose/1 接受 Bin0 作为参数,先打印出 Bin0 以及 Bin0 的内部信息,然后再追加 Bin0 得到 Bin1,再打印出 Bin0 和 Bin1 的内部信息。打印内部信息的 erts_debug:get_internal_state/1 是一个未公开的调用,参见这篇博文[注8]。为了测试这个函数,必须再写一个测试函数 do_append_test_verbose/0,然后在 shell 里面调用 do_append_test_verbose/0,而不要直接在 shell 中调用 append_test_verbose/1(如果在 shell 中调用,Bin0 总是 refc binary,这应该是和 shell 的机制有关)。注意:之所以要单独列出一个函数以 Bin0 作为参数传入而不是像效率指南中那样直接写 Bin0 = <<0>>,是为了避免编译器做优化,直接把 Bin1 给计算出来了。
好了,在 shell 中调用 do_append_test_verbose(),得到以下输出:
328
> bin_test:do_append_test_verbose().
以上是关于Erlang数据类型的表示和实现——binary的主要内容,如果未能解决你的问题,请参考以下文章 |