contributed by < freshLiver
>
linux2022
struct list_head
為 list.h
中定義的結構體,這個結構體中包含了 prev
與 next
兩個定義於 list_head
的指標,分別藉由指向前、後一個節點,以便實作出 circular doubly-linked list。
struct element_t
為 queue.h
中定義的結構體,而在這個結構體中,除了定義 value
來儲存資料之外,也定義了結構體 list_head
的變數,並讓 list_head
中定義的 prev
以及 next
分別指向前、後一個佇列節點中的 list
,以便在佇列中的各個節點間移動。
element_t
中包含了 list_head
這次作業檔案 lsit.h
中除了定義結構體 list_head
之外還包含了數個相關的函式以及巨集,是 Linux 原始碼中的 list.h 的簡化版(少了一些 list_head
相關的操作函式以及 hlist
相關的部份)。
關於本次作業的 list.h
與 Linux 專案中的 list.h
的細節差異在你所不知道的 C 語言: linked list 和非連續記憶體中有更詳細的解說
而這個結構體也在 Linux 專案中被大量重複使用,除了包含了 circular doubly-linked list 相關的基本操作之外,若是想要在它之上定義其他變數的話,只需要另外定義新的結構體並在其中定義一個 list_head
的變數就可以進行擴充,而在擴充的結構體相關的操作中也可以直接使用 list.h
中的相關函式,而不用為每個新定義的結構體實作基本操作的函式,藉此來達到類似於其他語言中「繼承」的概念,也能夠藉由使用用這些函式以及巨集使得程式碼更簡潔、更具有可讀性。
為什麼在這次作業要實作的相關操作函式中,都是以 list_head
作為參數而不是直接使用 element_t
作為參數?
TODO: 思索這樣規劃帶來的效益
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
在這次要完成的佇列相關操作函式幾乎都需要透過 head
這個節點來走訪佇列,但在 list_head
構成的雙向鏈結串列中, head
節點只負責指向串列的開頭以及結尾的節點,實際上不需要儲存資料,因此若是使用 element_t
作為參數的話,會出現閒置的空間造成浪費。且在走訪串列時實際還是透過 list_head
結構體來進行,因此也會造成存取首位、末位節點時需要使用像是 head->list->next
或 head->list->prev
這樣的額外操作。
根據 Linus manual page 中對 offsetof 的說明,offsetof
是一個定義在 <stddef.h>
中的巨集,而 offsetof
可以用來獲取給定結構體 type
中某個 field member
相對於該結構體起始位置的位元組偏移量。
而在 GitHub 上 Linux 專案原始碼中的 stddef.h 檔案中有明確定義 offsetof
這個巨集的實作為:
從這邊可以看到,當編譯器沒有定義 __compiler_offsetof
時,會將 offsetof
這個巨集定義為 ((size_t)&((TYPE *)0)->MEMBER)
,而在這個定義中透過將 0 強制轉型成 TYPE
的指標使得該結構體的開頭地址為 0,接著再透過對該結構體中的 field MEMBER
取址來計算出 MEMBER
在這個結構體中的位元組偏移量。
TODO : 是否會造成 null pointer dereference
TODO : man page 有提到 padding 的問題,嘗試找到關於其的詳細說明
This macro is useful because the sizes of the fields that compose a structure can vary across implementations, and compilers may insert different numbers of padding bytes between fields. Consequently, an element's offset is not necessarily given by the sum of the sizes of the previous elements.
是否能找到 offsetof
的實作,參考:https://en.wikipedia.org/wiki/Offsetof
顯然「第一手材料」就在 Linux 核心原始程式碼,不要翻閱 Wikipedia (裡頭可能存在若干錯誤)。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
回覆老師:
我原先是透過 include 找到 offsetof
這個巨集在系統中 stddef.h 這個標頭檔中的定義是:
但是並沒有找到關於 __builtin_offsetof(t, d)
的實作,在老師提醒後我去翻閱 GitHub 上 Linux 中專案中於 stddef.h 的原始碼就有找到實際的定義了,感謝老師的提醒!
格式化問題:在上方的巨集中可發現 (ptr) -offsetof 的 - 與 offsetof 間缺少空格。
以 clang-format-11
及 clang-format-12
進行測試,若是將 Clang-format Style Option 中的 SpaceAfterCStyleCast 設為 false
會發現 (ptr)
與 -offset
間的空白消失:
嘗試用新版的 clang-format,例如
clang-format-12
,後者已被 Ubuntu Linux 收錄。在 .ci/check-format.sh 中,指定用clang-format-12
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
因此推測是 clang-format 將前面的 (char *) (ptr)
當成是轉型,而後面的 -offsetof
則被判斷成負數,造成減號 -
後的空格被移除。
可透過將 (char *) (ptr)
加上額外的括號,使排版與預期相符:
container_of
是一個透過使用 offsetof
來獲取給定的結構體 type
中某個 field member
的指標 ptr
所屬的結構體物件的地址。
主要的計算方式為使用 offsetof
獲取 member
相對於結構體 type
開頭的位元組偏移量,再將 member
的指標 ptr
中儲存的地址扣除該偏移量以計算出結構體的開頭地址。
然而在 C 語言標準文件中的 6.5.6 Additive operators 有關於加法以及減法運算子的說明,而在該節的第 7 項有說明當對一指向某物件的指標進行加減法時,會和對陣列的首個元素的地址進行加減運算有著相同表現。
- For the purposes of these operators, a pointer to an object that is not an element of an array behaves the same as a pointer to the first element of an array of length one with the type of the object as its element type.
而在其後的第 8 項則有關於對指向陣列元素的指標進行加減運算時會有什麼表現:
- When an expression that has integer type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the pointer operand points to an element of an array object, and the array is large enough, the result points to an element offset from the original element such that the difference of the subscripts of the resulting and original array elements equals the integer expression.
綜合第 7 項與第 8 項我們可以知道若是直接對一指標進行加減運算(假設 ptr + k
)的話,其效果就猶如陣列索引的移動,而不是對記憶體位置直接加減 k
個位元組。
也因此在 container_of
這個巨集的定義中:可以看到它是先將指標的型別轉成 char *
使其在進行加減運算時能夠確實的將記憶體位置以一個位元組的單位進行計算,以搭配 offsetof
的計算結果來得到正確的結構體物件的開頭地址。
相似的概念有也在你所不知道的C語言:指標篇中的回頭看 C 語言規格書的部份被提及
延伸問題:是否能將 char *
夠改成 void *
?
根據規格書中 6.5.6 Addit ive operators 對加法運算部份的描述:
- For addition, either both operands shall have arithmetic type, or one operand shall be a pointer to a complete object type and the other shall have integer type. (Incrementing is equivalent to adding 1.)
可以看到加法運算有兩種情境:
而在 container_of
這個巨集中,很明顯的屬於第二種情境,因此需要檢查的就是 void *
是否為一個「指向 Complete Object Type 的指標」。
然而在規格書中的 6.2.5 Types 的部份則說明了型別的類型以及 void
是什麼類型的型別:
- The meaning of a value stored in an object or returned by a function is determined by the type of the expression used to access it. (An identifier declared to be an object is the simplest such expression; the type is specified in the declaration of the identifier.) Types are partitioned into object types (types that describe objects) and function types (types that describe functions). At various points within a translation unit an object type may be incomplete (lacking sufficient information to determine the size of objects of that type) or complete (having sufficient information).
在第 1 項就將 type 分成 object type 以及 function type 兩類,而 object type 又可以再分成 complete 與 incomplete 兩種。
- The void type comprises an empty set of values; it is an incomplete object type that cannot be completed.
第 19 項則說明了 void
型別是一個 incomplete object type。
從綜合以上可以知道 void
不是 Complete Object Type,因此 void *
也不會是「指向 Complete Object Type 的指標」,所以若是將巨集中的 char *
改成 void *
的話,該巨集將不屬於加法運算的任一種情境。
除了計算結構體開頭地址的部份之外,在定義中還使用到了 __typeof__
這個 GNU C Extension 中定義的關鍵字 typeof
的 Alternate Keyword,透過使用這個關鍵字並配合另一個 GNU C Extension Statements and Declarations in Expressions 使用,這個巨集會先使用結構體 type
的 null pointer 來存取目標 field member
的類別,然後宣告一個該類別的變數 __pmember
並將 ptr
中的地址賦值給它,以達到檢查行別的效果。
可以透過簡單的實驗看到這個型別確認會帶來什麼效果:
當沒有使用 typeof
進行型別檢查時,透過 gcc 可以順利的編譯過,且沒有任何錯誤或是警告。但若是加上 typeof
進行型別檢查的話,雖然一樣可以編譯過,但這次就出現了型別警告的訊息了。
TODO :
在上面的巨集定義中使用了 #ifdef __LIST_HAVE_TYPEOF
來決定是有要使用 GNU C 的兩個擴充語法,但 __LIST_HAVE_TYPEOF
會隨著 __GNUC__
的定義被定義,是否有需要在其中使用 __extension__
以及 typeof
的 Alternate Keywork 來避免編譯器錯誤
-std=c89
參考:
https://gcc.gnu.org/onlinedocs/gcc/Alternate-Keywords.html
https://gcc.gnu.org/onlinedocs/gcc/C-Dialect-Options.html
q_new
根據 The GNU C Library 中 3.2.3.1 Basic Memory Allocation 的說明,malloc
會在 size 為 0 或是無法分配空間時回傳 null pointer,因此需要在 malloc
後檢查 head
是否為 NULL
。
q_size
將
head == NULL
改為!head
,保持程式碼的精簡
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
q_insert_head
及 q_insert_tail
避免濫用
&
,否則很難區分 operator 和英語的原本意思
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
若要加入一個節點 node 到佇列首位的話,需要修改到 4 個連結:
而其中要注意的部份是第 4 項的連結,若是一開始就將首位節點 head->next
更新的話,後面會沒辦法直接取得原本的首位節點(假設沒另外存起來的話)。
而其實新增節點的操作在 list.h
中就有被以函式的形式被實作了,叫做 list_add
,所以其實 q_insert_head
可以再簡化成:
q_insert_tail
要做的是跟 q_insert_head
相似,只有更新節點間連結的部份不同:
而這個操作也有在 list.h
中被實作出來,所以可以用 list_add_tail
簡單的完成。
除了使用 list.h
中提供的巨集簡化程式碼之外,也使用 strdup
來簡化 malloc
、memcpy
、string[size] = '\0'
的操作:
q_remove_head
和 q_remove_tail
q_remove_head
跟 q_remove_tail
也有類似 list_add
的函式可以用,叫做 list_del
,而這個函式可以用在任一節點上,所以只需要透過 list_first_entry
和 list_last_entry
兩個巨集拿到頭尾節點後就可以使用 list_del
移除連結並釋放資源。
q_delete_mid
用兩個指標 ptr
與 rev
分別從佇列頭尾而行,直到兩指標交會或是 rev
指到 ptr
的下一個節點,此時 ptr
即為中間點。
q_sort
和 mergesort
在 singly-linked list 中,若要實作一個遞迴版的 mergesort 的話,可以簡單的透過以下程式碼:
雖然這個方式也可以應用在 circular doubly-linked list 中,但很明顯的有以下幾點問題需要解決:
node->prev
因此需要對上面的 mergesort 進行一些修補以避免破壞 circular doubly-linked list 的特性:
在原本 singly-linked list 的版本中,在 merge 兩個 sorted linked list 時只需要比對 left
以及 right
的值,並將較小的節點 append 到 tmp_head
即可。但若是對 doubly linked list 進行一樣的操作的話,會造成排序後的節點的 node->prev
所指向的節點與預期中的前一個節點不同,因此在 doubly 的版本中,在 merge 完之後需要再依序走訪 new_head
並將各節點的 node->prev
指向正確的節點。
除了 node->prev
需要指向正確的節點之外,還需要注意到 head
是一個頭尾相連的 circular linked list,而 mergesort 中第 9 列用來尋找中間點的 for 迴圈的結束條件是 fast->next == NULL
或 fast->next->next == NULL
,因此在傳入佇列的首尾節點之前,需要將佇列的尾端節點的 next
指向 NULL
,以免造成無限迴圈。
而在 mergesort 結束後,由於 head->next
有可能並非實際擁有最小值的節點,因此需要將 head
與 mergesort 回傳的 list 重新建立雙向鏈結;相似地,head->prev
在 mergesort 前已改指向 NULL
,因此需要移動到最後一個節點,並將其與 head
重新建立雙向鏈結。
mergesort
**next
會被 cppcheck 的 style 檢查抱怨 scope 可以縮小,所以需要放在迴圈內,而長度限制則會讓它分成兩行,所以就乾脆讓 **next
的宣告與賦值分成兩行了。
prev
連結在以指標的指標重新實作時發現,其實 mergesort
中可以不使用 prev
,所以可以把最後修復 prev
的 for
迴圈移除,改在 q_sort
最後一次完成,不僅可以讓程式碼更精簡,也能避免在遞迴呼叫時重複修正已修正過的 prev
連結。
TODO
ptmp->next = left ? left : right;
以減少分支q_delete_dup
q_delete_dup
的實作概念是額外建立一個佇列 duplist
來存放 head
中重複的節點。
這個實作想法利用了 head
為遞增排序的特性,因此走訪每個節點時只須與前一個節點比較即可,若當前節點與前一個節點擁有相同值的話,就將其移動到 duplist
中。
待 head
中的所有節點都走訪過之後,再搭配 container_of
相關的 macro 來釋放 duplist
中的所有節點以及它們的資料。
上方的 q_delete_dup
實作在 6edd0f0 的修正後就會被檢查出錯誤,因此需要進行修正。
q_delete_dup
原版是從首位節點開始依序與前一個節點進行比較,直到走到最後一個節點為止,然而當最後 2 個(或更多)節點相同時,最後一個節點需要在 list_for_each_entry_safe
後進行額外的確認,但又因為 list_for_each_entry_safe
的結束條件是 ptr->list != head
,所以在結束後需要用 ptr->list.prev
才能取得最後一個節點,所以我決定直接重寫這部份。
修正後的版本則是從首位節點開始依序與後一個節點進行比較,並將 list_for_each_entry_safe
替換成以 next->list.next != head
為結束條件的 for
迴圈,讓 ptr
在結束迴圈時的狀態是指向最後一個節點,以便檢查是否需要將最後一個節點也移入 duplist
中。此外,也透過新增一個布林變數來簡化迴圈內所需的操作。
do while
部份在第一次看這段程式碼的時候,想了很久都不知道在幹麻,因此只好直接畫出來一步步觀察它的規律以及奧妙,並參考了你所不知道的 C 語言: linked list 和非連續記憶體 和 blueskyson 以及 kdnvt 的開發紀錄來輔助理解這部份到底在做什麼。
do while
部份程式碼首先,為了方便後面說明,將 do while
中的程式碼分成三個 part。
由於還不太熟悉 graphviz 的語法導致畫不出看起來整齊的圖,因此這邊使用 drawio 自己畫、自己移動指標觀察規律。而為了簡化圖示,沒有箭頭的格子就代表指向 NULL
。
0b0000
pending
會是空的,而 list
則指向當前未排序串列的首位節點 (後面統一稱作 first
)。do while
的部份後,則因為 count
以及 bits
都為 0,因此只會進行新增節點到 pending
串列的步驟 (part3)。pending
將會指向 first
,而 list
將會移動到 first
的下一個節點 (後面統一稱作 second
)。0b0001
count == 0b0001
,由於尾端有 1
,所以會先進行右移檢查的部份 (part1)。而由於只有最後一位元是 1
,因此 tail 會往前走一次,最後指向節點 5 prev
,但因為右移結束後不會進入到 if
區塊,因此這段其實不會被用到。first
加入到 pending
中,並在停在 pending
指向 first (4)
、list
指向 second (3)
的狀態。到這邊為止有幾個需要注意的地方:
first
新增到 pending
後,first
的 next
會被指向 NULL
,也就是說節點被加到 pending
串列後就不能以 next
依序拜訪。pending
後 next
連結都會被指向 NULL
,但是在加入前一輪時其實已經有將 list->next
指向 pending
,而 list
其實就是下一輪的 first
,也就是說其實可以用 prev
依序拜訪 pending
中的串列。0b0010
count == 0b0010
的情況,由於最低位元不是 1
,因此不用做 part1,但因為 bits != 1
因此要先進行合併 pending
中節點 (part2) 的操作。a
會先移動到當前 tail
指向的指標所指向的節點(圖中的節點 4
),而 b
則會指向 a
的前一個節點 (圖中的節點 5
)。next
會指向 5 ,而 a
則會指向排序後串列的首位節點(在此次合併中為 4),並需要將 a->prev
指向 b->prev
(在此次合併中為 NULL
)以及將 tail
指向的指標指向 a
。pending
會指向 first (2)
,而 list
則會指向 second (3)
。0b0011
count == 0b0011
時,首先會先進行 part1 移除尾端的連續 1、將 tail
指向 4 的 prev
,但因為 bits
為 0,所以其實沒有作用。pending
會指向 first (3)
,而 list
則會指向 7
。進行到這邊,除了單純位移以及新增節點到 pending
串列中之外,還進行了合併 (part2) 的操作,有幾個細節可以注意:
a
、b
進行合併時只會將 next
連結指向正確的節點,prev
則不會被改變。而合併結束後就用了這個特性,讓新的已排序串列的首位節點的 prev
指向串列 b
的 prev
所指向的節點。pending
中的節點是以 prev
作為連結互相連結,而 next
雖然在剛被加入 pending
時會被指向 NULL
,但是在合併時 next
會被正確的連接、形成正確排序的串列。a
、b
節點可以被正確的透過 prev
依序走訪,但是在合併後,不論合併後的已排序串列的首位節點是 a
還是 b
,最後能夠透過 pending
以及 prev
連結指標走訪的節點只有排序後串列的首位節點而已(第 14、15 行在做的事)。0b0100
count == 0b0100
時,由於尾端沒有連續的 1,因此不須進行位移以及移動 tail
的 part1。但由於 bits != 0
,因此需要進行 part2 的合併。a
會指向 3,而另一已排序串列 b
則會指向 2。a
,其 prev
連結則會指向原本 b->prev
指向的 4(由於首位節點沒變,因此指向的節點也沒變),且 tail
指向的 pending
指標則會改指向合併後的首位節點 a
。pending
將會指向 first (7)
,而 list
則會指向 second (6)
。到這邊為止則是又重複了一次 count == 0b0010
的操作,只是這次是兩個不一樣的節點。
0b0101
count == 0b0101
時,會先進行 part1 將 tail
指向 7 的 prev
,接著再進行 part2 的合併。part1
有進行,因此 a
為 7 的 prev
所指向的節點,也就是 2,而 b
則為它的前一個節點 4。而合併後會產生一個新的已排序串列,此時將 a
更新成該串列的首位節點 2
,並且將 a->prev
更新、 tail
指向的指標指向 2。first (6)
新增到 pending
中,並將 list
指向 NULL
,而由於 list
指向 NULL
因此 do while
的部份就結束了。到這邊為止,大概可以抓到一個規律是:當合併( part2 )發生時,*tail
會指向最接近 pending
開頭的兩個大小皆為 的子串列,並進行合併。
但:
part1
還能夠剛好將 *tail
移動到到最近一對相同大小的子串列?還在思考是否能以二進制的規律理解
// TODO :
The cost distribution of queue-mergesort, optimal mergesorts, and
power-of-two rules
Wei-Mei Chen, Hsien-Kuei Hwang, Gen-Huey Chen
Journal of Algorithms 30(2); Pages 423–448, February 1999
https://doi.org/10.1006/jagm.1998.0986
https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.4.5380
count | 合併前 (上輪結果) | 本輪動作 (位置) |
---|---|---|
0b0000 | [] | add |
0b0001 | [1] | add |
0b0010 | [1,1] | merge(0), add |
0b0011 | [1,2] | add |
0b0100 | [1,1,2] | merge(0), add |
0b0101 | [1,2,2] | merge(1), add |
0b0110 | [1,1,4] | merge(0), add |
0b0111 | [1,2,4] | add |
0b1000 | [1,1,2,4] | merge(0), add |
0b1001 | [1,2,2,4] | merge(1), add |
0b1010 | [1,1,4,4] | merge(0), add |
0b1011 | [1,2,4,4] | merge(2), add |
0b1100 | [1,1,2,8] | merge(0), add |
0b1101 | [1,2,2,8] | merge(1), add |
0b1110 | [1,1,4,8] | merge(0), add |
0b1111 | [1,2,4,8] | add |
表格中間 column 的陣列代表該該輪合併前 pending 串列的狀態,每個元素都代表一個「已排序」的串列(如前面圖中著有相同顏色的節點),該元素的數值則代表該串列的長度,而陣列最左側的元素就是當下 pending
指標指向的串列。
merge_final
部份而當 list
指向 NULL
時,代表所有節點都走訪過了,且全部的節點都在 pending 串列中因此最後還需要依序兩兩合併所有 pending 中的已排序串列:
而要合併 pending
中的子串列可以用類似 iterative merge k sorted list 的方式,依序從 pending 開頭合併到剩下最後兩個子串列。
為什麼剩最後兩個子串列時就停了?
到目前為止的合併過程都只有保證 next
連結會指向正確的節點,而 prev
連結則是用來指向下一個 pending
中的子串列(僅各個子串列的首位節點的 prev
能用來走訪 pending
串列,其他合併後非首位的節點的 prev
則不應該被使用),因此需要趁最後合併時將 prev
復原、指向正確的節點。
最後的合併大致上與 merge 要做的事相同,首先是 part1 的部份,依序比較兩串列的首位節點,並將較小的節點加到雙向串列的尾端 (tail
),直到其中一個串列為空。
當 part1
完成後,不像是單向鏈結串列可以直接將剩餘的接在後面,由於有 prev
連結需要處理,所以要依序將剩餘的節點加入到尾端並恢復 prev
連結。
問題一:
為什麼要在 part2 中加上 if (unlikely(!++count)) cmp(priv, b, b);
?
首先可以先來看原本的註解寫了什麼:
從這說明可以知道這是為了兩個子串列非常不平衡時設計的一個分支,每當新增一個節點時就會 ++count
,看起來沒什麼意義,但回去看 count
宣告時的型別會發現它是 uint8_t
(原始程式碼中是另外使用 typedef 將 u8
定義成 u_int8_t
,這邊為了方便解釋直接替換成 uint8_t
),所以若是剩餘的節點非常多的時候會因為 overflow 而從頭開始計算,而在 overflow 時就會呼叫 cmp
函數,因此可以達成定期呼叫 cmp
函式的效果。
問題二:
為什麼需要定期呼叫 cmp
?
裡面提到的 cond_resched
是什麼?
// TODO
likely
與 unlikely
巨集這兩個巨集被定義在 include/linux/compile.h 中。
TODO
Bottom-up Mergesort: A Detailed Analysis
Wolfgang Panny, Helmut Prodinger
Algorithmica 14(4):340–354, October 1995
https://doi.org/10.1007/BF01294131
https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.6.5260
Queue-Mergesort
Mordecai J. Golin, Robert Sedgewick
Information Processing Letters, 48(5):253–259, 10 December 1993
https://doi.org/10.1016/0020-0190(93)90088-q
https://sci-hub.tw/10.1016/0020-0190(93)90088-Q
atexit
原本想直接對 make test 的過程使用 Valgrind 檢查,但是發現自動測試程式是用 python 進行的,所以只好使用 Valgrind 分別檢查各個測資。
若以 Valgrind 執行 qtest
並以 trace-01-ops.cmd
作為輸入測資的話:
可以從上方執行紀錄看到有在 linenoiseHistory
時使用到的 malloc
與 strdup
分配到的空間沒被釋放。
而實際到該函式檢查則會發現這個函式是在在新增紀錄到 history
這個 char **
的全域變數中(字串的陣列),而出問題的 1224 行是在分配空間給 history
、1236 則是在分配空間給要新增的命令歷史紀錄,而它後來又存到 history
的某個欄位中。到這邊為止可以發現這兩個問題都是跟 history
相關,所以依次檢查 history
是否有哪邊處理的不完善。
依序檢查了 linenoise.c
中使用到 history
的部份似乎都沒什麼問題,所以參考了 laneser 以及 Risheng 兩位的開發紀錄,並在其中看到似乎是在以檔案形式輸入測資時,沒有正確呼叫 freeHistory
造成的錯誤,因此改從 freeHistory
開始回推發生錯誤的原因。
而從 freeHistory
往回推會發現該函式只會被 linenoiseAtExit
呼叫,而 linenoiseAtExit
則是由 atexit
這個函式呼叫,而根據 atexit 的 man page 中的說明可以知道這個函式會在程式正常結束時呼叫設定的函式 function
,也就是說 linenoiseAtExit
這個函式只有在 qtest
結束時才會被呼叫。
知道了可能的問題後,接著對 linenoiseAtExit
函式設定中斷點看看會發生什麼事:
qtest
時,程式會在結束後(輸入 quit
)進入到 linenoiseAtExit
函式,無 memory leak 相關錯誤。<
將測資重新導向至 qtest
執行時,程式結束後不會進入 linenoiseAtExit
函式,會出現 memory leak 相關錯誤。-f
指定測資執行 qtest
時,程式結束後不會進入 linenoiseAtExit
函式,會出現 memory leak 相關錯誤。可以發現與參考的兩位同學開發紀錄中提到的情況相同,而接下來為了找出是什麼原因造成這個差異,所以使用 GDB 逐步執行並比較不同的輸入方式會發現:
linenoise
進入到 linenoiseRaw
函式linenoise
進入到 linenoiseNoTTY
函式-f
參數指定測資檔案,則會直接進入到 cmd_select
函式,而不會進入到 linenoise
函式綜合執行結果以及進入的函式差異,可以推測問題可能出在 linenoiseRaw
這個函式中,而 linenoiseRaw
中又呼叫了 enableRawMode
函式,下方為該函式的其中一部份,可以看到在第 8 行出現了前面提到的 atexit
以及 linenoiseAtExit
,且 atexit
並沒有在其他地方被呼叫,因此合理推測這就是問題所在。
推測出問題所在後開始嘗試修改程式碼來解決這個問題:
為了讓三種輸入方式都能夠呼叫到 atexit(linenoiseAtExit)
,我在 linenoise.h
以及 linenoise.c
中新增了一函式 setAtExit
,並將 atexit
相關的部份移到該函式內:
並在三種輸入方法都有呼叫到的 run_console
中呼叫此函數來設定在 qtest
結束後需要呼叫 linenoiseAtExit
來釋放資源。
而重新編譯並用 valgrind 測試 trace-01-ops.cmd
後,可以看到不論是用 -f
指定測資、還是透過重新導向輸入測資,錯誤訊息就如預期的消失了:
test_malloc
在解決 atexit
的問題後,繼續以 Valgrind 對其他測資進行測試,發現在執行 trace-14-perf.cmd
這個測資時會跳出大約 300 行的錯誤訊息,查看了測資後發現是在測 reverse
以及 sort
兩函式的效率,而這兩個操作的測試函式 do_reverse
及 do_sort
中有使用 alarm
來中斷執行過久的函式。
若將 harness.c
中的 time_limit
調高到 99999
的話,會發現操作都能順利完成, Valgrind 也不會跳錯誤訊息,所以初步推測問題是出在 singal 相關操作的部份。
而為了簡化問題,先將 trace-14-perf.cmd
中的 sort
操作移除:
接著再重新以 Valgrind 檢查問題:
可以從這些錯誤訊息發現,這些沒被釋放的記憶體空間都是從 harness.c
中的 test_malloc
這個函式中產生的,而根據作業說明以及查看 harness.c
中的程式碼,會知道這個函式是用來包裝 malloc
並紀錄已分配的記憶體,來檢查 q_free
等功能是否有正確被實作。
而在 qtest
的錯誤訊息中可以看到在 Freeing queue 時,由於超出 alarm
設定的時間,因此有部份應該被釋放的記憶體空間尚未被釋放:
若在程式碼中尋找 Freeing queue 這個訊息的話,會發現這個訊息是當 qtest
收到 quit
命令後的提示訊息,而在印出訊息後就會進行 q_free
:
因此這邊猜測是 q_free
進行到一半時就因 alarm
的設定而產生 SIGALRM
,而在 queue_init
中又有透過 signal(SIGALRM, sigalrmhandler);
設定當 SIGALRM
發生時要進行的處理(使用 siglongjmp
跳到 exception_setup
進行後續處理),造成 q_free
沒辦法釋放所有 allocated
串列中紀錄的記憶體空間。
從上面的過程可以推測出問題出在 q_free
沒辦法釋放 allocated
這個鏈結串列中紀錄的記憶體空間,因此這邊嘗試為檢查是否有空間尚未被釋放的函式 allocation_check
新增一個參數來決定是否要在檢查後釋放所有空間:
並且為 qtest
中使用到此函式的部份進行修改(傳送參數 true
),接著再重新以 valgrind 測試在超時後是否仍有記憶體沒被釋放:
可以看到這次雖然仍然因為超時而中斷 q_free
的操作,並且有 68081 個 blocks 偵測出沒被正確釋放,但是後面卻沒出現 Valgrind 的錯誤訊息了,這與上述修改的預期結果相符。
若是多執行幾次會發現,其實這個問題沒有完全解決,Valgrind 仍有機會跳出錯誤訊息:
可以看到這個錯誤仍發生在 test_malloc
中,但這次卻只有一個 block 沒被釋放,且這個記憶體位址是 definitely lost in loss record。
若是回到 test_malloc
中會發現函式中其實只是呼叫 malloc
後再紀錄到 allocated
這個串列中而已,因此初步推測是在 malloc
後,但是在新增到 allocated
之前發生了 SIGALRM
,所以即使在 allocation_check
時手動釋放了 allocated
中的紀錄,也會因為這個 block 根本沒被紀錄而造成 memory leak 的發生。
初步解法:
若是有辦法暫時暫停 alarm
的計時器的話,或許能夠讓所有 test_malloc
中 malloc
的空間能夠被正確加入到 allocated
串列中,以確保後它能夠在後去操作被正確釋放。
可以在 console_init
中透過 ADD_COMMAND
或是 add_cmd
新增測試時的命令,兩者的差異在於前者是透過巨集的 Stringizing 以及 Concatenation 來簡化呼叫 add_cmd
要傳遞的參數,而 ADD_COMMAND
則會根據參數來設定命令的名稱以及執行命令時要呼叫的對應函式。
當 shuffle
命令被執行時,會先呼叫此函式,而這個函式參考了 do_sort
加入了佇列的檢查以及設定例外,接著才會呼叫 q_shuffle
函式。
TODO : check argc
而在 shuffle
函式則是參考了 Fisher–Yates shuffle 的 modern method 進行實作。