contributed by <NOVBobLee
>
GitHub
2023 年暑期 Linux 核心課程第 1 次測驗題
2023 年暑期 Linux 核心課程第 2 次測驗題
1
−1futex (Fast User-space muTEX) 為 Linux 系統呼叫之一,代號 SYS_futex
,是 Linux 提供給開發者實作 locking 與 semaphore 等機制的組件,目的在減少不必要的 user-kernel space 轉換。在沒有競爭的情況下, futex 的操作可以只在 user space 中完成,相反地,在有競爭的情況下,才會進入 kernel space ,執行將等待者排進等待佇列,進入睡眠狀態等。
當一群執行緒持有一共享記憶體 (限定 32-bit 長度, 64 位元架構也不例外,因此也被稱為 futex word ) ,各執行緒根據各自使用的 futex 操作和 futex 的值,可以自己進入睡眠狀態或是被其他執行緒喚醒。根據 futex 不同的操作,有各自相異的帶入參數。
基本參數:
uaddr
: futex 的地址 (需 4 位元組對齊) 。futex_op
: futex 的操作代號,如 FUTEX_WAIT
、FUTEX_WAKE
等。val
: 此值對於不同 futex 操作會有不同的意義。futex 操作 (futex_op
) 敘述:
FUTEX_WAIT
: 若位於 uaddr
上的 futex 等於 val
,此執行緒進入睡眠狀態,同時也會進入此 futex 的等待佇列。若有使用 timeout
,可以在時間到以後被喚醒;若沒有使用,則必須等待其他執行緒使用 FUTEX_WAKE
等方式,喚醒 futex 等待佇列裡的執行緒 (沒有順序保證) 。最初的比較若不相等,則會得到 EAGAIN
的回傳值,比較目的是避免錯過任何喚醒機會。How does it prevent losing wake-ups?
FUTEX_WAKE
: 喚醒屬於 uaddr
上 futex 的等待佇列中之執行緒 (沒有順序保證) ,最大數量為 val
。
FUTEX_REQUEUE
: 若位於 uaddr
上的 futex 等於 val3
,則喚醒最多 val
數量於此 futex 等待佇列中的執行緒,而在此佇列中剩下的執行緒將會被移動到位於 uaddr2
上的 futex 等待佇列中。移動上限數量為 val2
。此操作目的為增加被喚醒的時機,比起原本只有 timeout 和 FUTEX_WAKE
方式, FUTEX_REQUEUE
可以移動到不同 futex 上,增加被喚醒的時機,同時也可以減少使用系統呼叫次數所帶來的成本。
比方移動到 mutex 上,由於 mutex 各自互斥,喚醒的方式變成一次喚醒一個,相比使用 FUTEX_WAKE
一次喚醒 INT_MAX
個 futex 產生競爭的情況,失敗又要回到 kernel space 裡等待下一次喚醒的不小成本。 (見下面 cond_broadcast()
說明)
FUTEX_PRIVATE_FLAG
: 告訴 kernel 此 futex 限制在此 process 內 (只於此 process 中的執行緒間分享) 。可以使用 <linux/futex.h>
使此操作與其他 futex 操作結合,如 FUTEX_WAIT_PRIVATE
等。當一處理器 A 持有 lock ,而一個處理器 B 等待其釋放 spin_lock()
,此時處理器 B 會不停去讀取自己 lock 的 cache line ,等其結果變為 false
時,才會進入下一個動作 exchange()
,試著去爭取 lock 。
在兩個以上處理器都在等待釋放的情況下,各自的 cache line 就停留在 Shared 狀態。相反得,假如沒有先 load()
,而是直接 exchange()
,這些處理器會不停地互相進行 RFO (Request for Ownership) ,然後傳送自己的 cache line 給得到擁有權的處理器 cache 上,再進行寫入動作,這些 cache line 會在 Modified 和 Invalid 之間不停變換狀態,這將會大大影響效能。
What every programmer should know about memory, part 2: CPU caches 見 3.3.4 MESI Protocol
pasue
是設計給 spin_wait 使用的處理器指令, spin_wait 在等待的期間,會不停查看 lock
的狀態,若沒有在迴圈內使用 pause
,讀取所佔的時間會變得相當高,由於 cache coherency 的關係,這會影響到持有 lock
的執行緒釋放 (寫入) 的時機點。若加入 pause
,則可以讓讀取的頻率降低,同時也能減少能源消耗。此外, pause
本身會產生 bubble (pipeline stall) ,達到 de-pipeline 的效果,這在離開迴圈時可以避免掉分支預測錯誤的懲罰。
[PDF] Intel® 64 and IA-32 Architectures Optimization Reference Manual.pdf 見 11.4.2
Stalls and flushes - lec13.pdf
當 mutex_lock()
沒取得 lock 時,會使用 futex_wait()
進入睡眠。當擁有 lock 的執行緒釋放 lock 時,會更改 mutex
的狀態,然後呼叫 futex_wake()
,喚醒一個睡眠中的執行緒。
只要存在睡眠中的執行緒, mutex
會維持著 MUTEX_LOCKED | MUTEX_SLEEPING
的狀態 (註: 呼叫 mutex_unlock()
會變回 0
的狀態,但只要存在 futex 在等待,就會回到 futex_wait()
的位置,狀態又變回 MUTEX_LOCKED | MUTEX_SLEEPING
) ,直到沒有睡眠中的執行緒。
照字面上看, cond_wait()
在等待 cond_signal()
或 cond_broadcast()
的信號,若在一定時間內沒等到,則會進入睡眠,直到有信號來通知才會被喚醒。 cond_
函數外面一定會被 mutex 包住,其中一個作用在保護 predicate ,以 qsort_mt.c
(下面程式碼) 舉例,就是 qs->st
。另一個則是保護 condvar (condition variable) ,也就是 cond->seq
(註: 在 cond_wait()
裡,CS 就第 3, 20
行而已,中間必須放掉 mutex ,否則就沒其他執行緒可以改變 predicate 和 condvar ) 。
第 3
行將 cond->seq
記錄下來,然後在第 8~14
行測試 cond->seq
是否有改變 (包括第 16
行 futex_wait()
也會測試 cond->seq
是否改變) ,亦即是否有執行緒呼叫 cond_signal()
或 cond_broadcast()
。若有改變,則取回 mutex 然後返回,否則在 futex_wait()
那進入睡眠等待。
Why add MUTEX_SLEEPING
flag after futex_wait()
?
回到 mutex 的程式碼,mutex 的睡眠也是用 futex 去實現。而喚醒是在 mutex_unlock()
中執行 futex_wake()
,執行條件為 mutex 在 MUTEX_SLEEPING
的狀態下。
但這是針對 mutex <–- cond_broadcast()
?
cond_broadcast()
使用 futex_requeue()
,先喚醒一個 cond_wait()
,之後的喚醒則是交由 mutex 內的 futex 來執行。由於在 cond_
函式外面會有 mutex 包住,每個 cond_
各自互斥,取代 futex_wake(INT_MAX)
互相競爭的狀況, mutex 會一次喚醒一個執行緒,直到所有在等待的 mutex 都喚醒完。
main.c
測試方式採多執行緒互相以 condvar 溝通,最終檢查交由 TSan 與 pthread_join()
檢測。上圖為程式中使用的物件圖示,每個執行緒會擁有各自的 node
,同時共享一個 clock
, node
之間會有 parent
的指標表示親子的關係,其中存在一個 node
是沒有 parent
的。
clock
是在執行緒間共享的物件,其中 ticks
成員為程式中的共用計數器,同時也是控制所有執行緒繼續運行或停止的旗號。 clock_wait()
在之後的子執行緒中, ticks
增加速度通常會大於 clock->ticks
的速度,那個 while
和 cond_wait()
就是提供暫時煞車的功能,使得每個子執行緒運行在不超過自己該有的步調上。
Should cond_broadcast()
be calling inside a mutex?
node_
函式控制各執行緒的運行許可。
程式的運行概述:
35
行各子執行緒開始運行,若親執行緒尚未執行第 39
行,則於第 6
行 clock_wait()
等待39
行親執行緒呼叫 clcok_tick()
,使各子執行緒通過 clock_wait()
,然後親執行緒在第 40
行 clock_wait()
等待(開始第 6-16
行的迴圈。子執行緒名稱由所擁有的 node
序數命名,例如第 0 號執行緒。)
parent
的第 0 號執行緒,其餘執行緒在第 8
行 node_wait()
等待bit
的關係,子執行緒的迴圈會是一次 clock_tick()
,一次 node_signal()
,當第 0 號執行緒執行第二次迴圈時,會呼叫 node_signal()
,使得第 1 號執行緒可以運行。同理,當第 1 號執行緒執行第二次會圈時,會呼叫 node_signal()
,使第 2 號執行緒可以運行。換句話說, parent
執行緒的迴圈數會是自己的兩倍,以第 0, 1, 2 號執行緒來看,其迴圈數比等於 。(當第 40
行的 clock_wait()
達到停止條件時。)
clock->ticks
為非負整數,子執行緒的迴圈就不會停下來,唯有第 41
行的 clock_stop()
或者整數溢位會變成負數。整數溢位理論上 (如果親執行緒在一定時間內沒被排程到) 可以排除, 1 << N_NODES
離溢位還差個 倍。clock_wait()
檢查是否 clock->ticks
為負數,一旦為負,則跳出迴圈,呼叫 node_signal()
通知下一個執行緒執行,最終每個子執行緒都完成自己的工作。pthread_join()
會回傳 0
,表示子執行緒有順利返回。在沒有子執行緒返回失敗的情況下,這次對精簡版 POSIX Thread 和 NPTL (Native POSIX Thread Library) 的檢驗就通過了。觀察每個執行緒自己的計數器 i
最後一個 (有進入) 迴圈的數值:
此數值並不會完全固定,多執行幾次會發現些許差異。以上面的數值為例, tick
的總和是 ,由於 bit
的關係,將之除以二得 (即 ) ,再加上第 39
行的 clock_tick()
,這樣就達到第 40
行的通行條件 了。
(消化中)
In pursuit of faster futexes
Adaptive mutexes in user space
Rethinking the futex API
Short subjects: Realtime, Futexes, and ntfs3
1½ Topics: realtime throttling and user-space adaptive spinning
A new futex API
1
−2精簡版 POSIX Thread 與 POSIX Thread 使用上有幾個差異:
int
,精簡版的則沒有回傳值。destory()
,精簡版的則不需要。pthread_cond_signal()
和 pthread_cond_broadcast()
參數只有 condvar ,精簡版則需多加 mutex 。加入精簡版 POSIX Thread 函式庫。
替換成精簡版 POSIX Thread 物件。
初始化函式替換成精簡版的,將回傳值處理的部份去掉。
destroy()
的部份直接捨棄掉。
1
−3在搶佔式系統中,當有高優先權的 process 是就緒狀態時,則可以搶佔低優先權 process ,讓高優先權 process 優先執行。現在假設一情況,有一低優先權的 process 正在處理 CS (Critical Sectoin) 時被搶佔,高優先權 process 開始執行自己的工作,然後準備進入同一個 CS ,此時 lock 的所有權還是在原本的低優先權 process 手上,為了不破壞 CS ,會轉讓給擁有 lock 的低優先權 process 優先執行,此時即構成了 Priority Inversion 。
圖片來源: Introduction to RTOS - Solution to Part 11 (Priority Inversion)
對應 Priority Inversion 的解法有許多種,其中之一為 PI (Priority Inheritance) 。當一高優先權 process 試圖取得 lock ,但擁有者為低優先權 process 時,會暫時性的將低優先權 process 的優先權暫時提昇成高優先權 process 的優先權,以防止其他不低於高優先權的 process 搶佔。等 lock 釋放後,才將優先權還原。
執行緒預設使用的排程器是 SCHED_OTHER
,其優先度永遠只有 0
。實驗需要使用到優先度,這部分需要指定其他排程器,比方說 SCHED_FIFO
和 SCHED_RR
,優先度從 1
到最大 99
,可以在 pthread_create()
指定執行緒排程方式和優先度。要注意的是使用 SCHED_OTHER
的優先度 0
永遠低於 SCHED_FIFO
和 SCHED_RR
的 1
到 99
。
圖片來源: Introduction to RTOS - Solution to Part 11 (Priority Inversion)
目標實驗情境是: 在沒有使用 PI 的情況下,會發生 Priority Inversion (如前一個示意圖) ,所以 task_h
會比 task_m
晚完成。而若是在使用 PI 的情況下,則不會發生 Priority Inversion ,執行緒完成順序會是按照優先度高到低的順序完成 (如上圖) 。
實驗中我們會限制執行在一個處理器上,簡化實驗易於觀察。使用的排程器為 SCHED_FIFO
。總共會創造三個執行緒,執行緒優先度由低到高分別為 task_l
、 task_m
、 task_h
。 task_l
跟 task_h
會使用同一個 CS ,而 task_m
則是單純占用處理器,流程會像 Priority Inversion 示意圖那樣,剛開始由 task_l
先進入 CS 並佔據,然後放 task_m
和 task_h
運行,觀察執行緒完成的順序,還有 task_h
進入 CS 的時間點。
此執行緒為最低優先度,為了達成 Priority Inversion ,必須讓此執行緒比 task_h
早取得 mutex 進入 CS ,目的是讓低優先度執行緒占用 mutex ,以觀察其他執行緒對此執行緒的行為,比方說在沒有使用 PI 的情況下,此執行緒會被 task_m
搶佔,相反地,在有使用 PI 的情況下,由於優先度被拉升到跟 task_h
一樣,使得此執行緒不會被搶占。
Make sure task_l
use sched_yield()
after at least one thread is created, or task_l
cannot yield and finishes first.
此執行緒單純占用處理器,直到主執行緒說停 (主執行緒 main
在實驗結束時會改變 stop
的狀態) ,由於優先度在中間,在有和沒有使用 PI 的情況下,其搶佔行為會是觀察 Priority Inversion 的關鍵。
We simplify the experiment by using one CPU with one blocking task task_m
. You can use multiple CPUs by using N
CPUs with N
blocking tasks task_m
instead.
此執行緒與 task_l
共用 CS ,但由於 task_l
先進入 CS 的關係,所以會在爭取 mutex 失敗後進入睡眠的狀態,此時剩下 task_l
與 task_m
可以執行,在沒有使用 PI 的情況下,會由 task_m
開始執行,只要 task_m
還沒執行完,優先度較低的 task_l
就沒有機會去釋放 mutex ,導致 task_h
無法在 task_m
可執行的狀態下進入 CS ,第 15
行的 h_touched
也就不會碰到。相反地,在有使用 PI 的情況下, task_m
是無法搶佔暫時性高優先度的 task_l
,所以可以在 task_m
可執行的狀態下釋放 mutex ,進而讓 task_h
進入 CS 碰到第 15
行。
每個執行緒完成時都會執行 task_finished()
,紀錄自己執行緒執行完成的順位。
由於要指定執行緒排程和優先度,所以需要 root 權限,使用 taskset
限制運行於某處理器,這裡指定在 0
號處理器。
shced(7) See Privileges and resource limits
以下結果為使用的是 glibc 的 pthread 。
這是沒有使用 PI 的結果,執行緒完成的順序為 task_m
> task_h
> task_l
,結果符合預期,有發生 Priority Inversion 。
以結果來推測過程:
task_l
取得 mutex 進入 CStask_l
讓出處理器或是被搶占, task_m
或 task_h
開始執行task_m
在執行中,則被 task_h
搶佔task_h
爭取 mutex 失敗,進入睡眠狀態task_m
或主執行緒 main
開始執行task_m
在執行中,則被主執行緒 main
從睡眠狀態中醒來 (時間到) 搶佔main
給予停止值 stop = true
,然後等待子執行緒返回task_m
優先度較高,開始執行task_m
離開迴圈,記錄返回順序 task_finished()
task_h
還在睡眠中,由 task_l
開始執行task_l
釋放 mutex ,喚醒 task_h
task_h
優先度較高,搶佔 task_l
task_h
進入 CS ,但 task_m
已執行結束,所以不會更改 h_touched
值task_h
釋放 mutex ,記錄返回順序 task_finished()
task_l
開始執行,記錄返回順序 task_finished()
這是有使用 PI 的結果
執行緒完成的順序為 task_h
> task_m
> task_l
,結果符合預期。
以結果推測過程:
task_h
爭取 mutex 失敗,進入睡眠狀態task_l
提升優先度,與 task_h
相同task_l
優先度高於 task_m
,所以由 task_l
開始執行task_l
釋放 mutex ,還原優先度,喚醒 task_h
task_h
優先度最高,搶佔 task_l
task_h
進入 CS ,此時 task_m
尚未執行結束,所以會更改 h_touched
值task_h
釋放 mutex ,記錄返回順序 task_finished()
main
或 task_m
執行task_m
離開迴圈,記錄返回順序 task_finished()
task_l
開始執行,記錄返回順序 task_finished()
這裡我們會使用 Linux 的 PI-futex 系統呼叫,過程大概都是若在 user space 使用 atomic 操作爭取 lock 失敗時,就會使用 kernel 的 RT-mutex 實作 PI 的次系統。
對於 PI 機制, futex 有提供相關的操作: FUTEX_LOCK_PI
, FUTEX_TRYLOCK_PI
, FUTEX_UNLOCK_PI
等,其字尾會用 PI ,其用法也有特殊的規定:
0
。FUTEX_WAITERS
bit ,變成 FUTEX_WAITERS | TID
。這個情況表示,在釋放 lock 的時候,不能只在 user space 裡釋放 (將 futex 改為 0
) ,而必須使用系統呼叫,請 kernel 去做釋放 lock 與後續的動作。相關 PI-futex 操作:
FUTEX_LOCK_PI
: 在 user space 爭取 lock 失敗時 ( futex 不等於 0
) 使用。FUTEX_TRYLOCK_PI
: 由於 lock 處於 stale state ( futex 有 FUTEX_WAITERS
或 FUTEX_OWNER_DIED
bit) ,此時 futex 不能單純於 user space 處理,必須交由 kernel 來執行 trylock 。如此情況像是 lock 的擁有者執行緒終止且尚未釋放,此時可以由 kernel 當中間人,讓其他執行緒取得 lock 。其他情況像是偷取 lock 的功能, PI-mutex 會有自己的優先度佇列,取得 lock 的順序會依照優先度高排到低,此時若使用 FUTEX_TRYLOCK_PI
的執行緒與原下一順位的執行緒有著一樣的優先度,且下一順位的執行緒尚未取得 lock 時,則可以偷取 lock 。FUTEX_UNLOCK_PI
: 在 user space 無法釋放 lock 時使用 (futex 不等於 TID ,可能有 FUTEX_WAITERS
bit) ,此操作將會釋放 lock 並喚醒位於等待佇列中最高優先權的執行緒。有兩次爭取 lock 的機會,一次在 user space ,就一般的 atomic CAS 操作,若 state
等於 0
,則改成自己的執行緒 ID tid
,表示取得成功。另一次在 kernel space ,由於處於 stale state ,必須請 kernel 來爭取 lock 。
_mutex_lock_pi()
會先在 user space 嘗試爭取 lock ,失敗時呼叫 futex ,若在 kernel space 爭取到 lock 則會馬上回到 user space ,否則進入睡眠狀態,等待 lock 釋放時,照優先度的順序被喚醒。
_mutex_trylock_pi_loop()
由於是處於 loop 中,所以使用的 CAS 是 compare_exchange_weak()
,速度較快,相對地有可能出現偽錯,這可以靠 loop 彌補。
在 _mutex_lock_pi()
剛開始會檢測是否處於 stale state,若看到 FUTEX_OWNER_DIED
那一定需要 kernel 處理。另一方面,若看到 FUTEX_WAITERS
,則代表除了 lock 擁有者,還有其他執行緒在等待這個 lock ,這邊是否也是要偵測到直接呼叫 futex ,或是可以放寬,偵測到一樣先在 user space 爭取看看?
Is there cache line bouncing problem for using CAS?
Experiment: Use perf
and compare the cache missing.
Does cmpxchg write destination cache line on failure? If not, is it better than xchg for spinlock?
Understanding std::atomic::compare_exchange_weak() in C++11
先在 user space 嘗試釋放,若失敗則使用 futex 呼叫,這代表有 FUTEX_WAITERS
bit ,需要 kernel 進一步處理。
測試結果與使用 glibc 的 pthread 相同。
Requeue-PI: Making Glibc Condvars PI-Aware
Android Audio System: Avoiding priority inversion
1
−4perf.c
中有兩種對 mutex 的效能測試,一種是在沒有競爭的情況下,測試 lock()
和 unlock()
的時間。而另一種則是在競爭情況下,測試 lock()
與 unlock()
的時間,其中競爭的方式以下面圖示: 由 4 個執行緒競爭 5 個 mutex ,這邊可以想像 mutex 是一個有序的環狀,最初每一個執行緒會持有一個 mutex (藍線),第一個動作是先 lock()
下一個 mutex (紅虛線),第二個動作在 mutex 得到後 unlock()
自己原本持有的 mutex (紅虛線),每個執行緒都反覆這兩個動作 25000
次。
從圖示可以看出執行緒與 mutex 間爭取的樣貌會是環狀的,此為一種「可能」發生 deadlock 的情況,所以 TSan 有時候會給出潛在 deadlock 的警告。不過實際上,這段程式並不會發生 deadlock ,由於 mutex 數量是比執行緒數量多一,這可以保證至少一個執行緒 lock()
是不會失敗的,然後此一執行緒在 unlock()
後,又會喚醒原本 lock()
失敗的執行緒,致使整個流程是一直有進展的。
2
−12
−22
−32
−43
−13
−23
−3