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 在堆中的表現形式。

這是一個典型的 boxed 對象,第一個字的標簽表示了這個對象的類型,arity 表示后面跟了幾個字。第二個字則表示這個 binary 中的字節數。接下來就是 arity - 1 個字,從低地址到高地址原樣保存了 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().
in do_append_test_verbose
Bin0 = <<0>>, heap_binary
in append_test_verbose
Bin0 = <<0>>, heap_binary
Bin0 = <<0>>, heap_binary
Bin1 = <<0,1,2,3>>, {refc_binary,4,{binary,256},3}
{<<0>>,<<0,1,2,3>>}
從輸出我們可以看出,Bin0 在剛創建的時候是 heap binary,然后被傳入 append_test_verbose/1,仍然是 heap binary。接下來被追加字節,可以看出 Bin 0 仍然是 heap binary,因為 Bin0 沒有被改動。從 Bin1 開始就能看到優化的作用了,Bin1 雖然只有 4 個字節,但是並不是以 heap binary 形式存在的,其本質上是一個 refc binary(后面可以看出,實際上 Bin1 引用的 boxed 對象是一個 sub binary,但是 erts_debug:get_internal_state/1 返回的是其背后真實的 binary 信息),大小為 4,這是符合常理的。從輸出可以看出, Bin1 底層的 Binary 對象大小為 256 字節,說明真的預留了追加用的空間。最后的 3 表示 ProcBin 中的 flags 字段,並且同時設置了 PB_IS_WRITABLE 和 PB_ACTIVE_WRITER,更說明其特殊之處。
下面我們就在 beam 匯編碼的指引下繼續探索這種優化的實現細節。依然拿效率指南上的例子,和上面的例子一樣,為了看到編譯器做的工作,把幾個追加的操作放在單獨的函數中:
do_append_test() -> Bin0 = <<0>>, append_test(Bin0). append_test(Bin0) -> Bin1 = <<Bin0/binary,1,2,3>>, Bin2 = <<Bin1/binary,4,5,6>>, Bin3 = <<Bin2/binary,7,8,9>>, Bin4 = <<Bin1/binary,17>>, {Bin4,Bin3}.
生成的 beam 匯編碼如下所示:
{function, append_test, 1, 4}.
{label,3}.
{line,[{location,"bin_test.erl",9}]}.
{func_info,{atom,bin_test},{atom,append_test},1}.
{label,4}.
{bs_append,{f,0},{integer,24},0,1,8,{x,0},{field_flags,[]},{x,1}}.
{bs_put_string,3,{string,[1,2,3]}}.
{bs_append,{f,0},{integer,24},0,2,8,{x,1},{field_flags,[]},{x,0}}.
{bs_put_string,3,{string,[4,5,6]}}.
{bs_append,{f,0},{integer,24},0,2,8,{x,0},{field_flags,[]},{x,2}}.
{bs_put_string,3,{string,[7,8,9]}}.
{bs_append,{f,0},{integer,8},3,3,8,{x,1},{field_flags,[]},{x,0}}.
{bs_put_string,1,{string,[17]}}.
{put_tuple,2,{x,1}}.
{put,{x,0}}.
{put,{x,2}}.
{move,{x,1},{x,0}}.
return.
每一個追加操作都是由兩條指令配對完成的:首先由 bs_append 指令分配好空間,設置好上下文,然后由 bs_put_string 指令將實際的內容復制到正確的位置。關鍵在於 bs_append 指令。下面我們通過幾個圖示來觀察 bs_append 指令是如何針對不同情況運作的,同時還能看到 bs_append 指令和 bs_put_string 指令的合作方式。有興趣的讀者還可以順便參考 bs_append 指令實現的源代碼,這條指令是由 erts/emulator/beam/erl_bits.c 文件中的 erts_bs_append() 函數實現的。
Bin0 = <<0>> 很簡單,就是一個表示 heap binary 的 boxed 對象,其中只包含一個字節。這里就不畫 Bin0 的圖示了。
然后運行 Bin1 = <<Bin0/binary,1,2,3>> ,雖然 Bin1 最終的值是 <<0,1,2,3>>,顯然也是能在 heap binary 中放得下的,但是 Erlang 虛擬機的優化使得情況變得更加復雜。下面的圖展示了為了創建 Bin1 而創建的數據結構以及之間的關系(注意這個圖只是為了展示數據結構之間的關系,所以就不太糾結比例和具體字段之類的細節了):
從圖中我們可以看出為什么 Erlang 虛擬機要這么設計。bs_append 的優化主要體現在 Binary 中預分配了空間,預分配空間應該比原有的空間要大,否則就沒有意義了。目前 Erlang 虛擬機采用的規則是,首先計算完成追加操作之后 binary 中需要容納的字節數,然后將這個值乘以 2,再和 256 比較取其較大者。那么這里自然而然 256 就更大了。既然這么大了,那么肯定要用 refc binary 來表示這個 binary。但是由於 Binary 中有大部分空間是預留的,我們實際的 binary 只占 Binary 對象中的一部分,所以還要引入 sub binary。因此,創建 Bin1 會涉及到 sub binary、proc binary 以及底層的 Binary 對象。
在上圖中,ErlSubBin 和 ProcBin 的 size 都設置為 Bin1 的實際大小,即 4。而 Binary 對象的 orig_size 大小則設置為 Binary 中緩沖區的大小,即 256。還要注意,ErlSubBin 的 is_writable 字段設置為 1,表示這個 sub binary 是可寫的(所謂可寫,也就是說可追加)。ProcBin 中的 flags 也被設置了,表示對應的 Binary 是可寫的(即預分配了空間並且可以向后追加內容),而且當前有人正在寫入內容。
空間都分配好了之后,bs_put_string 指令將字符串 1,2,3 填充在正確的位置。最后將新創建的 ErlSubBin 設置為結果返回,也就是說 Bin1 實際上是一個 sub binary。
接下來看運行 Bin2 = <<Bin1/binary, 4, 5, 6>> 之后會發生什么,如下圖所示:
前面費了這么大的功夫,就是為了后續的 binary 追加操作能更加高效。從上圖可以看出,Bin2 只創建了一個 ErlSubBin,而且 bs_put_string 仍然在之前預分配的空間中寫入,避免了分配內存的操作。Bin2 怎么知道之前已經預分配了內存呢?這是因為 bs_append 在追加 Bin1 的時候,發現 Bin1 是一個 sub binary,而且 is_writable 設置為 1,所以 bs_append 就知道可以繼續在后面追加了,而且在追加上下文中都設置好了各種偏移指針,bs_put_string 可以輕松填入內容。這里還要特別注意,bs_append 在創建自己的 ErlSubBin 之前,還把 Bin1 對應的那個 ErlSubBin 的 is_writable 設置為 0,即不可寫了。這樣就可以保證后面如果有繼續追加 Bin1 的操作的時候,當前這個 Binary 在 Bin1 之后的內容不會被覆蓋掉,bs_append 會創建出一套新的上述對象“三件套”(ProcBin、ErlSubBin 和 Binary),然后將 Bin1 的內容復制到新的 Binary 中。后面創建 Bin4 的時候就會發生這樣的操作。
接下來是創建 Bin3,運行 Bin3 = <<Bin2/binary, 7, 8, 9>>,如下圖所示:
和 Bin2 的創建一樣,Bin3 再一次享受到了內存預分配的好處,只需要在進程堆中弄一個新的 ErlSubBin即可,然后把 Bin2 的那個 sub binary 設置為不可寫,最后填入要追加的字節。
在享受這種優化的時候,如果 Binary 空間不夠用了怎么辦?沒事,只要把所需字節數翻倍一下,然后 realloc 一個 Binary 對象即可,同時要修改 ProcBin 中的 val 和 bytes 字段。由於在整個過程中,Binary 中的引用計數 refc 一直為 1,只有一個 ProcBin 引用了這個 Binary,所以只需要修改一個 ProcBin 中的字段。雖然圖中的 ErlSubBin 中也有字段 offs 指向了 Binary 中的緩沖區,不過這個不用管,因為 offs 是索引值,圖中的 offs 后面的箭頭並不表示指針。
根據前面的描述,每次 bs_append 享受追加優化的時候都要把之前被追加的那個 sub binary 改成不可寫,所以在運行 Bin4 = <<Bin1/binary, 17>> 的時候,Bin1 已經是不可寫的 sub binary 了,所以創建 Bin4 的時候要創建全新的“三件套”,這些新創建的對象和之前上面圖中的那些對象就沒有關系了,而且結構是一樣的,所以本文就省略 Bin4 的圖示了。
可寫 binary 的降級
從上面的討論我們可以看出,如果有追加 binary 的操作,refc binary 的數據緩沖區一不小心就變得好大,而且是 double 的那種變大。如果被追加的 binary 本來就是很小的 heap binary,那么被追加之后就會變得好幾倍大(256 字節)。這種優化雖然方便了往后面追加,但是這種優化除了會占用了額外的空間之外,根據前面的討論,還有一個限制,那就是可追加的 refc binary 對應的底層 Binary 只能有一個 ProcBin 引用。因此在進行某些操作的時候,Erlang 虛擬機會對 refc binary 進行降級(emasculate)操作。比如說發送消息的時候:在同一個 Erlang 節點中一個進程向另一個進程發送 refc binary 的時候,實際上只有 ProcBin 參與了復制,所以 Binary 對象的引用計數 refc 會增加 1。如果這時這個 refc binary 預留了空間並且正在被追加,那么為了保證后續操作的正確性,Erlang 虛擬機會將這個 refc binary 降級。
看下面的例子:
1 bs_emasculate(Bin0) -> 2 Bin1 = <<Bin0/binary, 1, 2, 3>>, 3 NewP = spawn(fun() -> receive _ -> ok end end), 4 io:format("Bin1 info: ~p~n", [erts_debug:get_internal_state({binary_info, Bin1})]), 5 NewP ! Bin1, 6 io:format("Bin1 info: ~p~n", [erts_debug:get_internal_state({binary_info, Bin1})]), 7 Bin2 = <<Bin1/binary, 4, 5, 6>>, 8 io:format("Bin2 info: ~p~n", [erts_debug:get_internal_state({binary_info, Bin2})]), 9 Bin2.
輸出如下所示:
437> bin_test:bs_emasculate(<<0>>).
Bin1 info: {refc_binary,4,{binary,256},3}
Bin1 info: {refc_binary,4,{binary,4},0}
Bin2 info: {refc_binary,7,{binary,256},3}
<<0,1,2,3,4,5,6>>
代碼中第 2 行 Bin1 會變成可寫的 sub binary,從輸出的第 2 行可以看出來。第 5 行將 Bin1 作為消息發送出去,發送之前 Erlang 虛擬機會將 Bin1 降級,從輸出的第 6 行我們可以看出 Bin1 現在沒有預留空間了,而且 ProcBin 的 flags 標志位也被清零了。
可寫 binary 降級是 erts/emulator/beam/erl_bits.c 文件中的 erts_emasculate_writable_binary() 函數實現的。在整個 OTP 源碼中可以找到下面的位置中調用了這個降級函數:

可以看出有幾個地方會對可寫的 binary 進行降級
- copy_struct 是對 Eterm 樹形結構的復制,例如發送消息的時候會調用 copy_struct
- 幾個 binary 相關的 bif
- 正則表達式 re 庫的 bif 實現
- binary 匹配操作,因為匹配上下文中會引用 Binary
- 轉換至外部 Eterm 格式的時候,在分布式 Erlang 中,節點之間傳遞 Eterm 采用的是外部 Eterm 格式
- 虛擬機將 iolist 輸出到 port 的時候(通過 iovec 輸出)
當然上面的列表僅供參考,具體的行為還請參閱相關的源代碼,本文就不詳述了,以后如果寫博文介紹到相關內容的時候再詳述。可寫的 binary 被降級之后,之后的追加操作又會創建新的“三件套”,並且復制被降級的 binary。
另外,在 gc 的時候可能會縮小(shrink)。預分配的 Binary 緩沖區畢竟占用空間,因此 Erlang 進程在進行垃圾回收的時候也會考慮縮小預留的這一部分的空間。
看下面的例子:
1 bs_shrink(Bin0) -> 2 Bin1 = <<Bin0/binary, 1, 2, 3>>, 3 io:format("Bin1 info: ~p~n", [erts_debug:get_internal_state({binary_info, Bin1})]), 4 erlang:garbage_collect(), 5 io:format("Bin1 info: ~p~n", [erts_debug:get_internal_state({binary_info, Bin1})]), 6 erlang:garbage_collect(), 7 io:format("Bin1 info: ~p~n", [erts_debug:get_internal_state({binary_info, Bin1})]), 8 ok.
第 2 行產生了一個可寫的 Bin1,然后連續強制進行兩次垃圾回收,每一次回收之后都查看一下 Bin1 的狀態,輸出如下所示:
438> bin_test:bs_shrink(<<0>>).
Bin1 info: {refc_binary,4,{binary,256},3}
Bin1 info: {refc_binary,4,{binary,256},1}
Bin1 info: {refc_binary,4,{binary,24},1}
ok
挺有意思的,第一次垃圾回收只是去掉了 ProcBin 中 PB_ACTIVE_WRITER 標志位,但是空間沒有縮小。第二次垃圾回收則真正回收了空間,但是並沒有縮減到真實數據的大小,而是還保留了一部分預留空間。后面再調用強制垃圾回收也不會再縮減 Binary 緩沖區的空間了。第一次回收和第二次回收的區別可能在於 minor gc 和 major gc 吧,我還沒有完全弄清楚 Erlang gc 的工作細節,所以不好妄下結論了,以后弄清楚了再寫文章討論。關於縮小后的 Binary 緩沖區大小,有興趣的讀者可以參閱 erts/emulator/beam/erl_gc.c 文件中的 sweep_off_heap 函數,這個函數清理的是進程堆中的 off-heap 數據。在“If we got any shrink candidates, check them out.” 這段注釋文字之后的代碼就是計算縮小后的大小並分配新 Binary 的代碼。
接下來討論 binary 的另外一項重要操作:模式匹配。模式匹配會用到 header 標簽為 0001 的 boxed 對象。
binary 匹配
這一部分依然采用“Erlang 代碼片段 -> beam 匯編碼 -> 虛擬機代碼”的方式研究 binary 匹配的實現。
這里用的 Erlang 代碼片段依然是效率指南上面的例子,如下所示:
1 my_binary_to_list(<<H, T/binary>>) -> 2 [H|my_binary_to_list(T)]; 3 my_binary_to_list(<<>>) -> [].
這段代碼的意圖是將一個 binary 轉換為一個列表,每一次調用從 binary 的頭部取出一個字節出來,然后放到結果列表的頭部。這個操作本身在 Erlang 中現在是通過 BIF 實現的,在虛擬機中直接用 C 語言操縱數據結構的效率更高。不過這個純 Erlang 的實現效率也很高,得益於聰明的 Erlang 編譯器和優化的虛擬機。下面我們來看 Erlang 對這種應用模式的優化。編譯器生成的 beam 匯編碼及注釋如下圖所示:
第 1 步創建匹配上下文,標志着下面要開始進行匹配操作了。 實際上在虛擬機層面,bs_start_match2 指令是優化的,只有在這個遞歸函數(例如本例中的 my_binary_to_list)第一次調用的時候才會真正創建匹配上下文。執行這條指令的時候,x[0] 傳入的參數是一個 binary(不論是什么類型的,sub binary、heap binary 或是 refc binary)或匹配上下文,這條指令對應的虛擬機代碼(process_main() 函數中的 OpCase(i_bs_start_match2_rfIId))會判斷傳入的是 binary 還是匹配上下文。如果是 binary,則調用 erts_bs_start_match_2() 函數創建新的匹配上下文。從上面的匯編碼中可以看出,這個新創建的匹配上下文一直在 x[0] 寄存器中,之后再 call 這個函數的時候 x[0] 中就是一個匹配上下文了,bs_start_match2 指令碰到傳入匹配上下文的時候基本啥都不用做,可以完全復用第一次創建的數據結構,因此效率很高。
第 2 步就在進行真正的匹配操作,bs_get_integer2(如果是其他數據類型,也有對應的指令)指令試圖取出一個字節。注意這條指令接受了 x[0] 作為參數,而 x[0] 中當前保存的就是匹配上下文,因此 bs_get_xxx 系列指令從 binary 中獲取了正確的數據之后,還要修改匹配上下文中的信息(詳見后述)。
匹配上下文對應的數據結構是 ErlBinMatchState,如下所示:
1 typedef struct erl_bin_match_buffer { 2 Eterm orig; /* Original binary term. */ 3 byte* base; /* Current position in binary. */ 4 Uint offset; /* Offset in bits. */ 5 size_t size; /* Size of binary in bits. */ 6 } ErlBinMatchBuffer; 7 8 typedef struct erl_bin_match_struct{ 9 Eterm thing_word; 10 ErlBinMatchBuffer mb; /* Present match buffer */ 11 Eterm save_offset[1]; /* Saved offsets */ 12 } ErlBinMatchState;
了解了 binary 匹配的工作方式及需求之后就很容易理解 ErlBinMatchState 這個數據結構了。和其他 boxed 對象一樣,thing_word 是對象的 header,其中包含 arity 和標簽,匹配上下文的標簽是 0001。通過 mb 中的 base 指針可以快速訪問 binary 數據所在的緩沖區。對於匹配操作來說,offset 和 size 兩個字段非常重要。offset 表示下一次在 binary 數據中要進行匹配的位置。初始化的時候自然為 0,然后 bs_get_xxx 系列的指令在成功從中匹配所需求的數據之后要負責更新這個 offset。通過 size 則可以快速獲得 binary 的大小信息。要注意,offset 和 size 的單位都是位,因此可以方便地適用於 binary 和 bitstring。后面的 saved_offset[] 數組是一個用於保存之前用過的 offset 的緩沖區。大部分情況下這個數組大小默認為 1,bs_start_match2 有一個參數可以設置這個數組的大小。bs_save2 和 bs_restore2 指令會用到這個數組。具體意義目前我也說不太清,應該是涉及到某一種特定模式的匹配代碼。
接下來的步驟在代碼注釋中都很明確了。
運行到第 7 步的時候,說明函數順利執行完。x[0] 寄存器原本保存的是表示 ErlBinMatchState 的 Eterm,但是在正常返回的時候,x[0] 修改為表示結果的列表的 Eterm,因此本身只創建了一次的 ErlBinMatchState boxed 對象在函數返回之后就失去引用了,會在恰當的時候被 gc 回收。
上例中步驟 3 之上的框中說明了編譯器在這里有一個優化。如果我們調用編譯器的時候添加 +bin_opt_info 參數,可以看到編譯器在這里輸出了一句話:
bin_test.erl:105: Warning: OPTIMIZED: creation of sub binary delayed
說明這里有一個關於 sub binary 的優化。在匹配語句 <<H, T/binary>> 中,剩下的 T 應該是一個 sub binary,但是編譯器精明地發現在函數體中並沒有使用這個 T,而是直接把這個 T 傳遞給下一次遞歸調用做匹配了,因此編譯器就不需要在這里創建一個 sub binary。如果編譯器無法判定 T 是否會被使用,那么編譯器還要創建一個 sub binary,產生額外開銷。
最后,我們再來看一下 binary 的 comprehension 構造操作的優化。binary comprehension 的操作同時涉及到了模式匹配和 binary 追加的操作。
binary comprehension 的優化
作為一門函數式語言,Erlang 自然支持 list comprehension,而作為 Erlang 特色的 binary,也提供了高效的 binary comprehension 的支持。想象一個非常簡單的需求:對於一個數據塊,需要將按照一定數字節(比如說 8)為單位來分塊,然后給每一塊添加一個前綴(比如說 <<0,1,2>>),最后將這些添加了前綴的分塊拼成一個大塊。粗看這種需求可以快速寫出代碼,但是效率可能不高,會涉及到很多重復的拷貝。有了 binary comprehension 的支持,可以寫出非常高效簡潔的代碼,而且只涉及到一次數據拷貝。代碼如下[注9]:
1 bc(Input) -> 2 << <<0, 1, 2, Bin:8/binary>> || <<Bin:8/binary>> <= Input >>.
很簡單明了,每次從 Input 中取出 8 個字節的 binary,然后在前面追加 <<0,1,2>>,並將所有的塊拼接在一起。同樣,為了研究其工作原理,我們看編譯器輸出的 beam 匯編碼:
了解了之前的匹配操作之后,這個圖應該好理解多了。重點在於兩條特殊的指令:bs_init_writable 和 bs_private_append。binary comprehension 的操作是完全可以通過 bs_append 指令實現的,但是 bs_append 是共享的,多個 binary 都可以通過對某一個 binary 進行 bs_append 生成,因此每一次調用 bs_append 都會生成一次 sub binary。而 bs_private_append 則不會,這條指令是被 comprehension 的 binary 私有獨享的,因此 bs_init_writable 和 bs_private_append 可以合作高效地構造 binary:bs_init_writable 構造出一個可寫的 ProcBin,並且將 ProcBin 標記為 PB_ACTIVE_WRITER,然后分配好最終所需的空間,創建 Binary 對象。bs_private_append 直接在這個 ProcBin 中追加,修改 ProcBin 的大小,然后建立好后續 bs_put_xxx 系列指令所需的上下文,不需要創建 sub binary。可見這一系列效率很高,最終的數據只經歷了一次拷貝。
好了,以上就是 Erlang 中 binary 數據結構的實現以及相關優化。雖然我不保證這些內容的詳盡性,但是應該覆蓋了大部分重要的內容。我們研究優化,不僅是為了了解和學習 Erlang 虛擬機中所做使用的各種技巧,更重要的是通過了解這些實現和優化,我們能更清楚地了解虛擬機的限制,知道 Erlang 虛擬機在執行我們編寫的代碼的時候會進行什么樣的操作,從而幫助我們編寫更高效的代碼(即避免編寫低效的代碼)。
[注5] http://blog.yufeng.info/archives/2903
[注6] 關於 Erlang 匯編碼(beam 抽象碼及虛擬機執行的匯編碼)的格式和反匯編可以參考 http://blog.yufeng.info/archives/34 和 http://blog.yufeng.info/archives/498 。在 http://erlangonxen.org/more/beam 可以找到大部分 beam 抽象碼和虛擬機匯編碼的功能說明即參數說明。
[注7] http://www.erlang.org/doc/efficiency_guide/binaryhandling.html
[注8] http://blog.yufeng.info/archives/2988
[注9] http://erlang.org/pipermail/erlang-questions/2013-April/073263.html







