Erlang數據類型的表示和實現(2)——Eterm 和立即數


Erlang 數據類型的內部表示和實現

Erlang 中的變量在綁定之前是自由的,非綁定變量可以綁定一次任意類型的數據。為了支持這種類型系統,Erlang 虛擬機采用的實現方法是用一個帶有標簽的機器字表示所有類型的數據,這個機器字就叫做 term。在 32 位機器上,一個 term 為 32 位寬;在 64 位機器上,一個 term 默認為 64 位寬[注2]。由於目前大規模的服務器基本上都是 64 位平台,所以本文下面的討論都基於 64 位平台。

Erlang 虛擬機采用的是虛擬寄存器機的形式,每一個調度器線程相當於一台虛擬的寄存器機。這種寄存器機模型下的 Erlang 進程也包含自己的棧和堆,這些棧和堆實際上就是 term 的數組。此外,這種寄存器機的寄存器文件也是用 term 數組表示的。

下面我們來詳細地看一看 Erlang 中 term(Eterm)的結構。

Eterm

Eterm 是一個打了標簽的機器字,Erlang 虛擬機可以通過標簽的具體內容判斷 Eterm 的類型,並且針對不同類型的 Eterm 采取不同的解釋。給機器字“打標簽”的意義實際上就是把機器字中的幾個位“偷出來”當做標簽使用,那么機器字中剩下的表示實際信息的位數就減少了,因此 Eterm 采用的標簽方案必須簡潔高效。

從前一節看出,Erlang 中有不少數據類型,而且有的數據類型還挺復雜,因此為了標簽占用機器字中太多的位,Eterm 采用了層次化的標簽方案。

第一層次的標簽叫做 primary tag,占用 Eterm 的最低兩位,因此有 4 種組合:

  • 01:表示一個列表,剩下的部分(62 位)是指向一個列表 cons 的指針。由於一個機器字是 64 位,所以 Eterm 必然采用 8 字節對齊,因而必然也是 4 字節對齊。而這里的指針只能指向一個 Eterm,所以借用 2 個位用作標簽不會影響指針的精度,在使用的時候在后面填兩個 0 就好了。
  • 10:表示一個 boxed(“裝箱”?)對象,即無法在一個機器字中表示的復雜對象。同樣,剩下的部分是一個保留了高 62 位的指針,這個指針指向 boxed 對象的對象頭(header),對象頭的定義和對象的數據定義取決於具體的對象。
  • 11:表示一個立即數(immediate)。立即數也就是能利用剩下的 62 個位編碼的小型對象。

這 3 種主要的 Eterm 如下圖所示:

下面先看最簡單的 Eterm:立即數。

立即數

立即數在剩下的 62 個位中,又借了 2 個位作為標簽用來區分立即數的類別:

  • 0011:本地的 pid
  • 0111:本地的 port
  • 1011:另一類立即數(IMMED2),即在剩下的 60 個位中進一步打標簽表示更小的立即數
  • 1111:帶符號的小整數。去掉符號位,還剩 59 位。

下面分別詳細介紹這 4 類立即數。

pid

首先是本地 pid。在玩 Erlang 的時候,我們每時每刻都會見到 pid 的身影,比如說剛打開 Erlang 的 shell 時,調用 self() 就能看到當前 shell 進程的 pid,例如 <0.32.0>,看上去很高級的樣子,用“.”分為好幾段,還用尖括號括起來。如果只是隨便玩玩,系統中進程不多,會發現好像只有中間那一段的數字會變,兩邊的數字總是 0,所以會覺得似乎兩邊的數字會用於什么神秘的用途。下面是表示 pid 的 Eterm 的結構:

注意上圖中 pid 的寬度是 32 位的,目前不論是在 64 位系統還是 32 位系統上,pid 的寬度都是 32 位,因此在 64 位系統上只用了 Eterm 的一半。最低 4 位是標簽。然后在剩下的 28 位中,固定地分為兩段了,一段是 Serial,一段是 Number。在 pid 的三段式表示法中,中間那一段是 Number,右邊那一段是 Serial,也就是說,打印出來的 pid 人為地將一個 28 位的整數分成了兩部分顯示。目前在代碼中將 Number 部分寬度定義為 15 位,剩下的 13 位是 Serial。由於 \(2^{15} = 32768\),所以當系統進程編號使用超過 32768 的時候,就會進位到 Serial 部分。所以在系統上不斷地創建新進程,就可以看到下面這樣的 pid 序列:

pid 的后面兩段明了了,那么第一段是什么呢?在分布式 Erlang 的環境中,建立兩個節點,如果在一個節點上把 pid 放在消息中發送至另外一個節點上的進程,在另一個節點上打印出這個 pid,就會發現第一段的數字變成了一個非零的值。沒錯,這個值就是和節點有關,具體意義見后。可是我們在上面的 pid Eterm 結構中並沒有看到用於保存節點的空間,這是因為 pid 當做消息發送給遠程節點之后,Erlang 的分布式機制會對 pid 做打包處理,外部節點收到之后會重組為表示“外部 pid”的新的 Eterm,這個 Eterm 就不是立即數了,變成了一個 boxed Eterm,具體詳見后述。在本地節點,第一個段永遠打印出 0。

要注意的是,Number 部分並不表示系統最大進程數的限制,Number 部分和 Serial 部分的長度是在編譯的時候通過宏寫死的。pid 最多有 28 個有效位,這些位構成的數據經過一定的變換可以成為進程表中的索引。進程表就是一個巨大的指針數組,每一個指針都指向一個進程的描述符,數組中包含的元素個數等於系統允許的最大進程數。目前默認最大進程數為 262144,也就是 18 位。最大進程數可以在啟動的時候通過虛擬機參數 +P 設置。要注意,由於進程表是靜態分配的,每一條 slot 都要占用 8 字節(實際上由於用空間換時間的優化,每一個進程的 slot 要占用 16 字節),所以最大進程數也不要太大了。Erlang 允許 pid 的這 28 位中最多拿出 27 位表示進程的索引,即最大允許\(2^{27}\)個進程(實際上少一個),那么如果真地設置這么大的限值,進程表本身就要占用\(2^{27} \times 2^3 \times 2 = 2GB\)內存。

以前 pid 里面的高 28 位直接作為進程表的指針。在 R16B 引入了進程表多核訪問相關的優化之后,為了避免多個調度器線程同時寫入進程表時造成 cache 失效引發的性能降低,連續 pid 對應的指針在進程表中的 slot 中間都間隔了 cache 線。也就是說第一個進程的指針占用第一條 cache 線的第一個位置,第二個進程就跳到第二條 cache 線了。直到所有 cache 線都用完,再跳回第一條 cache 線中的第二個位置分配新的進程指針。因此采用了這種優化之后 pid 的數據值需要做簡單的變換才能得到真正的進程表索引。

port

下圖是表示 port 的 Eterm:

 

從圖中可以看出,port 和 pid 差不多,也是 32 位,只是不區分 Serial 和 Number,整個有效位都作為 Number,所以我們打印 port 的時候得到的是像 #Port<0.52> 這樣的結果,只分了兩個段。第二個段就是 Number 的值。第一個段和 pid 是一樣的,在本地永遠打印出 0,發送到外部節點之后,會變成另一個 boxed Eterm。

Erlang 中 port 表的實現用的就是進程表的實現,即 erts/emulator/beam/erl_ptab.h 頭文件中定義的 ErtsPTab 數據結構。因此各種限制和優化和進程表都是一樣的。

帶符號小整數

去掉 4 個標簽位之后,還剩下 60 個位,系統可以用這 60 個位表示無符號整數或有符號整數,具體就看怎么用了。字節也是用這個類型表示的。如果在 Erlang 中使用字符串,盡管每一個字符只有一個字節,但是需要占用一個 CONS 一共 16 字節。在需要操作整數的時候,如果Erlang 編譯器和虛擬機發現只需要不到 60 個位就能表示的時候,就會自動使用立即數,避免二次訪問。

IMMED2

Erlang 里面立即數的類別不算少,但是也不好借太多位,借了太多位的話數據本身能用的位數就少了,所以 Erlang 采用了多級標簽的方式,一些不需要那么多位的小數據類型,就放在 IMMED2 這一級了。IMMED2 立即數級別在 1011 的基礎上進一步借了 2 位,分別表示 3 種數據類型:

  • 001011:atom
  • 011011:catch,用於表示 Erlang 中 catch 語句的代碼的 Eterm,這個 Eterm 只會出現在進程的棧上。這個數據類型屬於 Erlang 虛擬機內部使用的數據類型,Erlang 程序不會直接操作或使用這個數據類型。由於這個數據類型涉及到 Erlang 內部的 Beam 字節碼以及虛擬機的執行機制,超出了本篇文章的范圍,因此這里先不討論(其實我自己也沒有完全弄清楚虛擬機執行機制的所有細節,所以等具體弄懂之后再寫關於 Beam 虛擬機執行機制相關的文章,在這些文章中一定會討論 catch 的實現)。
  • 111011:NIL,表示空指針。雖然 NIL 是打標簽的值,但是 Erlang 虛擬機中還是專門定義了一個值表示 NIL,也就是除了這 6 位的標簽之外其他位置全部填 1。

目前 IMMED2 這個層次下就只有上面這 3 種類型。catch 超出本文范圍,NIL 很簡單一句話說明白,下面就來詳細談一下 atom。在 Erlang 里 atom 真是抬頭不見低頭見,可以通過 atom 來表示各種意義的常量。在其他語言,例如C/C++中要實現類似的功能,我們可以使用 #define 宏定義,可以用 enum 枚舉,還可以用 const 常量等方法。使用這些方法的時候,總會覺得不是太舒服,比如使用 #define 宏定義和 const 常量,除了本來就頭痛的給宏或常量命名之外,還要真正填上一個值,為了讓這些值不沖突,又是一件頭痛的事情了。如果用字符串吧,那么每次匹配的時候還要做低效的字符串操作。

在 Erlang 中,使用 atom 既方便又高效,我們就來看看 atom 是怎么實現的。atom 的 Eterm 除去 6 位的標簽之外剩下的部分,就是 atom 在 Erlang 虛擬機中的索引,也就是一個整數值。在 Erlang 中,有關 atom 比較的操作只需要比較兩個索引值即可,就是整數操作,因此非常高效。atom 本身是一個字符串,那么 atom 的索引是怎樣對應上具體的字符串的呢?也就是需要實現字符串和索引值之間的互相映射,字符串和索引值都必須唯一,這顯然需要使用散列表。Erlang 虛擬機內實現了一套通用的索引和散列表機制,atom 表就是這個機制的一個客戶。下圖是這套機制中關鍵數據結構之間的關系。

圖中左側是散列表部分,右側是索引部分。先看左側。這個散列表采用的是標准的數據結構教科書上的實現。查找的時候:通過散列函數計算被散列對象的散列值,然后對散列表的長度取模,得到圖中左側指針數組的索引,接下來運氣好的話能直接得到查找的對象(封裝在 HashBucket 中),運氣不好的話可能查不到,或者發生碰撞進行線性搜索(例如圖中通過散列值得到索引 2 的時候就發生了碰撞,需要線性搜索 HashBucket 中匹配的 hvalue)。插入的時候:同樣是先計算散列值得到索引,然后看對應的指針是否已經有對象了,如果沒有,則直接加入,如果有的話,則插入隊列頭部。散列表在擴容的時候,會選擇下一個合適的大小(erts/emulator/beam/hash.c 文件中的 h_size_table 數組列出了散列表大小增長的序列,數組里面都是素數,但是基本上符合倍增的關系),把老表復制到新表,然后刪除老表。當然,增長是有限制的,散列表大小不能超出 h_size_table 數組中指定的最大值。

圖中右側是索引部分。索引表實際上是指向被索引對象的指針的數組,被索引對象的索引值就是對應指針在數組中的自然順序。由於事先無法確定具體的索引數目,所以索引表的大小是動態增長的,增長單位為一個索引塊的大小,每個索引塊中有固定數目的指針(例如 1024 個)。散列表中每插入一個新的對象的時候,設置索引表中最小的那個可用索引。如果新的索引超出了索引塊的邊界,那么分配一個新的索引塊,並且更新索引塊表中的指針。同樣,索引表的增長也是有限度的,索引塊表的指針用完了就不能再增長了。索引塊表的長度是在創建索引表的時候設置的,所以理論上可以很大,不超出內存限制即可,但是實際中還要考慮散列表的大小,這兩者是相互制約的。

描述了散列和索引的數據結構和實現之后,我們回到散列索引的客戶——atom。由於散列和索引是通用的,所以散列表指向的對象是 HashBucket 數據結構,而索引表指向的是 IndexSlot 數據結構。為了將散列和索引結合起來,這兩個數據結構是重疊的,HashBucket 在 IndexSlot 頭部。具體客戶在使用的時候,要把 IndexSlot 放在自己的頭部,這樣就把具體的對象和散列索引結合起來了(就好像原始的面向對象的實現)。 從圖中可以看出,我們的 atom 數據結構除了上述結構之外,還包含了具體的字符串指針、長度以及編碼信息。將這些信息串起來之后,我們就可以高效地在常量時間內查詢 atom 是否已經存在,已經存在的 atom 的索引值是什么,某個索引值對應的 atom 是什么以及插入新的 atom。

另一個問題,向散列表插入元素的時候,散列表要負責分配對象的內存,而散列表是通用的,那么散列表怎么知道分配例如 atom 呢?解決方法是散列表中元素的分配、比較、釋放以及散列值計算的操作都通過回調函數的方式提供給散列表。這里 atom 使用的散列函數是經典的 hashpjw 散列算法,這個算法是字符串散列常用的算法。

在一個 Erlang 節點內,atom 表是全局共享的,因此多個線程對 atom 表的訪問是通過讀寫鎖保護的。對 atom 表的操作絕大部分都是讀操作,只有真正插入新的 atom 時的操作才是寫操作,插入新 atom 的情況一般不頻繁,而且也很少有多個線程爭搶着插入新 atom 的情況,大部分情況都是試圖插入 atom 但是發現其實已經存在了,因此 atom 表使用的讀寫鎖是針對讀操作優化的讀寫鎖。使用針對讀操作優化的讀寫鎖時讀鎖的開銷非常小,即使是在大量線程爭用的情況下。

Erlang 中的 atom 表是不進行垃圾回收的,畢竟在程序員不濫用 atom 的情況下,atom 數目可以控制在合理范圍內。而且跟蹤每一個 atom 的引用狀況會產生很大的開銷。所以不要濫用 atom,把 atom 表塞滿是把 Erlang 虛擬機 crash 掉的一種方法。目前默認的 atom 數目限制是 1048576(\(1024 \times 1024\)),通過虛擬機的 +t 參數可以設置。

上面我們把 Erlang 中所有立即數都過了一遍,下面我們來看列表數據類型。


[注2] 在 64 位系統上,Erlang 虛擬機支持一種 hybrid heap(混合堆)模式。在這種模式中,混合使用 32 位寬的 term 和 64 位寬的 term,因為有一些數據類型的 term 並不需要 64 位寬,如果在 64 位系統下統一使用 64 位寬的 term,會造成一定程度的內存浪費。由於混合堆模式顯然會使得虛擬機更復雜,因此屬於一種實驗性質的優化措施,默認是關閉的,本文也不討論混合堆模式。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM