Boxed 對象
Boxed 對象是比較復雜的對象,在 Erlang 中主標簽為 10 的 Eterm 表示一個對 boxed 對象的引用。這個 Eterm 除去標簽之后剩下的實際上是一個指針,指向具體的 boxed 對象。如下圖所示,boxed 對象由對象頭和具體的數據組成,這些字都排布在一起,占用進程棧中的一段連續空間(不像列表那樣會分開)。
對象頭分為 3 部分:主標簽固定為 00(因此也沒有 Eterm 以 00 為主標簽),然后是 4 個位表示的 header 標簽,這個標簽表示了這個 boxed 對象的具體類型。接下來剩下的部分就是對象的大小 n,在對象頭之后的 n 個字就是這個對象的具體數據。具體數據的格式和意義取決於具體的對象類型。對象頭中表示大小的值也稱為對象的 arity,目前在 Erlang 虛擬機中規定最多使用 24 位表示這個 n,因此對象最大不超過 \(2^{24} = 16777215\) 個字。
下面列出了 Erlang 虛擬機支持的所有 header 標簽:
- 0000:表示元組,高位的 size 即元組中元素個數。
- 0001:內部使用的 binary 匹配狀態。
- 001x:大數(bignum),x 為符號位,可以表示任意大的整數,限制取決於內存。
- 0100:本地 ref
- 0101:fun
- 0110:浮點數
- 0111:export 信息
- 1000:refc_binary,引用計數的 binary,即大 binary
- 1001:heap_binary,小 binary,直接放在堆中
- 1010:sub_binary,分離 binary 時產生的子 binary
- 1011:沒有使用
- 1100:外部 pid
- 1101:外部 port
- 1110:外部 ref
- 1111:沒有使用
以上這些 boxed 對象在 Erlang 虛擬機內都稱為 thing。這些“東西”有一些是表示 Erlang 開發者可以直接使用的數據類型,有一些則表示內部數據類型。下面我們來依次了解這些數據類型。
元組
元組很簡單,實際上就是一個數組,如下圖所示的一個 3 元素的元組:
元組中的元素在內存中依次排開,中間沒有間隔,每一個元素都是一個 Eterm,所以元組中的元素可以是任意類型的。和列表一樣,如果創建一個元組的時候引用了其他對象,那么這些被引用的對象也是共享的。但是當元組跨越了進程的邊界的時候,也會被扁平化。
大整數,浮點數
大整數的符號保存在對象頭的標簽中,因此大整數對象本身的數據保存的就是大整數的絕對值。由於 Erlang 支持任意大的整數,所以大整數的長度一定是至少 1 個字的。那么大整數是以什么樣的格式保存在這些機器字中的呢?假設某個大整數需要用 n 個字表示,而且每一個機器字寬度為 64 位元,那么這個大整數的值為:
\[ S = (2^{64})^{n-1} \times w_{n-1} + (2^{64})^{n-2} \times w_{n-2} + \cdots + (2^{64})^{0} \times w_{0} = \sum_{i=0}^{n-1} (2^{64})^{i} \times w_{i} \]
其中 \(w_{i}\) 表示第 \(i\) 個機器字。可以看出,低地址處的機器字表示大整數中的低位。實際上,就是從高地址到低地址一段一段拼起來。Erlang 虛擬機在操作大整數的時候,計算方法和我們筆算算數是一樣的。我們做算數筆算的時候,是一個數字一個數字地挨個計算的,如果碰到進位,則加到更高一位的數字中。Erlang 虛擬機的大數算法在做計算的時候,也是一個數字一個數字地算,只不過剛好使用一個機器字作為我們筆算時的一個數字,在虛擬機的代碼中,也是把一個機器字 typedef 為 ErtsDigit。換句話說,我們筆算是在算 10 進制數,而 64 位 Erlang 虛擬機內部則是在進行\(2^{64}\)進制的整數計算。Erlang 大數計算的算法都在 erts/emulator/beam/big.c 文件中,有興趣的讀者可以讀一讀,雖然原理挺簡單,但是里面還是有不少技巧的。比如說 Erlang 虛擬機在進行大數計算之前,要事先為結果分配好空間,分配好空間之后大數對象的大小就固定了,大數算法在計算的過程中將結果填在分配好的空間中。此外還有不少大數到字符串(各種進制的表達)之間的轉換,在這種轉換過程中需要在兩個方向預估對方數據類型所需的空間大小。Erlang 虛擬機采用了信息熵的方法,通過查表的方式查找某個進制下的一個數碼(應該是 digit,但是在代碼中由於已經用 digit 表示一個固定機器字,所以 big.c 代碼中用了 character 這個詞表示大整數的一個數碼)所需的位元數(即 \(\log_{2}r\),\(r\) 表示進制)。
浮點數就比較簡單了。Erlang 中的浮點數是雙精度浮點數,實際上就是編譯器原生的 double 類型,因此在 64 位的機器上剛好是一個機器字的大小。那么浮點數對象的 header 很簡單,除去固定標簽之外,arity 值固定為 1。然后后面跟着的一個字大小的數據就是浮點數符合 IEEE 754 規范的表示形式(注4)。
本地 ref
如果是在 32 位機器上,ref 對象表示形式如下所示:
header 里面的 arity 總是設置為 3,后面跟着 3 個 32 位的機器字,其中第 0 個只用了 18 位。這個圖也結解釋了為什么 ref 的值超過了 \(2^{82}\) 之后就會繞回為 0(18+32+32=82)。
在 64 位的機器上,ref 能表示的數據寬度也是 82 位,如果和 32 位機器不一樣的話就不好交換數據了。那么如果在 64 位的機器上,只是簡單地拓寬 header 以及后面的 3 個字,那么每個 ref 就要浪費 \(4 \times 3=12\) 個字節的空間,所以在小尾順序的 64 位機器上,就成下面這樣子了:
其中 word 0 依然只使用了 18 位,而且在 header 后面的第一個機器字的低 32 位還保存了一個數字 3,即后面跟着的 32 位元字數。
Erlang 虛擬機通過 3 個全局變量分別表示 word 0、1 和 2 的當前值。調用 BIF make_ref() 的時候,Erlang 虛擬機會創建一個新的 ref。由於全局當前 ref 值是用多個變量表示的,所以 make_ref() 會通過一個自旋鎖保護對這些變量的操作,遞增全局 ref 的值,然后根據新的 ref 值創建新的 ref 對象並返回對應的 Eterm。遞增操作針對 word 0 遞增,如果 word 0 超過了 \(2^{18}\),則進位到 word 1,word 1 歸零的話則進位到 word 2。
我們打印 ref 的時候,得到的是類似 #Ref<0.0.0.2055> 這樣的輸出,通過 3 個句點將輸出結果分為 4 段。第 1 段,和 pid 和 port 的第一段是一樣的,表示節點,在本地節點總是為 0,后面 3 段分別為上面的 word 2、1 和 0。所以 ref 較少的時候前面幾段都為 0。
binary
binary 的內容比較繁多,單獨放在本系列的第 5 篇了。
外部 pid、port 和 ref
外部 Eterm 是 Erlang 虛擬機的分布式節點之間交換 Eterm 時所用的格式。由於具體涉及到分布式 Erlang 的工作細節,所以打算在專門介紹 Erlang 分布式機制的博文中具體討論。
fun 和 export
涉及到 Beam 虛擬機的代碼格式和工作原理,打算放在專門介紹 Beam 虛擬機的博文中具體討論。
[注4] 參見 http://en.wikipedia.org/wiki/IEEE_floating_point