主講人: jserv / 課程討論區: 2024 年系統軟體課程
返回「Linux 核心設計/實作」課程進度表Image 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
根據 kecho pull request #1 指出 CMWQ 版本的實作得益於 locality 及事先準備的執行緒使得 server 的執行時間可優於 kthread 的版本。
利用 commit 7038c2 並修正其問題改寫為 kthread-based kecho 並與目前使用 CMWQ 所實作的版本比較二者的差異。
可見二者在執行上的差異極大,主要的原因就是因為 kthread-based 的版本在收到 client 連線請求之後才會開始處理 kthread 的建立。而 CMWQ 因為讓 workqueue 可以依照目前任務執行的狀態來分配對應的執行緒,而且 workqueue 使用 thread pool 的概念,因此不用額外再重新建立新的 kthread,在 client 連線時可直接將 worker 函式分配給空閒的執行緒來進行。省去建立 kthread 所耗費的時間成本,在大量的連線湧入時,更能體現其好處。
kecho pull request #1 提到 kthread-based 的 kecho 在回應時間上有很大的比例是受到 kthread 建立的成本影響,為瞭解其實際造成影響的比例,利用 eBPF 來測量建立 kthread_run
的時間成本。
為了測量 kthread 建立的成本,使用 my_thread_run
來包裝 kthread_run
讓 eBPF 可以將測量的動作注入在該函式裡面:
在注入 kprobe 之後,執行 bench
進行測試:
可以發現 kthread_run
的執行成本大約為 200 us。
khttpd
在 kecho
的實作中,為了有效管理 work ,所有的 work 都會被加到一個鏈結串列,可在 http_server.h
新增以下結構體:
該結構體的作用是充當鏈結串列的首個節點,成員 is_stopped
用以判斷是否有結束連線的訊號發生
接著修改原本的結構體 struct http_request
,新增鏈結串列節點及 work 結構體:
整個程式的主要流程是建立 CMWQ 連線建立後建立 work workqueue 開始運作 釋放所有記憶體。
首先建立 CMWQ 的部份在掛載模組時執行,位於函式 khttpd_init
,以下為修改的部份
使用函式 alloc_workqueue
建立 CMWQ ,而這裡要注意參數 flag
的值會根據需求而不同,根據 kecho
的註解說明,如果是想要長時間連線,像是使用 telnet 連線,可將 flag
設成 WQ_UNBOUND
,否則指定為 0 即可
自己實際二個都設定過,的確使用 WQ_UNBOUND
的效率沒有來的非常好,主要原因可能是 work 可能會被 delay 導致,也有發生測試的時候電腦當機的情況
接著是建立 work 的部份,使用時機是在 server 和 client 建立連線後,以下新增函式 create_work
用來新增 work
函式 create_work
主要流程為建立 work 所需的空間 初始化 work 將 work 加進鏈結串列裡。
最後釋放記憶體的部份單純許多,就是走訪整個鏈結串列,並逐一釋放。
使用命令 ./htstress http://localhost:8081 -t 3 -c 20 -n 200000
測試,以下為執行結果
可以發現整個伺服器的吞吐量 (throughput) 有大幅的成長
原本的實作 | 新增 CMWQ |
---|---|
30274.801 | 51801.192 |
如下圖,可以簡單將 HTTP 分成二種傳輸模式,分別是 multiple connections 及 persistent connection ,前者會在伺服器回應請求之後中斷連線,後者則會持續保持連線,根據 HTTP 的敘述,可以得到幾件資訊
Connection: keep-alive
這邊可以利用 khttpd
做簡單的測試,使用命令 telnet localhost 8081
進行連線,在分別輸入 GET / HTTP/1.0
及 GET / HTTP/1.1
進行測試,並分別觀察伺服器回傳的資料
GET / HTTP/1.0
GET / HTTP/1.1
根據回傳的 Connection: xxxxx
資訊可以得知,結果符合上述的敘述,因此可確認 kHTTPd 本身就有 keep-alive 的功能
為了實作 directory listing 的功能,首先要做的第一件事就是讀取現行目錄的檔案名稱,新增函式 handle_directory
用來實踐該功能,完整的修改可以參考 Add the function of directory list
函式 handle_directory
主要做以下幾件事
GET
,並送出對應的 HTTP header (第 7 ~ 19 行)iterate_dir
走訪目錄內的所有資料夾 (第 28 ~ 34 行)接著根據上述的第 6 行,將把函式 iterate_dir
導向到函式 tracedir
,換言之就是在執行函式 iterate_dir
的過程中會呼叫 tracedir
,以下為函式 tracedir
的實作
函式 tracedir
的功能就是會走訪整個目錄的資料,並且每執行一次就會將資料送到 client
而這裡有個較特別之處,即使用到巨集 container_of
,由於函式 tracedir
的參數是固定的,又需要 socket 參數來送出資料,因此這邊將結構 dir_context
放進結構 http_request
裡,如此一來,透過巨集 container_of
就可以達到不用傳遞 socket 也可以使用的效果
最後展現目前的結果 (節錄部份)
首先節錄主要測試的程式碼,使用到的函式位於 fs/d_path.c 及 fs/namei.c
輸入命令 sudo insmod khttpd.ko
並用 Chrome 網頁瀏覽器測試後,實際的結果如下所示,沒有顯示絕對路徑
接著嘗試另一種方法,在 fs/d_path.c 發現函式 d_absolute_path
,想嘗試執行試試,但函式 d_absolute_path
沒有使用巨集 EXPORT_SYMBOL
,因此無法直接在核心模組進行呼叫。
換另一種方式:新增核心模組參數 WWWROOT
,在掛載模組時直接指定要開啟的路徑。參考 The Linux Kernel Module Programming Guide ,使用巨集 module_param_string
新增參數 WWWROOT
為了讓 WWWROOT
可以傳遞到其他檔案,在結構 httpd_service
新增成員 dir_path
,主要用來傳遞資料到不同檔案
接著在函式 khttpd_init
新增以下程式碼,主要功能是用來判斷參數 WWWROOT
是否為空字串,如果是則使用預設的路徑,這裡採用 "/"
分別在掛載核心模組時輸入 sudo insmod khttpd.ko
及 sudo insmod khttpd.ko WWWROOT='"home/user/khttpd"'
,並得到以下結果 (節錄部份結果)
sudo insmod khttpd.ko
sudo insmod khttpd.ko WWWROOT='"home/user/khttpd"'
目前可以藉由參數 WWWROOT
輸入伺服器開啟的目錄
想要讀取檔案的資料,必需先知道檔案的屬性,如檔案大小以及檔案類型,在 Linux kernel 裡,檔案的屬性由結構 inode
所管理,位於 include/linux/fs.h ,而這裡主要使用到成員 i_mode
及 i_size
,前者主要表示檔案的類型,後者儲存檔案的大小
相同的,檔案類型一樣位於 include/linux/fs.h ,可以看到不同類型的檔案有不同的數值
接著如何判斷檔案類型,參考 include/uapi/linux/stat.h 的資料,發現可以判斷檔案類型的巨集,這裡主要使用巨集 S_ISDIR
及 S_ISREG
,前者用來判斷是否為目錄,後者則是判斷是否為一般文件
接著開始修改程式,完整修改位於 Add the function of read file 及 Fix bug on reading file in deeper directory ,主要修改函式 handle_directory
修改後的函式 handle_directory
做了以下幾件事
catstr
,將 WWWROOT
的路徑及 client 的要求接在一起,並且輸出到 pwd
,再由函式 filp_open
打開檔案NOT FOUND
訊息給 client接著稍微修改前面的實作,讓伺服器可以處理 ".."
的要求,完整修改參考 Consider request ".."
to go back previous page ,以下節錄主要的修改
函式 tracedir
主要只是移除多餘的程式碼,而函式 http_parser_callback_request_url
因進入到多層目錄後會回不去原本的目錄而有的改動。
考慮以下:假設現行目錄為 /ab/cd
並且送出 ..
,原來的時候會產生的結果為 /ab/
,接著再送出一次 ..
會產生的結果仍然為 /ab/
,表示進到二層以上的目錄後會回不到更早的目錄。
為了解決這樣的問題才會有以上的更動,如果路徑的最後一個字元為 '/'
,只要將其移除即可,用一樣的例結果會變成 /ab/cd
/ab
(空字串)
之前的實作由於每次傳送目錄資料時,不知總資料大小,因此都是送完資料後直接關閉連線,而在 HTTP 1.1 中提供了 Chunked encoding 的方法,可以將資料分成一個個的 chunk 並且分批發送,如此一來可以避免要在 HTTP header 中傳送 Content-Length: xx
參考 Transfer-Encoding: Chunked encoding 並由以下的範例可以得到幾個資訊
\r\n
隔開在正式修改程式之前,之前撰寫的函式 send_http_header
和 send_http_content
實在是太冗長,因此將二者重新修改並且寫的更有彈性,新增巨集函式 SEND_HTTP_MSG
如下
如此一來,輸入的資料可以讓使用者任意送出,程式碼也變得更簡潔
以下主要列出使用 chunked encoding 的部份,分別是函式 handle_directory
及 tracedir
主要修改的部份在於發送 HTTP header 時,需要新增 Transfer-Encoding: chunked
,另外每次傳送資料時後要先送出該資料的長度,最後要記得送出長度為 0 的資料
經過這樣的修改後,目前的伺服器可以送出不固定大小的資料
最後展示執行結果
參考 MIME 類別 可以初步了解 MIME。MIME 是種表示檔案或各式位元組的標準,並定義於 RFC 6838 裡,如果要使用 MIME 的功能,則要在伺服器回應的 HTTP header 的項目 Content-Type
提供正確的類型
至於要回應什麼要的類型,可參考 Common MIME types ,裡頭提供了不同的副檔名應該要回應的型態
如此一來可以開始修改程式碼,完整修改參考 Add MIME to deal with different kind of files ,新增檔案 mime_type.h
裡面儲存常見的 MIME 類型
新增函式 get_mime_str
,功能為根據要求的檔案找到對應的回應訊息
接著修改函式 handle_directory
裡處理一般檔案的部份,主要就是利用函式 get_mime_str
取得對應的回應訊息
在目前的實作發現了一個問題,只要有對伺服器做請求後,在卸載模組時會產生以下的錯誤訊息
首先查了 fs/inode.c
的第 1676 行,參考 fs/inode.c 可以找到對應的函式 iput
而程式錯誤就是發生在上述函式的第 5 行,從程式碼大致可以先猜這次的程式錯誤和檔案系統有關
最後發現,當我對伺服器送出請求後,伺服器會經過開啟檔案及讀取檔案的步驟,但是關閉檔案並沒有執行,程式會停留在函式 filp_close
,直到下一次的請求出現才會關閉,相關程式碼如下
因此當 client 從遠端關閉時,最後一次請求的檔案的 file descriptor 是沒有被關閉的,因此這時如果卸載模組就會產生上述的問題
為了解決這個問題,目前的想法是可以建立 timer 管理連線,讓伺服器可以主動關閉逾時的連線,詳細步驟在後面會有解釋
根據 高效 Web 伺服器開發 - 實作考量點 提到以下考量點
當 Web 伺服器和客戶端網頁瀏覽器保持著一個長期連線的狀況下,遇到客戶端網路離線,伺服器該如何處理?
通訊協定無法立刻知悉,所以僅能透過伺服器引入 timer 逾時事件來克服
目前的 kHTTPd 沒有使用 timer 來關閉閒置的連線,因此會導致部份資源被佔用。參考 sehttpd 裡 timer 的實作,主要使用 min heap 來做管理,相關資訊可以參考二元堆積。
為了方便解決這個問題,將問題分成以下幾個小問題並且逐一解決
要將 socket 設定為 non-blocking 的原因在於,原本的實作中 socket 預設為 blocking ,因此執行緒會停滯在函式 kernel_accept
,但這樣的話沒有辦法去判斷是否已經有連線逾期,因此將 socket 設定為 non-blocking 可以避免執行緒停滯在函式 kernel_accept
,完整修改參考 Set socket non-blocking and remove accept_err
參考 kernel_accept ,其中參數 flags
可以設定為 SOCK_NONBLOCK
,如下所示
如此一來 socket 就能被改成 non-blocking 模式。
要讀取目前的時間,在 sehttpd 中使用系統呼叫 gettimeofday
實作,對應程式碼如下
而在 khttpd 裡無法使用系統呼叫,參考 include/linux/time64.h 裡的結構 timespec64
,其定義如下,其中成員 tv_sec
表示秒而成員 tv_nsec
表示奈秒
接著參考 include/linux/timekeeping.h 裡的函式 ktime_get_ts64
可以將目前的時間轉換成上述提到的結構 timespec64
的形式,以下擷取部份程式碼
有了以上的背景知識,可開始在 kHTTPd 上進行實作,建立函式 time_update
如下:
如此一來就可以得到當下的時間,單位為毫秒。
先定義問題:首先只會有一個 consumer 移除資料,亦即執行在背景的執行緒,而 producer 則是由多個處理連線的執行緒組成,因此歸納為 MPSC 的問題。
以下是簡略但存在缺陷的 lock-free 實作。定義 timer 和 priority queue 的結構體:
整個 priority queue 的流程如下所示
prio_queue_insert
新增新的 timer 並加到 priority queuehandle_expired_timers
偵測是否有 timer 逾期http_free_timer
釋放所有 timer 及 priority queuekey
函式 prio_queue_insert
主要功能為插入 timer 到 priority queue 裡,如同前面所說,這次的實作可以解讀成 MPSC ,因此這裡需要解決多個 producer 要插入的問題
而這裡的解決方式是利用判斷新舊成員數決定資料是否被別人寫入,也就是上述程式碼第 17 行,接著使用函式 prio_queue_cmpxchg
執行 CAS 操作,程式碼如下。
參照 Semantics and Behavior of Atomic and Bitmask Operations,若要用 Linux kernel 提供的 atomic_cmpxchg
實作 CAS ,應當留意到 Linux 核心提供的 atomic API 只能對變數本身的值做讀寫,不能對變數指到的資料讀寫,因此改成以下 inline assembly 的方式實作,主要更動就是從原本的 128 位元改成了 64 位元:
另外, min heap 在插入新的資料後都要經過 swim 的方式移動到正確的位置,而在這次的案例,資料 key
紀錄逾期的時間,且每個 timer 插入的時間一定都會比之前的 timer 大,因此不會出現後面的資料比前面的資料小的情況,也就可以省略 swim 的動作,如此一來,這樣就和 ring buffer 的操作相同。
函式 prio_queue_delmin
主要功能為從 priority queue 移除最小的 timer,因是 MPSC ,這裡避免 root 和最後一個成員交換時會有 producer 加入新資料 (程式碼第 14 行),也是依據 heap 的新舊成員數來判斷是否有受到其他 producer 的影響
接著就是更新新的成員數並且執行 sink 的動作,最後關閉該 timer 的連線以及釋放其記憶體
使用命令 ./htstress localhost:8081 -n 20000
進行測試,以下節錄部份的程式運行過程,可觀察到多個執行緒執行的狀況符合預期。
接著展示伺服器會更新每個連線的逾期時間,目前每個連線約等待 8 秒,可以看到第一次測試約等了 8 秒後自動關閉連線,且第二次的連線在送出請求後會再等待新的 8 秒
目前的成果,使用命令 ./htstress localhost:8081 -n 20000
參考 Ftrace 及《Demystifying the Linux CPU Scheduler》第六章。
ftrace 是一個內建於 Linux 核心的動態追蹤工具,可用來追蹤函式、追蹤事件、計算 context switch 時間及中斷被關閉的時間點等等。
先確認目前的系統是否有 ftrace,輸入以下命令
期望輸出如下
接著要怎麼使用 ftrace?可藉由寫入路徑 /sys/kernel/debug/tracing/
內的檔案來設定 ftrace ,以下提供部份檔案,使用命令 sudo ls /sys/kernel/debug/tracing
查看
至於這些檔案負責什麼功能,以下列出實驗有使用到的設定,剩下可以從 Ftrace 找到說明
current_tracer
: 設定或顯示目前使用的 tracers ,像是 function
、 function_graph
等等tracing_on
: 設定或顯示使用的 tracer 是否開啟寫入資料到 ring buffer 的功能,如果為 0 表示關閉,而 1 則表示開啟trace
: 儲存 tracer 所輸出的資料,換言之,就是紀錄整個追蹤所輸出的訊息available_filter_functions
: 列出 kernel 裡所有可以被追蹤的 kernel 函式set_ftrace_filter
: 指定要追蹤的函式,該函式一定要出現在 available_filter_functions
裡set_graph_function
: 指定要顯示呼叫關係的函數,顯示的資訊類似於程式碼的模樣,只是會將所有呼叫的函式都展開max_graph_depth
: function graph tracer 追蹤函式的最大深度有了以上的知識,可開始追蹤 kHTTPd,這裡嘗試追蹤其中每個連線都會執行的函式 http_server_worker
,第一步是要掛載核心模組,且透過檔案 available_filter_functions
確定是否可以追蹤 khttpd 的函式,輸入命令 cat available_filter_functions | grep khttpd
查看,可見 kHTTPd 裡可被追蹤的所有函式:
接著建立 shell script 來追蹤函式 http_server_worker
,如下所示
主要邏輯就是先清空 ftrace 的設定,接著設定函式 http_server_worker
為要追蹤的函式,最後在測試時開啟 tracer
執行 shell script 後,從 ftrace 的檔案 trace
可以看到追蹤的輸出,以下節錄部份輸出
由上面的結果可以看到整個 http_server_worker
函式所花的時間以及內部函式所花的時間,有這樣的實驗可以開始分析造成 khttpd 效率低落的原因
將可以追蹤函式的深度增加後,再次追蹤函式 http_server_worker
一次,以下為單次連線的追蹤結果
由上面的結果可見,影響 kHTTPd 效能最大的部份在於走訪目錄的函式 iterate_dir
,其次為用來接受和送出資料的函式 kernel_recvmsg
及 http_server_send
。