# Linux 核心專題: 核心模式的網頁伺服器 > 執行人: ginsengAttack > [解說影片](https://www.youtube.com/watch?v=guPlLPipAcc) ### Reviewed by `Max042004` > 明明可以採用 blocking 的 kernel_accept 進行連線監聽即可? 你應該是指 non blocking 的 kernel_accept 吧? 我也有相同的問題 > 我的想法是,該專題講求大量的 I/O 處理,所以使用這種能夠同時處理多個連線的機制。之後會測試這種方法可不可以引入 ktcp ,提升併行處理的能力。 ### Reviewed by `Denny0097` 引入 CMWQ 機制之後,為什麼可以使效能有顯著的提升?可以進行更詳細的說明。 > CMWQ 的機制會在核心建立 Worker Pool ,核心會依 workqueue 旗標 (如 CPU 綁定、優先順序等) 自動安排 worker 並在適當時機切換與重用,降低 idle worker 數量並提升系統使用率。 > >使用 kthread 的話會在新連線進入才生成一個 kernel thread ,CMWQ 為基礎的實作透過 thread pool 持續保留空閒 worker,work item 進入佇列後即可指派至可用執行緒,無須再建立 kthread  ## 任務簡述 依循 [ktcp](https://hackmd.io/@sysprog/linux2025-ktcp/) 作業規範,進行開發,務必提交給進 khttpd 的 pull request 並接受 review。適度引入[第 11 週測驗題](https://hackmd.io/@sysprog/linux2025-quiz11)提及的 kweb 設計,將 ktthpd 調整為高度並行設計,並排除開發過程遇到的缺失。 ## 閱讀 ktcp 課程教材 ### khttpd 功能 透過 `open_listen_socket` 監聽特定 port 等待連線,並使用 `kthread_run` 建立背景程式 `http_server_daemon`。 `http_server_daemon` 接收連線請求,並產生獨立的 thread 服務每一個連線,為教材[事件驅動伺服器:原理和實例](https://hackmd.io/@sysprog/linux-io-model/https%3A%2F%2Fhackmd.io%2F%40sysprog%2Fevent-driven-server),中提到的事件驅動伺服器。 ### 運行`http_server_worker` 負責服務單一連線,以 [HTTP parser](https://github.com/nodejs/http-parser) 解析 http request ,目前該專案已經停止維護,後續會改用 [picohttpparser](https://github.com/h2o/picohttpparser) 以更輕量的方法替代。 ### 支援 HTTP keep-alive 模式 TCP 連線需要 three way handshake,這是一個龐大開銷,所以 HTTP 支援 keep-alive 模式,降低通訊本。 對應到 `http_server.c` 中的第176-187行: ```c while (!kthread_should_stop()) { ... if (request.complete && !http_should_keep_alive(&parser)) break; ... } ``` 支援單次連線多次傳輸。 ## 閱讀 kweb 程式碼 與 Khttpd 不同,不會給每個連線一個獨立的 thread,而是產生與 CPU 數量相同的 thread,以一個 `listener` 作為生產者,將資料( socket )放進 buffer ,由作為消費者的 worker 取出後服務客戶端。 當中使用了 `vfs_poll` 幫助 worker 處理多個客戶端的連線,此為核心提供的 [I/O Multiplexing](https://hackmd.io/@sysprog/linux-io-model/https%3A%2F%2Fhackmd.io%2F%40sysprog%2Fevent-driven-server) api。 但我有疑問,為何 listener 同樣採用此 api: ```c while (HHHH) { if (!(vfs_poll(ctx0->listen_file, NULL) & POLLIN)) { schedule_timeout_interruptible(msecs_to_jiffies(10)); continue; } for (;;) { struct socket *cs = NULL; int err = kernel_accept(ctx0->listen_sock, &cs, O_NONBLOCK); ... } cond_resched(); } ``` 明明可以採用 blocking 的 `kernel_accept` 進行連線監聽即可? 我的想法是,該專題講求大量的 I/O 處理,所以使用這種能夠同時處理多個連線的機制。 之後進行效能量測,研究引入此種方法是否更具吞吐量。 而之所以使用 `schedule_timeout_interruptible` 讓模組暫時睡眠,是為了避免 busy waiting 耗費過多 CPU 效能。 ## 開發問題整理 1. 管理資源,使用到的資源在用完時要自行釋放,否則模組將無法正確卸載。 2. stack 大小要注意, overflow 會使整個系統崩潰。 3. 要進行錯誤處理,不能預設每個操作都會順利,在這邊貪圖方便,會導致後續排查問題更麻煩,後果也更嚴重。 ## TODO: 導入 Concurrency Managed Workqueue 以 `alloc_workqueue` 創建 workqueue,並在移除模組的時候使用: ```c flush_workqueue(...); destroy_workqueue(...); ``` 將 workqueue 移除。 workqueue 當中的 work item 資料結構為: ```c typedef void (*work_func_t)(struct work_struct *work); struct work_struct { atomic_long_t data; struct list_head entry; work_func_t func; #ifdef CONFIG_LOCKDEP struct lockdep_map lockdep_map; #endif }; ``` 想要把 `kthread_run` 改動為 workqueue,還要考慮資料結構的問題,原程式使用 `void *arg` 作為傳入執行緒的參數,而 CMWQ 指定以 `struct work_struct` 作為參數。 但是我們必須將 socket 傳輸到對應執行緒,所以要採用作業一提到的方法,以契合 linux 核心風格的方式,將 `work_struct` 嵌入到其他結構,並透過 `contianer_of` 取出資訊。 :::danger 務必依循課程教材規範的術語,見 https://hackmd.io/@sysprog/it-vocabulary ::: 接著,以把 work item 加入 workqueue 的方式取代原有的 `kthread_run` : ```c INIT_WORK(...);//將函數封裝進已經定義好的結構 queue_work(...,...);//加入 workqueue ``` 透過教材提供的 hstress ,量測效能: ```c requests: 100000 good requests: 100000 [100%] bad requests: 0 [0%] socket errors: 0 [0%] seconds: 1.422 requests/sec: 70299.857 ``` 提升至: ```c requests: 100000 good requests: 100000 [100%] bad requests: 0 [0%] socket errors: 0 [0%] seconds: 0.992 requests/sec: 100788.570 ``` 另外,如果要 commit ,會遇到靜態分析問題: ``` suppress=unusedStructMember:http_server.h ``` 將 `pre-commit.hook` 中的這個抑制移除即可。 具體程式碼: > commit:[5a57bcd](https://github.com/sysprog21/khttpd/commit/5a57bcd665ad8affd40f95bcec66254362e2bd26) ## TODO: directory listing 功能 參考教材提到的核心 api : `iterate_dir ` 具體定義如下 ```c #include<linux/fs.h> int iterate_dir(struct file *filp, struct dir_context *ctx); ``` 輸入的兩項參數中,前者為檔案結構,後者則包裝著回調函數,我們將特定的檔案位置輸入後,此函數會走訪該目錄底下的所有檔案,並呼叫包裝在 `dir_context *ctx` 中的函數。 基本功能為:走訪目錄每一個檔案->讀取檔名->製作相應 html 欄位->傳輸 。 考慮到網路佇列的行為,網卡限制 MTU ,所以單個封包大小是有限制的,從使用者的觀點來看,訊息是完整的被傳輸,但是在傳輸層的角度,封包卻是需要被切割的,由目的端的傳輸層進行重組。 我們可以利用這個特點,分批的傳輸資訊,也就是可以直接在走訪每一個檔名的時候直接用 `kernel_sendmsg` 進行傳輸。 接著我們要準備回調函數,定義的結構為: ```c int (*actor)(struct dir_context *ctx, const char *name, int namelen, loff_t offset, u64 ino, unsigned int d_type); ``` 比照教材宣告: ```c static int tracedir(struct dir_context *dir_context, const char *name, int namelen, loff_t offset, u64 ino, unsigned int d_type) ``` 因為該函數內部需要呼叫到 `http_server_send` ,所以必須要能夠接收 socket 等資訊,採用跟之前一樣的方法: ```c struct http_request { struct socket *socket; enum http_method method; char request_url[128]; int complete; struct dir_context dir_context;//<- new insert }; ``` 同時應進行修改,並移除原專案長度限制: ```c "Content-Type: text/plain" -> "Content-Type: html/plain" ``` 此時檔案資訊可以正常傳輸。 但考慮到上述分段傳輸,接收端難以得知訊息是否傳輸完成,所以接收端網頁會不停載入,甚至無法顯示,這邊提供兩種方式: 1. `kernel_sock_shutdown` 為關閉連線的 api ,透過在傳輸結束時呼叫該 api,即可手動的關閉連線,告知接收端傳輸結束 2. 使用 chunked 方法一不是優秀的選擇,因為他不是通知接收端 http 傳輸結束,而是直接關閉 tcp 連線,與原專案支援 keep-alive 的功能背道而馳,所以應該要採用 chunked 也就是分塊傳輸編碼。 ### 讀取檔案 既然已經能夠輸出目錄內容,便要能傳輸目錄中的檔案,核心以 `inode` 管理檔案屬性。 並提供[巨集](https://github.com/torvalds/linux/blob/master/include/uapi/linux/stat.h): ```c #define S_ISLNK(m) (((m) & S_IFMT) == S_IFLNK) #define S_ISREG(m) (((m) & S_IFMT) == S_IFREG) #define S_ISDIR(m) (((m) & S_IFMT) == S_IFDIR) #define S_ISCHR(m) (((m) & S_IFMT) == S_IFCHR) #define S_ISBLK(m) (((m) & S_IFMT) == S_IFBLK) #define S_ISFIFO(m) (((m) & S_IFMT) == S_IFIFO) #define S_ISSOCK(m) (((m) & S_IFMT) == S_IFSOCK) ``` 之後會需要用到 `S_ISDIR` 和 `S_ISREG` 兩者,前者判斷是否為目錄,啟用上述 directory listing 功能,後者判斷是否為檔案,進行接下來的檔案傳輸功能以此進行判斷。 首先根據客戶端傳送的網址,查找對應目錄,判斷為檔案後,以 `i_size` 分配與檔案同等大小的空間,並使用 `read_file` 將檔案儲存至該空間,接下來就可以直接傳出資料,最後要把剛剛分配的空間釋放。 > Commit: [a36f550](https://github.com/sysprog21/khttpd/commit/a36f550768a9d03615ec1a052a68e33b63690037) ### MIME 上面的功能已經可以傳送檔案,可是在 HTTP header 中有 `Content-Type` 的欄位提供正確檔案類型,可以參考[資料](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types),以此生成對應標頭。 透過查表的方式進行搜尋,將對應的資料寫入在另一個 `mime.h` 檔案中。 教材採用循序的搜尋,是否可以改用雜湊提高速度? > Commit: [670c3bb](https://github.com/sysprog21/khttpd/commit/670c3bbe3a03fa7d7945d4797445ce86bad100ac) ## 使用 Ftrace 定位效能瓶頸並改善 (http parser) 在 [assessment](https://hackmd.io/J8nSa524R52WMX5CqhDpJw?view)中已根據教材內容操作 Ftrace。 瓶頸為: ```c http_parser_execute() http_parser_callback_message_complete() http_server_send() http_server_recv() ``` I/O 操作本來佔據的時間就多,必須引入更複雜的機制處理,這邊先討論 `http_parser_execute()` 這是一個 http 請求解析工具,採用狀態機模型,會針對輸入資料 進行逐字元處理, 雖然設計簡潔、具備良好可攜性,但在核心環境中處理封包時效率不彰。每個字元都會觸發一次狀態轉換與條件分支 (switch 或 if-else) ,導致頻繁跳躍與 cache miss,且難以批次處理。 可以比照作業需求,採用更輕量化的解析工具 [picohttpparser](https://github.com/h2o/picohttpparser) 。 該專案關鍵 api 為: ```c int phr_parse_request(const char *buf, size_t len, const char **method, size_t *method_len, const char **path, size_t *path_len, int *minor_version, struct phr_header *headers, size_t *num_headers, size_t last_len); ``` 並注意,該專案沒有提供 `http_should_keep_alive ` 這種 api ,所以我們要自行解析,檢查 HTTP header 中的 `"Connection"` 欄位,判斷是 "keep-alive" 還是 "Close" 。 ```c for (int i = num_headers-1; i>=0; i--) { if (!strncasecmp(headers[i].name, "Connection", (size_t)headers[i].name_len)) { if (!strncasecmp(headers[i].value, "keep-alive", (size_t)headers[i].value_len)) should_keep_alive = 1; else should_keep_alive = 0; break; } } ``` > Commit [0bbe237](https://github.com/sysprog21/khttpd/commit/0bbe237efb205c1d6ebaadc4bbd77c1f2a18aa86) 另外,老師的專案是在 make 的時候動態下載 http-parser,如果我直接將 picohttpparser 放在專案中,是否會出現問題? ## TODO: 支援 HTTP 壓縮 Linux 核心提供壓縮 API,可以幫助我們減少傳輸的開銷。 並且 HTTP 也有相應的支援,例如 gzip 與 deflate。 有兩個關鍵 api : `crypto_alloc_comp` 與 `crypto_comp_compress` ,前者建立壓縮實體,後者進行資料壓縮。 在上述的檔案傳輸前,使用該函數進行壓縮,並在標頭加入 `Content-Encoding` 字樣。 同時,壓縮的檔案類型也必須進行考慮,純文字檔可以直接壓縮,但是圖片檔可能被壓縮破壞無法還原。 輸出檔案大小: ```c [ 7725.472406] khttpd: original size:2687 [ 7725.472407] khttpd: compress size:736 ``` > Commit [4ba90e2](https://github.com/sysprog21/khttpd/commit/4ba90e2b91510cd88fce9b4eef5352f1e4dc9523) ## TODO: 引入 timer 以主動關閉逾期的連線 (未完成) 這邊教材使用的方法是,使用 min heap 建立 timer 的 priority queue,為每個連線設置過期期限。 同時會將背景程式的 socket 設為 non-blocking,但是這邊我認為不太適合,因為這樣會導致背景程式 busy waiting,為何不用獨立的 thread 進行? 另外,在另一篇教材中提到,我們可以將 timer 以檔案的方式進行操作,也就是所謂的 timerfd,並透過 epoll 等機制監控時間是否過期,核心法使用該技巧嗎? - [ ] 仔細思考後,發現這個想法不合理,該類機制僅能通知事件發生,實際上的處理邏輯還是要另外考慮。 ## TODO: 加入透過 sysfs 提供的控制介面 加入 sysfs 可提供核心與使用者的通訊橋梁,有兩種方法,一者是使用 kweb 的方法、一者是使用 kxo 的方法。 這裡試著比照 kxo 的方法進行。 首先定義結構體: ```c struct ktcp_attr { char enable; rwlock_t lock; }; static struct ktcp_attr attr_obj; ``` 然後定義操作: ```c static ssize_t ktcp_state_show(struct device *dev, struct device_attribute *attr, char *buf) { ... } ``` 最後透過過巨集註冊操作,然後綁定到設備之上: ```c static DEVICE_ATTR_RW(ktcp_state); ... ret = device_create_file(ktcp_dev, &dev_attr_kxo_state); ``` 後續會將此方法改為 kweb 中的方法,因為這種方法依賴字節驅動,而 ktcp 沒有這種需求,或者是後續可以開發相應的功能,把核心空間的資料傳輸到使用者空間之中? 為了達成關閉伺服器的運作,在背景程式與 `server_worker` 中寫入: ```c ///deamon if (attr_obj.enable == '0') continue; ///worker while (!kthread_should_stop() && enable == '1') ``` 並不要忘記釋放資源。 最後我們就可以透過再命令提示字元輸入: ``` echo 0 | sudo tee /sys/class/ktcp/ktcp/ktcp_state ``` 來關閉伺服器了。 > commit:[90c9f95](https://github.com/sysprog21/khttpd/commit/90c9f95a500b38a0a4dc4f18ec6149ecbdc66ecd) ## TODO: keep-alive 的效能瓶頸 (未完成) 服務某個連線的時候, thread 會在 `kernel_recv` 的地方以 blocking 的方式進行等待,效能分析也指出,此處花費時間甚多。 ## TODO: 實作 content cache (未完成) 每個連線一個獨立的 `cache` ,把曾經請求過的目錄資訊紀錄在 cache 中。 cache 以鏈結串列形式儲存,目錄的欄位一項一項的存在鏈結串列中,最後將整串指標放入雜湊表