何為跳表
跳躍表(skiplist),簡稱「跳表」。是一種在鏈表基礎上進行優化的數據結構,最早由 William Pugh 在論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》中提出。
William Pugh 于 1989 在論文中將「跳表」定位為:一種能夠替代平衡樹的數據結構,比起使用強制平衡算法的各種平衡樹,跳表采用一種隨機平衡的策略。因此跳表擁有更為簡單的算法實現,同時擁有與平衡樹相媲美的時間復雜度(logn 級別)和操作效率。
設計思想
普通鏈表是一種順序查找的數據結構。即在查找某個元素時需要依次遍歷所有元素進行比較。且在元素有序的情況下也無法像數組那樣利用二分查找,固元素查詢時間復雜度為 O(n),若需維護鏈表有序,元素插入的時間復雜度也同樣需要 O(n)。
在一些數據量極大的場景下,O(n) 的時間復雜度仍然存在優化的空間。
平衡二叉查找樹 AVL 或者紅黑樹是經常被用來實現上述優化的數據結構。但這些平衡樹需要在整個過程中保持樹的平衡,這帶來了一定的復雜度。
而跳表的核心思想類似于對有序鏈表中元素建立索引。整個跳表按照層次進行構建,底層為原始的有序鏈表,然后抽取鏈表中的關鍵元素到上一層作為索引,從剛構建的索引中可以繼續抽取關鍵元素到新的一層,以此類推。
跳表原理結構如下圖所示:
以查找元素 2 為例,查找將從第 2 層索引確定 1 ~ 5 范圍,再到第 1 層索引進一步確定 1 ~ 3 范圍,最后回到底層原始鏈表查找到元素 2。
性能分析
上文提到了「抽取每一層的關鍵元素到上一層作為索引」,但是隨著數據量的增加每一層的結點也會越來越多,該如何選取結點讓跳表的結點呈現我們所需的分布?
跳表采用「投硬幣」的方式決定結點是否向上提升。
假設現在底層有序鏈表有 n 個結點且投硬幣概率為 50%,則第一層應該[1]有 n/2 個索引結點,第二層有 n/4 個索引結點,第 i 層索引有 n/2i 個結點,當 n/2i 等于 2 時,意味著已經到了最高層。此時由 n/2i = 2,可推導出 i = log2n。
即投硬幣概率為 50% 時,跳表層高為 log2n,且由于每兩個結點就有一個結點上升為一個索引結點。所以當從最上層向下搜索的過程中,每一個最多只會比較 3 個結點(常數級),所以整個搜索過程時間復雜度最終為 log2n。
[1] 概率事件,并非一定具有準確的 n/2 結點。
將上述過程進一步擴展概率為 p,則時間復雜度為 log1/pn。其中每一層比較的次數不超過 1/p。
上述是為了方便理解而簡化的概率推導過程,結論也建立在 n 足夠大的前提下。實際推導過程要復雜很多,有興趣的讀者可以閱讀論文原文:《Skip Lists: A Probabilistic Alternative to Balanced Trees》
實現
上文介紹了跳表的基本思想,其中為了方便理解和講述,我們將索引結點單獨繪制成一個結點。如果完全按照上文圖示實現跳表,則跳表需要額外 n 個結點空間。但在實際實現時,無需額外結點只需使用指針指向相應結點即可,因此只是多出了 n 個指針而已。
即跳表實際實現的結構如下圖所示:
其中黃色格子為數據結點,白色格子為數據結點內的指針。
LevelDB 中的跳表源碼解析
我們以 LevelDB 中的跳表實現 skiplist.h 為例,分析跳表的具體實現
設計結點結構如下:
template <typename Key, class Comparator>
struct SkipList<Key, Comparator>::Node {
explicit Node(const Key& k) : key(k) {}
// 存儲 key
Key const key;
// ......
private:
// 下標表示結點的層次 level
// 整個數組表示該結點在各層次的存儲情況
std::atomic<Node*> next_[1];
}
如下圖所示:
進一步理解如下圖所示:
其中上圖 head_ 內的 next_ 數組存儲著指向各個索引層次第一個元素的指針。
其它每個結點(如圖中的結點 1)中的 next_ 數組包含了如下信息:
結點在各個索引層中的下一個結點的指針
查詢元素
元素查詢主要邏輯集中在 FindGreaterOrEqual 這個函數,就以這個函數為例,體現元素查詢過程:
// 搜索大于等于 key 的所有結點
template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node*
SkipList<Key, Comparator>::FindGreaterOrEqual(const Key& key,
Node** prev) const {
Node* x = head_;
// 獲取當前結點的層高
// 從最上層的索引層開始遍歷
int level = GetMaxHeight() - 1;
while (true) {
// 假設 next_ = [*3, *5, *6]
// 表示該結點:
// 在第 2 層的下一個索引結點為 6
// 在第 1 層的下一個索引結點為 5
// 在第 0 層的下一個結點為 3
// 那么就可以直接通過 next_[level] 找到下一個索引結點
Node* next = x->Next(level);
if (KeyIsAfterNode(key, next)) { // key 是否在當前結點之后(大小關系由比較器最終確認)
// Keep searching in this list
// 繼續遍歷搜索該層的剩余結點
x = next;
} else { // key 是否在當前結點之后(大小關系由比較器最終確認)
// 記錄結點到 prev 數組
// prev 數組記錄每個索引層次要插入 key 的位置
if (prev != nullptr) prev[level] = x; prev
if (level == 0) { // 遍歷到 0 層,遍歷結束
return next;
} else {
// Switch to next list
// 進入下一層遍歷
level--;
}
}
}
}
刪除元素
LevelDB 業務層面無刪除結點的需求,見源碼注解如下:
// (1) Allocated nodes are never deleted until the SkipList is
// destroyed. This is trivially guaranteed by the code since we
// never delete any skip list nodes.
插入元素
template <typename Key, class Comparator>
void SkipList<Key, Comparator>::Insert(const Key& key) {
// TODO(opt): We can use a barrier-free variant of FindGreaterOrEqual()
// here since Insert() is externally synchronized.
Node* prev[kMaxHeight];
// 獲取所有大于等于(比較器定義) key 的結點
// prev 保存各個索引層要插入的前一個結點
Node* x = FindGreaterOrEqual(key, prev);
// Our data structure does not allow duplicate insertion
// 不允許插入重復的元素
// 那么為空,表示沒有 >= key 的結點。要么不等于列表中的所有 key,表示沒有重復元素
assert(x == nullptr || !Equal(key, x->key));
// 生成一個隨機高度
int height = RandomHeight();
// 如果隨機高度比當前最大高度大
if (height > GetMaxHeight()) {
// prev 下標從原先的最大 height 到最新的最大 height 之間初始化為 head_
for (int i = GetMaxHeight(); i < height; i++) {
prev[i] = head_;
}
// It is ok to mutate max_height_ without any synchronization
// with concurrent readers. A concurrent reader that observes
// the new value of max_height_ will see either the old value of
// new level pointers from head_ (nullptr), or a new value set in
// the loop below. In the former case the reader will
// immediately drop to the next level since nullptr sorts after all
// keys. In the latter case the reader will use the new node.
// 原子操作:保存最新的最大高度
max_height_.store(height, std::memory_order_relaxed);
}
// 創建一個新結點
x = NewNode(key, height);
for (int i = 0; i < height; i++) {
// NoBarrier_SetNext() suffices since we will add a barrier when
// we publish a pointer to "x" in prev[i].
//
// 插入新結點,即:
// new_node->next = pre->next;
// pre->next = new_node;
x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
prev[i]->SetNext(i, x);
}
}
并發處理
LevelDB 的跳表實現支持單線程寫、多線程讀,為了滿足該特點,LevelDB 在更新和讀取時需要注意 C++ memory_order 的設置。
在講解 LevelDB 跳表中的 memory_order 之前需要先介紹相關的基礎知識。
原子性
原子寓意著「不可再分的最小單位」,固計算機領域提及的原子性操作指的是那些「不可或不該再被切分(或中斷)的操作」。
而關于原子性,我們應當具有一個基本的認知:高級語言層面,單條語句并不能保證對應的操作具有原子性。
在使用 C、C++、Java 等各種高級語言編寫代碼時,不少人會下意識的認為一條不可再分的單條語句具有原子性,例如常見 i++。
// 偽碼
int i = 0;
void increase() {
i++;
}
int main() {
/* 創建兩個線程,每個線程循環進行 100 次 increase */
// 線程 1
Thread thread1 = new Thread(
run() {
for (int i = 0; i < 100; i++) increase();
}
);
// 線程 2
Thread thread2 = new Thread(
run() {
for (int i = 0; i < 100; i++) increase();
}
);
}
如果 i++ 是原子操作,則上述偽碼中的 i 最終結果為 200。但實際上每次運行結果可能都不相同,且通常小于 200。
之所以出現這樣的情況是因為 i++ 在執行時通常還會繼續劃分為多條 CPU 指令。以 Java 為例,i++ 編譯將形成四條字節碼指令,如下所示:
// Java 字節碼指令
0: getstatic
1: iconst_1
2: iadd
3: putstatic
而上述四條指令的執行并不保證原子性,即執行過程可被打斷??紤]如下 CPU 執行序列:
- 線程 1 執行 getstatic 指令,獲得 i = 1
- CPU 切換到線程 2,也執行了 getstatic 指令,獲得 i = 1。
- CPU 切回線程 1 執行剩下指令,此時 i = 2
- CPU 切到線程 2,由于步驟 2 讀到的是 i = 1,固執行剩下指令最終只會得到 i = 2
以上四條指令是 Java 虛擬機中的字節碼指令,字節碼指令是 JVM 執行的指令。實際每條字節碼指令還可以繼續劃分為更底層的機器指令。但字節碼指令已經足夠演示原子性的含義了
如果對底層 CPU 層面如何實現機器指令的原子操作[1]感興趣,可查閱 Spinlock、MESI protocol 等資料。
[1] 一條 CPU 指令可能需要涉及到緩存、內存等多個單元的交互,而在多核 CPU 的場景下并會存在與高層次多線程類似的問題。固需要一些機制和策略才可實現機器指令的原子操作。
有序性
上述已經提到 CPU 的一條指令執行時,通常會有多個步驟,如取指IF 即從主存儲器中取出指令、ID 譯碼即翻譯指令、EX 執行指令、存儲器訪問 MEM 取數、WB 寫回。
即指令執行將經歷:IF、ID、EX、MEM、WB 階段。
現在考慮 CPU 在執行一條又一條指令時該如何完成上述步驟?最容易想到并是順序串行,指令 1 依次完成上述五個步驟,完成之后,指令 2 再開始依次完成上述步驟。這種方式簡單直接,但執行效率顯然存在很大的優化空間。
思考一種流水線工作:
指令1 IF ID EX MEM WB
指令2 IF ID EX MEM WB
指令3 IF ID EX MEM WB
采用這種流水線的工作方式,將避免 CPU 、存儲器中各個器件的空閑,從而充分利用每個器件,提升性能。
同時注意到由于每條指令執行的情況有所不同,指令執行的先后順序將會影響到這條流水線的負載情況,而我們的目標則是讓整個流水線滿載緊湊的運行。
為此 CPU 又實現了「指令重排」技術,CPU 將有選擇性的對部分指令進行重排來提高 CPU 執行的性能和效率。例如:
x = 100; // #1
y = 200; // #2
z = x + y; // #3
雖然上述高級語言的語句會編譯成多條機器指令,多條機器指令還會進行「指令重排」,#1 語句與 #2 語句完全有可能被 CPU 重新排序,所以程序實際運行時可能會先執行 y = 200; 然后再執行 x = 100;
但另一方面,指令重排的前提是不會影響線程內程序的串行語義,CPU 在重排指令時必須保證線程內語義不變,例如:
x = 0; // #1
x = 1; // #2
y = x; // #3
上述的 y 一定會按照正常的串行邏輯被賦值為 1。
但不幸的是,CPU 只能保證線程內的串行語義。在多線程的視角下,「指令重排」造成的影響需要程序員自己關注。
// 公共資源
int x = 0;
int y = 0;
int z = 0;
Thread_1: Thread_2:
x = 100; while (y != 200);
y = 200; print x
z = x + y;
如果 CPU 不進行「亂序優化」執行,那么 y = 200 時,x 已經被賦值為 100,此時線程 2 輸出 x = 200。
但實際運行時,線程 1 可能先執行 y = 200,此時 x 還是初始值 0。線程 2 觀察到 y = 200 后,退出循環,輸出 x = 0;
C++ 中的 atomic 和 memory_order
C++ 提供了 std::atomic 類模板,以保證操作原子性。同時也提供了內存順序模型 memory_order指定內存訪問,以便提供有序性和可見性。
其中 memory_order 共有六種,如下表所示:
memory_order | 解釋 |
---|---|
memory_order_relaxed | 只保證原子操作的原子性,不提供有序性的保證 |
memory_order_consume | 當前線程中依賴于當前加載的該值的讀或寫不能被重排到此加載前 |
memory_order_acquire | 在其影響的內存位置進行獲得操作:當前線程中讀或寫不能被重排到此加載前 |
memory_order_release | 當前線程中的讀或寫不能被重排到此存儲后 |
memory_order_acq_rel | 帶此內存順序的讀修改寫操作既是獲得操作又是釋放操作 |
memory_order_seq_cst | 有此內存順序的加載操作進行獲得操作,存儲操作進行釋放操作,而讀修改寫操作進行獲得操作和釋放操作,再加上存在一個單獨全序,其中所有線程以同一順序觀測到所有修改 |
六種 memory_order 可以組合出四種順序:
- Relaxed ordering 寬松順序
Thread1:
y.load(std::memory_order_relaxed);
Thread2:
y.store(h, std::memory_order_relaxed);
寬松順序只保證原子變量的原子性(變量操作的機器指令不進行重排序),但無其他同步操作,不保證多線程的有序性。
- Release-Acquire ordering 釋放獲得順序
std::atomic<std::string*> ptr;
int data;
void producer()
{
std::string* p = new std::string("Hello"); // #1
data = 42; // #2
ptr.store(p, std::memory_order_release);
}
void consumer()
{
std::string* p2;
while (!(p2 = ptr.load(std::memory_order_acquire)))
;
assert(*p2 == "Hello"); // 絕無問題 #3
assert(data == 42); // 絕無問題 #4
}
int main()
{
std::thread t1(producer);
std::thread t2(consumer);
t1.join(); t2.join();
}
如例子所示,store 使用 memory_order_release,load 使用 memory_order_acquire,CPU 將保證如下兩點:
- store 之前的語句不允許被重排序到 store 之后(例子中的 #1 和 #2 語句一定在 store 之前執行)
- load 之后的語句不允許被重排序到 load 之前(例子中的 #3 和 #4 一定在 load 之后執行)
同時 CPU 將保證 store 之前的語句比 load 之后的語句「先行發生」,即先執行 #1、#2,然后執行 #3、#4。這實際上就意味著線程 1 中 store 之前的讀寫操作對線程 2 中 load 執行后是可見的。
注意是所有操作都同步了,不管 #3 是否依賴了 #1 或 #2
值得關注的是這種順序模型在一些強順序系統例如 x86、SPARC TSO、IBM 主框架上是自動進行的。但在另外一些系統如 ARM、Power PC 等需要額外指令來保障。
- Release-Consume ordering 釋放消費順序
理解了釋放獲得順序順序后,就非常容易理解釋放消費順序,因為兩者十分類似。
std::atomic<std::string*> ptr;
int data;
void producer()
{
std::string* p = new std::string("Hello"); // #1
data = 42; // #2
ptr.store(p, std::memory_order_release);
}
void consumer()
{
std::string* p2;
while (!(p2 = ptr.load(std::memory_order_consume)))
;
assert(*p2 == "Hello"); // #3 絕無出錯: *p2 從 ptr 攜帶依賴
assert(data == 42); // #4 可能也可能不會出錯: data 不從 ptr 攜帶依賴
}
int main()
{
std::thread t1(producer);
std::thread t2(consumer);
t1.join(); t2.join();
}
store 使用 memory_order_release,load 使用 memory_order_consume。其效果與 Release-Acquire ordering 釋放獲得順序類似,唯一不同的是并不是所有操作都同步(不夠高效),而是只對依賴操作進行同步,保證其有序性上例就是 #3 一定發生在 #1 之后,因為這兩個操作依賴于 ptr。但不會保證 #4 一定發生在 #2 之后(注意「釋放獲得順序」可以保證這一點)。
- Sequential consistency 序列一致順序
理解上述幾種順序后,Sequential consistency 就很好理解了。
「釋放獲得順序」是對某一個變量進行同步,Sequential consistency 序列一致順序則是對所有變量的所有操作都進行同步。
store 和 load 都使用 memory_order_seq_cst,可以理解對每個變量都進行 Release-Acquire 操作。所以這也是最慢的一種順序模型。
LevelDB 跳表的并發處理
在 LevelDB 的 skiplist.h 中,涉及到了 atomic 和 memory_order,我們結合上文的介紹來理解其中的實現邏輯。
首先對跳表的最大高度 max_height_ 設置了 atomic,并采用 memory_order_relaxed 進行讀寫:
// 確保在所有平臺下以及內存對齊或非對齊情況下
// 對 max_height_ 的讀寫都是原子性的
std::atomic<int> max_height_;
// ....
// store 和 load 都采用了 memory_order_relaxed
// 即采用 Relaxed ordering 寬松順序
// 即對多線程有序性不做保證
max_height_.store(height, std::memory_order_relaxed);
// ...
max_height_.load(std::memory_order_relaxed);
max_height_ 如同實現一個計數器 i++ 一樣,如果多線程讀不是原子性的,那么就會造成類似某個線程讀到舊數據或不完整數據的局面。
其次對跳表結點的索引結點也進行了 atomic 的處理,如下所示:
std::atomic<Node*> next_[1];
// ...
// 插入結點時
next_[n].store(x, std::memory_order_release);
// ...
// 讀取結點時
next_[n].load(std::memory_order_acquire);
從中可知,對 next_[n] 使用了 Release-Acquire ordering 釋放獲得順序,其可以保證某個線程進行 store 后,其他所有執行 load 的讀線程都將讀到 store 的最新數據。因為釋放獲得順序保證了 先 store 后 load 的執行順序。
這也正是 LevelDB 的跳表支持多線程讀的原因。
值得注意的是其中還實現了 NoBarrier_SetNext 和 NoBarrier_Next。這兩個沒有內存屏障的操作實際就是使用了寬松順序對 next_[n] 進行讀寫。這種操作是線程不安全的,為什么需要這種操作?
void SkipList<Key, Comparator>::Insert(const Key& key) {
// ...
// 插入新結點
x = NewNode(key, height);
for (int i = 0; i < height; i++) {
// 這兩句相當于:
// new_node->next = pre->next;
// pre->next = new_node;
x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
prev[i]->SetNext(i, x);
}
}
在一個鏈表中插入一個結點的步驟實際就是:
new_node->next = pre->next;
pre->next = new_node;
而 new_node->next = pre->next; 這一步賦值不必立馬對所有讀線程可見,因為此時還未完全插入結點,并不影響讀線程的讀取。如下圖所示:
為什么要特意使用 NoBarrier_SetNext ?因為寬松順序效率更高,可以看到 LevelDB 的跳表實現為了性能已經優化到了如此變態的地步。
附錄
源碼注解
對 LevelDB 中跳表 skiplist.h 的源碼實現做了詳細注解,可見源碼注解。
操作樣例
- 創建一個 SkipList,數據結構初始化如下圖所示:
- 新增一個結點,且設 key = 10,隨機 height = 4,則數據結構如下圖所示:
- 繼續新增一個結點,且設 key = 5,隨機 height= 3,則數據結構如下圖所示:
-
繼續新增一個結點,且設 key = 4,隨機 height = 5,則數據結構如下圖所示:
implement_3.png
參考資料
跳躍列表 Wikipedia
Skip list Wikipedia
Skip Lists: A Probabilistic Alternative to Balanced Trees
Atomic_semantics Wikipedia
Spinlock Wikipedia
MESI_protocol Wikipedia
CPU的工作過程
std::memory_order
周志明.2011.深入理解 Java 虛擬機
葛一鳴,郭超.2015.Java高并發程序設計
汪
汪