contributed by < blueskyson
>
參考你所不知道的 C 語言:linked list 和非連續記憶體實作
以下開發過程以 Head 表示佇列的頭,以 Node 表示 element_t
結構體。
首先 malloc
一個 struct list_head *head
作為佇列的 Head,並檢查 head
是否為 NULL
。接下來我把 head->prev
和 head->next
初始化為 head
,INIT_LIST_HEAD
函式已經實作了前述初始化。
參考 kdnvt,若 malloc head
失敗,head
本身就會指向 NULL
。所以程式碼還能改得更短:
首先確認傳入的 struct list_head *l
是否為 NULL
。接下來使用 list_for_each_entry_safe
走訪佇列中所有 Node,使用 list_del
將 node
從佇列移除並且 free
掉 node->value
和 node
本身。最後 free
掉 l
,即佇列的 Head。
檢查 Head 是否為 NULL
。malloc
一個 element_t *node
作為即將插入佇列的 Node,並檢查 node
是否為 NULL
。接下來透過 strdup
將字元陣列 s
複製到 node->value
。最後呼叫 list_add
將 node
插入佇列的開頭。
檢查 Head 是否為 NULL
。malloc
一個 element_t *node
作為即將插入佇列的 Node,並檢查 node
是否為 NULL
。接下來透過 strdup
將字元陣列 s
複製到 node->value
。最後呼叫 list_add_tail
將 node
插入佇列的尾端。
檢查 Head 是否為 NULL
、檢查佇列中是否有 Node。透過 list_entry
取得佇列的第一個 Node,然後用 list_del
移除此 Node。接下來檢查 sp
是否為 NULL
以及 bufsize
是否為 0,使用 strncpy
把 Node 的字元陣列複製到 sp
中。
改進:
list_empty(head)
替換 head == head->next
list_first_entry(head, element_t, list)
替換 list_entry(head->next, element_t, list)
檢查 Head 是否為 NULL
、檢查佇列中是否有 Node。透過 list_entry
取得佇列的最後一個 Node,然後用 list_del
移除此 Node。接下來檢查 sp
是否為 NULL
以及 bufsize
是否為 0,使用 strncpy
把 Node 的字元陣列複製到 sp
中。
改進:
list_empty(head)
替換 head == head->next
list_last_entry(head, element_t, list)
替換 list_entry(head->prev, element_t, list)
檢查 Head 是否為 NULL
,接下來透過 list_for_each
走訪整個佇列以計算 Node 的數量。
宣告 struct list_head *slow = head->next, *fast =slow->next
,slow
每次會指向 slow
的下一個 list_head
,而 fast
會指向 fast
的下下個 list_head
,所以當 fast
指向 head
時,slow
正好在佇列正中間的 Node。然後,透過 list_entry
得到 slow
所在的 Node 的位址、透過 list_del
把 slow 從佇列中移除,最後 free
掉這個 Node 的所有資料。
改進:
list_empty(head)
替換 head == head->next
確認佇列的 Head 是否為 NULL
。在 list_for_each_entry_safe
的每次迭代,prev_value
會指向前一個未重複的字串,若 prev_value
所儲存的字串與 node->value
一模一樣,代表字串重複,將當前的 Node 從佇列移除;反之將 prev_value
指向 node->value
。
#73 修正了評分程式的 bug,我上面的 q_delete_dup
不能通過測試,所以我修正了程式碼:
確認佇列的 Head 是否為 NULL
。用 first
和 second
指向一對連續的 list_head
,操作 first->prev
、first
、second
、second->next
的指標來達成 swap,最後將 first
、second
指向下一對連續的 list_head
。
改進:
首先確認佇列的 Head 是否為 NULL
。接下來宣告 struct list_head *prev, *node
代表 circular linked list 中的任兩個連續的 list_head
,接下來透過 do while
迴圈,把 prev->prev
與 prev->next
指向的位址對調,然後把 prev
和 node
指向下兩個連續的 list_head
,直到所有 list_head
的 prev
和 next
指向的位址都被對調。
圖示:
尚未反轉的佇列
Step 0: 反轉 Head
Step 1: 反轉 Node 1
Step N: 反轉 Node N
實作 merge sort。想法:在排序過程中把佇列的 Head 斷開、把所有 Node 當作 singly-linked list,當排序完成,再把佇列恢復成 circular doubly-linked list。
注意連字號擺放位置: singly-linked, doubly-linked
:notes: jserv
首先讓最後一個 Node 的 next
指向 NULL
,將 &(head->next)
傳入遞迴函式 sort_recur
。在 sort_recur
中,以傳入的 list 少於兩個 Node 作為中止條件,若此段 list 的長度大於等於兩個 Node,則把它平分成 left
、right
左右兩段,呼叫 sort_recur(&left)
, sort_recur(&right)
進入下一層遞迴。當 sort_recur
回傳後,用 strcmp()
比較字串長度及大小,把 left
及 right
的所有 Node 由小到大排序。
當 sort_recur
的遞迴結束,佇列已經排序完成,但是所有 list_head
的 prev
都亂指一通,所以我再用一個 while
迴圈來把 prev
指向前一個 Node 的,然後再讓最後一個 Node 的 next
指向 Head,至此佇列便恢復成 circular doubly linked list。
sort_recur
應用 pointer-to-pointer 與 dummy_head 使程式碼精簡。
圖示如下:
尚未排序的佇列
前處理: 把佇列視為 singly linked list,讓最後一個 Node 的 next
指向 NULL
。
Merge sort: 示意圖摘自 你所不知道的 C 語言:linked list 和非連續記憶體
完成排序的佇列: 此時所有 Node 的 next
按照順序連接,但是 prev
亂指一通。
讓佇列恢復成 circular doubly linked list
閱讀時間點: Latest commit 9dbbc3b on Jul 8, 2021
list_sort
實做為 的 balanced merge,即,假設現在有兩個長度為 的 pending sublist (正等著被 merge),如果這兩個 sublist 後面還有 以上個元素,就馬上 merge 這兩個 sublist 成為 長度的 list,如此一來合併後的 list 與剩下的 個元素數量正好為 。
這樣做的好處是,當 cache 容納得下 個元素時,可以避免 cache thrashing。這個實作沒有比 bottom-up merge sort 好,但是可以減少 次比較,所以當 L1 cache 可以容納 個元素時此作法會比較快。
既然 balanced merge sort 可以減少 次比較,不知道為什麼註解說 bottom-up merge sort 要比 $2:1$ balance merge sort 好, 這個數字怎麼來的也不知道。需要計算數學與設計實驗測量 branch、cache miss 數量。
快去讀論文!
:notes: jserv
為了找到更詳細的資料,我查看了 list_sort.c 的 commit 紀錄,發現 merge 的實作在 b5c56e0 引入,commit 訊息解釋了為何要如此實作,並提供三個參考資料:
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
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
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
稍後詳閱。
第一行 __attribute__((nonnull(2,3)))
是告訴 gcc 這個函式應該具有什麼屬性,在編譯時期就可以檢查程式碼的正確性,詳情在下方__attribute__ 的作用說明。
參數說明:
@priv
: private data,list_sort()
並不會直接操作,只會把 priv
傳遞給 @cmp
,如果 @cmp
不需要使用 priv
,可以直接傳入 NULL
作為 priv
,但是 @cmp
仍必須讓第一個參數去接收 priv
。(可以參考 linux/drivers/gpu/drm/i915/gvt/debugfs.c 如何使用 list_sort
以及 mmio_offset_compare
)@head
:要排序的串列的 Head。@cmp
:元素比較函數,為 function pointer,其型態 list_cmp_func_t
宣告如下:
@cmp
的回傳值必須是 int
型態,@cmp
的第一個參數(型態為 void*
)負責接收 @priv
。第二、三個參數 @a
、@b
為串列的 Node 的 list_head
。如果 @a
應該排在 @b
之後,@cmp
必須回傳 > 0(例如 @a > @b
,並且升冪排序);反之,如果 @a
應該排在 @b
之前(即順序不變),@cmp
必須回傳 <= 0。list_sort
是 stable sort,因此不需要區分 @a < @b
和 @a == @b
的情況。
這裡原文提到 "This is compatible with two styles of @cmp function … e.g. plug_ctx_cmp()
in block/blk-mq.c." 但是在 block/blk-mq.c 裡根本沒有 plug_ctx_cmp()
,我也不知道是什麼機制允許 @cmp
為兩個參數的 function pointer,或許我理解錯 "two styles of @cmp function" 的意思?
首先檢查 list 是否至少有兩個 Node,接下來讓最後一個 Node 的 next
指向 NULL
,使其變成 singly linked list。prev
指標不再指向前一個 Node,而是另有用途。
參照上方 do while 迴圈與註解可以看出 merge sort 實做的一些想法:
pending
是已經排序好但尚未被 merge 的 sublists 的 list,亦稱為 "list of list"。bits
用於判斷何時必須 merge 相鄰的若干個 Node,其目的是檢查目前的 sublist 是否湊滿 個 Node,若目前有 個 Node,if(likely(bits))
就會成立(likely()
用於優化編譯後的組合語言,稍後在 likely 與 unlikely 巨集解釋),並呼叫 merge
來合併最新的兩個 pending sublists,然後讓 list
指向下一個 Node;若不到 能 merge
,就只讓 list
指向下一個 Node。
for (bits = count; bits & 1; bits >>= 1)
會持續將 bits
右移,直到遇到由右向左數來的的第一個 clear bit,若 for 迴圈完畢後 bits
仍不為 0,就 merge 兩個長度一樣的 pending sublists。只閱讀程式碼太抽象,我列舉 count
等於 0 到 11 的狀態:
count(十進位) | count(二進位) | 所有 sublist 的狀態 |
---|---|---|
0 | [1] | |
1 | [1,1] | |
2 (merge) | [1,1,1] -> [2,1] | |
3 | [2,1,1] | |
4 (merge) | [2,1,1,1] -> [2,2,1] | |
5 (merge) | [2,2,1,1] -> [4,1,1] | |
6 (merge) | [4,1,1,1] -> [4,2,1] | |
7 | [4,2,1,1] | |
8 (merge) | [4,2,1,1,1] -> [4,2,2,1] | |
9 (merge) | [4,2,2,1,1] -> [4,4,1,1] | |
10 (merge) | [4,4,1,1,1] -> [4,4,2,1] | |
11 (merge) | [4,4,2,1,1] -> [8,2,1,1] |
上表中第一個欄位是 count
在 do while 迴圈中每一次迭代的值(0 到 n-1),後面緊接著 (merge) 代表在該次迭代中有呼叫 merge
來合併 sublist。
第二個欄位是以二進位表示 count
,可以注意到由右到左的連續 set bits 被標記為紅色,代表被 for 迴圈右移而被移除。
第三個欄位為每次 do while 迴圈迭代完成後,所有 pending sublist 的狀態,每個以逗號隔開的數字代表該 sublist 的 Node 數量,所有數字加起來會等於 count+1
,箭號代表 merge 後 pending lists 變為箭號後的狀態。舉 count == 5
為例,[2,2,1,1]
代表第一、二個 Node 屬於第一個 sublist,第三、四個 Node 屬於第二個 sublist,第五個 Node 自成第三個 sublist,第六個 Node 自成第四個 sublist。此時因為第一、二個 sublist 長度皆為 ,且第一、二個 sublist 後面的 Node 數量也為 個,符合 balanced merge,所以 merge 這兩個 sublist,因此狀態變為 [4,1,1]
。
解釋完 bits
的奧妙機制後,接下來談實作上如何是如何儲存 pending sublist 狀態,最關鍵的手法是利用 prev
指向每個 sublist 的第一個 Node。在每次 do while 迭代,list
會指向第 count
個 Node,pending
會指向前一個 Node,indirect pointer tail
會指向 &pending
並在 bits
向右移時,一直指向 tail = &(*tail)->prev
藉以把自己移動到可能將被 merge 的 sublist,在 sublist 被 merge 後更新 prev
。
圖示:
執行完 count = 0 的迭代:
Node 1 自成一個 sublist,所以所有 sublists 的狀態為 [1]
。
count = 1
此時 Node 1 自成一個 sublist、Node 2 也自成一個 sublist,sublists 的狀態為 [1,1]
。
count = 2
此時 Node 3 也自成一個 sublist,sublists 的狀態為 [1,1,1]
。我們發現 Node 1 與 Node 2 為首的 sublist 長度都為 ,且後面有一個 Node 3,形成 ,我們 merge 以 Node 1 和 Node 2 為首的 sublists,讓狀態變成 [2,1]
。長度為 2 的 sublist 即為下圖綠色區域,Node 1、Node 2 是排序好的 singly linked list。
count = 3
sublists 的狀態為 [2,1,1]
。
count = 4
sublist 2 與 sublist 3 長度都為 ,且後面有 Node 5 形成 ,我們 merge sublist 2 和 sublist 3,讓狀態由 [2,1,1,1]
變成 [2,2,1]
。
count = 5…N 省略
由上方的圖示可以看出 prev
串連了每個 sublist 的第一個 Node,透過 tail = &pending
以及執行若干次 tail = &(*tail)->prev
定位到指定的 sublist。
prev
,恢復成 circular doubly-linked list前面的 do while 迴圈執行結束後會留下許多長度為 且由大到小排序的 pending sublist,舉 n = 15 為例,do while 結束後會留下 [8,4,2,1] 4 個 sublist,此時我們需要透過以下 for 迴圈達成:[8,4,2,1] -> [8,4,3] -> [8,7]。最後呼叫 merge_all
來合併剩餘的 [8,7],並且將整個 list 恢復成 circular doubly linked list。
__attribute__
的作用前面閱讀 list_sort
原始碼時,在函數的開頭使用了 __attribute__((nonnull(2,3)))
。為了釐清它的作用,尋找第一手材料,首先進入 gcc 9.x 的 onlinedocs,找到第 6 章 Extensions to the C Language Family,找到 6.33 Declaring Attributes of Functions:
在 GNU C 和 C++ 中,您可以使用 Function Attributes(以下簡稱"屬性")為函式指定某些屬性,這些屬性可以幫助編譯器優化函式,或更仔細地檢查程式碼的正確性。您還可以使用屬性來控制函式的 memory placement、code generation options 和 call/return conventions。許多 attribute 是 target-specific 的,例如許多 target 支援定義 interrupt handler functions 的屬性,這些函式通常必須使用特定的 register 來執行和回傳,此類屬性與其對應的 target 將在 6.33 的每個小節描述。但是大部分 target 都支援相當多屬性了,這些通用的屬性在 6.33.1 Common Function Attributes 描述。
屬性由函數宣告中的 __attribute__
關鍵字引入,接著用雙括號括起想要使用的屬性,若想要指定多個屬性,就在雙括號內用逗點分隔它們,或者在一個屬性之後緊跟另一個。有關屬性語法的確切規則,請參閱 6.39 Attribute Syntax。如果兩個屬性互相不兼容,編譯器會直接忽略屬性並產生警告。
GCC 還支援 6.34 Variable Attributes、6.35 Type Attributes、6.36 Label Attributes、6.37 Enumerator Attributes、6.38 Statement Attributes。
讀到這裡我們已經完全瞭解 Function Attributes 的目的與語法,接下來閱讀它如何去搭配 nonnull
屬性。在 6.33.1 Common Function Attributes 找到 nonnull
的段落:nonnull
屬性可以應用於使用至少一個 pointer 作為參數的函式,它表示傳入該欄位的參數必須不為 NULL
,例如以下函式的宣告:
宣告表明第 1、2 個參數不能為 NULL
,也就是 dest
和 src
為 non-null。如果編譯器確定在標記為 non-null 的參數槽中傳遞了 NULL
,並且編譯時啟用了 -Wnonnull
選項,就會發出警告,見 Warning Options。除非啟用了 -fno-delete-null-pointer-checks
選項,否則編譯器還可以根據某些 non-null 參數進行優化。此外啟用 -fisolate-erroneous-paths-attribute
選項以使 GCC 把任何傳遞 NULL
到 non-null 函數的呼叫轉換為 traps,請參閱 Optimize Options。
我不知道 GCC 的 "trap" 是什麼東西,查不到解釋。
就是你在計算機組織課程學到的 trap, interrupt, exception
:notes: jserv
如果沒有在 nonnull
填入 arg-index,則所有 pointer 參數都被標記為 non-null,例如:
在 list_sort 實作中,某些 if 條件判斷會被包在 likely
與 unlikely
巨集中,其宣告在 linux/compiler.h,這個巨集有兩種實現(摘錄自 compiler.h、compiler_types.h 與 compiler_attributes.h,忽略其餘無關的程式碼)。
實作一:
實作二:
觀察上述兩種實作,可以得知以下資訊:當 CONFIG_TRACE_BRANCH_PROFILING
被定義時才會採用實作一,否則採用實作二。兩種實作的關鍵都是 __builtin_expect(!!(x), expect);
。
實作一雖然看起寫很多行看起來很複雜,但仔細看看可以發現它比實作二多做了三件事:
static struct ftrace_likely_data ______f
,將 __func__
、__FILE__
、__LINE__
放入 ______f
中對應的成員。__aligned
與 __section
是用 macro 再包裝的 Variable Attributes。__builtin_expect(!!(x), expect)
並把回傳值儲存到 ______r
。ftrace_likely_update(&______f, ______r, expect, is_constant)
,由函式的名子與傳入參數可以得知 ftrace_likely_update
用於追蹤每次執行 likely
當前所在的函式、原始碼檔名、行號、__builtin_expect
的回傳值、預期為 1 或 0、以及傳入的 x
是否為常數。由此推論實作一是用來給開發者分析 likely 與 unlikely 的發生頻率是否如預期所想,畢竟把一個相對不常成立的條件放到 likely
會降低效能,有這麼一個 trace 工具很合理,此外 likely_notrace
不會追蹤上述資訊。
關於 __func__
、__FILE__
、__LINE__
參見 Standard Predefined Macros。關於 __builtin_constant_p
、__builtin_expect
參見 Other Built-in Functions Provided by GCC。
Built-in Function: long builtin_expect (long exp, long c)
您可以使用 __builtin_expect 為編譯器提供分支預測的資訊,一般來說您更應該要使用 profile feedback(-fprofile-arcs),因為寫程式的人在預測程式實際執行方式出了名的不準。builtin_expect
的回傳值是 exp
的值,必須是一個 integral expression,並且預期 exp == c
。
上方程式碼表示 x
較可能為 0,因此 foo
不應該常被執行。
若想要判斷浮點數,可以改用上方的寫法,判斷浮點數的指標是否為 NULL
。
期望 __builtin_expect
的 exp
為 1 的機率由 GCC 的 builtin-expect-probability
參數控制,預設為 90%。您還可以使用 __builtin_expect_with_probability
自行指定機率。如果在迴圈內使用此 built-in,則機率會影響優化過後的迭代次數。
我在 queue.c 中使用前置處理器來切換自己實做的排序與 list_sort。如果有 #define USE_LINUX_LIST_SOR
,就會編譯 #ifdef
和 #else
之間的程式碼;否則編譯 #else
和 #endif
之間的程式碼。詳細在:blueskyson/lab0-c@24782f7。
我修改後的 queue.c 如下:
打算用 perf
測試排序 100、1k、10k、100k、1000k 個 Node 的 branches, instructions, context-switches。每筆測試使用 perf
重複 10 次,最後畫成表格或折線圖分析我的 sort 與 list_sort 的差異。
使用 perf
有個缺點,sort
前後的 ih RAND number
和 free
的過程也會被一併記錄下來,所以用 perf
只能看出誰好誰壞,無法單獨計算 q_sort
函式耗用的資源。
參考 Linux 效能分析工具: Perf 來設定 perf
。
在 lab0-c 新增 perf-traces 目錄,目錄結構如下:
num.cmd
代表排序 num
個隨機字串,例如:
接下來我寫了一個 bash script 來執行效能測試,這個腳本會讓 perf
對每個 qtest -v 0 -f perf-traces/$i.cmd
重複執行十次,並將輸出儲存在 perf-traces/$i_report
。腳本內容如下:
詳細在:blueskyson/lab0-c@299c0b2。
接下來執行:
就能得到類似以下的報告:
最後我將報告整理成表格。
我實作的 sort
node num | time (ms) | branches | instructions | contex-switches |
---|---|---|---|---|
100 | 0.5918 (±5.14%) | 284176 | 1411366 | 0 |
1000 | 0.8602 (±0.72%) | 758525 | 3574613 | 0 |
10000 | 5.5165 (±1.43%) | 5830672 | 26608412 | 0 |
100000 | 77 (±2.19%) | 59741126 | 270658689 | 1 |
1000000 | 1195 (±0.34%) | 630086164 | 2844184459 | 39 |
list_sort
node num | time (ms) | branches | instructions | contex-switches |
---|---|---|---|---|
100 | 0.5774 (±4.01%) | 285063 | 1419843 | 0 |
1000 | 0.8351 (±1.16%) | 747332 | 3557516 | 0 |
10000 | 4.9556 (±0.27%) | 571656 | 26554288 | 0 |
100000 | 67 (±1.32%) | 58440294 | 270425912 | 5 |
1000000 | 1012 (±0.52%) | 614368417 | 2840187614 | 34 |
由上兩表可以看出 list_sort
在任何情況下,平均執行時間 (上表的 time (ms)) 都比較少,在 node num 為 100 以外的情況下 branch 和 instructions 也都比較低。這說明了 list_sort 在 node num 大於 100 時使用比我的 merge sort 還要少的指令、更快的完成排序。
q_sort
的 Wall Clock Time因為我想得知 q_sort
函式在這兩種實作確切的執行時間,我將測試用的 cmd 檔的 sort
改成 time sort
。並且修改 console.c 的 do_time
使得顯示的執行時間顯示到小數點後第六位:
然後執行以下 bash 腳本:
得到一連串執行時間:
畫成表格:
node num | my sort time | list_sort time | list_sort time 是 my sort 的多少 |
---|---|---|---|
200k | 121.04 | 108.78 | 89.87% |
400k | 355.91 | 266.27 | 74.81% |
600k | 483.81 | 423.91 | 87.61% |
800k | 712.89 | 636.96 | 89.34% |
1000k | 934.43 | 761.94 | 81.54% |
將兩者的執行時間畫成圖表:
接下來我把 sort 的所有執行時間同除以 node 數量為 200k 的執行時間,讓第一次執行時間固定為 1.0,然後與 、 的圖表比較:
首先我觀察 qtest 添加指令的機制,發現 ADD_COMMAND
巨集定義如下:
前置處理器會把 cmd
字串串接為 "do_"cmd
,自動產生 do_ 開頭的函式名稱,透過 add_cmd
將這個 do_ 函式和函式說明串接到 console.c 的 cmd_list
。我在 qtest.c 的 console_init
中添加以下程式碼,並且寫了一個 do_shuffle
函式:
這樣便新增了一個 shuffle
指令,功能與 show
一樣。接下來模仿 do_sort
,在執行 shuffle 之前檢查佇列並發出警告訊息,再執行 q_shuffle
。
q_shuffle
實作我的想法是按照 Fisher–Yates shuffle 提供的演算法:
我一開始想透過操作上述 pseudo code 的 a[j]
和 a[i]
的 prev
與 next
來達成 shuffle,但發現如果 i == j + 1
時會有例外狀況需要另行處理。後來想到可以直接對調 a[i]
和 a[j]
的 value
就好,這樣就不用判斷例外,而且可以可以用較少的指標操作(完全不用 list_add
、list_del
)完成交換。
此外我參考 XOR Linked List 定義 XOR
巨集來交換 value
指向的位址,只是覺得這樣比較帥,不知道與宣告 char *tmp
比起來有哪些優缺點。詳細變更見:blueskyson/lab0-c@10acbd9。
執行 make valgrind
後,並沒有顯示任何 memory leak 的報告,輸出如下:
我再參考 2020leon,執行 valgrind ./qtest -f ./traces/trace-eg.cmd
也完全沒有如預期輸出任何錯誤訊息。
接著我再按照 lab-0 的作業說明,測試了一個明顯會 memory leak 的程式:
輸出顯示 valgrind 是正常的,可以抓出 memory leak。
為何沒有任何 memory leak 還需要再探討。