執行人: han1018
專題解說錄影
以第七次作業為基礎,熟悉 Linux 核心模組的開發、網路通訊機制、RCU 使用,和排除相關的技術議題。
應對下方修改,嘗試提交 pull request 到 khttpd。
參考資訊: (適度改寫下方程式碼並驗證)
分析效能表現和提出改進方案
編譯 khttpd 專案
掛載 khttpd 模組並且指定 port
掛載模組後查訪 index.html 會成功得到 Hello World!! 表示掛載成功
khttpd
主要的運作是在 http_server_daemon
中以阻塞 (blocking) 方式偵測和等待新的 socket 連線。當接收到新的 socket 連線後,會立即建立一個 kthread 執行回覆客戶端內容的 http_server_worker
函式。這次的改動是使用 work 和 workpool 取代 kthread,以提高閒置 CPU 的使用率。
詳細改動可以參照 Implement CMWQ to improve throughput,
改動部分,新增了兩個結構體 khttpd_service
、khttpd
,用於存放 work item 和 workqueue。
接著,在等待 socket 連線的函式 http_server_daemon
中,用 create_work
取代建立 kthread,讓連線的 client 執行回覆函式 http_server_worker
。
在 http_server_worker
函式內,由於無法從 kthread 傳遞參數至函式,因此改用 CMWQ(Concurrent Managed Work Queue)。使用 container_of
來取得 work item 結構體,從而獲得 work item 的 socket。
建立 CMWQ 的 work item 和移除程式如下:
實際來測試使用 Before 的 kthread 方法執行 http_server_worker
與使用 CMWQ 方法執行的差異。可以看到從 kthread 改至 CMWQ 後效果提升不少,從 11218 上升至 14695 每秒!
加上 CMWQ 前:
./htstress http://localhost:8081 -n 50000
加上 CMWQ 後:
./htstress http://localhost:8081 -n 50000
dir_context
是 Linux 核心中用於檔案系統中的目錄讀取操作的一個結構體。可以在檔案系統中讀取目錄,並將它們傳遞給用戶空間的程序。這裡用做回傳走訪目錄下每一個節點的內容至 client 顯示。filp_open
與 user mode 的 open 相似,負責在 kernel mode 中打開指定的檔案並回傳一個 file pointer。參考 Risheng1128 的方式,建立 handle_directory 函式實作回傳目錄的功能。在 client socket 連線後進入 handle_directory
,打開指定目錄,將目錄中的每一個內容名稱用 HTML table 傳至 client,完整程式可以參考 Add the functions to return the directory list。
走訪每一個目錄節點的 callback 函式是 tracedir
,tracedir
的功能就是會走訪整個目錄的資料,並且每執行一次就會將資料送到 client。將目錄中檔案名稱包成一個一個 HTML table 的 tr
再傳給 client。
傳給 client 的 HTML 程式除了內容也需要負責傳送 HTTP 頭欄位、結尾等內容,如下:
最後在 client 連線後的 callback 函式設定執行前面建立好的傳輸內容函式,取代掉原本單純的回傳 "hello world" 字樣:
目前結果已經可以顯示指定路徑下的目錄了,如下圖:
參考 fatcatorange 方法,在目錄之間可以用點選方式返回前一目錄,好處是可以返回上一頁時可以不用點選瀏覽器的返回上一頁按鍵而看到之前目錄的快取。如果程式寫出 Bugs 時便可以第一時間知道。
返回連結的超連結用當前位置 + /../
,便可以回至前一個目錄位置。
為了讓返回連結出現在第一個,這裡將返回連結順序放在 HTML head 傳送完之後回傳。在出現的返回連結的地方,避開首頁位置增加判斷 strcmp(request->request_url, "/")
,網址位於子目錄位置時才顯示返回連結。
成果如下圖:
參考 Risheng1128 的方式,新增掛載模組時的參數 root
,掛載模組時可以設定目錄位置給模組參數 root
指定要開啟的路徑。完整的修改可以參考
Add the root parameter that can specify the directory path
設定模組的參數方式如下,參考 The Linux Kernel Module Programming Guide 使用 module_param_string
新增參數 root。
從掛載模組得到的參數需要傳給 http_server_daemon
,這裡需要增加傳入 kthread 的參數,從 kthread 參數結構體 http_server_param
增加 root_path
指標變數。
現在便可以通過掛載模組時指定 root 變數一個目錄路徑,在連線網站時可以得到該目錄下的內容。
MIME 定義在 RFC 6838 標準裡,用於表示檔案、數據的性質和格式。如果想要在網頁中顯示不同類型的檔案(如 .pdf, .jpg, .mov 類型)需要在 HTTP header 中定義它的 Content-Type
。
Content-Type 支援了 HTTP 可顯示的所有內容,可以參考 Common MIME types,裡面包含不同支援的檔案類型,以及對應的回應訊息。Content-Type 在使用時需要設定為 Content-Type : type/subtype
,type/subtype
可以參考 Common MIME types 內容進行設定。
這裡引用 Risheng1128 建立的常見 MIME 類型及回應內容,用一個 .h 建立 MIME 表格對應不同類型的檔案回應訊息。在傳送 HTTP header 標頭前會先確認這個檔案的類型,查找表格中對應的回應訊息,接著設定於 Content-Type
。完整的修改可以參考 Support MIME type with common type of files
查找 MIME 回應訊息程式如下,會先建立一個表格存入常見的標頭格式,用走訪每一個節點方式找到對應的 MIME 類型,回傳給 HTTP Header。
成果如下以可以顯示如 pdf, jpg 等常見檔案:
PDF :
JPG :
目前為止我們的 HTTP 傳輸實作是採用 keep-alive
的方式。這樣的用意是傳輸內容給 client 後不會立即關閉 socket 通道,會等待一段時間等待 client/server 傳輸新的內容。好處是避免每傳輸一筆資料就重新建立一個 socket,浪費許多資源和等待時間。
參考 Keep-Alive 如下圖,在 HTTP 1.1 的版本是預設使用 persistent connection ,允許在單一連線下處理多個請求。Persistent connection 模式下 client 會持續等待 server 傳來的資訊直到結束,而什麼時候結束便衍伸出了一個問題。Keep-alive 模式下使 client 難以確定一個回應結束和下一個回應開始的時間,為了解決這個問題需要使用 HTTP 1.1 引入的 Chunked transfer encoding
來給我們一個結束訊號,得到結束訊號後便可以將 socket 關閉。
參考 Transfer-Encoding: Chunked encoding,根據範例得到以下資訊:
\r\n
隔開0
的資料參考 fatcatorange 說明,和上述網站略微不同的是結束時需要設定為 0\r\n\r\n\r\n
才可以正確關閉 socket 連線。由此可以調整 Keep-alive 傳輸模式為 Chunked transfer encoding
,完整修改可以參考 Fix: client directory page keep waiting data,
成果如下圖,修改為 Chunked transfer encoding
方法後可以觀察到每次 client 請求連線,Client 端不會因為一直等待 server 傳輸內容而轉圈圈了。
Before (Content-length mode):
After (Chunked-transfer-encoding mode):
節錄部分 Ftrace 結果:
從左至右的每個資訊表示意思為:
0):CPU 編號。
us:右方函式執行時間
xxx():執行的函式
+, # 符號意思:+
表示右方函式執行的時間,#
表示函式執行時間異常長。
觀察上方 ftrace
列出的每一個函式執行時間,可以發現到每一次客戶端要求某個目錄或檔案內容時都需要執行 iterate_dir 函式讀取目錄或檔案,這會導致吞吐量下降。上方 ftrace 結果中可以看到,第 473 行在走訪目錄需要花費大量的時間,如果短時間請求同一個 request_url 便需要花費大量的時間在相同的資訊上。為了能夠避免重複讀取相同的檔案資訊,這里可以實作一個 cache 儲存之前讀過的目錄或檔案把它們放在記憶體。一段時間內,若有相同的 request_url 請求就可以直接傳送已經儲存在記憶體的內容,減少走訪目錄讀取檔案資訊或是內容的時間、進而增加吞吐量。
為了將走訪目錄得到的資訊儲存起來,這裡的想法是建立一個 cache_content
的結構體在每次走放目錄下的檔案資訊時用鏈結串列串連需要的內容,每一個內容都是要傳送給客戶端的資訊。用鏈結串鏈串連內容的好處是既使目錄下的內容非常多也不需要擔心需要一次分配多大的記憶體空間,如果目錄下的內容非常少也不需要擔心浪費過多的記憶體空間。接著用一個 hash_table 儲存 request_url
和 cache_content
資訊,計算 request_url
對應到 hash_table 的位置將 cache_content
儲存起來。在每次客戶端傳送請求時會先檢查 hash_table 請求的網址有沒有暫存的資訊,如果有就從 hash_table 中取出資訊傳送給客戶端,如果沒有才重新走放指定的目錄得到所有資訊然後傳送給客戶端。
在這裡先定義了我們需要的 content cache 結構體 cache_content
,走訪目錄的每一個節點時都會將資訊儲存在結構體的 buf 中並且將 list_head 加入鏈結串列中。
接著在 http_request
結構體中增加一個鏈結串列指標,儲存所有的目錄資訊。
接著便可以修改走訪目錄的程式,將 socket 要傳輸的內容、HTTP 標頭檔、HTML 標頭檔等資儲存在 cache_content
的 buf 裡接著加入至 http_request
的鏈結串列 cache_list
。所有的要傳送給客戶端的資訊都加入至鏈結串列後,便可以將 cache_list
鏈結串列加入至 hash table 中提供下一次請求時查詢。
加入 hash table 的實現如下,這裡定義了一個 2^8 大小的 hashtable,hash_insert
這個函式會將傳入的 URL 和 cache 內容的鏈結串列 cache_list
插入到雜湊表中,如果該 URL 已經存在,則不做插入並釋放已分配的記憶體。考量到多執行緒下可能會有多個函式插入 URL 到 hash table,所以在插入時使用 spinlock 來保護資料正確性,如果 URL 已經存在至 hash table 則退出。
首先定義了一個結構體 hash_content
用於儲存 hash table entry 的資料結構
head
:指向 cache 鏈結串列request
:客戶端請求的 URLnode
:指向 Linux 核心的 hash listtimer_node
:用於後續釋放資源接著下面程式是實作插入 URL 至 hash table。
函式整段的流程如下:
插入 URL 至 hash table 後每次訪問 URL 時只需要檢查 hash table 有無已存在的 cache,有的話則取出內容傳送至客戶端即可,不用再走訪 URL 指定的目錄。
至此實作了儲存走訪目錄的內容、URL 至 hash table,減少走訪目錄取得檔案資訊內容的時間。但使用 benchmark 測試時卻發現效果遠不如預期,改善後只有約 10 % 的進步。根據 ftrace 觀察原因,發現時間都卡在 socket 傳送資訊給 client 的 http_server_send
函式上。這裡的實作會走訪 cache_list 鏈結串列裡的內所有節點,將節點的 buf
內容依序傳給 client,所以假如有 n 個節點就需要執行 n 次的 http_server_send
函式,這樣的時間成本會太高。更好的方式是使用 scatter-gather I/O
方式用 iovec
結構體將多個 buffer 內容一次傳給客戶端,減少系統調用次數。
測試結果:
Before:
After :
使用 scatter-gather I/O
一次讀取多個非連續記憶體區域傳送至客戶端,這樣的方式減少了 socket 系統調用的開銷,增加了效能和吞吐量,提高了整體網絡伺服器的回應速度,增加約 85 % 的吞吐量。
scatter-gather I/O
使用方式如下:
kvec
陣列儲存多個非連續記憶體空間iov_base
指向非連續記憶體區域iov_len
設定非連續記憶體內容的長度http_server_send2
裡的 kernel_sendmsg
一次性將收集到的非連續記憶體傳送到 socket。完整的修改可以參考:Optimize Content Cache Transmission with Scatter-Gather I/O
改善後,可以看到使用 content cache 前後吞吐量的變化,並行數 = 1 時從 2135 上升至 10866,並行數 = 20 時從 24318 上升至 45093 次每秒,效果如預期。這是合理的原因是 benchmark 存取的是同一個網站且被存於快取記憶體,因此可以直接取出傳給客戶端。
Before content cahce :
After content cahce:
從下方 ftrace 數據可以觀察到,一次就可以將所有非連續記憶體內容傳給 client,減少了傳輸開銷:
參考資料
善用 Kernel Crypto API,至少涵蓋 gzip 壓縮。
目前為止,已經實作了傳輸指定目錄下的檔案資訊、顯示檔案內容等功能,點擊目錄的超連結後便能在網頁中顯示不同的檔案內容,例如 PDF, JPG 圖片等功能。而這些檔案往往資料非常大,需要傳送資訊也要更久,如果可以傳輸壓縮後的檔案至客戶端,便可以大幅減少資料傳送量,要顯示時於網頁時再從客戶端解壓縮即可。這裡就是打算實作 Linux 核心支援的壓縮 API ,將壓縮後的檔案內容傳送給客戶端,希望降低傳輸的資料量。
這裡預期實作常見 HTTP 的壓縮演算法 - gzip / deflate。從 cat /proc/crypto
可以看到電腦中有支援的壓縮方式,這裡我使用的是 deflate
演算法,接下來再使用 Linux kernel API 來指定壓縮演算法來壓縮指定的記憶體空間。
Linux Kernel 提供的 API 為 crypto_alloc_comp
、crypto_comp_compress
兩個函式來做資料壓縮,crypto_alloc_comp
用來指定使用的壓縮演算法名稱,crypto_comp_compress
用於實際壓縮數據,根據 crypto_alloc_comp
壓縮演算法來壓縮輸入的數據,並將結果存在指定的輸出記憶體空間中。
deflate
演算法crypto_alloc_comp
建立實體下面是建立的壓縮函式,接著需要把要傳送給 HTTP 的內容壓縮,並設定 HTTP 標頭檔的 CONTENT-ENCODING
,指定我們的壓縮演算法。
從網頁的開發者工具中觀察 benchmark 工具 htstress.c
可以看到 壓縮前後數據從 16436 縮小至 5597,有明顯的下降。
Before:
After :
根據 高效 Web 伺服器開發 - 實作考量點 提到以下考量點
當 Web 伺服器和客戶端網頁瀏覽器保持著一個長期連線的狀況下,遇到客戶端網路離線,伺服器該如何處理?
: 通訊協定無法立刻知悉,所以僅能透過伺服器引入 timer 逾時事件來克服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
前面實作的 HTTP 1.1 keep-alive ,每次客戶端與伺服器傳送完資料後會持續保持連線,允許在同一個 TCP 連接中傳送多個 HTTP 請求和回應,避免了每次請求都要重新建立連接的延遲。為了讓伺服器與客戶端在所有資料傳輸完成後可以自動關閉 TCP,這裡建立請求結束後可以引入 timer 逾時事件,關閉一段時間沒有傳輸的 TCP 連線。
目前的 kHTTPd 沒有使用 timer 來關閉閒置的連線,因此會導致 socket 資源被佔用。參考 sehttpd 裡 timer 的實作,使用 min heap 實做 timer 的優先佇列,讓查詢、新增、維護刪除的時間複查度維持在 O(log N)。
要在 kHTTPd 中的 socket 新增 timer 管理連線,可以將問題分成以下幾個小問題並且逐一解決:
將 socket 設定為 non-blocking 的原因在於,原本的實作中 socket 預設為 blocking,執行緒會停滯在函式 kernel_accept 等待新的 socket 請求,這樣沒有辦法不停的維護逾期連線的 timer、關閉逾期的 socket,因此將 socket 設定為 non-blocking。
根據 kernel_accept 文件,參數 flags 可以設定為 SOCK_NONBLOCK
,如果沒有請求連線會立即返回並回傳 -EAGAIN,接著我們就使用 handle_expired_timers
函式維護 socket 的 timers,刪除逾期的 timer。
首先,我們需要建立 timer 的優先佇列管理每個連線,為了讓查詢、維護、新增 timers 有效率這裡使用 min heap 實作的優先佇列。在 kHTTPd 中只會有一個 consumer 移除資料,亦即執行在背景的執行緒,而 producer 則是由多個處理連線的執行緒組成,因此歸納為 MPSC 的問題。
這裡先定義了兩個結構體 socket timer 和維護 timer 的優先佇列:
整個 priority queue 的流程如下所示
key
,增加逾期期限下面是新增連線 timer 的函式,其中考量到多執行緒的環境會有並行的 work items 插入 timer 至優先佇列,這裡使用 atomic 方式讀取目前的時間和數量,並在插入優先佇列的前後使用 spin_lock
保護佇列的正確性,避免 race condition。
刪除過期的 timer 方式如下,min heap 特點是愈早的過期時間在愈上面,最上層的 timer 就是最早的過期時間。只要取出過期時間最早的 timer 查看是否過期,過期則刪除和關閉過期的 timer 和 socket,直到 min timer 過期時間大於現在的時間,表示剩餘 timer 都沒有過期。
過期的 timer 需要執行一個 callback 函式關閉 socket,執行後會將 socket 通道關閉並且結束 work item 的生命週期。
下面影片展示 socket 超過 expired time 後被關閉:
RCU 是 Linux 核心的同步機制之一,允許多個 reader 在單一 writer 更新資料的同時,可以在不需要 lock 的前提,正確讀取資料。RCU 適用於頻繁的讀取 (即多個 reader)、但資料寫入 (即少量的 updater/writer) 卻不頻繁的情境,例如檔案系統,經常需要搜尋特定目錄,但對目錄的修改卻相對少,這就是 RCU 理想應用場景。
RCU 藉由 lock-free 程式設計滿足以下場景的同步需求:
以上資訊參考:RCU 同步機制
因為存放 content cache 的 hash table 通常讀取的次數是大於修改的次數,RCU (Read-Copy Update) lock free 同步機制可以提高讀取效率,使多個執行緒可以在有資料在更新時還能做讀取的動作,避免頻繁的 lock 導致效能下降。
Hash table 除了前面提到的可以新增 content cache,這裡也考量到釋放的問題。hash content 需要加入前面實作的 timer_node 並設定過期時間,當過期時間小於當前時間時就會被 handle_expired_timers
函式執行 callback 函式刪除 hash table 中的 cache。這裡 hash table 一樣是 MPSC 的問題,不一樣的地方是在刪除 hash content 的同時如果其還被執行緒存取著不會馬上被釋放,會等到沒有執行緒存取時才被釋放。
首先,在 hash table 存取時設定 rcu_read_lock
、rcu_read_unlock
,確保讀取操作在進行期間 hash table 不會被破壞。
接著在 timer callback 函式 remove_key_from_hashtable
,將節點從 hash table 中移除後設定 synchronize_rcu
等待所有執行緒離開被刪除節點,最後才釋放被移除的節點。
展示 hash table 釋放記憶體影片:
ftrace script 參考 Paintako :