查看源代码 构建和匹配二进制数据

本节提供了一些关于如何高效处理二进制数据的示例。接下来的章节将深入探讨二进制数据的实现方式,以及如何最好地利用编译器和运行时系统所做的优化。

可以通过以下方式高效地构建二进制数据

推荐做法

my_list_to_binary(List) ->
    my_list_to_binary(List, <<>>).

my_list_to_binary([H|T], Acc) ->
    my_list_to_binary(T, <<Acc/binary,H>>);
my_list_to_binary([], Acc) ->
    Acc.

像示例中那样将数据追加到二进制数据是高效的,因为运行时系统对其进行了特殊优化,以避免每次都复制 Acc 二进制数据。

在循环中将数据前置到二进制数据不是高效的

不推荐做法

rev_list_to_binary(List) ->
    rev_list_to_binary(List, <<>>).

rev_list_to_binary([H|T], Acc) ->
    rev_list_to_binary(T, <<H,Acc/binary>>);
rev_list_to_binary([], Acc) ->
    Acc.

对于长列表,这种方式效率不高,因为每次都会复制 Acc 二进制数据。一种使函数更高效的方法是这样:

不推荐做法

rev_list_to_binary(List) ->
    rev_list_to_binary(lists:reverse(List), <<>>).

rev_list_to_binary([H|T], Acc) ->
    rev_list_to_binary(T, <<Acc/binary,H>>);
rev_list_to_binary([], Acc) ->
    Acc.

另一种避免每次复制二进制数据的方法是这样:

推荐做法

rev_list_to_binary([H|T]) ->
    RevTail = rev_list_to_binary(T),
    <<RevTail/binary,H>>;
rev_list_to_binary([]) ->
    <<>>.

请注意,在每个推荐做法示例中,要追加到的二进制数据始终作为第一个段给出。

可以通过以下方式高效地匹配二进制数据

推荐做法

my_binary_to_list(<<H,T/binary>>) ->
    [H|my_binary_to_list(T)];
my_binary_to_list(<<>>) -> [].

二进制数据的实现方式

在内部,二进制数据和位串以相同的方式实现。在本节中,它们被称为二进制数据,因为这是它们在模拟器源代码中被调用的名称。

内部有四种类型的二进制对象可用

  • 两种是二进制数据的容器,被称为

    • 引用计数二进制数据 (简称 Refc 二进制数据)
    • 堆二进制数据
  • 两种只是对二进制数据的一部分的引用,被称为

    • 子二进制数据
    • 匹配上下文

变更

在 Erlang/OTP 27 中,二进制数据和位串的处理被重写。为了充分利用运行时系统中的这些更改,需要更新编译器,这计划在未来的版本中进行。

由于从效率和优化的角度来看,实际上没有太大变化,因此以下描述尚未更新以描述 Erlang/OTP 27 中的实现。

引用计数二进制数据

引用计数二进制数据由两部分组成

  • 存储在进程堆上的对象,称为 ProcBin
  • 二进制对象本身,存储在所有进程堆之外

二进制对象可以被来自任意数量进程的任意数量的 ProcBin 引用。该对象包含一个引用计数器,用于跟踪引用数量,以便在最后一个引用消失时将其删除。

进程中的所有 ProcBin 对象都是链表的一部分,以便垃圾回收器可以跟踪它们并在 ProcBin 消失时递减二进制数据中的引用计数器。

堆二进制数据

堆二进制数据是小型二进制数据,最多 64 字节,直接存储在进程堆上。当进程进行垃圾回收以及作为消息发送时,它们会被复制。它们不需要垃圾回收器进行任何特殊处理。

子二进制数据

引用对象子二进制数据匹配上下文可以引用引用计数二进制数据或堆二进制数据的一部分。

子二进制数据是通过 split_binary/2 创建的,并且当二进制数据在二进制模式中匹配出来时也会创建。子二进制数据是对另一个二进制数据(引用计数二进制数据或堆二进制数据,但从不引用另一个子二进制数据)的一部分的引用。因此,匹配出一个二进制数据的成本相对较低,因为实际的二进制数据永远不会被复制。

匹配上下文

匹配上下文类似于子二进制数据,但针对二进制数据匹配进行了优化。例如,它包含指向二进制数据的直接指针。对于从二进制数据中匹配出来的每个字段,匹配上下文中的位置都会递增。

编译器会尝试避免生成创建子二进制数据的代码,只是为了在短时间后创建一个新的匹配上下文并丢弃子二进制数据。编译器会保留匹配上下文,而不是创建子二进制数据。

只有当编译器知道匹配上下文不会被共享时,它才能进行这种优化。如果它被共享,Erlang 的函数式属性(也称为引用透明性)将会被破坏。

构建二进制数据

以下列方式追加到二进制数据或位串的操作经过特殊优化,以避免复制二进制数据

<<Binary/binary, ...>>
%% - OR -
<<Binary/bitstring, ...>>

运行时系统以一种使其在大多数情况下都有效的方式应用此优化(对于例外情况,请参阅强制复制的情况)。其基本形式的优化不需要编译器的任何帮助。但是,当编译器确定可以更高效地应用优化时,编译器会向运行时系统添加提示。

变更

Erlang/OTP 26 中添加了使优化更高效的编译器支持。

为了解释基本优化的工作原理,让我们逐行检查以下代码

Bin0 = <<0>>,                    %% 1
Bin1 = <<Bin0/binary,1,2,3>>,    %% 2
Bin2 = <<Bin1/binary,4,5,6>>,    %% 3
Bin3 = <<Bin2/binary,7,8,9>>,    %% 4
Bin4 = <<Bin1/binary,17>>,       %% 5 !!!
{Bin4,Bin3}                      %% 6
  • 第 1 行(标记为 %% 1 注释)将一个堆二进制数据分配给 Bin0 变量。

  • 第 2 行是一个追加操作。由于 Bin0 没有参与追加操作,因此会创建一个新的引用计数二进制数据,并将 Bin0 的内容复制到其中。引用计数二进制数据的 ProcBin 部分的大小设置为二进制数据中存储的数据的大小,而二进制对象则分配了额外的空间。二进制对象的大小为 Bin1 大小的两倍或 256,以较大者为准。在本例中,它是 256。

  • 第 3 行更有趣。Bin1已经在追加操作中使用过,并且末尾有 252 字节的未使用存储空间,因此将 3 个新字节存储在那里。

  • 第 4 行。这里也是如此。剩下 249 字节,所以存储另外 3 字节没有问题。

  • 第 5 行。这里发生了一些有趣的事情。请注意,结果不是追加到 Bin3 中的先前结果,而是追加到 Bin1。预期 Bin4 将被赋值为 <<0,1,2,3,17>>。还预期 Bin3 将保留其值 (<<0,1,2,3,4,5,6,7,8,9>>)。显然,运行时系统不能将字节 17 写入二进制数据,因为这会将 Bin3 的值更改为 <<0,1,2,3,4,17,6,7,8,9>>

    为了确保保留 Bin3 的值,运行时系统在存储 17 字节之前复制 Bin1 的内容到新的引用计数二进制数据。

    这里没有解释运行时系统如何知道不允许写入 Bin1;让好奇的读者通过阅读模拟器源代码(主要是 erl_bits.c)来弄清楚它是如何完成的。

构建二进制数据的编译器支持

变更

Erlang/OTP 26 中添加了使优化更高效的编译器支持。

在上一节的示例中,展示了运行时系统可以通过将其复制到引用计数二进制数据(第 2 行)来处理对堆二进制数据的追加操作,也可以通过复制二进制数据(第 5 行)来处理对先前版本二进制数据的追加操作。这样做并非没有代价。例如,为了使其能够知道何时需要复制二进制数据,运行时系统必须为每个追加操作创建一个子二进制数据。

当编译器可以确定不需要处理这些情况,并且追加操作不可能失败时,编译器会生成使运行时系统应用更高效的优化变体的代码。

示例

-module(repack).
-export([repack/1]).

repack(Bin) when is_binary(Bin) ->
    repack(Bin, <<>>).

repack(<<C:8,T/binary>>, Result) ->
    repack(T, <<Result/binary,C:16>>);
repack(<<>>, Result) ->
    Result.

repack/2 函数仅保留二进制数据的单个版本,因此永远不需要复制二进制数据。编译器将 repack/1 中创建空二进制数据的操作重写为创建一个预留了 256 字节的引用计数二进制数据;因此,repack/2 中的追加操作永远不需要处理未准备好进行追加的二进制数据。

强制复制的情况

二进制数据追加操作的优化要求对于二进制数据存在单个 ProcBin 和对 ProcBin 的单个引用。原因是二进制对象可以在追加操作期间移动(重新分配),并且发生这种情况时,必须更新 ProcBin 中的指针。如果存在多个 ProcBin 指向二进制对象,则无法找到并更新所有这些 ProcBin。

因此,对二进制数据的某些操作会标记它,以便任何未来的追加操作都将被迫复制二进制数据。在大多数情况下,二进制对象将同时缩小以回收分配用于增长的额外空间。

当按如下方式追加到二进制数据时,只有从最新追加操作返回的二进制数据才支持进一步的廉价追加操作

Bin = <<Bin0,...>>

在本文开头部分的代码片段中,追加到 Bin 将是廉价的,而追加到 Bin0 将会强制创建一个新的二进制数据并复制 Bin0 的内容。

如果一个二进制数据作为消息发送给进程或端口,该二进制数据将被收缩,并且任何进一步的追加操作都将把二进制数据复制到一个新的二进制数据中。例如,在以下代码片段中,Bin1 将在第三行被复制。

Bin1 = <<Bin0,...>>,
PortOrPid ! Bin1,
Bin = <<Bin1,...>>  %% Bin1 will be COPIED

如果将二进制数据插入到 Ets 表中,使用 erlang:port_command/2 发送给端口,或者将其传递给 NIF 中的 enif_inspect_binary,也会发生同样的情况。

匹配二进制数据也会导致它收缩,并且下一个追加操作将复制二进制数据。

Bin1 = <<Bin0,...>>,
<<X,Y,Z,T/binary>> = Bin1,
Bin = <<Bin1,...>>  %% Bin1 will be COPIED

原因是匹配上下文包含指向二进制数据的直接指针。

如果进程只是简单地保留二进制数据(无论是在“循环数据”中还是在进程字典中),垃圾回收器最终可以收缩二进制数据。如果只保留一个这样的二进制数据,它将不会被收缩。如果进程稍后追加到已收缩的二进制数据,则将重新分配二进制对象以腾出空间来追加数据。

匹配二进制数据

让我们回顾一下上一节开头的例子。

推荐做法

my_binary_to_list(<<H,T/binary>>) ->
    [H|my_binary_to_list(T)];
my_binary_to_list(<<>>) -> [].

第一次调用 my_binary_to_list/1 时,会创建一个匹配上下文。匹配上下文指向二进制数据的第一个字节。匹配出 1 个字节,并且匹配上下文更新为指向二进制数据的第二个字节。

此时,创建子二进制数据是有意义的,但在本例中,编译器看到很快将调用一个函数(在本例中,是调用 my_binary_to_list/1 本身),该函数将立即创建一个新的匹配上下文并丢弃子二进制数据。

因此,my_binary_to_list/1 使用匹配上下文而不是子二进制数据调用自身。当看到传递的是匹配上下文而不是二进制数据时,初始化匹配操作的指令基本上什么也不做。

当到达二进制数据的末尾并且匹配第二个子句时,匹配上下文将简单地被丢弃(在下一次垃圾回收中删除,因为它不再有任何引用)。

总而言之,my_binary_to_list/1 只需要创建一个匹配上下文,而不需要创建任何子二进制数据。

请注意,当遍历完整个二进制数据后,my_binary_to_list/1 中的匹配上下文被丢弃。如果迭代在到达二进制数据的末尾之前停止会发生什么?优化是否仍然有效?

after_zero(<<0,T/binary>>) ->
    T;
after_zero(<<_,T/binary>>) ->
    after_zero(T);
after_zero(<<>>) ->
    <<>>.

是的,它会。编译器将删除第二个子句中子二进制数据的构建。

...
after_zero(<<_,T/binary>>) ->
    after_zero(T);
...

但是它将在第一个子句中生成构建子二进制数据的代码。

after_zero(<<0,T/binary>>) ->
    T;
...

因此,after_zero/1 构建一个匹配上下文和一个子二进制数据(假设传递给它的是包含零字节的二进制数据)。

类似以下的代码也将被优化。

all_but_zeroes_to_list(Buffer, Acc, 0) ->
    {lists:reverse(Acc),Buffer};
all_but_zeroes_to_list(<<0,T/binary>>, Acc, Remaining) ->
    all_but_zeroes_to_list(T, Acc, Remaining-1);
all_but_zeroes_to_list(<<Byte,T/binary>>, Acc, Remaining) ->
    all_but_zeroes_to_list(T, [Byte|Acc], Remaining-1).

编译器删除了第二个和第三个子句中子二进制数据的构建,并在第一个子句中添加了一条指令,将 Buffer 从匹配上下文转换为子二进制数据(如果 Buffer 已经是二进制数据,则不执行任何操作)。

但是在更复杂的代码中,如何知道是否应用了优化?

选项 bin_opt_info

使用 bin_opt_info 选项,让编译器打印关于二进制优化的许多信息。它可以传递给编译器或 erlc

erlc +bin_opt_info Mod.erl

或者通过环境变量传递。

export ERL_COMPILER_OPTIONS=bin_opt_info

请注意,bin_opt_info 并非要成为添加到您的 Makefile 中的永久选项,因为它生成的所有消息都无法消除。因此,在大多数情况下,通过环境变量传递该选项是最实际的方法。

警告如下所示。

./efficiency_guide.erl:60: Warning: NOT OPTIMIZED: binary is returned from the function
./efficiency_guide.erl:62: Warning: OPTIMIZED: match context reused

为了更清楚地说明警告所指的具体代码,以下示例中的警告将作为注释插入到它们所指的子句之后,例如

after_zero(<<0,T/binary>>) ->
         %% BINARY CREATED: binary is returned from the function
    T;
after_zero(<<_,T/binary>>) ->
         %% OPTIMIZED: match context reused
    after_zero(T);
after_zero(<<>>) ->
    <<>>.

第一个子句的警告表明无法延迟子二进制数据的创建,因为它将被返回。第二个子句的警告表明不会(暂时)创建子二进制数据。

未使用的变量

编译器会找出变量是否未使用。为以下每个函数生成相同的代码。

count1(<<_,T/binary>>, Count) -> count1(T, Count+1);
count1(<<>>, Count) -> Count.

count2(<<H,T/binary>>, Count) -> count2(T, Count+1);
count2(<<>>, Count) -> Count.

count3(<<_H,T/binary>>, Count) -> count3(T, Count+1);
count3(<<>>, Count) -> Count.

在每次迭代中,二进制数据中的前 8 位将被跳过,而不是被匹配出来。