contributed by < haogroot
>
linux2020
Kernel version: 5.3.0-40-generic
OS: Ubuntu 19.10
CPU model: Intel® Core™ i7-8565U CPU @ 1.80GHz
$ sudo insmod khttpd.ko port=1999
這命令是如何讓 port=1999
傳遞到核心,作為核心模組初始化的參數呢?在 khttpd_main.c 中,用到巨集 module_param
:
在 linux 裏面的原始定義在 include/linux/moduleparam.h
,
name
為 kernel module parameter name 。type
為 parameter 的 data type 。perm
則代表在 sysfs 中的 visibility (0444 代表 world-readable, 0644 代表 root-writable)。因此可以理解為建立了一個 ushort
type 的變數 port
做為 kernel module parameter。
若將上述巨集展開可得 module_param_named(port, port, ushort, S_IRUGO)
,以下摘自include/linux/moduleparam.h
,
再將上述展開可得
param_check_ushort
是在 compile-time 時候做 type-checking,
module_param_cb
是一個 callback function,繼續將其展開可得 __module_param_call(MODULE_PARAM_PREFIX, port, ¶m_ops_ushort, &port, perm)
,發現他用於註冊 module parameter。
以下摘自 include/linux/moduleparam.h
:
參照 fibdrv 作業說明 裡頭的「Linux 核心模組掛載機制」一節,MODULE_XXX
系列的巨集在最後都會被轉變成
並且透過 __attribute__
告訴編譯器,將這個變數放在 __param
段中,這部份我們可以透過 objdump -s khttpd.ko -j __param
來發現確實有名為 __param
的 section。
透過 strace
來追蹤 insert khttpd module 的過程:
可以看到以下 log:
可以參照 fibdrv 作業說明 裡頭的「Linux 核心模組掛載機制」一節,有描述如何載入 module 的過程。
一開始我們將 perm 設為 S_IRUGO
,即為 444,代表此 parameter 為 world-readable ,在 insert kernel module 過後,我們可以從 sysfs 內得知 module parameter port 的值,透過以下命令驗證確實可以得到 port 的值:
Reference: CS:APP ch11 slides
CS:APP 第 11 章中範例中 open_listenfd()
與 kHTTPd 中 open_listen_socket()
都是處理一樣的任務,最終都會產生一個可以監聽是否有新連線要求的 file descriptor。
但在 CS:APP 第 11 章中提供的 server 範例中, server 每次只能處理一個 client ,書中稱之為 Iterative Server,而 kHTTPd 則能夠同時處理多個 client 連線,這樣的 server 稱之為 Concurrent Server.
kHTTPd 透過 kernel thread 來負責監聽是否有來自 client 的連接需求,使得 kHTTPd 能夠同時處理多個 client。
每當有新連線建立,再透過新的 kernel thread 負責處理與每個 client 間資料的交換。
htstress.c
用到 epoll 系統呼叫,其作用為何?這樣的 HTTP 效能分析工具原理為何?Benchmarking 命令如下,執行總共 10,000 個 request,同時只有 1 個連線,開啟 4 個 thread (根據你的 cpu cores 開啟幾個 thread ),針對 http://localhost:8081/ 進行測試。
先觀察 htstress.c 的行為,在 htstress.c
中開啟 threads,每個 thread 均執行 worker
。
:question: 為什麼要執行 worker(0)
?
若是直接透過
pthread_create
新建所有需要的 thread 時,會發生 main thread 繼續往下執行,並沒有等待所有的 thread 執行結束,這樣的狀況我們也無法統計結果。 若是透過pthread_exit()
與pthread_join()
來等待所有 thread 執行完畢,這樣狀況下 main thread 也會被 block 住造成資源的浪費,因此在這裡新建了 ( num_threads-1 ) 個 thread,同時也讓 main thread 執行worker(0)
,使得所有 thread 都充份用來做 benchmarking 。
接著我們來看 static void *worker(void *arg)
,
epoll_create
: 產生 epoll 專用的 file descriptor,裏面所帶參數須大於 0,自從 Linux 2.6.8 後,裏面參數已不代表任何意義。
epoll_wait
: wait for an I/O event on an epoll file descriptor.
init_conn()
:負責建立連線並新增 EPOLLOUT
到 epoll 的 interest list , EPOLLOUT
代表 file is available for write operations.
接著 epoll_wait
會等待發生在 epoll file descriptor 的 I/O event ,前面在 init_conn
我們已經註冊 EPOLLOUT
,所以只要 file descriptor 準備可以去寫入了,epoll_wait
就會將準備好的 file descriptors 返回。
返回後開始將資料寫入 socket ,並且將關注的 event type 修改為 EPOLLIN
。
接著當 socket 有資料可以讀時, epoll_wait
返回 file descriptor ,接著即可從中讀取資料,透過判斷式 if (c == '4' || c == '5')
來確認是否是 bad request ,在 HTTP response code 定義中, 400 - 499 代表 client errors , 500 - 599 代表 server errors 。
當 recv
返回 0 時,代表 socket 被關閉或是沒有資料可讀時,會關閉 file descriptor,接著透過 atomic_fetch_sub
或 atomic_fetch_add
來操作 multi-thread 共用的變數 num_requests
, good_requests
和 bad_requests
。
如果沒有達到 max_request , 就會再次使用 init_conn()
來建立新連線,藉此不斷測試。
epoll
作用為何在 Linux 設計哲學中,所有的東西都是 file ,所以 socket 也是 file ,如果我們對 socket 進行讀寫,就相當於操作 I/O 。
今天如果要進行 http benchmarking ,在 user space 的 process 是無法直接操作 I/O ,必須透過 system call 要求 kernel 協助 I/O 操作 (在 worker()
中即使用 send()
和 recv()
來對 socket 讀寫)。
而 I/O 操作又分為以下兩種,我們以讀取資料為例:
如果今天是 non-blocking I/O ,一但沒有資料,變成需要不斷去操作 I/O 直到資料準備好,相當浪費 cpu 時間,這作法顯然不實際;
而若是使用 blocking I/O ,雖然我們可以透過 multi-thread 方式來應付每個 I/O ,但這樣會造成資源的浪費,與更多 context-switching 等影響。
因此 linux kernel 提供了 I/O multiplexing 機制來解決這種問題,提供方法讓單一 process 可以同時監控多個 file descriptor ,一旦某個 file descriptor 準備好,就通知 process 來進行讀寫操作, Linux 提供了 3 種這樣的機制,分別為 poll
, select
和 epoll
。
epoll v.s. select / poll
在 kernel/thread.c 中,提到兩種狀況下 kernel thread 會終止
do_exit()
kthread_should_stop()
return true (若 kthread_stop()
被呼叫,kthread_should_stop()
就會回傳 true)觀察 kHTTPd server 的程式碼,透過 khttpd_init()
指定建立一個 kernel thread 執行 http_server_daemon()
。
http_server_daemon()
負責接收來自連線要求,並且為新連線建立 kernel thread 執行 http_server_worker()
,khttp_server_daemon()
會不斷確認 kthread_should_stop()
是否有 return true ,如果有的話這個 thread 也會終止並返回 0。
而在 khttpd_exit()
時就會呼叫 kthread_stop()
,因此我們可以確認在 module 被移除時, 在 khttpd_init()
所建立的 kernel thread 會被正確停止 ,但這邊有一個缺失需要修正,在 kernel/kthread.c 原始碼 裡頭 kthread_stop()
的註解提到:
If threadfn() may call do_exit() itself, the caller must ensure task_struct can't go away.
如果要 stop thread 之前,必須確認該 thread 還存在,因此應該在移除前做檢查,修正為以下程式碼:
接著探討在 http_server_daemon
中為每個新連線建立新的 kernel thread 並執行 http_server_worker
,在後者內同樣利用 kthread_should_stop
來檢查,但是在程式碼卻沒有地方呼叫 kthread_stop()
來終止,導致為每個新連線建立的 kernel thread 並沒有被正確釋放。
如果 kHTTPd module 都已經被移除,那他所建立的 thread 也應該被移除,在 http_server_daemon()
回傳之前應該先將他所建立的 kernel threads 都釋放。
對於正確釋放 kernel thread 有兩個想法可以觀察:
http_server_daemon()
所建立的 thread 可以主動確認 http_server_daemon()
是否已經結束來決定是否該終止自己。http_server_daemon()
準備回傳,可以傳送訊號給所有由他所建立的 child thread ,通知他們需要結束自己。從下方 struct task_struct
的原始碼定義中看到有相關成員可能是我們可以利用的,如果 parent thread 和 child thread 之間是已經有所連結,那我們前面的兩個方向就有機會達成。
在 trace 過程中看到 group_send_sig_info()
或是 exit_notify_info()
等可以對 process group 或是 relatives 傳送 singal 的 function,或許可以用來處理移除 orphan threads.
在觀察上面兩個函式時,有提到 thread group 這個概念,開始研究相關資料。
在 include/linux/pid.h 中有以下 enum pid_type
定義 :
PID
( process identifier ) 。TGID
都等於 thread group 的主 thread 的 PID
,這樣就可以把 signal 發給指定 PID
內所有的 thread ,而每個 thread 則擁有自己專屬的 PID
,這樣 scheduler 才能夠將各個 thread 獨立排程 。PGID
代表 process group 的 id ,每個 process 都屬於一個 process group ,PGID
等同 process group leader 的 PID
,通常 process group 的第一個成員就是 process group leader 。SID
是 session id 。以下這張圖可以幫我們更好理解 PID
與 TGID
的關係。
Reference to https://stackoverflow.com/a/9306150/4545634
根據以上,我們朝 TGID
來著手,在 http_server_daemon
要回傳之前,對整個 thread group 發送訊號來告知他們該結束。
我們先透過這些 thread 的 pid
與 tgid
來確認他們是否屬於同一 thread group,透過 printk
, task_pid_nr()
與 task_tgid_nr()
來印出 thread 的 pid
與 tgid
。
結果如下,可以得知他們不屬於同一個 thread group。
進一步透過 ps
來查看更多詳細資訊:
這裡看到 PPID
, PPID
代表 parent process ID , 是由 parent process 啟動該 process 的。
去查 ppid = 2 是誰,發現他是 kthreaadd
。
再透過 $ ps axjf
可以發現原來許多 process 都是由 kthreadd
所建立的。前面原本預期在 http_server_daemon()
內執行 kthread_run
來建立 kernel thread 並執行 http_server_worker()
,所以他們之間應該會是 parent 與 child process 的關係,但最後發現他們的 parent process 都是 kthreadd
。
根據 linux kernel development 3rd edition 中 kernel Threads 章節,
Kernel threads are created on system boot by other kernel threads. Indeed, a kernel thread can be created only by another kernel thread.The kernel handles this automatically by forking all new kernel threads off of the kthreadd kernel process.
在這個章節我們使用 kthread_run()
最終會使用 __kthread_create_on_node()
,在後者中就會喚醒 kthreadd
來幫忙建立 kernel thread ,這也是為什麼我們會看到所有 kernel thread 的 parent 都是 ppid
為 2 的 kthreadd
。
allow_signal(int sig)
: 讓 kernel thread 知道這個 signal 會被處理,不要 drop 這個 signal.
send_sig
是 Linux v0.11 就存在的 kernel API,在本核心模組用來做「善後」操作,不過這樣的使用有機會縮減,請思考如何進行。
:notes: jserv
:question:
原本覺得在 khttp_exit()
可以拿掉 send_sig()
,因為透過使用 kthread_stop
可以使得 while (!kthread_should_stop())
離開,並讓 http_server_daemon
回傳,但沒想到拿掉 send_sig
後, http_server_daemon
卻無法結束。
這個觀察很重要。翻閱 kthread_stopped
原始程式碼,你會注意到 dequeue_signal
的使用,而在 kthreadd 也涉及 signal 的初始化,那為何核心內部的執行緒和 signal 有緊密關聯呢?請繼續思考
:notes: jserv
[實驗一]
設計一個簡單實驗 (實驗程式碼連結),不使用 send_sig()
,可以發現我同樣在 init 時期透過 kthread_run()
來建立 kthread ,並在模組被移除時,僅透過 kthread_stop()
來終止 kthread ,卻沒有碰到如同 kHTTPd
那樣卡住的情況。有兩個差別
http_server_daemon
中有使用 allow_signal()
與 signal_pending()
allow_signal
與 signal_pending
也不會導致 thread 卡住http_server_daemon
中又建立新的 kthread 。[實驗二]
另外一個實驗 (實驗程式碼連結)我在 kernel thread 中再去建立新的 kthread ,同樣沒有碰到如同 kHTTPd
一樣卡住無法結束情況,但是碰到了 BUG: unable to handle page fault for address
。接下來翻閱程式碼來尋找方向,這應該是跟 thread 沒有被正確釋放有關。
kthread_stop()
與 kthread_create()
kthread_stop
翻閱 kthread_stop
原始程式碼,
wait_for_completion()
內,他會 block 直到有人使用 complete()
。 Reference: Completions - “wait for completion” barrier APIskthread_create()
kthread_create()
最終會使用 __kthread_create_on_node()
,在後者,會將需要新建的 kthread 內容放進 struct kthtrad_create_info
內,並喚醒 kthreadd_task
,接著會等到 kthreadd_task
將 kthread 建立完成後才返回。
kthreadd_task
是一個執行 kthreadd
函式的 kernel thread , kthreadd
專門負責為整個 linux 建立 kernel thread 。
值得注意的是,在 create_kthread()
中,當創立新 kthread 時,預設是不會關注任何 signals ,這也是為什麼在 kHTTPd
中會使用 allow_signal()
來關注特定訊號的原因。
回到前面的問題,為什麼拿掉 send_sig
會導致 http_server_daemon()
卡住呢?
再回頭觀察 kHTTPd
的程式碼,透過 pr_info()
來觀察,可以發現當 http_server_daemon
啟動後,他停在 kernel_accept()
。
在 kernel_accept
最終會呼叫 inet_csk_accept()
,在後者內由於 socket 並不是 non-blocking,因此會再使用 inet_csk_wait_for_connect()
來進入睡眠來等待新連線,觀察下方程式碼,可以發現有四種狀況能夠讓此函數返回,也就代表 kernel_accept()
不會是卡住的狀況。其中一種狀況,就是透過 signal_pending()
來確認是否有收到任何訊號。
到這邊也能夠釐清我前面的問題:
原本覺得在
khttp_exit()
可以拿掉send_sig()
,因為透過使用kthread_stop
可以使得while (!kthread_should_stop())
離開,並讓http_server_daemon
回傳,但沒想到拿掉send_sig
後,http_server_daemon
卻無法結束。
透過 send_sig()
送出的訊號讓 http_server_daemon()
中的 kernel_accept()
能夠順利回傳。 另外有一點值得注意的是, kthreadd
透過 create_kthread()
建立的 kthread ,預設狀況下是會忽略所有的訊號的,所以若在 http_server_daemon
中沒有透過 allow_signal()
來告知 kthread 要去處理特定訊號的話, send_sig()
同樣不會成功讓 kernel_aceept()
回傳的。
程式碼 https://github.com/haogroot/khttpd/commit/18d649dcb94c8a47865065e56ad76c322e90ff8b
benchmarking command:
原始作法量測結果:
requests: 100000
good requests: 100000 [100%]
bad requests: 0 [0%]
socker errors: 0 [0%]
seconds: 2.249
requests/sec: 44473.461
以 workqueue 改寫:
requests: 100000
good requests: 100000 [100%]
bad requests: 0 [0%]
socker errors: 0 [0%]
seconds: 1.031
requests/sec: 97031.891