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
:
module_param(port, ushort, S_IRUGO);
在 linux 裏面的原始定義在 include/linux/moduleparam.h
,
#define module_param(name, type, perm) \
module_param_named(name, name, type, perm)
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
,
#define module_param_named(name, value, type, perm) \
param_check_##type(name, &(value)); \
module_param_cb(name, ¶m_ops_##type, &value, perm); \
__MODULE_PARM_TYPE(name, #type)
再將上述展開可得
param_check_ushort(port, &(port));
module_param_cb(port, ¶m_ops_ushort, &port, perm);
__MODULE_PARM_TYPE(port, #ushort)
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
:
/* This is the fundamental function for registering boot/module
parameters. */
#define __module_param_call(prefix, name, ops, arg, perm, level, flags) \
/* Default value instead of permissions? */ \
static const char __param_str_##name[] = prefix #name; \
static struct kernel_param __moduleparam_const __param_##name \
__used \
__attribute__ ((unused,__section__ ("__param"),aligned(sizeof(void *)))) \
= { __param_str_##name, THIS_MODULE, ops, \
VERIFY_OCTAL_PERMISSIONS(perm), level, flags, { arg } }
參照 fibdrv 作業說明 裡頭的「Linux 核心模組掛載機制」一節,MODULE_XXX
系列的巨集在最後都會被轉變成
static const char 獨一無二的變數[] = "操作 = 參數"
並且透過 __attribute__
告訴編譯器,將這個變數放在 __param
段中,這部份我們可以透過 objdump -s khttpd.ko -j __param
來發現確實有名為 __param
的 section。
透過 strace
來追蹤 insert khttpd module 的過程:
$ sudo strace insmod khttpd.ko port=1999
可以看到以下 log:
finit_module(3, "port=1999", 0) = 0
可以參照 fibdrv 作業說明 裡頭的「Linux 核心模組掛載機制」一節,有描述如何載入 module 的過程。
一開始我們將 perm 設為 S_IRUGO
,即為 444,代表此 parameter 為 world-readable ,在 insert kernel module 過後,我們可以從 sysfs 內得知 module parameter port 的值,透過以下命令驗證確實可以得到 port 的值:
$ cat /sys/module/khttpd/parameters/port
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。
http_server = kthread_run(http_server_daemon, ¶m, KBUILD_MODNAME);
每當有新連線建立,再透過新的 kernel thread 負責處理與每個 client 間資料的交換。
int http_server_daemon(void *arg)
{
...
while (!kthread_should_stop()) {
int err = kernel_accept(param->listen_socket, &socket, 0);
...
worker = kthread_run(http_server_worker, socket, KBUILD_MODNAME);
...
}
return 0;
}
htstress.c
用到 epoll 系統呼叫,其作用為何?這樣的 HTTP 效能分析工具原理為何?Benchmarking 命令如下,執行總共 10,000 個 request,同時只有 1 個連線,開啟 4 個 thread (根據你的 cpu cores 開啟幾個 thread ),針對 http://localhost:8081/ 進行測試。
# run HTTP benchmarking
$ ./htstress -n 100000 -c 1 -t 4 http://localhost:8081/
先觀察 htstress.c 的行為,在 htstress.c
中開啟 threads,每個 thread 均執行 worker
。
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 。
/* run test */
for (int n = 0; n < num_threads - 1; ++n)
pthread_create(&useless_thread, 0, &worker, 0);
worker(0);
接著我們來看 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.
static void *worker(void *arg)
{
epoll_create();
init_conn();
...
}
init_conn()
:負責建立連線並新增 EPOLLOUT
到 epoll 的 interest list , EPOLLOUT
代表 file is available for write operations.
static void init_conn(int efd, struct econn *ec)
{
struct epoll_event evt = {
.events = EPOLLOUT,
.data.ptr = ec,
};
if (epoll_ctl(efd, EPOLL_CTL_ADD, ec->fd, &evt)) {
...
}
}
接著 epoll_wait
會等待發生在 epoll file descriptor 的 I/O event ,前面在 init_conn
我們已經註冊 EPOLLOUT
,所以只要 file descriptor 準備可以去寫入了,epoll_wait
就會將準備好的 file descriptors 返回。
返回後開始將資料寫入 socket ,並且將關注的 event type 修改為 EPOLLIN
。
static void *worker(void *arg)
{
for (;;) {
do {
nevts = epoll_wait(efd, evts, sizeof(evts) / sizeof(evts[0]), -1);
} while (!exit_i && nevts < 0 && errno == EINTR);
for (int n = 0; n < nevts; ++n) {
if (evts[n].events & EPOLLOUT) {
ret = send(ec->fd, outbuf + ec->offs, outbufsize - ec->offs, 0);
...
if (ec->offs == outbufsize) {
evts[n].events = EPOLLIN;
evts[n].data.ptr = ec;
if (epoll_ctl(efd, EPOLL_CTL_MOD, ec->fd, evts + n)) {
...
}
}
}
...
}
}
}
接著當 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()
來建立新連線,藉此不斷測試。
else if (evts[n].events & EPOLLIN) {
for (;;) {
ret = recv(ec->fd, inbuf, sizeof(inbuf), 0);
if (ret <= 0)
break;
if (ec->offs <= 9 && ec->offs + ret > 10) {
char c = inbuf[9 - ec->offs];
if (c == '4' || c == '5')
ec->flags |= BAD_REQUEST;
}
...
}
if (!ret)
{
int m = atomic_fetch_add(&num_requests, 1);
if (max_requests && (m + 1 > (int) max_requests))
atomic_fetch_sub(&num_requests, 1);
else if (ec->flags & BAD_REQUEST)
atomic_fetch_add(&bad_requests, 1);
else
atomic_fetch_add(&good_requests, 1);
if (max_requests && (m + 1 >= (int) max_requests)) {
end_time();
return NULL;
}
if (ticks && m % ticks == 0)
printf("%d requests\n", m);
init_conn(efd, ec);
}
}
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。
int http_server_daemon(void *arg)
{
while (!kthread_should_stop()) {
int err = kernel_accept(param->listen_socket, &socket, 0);
...
worker = kthread_run(http_server_worker, socket, KBUILD_MODNAME);
...
}
return 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 還存在,因此應該在移除前做檢查,修正為以下程式碼:
static void __exit khttpd_exit(void)
{
if (http_server)
kthread_stop(http_server);
}
接著探討在 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 之間是已經有所連結,那我們前面的兩個方向就有機會達成。
struct task_struct {
...
/* Recipient of SIGCHLD, wait4() reports: */
struct task_struct __rcu *parent;
/*
* Children/sibling form the list of natural children:
*/
struct list_head children;
/* The signal sent when the parent dies: */
int pdeath_signal;
...
/* PID/PID hash table linkage. */
struct pid *thread_pid;
struct hlist_node pid_links[PIDTYPE_MAX];
struct list_head thread_group;
...
}
在 trace 過程中看到 group_send_sig_info()
或是 exit_notify_info()
等可以對 process group 或是 relatives 傳送 singal 的 function,或許可以用來處理移除 orphan threads.
在觀察上面兩個函式時,有提到 thread group 這個概念,開始研究相關資料。
在 include/linux/pid.h 中有以下 enum pid_type
定義 :
enum pid_type
{
PIDTYPE_PID,
PIDTYPE_TGID,
PIDTYPE_PGID,
PIDTYPE_SID,
PIDTYPE_MAX,
};
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
的關係。
USER VIEW
<-- PID 43 --> <----------------- PID 42 ----------------->
+---------+
| process |
_| pid=42 |_
_/ | tgid=42 | \_ (new thread) _
_ (fork) _/ +---------+ \
/ +---------+
+---------+ | process |
| process | | pid=44 |
| pid=43 | | tgid=42 |
| tgid=43 | +---------+
+---------+
<-- PID 43 --> <--------- PID 42 --------> <--- PID 44 --->
KERNEL VIEW
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。
[220765.004939] http_server_worker gets the signal PID: 15635, TGID: 15635
[220765.004949] khttpd: requested_url = /
[220765.039951] khttpd: requested_url = /favicon.ico
[220788.393210] http_server_daemon PID: 15431, TGID: 15431
進一步透過 ps
來查看更多詳細資訊:
$ ps -ejf | { head -1; grep khttpd;}
UID PID PPID PGID SID C STIME TTY TIME CMD
root 28369 2 0 0 0 08:20 ? 00:00:00 [khttpd]
root 29477 2 0 0 0 08:28 ? 00:00:00 [khttpd]
這裡看到 PPID
, PPID
代表 parent process ID , 是由 parent process 啟動該 process 的。
去查 ppid = 2 是誰,發現他是 kthreaadd
。
$ ps 2
PID TTY STAT TIME COMMAND
2 ? S 0:02 [kthreadd]
再透過 $ 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,在本核心模組用來做「善後」操作,不過這樣的使用有機會縮減,請思考如何進行。
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 有緊密關聯呢?請繼續思考
[實驗一]
設計一個簡單實驗 (實驗程式碼連結),不使用 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
原始程式碼,
/**
* kthread_stop - stop a thread created by kthread_create().
* @k: thread created by kthread_create().
*
* Sets kthread_should_stop() for @k to return true, wakes it, and
* waits for it to exit. This can also be called after kthread_create()
* instead of calling wake_up_process(): the thread will exit without
* calling threadfn().
*
* If threadfn() may call do_exit() itself, the caller must ensure
* task_struct can't go away.
*
* Returns the result of threadfn(), or %-EINTR if wake_up_process()
* was never called.
*/
int kthread_stop(struct task_struct *k)
{
...
struct kthread *kthread;
// 與 put_task_struct() 相對應,對 atomic variable 進行操作
get_task_struct(k);
/* 從 task_struct 來拿到 kthread */
/* 可以對照於 set_kthread_struct() 來看 */
kthread = to_kthread(k);
/* set tkthreas_should_stop */
set_bit(KTHREAD_SHOULD_STOP, &kthread->flags);
/* wake up thread whose state is TASK_PARKED and wait it for return */
kthread_unpark(k);
/* Wake up a specific process and move it to the set of runnable process */
wake_up_process(k);
/* 等待 kthread 結束 */
wait_for_completion(&kthread->exited);
ret = k->exit_code;
/* 最終使用 free_task 來正確釋放 kthread */
put_task_struct(k);
...
return ret;
}
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 建立完成後才返回。
struct task_struct *__kthread_create_on_node( ... )
{
struct kthread_create_info *create = ...
wake_up_process(kthreadd_task);
if (unlikely(wait_for_completion_killable(&done))) {
...
wait_for_completion(&done);
}
}
kthreadd_task
是一個執行 kthreadd
函式的 kernel thread , kthreadd
專門負責為整個 linux 建立 kernel thread 。
值得注意的是,在 create_kthread()
中,當創立新 kthread 時,預設是不會關注任何 signals ,這也是為什麼在 kHTTPd
中會使用 allow_signal()
來關注特定訊號的原因。
static void create_kthread(struct kthread_create_info *create)
{
/* We want our own signal handler (we take no signals by default). */
pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES | SIGCHLD);
...
}
回到前面的問題,為什麼拿掉 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()
來確認是否有收到任何訊號。
/*
* Wait for an incoming connection, avoid race conditions. This must be called
* with the socket locked.
*/
static int inet_csk_wait_for_connect(struct sock *sk, long timeo)
{
...
for (;;) {
prepare_to_wait_exclusive(sk_sleep(sk), &wait,
TASK_INTERRUPTIBLE);
release_sock(sk);
if (reqsk_queue_empty(&icsk->icsk_accept_queue))
timeo = schedule_timeout(timeo);
sched_annotate_sleep();
lock_sock(sk);
err = 0;
if (!reqsk_queue_empty(&icsk->icsk_accept_queue))
break;
err = -EINVAL;
if (sk->sk_state != TCP_LISTEN)
break;
err = sock_intr_errno(timeo);
if (signal_pending(current))
break;
err = -EAGAIN;
if (!timeo)
break;
}
return err;
}
到這邊也能夠釐清我前面的問題:
原本覺得在
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:
./htstress -n 100000 -c 1 -t 8 http://localhost:8081/
原始作法量測結果:
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