在 ktcp 提到,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〉,對比 NGINX 和 cserv,歸納架構設計的異同,並著手改進後者。
參考資訊:
$ lscpu
參閱〈Inside NGINX: How We Designed for Performance & Scale〉,對比 NGINX 和 cserv,歸納架構設計的異同,逐一探討 cserv 相對於 NGINX 在高效能表現尚欠缺的關鍵設計,例如 AIO。
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 的架構,讓 不同的 workers 常駐在 cpu 上,同時讓另外一個 master 去處理其他 worker 需要的高權限操作 (e.g., Fetch file, transfer file, print, etc.)。
根據〈Inside NGINX: How We Designed for Performance & Scale〉,Nginx 的 master 專門執行一些特權操作如讀取設定檔案,bind 等等,worker 則負責把所有的工作完成,例如讀寫以及處理連線,而 worker 之所以會有效率的處理連線,也可以參照
NGINX 開發日誌中以送貨員以及排隊等候的機制去看待這個問題
假設有一堆顧客,他們需要取貨,而有些貨物(1-3 號箱)盡在眼前,同時也很多貨物存在遙遠的倉庫中,此時注意這邊這位工作者(worker) 只會盲目的遵從目前顧客的要求,並且服務目前的顧客,假設有一個顧客要求比較遠的 4 號箱子,如此一來造成了護衛效應,擋住了後面也許有機會服務更快的顧客。
讓我們再想想另外一個情形,當這個 worker 學乖了,他利用一個送貨區,當每次有太遠的貨物需要取貨時,他並不會自己慢慢跑過去,而是一通電話叫送貨人員送來。如此一來就可以更快的服務到其他顧客,與此同時目前顧客也不會因服務其他人而導致他的任務沒有進展。
而 NGINX 的 worker 也一樣,當一個進程可能需要很長的操作時間時,他不會自己處理,而是將這個任務放進 TaskQueue 中,而任何空閒的 Thread 都可以將此 queue 中的任務取出來執行。
TasksQueue 以及 Thread Pool 在這邊扮演的腳色就是送貨員以及送貨車,Taskqueue 會接收由 worker process 來的工作,處理完之後再把結果送回 worker process 中。
為驗證這個事實,可以先使用以下命令
數一下的確可以發現 nginx: worker process
存在於 nginx: master process
底下。
其實 cserv 也實作同樣的主從式架構,但與 NGINX 相異處在於,cserv 以 coroutine 做到 cooperative multitasking 以達到相對高效的結果。
cserv 亦有 worker
根據
當中可以看到此 worker 一開始被分配到不同的 cpu 底下,由此可以得到 cserv 中也有 worker。
對於 cserv 來說,當一個子程序要做 I/O 這類型長時間的阻斷式程序,coroutine 會暫停程序,而不會阻塞其他行程。
Goal: 以 Cppcheck, Address Sanitizer, Valgrind 等工具找出現行 cserv 程式碼的記憶體操作缺失,著手改進並提交 pull request。
程式碼運用到 coroutine,但沒正確呼叫 coro_stack_free
函式。
Address Sanitizer
尋找記憶體缺失使用 Address Sanitizer 的方法非常簡單,只需要在編譯的時候增加
-fsanitize=address -static-libasan
的選項即可
接下來就可以觀察具體來說到底發生甚麼樣的缺失。
根據以上改動執行 ./cserv start
後可以看到非常多錯誤訊息
根據以上的輸出,在 3 行回報了這錯誤的原因,並在第 12 行告訴我們實際上出問題的位置即存在 sched.c
的 add_to_timer_node
中
add_to_timer_node
反覆試驗之後發現問題出現在讀取紅黑樹時讀取到錯誤的位置,以上程式碼在第 7 行使用 container_of
取出 coroutine 在第 8 行的地方要讀取出 each
時,ASan 就會報錯。原本認為與 race condition 有關連,於是在 coroutine 中加入了 spin_lock,但同樣的錯誤還是會出現
也許這個問題也許出在紅黑樹的存放區塊,memcache.[c|h]
中 (但目前不知道要怎麼驗證)
原本的猜想:
這個猜想在使用 gdb 之後發現是不可能的事情
在這邊可以發現位址都存在同一個 memory block 中。
為了驗證 coroutine 存在記憶體缺失,勢必需要撰寫程式來驗證 switch.h / sched.h
這個檔案中存在記憶體缺失。
既然目標很明確,接下來只要實驗看看究竟是哪一個 function 沒有正確的釋放記憶體。
第一件事情就是必須把所有功能改為其他程式可以存取到,並且把 sched 搬到要測試的主程式中
改為所有編譯單元 (compilation unit) 都可存取到符號
自己撰寫了測試程式之後,基本上可以把錯誤限縮到 move_to_inactive_tree
當中
這個小程式跑出來的結果竟與先前的 asan
錯誤相同,錯誤碼如下:
move_to_inactive_tree
的實作這個 function 如同其名,就是要把輸入的 coroutine 放到 sched.cache 中的 rb_tree
問題出現在第二個 add_to_timer_node
gdb
v.s udb
對於這次專案來說,能夠有任何成果都必須要歸功於工具的使用,尤其是能夠紀錄程式狀態的 udb
,即便操作都跟 gdb
相同,但後者卻無法做到跳回前一行的狀態,能否在狀態之間切換會是把問題找出來的關鍵點。
第 8 行將 tmp取出之後,未顧及 rb-node
是否充分初始化,而該 timer_node 的 root 會指向不能存取的區域。以下為 udb
擷取的片段
ASan
時可以發現其中的記憶體並未被初始化ASan
從這邊可以看到,不管左邊還是右邊都並沒有進行初始化,而也因為如此,利用 container_of
沒有辦法找出對應的 coroutine,重點在於初始化,於是在 tmp 後面新增一行初始化即可解決這個問題
新增此行之後,Asan
就不會再 runtime 的時候瘋狂報錯了。也因此而完成了 cserv 的 issue 6
coro_stack_free
並未被正常呼叫消除了前一個 address sanitizer 的問題之後,縱使不會在執行時跳出警告訊息,當使用 ./cserv stop
的時候以下警告訊息冒出了 12 次
十二次,第一個聯想到的就是因為這個電腦的環境總共有 12 個核心,也就是 12 個 worker (by default),可以實驗看看如果更改 conf
檔會不會有所改變
跟預期的一樣,警告的次數完全跟 worker 的數量相同。
為了驗證 coroutine 在 runtime 以及結束時並沒有正常的收回 coroutine 的空間,這邊利用 htstress
在 runtime 時檢查每一個 worker 的 sched
照理來說,當一個 connection 在 timeout 或是結束連線時,必須要在伺服器端主動釋放連線,但總會有幾個 worker 在結束連結許久後遲遲不把 coroutine 釋放。
另外,並沒有在 sched.c
中觀察到任何 sched.curr_coro_size--
,也許新增一個 garbage collection 系統會是一個可行的選擇
首先在 coroutine 的 structure 中加入 refcnt
,當這個 coroutine 被引用時(也就是呼叫 coro_routine_proxy
時),就會增加,若這個 coroutine timeout,需要被 dereference 時(在 timeout_coroutine_handler
當中),則減少。
除此之外在 schedule_cycle
中新增 cleanup function,
但目前還無法正確的實作出來,當重新啟動時,還是無法把相對應的 coroutine 刪除,也許 timeout_handler 沒有正確的把 sched.curr_coro_size 給往下調整
對 NGINX, lwan, cserv 三者進行效能分析,並從實驗數據解讀,探討現行 cserv 可改進之處。
研讀〈Thread Pools in NGINX Boost Performance 9x!〉,將 AIO 導入到 cserv,從而提高大檔案傳輸的效率。
在理解為何導入 AIO 可以提升大檔案傳輸效率的時候,我們可以先了解非同步 I/O 以及同步 I/O 的區別。
在恐龍書上其實只有講述這三種不同的 I/O,其中 Blocking 就是 Synchronous。
以下例子來解釋 Synchronous (blocking) I/O
Synchronous I/O 有一些優點,那就是實作起來非常方便而且可讀性也很高。但缺點就非常多,當讀取的檔案過大,這個方法只會對系統造成非常大的衝擊
這邊想要請教老師,我一直想要把此種衝擊用 Convoy effect 來解釋,但怕定義不符合。究竟能不能使用護衛效應來解釋 blocking I/O
Asynchronous I/O 跟前者的優缺點幾乎是對調的,他可以高效利用資源,提高性能。但缺點就是複雜性相對起來高很多,而且也需要較多額外處理才可以確保程式的正確執行。
以下例子簡易描述 aio_read 的流程