檢視原始碼 建構與匹配二進位資料

本節提供一些關於如何有效處理二進位資料的範例。後續章節將深入探討二進位資料的實作方式,以及如何充分利用編譯器和執行階段系統所做的最佳化。

二進位資料可以透過以下方式有效率地建構

應該

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 中的實作方式。

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 指向二進位物件,則無法找到並更新所有這些 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 位將被跳過,而不是匹配出來。