contributed by < Risheng1128
>
實作之前,先分析目前 lab0-c 對管理佇列的設計
雙向鏈結串列
佇列元素
由上述兩種的結構,可以知道佇列的元素本身就是鏈結串列的節點,會產生如下的示意圖
接著是和去年不同的部份,現在可以管理多個佇列,主要是由於以下的結構實作
上述的結構為每個佇列的節點,成員說明如下:
q
: 指向佇列的第一個節點chain
: 負責管理佇列的鏈結串列的節點size
: 佇列節點的數量id
: 鏈結串列編號接著以下的結構為管理佇列的鏈結串列的第一個節點
由上述的結構,可以得知目前管理佇列的鏈結串列示意圖如下:
其中 q
則是對應到上述佇列的結構
函式功能: 建立新的「空」佇列
函式流程
INIT_LIST_HEAD
初始化實際程式碼:
函式功能: 釋放佇列所佔用的記憶體
函式流程
list_del
移除該節點並釋放其空間實際程式碼:
函式功能: 在佇列開頭 (head) 插入 (insert) 給定的新節點
函式流程
malloc
分配 element_t
大小的記憶體空間,若失敗則回傳 false
strdup
建立複製字串資料list_add
將節點加在 head
的下一個位置實際程式碼:
其中函式 strdup
的實作如下
函式功能: 在佇列尾端 (tail) 插入 (insert) 給定的新節點
函式流程
malloc
分配 element_t
大小的記憶體空間,若失敗則回傳 false
strdup
建立複製字串資料list_add_tail
將節點加在 head
的前一個位置實際程式碼:
函式功能: 在佇列開頭 (head) 移去 (remove) 給定的節點
函式流程
NULL
或是空佇列,則回傳 NULL
head->next
) 並且取得其資料實際程式碼:
函式功能: 在佇列尾端 (tail) 移去 (remove) 給定的節點
函式流程
NULL
或是空佇列,則回傳 NULL
head->prev
) 並且取得其資料實際程式碼:
函式功能: 計算目前佇列的節點總量
函式流程
NULL
則回傳 0
實際程式碼:
函式功能: 移走佇列中間的節點
函式流程
NULL
或是空佇列則回傳 false
forward
) 及往後 (afterward
) 走,並找到中間的節點 (最後為 forward
)實際程式碼:
函式功能: 在已經排序的狀況,移走佇列中具備重複內容的節點
函式流程
NULL
則回傳 false
curr
和 next
的元素地址
next
指向的節點進行移除並釋放空間,同時 flag
被設置為 true
,字串比較則使用 strcmp 函式,當資料相同時回傳 0
flag
為 true
時,表示發生過資料相同的情況,但 curr
指到的節點尚未釋放,因此要記得釋放該空間
實際程式碼:
TODO: 用更精簡的方式撰寫程式碼。
:notes: jserv
後來參考 alanjian85 同學的作業,發現了更好的解法,兩者相同的部份都是使用指標 prev
及 curr
來判斷資料是否相同,而不同之處在於我原本的實作是移除 curr
指向的節點,後者則是移除 prev
的節點,如此一來可以節省 curr
的迴圈
修改後的程式碼如下所示,修改紀錄為 commit:
函式功能: 交換佇列中鄰近的節點
函式流程
NULL
則直接離開函式first
指到的節點取出並加到 second
指到的節點後,如此一來就達到交換的功能實際程式碼:
函式功能: 以反向順序重新排列鏈結串列,該函式不該配置或釋放任何鏈結串列節點,換言之,它只能重排已經存在的節點
函式流程
next
指到目前節點的下一個prev
及 curr
實際程式碼:
函式功能: 類似 q_reverse
,但指定每 k 個節點為一組,進行反向順序排列
以下的函式流程以 trace-06-ops.cmd
的測資為例
l = [e, e, d, c, b, a, a, a], k = 3
函式流程
count
第一次和 k
相同且在執行函式 q_reverse
之前,此時從佇列頭開始,可以看到形成了一個單向環狀鏈結串列 [e, e, d] ,這麼做的目的是要滿足函式 q_reverse
輸入,也就是函式 q_reverse
是以輸入節點的下一個節點 (head->next) 開始反轉sub_head
及 sub_tail
指向節點的相對位置會相反old_tail->next
指向 sub_tail
指到的節點且 sub_head->next
指向 next->head
指到的節點,前者在這部份不會有任何改變,但在鏈結串列之間反轉後,需要上次反轉後的 tail 接上,就會有所功能restructure_list
將鏈結串列接好實際程式碼:
其中函式 restructure_list
的實作如下,功能為將雙向鏈結串列的 prev
指標接好
函式功能: 以遞增順序來排序鏈結串列的節點
函式流程
函式 q_sort
的實作被主要拆為三個函式 q_sort
, mergesort
及 mergelist
q_sort
:
首先將指向 head
的指標改成 NULL
,目的在於把雙向鏈結串列的終點從 head
改為 NULL
,變成單向鏈結串列
接著呼叫函式 mergesort
開始進行 merge sort
排序完後每個節點的 prev
會亂掉,因此需要走訪各個節點並且把所有 prev
接回來
mergesort
: 參考你所不知道的 C 語言: linked list 和非連續記憶體裡, merge sort 的實作方式並做修改
使用「快慢指標」Floyd’s Cycle detection 演算法找出鏈結串列正中間的節點,並將鏈結串列切成 left
及 right
兩組鏈結串列
呼叫函式 mergelist
合併 left
及 right
mergelist
: 參考 21. Merge Two Sorted Lists ,並實作合併兩個鏈結串列
利用指標的指標 indirect
指向指標 res
,並藉由修改指標完成鏈結串列合併的動作
使用函式 strcmp
判斷資料的大小
實際程式碼:
函式功能: 對所有節點而言,移除所有在其之前且資料較小的節點
思考邏輯: 目標是把對每個節點之前,所有資料比其小的節點移除,換言之,其實可以利用雙向鏈結串列的特性,反向走訪所有的節點,並產生遞增的鏈結串列
實際程式碼:
函式功能: 合併所有已排序的佇列,並合而為一個新的已排序佇列
函式流程
merge
暫時儲存已經合併的佇列merge
合併 (利用函式 mergelist
) ,由於函式 mergelist
的輸入皆為單向鏈結串列,因此這裡需要先將原本的佇列變成單向鏈結串列,再進行合併
chain
的第一個元素上,並使用函式 restructure_list
將 prev
完整連接
實際程式碼:
原始碼的運作分析放在額外的筆記 研讀 Linux 核心原始程式碼 list_sort.c
完整的實作紀錄可參考 commit ,以下為實作的流程
首先,將 Linux 核心原始檔案 list_sort.c 及 list_sort.h 的相關程式碼加進 lab0-c 並作修改,其中包含 list_sort 完整的程式碼及其函式定義,接著將以下 lab0-c 沒有的巨集函式加進 list_sort.h
likely()
(位於 include/linux/compiler.h
)unlikely()
(位於 include/linux/compiler.h
)完成的 list_sort.h
實際程式碼如下所示:
接著在 Makefile
新增要編譯出的目標檔案,這裡可由變數 ENABLE_LINUX_SORT
讓使用者決定是否要使用 list_sort
在 list_sort.c
新增函式 list_cmp
,用來比較節點的資料
最後則是修改檔案 qtest.c
裡的函式 do_sort
,並搭配剛剛在 Makefile
裡設定的參數來決定要使用原本的 q_sort
或是新引入的 list_sort
首先建立一個用來量測排序時間的測試檔 trace-time-measure.cmd
,內容如下所示,其中隨機產生的資料數會改變
接著修改以下的 Makefile
腳本並輸入命令 make check
來分別測試 q_sort
及 list_sort
可以統計出兩者執行的時間,測試方式為對每個函式分別測試 3 次並紀錄其排序時間,同時資料總數從 100000 以公差為 100000 遞增到 500000
資料總數 | q_sort 第一次 (s) |
q_sort 第二次 (s) |
q_sort 第三次 (s) |
list_sort 第一次 (s) |
list_sort 第二次 (s) |
list_sort 第三次 (s) |
---|---|---|---|---|---|---|
100000 | 0.072 | 0.071 | 0.068 | 0.039 | 0.040 | 0.038 |
200000 | 0.190 | 0.222 | 0.200 | 0.114 | 0.120 | 0.114 |
300000 | 0.333 | 0.361 | 0.330 | 0.196 | 0.198 | 0.194 |
400000 | 0.488 | 0.528 | 0.517 | 0.286 | 0.284 | 0.301 |
500000 | 0.646 | 0.685 | 0.699 | 0.376 | 0.393 | 0.363 |
接著將以上的數據取平均後如下所示
資料總數 | q_sort 平均 (s) |
list_sort 平均 (s) |
---|---|---|
100000 | 0.070 | 0.039 |
200000 | 0.204 | 0.116 |
300000 | 0.341 | 0.196 |
400000 | 0.511 | 0.290 |
500000 | 0.676 | 0.377 |
最後使用 gnuplot 畫圖,參考 gnuplot 語法解說和示範 ,以下為比較結果,可以發現 list_sort
的排序時間比起 q_sort
快了不少
接著利用 perf 找到兩者的差異,安裝可以參考 Linux 效能分析工具: Perf
輸入命令 perf stat --repeat 5 -e cache-misses,cache-references,instructions,cycles ./qtest -f traces/trace-time-measure.cmd
→ 程式會執行 5 次,並且自動平均,其中每次測試的資料數都設定為 500000 ,以下為 q_sort
及 list_sort
執行的結果
首先是 q_sort
的結果
接著是 list_sort
的結果
將輸出的結果做成表格,如下表所示
q_sort |
list_sort |
|
---|---|---|
cycles | 50,7244,9200 | 31,8624,7214 |
instructions | 22,6838,1411 | 22,7975,0582 |
cache-references | 3288,3709 | 2577,8636 |
cache-misses | 2256,8287 | 1887,5232 |
insn per cycle | 0.44 | 0.71 |
可以發現 list_sort
發生 cache miss 的次數比 q_sort 來的少,可以對應到 Linux 核心的鏈結串列排序 裡提到「用 bottom up 實作 merge sort 對 cache 較友善,可避免大量的 cache trashing」
探討 list_sort.c
的比較次數。
:notes: jserv
輸入命令 make valgrind
對資料夾 traces
裡的所有測試資料使用 valgrind 進行檢查
由以上的測試結果,可以發現對於目前的實作, valgrind 沒有發出任何的警告
接著使用 Massif 觀察記憶體使用情況,輸入命令 valgrind -v --tool=massif ./qtest -f traces/trace-01-ops.cmd
產生輸出檔 massif.out.17672
接著輸入命令 massif-visualizer ./massif.out.17672
讀取上述指令產生的輸出檔,以下為結果:
以 traces/trace-01-ops.cmd
為例,可以發現所有分配的記憶體在最後會完全被釋放
web
命令得以搭配上述佇列實作使用經過實驗證實,目前的實作會有以下的問題
問題: 在輸入命令 web
並建立連線後,會有以下的問題
Unknown command 'favicon.ico'
首先從第一個問題,可以得知上述提到的延遲是由於 lab0-c 內部的函式 read
壅塞所導致,主要是 I/O 正在處理來自客戶端或是來自命令列的命令,使其一無法馬上執行。因此這邊使用函式 select 對檔案描述子 (file descriptor) 進行管理,以下為 select 的 I/O 模型,屬於 Multiplexing 模型,分成兩部份,第一部份為 select ,目的為等待準備好的資料,並回傳已經準備好的資料數,第二部份則是讀取準備好的資料。透過這樣的方法,可以一次管理多個檔案描述子。
descriptor 翻譯為「描述子」,參見 operator 為什麼翻譯為運算子
:notes: jserv
而目前的目標則是同時管理 stdin 及 socket 的資料,首先可以參考原本管理 I/O 的實作,位於檔案 console.c
的函式 run_console
將目光放在上述程式的變數 use_linenoise
,其功能是用來區別目前輸入為 stdin 或是 socket ,當輸入命令 web
時,根據函式 do_while
的內容,可以發現變數 use_linenoise
被設定為 false
(下列程式碼第 12 行) ,同時也表示下次將讀取 socket 的資料
因此可以得知函式 run_console
第 10 行的迴圈用來處理來自 stdin 的資料,而第 19 行則是處理來自 socket 的資料
接著修改函式 run_console
如下所示,移除變數 use_linenoise
並且由函式 linenoise
來同時管理兩者的資料
接著引入上述提到的函式 select
到函式 linenoise
內部,主要修改位於檔案 linenoise.c
的核心函式 line_edit
。首先使用關鍵字 extern
取得 console.c
的變數 web_fd
,其為 socket 檔案描述子
當然變數 web_fd
的宣告也要作修改
接著在函式 line_edit
開始實作 select 的設定,可以參考 CS:APP 的第 12 章,前面提到要管理 stdin 及 socket ,因此設定的部份可以對應到下列程式的第 3 行及第 7 行,使用巨集函式 FD_SET
啟動 select 的監聽,其他的巨集函式可以參考 select(2) — Linux manual page
接著是讀取函式的部份,實作如下所示,有兩種情況 — socket 及 stdin ,前者主要是使用 web_recv
讀取資料,並且對客戶端回傳 HTTP 狀態碼 ,後者則是維持原本的實作,使用函式 read
讀取來自 stdin 的資料
接著將 socket 設定為 non-blocking 模式,修改檔案 web.c
裡的函式 web_open
,如下所示
另外要特別注意,當 socket 設定為 non-blocking 模式時,此時的讀取函式 read
需要考慮到回傳值 EAGAIN
及 EWOULDBLOCK
,可參考 read(2) — Linux manual page ,以下為說明
- EAGAIN or EWOULDBLOCK: The file descriptor fd refers to a socket and has been marked nonblocking (O_NONBLOCK), and the read would block. POSIX.1-2001 allows either error to be returned for this case, and does not require these constants to have the same value, so a portable application should check for both possibilities.
因此將函式 rio_read
進行修改
依循 CONTRIBUTING.md 規範,在迴圈內讓 EOF 狀況及早脫離
:notes: jserv
接著是目前的展示成果,主要展示當 stdin 已經先輸入資料但尚未處理,接著從另一個終端機透過 socket 輸入命令,並不會導致資料阻塞,完整實作可見 commit 26a5dc
Note: 由於目前讀取資料後的操作是透過函式 line_edit_insert
處理,因此會讓兩個 I/O 的資料拼接在一起
TODO:
shuffle