這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
在之前的文章中,我們介紹了一些持久化資料結構實現的基本原理和 Vector Trie 這一資料結構在 Golang 下的實現過程。這篇文章終於來到了實現持久化 List 的最後一步: 實現 Transient 和持久化的功能。
這篇文章是系列文章的一部分,如果還沒有瀏覽過文章的其它部分請參考:
- 持久化資料結構簡介
- Vector Trie 的實現
- Transient 及持久化 (本文)
在之前的文章中,我們已經看到了如何?一個 Vector Trie,也知道如何使用 Vector Trie 來實現共用資料結構的持久化 List:在每次修改時,我們複製從根節點到被修改節點路徑上的所有節點,並使用得到的新的 Root 節點構造一個新的 List 的 HEAD資料結構。這樣通過新的 HEAD 我們就可以訪問到新的資料,通過舊的 HEAD 我們可以得到舊的資料。
按照這樣的思路,我們需要更改 List 的介面,對於每一個會修改 List 元素的操作,我們都要返回一個新的 List對象而不是在原來的對象上修改。比如說:
1 2 3 4 5 6 7
|
type List interface { Get(n int) (interface{}, bool) Set(n int, value interface{}) (List, bool) PushBack(value interface{}) List RemoveBack() (List, interface{}) Len() int }
|
為了實現這樣的介面,我們有兩種選擇:
- 對於
Set
、PushBack
和 RemoveBack
函數,我們把它們修改成返回新的 listHead
的形式
- 先實現
TransientList
,對於Set
、PushBack
和 RemoveBack
函數,我們把它改造成先把自己轉換成 TransientList
並修改,最後返回將 Transient
持久化的結果
由於我們之前的代碼設計上預先做了準備,兩種方案的實現難度其實差別不大。但是因為 Transient支援對資料結構一連串操作的高效執行,我們決定採用第二種方案。第二種方案也會使得代碼更簡潔、複用的程度更高。
那麼什麼是 Transient 呢?下面我們就來介紹它。
Transient 的原理
前面說道,持久化資料結構的實現原理是複製一條路徑上的節點。在我們的設計中,每個節點的寬度是32,那麼如果我們連續地修改幾個相鄰的元素,即使這些元素都在一個葉子節點上,它也會被複製很多遍。這樣的行為是非常低效的。為了能讓我們高效地進行一連串的修改,一種解決方案就是允許一個持久化資料結構臨時地變成非持久的,在我們一連串的修改之後,再轉變回來。這樣每次修改都會在原地進行,從而極大地改善了效能。這裡臨時產生的非持久化資料結構就是我們所說的 Transient。
但是同樣我們也要知道,使用 Transient 是有一定風險的。首先作為一種可變資料結構,它一般來說會被實現為非安全執行緒的類型,因此如果並發地操作它,就可能產生 Race condition 等問題。另外,如果使用者在使用時保留了對於 Transient 的引用,把 Transient 轉變為持久化之後仍然對 Transient 進行了修改,那麼產生的持久化對象實際上也會被改變。也就是說,引入 Transient 可能會導致無效的持久化。
儘管 Transient 帶來了一些風險,但是考慮到效能上的提升,它還是值得的。Transient 的實現有兩個關鍵點:
- 為每個 Transient 分配一個全域唯一的
id
,當 Transient 每次對內部結構進行修改時,保證修改過的節點都被打上這個id
作為標記
- 當 Transient 每次需要對節點進行修改時,它先檢查目標節點是否和自己有相同的
id
,如果相同,那麼這個節點是自己之前曾經修改或複製過的,因此可以在節點上直接進行修改。否則這個節點可能是之前的 Transient 產生的,為了防止改變原來的資料,我們應該複製當前節點一份。
這兩條策略保證我們可以安全地修改 Transient 而不會改變原來的資料。關鍵在於,通過為 Vector Trie 的節點打上id
標誌,Transient 可以判斷一個節點的記憶體是不是由自己分配出來的。對於id
不一樣的節點,它是由當前 List 修改曆史上出現過的某個 Transient 產生的,而之前那個 Transient 可能已經被轉換為持久對象,因此那些節點不應該被直接修改。而如果id
一致,則表明當前 Transient 新近修改過這一節點,我們就可以再次修改。這一步是基於持久化過的 Transient不再會被使用者修改的假定。這也是為什麼如果 Transient 持久化之後仍被修改,則產生的持久化對象的不可變性就會被破壞的原因。
對比了持久化 List 和 Transient 在執行修改時的不同。
圖中上方是不使用 Transient 時的情況,右邊三種不同的顏色代表連續的三次修改。在這種情況下,我們的每次修改都會產生一個新的 Root 節點和一份新的葉子節點。這樣顯然是沒有效率的。
在圖中的下方是使用了 Transient 時的情況,每個節點都包含了一個 ID (圖中紫色標記),當第一次修改進行的時候我們為 Transient分配了一個新的 ID b
,在修改的過程中檢查需要更新的節點,發現他們都具有 ID a
,與當前 ID 不同,因此需要進行一次複製。在接下來的兩次修改中,由於 Transient 在其生命週期中 ID 不變,進行修改時發現目標節點的 ID 與當前 Transient 一致,因此我們不需要再複製節點,可以直接進行 In-place 的更新。
以上就是 Transient 的基本原理。由這個基本原理可以看出,實際上 Transient 和我們的持久化 List可以共用一套底層的資料結構,其差別僅在於 Transient 擁有一個 ID 而 List 沒有。實際上,為了區分這兩種情況,我們為所有的 List 的 HEAD
分配一個特殊的 ID,譬如0
。在 List 和 Transient 之間轉換可以使用下列手段:
- 將 List 轉化為 Transient,我們使用某種 ID 產生器產生一個唯一且不同於 List ID 的 ID (如正整數)並分配給 List
- 將 Transient 轉為 List,我們將 Transient 的 ID 重設為 List ID (如
0
)
在我們的情況下, 由於 Golang 特殊的物件導向設計,我們實際上可以將 List 內部資料結構實現為 Transient 內部資料結構的一個 alias。
Transient 的實現
Unique ID 產生器的實現
對於 Transient 來講,如何為每次修改產生獨一無二的 ID 是一件重要的問題。在現實中存在很多功能各異的獨特 ID 產生演算法,他們有的只能工作在單機情況下,有的可以保證分布式情況下的唯一性,有的產生成本比較高,有的則非常輕量。在這裡,我們選擇最簡單的一種方式:累計uint64
類型的正整數。
通過在單例情況下累計正整數的方式,我們可以保證產生的 Unique ID 在當前進程中是具有唯一性的。其原理是通過sync/atomic
包下的原子操作AddUint64
來實現遞增操作。這一操作既快速又安全執行緒。
以下是實現這一功能的內部包counter
:
1 2 3 4 5 6 7 8 9
|
package counter
import "sync/atomic"
var id uint64 = 0
func Next() uint64 { return atomic.AddUint64(&id, 1) }
|
List 介面的更新
前面我們說道,可以將 List 實現為 Transient 的一個 alias。在這一步,我們先將之前部落格裡實現的 List 內部資料結構重新命名為tListHead
,代表他是一個 Transient List 的 Head,之前實現的方法也都一併轉移過來。除此之外,我們還要在新的tListHead
和它內部的 Trie 樹節點上都添加 ID 欄位:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
// Transient List Head type tListHead struct { id uint64 len int level int offset int root *trieNode tail *trieNode }
// Trie Node type trieNode struct { id uint64 children []interface{} }
|
然後我們重新定義 List 的介面和實現方法:
1 2 3 4 5 6 7 8 9 10
|
type List interface { Get(n int) (interface{}, bool) Set(n int, value interface{}) (List, bool) PushBack(value interface{}) List RemoveBack() (List, interface{}) TransientList() TransientList Len() int }
type listHead tListHead
|
新的介面不再是在原來的基礎上進行修改,而是每次操作都返回新的 List 對象。我們還添加了一個方法用於將當前 List轉換為一個 Transient。注意到listHead
只是tListHead
的一個 alias,因此在 Go 語言中他們之間可以相互類型轉換。接下來我們定義一個全域公用的 empty
變數代表空的 List,由於我們希望所有的空 List 都一樣而持久化 List 是不會被改變的,因此我們並不會在 New 時建立新的Null 物件而是每次都返回這一個對象。這樣也節約了建立 List 時的記憶體消耗。
1 2 3 4 5
|
var empty = &listHead{0, 0, 0, 0, nil, nil}
func New() List { return empty }
|
List 的 Get
因為不會改變元素的值,我們直接通過類型轉化的方法將listHead
轉換為tListHead
並調用後者的對應方法獲得結果:
1 2 3
|
func (head *listHead) Get(n int) (interface{}, bool) { return (*tListHead)(head).Get(n) }
|
對於其它修改操作,我們都先將其轉換為 Transient 執行完修改操作之後再持久化回來。這樣就可以獲得新的 List 了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
func (head *listHead) Set(n int, value interface{}) (List, bool) { t := head.TransientList() if t.Set(n, value) { return t.Persist(), true } else { return head, false } }
func (head *listHead) PushBack(value interface{}) List { t := head.TransientList() t.PushBack(value) return t.Persist() }
func (head *listHead) RemoveBack() (List, interface{}) { if head.len == 1 { value, _ := head.Get(0) return empty, value } else { t := head.TransientList() value := t.RemoveBack() return t.Persist(), value } }
|
下面給出了TransientList
方法的實現。由於 List 的不可變性質需要被保留,因此轉化為 Transient 實際上需要建立立一個 tListHead
,這樣對於 Transient 的修改就不會影響到原來的 List。這裡調用了之前實現的 counter
包來獲得 Unique ID。
1 2 3 4
|
func (head *listHead) TransientList() TransientList { id := counter.Next() return &tListHead{id, head.len, head.level, head.offset, head.root, head.tail} }
|
Transient 修改操作的實現
接下來還需要更新 Transient 修改操作的實現來保證不會影響到其它 Transient 以及之前的持久化 List。之前的 List 在實現的過程中我們已經部分考慮到這種問題了,大部分操作被設計為遞迴執行,同時對 Trie 樹的遞迴操作會賦值給原來節點。在這一基礎上我們首先為 trieNode
添加clone
和setChild
兩個方法。
clone
方法會將當前節點的內容複寫一邊並返回新的節點,它接受一個id
參數,新複製出來的節點的id
屬性會被設定為這一參數。
1 2 3 4 5
|
func (node *trieNode) clone(id uint64) *trieNode { children := make([]interface{}, NODE_SIZE) copy(children, node.children) return &trieNode{id, children} }
|
setChild
則是方便實現修改節點功能的函數,它的第一個參數也是id
。如果傳入的id
與節點原來的id
相同,則這一方法直接在原來的節點上進行修改並返回原來的節點,否則它將會clone
節點並在新的節點上進行操作。
1 2 3 4 5 6 7 8 9 10
|
func (node *trieNode) setChild(id uint64, n int, child interface{}) *trieNode { if node.id == id { node.children[n] = child return node } else { newNode := node.clone(id) newNode.children[n] = child return newNode } }
|
之前 List 修改操作的各個內建函式也都被加上了id
作為參數。除此之外,如果Set
前後 List 包含值相同,我們希望實際效果是對象沒有被修改,在這一步我們也做了一些小心的操作來儘可能保證。具體的代碼不再贅述,完整的代碼請參考這個檔案。
下面是將 Transient 轉化為持久化的函數,由於我們預期使用者在將 Transient 持久化之後不會再修改原來的 Transient(儘管無法從代碼上保證),所以我們可以簡單地使用類型轉換來將 tListHead
轉換為 listHead
。
1 2 3 4 5
|
func (head *tListHead) Persist() List { perisitHead := (*listHead)(head) perisitHead.id = 0 return perisitHead }
|
總結
這篇文章介紹了 Transient 的實現原理和最終實現持久化 List 的方法。可以看出 Transient 是為了提高持久化 List在連續修改操作下的效率而引進的資料結構,同時引入 Transient 也會簡化持久化 List 實現的複雜度。但是如果使用者以不正確的方式使用 Transient ,可能會破壞持久化 List 的持久性。在 Transient 存在的情況下,持久化 List 的修改操作被實現為先轉換為 Transient 並修改,最終將 Transient 持久化這樣的方法。
至此,我們就實現了一個功能較為完整的持久化 List 類。持久化 List 類是持久化資料結構當中最容易實現的一種,但是通過研究它的實現過程,我們可以體會到實現持久化資料結構的一些主要思路。這篇文章的結束宣告 Functional Go這一系列 Blog 暫時告一段落。下一個系列將會開始探討另一類非常重要的資料結構 Map 的持久化實現方法(Hash Array Mapped Trie)。
本文實現的代碼已經開源在 GitHub 上。按照計劃,配合 Blog 的更新,我也會繼續將進一步實現的持久化資料結構添加到這一倉庫中。也歡迎各位讀者對我實現的代碼提出意見建議或反饋 Bug 以及貢獻代碼。