4  构造和匹配二进制

4  构造和匹配二进制

本节提供了一些有关如何以高效方式处理二进制的示例。后面的部分将深入探讨二进制的实现方式以及如何最佳地利用编译器和运行时系统所做的优化。

二进制可以以以下方式高效地 **构建**

执行

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 二进制**(引用计数二进制的缩写)
    • 堆二进制
  • 另外两种只是对二进制一部分的引用,被称为

    • 子二进制
    • 匹配上下文

Refc 二进制由两部分组成

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

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

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

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

引用对象 **子二进制** 和 **匹配上下文** 可以引用 Refc 二进制或堆二进制的一部分。

**子二进制** 是由 split_binary/2 创建的,以及当在二进制模式中匹配出二进制时创建的。子二进制是对另一个二进制(Refc 或堆二进制,但永远不会是对另一个子二进制)的一部分的引用。因此,匹配出二进制相对便宜,因为实际的二进制数据永远不会被复制。

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

编译器尝试避免生成创建子二进制的代码,仅在随后不久创建新的匹配上下文并丢弃子二进制。它不会创建子二进制,而是保留匹配上下文。

编译器只有在知道匹配上下文不会被共享的情况下才能执行此优化。如果它被共享,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 尚未参与追加操作,因此将创建一个新的 Refc 二进制,并将 Bin0 的内容复制到其中。Refc 二进制的 **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 的内容到一个新的 Refc 二进制。

    这里没有解释运行时系统如何知道它不允许写入 Bin1;它留给好奇的读者作为练习,让他们通过阅读模拟器源代码(主要是 erl_bits.c)来弄清楚它是如何做到的。

改变

对编译器支持以使优化更有效的更改是在 Erlang/OTP 26 中添加的。

在上一节中的示例中,我们展示了运行时系统可以通过将其复制到 Refc 二进制(第 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 个字节预留空间的 Refc 二进制;因此,repack/2 中的追加操作永远不需要处理未准备好追加的二进制。

对二进制追加操作的优化要求有一个 **单一** 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 选项可以让编译器打印大量关于二进制优化的信息。可以将其传递给编译器或 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 位将被跳过,而不是被匹配出来。

二进制处理在 R12B 中得到了显著改进。由于在 R11B 中有效的代码在 R12B 中可能不再有效,反之亦然,因此本效率指南的早期版本包含了一些关于 R11B 中二进制处理的信息。