# 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 以鏈結串列形式儲存,目錄的欄位一項一項的存在鏈結串列中,最後將整串指標放入雜湊表