# Linux 核心專題: 高效網頁伺服器開發 > 執行人: JoshuaLee0321 > [GitHub](https://github.com/JoshuaLee0321/cserv) > [專題講解影片](https://youtu.be/PMLyoVDjCrs) > [期末自我評量](https://wiki.csie.ncku.edu.tw/User/JoshuaLee0321) :::success :question: 提問清單 * ? ::: ## 任務簡述 在 [ktcp](https://hackmd.io/@sysprog/linux2023-ktcp) 提到,[cserv](https://github.com/sysprog21/cserv) 展現 event-driven, non-blocking I/O Multiplexing (主要是 epoll), shared memory, processor affinity, coroutine, context switch, UNIX signal, dynamic linking, circular buffer, hash table, red-black tree, atomic operations 等議題的實際應用。本任務預計參閱〈[Inside NGINX: How We Designed for Performance & Scale](https://www.nginx.com/blog/inside-nginx-how-we-designed-for-performance-scale/)〉,對比 [NGINX](https://nginx.org/en/) 和 [cserv](https://github.com/sysprog21/cserv),歸納架構設計的異同,並著手改進後者。 參考資訊: * [ktcp: cserv 高效網頁伺服器](https://hackmd.io/@sysprog/linux2023-ktcp-d) * [The Architecture of Open Source Applications: nginx](https://aosabook.org/en/v2/nginx.html) * [Inside NGINX: How We Designed for Performance & Scale](https://www.nginx.com/blog/inside-nginx-how-we-designed-for-performance-scale/) ### 實驗環境 > `$ lscpu` :::spoiler ```bash Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Address sizes: 43 bits physical, 48 bits virtual Byte Order: Little Endian CPU(s): 12 On-line CPU(s) list: 0-11 Vendor ID: AuthenticAMD Model name: AMD Ryzen 5 2600X Six-Core Processor CPU family: 23 Model: 8 Thread(s) per core: 2 Core(s) per socket: 6 Socket(s): 1 Stepping: 2 Frequency boost: enabled CPU max MHz: 3600.0000 CPU min MHz: 2200.0000 BogoMIPS: 7199.09 ... Caches (sum of all): L1d: 192 KiB (6 instances) L1i: 384 KiB (6 instances) L2: 3 MiB (6 instances) L3: 16 MiB (2 instances) NUMA: NUMA node(s): 1 ``` ::: ## 架構設計描述和檢討 參閱〈[Inside NGINX: How We Designed for Performance & Scale](https://www.nginx.com/blog/inside-nginx-how-we-designed-for-performance-scale/)〉,對比 [NGINX](https://nginx.org/en/) 和 [cserv](https://github.com/sysprog21/cserv),歸納架構設計的異同,逐一探討 cserv 相對於 NGINX 在高效能表現尚欠缺的關鍵設計,例如 [AIO](https://www.nginx.com/blog/thread-pools-boost-performance-9x/)。 ### 主從式架構 #### NGINX > NGINX leads the pack in web performance, and it’s all due to the way the software is designed. Whereas many web servers and application servers use a simple threaded or process‑based architecture, NGINX stands out with a sophisticated event‑driven architecture that enables it to scale to hundreds of thousands of concurrent connections on modern hardware. NGINX 高效的原因歸於其架構,NGINX 使用 [master-slave](https://en.wikipedia.org/wiki/Master/slave_(technology)) 的架構,讓 不同的 workers 常駐在 cpu 上,同時讓另外一個 master 去處理其他 worker 需要的高權限操作 (e.g., Fetch file, transfer file, print, etc.)。 根據〈[Inside NGINX: How We Designed for Performance & Scale](https://www.nginx.com/blog/inside-nginx-how-we-designed-for-performance-scale/)〉,Nginx 的 master 專門執行一些特權操作如讀取設定檔案,bind 等等,worker 則負責把所有的工作完成,例如讀寫以及處理連線,而 worker 之所以會有效率的處理連線,也可以參照 [NGINX 開發日誌](https://www.nginx.com/blog/thread-pools-boost-performance-9x/)中以送貨員以及排隊等候的機制去看待這個問題 ![](https://hackmd.io/_uploads/ByZBMI7d2.png) 假設有一堆顧客,他們需要取貨,而有些貨物(1-3 號箱)盡在眼前,同時也很多貨物存在遙遠的倉庫中,此時注意這邊這位工作者(worker) 只會盲目的遵從目前顧客的要求,並且服務目前的顧客,假設有一個顧客要求比較遠的 4 號箱子,如此一來造成了護衛效應,擋住了後面也許有機會服務更快的顧客。 讓我們再想想另外一個情形,當這個 worker 學乖了,他利用一個送貨區,當每次有太遠的貨物需要取貨時,他並不會自己慢慢跑過去,而是一通電話叫送貨人員送來。如此一來就可以更快的服務到其他顧客,與此同時目前顧客也不會因服務其他人而導致他的任務沒有進展。 ![](https://hackmd.io/_uploads/BJGXXUQdn.png) 而 NGINX 的 worker 也一樣,當一個進程可能需要很長的操作時間時,他不會自己處理,而是將這個任務放進 TaskQueue 中,而任何空閒的 Thread 都可以將此 queue 中的任務取出來執行。 TasksQueue 以及 Thread Pool 在這邊扮演的腳色就是送貨員以及送貨車,Taskqueue 會接收由 worker process 來的工作,處理完之後再把結果送回 worker process 中。 ![](https://hackmd.io/_uploads/SkgcLwlDn.png) 為驗證這個事實,可以先使用以下命令 ```bash ~$ sudo apt-get install nginx # 下載 NGINX ~$ ps -ef --forest |grep nginx # 查詢有關 nginx 的 process # output: # root 920 1 0 五 30 ? 00:00:00 nginx: master process /usr/sbin/nginx -g daemon on; master_process on; # www-data 922 920 0 五 30 ? 00:00:00 \_ nginx: worker process # www-data 923 920 0 五 30 ? 00:00:40 \_ nginx: worker process # www-data 925 920 0 五 30 ? 00:00:07 \_ nginx: worker process # www-data 926 920 0 五 30 ? 00:00:29 \_ nginx: worker process # www-data 928 920 0 五 30 ? 00:00:00 \_ nginx: worker process # www-data 929 920 0 五 30 ? 00:00:00 \_ nginx: worker process # www-data 931 920 0 五 30 ? 00:00:00 \_ nginx: worker process # www-data 932 920 0 五 30 ? 00:00:00 \_ nginx: worker process # www-data 933 920 0 五 30 ? 00:00:00 \_ nginx: worker process # www-data 934 920 0 五 30 ? 00:00:00 \_ nginx: worker process # www-data 935 920 0 五 30 ? 00:00:00 \_ nginx: worker process # www-data 936 920 0 五 30 ? 00:00:00 \_ nginx: worker process ``` 數一下的確可以發現 `nginx: worker process` 存在於 `nginx: master process` 底下。 #### cserv 其實 cserv 也實作同樣的主從式架構,但與 NGINX 相異處在於,cserv 以 coroutine 做到 cooperative multitasking 以達到相對高效的結果。 :::warning cserv 亦有 worker > 根據 ```c // src/process.c void process_init() { ... for (int i = 0; i < g_worker_process; i++) { struct process *p = &worker[i]; p->pid = INVALID_PID; p->cpuid = i % get_ncpu(); } ... } ``` > 當中可以看到此 worker 一開始被分配到不同的 cpu 底下,由此可以得到 cserv 中也有 worker。 ::: 對於 cserv 來說,當一個子程序要做 I/O 這類型長時間的阻斷式程序,coroutine 會暫停程序,而不會阻塞其他行程。 ## 修正記憶體操作的錯誤 Goal: 以 [Cppcheck](https://cppcheck.sourceforge.io/), [Address Sanitizer](https://github.com/google/sanitizers/wiki/AddressSanitizer), [Valgrind](https://valgrind.org/) 等工具找出現行 cserv 程式碼的記憶體操作缺失,著手改進並提交 pull request。 程式碼運用到 coroutine,但沒正確呼叫 `coro_stack_free` 函式。 ### 使用 `Address Sanitizer` 尋找記憶體缺失 > 使用 Address Sanitizer 的方法非常簡單,只需要在編譯的時候增加 `-fsanitize=address -static-libasan` 的選項即可 ```diff - CFLAGS += -O2 -g + CFLAGS += -O2 -g -fsanitize=address -static-libasan - LDFLAGS= -ldl + LDFLAGS = -ldl -fsanitize=address -static-libasan ``` 接下來就可以觀察具體來說到底發生甚麼樣的缺失。 ----- 根據以上改動執行 `./cserv start` 後可以看到非常多錯誤訊息 ```bash= ==64560==ERROR: AddressSanitizer: SEGV on unknown address (pc 0x55ff33de0c2e bp 0x55ff346dd250 sp 0x7f63b642fee0 T0) ==64560==The signal is caused by a READ memory access. ==64560==Hint: this fault was caused by a dereference of a high value address (see register values below). Dissassemble the provided pc to learn which register was used. #0 0x55ff33de0c2e in add_to_timer_node src/coro/sched.c:126 #1 0x55ff33de0c2e in move_to_inactive_tree src/coro/sched.c:161 #2 0x55ff33de0c2e in schedule_timeout src/coro/sched.c:347 #3 0x55ff33de37c4 in worker_accept_cycle src/process.c:170 #4 0x55ff33de0331 in coro_routine_proxy src/coro/sched.c:323 #5 0x55ff33de00f0 (/home/joshua/linux2023/cserv/cserv+0xef0f0) AddressSanitizer can not provide additional info. SUMMARY: AddressSanitizer: SEGV src/coro/sched.c:126 in add_to_timer_node ``` 根據以上的輸出,在 3 行回報了這錯誤的原因,並在第 12 行告訴我們實際上出問題的位置即存在 `sched.c` 的 `add_to_timer_node` 中 ### 觀察`add_to_timer_node` ```c= static void add_to_timer_node(struct timer_node *tm_node, struct coroutine *coro) { struct rb_node **newer = &tm_node->root.rb_node, *parent = NULL; while (*newer) { struct coroutine *each = container_of(*newer, struct coroutine, node); /* 整個 each 都有問題 */ int result = coro->coro_id - each->coro_id; parent = *newer; if (result < 0) { newer = &(*newer)->rb_left; } else { newer = &(*newer)->rb_right; } } rb_link_node(&coro->node, parent, newer); rb_insert_color(&coro->node, &tm_node->root); } ``` 反覆試驗之後發現問題出現在讀取紅黑樹時讀取到錯誤的位置,以上程式碼在第 7 行使用 `container_of` 取出 coroutine 在第 8 行的地方要讀取出 `each` 時,ASan 就會報錯。原本認為與 race condition 有關連,於是在 coroutine 中加入了 spin_lock,但同樣的錯誤還是會出現 ```diff + spin_lock(&coro->lock); int result = coro->coro_id - each->coro_id; + spin_unlock(&coro->lock); ``` ~~也許這個問題也許出在紅黑樹的存放區塊,`memcache.[c|h]` 中 (但目前不知道要怎麼驗證)~~ :::success 原本的猜想: * 還記得以前學 MIPS 的時候,jump 指令為 [pseudo addressing mode](http://allyouneedtoknowaboutcomputer.blogspot.com/2012/12/mips-addressing-mode.html),也就是會將 memory block 限縮到 memory 位址實際上最後 26 位,而無法跳脫出 memory block。 > 這個猜想在使用 gdb 之後發現是不可能的事情 ```bash 0x000055555555aba7 in add_to_timer_node (coro=0x5555555f5e90, tm_node=0x5555555ebc40) at src/coro/sched.c:122 ``` 在這邊可以發現位址都存在同一個 memory block 中。 ::: ### coroutine 記憶體缺失 為了驗證 coroutine 存在記憶體缺失,勢必需要撰寫程式來驗證 `switch.h / sched.h`這個檔案中存在記憶體缺失。 既然目標很明確,接下來只要實驗看看究竟是哪一個 function 沒有正確的釋放記憶體。 第一件事情就是必須把所有功能改為其他程式可以存取到,並且把 sched 搬到要測試的主程式中 改為所有編譯單元 (compilation unit) 都可存取到符號 ```diff // src/coro/sched.c - static inline <type> SomeFunction(); + <type> SomeFunction(); // src/coro/sched.h + all functions ``` 自己撰寫了測試程式之後,基本上可以把錯誤限縮到 `move_to_inactive_tree` 當中 ```c int main() { schedule_init(10, 1024); event_loop_init(1024); int tmp = dispatch_coro(NULL_func, NULL); printf("dispatch Done errno: %d\n", tmp); struct coroutine *coro = create_coroutine(); // move_to_active_list_head(coro); get_coroutine(); run_active_coroutine(); dispatch_coro(NULL_func, NULL); check_timeout_coroutine(); // start treating tree // coroutine_switch(sched.current, &sched.main_coro); move_to_inactive_tree(coro); return 0; } ``` 這個小程式跑出來的結果竟與先前的 `asan` 錯誤相同,錯誤碼如下: ```c ==10711==ERROR: AddressSanitizer: SEGV on unknown address (pc 0x55db0b0235e7 bp 0x7ffee8368fd0 sp 0x7ffee8368f90 T0) ==10711==The signal is caused by a READ memory access. ==10711==Hint: this fault was caused by a dereference of a high value address (see register values below). Dissassemble the provided pc to learn which register was used. #0 0x55db0b0235e7 in add_to_timer_node coro/sched.c:84 #1 0x55db0b0238e4 in move_to_inactive_tree coro/sched.c:119 #2 0x55db0b02a6d0 in main /home/joshua/linux2023/cserv/testing/test.c:41 #3 0x7ff116629d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58 #4 0x7ff116629e3f in __libc_start_main_impl ../csu/libc-start.c:392 #5 0x55db0af41504 in _start (/home/joshua/linux2023/cserv/testing/test+0xf504) ``` ### 仔細觀察 `move_to_inactive_tree` 的實作 這個 function 如同其名,就是要把輸入的 coroutine 放到 sched.cache 中的 rb_tree 問題出現在第二個 `add_to_timer_node` ```c= void move_to_inactive_tree(struct coroutine *coro) { struct rb_node **newer = &sched.inactive.rb_node, *parent = NULL; while (*newer) { ... } struct timer_node *tmp = memcache_alloc(sched.cache); if (unlikely(!tmp)) /* still in active list */ return; tmp->timeout = coro->timeout; add_to_timer_node(tmp, coro); rb_link_node(&tmp->node, parent, newer); rb_insert_color(&tmp->node, &sched.inactive); } ``` :::info ### `gdb` v.s `udb` 對於這次專案來說,能夠有任何成果都必須要歸功於工具的使用,尤其是能夠紀錄程式狀態的 `udb`,即便操作都跟 `gdb` 相同,但後者卻無法做到跳回前一行的狀態,能否在狀態之間切換會是把問題找出來的關鍵點。 ::: 第 8 行將 tmp取出之後,未顧及 `rb-node` 是否充分初始化,而該 timer_node 的 root 會指向不能存取的區域。以下為 [`udb`](https://docs.undo.io/UDB-quickref.pdf) 擷取的片段 * 若打開 `ASan` 時可以發現其中的記憶體並未被初始化 ```bash $43 = {node = {rb_parent_color = 137446328392345678700, rb_left = 0xbebebebebebebebe, rb_right = 0xbebebebebebebebe}, root = {rb_node = 0xbebebebebebebebe}, timeout = -4702111234474983746} ``` * 若沒有打開 `ASan` ```bash $43 = {node = {rb_parent_color = 0, rb_left = 0x0, rb_right = 0x0}, root = {rb_node = 0x0}, timeout = 0} ``` 從這邊可以看到,不管左邊還是右邊都並沒有進行初始化,而也因為如此,利用 `container_of` 沒有辦法找出對應的 coroutine,重點在於初始化,於是在 tmp 後面新增一行初始化即可解決這個問題 ```diff struct timer_node *tmp = memcache_alloc(sched.cache); if (unlikely(!tmp)) /* still in active list */ return; + tmp->root = RB_ROOT; ``` 新增此行之後,`Asan` 就不會再 runtime 的時候瘋狂報錯了。也因此而完成了 [cserv 的 issue 6](https://github.com/sysprog21/cserv/issues/6) ### `coro_stack_free` 並未被正常呼叫 消除了前一個 address sanitizer 的問題之後,縱使不會在執行時跳出警告訊息,當使用 `./cserv stop` 的時候以下警告訊息冒出了 12 次 ```bash ==8832==WARNING: ASan is ignoring requested __asan_handle_no_return: stack type: default top: 0x7ffe6c4a2000; bottom 0x7f909f82c000; size: 0x006dccc76000 (471587053568) ``` 十二次,第一個聯想到的就是因為這個電腦的環境總共有 12 個核心,也就是 12 個 worker (by default),可以實驗看看如果更改 `conf` 檔會不會有所改變 ```bash ==9179==WARNING: ASan is ignoring requested __asan_handle_no_return: stack type: default top: 0x7fffc1b1a000; bottom 0x7fab52252000; size: 0x00546f8c8000 (362648731648) False positive error reports may follow For details see https://github.com/google/sanitizers/issues/189 ==9178==WARNING: ASan is ignoring requested __asan_handle_no_return: stack type: default top: 0x7fffc1b1a000; bottom 0x7fab52252000; size: 0x00546f8c8000 (362648731648) False positive error reports may follow For details see https://github.com/google/sanitizers/issues/189 ==9177==WARNING: ASan is ignoring requested __asan_handle_no_return: stack type: default top: 0x7fffc1b1a000; bottom 0x7fab52252000; size: 0x00546f8c8000 (362648731648) False positive error reports may follow For details see https://github.com/google/sanitizers/issues/189 ==9176==WARNING: ASan is ignoring requested __asan_handle_no_return: stack type: default top: 0x7fffc1b1a000; bottom 0x7fab52252000; size: 0x00546f8c8000 (362648731648) False positive error reports may follow For details see https://github.com/google/sanitizers/issues/189 ``` 跟預期的一樣,警告的次數完全跟 worker 的數量相同。 ### 總是無法正確的釋放 coroutine 為了驗證 coroutine 在 runtime 以及結束時並沒有正常的收回 coroutine 的空間,這邊利用 [`htstress`](https://hackmd.io/@sysprog/linux2022-ktcp) 在 runtime 時檢查每一個 worker 的 sched ```c /* src/coro/sched.c */ void schedule_cycle() { for (;;) { check_timeout_coroutine(); run_active_coroutine(); printf("current sched: %ld\n", sched.curr_coro_size); sched.policy(get_recent_timespan()); run_active_coroutine(); } } ``` 照理來說,當一個 connection 在 timeout 或是結束連線時,必須要在伺服器端主動釋放連線,但總會有幾個 worker 在結束連結許久後遲遲不把 coroutine 釋放。 ```bash= $ terminal1 :./htstress -n 1000000 -c 100 -t 10 http://172.27.229.224:8081/ $ terminal2 :./cserv start current sched: 1001 current sched: 50 current sched: 1001 current sched: 1 current sched: 50 current sched: 1001 current sched: 1001 current sched: 50 current sched: 1 current sched: 1001 current sched: 50 ``` 另外,並沒有在 `sched.c` 中觀察到任何 `sched.curr_coro_size--`,也許新增一個 [garbage collection](https://zh.wikipedia.org/zh-tw/%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6_(%E8%A8%88%E7%AE%97%E6%A9%9F%E7%A7%91%E5%AD%B8)) 系統會是一個可行的選擇 ### 實作垃圾回收系統 首先在 coroutine 的 structure 中加入 `refcnt`,當這個 coroutine 被引用時(也就是呼叫 `coro_routine_proxy` 時),就會增加,若這個 coroutine timeout,需要被 dereference 時(在 `timeout_coroutine_handler` 當中),則減少。 ```diff static __attribute__((__regparm__(1))) void coro_routine_proxy(void *args) { struct coroutine *coro = args; + coro->refcnt++; coro->func(coro->args); move_to_idle_list_direct(coro); coroutine_switch(sched.current, &sched.main_coro); } static inline void timeout_coroutine_handler(struct timer_node *node) { struct rb_node *recent; while ((recent = node->root.rb_node)) { struct coroutine *coro = container_of(recent, struct coroutine, node); rb_erase(recent, &node->root); coro->active_by_timeout = 1; + coro->refcnt--; move_to_active_list_tail_direct(coro); } } ``` 除此之外在 `schedule_cycle` 中新增 cleanup function, ```diff void schedule_cycle() { for (;;) { check_timeout_coroutine(); run_active_coroutine(); printf("current sched: %ld\n", sched.curr_coro_size); + /* check garbage */ + if (sched.curr_coro_size > sched.max_coro_size / 2){ + collect_cycle(); + } sched.policy(get_recent_timespan()); run_active_coroutine(); } } ``` 但目前還無法正確的實作出來,當重新啟動時,還是無法把相對應的 coroutine 刪除,也許 timeout_handler 沒有正確的把 sched.curr_coro_size 給往下調整 ## 效能評比和分析 對 NGINX, [lwan](https://github.com/lpereira/lwan), cserv 三者進行效能分析,並從實驗數據解讀,探討現行 cserv 可改進之處。 ## 導入 AIO 研讀〈[Thread Pools in NGINX Boost Performance 9x!](https://www.nginx.com/blog/thread-pools-boost-performance-9x/)〉,將 AIO 導入到 cserv,從而提高大檔案傳輸的效率。 ### Synchronous I/O v.s Asynchronous I/O 在理解為何導入 AIO 可以提升大檔案傳輸效率的時候,我們可以先了解非同步 I/O 以及同步 I/O 的區別。 ![](https://hackmd.io/_uploads/B1IETBlv2.png) 在恐龍書上其實只有講述這三種不同的 I/O,其中 Blocking 就是 Synchronous。 * Synchrous I/O - 當行程必須要呼叫 I/O 裝置時,整個行程都會停下來等他完成 以下例子來解釋 Synchronous (blocking) I/O ```c int size = read(fd, buf, length); /* 在此行暫停等到 read 結束 */ if (size <= 0) return; /* 處理 read 完之後的結果 */ ``` Synchronous I/O 有一些優點,那就是實作起來非常方便而且可讀性也很高。但缺點就非常多,當讀取的檔案過大,這個方法只會對系統造成非常大的衝擊 > 這邊想要請教老師,我一直想要把此種衝擊用 Convoy effect 來解釋,但怕定義不符合。究竟能不能使用護衛效應來解釋 blocking I/O * Asynchronous I/O - 是一種非阻塞的 I/O 他允許應用程式在發起 I/O 之後繼續做不同的事情,而不需要等待這個操作完成。 Asynchronous I/O 跟前者的優缺點幾乎是對調的,他可以高效利用資源,提高性能。但缺點就是複雜性相對起來高很多,而且也需要較多額外處理才可以確保程式的正確執行。 以下例子簡易描述 aio_read 的流程 ```c /* I/O in background */ aio_read(aiocpb); /* do sth else */ do_sth(); /* collect result */ collect(); ``` -----