linux/list.h 是 Linux 作業系統中相當實用的 doubly linked list 封裝,只要在自定義的結構中加入 struct list_head
,就可以搭配 Linux 中一系列的 linked list 操作(詳見The Linux Kernel API - List Management Functions) 來建立自定義結構的 linked list。在撰寫 kernel module 時會有機會跟其打交道。除了會使用到相關的 API 之外,其中的實作細節也很值得我們細細品味,因此,何嘗不是個好注意呢來看看程式碼呢?
我們可以先從 linux-list,由成大的黃敬群(jserv) 教授所建立的專案來一探究竟。這是一個和 Linux linked list 很相似的實作,但做了適當的簡化,並只留下一部分函數。
container_of()
#if defined(__GNUC__)
),__LIST_HAVE_TYPEOF
會被設為 1__extension__
: 根據 3.4 Options Controlling C Dialect,當語法被要求和 ANSI C 相容時(如 gcc 中加入 -ansi
參數),像是 typeof
等 gcc extension 將不能被使用。而在 6.48 Alternate Keywords 則提到可以用替換關鍵字的方法實作沒有 extension 時的替代方案。但意思就是這種設計會存在用到 extension 和沒用到的兩種實作,編譯器會對於使用到 extension 的部份扔出警告,可以透過 __extension__
避免警告的產生。({ ... })
是 statement expression 的語法((type *) 0)->member
取得目標 member 的 instance,typeof 會取得其型別。(注意到 pointer 0 沒有被 dereference 且 instance 沒被 access,所以合法),於是可以得到正確型別的 ptr
指標,存入 __pmember
ptr
是 type
中的 member
型別之指標__pmember
減去該成員在 struct 中的偏移就可以得到 struct 的開頭位址
offsetof
定義在 stddef.h
中char *
,以確保 address 的計算符合預期(可參考 The (char *) casting in container_of() macro in linux kernel 的說明)LIST_HEAD()
/ INIT_LIST_HEAD()
struct list_head
,先將 next
和 prev
都指向自己head
的 next
所指代表 linked list 結構的開頭而 prev
則指向結構的結尾list_add
/ list_add_tail
node
插入 linked list head
的開頭或者結尾list_del
node
從其所屬的 linked list 結構中移除node
本體甚至是包含 node
的結構所分配的記憶體皆未被釋放,僅僅是將 node
從其原本的 linked list 連接移除LIST_POISONING
使得被刪除的 node
對於 next
或者 prev
的存取會引發 build time 的 invalid memory access(如果系統禁止 predefined memory access)list_del_init
list_del
,但移除的 node
會額外呼叫 INIT_LIST_HEAD
把 prev
和 next
指向自己list_empty
head
的 next
是否指向自己,確認 list 是否為 empty 狀態list_singular
head
非 empty 狀態且 prev
和 next
是同一個節點,表示 linked list 只有一個節點list_splice
/ list_splice_tail
list
的所有 node 都插入到 head
的開始 / 結束位置中list
本身仍維持原貌list_spice_init
/ list_splice_tail_init
list_splice
/ list_splice_tail
類似,但移除的 list
會額外呼叫 INIT_LIST_HEAD
把 prev
和 next
指向自己list_cut_position
head_from
的第一個節點到 node
間的一系列節點都移動到 head_to
上head_to
必須是 empty 狀態(next
和 prev
都指向自己),否則可能發生 memory leaklist_move
/ list_move_tail
node
從原本的 linked list 移動到另一個 linked list head
的開頭或尾端list_entry
container_of
等價的 wrapperlist_first_entry
/ list_last_entry
list_for_each
node
和 head
不能在迴圈中被更改,否則行為不可預期list_for_each_entry
struct list_head
的另外一個結構之 entrynode
和 head
不能在迴圈中被更改,否則行為不可預期typeof
之限制,只能在 GNUC 下使用list_for_each_safe
/ list_for_each_entry_safe
safe
紀錄每個 iteration 所操作的節點(entry)的下一個節點(entry),因此刪除目前的節點(entry)是可被允許的,其他操作則同為不可預期行為我們可以從一些範例來看實際的使用。例如在專案中的 quick-sort.c 程式碼。
head
所承載的 linked list 有兩個以上 entry,否則就返回,不需要被排序INIT_LIST_HEAD
初始化另外兩個 list 結構,它們分別是用來插入 entry 中比 pivot 小或者其他的節點list_first_entry
取得第一個 entry 選為 pivotlist_del
將該 pivot entry 從 linked list 中移除cmpint
回傳兩個指標中的值相減的數值,因此小於零表 item->i
的值比 pivot
的值小,加入 list_less
,反之則同理list_less
和 list_greater
排序list_for_each_entry_safe
中,list_move_tail
和 list_move
會將所有原本在 head
中的節點移出,因此首先 list_add
加入 pivot
,再把已經排好的 list_less
放在 pivot 前 list_greater
放在 pivot 後,完成排序上面的程式碼是由 jserv 老師所實作,有了對其的理解後,我們也可以自己來實際使用一下相關的 API。這裡我用 2021q1 第 1 週測驗題 的作業要求作為範例,參考 Optimized QuickSort — C Implementation (Non-Recursive),嘗試一下基於 Linux like 的 linked list 實作類似的非遞迴 quick sort。
begin
和 end
表示在 linked list 中的排序目標的開頭和結尾,因此最初是整個 linked list 的頭至尾begin
和 end
的效果類似 stack,會填入每一輪要處理的節點開頭至結尾 ,因此先取出該輪的頭尾至 L
和 R
i == MAX_LEN - 1
的目的是額外檢查這輪如果填入 begin
和 end
是否會超出原本給定的陣列大小,因為我們所給予的空間是有限的R
)一直往 prev
前進,找到比 pivot
值更小的節點的話就將其值移到開頭的 L
去L
)一直往 next
前進,找到比 pivot
值更大的節點的話,就將其值移到結尾的 R
去L != R
則負責判斷當 L
往 next
而 R
往 prev
移動碰在一起時,當 L == R
時,不再做上述的操作,離開迴圈L
所在地方是 pivot
的值應在的正確位置,因此將 pivot
的值填入 L
pivot
往後到結尾的一段,兩個點分別是 L
的 next
,和這輪的 end[i]
pivot
以前從原本的 begin[i]
到 L
一段L == R
或者 &begin[i]->list == head
,表示此段 linked list 已經不需要再做處理,i--
類似於 pop stack 的概念Some issue:
比較 linux-list 與 linux/list.h 的實作,會發現最大的差異在於 WRITE_ONCE
macro 的使用。
我們可以比較 list_add
來探討差異的細節
乍看之下,如果把 WRITE_ONCE(prev->next, new)
替換成 prev->next = new
,兩者其實不是也沒甚麼差異嗎?
WRITE_ONCE
定義在 linux/tools/include/linux/compiler.h 路徑下,我們可以從註解中對其作用進行理解:
Prevent the compiler from merging or refetching reads or writes. The compiler is also forbidden from reordering successive instances of READ_ONCE and WRITE_ONCE, but only when the compiler is aware of some particular ordering. One way to make the compiler aware of ordering is to put the two invocations of READ_ONCE or WRITE_ONCE in different C statements.
簡而言之,通過 WRITE_ONCE
和 READ_ONCE
的使用,可以避免編譯器合併、切割、甚至重排特定的記憶體讀寫操作。下面是 WRITE_ONCE
的定義:
我們可以再次看到 statement expression 技巧的應用。此外,可以看到 WRITE_ONCE
把 val
被寫入 union 結構中,使其與一個 char _c[1]
共享空間。藉由以 union 為基礎的 type-punning
技巧,可避免違反 strict aliasing 規則,使得 __c
能作為這個 union 的指標來使用,從而允許編譯器最佳化。
需注意 type tunning 的方式有不同類型,而在 gcc 中,使用 union 進行 type punning 的方式被作為語言的擴展並合法(詳見 gcc: nonbugs),該方式不會違反 gcc 的 strict aliasing 規則。但在其他編譯器中則可能因為違反規則導致 undefined behavior。
延伸閱讀:
關鍵的 __write_once_size
的任務則是把 __val
寫入至 x
中,但通過巧妙的設計避免了編譯器將其做錯誤的優化,細節請見下面的 __write_once_size
說明
在深入 __write_once_size
之前,先來看看這個型別定義。
首先需要了解何謂 aliasing: 其所指為同一個位置可被多個 symbol 存取時,這種情形會造成編譯器優化上的難處。根據 Options That Control Optimization, -fstrict-aliasing
參數會讓編譯器預設程式撰寫者不會將相似類型(例如 int 和 unsigned int) 以外的 pointer 指向同一個記憶體位置,以允許做更激進的優化,這個參數在 -O2
、-O3
、-Os
下會預設開啟。
但我們可以根據 Specifying Attributes of Types 中所提到,使用 __may_alias__
告知編譯器此型別允許 aliasing 的發生,避免對此的過度優化導致非預期的程式行為,至於 Linux 這裡特別定義 *_alias_t
的目的可以參考註解:
Following functions are taken from kernel sources and break aliasing rules in their original form.
While kernel is compiled with -fno-strict-aliasing, perf uses -Wstrict-aliasing=3 which makes build fail under gcc 4.4. Using extra
__may_alias__
type to allow aliasing in this case.
對於 1 / 2 / 4 / 8 bytes 的變數,可以直接搭配 volatile
的使用提示編譯器不要優化這個寫入操作。volatile
所考慮的情境在於: 變數的值即使從程式中乍看不會改變,在每一次存取時實際則有可能被更動(例如該變數可能指向某個 memory map I/O,會被硬體更動值,或者被 signal handler 和 main program 共用的 global variable),因此可以避免寫入操作被錯誤優化。
對於其他長度的變數,則可以透過 memory barriers 搭配 memcpy
的方法來寫入。barrier()
如同其名稱是個屏障,告訴編譯器不能 barrier()
前的 load/store 移動到 barrier()
後,反之亦然。
值得一題的是,Linux 中採用了這種 "volatile access" 而非直接將 object 標註為 volitile 屬性,在 doc: volatile considered evil 中有提及後者可能隱藏的問題。
在 linux/list_sort.c 中,可以看到 linux 特別針對系統層級的考量所設計的 linked list sort。在最頂端 GitHub 連結中也可以看到整合至使用者層級的實作。接下來,嘗試探討其中的設計原理和可能的考量。
感謝詳細的註解,我們可以不必貿然的挑戰程式碼,先探討其中的實作內容。首先是需要的三個參數
priv
是一個 pointer,可以用來傳輸 cmp
需要的參數head
要被排序的 listcmp
是比較自定義大小的 function pointer註解中說明了在比較 a
b
時,如果 a
需至於 b
的之後,則 cmp
需回傳大於 0 的值。list_sort 是 stable sort,所以不必區分小於或等於 0 的分別。
最後則展示了一個簡單的 multi-word comparision。
接著,註解中提到這裡的 merge sort 實作重點。方法會保持兩個要被 merge 的 list 至少是 2 : 1 的狀態(比較大的 list 至多是小的的兩倍),這可以有效的降低比較的次數。list_sort 與一般的 fully-eager bottom-up mergesort 方法不同,後者只要出現兩個 2^k 大小的 list,就會立刻被合併。而前者的方法是在出現兩個 大小的 list 的時候,不立即合併,反之,一直等到下一個 2^k 的 list 被蒐集起來時,前者的這兩個 linked list 才會被合併起來。只要這 2 : 1 的 list 可以完全被放到 cache 裡,就可以避免 cache thrashing。
在 [RESEND PATCH v2 5/5] lib/list_sort: Optimize number of calls to comparison function
可以看到更完整的敘述。
那麼前述的規則該怎麼維持呢? 方法中會透過一個 count
來紀錄 pending 的節點數量。當目前的 count + 1 後,會設置第 k 個 bit 為 1,k-1 至 0 bit 為 0 時(除了 count
為 的情境),我們就把兩個 長度的 list 做合併。細節在後面探討程式碼時會再嘗試說明得更清楚。
後續註解有點掌握不到意思(菜英文),所以下面直接從程式碼的部份做理解並解釋。
首先破壞掉 linked list 的 circular 結構,最後的節點(head->prev
) 的 next
指向 NULL。
__attribute__((nonnull))
讓 compiler 可以對指定位置的 pointer 是 NULL 時發出警告這裡我們可以先來觀察 count
與 merge 的進行之規律。注意到這裡的格式中,左邊是在該 count 的 iter 開始時的狀態,右邊則是經可能的 merge 後把自己加入 pending list 後的狀態。
可以觀察到,count
的最小位元的 0 假設在位置 k,根據規則就要 merge 出 的 list (除了 count
為 )。而後面為 1 的 bit k - 1, k - 2 … 0 可以代表 pending 裡有 大小的 Llist 各一個,且因為 *tail
一開始就指向 的那個 list,所以 *tail
往 prev 走 k 步就會找到 大小的 list A,而 A 的 prev
是另一個 大小的 list B,我們要把兩者 merge 在一起。
再回到程式碼,for 迴圈中透過 count
最低位元的連續 1 找到此輪要 merge 的兩個 list,且 bits 若不為 0 剛好會等同於我們提到要 merge 的 count
。最後。每一輪的 list 會把自己的 next
設為 NULL 而 prev
指向 pending,並更新成原本的 next
所指向的下一個 node 繼續流程。
下面看一下 merge 具體的實作:
merge 接受參數的兩個 list_head
a / b 後,tail
初始時指向一個 dummy pointer。然後 for 迴圈比較 a / b。如果 a 應該在 b 之前,則 *tail
改指向 a,並且 tail
被更新以指向 "指向 a 的下個節點的 pointer"(注意這裡沒多打一個指向XD),這個步驟等同把 a 加到一個新的 linked list 中,然後下一次改成比 a 的 next
和 b,反之也是類似道理。直到 a
或 b
的 next
為 NULL 時,把另一個加入 *tail
則完成。
繼續來看 list_sort 的實作。
當所有的節點都被加入 pending 後,把所有的 pending 都 merge 在一起。刻意留下 pending 中的其一 pending
和其他的 pending merge 在一起的 list
去做 merge_final
,後者的目的是因為 merge
只有做單向(next
)的鏈結,需要把 prev
也接去正確的點上,並且回復至 circular linked list 的狀態。