記憶體順序(Memory Order)

2021-09-20 02:08:43 字數 4134 閱讀 6090

這篇文章主要介紹記憶體順序(memory order),然後會結合 rocksdb | leveldb 中的 skiplist 原始碼來具體分析 rocksdb skiplist 如何通過記憶體順序和原子操作做到無鎖併發(一寫多讀)。

memory order

記憶體順序描述了計算機 cpu 獲取記憶體的順序,記憶體的排序既可能發生在編譯器編譯期間,也可能發生在 cpu 指令執行期間。

為了盡可能地提高計算機資源利用率和效能,編譯器會對**進行重新排序, cpu 會對指令進行重新排序、延緩執行、各種快取等等,以達到更好的執行效果。當然,任何排序都不能違背**本身所表達的意義,並且在單執行緒情況下,通常不會有任何問題。

但是在多執行緒環境下,比如無鎖(lock-free)資料結構的設計中,指令的亂序執行會造成無法**的行為。所以我們通常引入記憶體柵欄(memory barrier)這一概念來解決可能存在的併發問題。

memory barrier

記憶體柵欄是乙個令 cpu 或編譯器在記憶體操作上限制記憶體操作順序的指令,通常意味著在 barrier 之前的指令一定在 barrier 之後的指令之前執行。

在 c11/c++11 中,引入了六種不同的 memory order,可以讓程式設計師在併發程式設計中根據自己需求盡可能降低同步的粒度,以獲得更好的程式效能。這六種 order 分別是:

relaxed, acquire, release, consume, acq_rel, seq_cst
memory_order_relaxed: 只保證當前操作的原子性,不考慮執行緒間的同步,其他執行緒可能讀到新值,也可能讀到舊值。比如 c++ shared_ptr 裡的引用計數,我們只關心當前的應用數量,而不關心誰在引用誰在解引用。

memory_order_release:(可以理解為 mutex 的 unlock 操作)

當前執行緒內的所有寫操作,對於其他對這個原子變數進行 acquire 的執行緒可見

當前執行緒內的與這塊記憶體有關的所有寫操作,對於其他對這個原子變數進行 consume 的執行緒可見

memory_order_acquire: (可以理解為 mutex 的 lock 操作)

在這個原子變數上施加 release 語義的操作發生之後,acquire 可以保證讀到所有在 release 前發生的寫入,舉個例子:

c = 0;

thread 1:

while (c.load(memory_order_acquire) != 3)

thread 2:

memory_order_consume:

在這個原子變數上施加 release 語義的操作發生之後,acquire 可以保證讀到所有在 release 前發生的並且與這塊記憶體有關的寫入,舉個例子:

a = 0;

c = 0;thread 1:

memory_order_acq_rel:

可以看見其他執行緒施加 release 語義的所有寫入,同時自己的 release 結束後所有寫入對其他施加 acquire 語義的執行緒可見

memory_order_seq_cst:(順序一致性)

同時會對所有使用此 memory order 的原子操作進行同步,所有執行緒看到的記憶體操作的順序都是一樣的,就像單個執行緒在執行所有執行緒的指令一樣

通常情況下,預設使用 memory_order_seq_cst,所以你如果不確定怎麼這些 memory order,就用這個。

rocksdb skiplist memory order

下面我們結合**具體看下 rocksdb skiplist 中的 memory order 使用。這部分內容需要你提前熟悉下相關**。

rocksdb skiplist 支援一寫多讀。它涉及了三種 memory order,包括 relaxed, release 和 acquire。

一寫多讀有以下幾點限制:

1. 寫入會在外部進行同步

2. 讀取期間 skiplist 不會被銷毀

3. skiplist 節點一旦被插入,不會被刪除,除非 skiplist 被銷毀

4. skiplist 節點一旦被插入,除了 next 域會變更外,其他域不會改變

我們把所有涉及 memory order 的操作分為三類:

1. 讀到舊的 max_height_:不影響查詢,我們可能讀取到新插入的節點也可能讀不到

2. 讀到新的 max_height_:a. 讀到 head_ 指向的舊節點,那麼當我們查詢 key 時,會發現 head_ 指向 nullptr,那麼會立即下降到下一層

b. 讀到 head_ 指向的新插入節點,那麼會使用這個新節點進行查詢

2. skiplist 的節點寫操作。

for (int i = 0; i < height; i++) 

對於正在初始化的節點來說,我們使用 relaxed 語義,即 nobarrier_setnext() 和 nobarrier_next(),因為這時候節點還沒有正式被加入到 skiplist,即對讀執行緒不可見,所以可以使用較弱的 relaxed 語義,但是會在初始化完成後使用 release 語義將節點插入到 skiplist 中,即 setnext()。根據 release 語義,之前所有 relaxed 操作在這個節點被插入到 skiplist 後對於其他執行緒的 acquire 操作都是可見的。

注意這裡插入節點的整個過程並不是原子的,在每一層插入節點才是原子的。所以有個值得注意的點是在節點插入時我們採用從下到上的方式,因為對於 skiplist 來說,key 在 skiplist 內意味著 key 一定在 level 0,所以如果從上到下插入的話可能出現幻讀,即在上層查詢比較的時候存在這個 key,但是當下降到 level 0 時發現這個 key 並不存在。

3. skiplist 的節點讀操作。對於節點的所有讀操作,都會使用 acquire 語義,也就是 next() 函式,因為要保證我們讀取的節點是最新的。

除了順序插入這個優化,在這個優化裡會用 relaxed 語義進行節點讀取,也就是 nobarrier_next() 函式,因為對於寫來說,會有外部同步,所以即使前後兩次插入執行緒不同,使用 relaxed 語義也能讀到最新的節點。

ps:

rocksdb skiplist 滿足線性一致性,即 linearizability,如果你了解了線性一致性可以去看下 skiplist 的單元測試。

rocksdb 裡面還有乙個 skiplist,叫做 inlineskiplist,它是支援多讀多寫的,節點插入的時候會使用 cas 判斷節點的 next域是否發生了改變,這個 cas 操作使用預設的memory_order_seq_cst。

結語:

memory order 是每個底層程式設計師都需要花時間去掌握的東西,至少會讓你對於併發程式設計的理解會更深。這裡有個對於 c++ 11 memory order 的知乎回答, 講得很簡潔明瞭,知乎使用者:如何理解 c++11 的六種 memory order?。

然後 rocksdb 也提供了乙個很好的學習 memory order 的地方—— skiplist,當初我看**時直接跳過了原子操作相關的東西,因為感覺很複雜,現在看來花點時間還是能弄明白的。

另外 acquire-release 語義最近也被我放進了自己寫的專案裡,替代了之前的 full memory barrier,鏈結就不放出來了。

以下兩個官方文件很適合做延伸閱讀,尤其是第乙個 linux kernal 文件,講得非常詳細,舉了很多例子,而且還涉及了很多其他的東西,第二個文件是 c++11 memory order 的 reference。

linux-kernal-memory-barrier

std::memory_order - cppreference.com

原文發布時間為:2018-10-4

順序表(動態分配記憶體

include 順序表 動態分配記憶體 2010 04 16 11 29 34 include include include include 用來清屏 using namespace std const int list size 1000 const int list size 10 typed...

C 併發程式設計(六) 記憶體順序

有六個記憶體順序選項可應用於對原子型別的操作 memory order relaxed,memory order consume,memory order acquire,memory order release,memory order acq rel,以及memory order seq cst...

JVM載入物件時記憶體載入順序

在開發中,有時會遇到這樣的情況 我明明給乙個變數賦值了,為什麼在使用該變數時卻是沒有值的,這個和jvm的記憶體載入順序有關,當你使用該變數時,這個變數還沒初始化完成。首先我們來看一段 public class objectloadmemorytest extends futest override ...