檢視〈Concurrency Primer〉草稿並記錄閱讀過程中的疑惑,嘗試用 C11 Atomics 改寫原有程式碼,並確保在 x86-64 及 QEMU/Arm 正確運作。預計產出:
檢視〈Concurrency Primer〉草稿並記錄閱讀過程中的疑惑,,參見〈Concurrency Primer〉導讀,確保原本的 C++ 程式都以 C11 Atomics 改寫並驗證。
改寫時,先列出原本的 C++11 程式碼,隨後則是改寫後的 C11 Atomics 實作
C++11 程式碼:
以 C11 改寫後的程式碼:
C++11 程式碼:
C11 程式碼:
這邊利用 TAS 與 Atomic Flag 製作一個類似於互斥鎖 (Mutex) 的程式。使用時將 lock 與 unlock 放在 Critical Section 頭尾便可。
根據 IBM Documentation 對 Atomic Flag 的解釋,若是 C11 Atomic Flag 沒有被初始化,則它會被初始成未定狀態。
You can use the macro ATOMIC_FLAG_INIT to initialize an atomic_flag object to the clear state. If an atomic_flag object is not explicitly initialized with ATOMIC_FLAG_INIT, it is initially in an indeterminate state.
C++11 程式碼:
C11 程式碼:
CAS (又稱 Compare Exchange) 可以用來比較並在 ts = expected 時修改數值,在這個例子中它被用來停止迴圈。
C++11 程式碼:
C11 程式碼:
這邊 Concurrency Primer 認為說,在使用 _strong 函式的情況下編譯器必須生成內外兩個迴圈來防止我們遇到 Sequential Consistent 錯誤,但其實我們不在乎錯誤發生何時發生,因此另有 _weak 版本的函式讓 CAS 只要沒有正確完成時就直接回傳。
C++11 程式碼:
C11 程式碼:
這邊必須將原本的函式後方加上 _explicit,才可以設定 Memory Order。
C++11 程式碼:
C11 程式碼:
Acquire 與 Release 是兩個成對的記憶體排序。Acquire 會將任何所有在它之後的記憶體操作維持排序並與其他 CPU 同步,通常會搭配記憶體柵欄來防止 CPU 重新排列操作。Release 則是與其相反。兩者常被用來進行鎖的實作,也因此 Acquire 常被用在 Load 操作,而 Release 被用在 Store 操作。
C++11 程式碼:
C11 程式碼:
Relaxed 是相對 Acquire Release 來講比較弱的記憶體操作,它可以保證兩個 Relaxed 操作不會被重新排序,但它不會為其他 CPU 去做同步操作。
C++11 程式碼:
C11 程式碼:
Acq-Rel 可以說類似於上面提到的 Acquire 與 Release,只不過它被特化用於同時需要進行讀取與儲存的函式,如 atomic_compare_exchange 系列函式。
另外還有一種排序為 SeqCst,保證程式之間可以有 Sequential Consistency,被認為是最強的記憶體排序,但因為每個 Atomic 操作都會對 CPU 快取造成一定的開銷,SeqCst 的花費自然比其他記憶體操作來得要大,因此它並不是唯一推薦的記憶體排序。
以 C11 Atomics 改寫 hungry-birds 並解釋運作原理 (注意: 存在若干實作錯誤),需要用 ThreadSanitizer 驗證,作為日後教材的補充內容。
該 lock-free 程式使用 C11 Atomics 實作佇列,利用 atomic_uintptr_t
這個指標類型儲存佇列頭尾以及 next
的值以構成 Linked List 的結構。
head
:佇列頭,主要用於 queue_pop
操作。tail
:佇列尾,主要用於 queue_push
操作。item_size
:儲存佇列中每個元素的大小。在該程式創建佇列時,會利用 sentinel 這個 Hexspeak 去表示佇列的開頭,並將頭尾用 atomic 函式設置為 0。
推入佇列的動作由 queue_push 函式達成,程式會首先配置一塊新的記憶體區塊 new_tail,並利用 atomic_init 初始化。接下來透過 atomic_exchange 操作交換佇列尾端與 new_tail 的值,取得下來的指標這邊以 old_tail 表示。
交換之後 old_tail 會有兩種可能情況:
取出佇列的動作由 queue_pop
完成,函式首先將 q->head
的初始值存儲於 popped
指標上,並將 popped
的值複製到 compare
並進行 CAS。
queue_has_front
函式確認有元素,否則會產生斷言錯誤。compare
的值與佇列尾相同,則代表這個佇列只有一個元素。
popped
的值再次複製到 compare
一次。這個操作我不清楚原因,但我認為是為了確保接下來與佇列頭的比較可以順利在 MPSC 的情況下執行。queue_push
時,佇列會被判定為「沒有元素」的情況,進而更改了佇列頭的值。因此,進行 q->head
與 compare 的比較時結果可能不相同。因此函式再次進行一次 CAS,若佇列頭的值沒有被更動則將其設為 0。compare
的值與佇列尾不同,則佇列有多個元素。
new_head
用來複製原本佇列頭的下一個值。由於佇列頭的下一個值可能還沒有被存入,即有執行緒還在 queue_push
的 atomic_store
區塊,因此這邊函式利用 while 迴圈讓 new_head
反覆存取 popped->next
直到其非 0。new_head
存取到值後,q->head
會存入 new_head
的值而完成操作。當以上操作執行完後 popped
指標的記憶體會被釋放並回傳取出成功。
無修改運行時程式不會提示任何錯誤而順利結束。若在 Makefile 內加上 -fsanitize=thread
標籤後運行,當程式進行到 Stress Test 區塊可以看到終端機發現有 data race 發生在 queue_pop
與 queue_push
函式,進而導致程式無法順利結束。
以 GDB 分析過後可以發現儘管進行了多次的 queue_push
,data race 總是會在第一次的 queue_pop
的 free 函式執行時發生,原因疑似是 queue_pop
執行 free
函式時,queue_push
同時對記憶體進行 malloc
。
我嘗試利用 atomic_test_and_set
函式,設置一個 atomic_flag
來達到類似互斥鎖的效果去執行問題段,雖未能解決大部分問題,但 data race 的發生時間有所變化。
第一次的 data race 一樣發生在第一次的 free 與 malloc 同時進行。
第二次的 data race 為新出現的問題,在第二次的 free 與 memcpy 同時進行的情況下發生。
第三次的 data race 與原本的第二次 data race 為同種問題,但發生時間被延後到數次 free 函式後,而非原本的在第一次 free 發生的情況。
以 atomics 保護 critical section