1. 程式人生 > >[2017/08/22]高效能C/C++程式設計中的那些資料結構

[2017/08/22]高效能C/C++程式設計中的那些資料結構

本文首發於騰訊KM,如轉載請註明作者,出處。

偶然在k吧首頁看到了luckyzuo的分享,因為自己一直對這方面很感興趣,所以在工作之餘對照ppt聽了講座錄音,受益匪淺。這次分享提到了幾種資料結構,我結合了自己的一些理解,寫了這篇文章。寫的時候查閱了許多資料,越發認識到自己基礎知識的薄弱(還是要學習一個)。由於水平有限,若有錯誤,請各位指正。

目錄:

一. 雜湊表的弱點

1.1 hash的硬傷

  1.1.1 沒有通適的高效hash函式

  1.1.2 容易存在衝突

  1.1.3 預估容量困難,儲存資料量增多導致衝突加劇,擴容的話需要對已儲存元素做rehash

1.2 一些彌補:動態多階雜湊

二. 紅黑樹 VS skip list

2.1 介紹

  2.1.1 紅黑樹概述

  2.1.2 skip list概述

2.2 高併發友好:紅黑樹 || skip list?

  2.2.1 什麼樣的資料結構才是高併發友好的?

  2.2.2 連結串列——高併發友好的資料結構

  2.2.3 紅黑樹——牽一髮而動全身

2.3 一些缺點:紅黑樹 & skip list :

三.快取友好的CP :有序陣列+二分查詢

3.1 使用場景

3.2 快取命中率的那些事兒

  3.2.1 快取不命中的時間處罰

  3.2.2 CPU預取與快取命中率

  3.2.3 快取命中率:線性結構 VS 非線性結構

3.3 快取友好的CP :有序陣列+二分查詢

談到高效能,選擇合理的資料結構總是必要的。這次分享中提到了這幾種資料結構:①AVL樹,紅黑樹; ③skip list(跳錶) ②Hash表 ④有序陣列。下面我將結合自己的理解,談一談這幾種資料結構。

一. 雜湊表的弱點

談到快速查詢,必須要提到的資料結構就是雜湊表。它以理論上O(1)的時間複雜度橫掃四海八荒無敵手。分享中丟擲了一個經驗性的結論,就是在資料量很大(百萬以上)的情況下,雜湊表比AVL樹的查詢效率更高。hash的好處我就不多說了,主要總結一下分享中提到的三處hash的硬傷:

1.1 hash的硬傷

1.1.1 沒有通適的高效hash函式


對於不同的場合,由於資料型別和資料量等因素各不相同,所以要綜合考慮各種情況,選取最合適的hash函式,儘量減少衝突的發生。
1.1.2. 容易存在衝突
O(1)的時間複雜度畢竟還是理論上的,在實際使用中,經常會出現裝載因子衝突的情況。
c. 預估容量困難,儲存資料量增多導致衝突加劇,擴容的話需要對已儲存元素做rehash
初期資料量小的時候,選用的hash函式尚能保證低衝撞,從而保證hash效率。但到資料量增多到一定程度,衝撞加劇,這個時候就要考慮對hash表進行擴容。擴容的時候,一方面增加桶的數量,另一方面要把所有已儲存的資料rehash一遍,重新分配並加入新的桶內。這樣做會使效率降低。
1.2 一些彌補:動態多階雜湊
在上面的三點硬傷中,預估容量困難和容易存在衝突是有緩解方法的。可以採用動態多階hash的方法。這裡就不再介紹多級hash了,km上有很多文章。動態多階hash採用動態擴增hash階數的方法,一方面降低了資料增長帶來衝突的問題,另一方面不用預估容量(直接動態擴增hash階數即可),最後還有效地節省了部分空間。
最後,如果想徹底避免以上的問題,可以考慮使用紅黑樹和skip list。在使用這兩個資料結構的時候,不用考慮衝突和容量。

二. 紅黑樹 VS skip list

2.1 介紹

2.1.1 紅黑樹概述
紅黑樹就不多介紹了,和AVL樹一樣,都是二叉查詢樹。它的查詢,插入,刪除操作的時間複雜度都是O(log2 n)。在C++STL庫中,以它作為實現set和map的底層資料結構。紅黑樹相對於AVL樹的好處是,它並不追求完全平衡,所以避免了AVL樹每次更改所進行的大量平衡度統計計算,開銷要小得多。此外,紅黑樹以此來換取增刪節點時候旋轉次數的降低,從而提高插入效率。
2.1.2 skip list概述
除了紅黑樹,這場分享中還提到了一個對我來說十分陌生的資料結構:skip list,也就是跳錶。由於錄音中並沒有對跳錶做詳細介紹,所以下去查了一些資料。skip list的實現我就不多說了。
skip list是一個很有趣的資料結構。它一方面保持了List的優勢:插入刪除靈活,另一方面通過逐層隨機建索引,採用空間換時間的思想,巧妙地解決了List查詢元素不易且效率低的硬傷。查了查資料,發現 skip list 和紅黑樹的效能是不相上下的。和紅黑樹一樣,它的查詢時間複雜度也是O(log2 n);從空間角度講,除了資料儲存,它們各自都需要分配另外的索引域。但是,比起 skip list, 紅黑樹有一些明顯的缺點。比如它的實現太困難了(手寫紅黑樹是無比的凶殘),再比如它對高併發的友好度並不如 skip list。這也是接下來要深入討論的一個點。

2.2 高併發友好:紅黑樹 || skip list?

2.2.1 什麼樣的資料結構才是高併發友好的?
在比較二者對高併發的友好度之前,我們首先要確定的是,什麼樣的資料結構才是高併發友好的?
首先,一個高併發友好的資料結構可以允許多個執行緒同時使用資料。如果多個執行緒要同時爭奪這個資料結構,它可以允許讀者和寫者併發執行在這個資料結構的不同部分。其次,在多核系統中,由於存在快取一致性,所以得考慮快取記憶體同步的成本,使其最小化。當一個執行緒更新資料結構的一部分時,要想讓這個改動對其他執行緒可見,需要在cache中移動的記憶體應儘量是其更改的那一部分,而不是更多其他與更改無關的部分。
2.2.2 連結串列——高併發友好的資料結構
為了方便討論,我們在這裡用連結串列代替skip list。首先丟擲結論,連結串列是一個高度併發友好的資料結構,原因有以下兩點:
①連結串列可以同時允許多個執行緒使用
下面來解釋原因。先來看第一點,連結串列可以同時允許多個執行緒使用嗎?答案是肯定的。因為在其上加的鎖是可以高度區域性化的。為什麼呢?首先看寫的情況:插入只需要鎖定兩個現有結點,即即將插入節點的前驅和後繼;刪除只需要鎖定三個節點,即被刪除的結點,及其前驅和後繼。下面再看讀的情況,一個讀執行緒最多隻會持有兩個結點的鎖,那就是遍歷連結串列的時候。我們可以採用hand-by-hand的加鎖方法,只需要保證下一個讀的節點不會在讀之前被其他執行緒修改(如果下個結點提前被刪除,那麼我們就會訪問到一塊已經不存在的記憶體),所以持有當前讀的結點的鎖和下一個結點的鎖就足夠了。更多情況下我們只對當前正在讀的結點感興趣,所以不需要持有兩把鎖,只需要鎖住當前結點就夠了。因為鎖粒度低,所以允許大量執行緒在同一連結串列上工作:它們只要處理連結串列的不同部分,就不會發生衝突。
②連結串列可以降低快取記憶體同步成本
再來看第二點,毋庸置疑的是,在多核系統裡對連結串列進行操作的時候,進行快取記憶體的同步成本很小。首先需要說明的是,在對快取記憶體進行讀寫的時候,寫的成本總是高於讀的成本。因為寫一個快取記憶體時,需要廣播其他快取記憶體同步資料,而讀則什麼都不用做。基於這個認知,我們也可以知道,大量的寫入會花費更多。那連結串列在這一點上表現如何呢?首先看寫中刪除結點的情況,需要傳輸的唯一資料是包含兩個相鄰結點的快取,這些記憶體會被傳輸到所有其他cpu核的快取,這些cpu會在隨後訪問該連結串列。再來看插入的情況,只需要傳遞三個結點的快取,顯然成本是很小的。
因為連結串列的每一個元素只是儲存在單一結點,而不像線性表那樣,插入和刪除對其一側的所有元素都有影響。那麼是不是意味著,基於結點的資料結構都有利於高併發呢?答案是否定的,下面就來說說紅黑樹。
2.2.3 紅黑樹——牽一髮而動全身
首先看一下紅黑樹的特性:它將每個結點標記成紅色或黑色,且在每個插入和刪除操作中,都要進行一次調整,來避免其不同分支太不平衡。而這種調整是通過子樹的旋轉來實現的。這就出現了一個問題:其中涉及的結點可能包括被操作結點的父親節點or叔叔結點,往上到祖父母結點,甚至最後牽涉到根節點。因為樹之間相隔任意距離的更新,都可能觸及相同的結點。觸及相同結點的概率隨著結點所在層數的減少而增加,直到根節點。
現在我們來討論它是否適合多執行緒操作。根據上面提到的紅黑樹特性來看,它的改變不可能是區域性的。再來看它是否是快取友好的,同理,由於牽涉到的結點諸多且難以預料,每次在紅黑樹上進行寫操作時,我們都要在各個快取記憶體中寫入遠遠大於修改資料的資料量,成本太高。所以,紅黑樹不是一種高度併發友好的資料結構。

2.3 一些缺點:紅黑樹 & skip list :

紅黑樹和skip list這兩種非線性結構雖然插入刪除比較靈活,但有一個缺點,那就是它們浪費空間。原因之一就是它們需要額外的空間儲存鏈域。另外,它們結點分配的記憶體對齊也會分配多餘空間。還有另外一個缺點,它們的快取命中率比較低。至於為什麼,後面會講到。考慮到以上兩點缺點,我們可以想到另外一種資料結構:有序陣列。這種線性的資料結構在這兩方面的效能是明顯更優的。

三.快取友好的CP :有序陣列+二分查詢

先丟擲一個結論:在查詢的時候,由於有序陣列+二分查詢的快取命中性好,所以它的查詢效率比hash,avl樹,skip list都快。那麼,在什麼樣的場景下,使用有序陣列做查詢更合適呢?

3.1 使用場景

在這次分享中,luckyzuo提到了這樣一種場景:假設你有一個穩定的資料集合,僅要從裡面查詢資料。可以考慮在程式啟動的時候,對資料排序,然後在有序集合上做二分查詢。這樣做有以下優點:第一,資料結構和演算法都實現簡單;第二,效率高:僅僅需要簡單的一次排序,以後的查詢時間複雜度都是O(log2 n);第三點,有序陣列有較高的快取命中率,所以更高效。
不妨考慮一個問題,為什麼線性結構的快取命中率要比非線性結構高呢?在解釋這個問題之前,先來解釋一下快取命中率這個概念。

3.2 快取命中率的那些事兒

當CPU想取某塊資料時,它首先從快取記憶體中查詢這個資料。如果其剛好存在,則稱之為快取記憶體命中,否則則稱為快取記憶體不命中。
3.2.1 快取不命中的時間處罰
當出現快取不命中的情況,大量的時間會被浪費。若不命中,會有不命中處罰。快取記憶體L1從L2中取資料,處罰是10個指令週期的時間消耗。若還取不到資料,L2再去到L3取資料,會有50個指令週期的處罰。快取記憶體從主存中得到的服務的處罰,是200個指令週期。這種層層取資料的時間消耗是很可怕的。
那麼,怎樣做可以提高命率,避免這些時間浪費呢?
3.2.2 CPU預取與快取命中率
要提高快取命中率,首先得了解CPU的一個能力,預取。
在記憶體和cache之間,預取是由CPU根據過去訪問的資料地址資訊,從此開始線性地往下獲取接下來的若干個記憶體單元,把這些未來可能訪問的資料預先存入cache,從而在資料真正被用到時不會造成cache失效。
在多級快取記憶體之間,預取是當高一級快取往低一級快取中取資料時,除了獲取目標資料所在的一個cache line,高一級快取會自動預取與其相鄰的一個cache line。
3.2.3 快取命中率:線性結構 VS 非線性結構
通過了解預取我們可以知道,在對一個線性資料結構進行操作時,由於其中資料的記憶體地址都是連續的,所以要用到的資料有極大概率被裝入快取中,避免了頻繁的各級cache資料交換所帶來的時間懲罰。
再來看看非線性結構:因為每個結點的記憶體地址都是不連續的,所以在獲取一個結點資料的時候,往往不能正確預取到這個資料結構的其他部分,快取命中率較低。因為我們下一次需要的結點有很大概率不存在於cache中,所以需要去記憶體裡去獲取。這一個層層去取資料過程是很浪費時間的。

3.3 快取友好的CP :有序陣列+二分查詢

我們已經知道了,有序陣列是快取友好的資料結構,同樣,二分查詢也是一種快取友好的演算法。
二分查詢還有另外一個名字,折半查詢,或許這個名字能更好地解釋其原理。我們都知道,二分查詢每一輪查詢結束之後,查詢範圍都會在原有基礎上縮小一半。所以,資料變得越來越密集,再加上有序陣列的連續性,也就越容易被命中。所以,這就是有序陣列+二分查詢效率如此高的原因。