contributed by < LindaTing0106
>
早期的並行程式在運行時,為了避免執行緒在 critical section 發生競爭,當執行緒進入 critical section 時有兩種處理方式,一種為每次都呼叫 system call 交給核心檢查是否有競爭發生,但這會造成不必要的成本花費。
另一種方式為使用 spinlock 將不能進入 critical section 的執行緒鎖住,但此舉造成 CPU 資源無法釋放,且在多核系統上需要另做處理。
Futex 的誕生,使得執行緒在 user space 上就可以檢查是否有競爭發生,如果有競爭發生再透過核心去處理,減少不必要的成本。
在下面將重現幾個此篇教材中出現的例子。
在 main.c 的程式中會先創建 ~ ,其中除了 以外,其餘 的父代都為 ,且全部節點都共享資料 clock 。
在創建執行緒時,因為一開始的 clock->tick 為 0 所以所有執行緒會被鎖在 clock_wait(self->clock, 1)
直到主執行緒執行完 clock_tick(&clock)
將 tick 設為 1 。
透過 node_wait(self->parent)
,除了 以外的節點都要等待自己父代的 ready 為 1 才能開始執行操作。
操作分為兩類,每個節點在 i 為基數時,執行 clock_tick(self->clock)
,在偶數時,執行 node_signal(self)
,將自己的 ready 設為 1 。
而最外層的 clock_wait(&clock, 1 << N_NODES)
,控制主執行緒在 tick 為 1 << N_NODES
之前都持續等待。直到 tick 為 1 << N_NODES
後, clock_stop
,並使其他執行緒脫離做事迴圈。
在 main.c 中,使用 common 當作共享變數。並創建 ~ 。將所有執行緒創建完之前,主執行緒會先將所有的執行緒的 start_mutex 都鎖起來,並等待 ready_count 的數量等於執行緒的數量,防止有的執行緒先往下執行。
在 開始執行前,要先確保 沒有被鎖住。其行為模式為,將 上鎖後,對 進行解鎖,因此最先執行的會是 ,且執行順序為 -> -> -> -> -> …。 整個過程執行 50 次後會計算整體執行時間。
下圖分別為使用 pthead 與 futex 的執行時間由低排至高,可以看出使用 futex 的執行時間必定小於 pthead。
而在此程式碼中,我們可以看到一開始先讀取 mutex->state 中的值,並且將其寫入 state 中,此處檢查 state 是否處在 lock 中,如果其處在 lock 中,則回傳 false 。
如果其不為 lock 則嘗試將 mutex->state 改為 lock ,如果在嘗試的過程中已被其他執行緒鎖住,則也回傳 false ,反之則成功上鎖,並且回傳 true 。
在比較效能時我注意到,其實可以直接嘗試將 mutex->state 改為 lock ,可以不需要在經過讀取此步驟。
更改過後的程式碼為
而效能比較圖為
明顯可以看到整體效能更佳。從兩張效能比較圖中可以發現,未經過讀取這一步驟的效能圖中,futex 在效能排名第 80 的測試中花費時間才開始顯著增加,而在原先的效能圖中,futex 在效能排名第 50 的測試中花費時間就已經開始上升。因此,這樣的改進可以有效提高 mutex->state 在 trylock 成功上鎖的比例。
在其中加入了執行緒優先順序的考量,根據給予的程式碼,我們可以發現如果沒有根據執行緒的優先性去對其進行調整的話,很有可能會發生 priority inversion ,在程式碼中,就是因為低優先序的執行緒會先將 lock 鎖住,而中優先序的執行緒則是會將 CPU 霸佔住,而此時高優先序的執行緒則因為低優先序的執行序取得不了 CPU ,無法將 lock 釋放掉,反而不是最先被執行完的執行緒,從而導致 priority inversion 。
原文有提到若使用預設的 mutex 會無法修正此問題,導致高優先序的執行續會在中優先序的後面才完成。因此並不會有 h 印出。
為了解決此問題,如果在 mutex_trylock_pi 中,嘗試取得 lock 失敗的話,便使用 FUTEX_LOCK_PI2 核心操作來處理 priority inversion 問題。
根據 pi-futex 所述,在執行緒嘗試鎖定 mutex 時,會先把 mutex 的值從 0 設定為執行緒的 tid 。如果這個操作失敗,表示互斥鎖已經被其他執行緒持有。
因為在 fast-path 失敗,因此呼叫 FUTEX_LOCK_PI 進入核心處理。核心會檢查這個 mutex 是否已經有等待佇列。沒有的話核心會建立一個。接著核心會尋找目前持有這個 mutex 的執行緒。並為此 mutex 附加一個 rt-mutex ,接著將 mutex 的值設定為 FUTEX_WAITERS | TID 。
接著當前的執行緒會嘗試鎖定 rt-mutex,並且開始等待。如果持有 mutex 的執行緒的優先權低於等待執行緒的優先權,核心會將持有執行緒的優先權提升到等待執行緒的優先權。則此時由於此執行緒的優先權提高,則其可以使用 CPU 並且釋放 Mutex 。當等待執行緒被喚醒,執行緒獲得 rt-mutex 的鎖定。核心會將 mutex 的值更新為目前執行緒的 tid ,表示目前執行緒持有了這個 mutex 。
也因此使用 mutex-pi 後,即可以解決此 priority inversion 的問題。
在描述 futex_requeue
的部分,提到
該操作會喚醒最多 limit 個等待者,但和 wait 不同的地方是,若 futex 中所有的等待者超過 limit 個,則所有的等待者皆會被喚醒並改在 other 這個 futex word 對應的 wait queue 中等待,函式中的 INT_MAX 表示可以從 futex 移到 other 的最大數量。
上述 但和 wait 不同的地方是
應該改為 和 walk 不同的地方是
。
futex 旨在找出 semaphores 和 spinlocks 之間的平衡, semaphores 會導致系統就算在沒有競爭的情況下依然需要透過系統呼叫,而 spinlocks 在有競爭的情況下可能導致 CPU 使用率忙碌。
此篇論文中使用 Promela 語言與 Spin 模型檢查來檢驗 futex-based mutex 和 condvar 的正確性。其中使用 Spin 可以產生反例,故我們可以看到是怎麼樣的執行順序導致其產生錯誤。
Futex 做為一個全域變數,所有的執行緒都可以使用他。
其中透過 wait[tid] 可以得知每個執行緒是否進入等待狀態。
num_waiting 則是顯示目前正在等待的執行緒數量。
FUTEX_WAIT 中的實作使用 d_step 的方式而不是 atomic ,這是由於 FUTEX_WAIT 具有確定性,也就是說在初始情況一樣的條件下,每次的輸出必定相同。
其中每一段 d_step 的操作都是不可分割的。
此段程式碼表示當 futex.word 與 val 相等時,將此執行緒設為等待中,如果 !futex.wait[_pid] 則表示其已經脫離等待,而如果 futex.word 和 val 不相等,則不會變動執行緒。
FUTEX_WAKE 使用 atomic ,是由於此段操作為非確定性,不是每次執行的時候,都可以確定是哪一個執行緒先被釋放。
assert(!futex.wait[_pid]) 說明要做喚醒的動作的執行緒不能是在等待中的。
此段程式碼中使用 num_woken 來計算已經 wake 了幾個執行緒,一直到 num_woken 等於 num_to_wake ,則跳出 FUTEX_WAKE 。
Atomic compare-and-exchange:
先將 location 保留在回傳值中,再來根據 location 等不等於預期值,來決定將其更變為何,如 location 等於預期值,則將其賦予為 desired ,如不等於,則其維持不變。
Atomic fetch-and-increment:
利用 inc(a) 處理溢出。
Atomic fetch-and-decrease:
和 Atomic fetch-and-increment 使用相同的原理撰寫。
這些函式確保能夠在 Promela 中模擬 C/C++ 的原子操作,並處理溢位和下溢情況,從而實現與實際系統行為一致的模擬。
根據 Promela Reference – active(2) 我們可以瞭解到初始程式會從 active 處開始執行,且每個程式至少需要有一個 active 。
而 [NUM_THREADS] 是宣告有幾個執行緒執行以下程式,如果 NUM_THREADS 的數字大於 255 ,則 Spin 發出警告,並僅創建前 255 個執行緒。
其中每個執行緒都會利用自訂的函式來鎖定 mutex 和解鎖 mutex ,全域變數 num_threads_in_cs 預設為 0,用於記錄執行緒進入和離開臨界區的數量。
利用此種方式我們檢驗兩件事情。
ltl safe_cs { [](num_threads_in_cs <= 1) }
來確認 critical section 中是否有超過 1 個執行緒。若 NUM_THREADS 數量為 3 。
設當前 futex_word 為 0 , Mutex 還沒被鎖上,則此時 進到 lock 時, old_value 會先保存 futex_word 原始值,接著 futex_word 會加 1 ,故此時 Mutex 被上鎖。
則現在當 futex_word 為 1 ,另一執行緒 進入 lock ,則 old_value = 1 ,且 futex_word = 2 ,如果此時沒有其他執行緒進入 lock ,則 old_value + 1 == futex_word 故此執行緒進入 futex_wait 等待呼叫。
若其他執行緒 進入 lock 則此時其 old_value = 2 , futex_word = 3 ,則由於 old_value == 1 且 futex_word == 3此時 無法進入 futex_wait。
則當執行緒無法進入 futex_wait 的情況一直持續發生,將會造成 futex_word 溢出後回到 0 ,而其他執行緒此時以為可以將 Mutex 上鎖的情況發生。
若以上狀況發生,則 critical section 中會有超過一個的執行緒,此時違反 Mutex 的互斥性。
這個例子演示了由於 futex_word 的值在競爭中不斷變化,執行緒之間可能會發生錯誤的同步,導致競爭失敗。且,由於 futex_word 的值在競爭時可能回到 0 ,導致了執行緒錯誤地認為它們可以鎖住 Mutex,而違反了互斥性原則。
另個情況為有執行緒不會結束。先假設 一樣先將 Mutex 上鎖,且 和 一樣進入競爭。此時 old_value 為 3 且 old_value 為 4 ,而 futex_word 的值為 0 ,這兩個執行緒的下一步都是執行 futex_wait。
此時 將 Mutex 解鎖,並使 futex_word 回到 0 。則此時 呼叫 futex_wait(0, 4) 失敗,並重新嘗試上鎖,成功上鎖後又馬上進行解鎖。此時 終於呼叫到 futex_wait(0, 0),此時 進入等待中,但因為其他執行緒都已經離開, 部會再有被喚醒的機會。導致了有執行緒不會離開的情況發生。
在此處 futex_word 的值代表了不同的涵義,當其為 0 時,表示他並未被上鎖。
當其為 1 時,表示上鎖了,但沒有其他在等待的執行緒。
當其為 2 ,表示上鎖了,同時也還有其他在等待的執行緒。
上鎖的路徑分為快路徑和慢路徑,如果一個執行緒 是透過 if ((old_value = cmpxchg(futex_word, 0, 1)) != 0)
將 Mutex 上鎖,則稱為快路徑。
反之,若在 while ((old_value = cmpxchg(futex_word, 0, 2)) != 0)
上鎖,則為慢路徑。
此處使用 Spin 驗證此模型的正確性。
此外也對模型進行了一些修改,其中有幾個錯誤的案例。
改為
最終會導致等待的執行緒不會被喚醒。
其過程如下
這是因為當 將 Mutex 解鎖後,會將 futex_word 設回 0 ,若此時 沒有檢查是否 Mutex 為沒上鎖的情況,則造成了明明在 Mutex 為空閒的情況下卻在等待。並且也沒有其他執行緒能夠將其喚醒。
替換為
則也出現等待的執行緒沒有被喚醒的情況發生。
使用 Waiter 和 Signaller 來檢查 condvar 的正確性。
該程式在執行時會先將 mutex 鎖上避免 data racing ,而後 num_signals_req 增加,表示等待 condvar 的執行緒增加,進到 cv_wait 後,需要等待 cv_wake ,最後才可將 mutex 解鎖。
而 Signaller 端相較起來稍微複雜些。
在 num_signals_req > 0 ,表示需要發送訊號,則鎖定 mutex 、發送訊號、減少 num_signals_req ,然後解鎖 mutex 。
如果沒有執行緒需要被喚醒,則會有兩種可能的情況。
可能造成
通過此種方式則可以避免在 wake 之後才成功執行 wait 。
但文中也有提到此種方式仍然有機會發生死結,當 在執行完 uint32_t old_value = futex_word; 且執行 futex_wait(&futex_word, old_value); 前,若呼叫 2^32 次 cv_signal() ,其中導致 futex_word.fetch_add(1); 將 futex_word 值執行至溢出,則此時 futex_word 即等於 old_value ,故 仍有機會進入等待,而導致陷入死結。
在 github 中作者有另外提供此版 CondVar 並且使用 spinlock 也可以驗證其正確性。
若同樣當 在執行完 uint32_t val = previous.load(); 且執行 futex_wait(&futex_word, val); 前,呼叫 2^32 次 cv_signal() ,則由於 previous 都會維持在執行 cv_wait 時 previous 的值,故不管呼叫多少次 cv_signal() 則 futex_word 的值都維持在 1 +previous ,因此不會有溢出的情況發生。
在檢查 drepper_mutex1.pml 時,我們先將 num_threads 設為 3 ,來檢查是否有達到互斥性。
若經檢查後違反互斥性,則顯示執行步驟詳細情況。
執行原程式 cpp/mutex_harness.cc ,發現應為錯誤的 cpp/drepper1.cc 沒有被檢測出來。
cpp/mutex_harness.cc 程式是使用 NTHREADS 個 threads ,每個 threads 應該要執行 ELEMS_PER_THREAD 次 count++; 。
則如果沒有任何的 data racing ,預想的 count 應該要為 NTHREADS * ELEMS_PER_THREAD 。
在上面錯誤例子中,提到當 futex_word 發生溢位時,會導致兩個以上的 thread 同時進入 critical sections , 但在 futex_word 為 uint32_t 的情況下 thread 的數量要非常大才有可能會溢位,因此我將此處的 uint32_t 改為 char 。
在此將 NTHREADS 設為 1000 , ELEMS_PER_THREAD 設為 1000 。
在 ThreadSanitizer 報錯中,我們可以看出是因為兩個執行緒同時進入 critical section 中,對共享變數進行寫入,而導致 data racing 的狀況發生。
且 count 值與理想不同。故證實 cpp/drepper1.cc 會出現 data racing 情況。
而在執行 cpp/drepper2.cc 時則不會出現以上報錯,且 count 值確實為 1000000 。
為了驗證其正確性,將 cpp/mutex_harness.cc 編寫為 C 語言 mutex_harness.c 。
這邊比較特別的是將 atomic 指令從 C++ 轉為 C 的過程。最初 C++ 程式中預設的 memory_order 方式都為 memory_order_seq_cst 。
則繼續沿用 NTHREADS 設為 1000 , ELEMS_PER_THREAD 設為 1000 的配置,運行程式。
可以看到在 drepper1.h 中還是存在 data racing 的問題。
而在執行 drepper2.h 時則不會出現以上報錯,且 count 值確實為 1000000 。
另外在這裡為了比較其效能,使用了 perf-test 中的檔案,並將 drepper2.h 加入進行比較。
在此得到結果,可以看到使用 drepper2.h 的 mutex 時,其表現與使用 pthread 差不多,甚至表現較差的案例需要的時間大於 pthread 。
在此推測為其中的 atomic 行為都是 memory_seq_cst 的模式,導致編譯器無法最大化優化其。
由於作者給出了很多種 mutex 實作,在這邊我將所有檔案都拿來做比較。
由於 drepper1 的 mutex 會有 data racing ,故我們可以忽視它,在這邊可以看到除了 drepper2 中較好的實驗結果有優於其他,剩餘的 mutex 其表現都差不多。
為了找出 drepper 和範例中的 mutex 為什麼效能上會有差距,先將 drepper 中速度最快的 drepper_1 與其進行比較。
可以看到就算是運行最快的 drepper_1 在比較簡單的 case 中效能還是比範例給的差。
在此我將 drepper_1 中使用到的 memory_order 改為更加彈性的 release 和 acquire ,但整體差別還是不大。
推測應該是由於範例中的 mutex 有 try_lock 此步驟,如果今天 lock 在足夠短的時間內被釋放,所以在實驗的時候我再將範例中 mutex 中 lock 改為不經過 trylock 。
此時即可以看到範例中的 mutex 優勢在於在此程式中,執行緒可以在很快的時間內釋放 lock 導致 mutex 在 trylock 時即可以拿到 lock 。
此時換為 drepper_2 與範例比較,則可以發現調整 memory_order 後的 drepper_2 與沒有 try_lock 的範例程式效能是差不多的。
於是我將 drepper_2 再加上 trylock 的功能,使得如果其他執行緒能夠在很快速的時間內將 mutex 釋出,則可以直接透過 trylock 的方式存取 lock 。
最後我將實驗次數調整至 10000 ,可以看到調整過後的 drepper_2 和原本的 mutex 的效能是差不多的。