目的: 檢驗學員對 UNIX 作業系統 fork/exec 系統呼叫的前世今生和「並行和多執行緒程式設計」的 Atomics 操作的認知
作答表單: 測驗 1 (針對 Linux 核心「設計」/「實作」課程)
1
考慮以下程式碼藉由 fork(2) 和 mmap(2) 系統呼叫來實作並行版本的合併排序,限制 fork 的次數不超過 5。假設 calloc
總是會成功,且預期由小到大排列。程式碼如下: (部分遮蔽)
假設 calloc
總會成功,且 mmap
映射的記憶體區塊亦可存取。
作答規範:
2
考慮我們即將為 llama.cpp 撰寫 Linux 核心的加速運算模組,名為 matmul.ko
,程式碼如下: (部分遮蔽)
matmul.c
(Linux 核心模組)user.c
(使用者層級的測試程式)請補完程式碼,使其運作符合預期。作答規範:
copy_
開頭的 Linux 核心函式延伸問題:
3
〈UNIX 作業系統 fork/exec 系統呼叫的前世今生〉提及 1963 年電腦科學家 Melvin Conway 博士提出的 fork-join 模型,亦即將任務切割成多個子任務,最終彙整各個子任務的結果並得到原任務之結果:
Work-stealing 演算法是指某個執行緒從其它工作佇列裡「竊取」(steal) 任務來執行,運作流程圖如下:
設想目前的情境下有個較大的任務,我們可將這個任務分解成許多彼此獨立的子任務。為了減少執行緒之間的競爭,我們將這些子任務分別放入不同的工作佇列 (workqueue) 中,並為每個佇列建立一個獨立的執行緒來執行工作佇列中的任務。執行緒與工作佇列逐一對應,例如執行緒 A負責處理工作佇列 A 中的任務。然而,有些執行緒可能會先完成自身佇列中的任務,其他執行緒對應的工作佇列中卻仍有任務在等待處理。
因此,那些經完成指派任務的執行緒會空閒下來。與其閒置,不如去幫助其他執行緒完成剩下的任務。於是,這個已沒有任務的執行緒就會去其他工作佇列中「竊取」(或說「認領」)一個任務來執行,於是,它們會存取到同一個工作佇列。為了減少竊取任務的執行緒與被竊取任務的執行緒之間的競爭,通常會使用雙向佇列 (double-ended queue,通常縮寫為 deque,發音為 [dek]),被竊取任務的執行緒永遠從雙向佇列的開端處取出任務來執行,而竊取任務的執行緒永遠從雙向佇列的尾端處取出任務來執行。
延伸閱讀:
我們嘗試以 C11 Atomics 撰寫 work stealing 程式碼,參見 work-steal.c (部分遮蔽),編譯和測試: (執行順序可能略有不同)
結構體:
我們可將一段程式碼的執行流程拆成多個段落,循序或是平行 (若不影響正確性) 的運行它們。則每個段落我們稱之為 work,這是以 work_t
描述的。對照論文〈Cilk: An Efficient Multithreaded Runtime System〉的敘述該資料結構也稱為 "closure",其中的成員包含:
code
: 各執行緒要啟動該任務時所要執行的函式之指標,且輸入至該函式的參數是 work_t
本身的 referencejoin_count
: 用來計算這個 work 還缺少了多少 arguments 才得以進行args
: 即 work 執行所需要的 arguments根據論文〈Cilk: An Efficient Multithreaded Runtime System〉,若 clousre 已具備所有執行需要的參數,則為 ready closure,否則為 waiting closure。
可建立多個執行緒來並行式的完成 work。每個執行緒各自維護一個 double-ended queue (簡稱 deque,發音是 /dɛk/,不要讀成 dequeue /diːˈkjuː/),透過佇列讓 work 可加入到其中一個執行緒中運行。而之所以需要 double-ended 則與 work stealing 的需求和新的 work 被 spwan 且加入 deque 的模式有關,這後續會再詳細說明。
deque 的結構僅包含佇列本體和 top/bottom 來表示佇列中元素的有效範圍。在某一執行緒上添加新的 work 稱為 push
,行為上是將 work 擺在 bottom 對應的位置,並且將 bottom 增加 1。而該執行緒從自身的 deque 挑選下個 work 稱為 take
,作法是選擇在 bottom - 1 位置的 work,並在取出後將 bottom 減少 1。換言之,對執行緒本身 dequeu 的是使用是偏向 stack(LIFO) 方式的。
而如果有執行緒處於閒置狀態,可以嘗試去 steal
其他執行緒的 work 來幫忙分擔。但 steal
的位置是 take
操作另一側的 top 位置。選擇和 take
不同側的 work 來消化可能是有好處的:
steal
是直接取走 dequeue 中最困難、最需費時完成的一個,因為如此才不會太快完成 steal 到的 work,又要再去 steal 下一個,造成 steal 上的成本。而通常並行模式上是大任務 spawn 出小任務,又 deque 對所屬執行緒是以 stack 方式使用,因此 steal top
位置的 work 很可能是更好的選擇push
對應上面的敘述,push
主要做的就只是將給定的 work w
更新到當前 bottom 所指的位置,然後更新 bottom
為 bottom + 1
而已。但要留意由於 deque 的 buffer 是可能被填滿的,此時我們需要透過 resize
先動態將 buffer 增大,再來完成上述的操作。
release fence 的用途是可保證 fence 後的 store 必然在 fence 之前的任意 load/store 之後。push 的流程大概是:
則編譯器可能會重排成以下:
這個重排是合法的,因為在單執行緒環境,提早更新 bottom
不會影響我們要 push 下個 entry 的結果。然而這個重排對多執行緒的狀況是不同的,因為假設執行緒 A 先做 (2) 且還沒來得及做 (1) 的情況下,另一個執行緒 B 還未等到 w
被放到正確位置,就可能先去處理該位置上的東西了。
take
take
的目的是從執行緒自己的 dequeue 中取得下個要實行的 work。整體的行為僅僅是得到 q->bottom - 1
位置的 work,並且將 q->bottom
減 1
(如果 buffer 非空)。
實際上仍要考量正確的同步,因為其他執行緒會以 steal
方式取走本該在此 deque 中的 work,情況就變得複雜。
逐行觀察的話,最開始先取得要拿走 work 的位置 b
,然後就直接將 q->bottom
進行減 1
的更新。這部份是被 atomic_thread_fence
確保必須先於後面的程式執行的。換言之,我們尚未確定 top
是否等於 bottom
(佇列為空的情況)前,就先行預設位置 b 的 work 會被取走而更新了 bottom
。
這是因為若等到確定好 deque 到底是否為空才更新 bottom
,可能出現一個 work 同時被 steal
又被 take
的競爭問題。反之先更新 bottom
即使事後之後發現 deque 是空的或是 work 已經被 steal 掉,只要再復原 bottom
就好。
考慮以下三種情境:
t < b
: 執行緒可直接取走 b
位置對應的 workt == b
: dequeue 中僅剩一個 entry,此時可能和 steal
操作產生競爭。可以透過 cmpxchg 來判斷是哪種情形,注意到為了可以作 cmpxchg,這裡刻意從 top
方向去取(邏輯上和從 bottom 取其實相同),也就是取完之後我們想將 q->top + 1
,而非 q->bottom - 1
,所以無論最後是前述的哪種情況都要復原 bottom
t > b
: deque 為空,需要復原 bottom
前面原本將 bottom 減 1
是預設可以成功拿到對應 work 的情況,但發現 dequeu 為空的情況下,需要將 bottom 復原以確保 bottom 的更新情況最終符合沒有取走任何 work 的情況。
steal
steal
操作是從其他執行緒的 deque 中偷取下一個 work,與 take
從 bottom
取起不同,steal
會從 top
方向進行。
atomic_thread_fence
保證需先取 top
再去取得 bottom
。這對應了之前提到當 deque 剩一個 work 時,take
會提前更新 bottom
,再嘗試從 top
取得 work 的行為。
則當 t < b
,表示很可能 dequeue 中有可以偷走的 work。但具體還是要透過 cmpxchg 去競爭,確保 take
和 steal
的兩個執行緒最終只有一個可以得到它。
thread
回到各個執行緒的主體。每個 thread 就是循環進行以下流程以選擇出下個要處理的 work
不過,終究會有把所有派發的 work 都做完的時候。而即便都看了一輪發現沒有可偷的 work,但不代表所有的 work 都完成,也有可能再回頭找一輪就可以偷到。那麼怎麼準確知道真的沒有要進行的 work 呢? 這裡的作法是建立一個額外的 work done_work
,做為其他 work 的一個 argument。當其他 work 完成時就將 argument 中 done_work
的 join_count
減 1
。一直到 join_count
歸零時 done_work
可執行底下的函式,再設置對應 flag 表示所有派發的 work 都完成。各個執行緒可以根據此 flag 決定是否可以結束。
補完程式碼,使其運作符合預期。作答規範:
x+y
,而是 x + y
(+
運算子前後各有一個空白字元)1 + x
,而是 x + 1
,除非後者會導致不同的運算結果AAAA
, BBBB
, CCCC
, DDDD
, EEEE
均為表達式延伸問題: