Try   HackMD

2022q1: 高效網頁伺服器

contributed by < blueskyson >

GitHub

探討從無到有打造 Linux 平台的高效能網頁伺服器,涵蓋是否該將伺服器實作於 Linux 核心內部、並行處理、I/O 模型、epoll、Reactor pattern,和 Web 伺服器在事件驅動架構的考量。

專題列表

相關資訊:

測試環境

這兩年來我所習慣的開發環境為 Ubuntu20.04,但在此次作業中使用 bcc 的 eBPF 時,不管使用編譯好的工具或是編譯原始碼都遇到諸多問題,所以選擇在 Arch Linux 上開發。

$ cat /proc/version
Linux version 5.18.0-arch1-1 (linux@archlinux)
(gcc (GCC) 12.1.0, GNU ld (GNU Binutils) 2.38) #1 SMP PREEMPT_DYNAMIC
$ lscpu
Architecture:                    x86_64
CPU op-mode(s):                  32-bit, 64-bit
Byte Order:                      Little Endian
Address sizes:                   39 bits physical, 48 bits virtual
CPU(s):                          12
On-line CPU(s) list:             0-11
Thread(s) per core:              2
Core(s) per socket:              6
Socket(s):                       1
NUMA node(s):                    1
Vendor ID:                       GenuineIntel
CPU family:                      6
Model:                           165
Model name:                      Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz
Stepping:                        2
CPU MHz:                         2600.000
CPU max MHz:                     5000.0000
CPU min MHz:                     800.0000
BogoMIPS:                        5199.98
Virtualization:                  VT-x
L1d cache:                       192 KiB
L1i cache:                       192 KiB
L2 cache:                        1.5 MiB
L3 cache:                        12 MiB
NUMA node0 CPU(s):               0-11

相依套件

# dev tools
$ sudo pacman -S bcc bcc-tools python-bcc linux-header

# git hooks
$ sudo pacman -S cppcheck clang aspell colordiff

# benchmark tools
$ yay -S apache-tools

$ git clone https://github.com/sysprog21/sehttpd

效能測量環境

隔離 cpu0 到 cpu3,這四個 cpu 只會執行 Read-copy-update,以及藉由 taskset 來把 sehttpd 固定在這四個 cpu 執行,藉此讓 sehttpd 被其他 process 搶佔的機會降到最低。

其餘 linux 系統調校還有抑制 ASLR、關閉 intel turbo mode 使得 cpu 頻率維持穩定。

隔離特定 cpu

最常見的方法為修改 /etc/default/grub,使其包含以下 GRUB_CMDLINE_LINUX="isolcpus=0-3" 再執行 update-grub 來達成目的。

但是我的 arch linux 一直無法成功修改 isolcpu 這個參數,所以我用另一種方式。首先在開機時進到 grub 選單,選取將要進入的作業系統,還不要按 enter

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

按下 e 編輯開機參數:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

在倒數第二行的最後面加入 isolcpus=0-3:

fi
linux ... quiet isolcpus=0-3
initrd ...

按下 ctrl xF10 開機,用 taskset 檢查 cpu 0 到 3 是否已經被 isolate:

$ taskset -p 1
pid 1's current affinity mask: ff0

排除其餘干擾因素

  1. 抑制 address space layout randomization (ASLR)
    ​​​$ sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space"
    
  2. 設定 scaling_governor 為 performance:
    ​​​# performance.sh
    ​​​for i in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
    ​​​do
    ​​​    echo performance > ${i}
    ​​​done
    
    執行腳本
    ​​​$ sudo sh performance.sh
    
  3. 針對 Intel 處理器,關閉 turbo mode:
    ​​​$ sudo sh -c "echo 1 > /sys/devices/system/cpu/intel_pstate/no_turbo"
    

簡介 epoll 與 seHTTPd 的架構

epollpoll 的變體,允許單一個執行緒值監看多個 file descriptor 的事件,在使用者監看的事件發生時回傳以通知使用者。事件通知的時機又分為 level trigger 和 edge trigger,在 edge trigger 模式中,只有事件觸發,或是 timeout 時 epoll_wait 才會回傳;在 level trigger 模式,epoll_wait 在事件狀態未變更前都會回傳。

sehttpd 是採用 edge trigger,在 mainloop.c 的關鍵程式碼如下:

int main(int argc, char **argv)
{
    // ...

    int listenfd = open_listenfd(cfg->port);        // open server's port
    int epfd = epoll_create1(0);                    // init epoll
    http_request_t *request = malloc(sizeof(http_request_t));
    struct epoll_event event = {
        .data.ptr = request,
        .events = EPOLLIN | EPOLLET,                // use edge trigger
    };
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &event);  // watch listenfd
    
    // ...
    
    while (1) {
        int n = epoll_wait(epfd, events, MAXEVENTS, time);    // event
        for (int i = 0; i < n; i++) {
            http_request_t *r = events[i].data.ptr;
            int fd = r->fd;
            if (listenfd == fd) {
                /* handle incomming connections */
                int infd = accept(listenfd, (struct sockaddr *) &clientaddr, &inlen);
                
                // ...
                
                init_http_request(request, infd, epfd, WEBROOT);
                event.data.ptr = request;
                event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
                epoll_ctl(epfd, EPOLL_CTL_ADD, infd, &event);
            } else {
                /* response to a request */
                // ...

                do_request(events[i].data.ptr);
            }
        }
    }
}

修正 seHTTPd 的缺失

將 seHTTPd 執行後,可以透過 ab 進行壓力測試,以下會同時開啟 1000 個連線,總共 50000 次連線連到 localhost:8081

$ ab -n 50000 -c 1000 -k http://127.0.0.1:8081/

只要短時間連線次數夠多,會顯示錯誤訊息:

[ERROR] (src/http.c:253: errno: Resource temporarily unavailable) rc != 0

找到錯誤的回傳點

首先 rchttp_parse_request_line(r) 的回傳值,為了知道 rc 確切的值,我將 http.c 第 253 行的的 log_err 改寫為:

log_err("rc == %d", rc);

如此一來透過 ab 測試所顯示的錯誤訊息變為:

[ERROR] (src/http.c:253: errno: Resource temporarily unavailable) rc == 10

從 http.h 中可以找到 HTTP_PARSER_INVALID_METHOD 所對應的值即為 10

enum http_parser_retcode {
    HTTP_PARSER_INVALID_METHOD = 10,
    HTTP_PARSER_INVALID_REQUEST,
    HTTP_PARSER_INVALID_HEADER
};

在 http_parser.c 的第 54 行和第 92 行都會回傳此值:

if ((ch < 'A' || ch > 'Z') && ch != '_') return HTTP_PARSER_INVALID_METHOD;

觀察 read 回傳值

我猜測標記 r->buf 的起始點 r->pos 指到錯誤的位置,或是超出陣列範圍(這個猜測是錯的,真正原因是下方黃底句子)。所以我將 http.c 的第 231 行的 read 下方印出 readr->pos 的回傳值,檢查在什麼情況下會出問題:

int n = read(fd, plast, remain_size); printf("n = %d, r->pos = %d\n", n, r->pos);

此時再執行測試,錯誤訊息變為:

Web server started. n = 106, r->pos = 0 n = -1, r->pos = 106 n = 106, r->pos = 106 n = -1, r->pos = 212 ... n = -1, r->pos = 8056 n = 68, r->pos = 8056 n = 38, r->pos = 8124 [ERROR] (src/http.c:254: errno: Resource temporarily unavailable) rc == 10 n = 106, r->pos = 0 n = 106, r->pos = 106 n = 106, r->pos = 212

由上述輸出可以得知,藉由 ab 發送的 http request 大小總共為 106 byte,而上面第 10、11 行發生錯誤的情況為:r->pos 距離 MAX_BUF (8124) 只剩 68 個 byte,所以沒辦法一次讀取 106 byte,下一次讀取 file descriptor 時,讀進來的是同一個 request 剩餘的 38 byte。然而原始程式將這 38 byte 當作下一個 request 的 head 來解析,理所當然會 parse error!

注意到 read 的回傳值 n 等於 -1 是正常現象,代表 EAGAIN,在 non-blocking I/O 代表資料尚未準備就緒,稍後再讀取一次。

修正 ring buffer 缺失

我的想法是,如果 n 等於 remain_size,代表 buffer 讀到尾端,很有可能還有存在 fd 的資料尚未被讀取,所以要再讀取剩餘的資料,複製到 r->buf 的頭端。

在 http.c 的第 231 行後面插入以下程式碼:

int n = read(fd, plast, remain_size); if (n == remain_size) { int n_again = read(fd, r->buf, MAX_BUF - n); // read again if (n_again > 0) n += n_again; }

對應的修改在 5217ef7

完成 seHTTPd 的 TODO