contributed by <zmke
、petermouse
、0xff07
>
參考 0xff07 的筆記,修改成可以接受字串輸入
Locked
: mutex 鎖了幾次
Changed
: mutex 在不同 thread 之間切換的次數
Cont.
: thread 嘗試要求已鎖住的 mutex 的次數
tot.Time[ms]
: mutex 鎖住的總時間
avg.Time[ms]
: 平均鎖住 mutex 的時間
max.Time[ms]
: 持有 mutex 的最大時間
可以發現到 Mutex #2
在使用上非常的頻繁!thread 之間常需要搶奪 mutex,也常因此等待
追蹤原程式碼的 Mutex #2
出現的位置 ($ addr2line -e sort 0x4014ca
)
第一次存取出現在 /mergesort-concurrent/threadpool.c:45
(每個 mutex 實際對應到程式碼會差一行,不知道為什麼)
可以發現是 threadpool 中負責保護 queue pop 與 push 的 Mutex
讀完 A Pragmatic Implementation of Non-Blocking Linked-Lists 的演算法之後想先動手實作看看,關於演算法的解釋 0xff07 的筆記有詳細的說明
bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)
當 ptr
和 oldval
相同的時候,會把 ptr
的內容改寫成 newval
,並回傳 oldval
Built-in functions for atomic memory access 的解釋如下
These builtins perform an atomic compare and swap. That is, if the current value of *ptr is oldval, then write newval into *ptr.
The “bool” version returns true if the comparison is successful and newval was written. The “val” version returns the contents of *ptr before the operation.
首先從 tqueue_pop()
開始著手修改,目標是把 tqueue_pop()
的 mutex 拿掉,參考 hungry-birds 的實作,並引用 concurrent-ll 內的 atomic_ops_if.h
先判斷 queue 裡面是否只有一個 node (head 和 tail 相同),如果只有一個 node 就嘗試把 tail 和 head 設成 NULL ,如果不只一個 node 嘗試把 head 設成 head->next
$ make check-words
結果為 Passed!
再用 mutrace 測試一次
$ mutrace ./sort 4 random.txt
發現只有更改 tqueue_pop()
就讓 Mutex #0
(從 address 可以看出就是前面的 Mutex #2
)的使用率大幅下降
tqueue_push()
一開始不知道要怎麼改變 tqueue_tail->next
並改變 tqueue_tail
之值,最後也是參考到 hungry-birds 的寫法,才有些想法
其實兩個的操作並不是不行切割,而是可以先選擇改變 tail
之值,確定成功之後,取得舊的 tail
,再將舊的 tail
node 指向新的 tail
。
我們發現程式碼大多數時候可以成功執行,但有時候 thread 沒有接收到中止的 task 使 thread 中止運作,導致進入無窮迴圈
之後讓程式重複執行500次,發現中間出現 core dumped,用$ addr2line -e ./sort
找到
threadpool.c
的 tqueue_free
free(cur);
可能發生重複 free 同一塊記憶體,或是 free 到沒有被 malloc 的記憶體
之前的程式還有很多有待改進的空間
__sync_val_compare_and_swap
了
_Atomic
老師提到的改善方法
這邊我們參考了 Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms 論文,看到很多 lock-free queue 程式碼都建立在這篇論文的基礎上實作,像是 Boost 也參考了這篇實作出 lock-free queue
原文中有個中介的資料結構叫做 pointer_t
,這個資料結構去指向真正的 node (node_t
) 以及存放一個變數名稱 count
代表 reference counting 的次數,可以用來避免 ABA 問題
比較特別的是, 一開始初始化 lock-free queue 就會新建一個 node ,這個 node 並沒有任何的作用, head 與 tail 會間接指向這個 node , lock-free queue 至少有一個 node 的存在。
push 時去確保
[E7] Q->tail
還是 Q->tail
[E8] Q->tail
的 next.ptr
為 NULL
再去嘗試做 [E9] CAS , 失敗重來
特別的是 [E8] 失敗時,也嘗試去更新 Q->tail
為 Q->tail->next.ptr
,失敗就算了沒關係(好隨便啊!)
[E17] 試著改變 tail 之值,失敗也沒關係,代表已經被其他人更新
先前提到 queue 會有一個 node , head 會指向這個 dummy node ,所以 pop 會先去抓取 dummy node 的下一個 node, 釋放指向 dummy node 的 pointer (ptr_t
) , 再去將取得的 node 作為 dummy node ,交給下一次 pop 的釋放 ptr_t
確保 [D5] Q->head
還是原本的 Q->head
[D6] Q->head.ptr
若等於先前抓到的 Q->tail.ptr
[D7],代表 queue 可能是空的, 這時進一步去檢查 tail->next.ptr
有值也嘗試更新 Q->tail
,失敗也是沒關係算了
然而如果 [D6] 是錯的 [D11] ,把值取出,並改變 head, head 可能改變失敗代表其他 thread 已經改變 head ,一切重新來過
這邊我們使用 C11 atomic_compare_exchange_weak
來替換 __sync_val_compare_and_swap
,與論文不同的是我們並沒有使用中介結構來操作
在 tqueue_init()
進行初始化的時候先新增一個 dummy_node
,並讓 head
和 tail
指向這個 node
整合後發現最後一個 task 無法正確被 pop ,導致程式無法終止,把原本 task_run()
內的 tqueue_push(pool->queue, _task);
改成 tqueue_push(pool->queue, task_new(NULL, NULL));
就能正確執行了,不知道原因為何
$ mutrace ./sort 4 random.txt
原本的 mutex 已經消失了
memory leak
根據原本的寫法,我們會 pop 每一個 task 並在 task 做完後 free task,但是最一開始的 dummy node 並沒有 free 掉且再也無法取得
如果 threadpool 被設計成可以中途停止的話,剩下在 task queue 中的 task 也無法被釋放記憶體
ABA 問題
這個問題我們目前還沒有遇到,但是有可能發生,還不曉得要怎麼解決,可能的解決方式有:
效率比原本的還差
pop 與 push 做的事情相當單純,反而 lock-free queue 要不斷地去嘗試 pop 與 push ,過度競爭的狀況使得效益上不減反增,我們對於 lock-free 的結構想得太簡化了,也許要回到每個 thread 有自己的 workqueue 的形式並搭配 ondition variable 等來實現有效率的 lock-free thread pool