Linux 核心專題:kHTTPd 改進
執行人: w96086123
專題解說錄影
Reviewed by SimonLee0316
利用 hashtable 建立cache 的方式儲存每個連線的時間,請問在這個實作中每個連線的重新發送一個請求就會在更新一次 cache 值嗎?
任務簡介
改進 kHTTPd 的並行處理能力,予以量化並有效管理連線。
TODO: 閱讀 CS:APP 第 11 章
紀錄對電腦網路的認知
TCP 3-way handshake 為何要?
L2 -> L4 的分工和作用
Ethernet frame 大小?
ICMP 封包大小?
TCP 3-way handshake 為何要?
注意用語
- data 是「資料」,而非「數據」
- 是電腦「網路」,而非「網絡」
務必使用本課程教材規範的術語!
TCP 使用 3-way handshake 的主要原因有以下幾點:
-
初始化序列號:
3-way handshake 中的一個關鍵步驟是交換初始序列號。這些序列號對於後續的資料傳輸非常重要:
1-1. 序列號的作用:
初始序列號在交握過程中被交換,並用於標記後續資料包的順序。這樣可以確保資料包能夠按照正確的順序被接收和處理。TCP 使用這些序列號來進行資料包的排序和重傳,以確保資料傳輸的可靠性和完整性。
1-2. 資料包重傳和排序:
如果資料包在傳輸過程中丟失或順序錯亂,TCP 可以根據序列號進行重傳和排序。這確保了即使在不可靠的網路環境中,資料也能夠正確無誤地被傳輸和處理。
1-3. 資料傳輸一致性:
通過使用序列號,TCP 能夠保證資料在網路中傳輸的一致性,避免資料丟失或重複接收。
-
確保雙方處於可以同步的狀態
2-1. 同步狀態確認:
三次交握過程中,客戶端和服務器互相交換初始序列號,這確保了雙方的資料傳輸是在同一個基準點上開始的。這樣可以避免資料包因網路延遲、丟失或重傳等問題導致的不同步狀況。
2-2. 資料傳輸可靠性:
確保雙方都已經準備好接收和發送資料,這樣可以避免資料包的錯誤傳輸或重傳,從而保證資料傳輸的可靠性。例如,如果雙方未同步,可能會出現資料包無法正確識別的情況,導致資料丟失或重複接收。
2-3. 避免資料包錯誤:
當前客戶端和服務器都處於同步狀態時,可以避免由於網路不同步或其他技術問題(如路由延遲)而導致的資料包錯誤,從而確保資料傳輸的完整性和正確性。
-
避免舊資料干擾
3-way handshake 也有助於防止舊資料包對新連接的干擾:
3-1. 避免重複和舊資料包:
當前的連接使用了新的序列號,每次建立連接時,TCP 會分配一個新的初始序列號。這確保了即使之前的連接已經關閉並建立了新連接,舊的資料包(帶有舊序列號)也不會干擾新連接。舊資料包會因為序列號不匹配而被丟棄。
3-2. 網路延遲和資料包識別:
在一些情況下,資料包可能因為網路延遲等原因滯留在網路中。通過每次連接使用不同的序列號,TCP 可以識別並丟棄這些過期或重複的資料包,防止其影響新連接。
3-3. 序列號的唯一性:
TCP 序列號具有唯一性,每個連接的序列號範圍是不同的,因此即使同一個 IP 和通訊埠組合重新建立連接,因為序列號的不同,也不會接收到舊資料包的影響。
L2 -> L4 的分工和作用
Image Not Showing
Possible Reasons
- The image was uploaded to a note which you don't have access to
- The note which the image was originally uploaded to has been deleted
Learn More →
每層加入不同的標頭並解析這些標頭,是為了確保數據能夠在複雜的網路環境中正確且高效地傳輸。
-
L4 (傳輸層) (Transport Layer)
主要動作:加入傳輸標頭(TH)與解析傳輸層標頭
功能:
- 傳輸層負責提供端到端的通信服務,確保資料能夠可靠且有序地從一個設備傳送到另一個設備。
- 增加傳輸標頭(TH),包含以下關鍵資訊:
- 源通訊埠號:標識資料來源的通訊埠。
- 目的通訊埠號:標識資料目標的通訊埠。
- 序列號:用於封包排序,確保資料按正確順序重組。
- 確認號:確認接收到的封包,確保資料傳輸的可靠性。
- 協定類型:如 TCP 或 UDP 。
解析傳輸標頭:
- 接收方在解析封包時,會查看傳輸標頭以了解資料的源通訊埠、目的通訊埠及其他控制信息,然後將資料傳遞給相應的程式。
-
L3 (網路層) (Network Layer)
主要動作:加入網路標頭(NH)與解析網路層標頭
功能:
- 網路層負責封包的路徑選擇和轉發,並加上網路標頭(NH),形成完整的封包。
- 網路標頭包含邏輯位址(IP 位址),標識網路中的設備,確保封包可以在不同的網路節點之間正確轉發。
作用:
- 路由選擇 (Routing)
- 根據網路拓撲和當前網路狀況選擇最佳路徑。
- 使用路由協定(如 OSPF)來更新和維護路由表。
- 邏輯位址分配
- 提供全球唯一的邏輯位址系統(如IP位址),保證資料能夠在不同網路之間正確傳送。
解析網路標頭:
- 接收方會檢查網路標頭中的IP位址,確定封包是否到達最終目的地或需要繼續轉發。
-
L2 (資料鏈路層) (Data link Layer)
主要動作:加入資料鏈結串列頭(DLH)和資料鏈結串列尾(DLT)與解析資料鏈路層標頭
功能描述:
- 資料鏈路層負責物理尋址、錯誤檢測和校正,確保資料能夠在同一網段內可靠傳輸。
- 資料鏈路層會將封包加上資料鏈結串列頭(DLH)和資料鏈結串列尾(DLT),形成資訊框(Frame)。
作用:
- 資料鏈結串列頭(DLH)
- 包含實體位址(如MAC位址)和錯誤檢測信息。
- 實體位址用於識別網路中的設備。
- 資料鏈結串列尾(DLT)
- 包含錯誤檢測碼(如FCS),用於檢測和校正數據傳輸過程中的錯誤。
解析資料路層標頭:
- 接收方會檢查資料鏈結串列頭中的實體位址,確認是否是目標設備,並通過資料鏈結串列尾的錯誤檢測碼檢查封包完整性
資料鏈路層可進一步分成兩個子層:
- 邏輯鏈路控制 (LLC) 子層
- 負責流量控制、錯誤檢測和封包的分段和組合。
- 提供統一的接口給上層網路層,隱藏不同物理媒介的差異。
- 媒介存取控制 (MAC) 子層
- 管理對共享媒介的存取控制,確保各設備在網路中不會發生資料碰撞。
- 定義物理位址(MAC 位址)以唯一標識網路中的設備。
Ethernet frame 大小?
在乙太網路 ( Ethernet ) 中,資料的傳輸單位是 Ethernet frame。
每個 Ethernet frame 以 Header 、 Data 、 Trailer 三個部分組成,每個 frame 長度有一定的規範,以下為各部分的規範:
-
Header
- 目的位址: 6 Bytes
- 源位址: 6 Bytes
- EtherType: 2 Bytes
-
Data
- 最小長度: 46 Bytes
- 最大長度: 1500 Bytes
-
Trailer
- FCS ( Frame Check Sequence ): 4 Bytes
由上述可以每個 Ethernet frame 最小為 64 Bytes , 最大為 1518 Bytes 。
另外在 Ethernet frame 有以下的動作:
- 若資料不足 46 Bytes 時,則會填充至 46 Bytes。
- 當資料過大,大於 1500 Bytes 時,會進行拆分傳輸,並且在接收端進行重組。
- 另外 FCS 則用於錯誤檢測,確保傳輸過程 frame 沒有遺失資料。
ICMP 封包大小?
參考於維基百科
Image Not Showing
Possible Reasons
- The image was uploaded to a note which you don't have access to
- The note which the image was originally uploaded to has been deleted
Learn More →
圖片參考至 IT邦幫忙
ICMP 封包由兩個主要部分組成: ICMP 頭和 ICMP 資料。這個 ICMP 封包通常嵌入在一個 IP 封包中,由 IP 頭 封裝。
- ICMP 頭
- 大小: 8 Bytes
- 內容:
- Type ( 1 Byte ) :表示 ICMP 類型,譬如:請求、回應等等
- Code ( 1 Byte ):輔助定義 Type 的子類別
- Checksum ( 2 Bytes ): 檢測 ICMP 封包是否有錯誤
- Rest of header ( 4 Bytes): 根據上述三種類別進行變化
- ICMP 資料
- 大小:可變化,最低可以為 0 , 之後根據 RFC 791 , IP 封包大小不能超過 576 Bytes 。且 IP 頭標準大小為 20 Bytes , ICMP 頭為 8 Bytes ,因此資料最大為 548 Bytes
- 內容:回應主要內容為錯誤訊息
因此 ICMP 封包最小為 8 Bytes 。
TODO: 改進 kHTTPd
以 ktcp 的 kHTTPd 為基礎,提升其並行處理能力,予以量化並有效管理連線
引入 CMWQ ,並且分析效能
參考 ktcp 作業流程,將 CMWQ 引入至 khttp 中
整理過後的整體程式流程為:
- 建立 CMWQ
- 連線建立後建立 work,並將此 work 加入至 workqueue 進行管理
- work 開始執行
- work 結束時,釋放所有記憶體
使用 http_service
管理 workqueue ,http_request
管理 request ,workqueue 、 work 都使用 list_head
方式進行連接。
建立 work : create_work
為每個連線請求進行 kernel space
的動態記憶體配置,並透過 INIT_WORK
為此請求進行初始化,再透過 list_add
加入至 workqueue 中
注意書寫規範:
- 程式碼註解不該出現中文,總是用美式英語書寫
- 使用 lab0 規範的程式碼書寫風格,務必用 clang-format 確認一致
釋放記憶體: free_work
用於釋放為建立連線所分配的記憶體空間,使用 list_for_each_entry_safe
巨集走訪每個在 workqueue
中所管理的 work
。
在 http_server_daemon
中將上述兩個函式加入
主要為修改執行方式與釋放方式,原有的方法是使用 kthread_run
建立並執行,因此需要改成使用 create_work
建立 work 且使用 queue_work
來執行此請求,最終結束時將此 workqueue
設定為停止狀態,並且利用 free_work
進行記憶體釋放。
注意書寫規範:
- 程式碼註解不該出現中文,總是用美式英語書寫
- 使用 lab0 規範的程式碼書寫風格,務必用 clang-format 確認一致
引入 CMWQ 之後,在處理 200000 的請求明顯可以在處理速度上加快了約 1.3 倍。
提供目錄檔案存取功能
Directory list
主要流程:
- 進行 method 判斷。由於此功能設定為 GET,因此對於非 GET 方法,回傳 501 狀態。
- 判斷設定的路徑是否存在,若不存在則回傳 404 狀態。
- 判斷此路徑是否為資料夾,若是則走訪此資料夾;若是檔案,則讀取檔案內容。
3.1 由於在走訪過程中無法得知整體長度,因此需要在走訪過程中直接發送資料,而非等待全部走訪完成後再統一處理並傳送資料。
static bool handle_directory(struct http_request *request)
{
struct file *fp;
char pwd[BUFFER_SIZE] = {0};
if (request->method != HTTP_GET) {
send_http_header(request->socket, HTTP_STATUS_NOT_IMPLEMENTED,
http_status_str(HTTP_STATUS_NOT_IMPLEMENTED),
"text/plain", 19, "close");
send_http_content(request->socket, "501 Not Implemented");
return false;
}
catstr(pwd, daemon_list.dir_path, request->request_url);
fp = filp_open(pwd, O_RDONLY, 0);
if (IS_ERR(fp)) {
send_http_header(request->socket, HTTP_STATUS_NOT_FOUND,
http_status_str(HTTP_STATUS_NOT_FOUND), "text/plain",
13, "Close");
send_http_content(request->socket, "404 Not Found");
return false;
}
if (S_ISDIR(fp->f_inode->i_mode)) {
char buf[SEND_BUFFER_SIZE] = {0};
request->dir_context.actor = tracedir;
snprintf(buf, SEND_BUFFER_SIZE, "HTTP/1.1 200 OK\r\n%s%s%s",
"Connection: Keep-Alive\r\n", "Content-Type: text/html\r\n",
"Keep-Alive: timeout=5, max=1000\r\n\r\n");
http_server_send(request->socket, buf, strlen(buf));
snprintf(buf, SEND_BUFFER_SIZE, "%s%s%s%s", "<html><head><style>\r\n",
"body{font-family: monospace; font-size: 15px;}\r\n",
"td {padding: 1.5px 6px;}\r\n",
"</style></head><body><table>\r\n");
http_server_send(request->socket, buf, strlen(buf));
iterate_dir(fp, &request->dir_context);
snprintf(buf, SEND_BUFFER_SIZE, "</table></body></html>\r\n");
http_server_send(request->socket, buf, strlen(buf));
kernel_sock_shutdown(request->socket, SHUT_RDWR);
} else if (S_ISREG(fp->f_inode->i_mode)) {
char *read_data = kmalloc(fp->f_inode->i_size, GFP_KERNEL);
int ret = read_file(fp, read_data);
send_http_header(request->socket, HTTP_STATUS_OK,
http_status_str(HTTP_STATUS_OK), "text/plain", ret,
"Close");
http_server_send(request->socket, read_data, ret);
kfree(read_data);
}
filp_close(fp, NULL);
return true;
}
在 tracedir
中,主要處理的為走訪資料夾 中內容,並且在走訪的過程中就會進行傳送。
Initital Path
流程:
- 先在
main.c
裡面設定初始位置
- 在
handle_directory
中將 request url
與初始位置組合
在 http_server
中,新增處理路徑合併的函式 catstr
,並且在 handle_dirctory
中使用此函式
Use Chunk to send data
在不使用 chunk 作為傳輸的時候可能會出現以下問題:
- 高延遲
如果需要等待整個資料處理完畢後再進行傳輸,會產生較高的延遲,尤其是在資料量較大時,更容易發生此問題
- 記憶體壓力
由於需要等待資料處理完畢之後才進行送出,因此會占用需多記憶體空間,導致空間不足
- 錯誤處理困難
當在傳輸的過程中出現錯誤,此時就需要重新處理與傳輸,因此增加了處理的複查度與時間
- 使用體驗不佳
因為上述所提到的高延遲問題,會產生 client 需要等待全部的資料傳輸完成後,瀏覽器才能開始呈現,因此在使用體驗上會是不佳的狀態
- 那如何使用 chunk 進行傳輸?
只需要在 HTTP header 發送時,新增 Transfer-Encoding: chunked
,即可使用 chunk 傳輸,並且在最後傳送資料長度為 0 的資料,表示傳出完成。
之後在傳送資料的時候,需要先標示這次的資料長度為多少,並且以 16 進位表示。
引入 timer ,主動關閉逾期的連線
這次實作的 timer 是使用 Priority Queue
與 Min Heap
進行管理。
選擇使用 Min Heap 的主要原因為尋找最小值的時間複雜度為常數時間。因為在這種需要頻繁地去尋找最小值的特定狀況下,直接使用 Min Heap 進行管控,是一種最常使用的方法。
timer node 的結構如下:
key
儲存逾期的時間點,作為 Priority 使用
pos
儲存此 node 在 queue 中的位置
timer 中主要的操作有以下:
- handle_expired_timers
取得目前 timer 中 key 為最小值的 node,並且判斷是否已經逾期,若是則刪除,直到 queue 為空
- http_timer_update
更新此 node 在 timer 中的 key 值,主要做於此 request 有新的請求時更新 timeout 的時間點
- http_add_timer
將新的 request 加入至 timer
整體的實作流程為:
- 初始化 timer
- 當初始化成功建立之後,程式會一直執行 handle_expired_timers
- 當建立 work 時,會執行 http_add_timer ,建立新的 timer node 並且放入 queue 中
目前的 kHTTPd 初步實作 HTTP 1.1 keep-alive,不過效率不彰,以 ftrace 一類的工具指出問題所在並改進
-
找出問題
以下是請求一次且從 ftrace 輸出中擷取的一部分:
由此可知,在每次的請求中所主要耗費在走目錄的過程,其次為傳送資料。
-
解決問題
由於走訪本身無法進行有效的改善,其時間複雜度為 O(n),因此我使用 cache 的方式進行改善。
我的 cache 是利用 hashtable 的方式建立
結構為:
我主要 url 作為 index 進行尋找,且紀錄原本 response 的資料。
主要操作如下:
主要流程為:
- 先進行初始化
- 在進入 handle_directory 之前,先尋找在 hashtable 中是否有 request url
2_1. 若有則回傳以記錄的 response data
2_2. 否則進行下一步
- 執行 handle_directory
- 在執行傳送資料之前先將資料儲存至 cache 中
-
實作結果
可以看到在重複請求同樣網址時,可以有效提升處理速度
學習 cserv 的 memcache 並在 kHTTPd 重新實作
在原有的設計上,會因為有大量的記憶體配置請求,每次 kernel 處理記憶體配置時,會需要保證其餘程式的安全,所以需要一定的時間處理該請求,因此會耗費大量的時間,並且當長時間的配置與釋放記憶體空間,會造成記憶體空間破碎化。
因為以上的原因,才使用 memcache 進行統一的記憶體空間管理。由於 memcache 只作為提供空間與數量管理的機制,因此需要將 LRU 加入至 hashtable 。
為何引入 LRU 會有幫助?你的理論依據是什麼?建立統計模型來探討。
主要操作如下:
整體流程為:
- 在 hashtable 初始化時,建立 memcache
- 在建立新的 hashtable 節點時,從 memcache 進行配置,並且將此節點放入置 LRU ,若進行配置時存在數量已超過最大值時則刪除最舊的節點
- 在尋找或插入 hashtable 節點時,會將此節點放入至 LRU 的最前方
TODO: 探討 eBPF 對於網頁伺服器開發的作用
量化 Linux 核心網路的行為
參考 fatcatorange 與使用 matplotlib 實施可視化分析,撰寫以下程式:
我選擇 handle_directory
作為量化目標,選擇此函式的原因是因為每次的請求都會進入此函式,並且根據上方 ftrace
的結果,得知此處是整個系統中最耗時的區域。
完成上方的改進 kHTTPd 之後,進行了 1000 、 10000 與 100000 次的連續請求,並完成以下的折線圖,以便後須觀察。
從折線圖中可以看出, handle_directory
函式的執行時間在不同的請求次數下表現出相對穩定的趨勢。具體分析如下:
- 1000 次請求:在 1000 次請求下,handle_directory 函數的執行時間較為穩定,只有少數幾個數據點出現較大的波動。
- 10000 次請求:在 10000 次請求下,執行時間的波動稍有增加,但整體趨勢仍然較為穩定,顯示出系統在較大負載下的穩定性。
- 100000 次請求:在 100000 次請求下,執行時間的波動性進一步增加,但總體上系統仍然保持穩定,沒有出現隨著時間推移或其他因素導致的處理成本顯著上升的情況。
整體來看,函式在不同請求次數下的表現相對穩定,並未因為時間的推移或其他因素導致處理成本顯著上升。