# 2020q1 Homework6 (sehttpd) contributed by < `StevenChen8759` > ###### tags: `linux2020` > [作業說明](https://hackmd.io/@sysprog/linux2020-sehttpd#H10-sehttpd) > [作業繳交區](https://hackmd.io/@sysprog/linux2020-homework6) > [GitHub](https://github.com/StevenChen8759/sehttpd) >[color=LightGreen] ## :card_file_box: 引入 `khttpd` 中的 [`htstress.c`](https://github.com/sysprog21/khttpd/blob/master/htstress.c) 效能量測並改善 ### :hammer: URL 解析錯誤修正 * 在 shell 執行 `./htstress.c` ,並輸入特定 URL 時,會得到下列錯誤: ```shell $ ./htstress -n 10000 -c 4 -t 4 -o 5000 http://127.0.0.1:8081/ 0 requests 1000 requests 2000 requests 3000 requests 4000 requests 5000 requests 6000 requests 7000 requests 8000 requests 9000 requests requests: 10000 good requests: 10000 [100%] bad requests: 0 [0%] socker errors: 0 [0%] seconds: 1.153 requests/sec: 8669.598 $ ./htstress -n 10000 -c 4 -t 4 -o 5000 http://127.0.0.1:8081 Segmentation fault (core dumped) ``` * 上列兩個指令所要求的 URL 只差在 port 後方是否有字元 `/` ,透過 `gdb` 追蹤錯誤發生點,可發現是由字串處理不當所引起,如下所示: ```shell $ gdb -q ./htstress Reading symbols from ./htstress...done. (gdb) r -n 10000 -c 4 -t 4 -o 5000 http://127.0.0.1:8081 Starting program: /home/stch/linux2020/sehttpd/htstress -n 10000 -c 4 -t 4 -o 5000 http://127.0.0.1:8081 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Program received signal SIGSEGV, Segmentation fault. 0x000000000040216c in main (argc=10, argv=0x7fffffffde08) at htstress.c:457 457 if (*rq == '/') { set listsize 30 (gdb) list 457 442 node = s; 443 444 char *rq = strpbrk(s, ":/"); 445 if (!rq) 446 rq = "/"; 447 else if (*rq == '/') { 448 node = strndup(s, rq - s); 449 if (!node) { 450 perror("node = strndup(s, rq - s)"); 451 exit(EXIT_FAILURE); 452 } 453 } else if (*rq == ':') { 454 *rq++ = 0; 455 port = rq; 456 rq = strchr(rq, '/'); 457 if (*rq == '/') { 458 port = strndup(port, rq - port); 459 if (!port) { 460 perror("port = strndup(rq, rq - port)"); 461 exit(EXIT_FAILURE); 462 } 463 } else 464 rq = "/"; 465 } 466 467 if (strnlen(udaddr, sizeof(ssun->sun_path) - 1) == 0) { 468 int j = getaddrinfo(node, port, &hints, &result); 469 if (j) { ``` * 因為位於 456 行的 `strchr()` 函式在未找到字元 `/` 時會回傳空指標,而接續的判斷式並未檢查是否回傳空指標就進行記憶體操作,造成 `Segmentation Fault`,我們新增 `if` 判斷式以避免存取空指標。 * 在修改之後,我們以 URL `http://127.0.0.1:8081/` 測試,結果正常。但改以 URL `http://127.0.0.1:8081` 測試,仍然有 Segmentation Fault,於是我們再次透過 `gdb` 檢查出錯的地方: ```shell gdb -q ./htstress Reading symbols from ./htstress...done. (gdb) r -n 10000 -c 4 -t 4 -o 5000 http://127.0.0.1:8081 Starting program: /home/stch/linux2020/sehttpd/htstress -n 10000 -c 4 -t 4 -o 5000 http://127.0.0.1:8081 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Program received signal SIGSEGV, Segmentation fault. strlen () at ../sysdeps/x86_64/strlen.S:106 106 ../sysdeps/x86_64/strlen.S: No such file or directory. (gdb) backtrace #0 strlen () at ../sysdeps/x86_64/strlen.S:106 #1 0x000000000040244f in main (argc=10, argv=0x7fffffffde08) at htstress.c:517 (gdb) br htstress.c:517 Breakpoint 6 at 0x402443: file htstress.c, line 517. (gdb) r -n 10000 -c 4 -t 4 -o 5000 http://127.0.0.1:8081 The program being debugged has been started already. Start it from the beginning? (y or n) y Starting program: /home/stch/linux2020/sehttpd/htstress -n 10000 -c 4 -t 4 -o 5000 http://127.0.0.1:8081 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Breakpoint 6, main (argc=10, argv=0x7fffffffde08) at htstress.c:517 517 outbufsize = strlen(rq) + sizeof(HTTP_REQUEST_FMT) + strlen(host); (gdb) p rq $1 = 0x0 (gdb) p host $2 = 0x7fffffffe22d "127.0.0.1" (gdb) c Continuing. Program received signal SIGSEGV, Segmentation fault. strlen () at ../sysdeps/x86_64/strlen.S:106 106 ../sysdeps/x86_64/strlen.S: No such file or directory. (gdb) ``` * 此次的執行問題是由 `strlen` 的呼叫引起 (參考第 14 行的 gdb 指令執行結果),參考 Linux 的 [strlen reference](http://man7.org/linux/man-pages/man3/strlen.3.html),會針對傳入的指標進行記憶體操作,以取得字串的長度,但參考網路上諸多文章討論,<font color=red> `strlen` 並不會檢查傳入的指標是否為 `NULL`</font>,參考 GDB 操作,因為 `rq` 為 `NULL`,造成 `strlen(rq)` 執行錯誤,因此我們亦須在呼叫 `strlen` 前,檢查傳入的指標 `rq` 是否為空。 ( [Ref_1](https://stackoverflow.com/questions/4181784/how-to-set-socket-timeout-in-c-when-making-multiple-connections) [Ref_2](http://www.cplusplus.com/reference/cstring/strlen/) ) * 經過修改後,即可正常執行,參考下列輸出: ```shell ./htstress -n 100 -c 4 -t 4 -o 5000 http://127.0.0.1:8081 0 requests 10 requests 20 requests 30 requests 40 requests 50 requests 60 requests 70 requests 80 requests 90 requests requests: 100 good requests: 100 [100%] bad requests: 0 [0%] socker errors: 0 [0%] seconds: 3.522 requests/sec: 28.394 ``` ### :bulb: 改善效能量測的機制 * 我們首先比較下列量測工具的特色,作為改善量測方法的參考。 * [htstress](https://github.com/sysprog21/khttpd/blob/master/htstress.c) 之效能量測工具特點: * 可調整測試 function 的重複量測次數 ( -n 參數輸入) * 可調整測試 function 的同時連線數 ( -c 參數輸入) * 可調整同一時間運行測試 fucntion 的 Thread 數 ( -t 參數輸入) * [wrk](https://github.com/wg/wrk) 之效能量測工具特點: * 可調整測試 function 的同時連線數 ( -c 參數輸入) * 可調整同一時間運行測試 fucntion 的 Thread 數 ( -t 參數輸入) * 可調整測試 function 重複執行的間隔 ( -d 參數輸入) * [httperf](https://github.com/httperf/httperf) 之效能量測工具特點: * 支援 https 協定下的測試 (編譯時已包含相關的加密套件) * 可調整測試的 Request 數與 Request Rate (request/sec),並可依據需求調整 request timeout。 > 待整理... > [name=Steven HH Chen][time=Tue, May 5, 2020 6:47 PM][color=red] ## :card_file_box: 改善 sehttpd 的實作 ### :wrench: 實作由 CMD 輸入指定 PORT 與 WEBROOT 兩個參數的功能 * 參考 `htstress` 中,利用 Linux 的系統呼叫 [`GETOPT(3)`](http://man7.org/linux/man-pages/man3/getopt.3.html) 實作參數解析的方法,我們在 `sehttpd` 中亦採用此方法實現參數解析,雖然程式碼較為複雜,但未來在增加輸入參數上較為容易。 * 因參數解析只在程式啟動時運作,若未來需針對 Server 的啟動時間成本進行最佳化,再將 `switch` 的實作改以效能較差 `computed goto` 實作即可。此處仍以閱讀較直覺的 `switch` 實現。 * 詳細實作請參考實作完成的程式碼 - [mainloop.c](https://github.com/StevenChen8759/sehttpd/tree/master/src) ### :hammer: HTTP 404/403 錯誤處理的效能改善 * 我們分別以 URL `http://127.0.0.1:8081/` 與 `http://127.0.0.1:8081/xxx/` 作為輸入,透過 `htstress` 量測執行效能,參考下列結果: ```shell $ ./htstress -n 1000 -c 4 -t 4 http://127.0.0.1:8081/ 0 requests 100 requests 200 requests 300 requests 400 requests 500 requests 600 requests 700 requests 800 requests 900 requests requests: 1000 good requests: 1000 [100%] bad requests: 0 [0%] socker errors: 0 [0%] seconds: 0.140 requests/sec: 7134.143 ``` ```shell $ ./htstress -n 1000 -c 4 -t 4 http://127.0.0.1:8081/xxx/ 0 requests 100 requests 200 requests 300 requests 400 requests 500 requests 600 requests 700 requests 800 requests 900 requests requests: 1000 good requests: 0 [0%] bad requests: 1000 [100%] socker errors: 0 [0%] seconds: 31.679 requests/sec: 31.567 ``` * 對於 URL `http://127.0.0.1:8081/xxx/` 而言,server 端接受要求的路徑沒有對應檔案,因此回覆 HTTP 404 錯誤碼,參考下列的網頁 Response: ```htmlembedded <html><title>Server Error</title><body> 404: Not Found <p>Can't find the file: ./www/xxx/index.html </p><hr><em>web server</em> </body></html> ``` * 但值得我們觀察的是,在輸入正確的 URL 時,Request 處理效能可達每秒 7134.143 個 request,但在輸入錯誤的 URL 時,其Request 處理效能卻只有每秒 31.567 個 request,明顯有改善的空間。 * 我們著手分析造成錯誤 URL 輸入造成效能降低的因素。在 TCP socket 接收由 client 傳來的 HTTP Request 後的,會依序進行下列處理流程: * 讀取 Socket Buffer 資料 (recv) * 剖析讀取的資料 - HTTP Line * 剖析讀取的資料 - HTTP Body * 剖析讀取的資料 - 統一資源識別器 (URI) * 讀取 client 要求的檔案並 Response * 比對我們透過 `htstress` 所觀察到的效能降低的原因,主要是在剖析 URI 時的差異,我們設定程式輸出 debug 的訊息,可發現其等待時間竟長達 500 ms,參考下方圖片的輸出: ![500ms_waiting](https://i.imgur.com/nA5lr8r.png) * 我們逐步追蹤程式在剖析 URI 時的行為,參考 [http.c](https://github.com/StevenChen8759/sehttpd/blob/master/src/http.c#L280) 可發現 `404 Not Found` 訊息由函式 `do_error()` 產生,並將訊息回傳給 Client 端,參考下列程式片段: ```cpp=277 parse_uri(r->uri_start, r->uri_end - r->uri_start, filename); struct stat sbuf; if (stat(filename, &sbuf) < 0) { do_error(fd, filename, "404", "Not Found", "Can't find the file"); continue; } if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) { do_error(fd, filename, "403", "Forbidden", "Can't read the file"); continue; } ``` * 其中,`continue` 的作用主要在於重新讀取 Socket 的 File Descriptor,主要是在 Non-blocking Socket 尚未傳輸完成 ( `EAGAIN` ) 時,程式能夠即時地重新聽取 File Descriptor,當 Socket的傳輸資料抵達時,Server 可立即針對資料進行處理,縮短資料抵達與資料開始處理的時間差。 * 但是在 Client 的 Request 已經有錯的狀況下,即可針對原本的 Connection 做出一些處理,此處我們選擇直接將 Connection 關閉,並將資源釋放給接續的連線,參考下列程式片段: ```cpp=287 parse_uri(r->uri_start, r->uri_end - r->uri_start, filename); struct stat sbuf; if (stat(filename, &sbuf) < 0) { do_error(fd, filename, "404", "Not Found", "Can't find the file"); // 404 not found, close connection free(out); debug("404 Not Found, close connection"); goto close; } if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) { do_error(fd, filename, "403", "Forbidden", "Can't read the file"); // 403 forbidden, close connection free(out); debug("403 Forbidden, close connection"); goto close; } ``` ```cpp=335 close: /* TODO: handle the timeout raised by inactive connections */ rc = http_close_conn(r); assert(rc == 0 && "do_request: http_close_conn"); ``` * 經過效能量測,輸入錯誤 URL 的處理效能已有改善,參考下列量測結果: ```shell $ ./htstress -n 1 -c 1 -t 1 http://127.0.0.1:8081/index.htm requests: 1 good requests: 0 [0%] bad requests: 1 [100%] socker errors: 0 [0%] seconds: 0.000 requests/sec: 4065.041 ``` ### :wrench: 以 [`sendfile`](http://man7.org/linux/man-pages/man2/sendfile.2.html) 系統呼叫實現 `zero-copy` * 原有的資料傳遞方式: * 利用 [`mmap`](http://man7.org/linux/man-pages/man2/mmap.2.html) 將檔案讀取至記憶體空間 * 透過 `write()` 將資料寫入 `(Socket) file descriptor` 將檔案回傳 Client。 * 上述的資料流牽涉到兩次大量記憶體操作,一次是由檔案讀取至記憶體,一次是執行讀取資料寫入(複製)至 Socket Write Buffer,且期間牽涉函式 `writen()` 呼叫,在 `writen()` 中頻繁的分支 (branch) 亦可能降低部分效能。參考下圖的資料流: ![data_flow_mmap](https://i.imgur.com/t7ncsZE.jpg) * 我們改以 [`sendfile`](http://man7.org/linux/man-pages/man2/sendfile.2.html) 系統呼叫替代上述的作法。資料傳遞的方式如下: * 先將讀取檔案開啟,得到操作該檔案的 `(Read) File Descriptor` * 透過 `sendfile()` 操作 `(Read) File Descriptor` 讀取檔案。 * 在此同時,`sendfile()` 將讀取的資料操作 `(Socket) File Descriptor` 寫入,將檔案同時回傳 Client。 * 因為 `sendfile()` 可直接將檔案讀取至至 Socket Write Buffer,因此可減少一次大量記憶體操作,且免去呼叫 `writen()` 函式與其內部的系統呼叫 `write()` 與迴圈等待的分支 (branch) 成本。除此之外,因為檔案是直接複製至 Socket Write Buffer,假設兩個操作是使用單一個 Thread,這樣的操作可使兩個動作**並行**,因此可達成效能的改善。參考下圖的資料流: ![data_flow_sendfile](https://i.imgur.com/Ef7bAFe.jpg) > 若使用兩個 Thread 操作可使兩個動作達成平行,理論上效能更佳,但須考量同步問題與同步化需付出的成本。參考 [Producer-Consumer Problem](https://en.wikipedia.org/wiki/Producer%E2%80%93consumer_problem) 解決 Buffer 的讀寫問題。 > 效能成本分析上,應再額外考量 `User Mode` 與 `Kernel Mode` 的切換成本,較為精確。 > [name=Steven HH Chen][time=Tue, May 5, 2020 6:12 PM][color=orange] * `sendfile` 的程式實現片段 ```cpp=200 int srcfd = open(filename, O_RDONLY, 0); assert(srcfd > 2 && "open error"); /* TODO: make sendfile() error handling function complete */ int retn = sendfile(fd, srcfd, NULL, filesize); if (retn != 0) { printf("sendfile error, errno: %d", errno); assert("sendfile error"); } close(srcfd); ``` * `htstress` 測量結果 - `mmap()` 方法 ```shell $ make strperf $ make strperf ./htstress -n 100000 -c 4 -t 4 http://127.0.0.1:8081 ... requests: 100000 good requests: 100000 [100%] bad requests: 0 [0%] socker errors: 0 [0%] seconds: 8.311 requests/sec: 12032.173 ``` * `htstress` 測量結果 - `sendfile()` 方法 ```shell $ make strperf ./htstress -n 100000 -c 4 -t 4 http://127.0.0.1:8081 ... requests: 100000 good requests: 100000 [100%] bad requests: 0 [0%] socker errors: 0 [0%] seconds: 7.069 requests/sec: 14146.625 ``` * 透過 `htstress` 測試兩種實現,可發現 `sendfile()` 方法有較佳的效能,但仍有改善空間。 * `EAGAIN` 錯誤處理 (2020/05/07 新增):在利用 `sendfile()` 後效能提升,加上後續整合 `computed goto` ...等效能改善方法,同一時間內可並行 or 平行的連線因而大量增加,在 `sendfile()` 大量地向 `Socket Write Buffer` 寫入資料的情況下,可能會使 `Write Buffer` 被填滿,此時因資料暫時無法寫入並回傳給 Client,且 `Socket` 被設定為 `non-blocking mode`,因此得到 `EAGAIN` 錯誤。 > Note: 對照 Non-Blocking Socket Read 的操作,在 Socket Read Buffer 為空的時候進行 Read,因為無資料可供讀取,亦會產生 `EAGAIN` 的錯誤。 > [color=Green] * 參考 [`sendfile`](http://man7.org/linux/man-pages/man2/sendfile.2.html) 針對參數 `offset` 的說明: ``` If offset is NULL, then data will be read from in_fd starting at the file offset, and the file offset will be updated by the call. ``` * 基於上述說明,我們可以不用考慮檔案在傳送給 Client 時,需重新設定 `file offset` 的問題,我們僅需重新呼叫 `sendfile()` 即可繼續將已開啟檔案中未傳輸的部分完成 Socket Write Buffer 寫入。 * 我們以下列的程式碼實現 EAGAIN 錯誤的處理,並考量分支次數的最小化,以達成較佳的效能: ```cpp=203 int retn; sendfile_again: retn = sendfile(fd, srcfd, NULL, filesize); // Process with sendfile error if (retn != 0) { // Socket Write Buffer Full, Call sendfile again... if (errno == EAGAIN) goto sendfile_again; /* TODO: make sendfile() error handling function complete */ /* TODO: make socket error handling function complete */ // printf("sendfile error, errno: %d\n", errno); // assert(retn==0 && "sendfile error"); } close(srcfd); ``` * 上述程式仍有改進空間,以下列出接下來的修正方向: * 加強 sendfile() 與 socket write 系統呼叫的錯誤處理機制,使能處理的錯誤更加全面。 * 將 sendfile() 的重試機制整合 Timer,設計適當地延遲機制,在延遲期間可執行其他任務 (如: accept()、recv()...等),強化各個任務之間的並行 (Concurreny) 程度,以符合 `non-blocking I/O` 的設計精神。除此之外,重新執行 `sendfile()` 的機制亦須設計完善,避免造成資料回傳不全。 * 參考 [lwan-io-wrappers](https://github.com/lpereira/lwan/blob/master/src/lib/lwan-io-wrappers.c#L205) 的 sendfile() 重試處理機制。