contributed by < frankchang0125
>
採用 MacBook Pro + VirtualBox (Ubuntu 18.04),環境都是在 Ubuntu 上做開發。
依據指示著手修改 queue.[ch] 和連帶的檔案:
依照 C Programming Lab: Assessing Your C Programming Skills 的說明,q_insert_tail
及 q_size
的時間複雜度必須為 ,因此在 queue_t
中新增 list_ele_t *tail
及 int size
來指向目前 queue 中最後一個節點及目前的 queue 的大小:
q->head
、q->tail
及 q->size
初始化。q
是否為 NULL
。q_free()
中,透過 while
迴圈,一個一個造訪 queue 中的每個 node 並將其所佔用的記憶體空間給釋放。在釋放 node 本身的記憶體空間前,要先釋放其 value
字串所佔的空間。當所有 nodes 所佔用的記憶體空間都釋放完後,最後再釋放 queue 本身所佔的記憶體空間。由於分配 node 記憶體空間除了在 q_insert_head()
中會用到,也會在 q_insert_tail()
中用到。因此我特別多定義了一個 q_allocate_node()
來統一處理:
char *s
是否為 NULL
或是空字串
,若為真,則直接 return NULL
。node
及其 value
字串的記憶體空間。特別要注意的是如果在分配 value
字串記憶體空間失敗的時候,需要在 return NULL
之前,把已經分配給 node 的記憶體空間給釋放掉,否則會有 memory leak 的情況。q
是否為 NULL
。q_allocate_node()
來分配 node 及其 value
字串的記憶體空間。node->next
指向目前的 q->head
。q->head
指向新增的 node。q->tail
為 NULL
,代表原先 queue 中並沒有任何的 nodes,因此 q->tail
也應指向本次所新增的 node。q->size
加 1。q
是否為 NULL
。q_allocate_node()
來分配 node 及其 value
字串的記憶體空間。q->head
為 NULL
,代表原先 queue 中並沒有任何的 nodes,因此 q->head
也應指向本次所新增的 node。q->tail
不為 NULL
,則將目前的 q->tail
node 的 next pointer
指向本次所新增的 node。q->tail
更新為本次所新增的 node。q->size
加 1。q
是否為 NULL
,及是否有 head node 可以被移除。q->head
,以便在最後釋放其記憶體空間;更新 q->head
為下一個 node (or NULL
)。q->tail == q->head
,代表目前 queue 終止有一個 node,因此必須將 q->tail
也設為 NULL
。value
字串至 *sp
,最多 bufsize - 1
個字元 + NULL terminator
。value
字串所佔的記憶體空間。q
是否為 NULL
,若為 NULL
(代表 new
指令沒有被執行) 則直接回傳 0。q
不為 NULL
,則直接回傳 q->size
( 時間複雜度)。q
是否為 NULL
或是 empty queue。prev
記錄前一個 node 及 next
記錄下一個 node,並依序將 cur
的 next pointer
指向 prev
。最後的 prev
即為原 queue 最後的 node,因此最後將 q->head
指向 prev
即完成 queue 的反轉。q_sort()
使用 merge sort 來排序 queue 中的 nodes。一開始透過快慢 pointer 的方式找出 linked list 中的 middle node,將其切分為左半部及右半部的 lists 分別做 merge sort (i.e. Divide and conquer)。sort()
底層都不會是採用 merge sort (實務上,會根據資料特性,e.g. 資料長度,混和多個不同的 sort algorithms)。但由於我們這邊排序的是 linked list,只需要改變 next pointer
所指向的 node 即可,因此空間複雜度僅為 。TODO:
看到別人 MetalheadKen 的作業 有提到 Linus Torvalds 所分享的 Good taste of codes。改寫本作業 linked list 的實作使其符合 Good taste of codes 規範。
把人名或代號標注出來,不要說「別人」
:notes: jserv
已更正
Frank ChangMar 01 2020
思考 nature sort order 在什麼場合用得到?提示: 思考 Linux 核心版本號碼
:notes: jserv
採用 natsort 來比對字串。如同 natsort 說明所述,在排序以下字串:rfc1.txt
、rfc2086.txt
、rfc822.txt
時,natsort 的排序結果將會是:rfc1.txt
-> rfc822.txt
-> rfc2086.txt
,也就是我們 "人類" 比較習慣的排序方式。而一般的 strcmp()
結果則會是:rfc1.txt
->rfc2086.txt
-> rfc822.txt
。
strnatcmp.h
及 strnatcmp.c
加入專案中。strnatcmp.o
加入 Makefile compile objects (OBJS
) 中:queue.c
中 include strnatcmp.h
:merge()
的 strcasecmp()
替換為 strnatcasecmp()
:traces/
下,並加上指令,也就是將:修改為:
修改 scripts/driver.py
,新增測試案例:
a. 修改 traceDict
:
b. 修改 traceProbs
:
c. 修改 maxScores
新增五組 tests 的分數 (各為 6 分)。
再次執行 make test
:
可以觀察到下列幾點:
ERROR: Not sorted in ascending order
),這是因為 qtest.c
中的 q_sort()
所使用的字串比對函式為 strcasecmp()
:將其換為 natsort 的 strnatcasecmp()
即可通過 test cases 18 ~ 22:
trace-15-perf
超時 (ERROR: Time limit exceeded. Either you are in an infinite loop, or your code is too inefficient
)。在交叉比對後,發現是在引入 natsort 後所造成。需將 harness.c
中的 time_limit
值增加至 2 以上才能通過。TODO:
研究 natsort 的 strnatcmp0()
是否能提升其演算法效率。
qtest
實作的記憶體錯誤使用 make valgrind
指令可以使用 Valgrind 分析我們的程式是否有 memory leak 的問題。
valgrind
指令是否存在。SANITIZER
,並重新編譯 qtest
。alarm()
給取消,因此複製所編譯出來的 qtest
,至 /tmp
資料夾,並透過 sed
將 qtest
binary 中的 alarm
函式呼叫全部替換為 isnan
。driver.py
,並加上 --valgrind
參數,在 driver.py
中就會使用 Valgrind 來執行 qtest
。可以閱讀 Valgrind manual 2.6.7 對於 .valgrindrc
的說明:
Note that Valgrind also reads options from three places:
- The file ~/.valgrindrc
- The environment variable $VALGRIND_OPTS
- The file ./.valgrindrc
These are processed in the given order, before the command-line options. Options processed later override those processed earlier; for example, options in ./.valgrindrc will take precedence over those in ~/.valgrindrc.
Any tool-specific options put in $VALGRIND_OPTS or the .valgrindrc files should be prefixed with the tool name and a colon. For example, if you want Memcheck to always do leak checking, you can put the following entry in ~/.valgrindrc:
This will be ignored if any tool other than Memcheck is run. Without the memcheck: part, this will cause problems if you select other tools that don't understand –leak-check=yes.
而 qtest
的 .valgrindrc
內容如下:
qtest
在原先版本的 queue.c
的 q_remove_head()
中,原先複製字串至 *sp
我是使用 memcpy()
的:
不過在使用 Valgrind 的時候,會出現 Invalid read of size 8
的錯誤:
但在將 memcpy()
換成 strncpy()
後,錯誤就消失了。
原本一直想不透到底是什麼問題,直到看見 Naetw 作業的說明:
memcpy 會完全複製 bufsize - 1 bytes 到 sp 裡,但是 ptr->value 的空間長度可能遠小於 bufsize - 1,先前誤解了註解意思,以為要直接複製 bufsize - 1 bytes,實際上它只是最大值而已。將 memcpy 替換成 strncpy 這樣一來在遇到 NULL byte 時複製行為便會直接停止。
才理解原來是 memcpy()
和 strncpy()
針對 NULL terminator
的處理不同所致。
查看 memcpy
GNU C Library 說明:
strcpy
、strncpy
GNU C Library 的說明:
由 strcpy
的說明我們可以看見:
This copies bytes from the string from (up to and including the terminating null byte) into the string to.
所以將 memcpy()
改為 strncpy()
才不會複製到多餘的 bytes。透過 Valgrind 我們的確可以找到原先所未設想到的記憶體溢漏 (or 錯誤存取) 情況。
Valgrind massif 的說明可以參考 massif 的官方說明文件。
massif 做為 heap profiler,可以分析並記錄我們程式的 heap 使用量。
使用方式只需指定 --tool=massif
即可,e.g.:
在程式執行完成後,會在該程式目錄下新增一個:massif.out.<你的程式 PID>
的檔案。我們可以再透過 ms_print
指令讀取該檔案,顯示 massif 分析的結果,e.g.:
以 trace-13-perf.cmd
做為範例:
原先的 .valgrindrc
的 show-leak-kinds
前面並沒有加上 memcheck:
,代表該指令只會在 memcheck 的 tool 下才會使用,因此指定 –tool 為 massif 時會出現:valgrind: Unknown option: show-leak-kinds=all
的錯誤訊息。將 .valgrindrc
中的 show-leak-kinds
改為:--memecheck:show-leak-kinds=all
即可避免此錯誤。
Valgrind massif 會在程式執行期間製作多個 snapshots,顯示程式 heap memory 執行時期的使用量。其中:
Detailed snapshot 和 Peak snapshot 都會顯示當下 heap 使用量的詳細資訊,包含分配記憶體的 stack traces。
我們可以看到因為我們的程式有正確的釋放所佔用的記憶體空間,因此最終的記憶體使用量會回到 0。
若我們修改我們的 q_free()
:
再次執行 Valgrind massif:
可以看到,此時的 Valgrind massif 顯示記憶體用量直到程式結束都沒有降低,代表我們有 memory leak 的發生。
透過 Valgrind massif,我們可以分析我們的程式 heap 的用量是否合理,並透過 stack trace,找出 heap 用量的 hot spot,並試著優化我們的程式。
預設 Valigrind massif 是採用 instructions executed 為橫軸單位,但如果程式執行的時間很短,會導致記憶體分配幾乎集中在圖形最後的部分 (因為 main()
在執行前還有很多 "前置作業",也會一併計算在 instructions executed 中)。因此可以在 .valgrindrc
中指定 massif tool 使用 --time-unit=B
參數 (i.e. --massif:time-unit=B
),將橫軸改為以 bytes allocated/deallocated 為單位。
select()
+ read()
為 I/O multiplexing 的一種。關於 Blocking / Non-blocking I/O / I/O multiplexing 及 Asynchronous I/O,可以參考下列文章的說明:
read()
+ 未設定 O_NONBLOCK flag、select()
、epoll()
。errno
指明錯誤 (e.g. EAGAIN、EWOULDBLOCK
)。User space 必須重複詢問 (e.g. 在 while
迴圈中重複呼叫 read()
) 直到 I/O 資料準備就緒才可進行 I/O operation。
read()
+ 設定 O_NONBLOCK flag、aio_read()
、aio_write()
read()
、recvfrom()
。aio_read()
、aio_write()
。所以 I/O multiplexing 為 Blocking I/O 的一種。但 select()
+ read()
比單一 read()
的好處在於 select()
可以同時等待多個 file descriptors
(fd
)。
在呼叫 select()
時所傳入的是多個 fd set
,而非只是一個 fd
。只要 fd set
中任一個以上的 fd
的 I/O 資料準備就緒,select()
就會返回。呼叫 select()
的 User space process 必須 iterate fd set
來檢查是哪幾個 fd
可以進行 I/O operation,而後再呼叫 read()
、recvfrom()
讀取資料。此時 I/O 資料應準備就緒,可以直接讀取。但也有例外,e.g. select()
man page 所述:
Under Linux, select() may report a socket file descriptor as "ready for reading", while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.
select()
後的 read()
、recvfrom()
是 Blocking I/O 或是 Non-blocking I/O,還是依是否設了 O_NONBLOCK flag 而定。將 read() 或 recvfrom() 設為 NON_BLOCKING 才可以避免 User process 再次被 blocked。
P.S. 由於 select()
最多只可同時 "監控" (watch) 1024 個 fd
,且當返回時,User space process 還必須 iterate 整個 fd set
才可以知道是哪些 fd
的資料已經準備好了,效率不佳。因此才有後續的 epoll()
。
回到 qtest
:
main()
呼叫 run_console()
並將 trace cmd 檔路徑傳入。run_console()
呼叫 push_file()
,分配一個 RIO_ELE
,並將 trace cmd 檔的 fd
填入。每個 RIO_ELE
中都有一個 internal buffer:char buf[RIO_BUFSIZE]
,也就是 RIO 套件 所指的 RIO buffer,另外多個 RIO_ELE
也會透過 prev pointer
串接成一個 linked list。push_file()
完成後,會再呼叫 cmd_select()
,準備解析 cmd 檔:cmd_select()
中第一次執行時,read_ready()
會回傳 false
,代表目前尚未有未執行的指令。再來會將 readfds
設為 local_readset
,做為接下來 select()
的 readset
參數。其中會將 trace cmd 檔的 fd
(也就是 buf_stack->fd
) 透過 FD_SET()
將 readfds
中對應的 bit 設為 1
,代表我們要 "監控" (watch) 此 fd
是否有資料可以讀取:P.S. select()
的 nfds
參數要設比我們最大想 "監控" (watch) 的 fd
值 + 1,因此 ndfs = infd + 1
。
select()
會立即返回,其返回值 (result
) 代表目前有多少個 fd
資料準備就緒,可以存取。接著透過 FD_ISSET()
檢查 trace cmd 檔的 fd
是否準備就緒。若為 true
,則再透過 readline()
讀取 trace cmd 檔的指令 (也是在此運用了 RIO 套件 的原理),並透過 FD_CLR()
從 readfds
中清除對應的 fd
bit::question: 疑問:
由於同時間只會 "監控" (watch) 一個 trace cmd 檔的 fd
,是否真的有需要使用 select()
? 或是其實直接呼叫 readline()
讀取 trace cmd 檔即可? 畢竟多呼叫了 select()
還是多了一個 system call,但卻沒有享受到 select()
所帶來的好處。
read()
在以下情況會有 short count 的問題:而 read()
在以下情況不會有 short count 的問題:
經過自己測試,read()
在讀取本地檔案時,碰到 \n
並不會返回。
因此,CS:APP RIO 套件 為了避免 short count 的情形,定義了 rio_t
這個 struct,其中包含了 internal buffer:rio_buf
、rio_cnt
記錄 internal buffer 中剩餘未讀取的 bytes 數,及 rio_bufptr
指向 internal buffer 中剩餘未讀取資料的起始 offset。在呼叫 read()
時,所傳入的 *buf
為 rio_buf
internal buffer。
readline()
運用了同樣的原理:
buf_stack->cnt <= 0
時 (代表目前沒有尚未解析的指令),會呼叫 read()
將讀取的資料存入 buf_stack->buf
,並重置 buf_stack->bufptr
及更新 buf_stack->cnt
。而後開始解析 buf_stack->buf
中所讀入的資料,直到碰到 '\n',代表完成了一個指令解析 (解析完的指令存在 linebuf
中)。buf_stack->cnt = 0
時,有下面兩種情況:
text size > RIO_BUFSIZE
,且 text 中都沒包含 \n
(因此 buf_stack->cnt
被減為 0 了),此時由於 RIO 的 internal buffer,因此可以繼續讀取後續的檔案內容。pop_file()
清除此 trace cmd 檔對應的 RIO_ELE
所佔用的記憶體空間。回到 cmd_select()
,執行 interpret_cmd()
執行對應的指令。
離開 cmd_select()
回到 run_console()
。但由於 cmd_done()
仍為 false
,因此又會再次進到 cmd_select()
。但此時若仍有尚未解析完成的指令,則 read_ready()
為 true
,會再次呼叫 readline()
。不過在 readline()
中,由於 buf_stack->cnt
仍 > 0
,因此不會再次呼叫 read()
,而只會更新 linebuf
,透過 while
迴圈,我們就可以將後續未解析完成的指令一一解析完成並執行,直到所有 buf_stack
linked list 的 RIO_ELE
都被處理完成為止 (cmd_done()
回傳值為 true
)。: