# Linux 核心專題: 以 eBPF 打造 TCP 伺服器 > 執行人: SuNsHiNe-75 > [專題解說錄影](https://youtu.be/xfmMvfNC0gM) > 後續實驗更新於:[Building a TCP Server with eBPF](https://hackmd.io/@SuNsHiNe-75/r1cUSmvQR),如 Ftrace 之問題排查。 ### Reviewed by `Wufangni` > BPF maps 不同型態之資料的儲存空間,以提供 User space 及 Kernel space 之資料共享。 BPF maps 作為一種共同存取空間,是否需要考慮到同步/不同步的問題?在這方面花費的額外成本是否會影響到 eBPF 效能? > 確實需要考慮資料的同步問題,觀察 Linux 核心的 bpf 相關檔案,map 類型如 `BPF_MAP_TYPE_HASH` 或 `BPF_MAP_TYPE_ARRAY` 就會在底層自行處理互斥問題;或是如 `bpf_atomic_add` 函式等,會透過 Atomic operation 來避免 Race condition 的情況。另外,如果是在高並行處理的環境下,是可能需要借助 Userspace 的同步機制(mutex 等)來管理 map 的存取。 > [name=SuNsHiNe-75] > 互斥機制會影響效能,但在 eBPF 技術本身強大的性能優勢下,我認為瑕不掩瑜。 > [name=SuNsHiNe-75] ### Reviewed by `Ackerman666` 1. 為何核心模式可更佳的利用 CPU 的 Locality 嗎 ? > 在核心模式下執行的伺服器可以更佳地利用 CPU 的 Locality 優勢 > 最主要的原因是,運行在核心模式下的程式,其資料處理能比較容易保持在 L1/L2 Cache 中;比起運行在 Userspace 中的程式,有更低的 Cache miss 機率。 > [name=SuNsHiNe-75] 2. 根據箱形圖,可以看出有幾筆資料是離群的,那麼想問有先刪除離群值再用平均數來統計嗎 ? 因為 800 筆的資料我認為容易受離群值影響。 > 感謝您的建議,確實該注意離群值對實驗分析的影響。但我還不是很確定那幾筆資料是否為「異常」離群值;或是它們單純是因為高並行環境下而使相應的 Response time 過長。 > [name=SuNsHiNe-75] ## 簡介 重現 [Linux 核心專題: 以 eBPF 建構 TCP 伺服器](https://hackmd.io/@sysprog/ryBw0adH2),確保能在 Linux v6.8+ 運作,量化 TCP 伺服器之效能表現,比較現有的方案。 > 參考 [2023 年開發紀錄](https://hackmd.io/@sysprog/ryBw0adH2) 之筆記 ### eBPF (Extended Berkeley Packet Filter) ![圖片](https://hackmd.io/_uploads/HkIj6mHr0.png) BPF 程式碼寫好後,會在 User mode 端轉換成 Bytecode,並注入到 Kernel。此時,會需要一 Verifier 防止其注入有害的東西(如無法使用的 Kernel API、存取範圍之限制等)。 :::danger 注意用語: * virtual machine 應翻譯為「虛擬機器」,而非簡略的「虛擬機」,用來區分 motor/engine (發動機) 在漢語常見的翻譯詞「機」。 ::: 驗證成功後,即可在 BPF 虛擬機器(一種說法,BPF 就類似 Kernel 的虛擬機器,協助其過濾封包任務)上執行。 之後,依據不同 BPF 之 Program type,如果觸發到不同的 Hook point,就會執行相關的 BPF 程式碼。並將獲得的資訊記錄到 Map。 最後,User 端可透過 System code 來存取 Map,以共享資料。 :::danger 改進漢語描述。 ::: ### BPF maps 不同型態之資料的儲存空間,以提供 User space 及 Kernel space 之資料共享。 其可透過 BPF syscall 來存取。 ```c int map_fd; union bpf_attr attr = { .map_type = BPF_MAP_TYPE_ARRAY; .key_size = sizeof(__u32); .value_size = sizeof(__u32); .max_entries = 256; }; map_fd = bpf(BPF_MAP_CREATE, &attr, sizeof(attr)); ``` ### eBPF 系統呼叫 `int bpf(int command, union bpf_attr *attr, u32 size)` 舉例: - BPF_MAP_CREATE - BPF_MAP_LOOKUP_ELEM - BPF_MAP_UPDATE_ELEM - BPF_PROG_LOAD > 可使用 **Bpftool** 工具,來達成與觸發系統呼叫相同的效果。 ### Socket Redirect ![圖片](https://hackmd.io/_uploads/B13rGNBH0.png) 當傳送端與目的端「在同一台電腦」時,可省略 Network stack 的處理,直接在兩個 Socket 間傳輸即可。 ### 使用 eBPF 建構 echo server ![圖片](https://hackmd.io/_uploads/ByDP7VHrC.png) 可在 Kernel space 達到接收/傳送封包的功能;不需修改、更新核心程式碼;User space 的 server 也就不用接收/傳送封包了。 如此,可節省兩次 Memory copies 的時間及空間,達到 **Zero-copy** 之成效。 ![圖片](https://hackmd.io/_uploads/Sy1Y4VHrC.png) 如上圖左半,即 client 與 server 在同一台電腦時,傳遞封包的情況;如上圖右半,若其在不同電腦,則可在另一端之 socket 收到封包後,將封包 redirect 到「該封包的傳送端」,以在「不將資料複製到 User 端」的情形下,回傳封包給對方。 實作成果:[YSRossi/ebpf-tcp-server](https://github.com/YSRossi/ebpf-tcp-server)。 ### 程式及 API 理解 > 部分參照 [bpf-helpers(7)](https://man7.org/linux/man-pages/man7/bpf-helpers.7.html)。 *bpf_sockops.c* - **`update_sockmap_ops`**:利用 `struct bpf_sock_ops` 中獲取所需的 socket 資訊作為 key,再使用 `bpf_sock_hash_update` 將該 socket 存入 map 供後續操作使用。 - **`bpf_sockmap`**:`BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB` 與 `BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB` 的意義是,若為這兩種情況,分別執行 `update_sockmap_ops` 將雙方的 socket 紀錄至 map。 > `BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB`: The SYNACK that concludes the 3WHS. > `BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB`: The ACK that concludes the 3WHS. *bpf_redir.c* - **`bpf_redir`**:先用一些條件句判斷該封包是否需 redirect。然後判斷 client 與 server 是否在同一台電腦上,並分別執行對應的 redirect 操作(redirect 之路徑不同)。 - `struct __sk_buff`:為即將被 socket 接收的封包,sk_skb 類型的程式碼可以存取 `struct __sk_buff` 的成員,選擇 sockmap key 的所需資訊,紀錄到 skm_key 中。 - **`bpf_sk_redirect_hash`**:使用 `bpf_sk_redirect_hash` 將該封包 redirect 到 server socket 的 egress interface,也就是傳送出去的方向。因此可以在 kernel space 中達到相當於在 user space 呼叫 recv 與 send 的功能,減少 user space 與 kernel space 之間 memory copy 的成本-**在 map 中以 `&skm_key` 搜尋目標 socket,並將 `msg` 傳到指定之目標 `&sockmap_ops`**。 > 原文:This helper is used in programs implementing policies at the skb socket level. If the `sk_buff` skb is allowed to pass (i.e. if the verdict eBPF program returns SK_PASS), redirect it to the socket referenced by map (of type `BPF_MAP_TYPE_SOCKHASH`) using hash key. Both ingress and egress interfaces can be used for redirection. The `BPF_F_INGRESS` value in flags is used to make the distinction (ingress path is selected if the flag is present, egress otherwise). This is the only flag supported for now. ## TODO: 使 eBPF/TCP 能運作於 Linux v6.8+ > 重現 [Linux 核心專題: 以 eBPF 建構 TCP 伺服器](https://hackmd.io/@sysprog/ryBw0adH2),確保能在 Linux v6.8+ 運作。 > 對照 [以 eBPF 建構 TCP 伺服器 開發過程](https://hackmd.io/@2UoAlr_DQGmZEWcbiclPrg/ryALE91gC) ### 實現環境 ```shell $ gcc --version gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0 Copyright (C) 2023 Free Software Foundation, Inc. $ uname -a Linux ss 6.8.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 20 00:40:06 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux $ lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Address sizes: 45 bits physical, 48 bits virtual Byte Order: Little Endian CPU(s): 4 On-line CPU(s) list: 0-3 Vendor ID: GenuineIntel Model name: Intel(R) Core(TM) i7-13700 CPU family: 6 Model: 183 Thread(s) per core: 1 Core(s) per socket: 1 Socket(s): 4 Stepping: 10 BogoMIPS: 4224.00 Caches (sum of all): L1d: 192 KiB (4 instances) L1i: 128 KiB (4 instances) L2: 8 MiB (4 instances) L3: 120 MiB (4 instances) NUMA: NUMA node(s): 1 NUMA node0 CPU(s): 0-3 ``` ### 編譯問題與解決方案 > Commit [61d248d](https://github.com/SuNsHiNe-75/ebpf-tcp-server/commit/61d248d205d4200b63141d64dbae42a3fa586414)。 **問題一**: ```shell In file included from bpf_sockops.c:1: ./bpf_sockops.h:4:10: fatal error: 'bpf/bpf_endian.h' file not found 4 | #include <bpf/bpf_endian.h> | ^~~~~~~~~~~~~~~~~~ 1 error generated. ``` 以 `sudo apt-get install libbpf-dev` 安裝 BPF 開發相關工具即可。 **問題二**: ```shell In file included from bpf_sockops.c:1: In file included from ./bpf_sockops.h:5: In file included from /usr/include/bpf/bpf_helpers.h:11: /usr/include/bpf/bpf_helper_defs.h:78:83: error: unknown type name '__u64' 78 | static long (*bpf_map_update_elem)(void *map, const void *key, const void *value, __u64 flags) = (void *) 2; | ^ /usr/include/bpf/bpf_helper_defs.h:102:42: error: unknown type name '__u32' 102 | static long (*bpf_probe_read)(void *dst, __u32 size, const void *unsafe_ptr) = (void *) 4; /*...*/ ``` 此問題是程式未提供 `__u32` 和 `__u64` 等資料型態給 bpf_helpers.h 使用。 因此,可在引用 bpf/bpf_helpers.h 前,先引用 linux/types.h 即可解決。 ```diff=4 + #include <linux/types.h> #include <bpf/bpf_helpers.h> ``` **問題三**: ```shell In file included from bpf_sockops.c:1: In file included from ./bpf_sockops.h:4: /usr/include/linux/types.h:5:10: fatal error: 'asm/types.h' file not found 5 | #include <asm/types.h> | ^~~~~~~~~~~~~ 1 error generated. ``` 透過命令 `sudo apt-get install -y gcc-multilib` 安裝相關檔案。 ### 載入及附加 透過 [scripts](https://github.com/SuNsHiNe-75/ebpf-tcp-server/tree/main/scripts) 中的 `ebpf_load.sh` 檔案,以 bpftool 進行 ebpf 相關程式之載入及附加到核心即可。 成功後,可使用 `sudo bpftool prog list` 檢查該 2 程式之相關掛載資訊: ```shell 85: sock_ops name bpf_sockmap tag c40f4006341c743d gpl loaded_at 2024-06-14T13:54:24+0800 uid 0 xlated 336B jited 193B memlock 4096B map_ids 14,16 btf_id 120 94: sk_skb name bpf_redir tag f1c44dbbde813660 gpl loaded_at 2024-06-14T13:54:24+0800 uid 0 xlated 504B jited 287B memlock 4096B map_ids 14,20 btf_id 130 ``` 另能透過執行 `scripts/delete.sh` 將其卸除。 ### 測試問題與解決方案 **問題一**: 開啟 ebpf-tcp-server 後,以 bench 程式重複執行測試時,儘管有成功連上 server;卻會在一定的測試次數(約 2 次)後失敗而不會回應 `correct`。而一段時間後,又可正常運作 2 次。 經測試後,發現程式碼會卡在如下之 `recv` 函式部分: ```c=72 gettimeofday(&start, NULL); int send_len = send(sock_fd, msg_dum, strlen(msg_dum), 0); int recv_len = recv(sock_fd, dummy, MAX_MSG_LEN, 0); gettimeofday(&end, NULL); ``` 為此,設計一 `recv` 函式之超時機制,並在特定函式執行時,輸出階段任務說明,以便追蹤測試現況: > Commit [8bc1cdc](https://github.com/SuNsHiNe-75/ebpf-tcp-server/commit/8bc1cdc6b72219ad19f2786573135233dd3cfebd)。 ```c struct timeval timeout; timeout.tv_sec = 3; // 3秒超時 timeout.tv_usec = 0; if (setsockopt(sock_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0) { perror("setsockopt"); exit(-1); } /*...*/ if (recv_len == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { printf("recv timeout occurred\n"); } else { perror("recv"); } exit(-1); } else if (recv_len == 0) { printf("Connection closed by peer\n"); exit(0); } else { dummy[recv_len] = '\0'; } ``` 使用 `tcpdump` 觀察封包傳遞情況,recv block 之情形如下: ```shell 10:41:38.614672 lo In IP localhost.34904 > localhost.12345: Flags [S], seq 1391280987, win 33280, options [mss 65495,sackOK,TS val 1045187079 ecr 0,nop,wscale 7], length 0 10:41:38.614682 lo In IP localhost.12345 > localhost.34904: Flags [S.], seq 2073540954, ack 1391280988, win 33280, options [mss 65495,sackOK,TS val 1045187080 ecr 1045187079,nop,wscale 7], length 0 10:41:38.614701 lo In IP localhost.34904 > localhost.12345: Flags [.], ack 1, win 260, options [nop,nop,TS val 1045187080 ecr 1045187080], length 0 10:41:38.614736 lo In IP localhost.34904 > localhost.12345: Flags [P.], seq 1:50, ack 1, win 260, options [nop,nop,TS val 1045187080 ecr 1045187080], length 49 10:41:38.614739 lo In IP localhost.12345 > localhost.34904: Flags [.], ack 50, win 260, options [nop,nop,TS val 1045187080 ecr 1045187080], length 0 10:41:41.662923 lo In IP localhost.34904 > localhost.12345: Flags [F.], seq 50, ack 1, win 260, options [nop,nop,TS val 1045190128 ecr 1045187080], length 0 10:41:41.703783 lo In IP localhost.12345 > localhost.34904: Flags [.], ack 51, win 260, options [nop,nop,TS val 1045190169 ecr 1045190128], length 0 ``` Server 回應成功並輸出 correct 之封包傳遞情形如下: ```shell 11:04:15.601552 lo In IP localhost.42356 > localhost.12345: Flags [S], seq 4015816221, win 33280, options [mss 65495,sackOK,TS val 1046544066 ecr 0,nop,wscale 7], length 0 11:04:15.601563 lo In IP localhost.12345 > localhost.42356: Flags [S.], seq 2350462786, ack 4015816222, win 33280, options [mss 65495,sackOK,TS val 1046544066 ecr 1046544066,nop,wscale 7], length 0 11:04:15.601581 lo In IP localhost.42356 > localhost.12345: Flags [.], ack 1, win 260, options [nop,nop,TS val 1046544066 ecr 1046544066], length 0 11:04:15.601623 lo In IP localhost.42356 > localhost.12345: Flags [P.], seq 1:50, ack 1, win 260, options [nop,nop,TS val 1046544066 ecr 1046544066], length 49 11:04:15.601632 lo In IP localhost.12345 > localhost.42356: Flags [.], ack 50, win 260, options [nop,nop,TS val 1046544066 ecr 1046544066], length 0 11:04:15.601693 lo In IP localhost.42356 > localhost.12345: Flags [F.], seq 50, ack 1, win 260, options [nop,nop,TS val 1046544067 ecr 1046544066], length 0 11:04:15.642171 lo In IP localhost.12345 > localhost.42356: Flags [.], ack 51, win 260, options [nop,nop,TS val 1046544107 ecr 1046544067], length 0 ``` 可以觀察到成功/失敗的封包傳遞情況相同-正常的三向交握、Client 傳長度為 49 bytes 的 PUSH 包給 Server,之後發送 FIN 包請求斷開連結;最後 Server 再回傳 ACK 包確認其 FIN 包之接收。 :::info 推測:應是 eBPF 流程方面的問題,TODO: 使用 [eBPF debugger](https://github.com/dylandreimerink/edb) 尋找其問題所在。 ::: ## TODO: 比較 echo 伺服器的效能表現 > 比較 [userspace echo server](https://github.com/sysprog21/kecho/blob/master/user-echo-server.c), [kecho](https://github.com/sysprog21/kecho), 和 eBPF 為基礎的 echo 伺服器的效能表現,不只該測試本機 (localhost),也要在同一個網域找其他機器來測試。要解讀實驗表現 ### kecho 運作原理解析 [kecho](https://github.com/sysprog21/kecho) 是一可將伺服器模組掛載至 Kernel space 運行的專案。 kecho server 在實作上透過使用 [Concurrency Managed Workqueue (CMWQ)](https://www.kernel.org/doc/html/latest/core-api/workqueue.html) 將任務有效率的分配給 threads 執行,可更有效率地運用 CPU 的資源;它也提供 Unbounded thread 讓行程可以切換到 idle 的 CPU 上,進一步增加對系統資源的使用率。 另外,開發者不需要對 Kernel threads 進行管理,CWMQ 會透過管理 Thread pool 來進行,以避免因為 daemon 動態建立過多的 threads 所衍生的問題。 > 觀察 [sysprog21/kecho/echo_server.c](https://github.com/sysprog21/kecho/blob/master/echo_server.c) 可知,我們可透過 daemon 操作,達成對 Work queues 的維護。 若有任務長時間佔用系統資源時,CMWQ 更會建立新的 thread 並分配任務到其他的 CPU 來執行。 觀察 [sysprog21/kecho/kecho_mod.c](https://github.com/sysprog21/kecho/blob/master/kecho_mod.c) 中之 `kecho_init_module` 函式可知,CMWQ 主要透過 [linux/workqueue.h](https://github.com/torvalds/linux/blob/master/include/linux/workqueue.h) 中的 `alloc_workqueue` 函式以建立對應之 Work queue。 ```c static int kecho_init_module(void) { int error = open_listen(&listen_sock); if (error < 0) { printk(KERN_ERR MODULE_NAME ": listen socket open error\n"); return error; } param.listen_sock = listen_sock; kecho_wq = alloc_workqueue(MODULE_NAME, bench ? 0 : WQ_UNBOUND, 0); echo_server = kthread_run(echo_server_daemon, &param, MODULE_NAME); if (IS_ERR(echo_server)) { printk(KERN_ERR MODULE_NAME ": cannot start server daemon\n"); close_listen(listen_sock); } return 0; } ``` ### user-echo-server 運作原理解析 除了基本的建立 socket、bind、listen 和 accept,user-echo-server 最關鍵的運作是其透過 epoll 監測事件的發生。 :::danger 本課程的「核心」,一定指「作業系統的核心」,而且若沒有特別聲明,就會是「Linux 核心」。於是,你該避免「核心」出現在作業系統以外的語境。 ::: 觀察 [kecho/user-echo-server.c](https://github.com/sysprog21/kecho/blob/master/user-echo-server.c) 中,`main` 函式的某段程式碼,擷取如下: ```c struct sockaddr_in client_addr; int epoll_events_count; if ((epoll_events_count = epoll_wait(epoll_fd, events, EPOLL_SIZE, EPOLL_RUN_TIMEOUT)) < 0) server_err("Fail to wait epoll", &list); /*...*/ for (int i = 0; i < epoll_events_count; i++) { /* EPOLLIN event for listener (new client connection) */ if (events[i].data.fd == listener) { int client; while ( (client = accept(listener, (struct sockaddr *) &client_addr, &socklen)) > 0) { /*...*/ } if (errno != EWOULDBLOCK) server_err("Fail to accept", &list); } else { /* EPOLLIN event for others (new incoming message from client) */ if (handle_message_from_client(events[i].data.fd, &list) < 0) server_err("Handle message from client", &list); } } ``` 進入 while 迴圈之後,利用 `epoll_wait` 暫停 Server 執行,等待 epoll 所監測的列表有事件產生(如使用者建立連線等)的時候才進行接下來的處理。 當 epoll 監測到有事件發生時,會進入到 for 迴圈的部分,首先會先檢查該事件的 file descriptor 是不是 listener,如果是 listener,就表示有新的連線要求,所以我們利用 `accept` 建立 client 的 file descriptor 並加入 epoll 的列表中追蹤其使用。 如果是以連線的 client 所觸發的事件,則呼叫 `handle_message_from_client` 函式來處理。 ### ebpf-tcp-server 運作原理解析 參照 [透過 eBPF 觀察作業系統行為](https://hackmd.io/@sysprog/linux-ebpf),eBPF 引入全新的 map 機制以便與核心溝通,運作原理如下: ![圖片](https://hackmd.io/_uploads/ByM2F_3L0.png) > 於使用者層級的程式在核心中開闢出一塊空間,建立起一個特製資料庫,讓 eBPF 程式得以互動 (見 `bpf_create_map` 函式),而這個資料庫 key-value 儲存方式,無論是從使用者層級抑或核心內部,都可存取,當然,如此設計最大的優勢就是效率。 而 ebpf-tcp-server 的核心機制正是經由 map 作為 Userspace 與 Kernel space 之溝通橋梁,設計 socket redirect 相關程式操作,並透過對應的 eBPF 程式虛擬空間,在不影響核心程式碼也不用撰寫核心模組的情況下,在 Kernel space 中達到接收與傳送封包的功能,建構 echo server。 觀察 [YSRossi/ebpf-tcp-server/bpf_redir.c](https://github.com/YSRossi/ebpf-tcp-server/blob/main/bpf_redir.c) 之程式碼如下: ```c SEC("sk_skb/stream_verdict") int bpf_redir(struct __sk_buff * skb) { if (skb->family != AF_INET) return SK_PASS; if (skb->local_port != SERVER_PORT) return SK_PASS; if (skb->len == 0) return SK_PASS; int ret; if (skb->remote_ip4 == skb->local_ip4) { struct sockmap_key skm_key = { .family = skb->family, .remote_ip4 = skb->remote_ip4, .local_ip4 = skb->local_ip4, .remote_port = skb->local_port, .local_port = bpf_ntohl(skb->remote_port), }; ret = bpf_sk_redirect_hash(skb, &sockmap_ops, &skm_key, BPF_F_INGRESS); } else { struct sockmap_key skm_key = { .family = skb->family, .remote_ip4 = skb->remote_ip4, .local_ip4 = skb->local_ip4, .remote_port = bpf_ntohl(skb->remote_port), .local_port = skb->local_port, }; ret = bpf_sk_redirect_hash(skb, &sockmap_ops, &skm_key, 0); } if (ret != SK_PASS) bpf_printk("bpf_sk_redirect_hash() failed %d, error \n", -ret); return ret; } ``` Socket redirect 之邏輯即「使用 `bpf_sk_redirect_hash` 將該封包 redirect 到 server socket 的 egress interface」,也就是傳送出去的方向,因此可以在 kernel space 中達到相當於在 user space 呼叫 `recv` 與 `send` 的功能。 另外需判斷 Server 與 Client 是否位於同一台電腦,這會影響 Redirect 的目標;還需判斷過濾封包的條件如 FIN 封包等。 ### 測試程式 解析 [kecho/bench.c](https://github.com/sysprog21/kecho/blob/master/bench.c) 著重在測量「同時處理多個 Clients 連線」的效能表現。 其透過 `create_worker` 建立 `MAX_THREAD` 個數的 threads 同時與 Server 建立連線,並使用 `struct timeval` 記錄「傳送資料與收到資料所花費的時間」,並將結果儲存在 `time_res` 的陣列中。 ### 以原專案之 *bench* 簡單測試 echo server 之效能 #### kecho 參照 [sysprog21/kecho](https://github.com/sysprog21/kecho) 將專案 clone 下來並編譯完成後,透過 `sudo insmod kecho.ko` 掛載核心模組,輸入 `telnet localhost 12345` 與運行在 localhost 上 12345 端口的伺服器建立 TCP 連接,並能輸入字串以測試回應。 而後運行 *bench* 程式,簡單評估 echo server 在特定數量的並行連接(這裡設置為 1000 個 threads;且 `BENCH_COUNT` 設置為 10)下的 Response time。 > It starts by creating number of threads (which is specified via `MAX_THREAD` in *bench.c*) requested, once all threads are created, it starts the benchmarking by waking up all threads with `pthread_cond_broadcast()`, each thread then creates their own socket and sends message to the server, afterward, they wait for echo message sent by the server and then record time elapsed by sending and receiving the message. 觀察 *bench.txt* 之 kecho 測試結果如下: ```c 0 142 1 232 2 282 3 316 /*...*/ 625 6865 626 5791 627 9120 628 5345 629 8720 /*...*/ 996 4415 997 4498 998 4093 999 3810 ``` > 左方為 thread ID;右方為 Response time。 #### user-echo-server :::danger 改進漢語表達。 ::: 測試 user-echo-server 前須先透過 `sudo rmmod kecho` 卸載 kecho 模組,否則會出現以下錯誤訊息: ```shell $ ./user-echo-server Main listener (fd=3) was created. Fail to bind: Address already in use ``` 透過命令 `netstat -tuln | grep 12345` 可觀察如下: ```shell $ netstat -tuln | grep 12345 tcp 0 0 0.0.0.0:12345 0.0.0.0:* LISTEN ``` :::danger 注意用語: * port 是「埠」,port number 就是「埠號」,而非「端口」。作個文明人,用精準的詞彙。 ::: 其指示 12345 埠遭 TCP 連結佔用,原因為起初我們掛載 *kecho.ko* 時,預設使用的埠正為 12345;而 user-echo-server 預設使用的 `SERVER_PORT` 也為 12345,故產生該錯誤。 卸載 kecho 模組後,可透過 `lsmod | grep kecho` 檢查是否卸載成功。 成功建立 user-echo-server 後,一樣運行 *bench* 程式簡單測試伺服器之 Response time: ```c 0 3726 1 3752 2 3769 3 3808 /*...*/ 625 375889 626 375915 627 375827 628 375998 629 375808 /*...*/ 996 1769091 997 1769057 998 1769112 999 1769143 ``` #### kecho v.s. user-echo-server ![圖片](https://hackmd.io/_uploads/B1uJPWa80.png) 很明顯能發現,在伺服器 Response time 方面,「執行於使用者層級」的 TCP echo server 比起「執行在 Linux 核心模式」的 TCP echo server 慢了許多。 鑒於 `bench` 所記錄的時間是從「Client 送出資料到收到 Server 端資料回傳」的時間,而且因為 user-echo-server 並不是一個並行的 server,所以在 epoll 同時收到多個 Client 的事件時,會依據 file descriptor 位於 `epoll_ev` 陣列的位置依序執行,導致處理速度緩慢。 :::danger 針對以下論述,需要有更精準且完整的描述,指出效能瓶頸發生在哪些地方。 ::: 針對 user-echo-server 之效能瓶頸總結: 1. **非並行設計**:使用者層級之 echo server 不是並行處理的伺服器。在同時收到多個客戶端事件時,使用 `epoll` 機制會依據 file descriptor 在 `epoll_ev` 陣列中的位置依序處理每個事件。代表每個事件的處理是「串行 (Serial)」的,會導致總處理時間變長。 2. **Response time delay**:根據 `bench` 所記錄的時間,Response time 是從「客戶端送出資料到收到伺服器端回傳資料」的整段時間。因此 user-level echo server 的串行處理方式,在負載較高時,每個客戶端的請求都會受到之前請求的處理時間影響,造成 Response time 顯著增加。 相比之下,核心模式下的 TCP echo server(kecho)則有以下優勢: 1. **並行能力**:Kecho 通過使用 CMWQ(Consolidated Work Queue)來提高並行處理能力。這允許伺服器同時處理多個客戶端的請求,避免了串行處理所帶來的效能瓶頸。 2. **Locality 優勢**:在核心模式下執行的伺服器可以更佳地利用 CPU 的 Locality 優勢,減少 Context-switch 和資料在 Userspace 和 Kernel space 之間的複製,從而提升處理效率。 3. **多執行緒**:因應 CMWQ 技術,Kecho 可以更有效地利用「可用的多個執行緒」來處理不同的客戶端請求,進一步提升整體效能。 如上敘述,kecho 在整體效能評測上優於使用 epoll 而非並行處理的 user-echo-server。 :::danger 注意用語。 ::: ### 本機端測試 echo server 之平均效能分析 #### 測試程式 鑒於使用 [原 bench.c](https://github.com/sysprog21/kecho/blob/master/bench.c) 測試 ebpf-tcp-server 仍會有 [問題](https://hackmd.io/@sysprog/H1AORs8I0#%E6%B8%AC%E8%A9%A6%E5%95%8F%E9%A1%8C%E8%88%87%E8%A7%A3%E6%B1%BA%E6%96%B9%E6%A1%88),在還未解決該問題之前,為了取得效能測試資料,我將 *bench.c* 中,資料寫進 bench.txt 的邏輯改成「每個 thread 執行結束並斷聯後,就直接將 Response time 資料寫入檔案中」,而非所有 thread 完成後才一併寫入檔案。此舉可在測試時,避免有 thread 掛掉或未能收到伺服器之回應(?,原因待釐清)而導致先前無任何測試資料保留輸出的情形。 :::danger 注意用語: * file 是「檔案」,而非「文件」(document) ::: > commit [8c40d3b](https://github.com/SuNsHiNe-75/kecho/commit/8c40d3b4a66dd9609a51d9aacc69b7694b6bf18a)。 > commit [c0fed36](https://github.com/SuNsHiNe-75/kecho/commit/c0fed36a5893a76a511a87c974ce576b51457fc9)。 #### ebpf-tcp-server :::danger server 就是「伺服器」,不要在敘述中出現過多中英文交雜的狀況。 ::: 在進行實驗之前需輸入命令 `ulimit -n 65535` 以增加檔案描述子的上限,否則伺服器端會輸出以下錯誤訊息:`accept: Too many open files`,其表示伺服器在執行 `accept` 函式時已達檔案描述子的上限。 :::danger 為何 Linux 核心要限制檔案描述子的數量呢? ::: 參照 [Linux 下应用程序最大打开文件数的理解和修改](https://www.cnblogs.com/fengjian2016/p/8759143.html) 文章,解釋「為何 Linux 核心要限制檔案描述子的數量」: 檔案描述子是連結每個程序和它所訪問的檔案(包括檔案和 socket)的指標。每個檔案描述子佔用系統的資源,包含記錄檔案名稱、位置、存取權限等信息的 file entry。而這些 file entry 儲存在「open files table」中,是系統的一部分。因此,若無限制地開啟大量檔案會導致系統資源枯竭,影響整體性能和穩定性。 另外,其分為兩種限制: - 系統層級限制:Linux 系統配置了 open files table 的上限,以防止系統被過多開啟的檔案拖垮。系統層級的檔案描述子限制對所有使用者有效,可以通過修改系統配置檔案(如 */etc/sysctl.conf*)來調整。 - 使用者層級限制:限制每個使用者能夠開啟的檔案數量,避免單個使用者消耗過多資源,影響其他使用者。可以使用 `ulimit -n` 命令來查看和修改這些限制。 :::danger 注意用語: - network 是「網路」,而非「網絡」 務必使用本課程教材規範的術語。 ::: 在高並行環境下(如多執行緒的網路連接),行程可能會因為打開太多檔案或 socket 而沒有正確關閉,導致資源洩漏-這會消耗系統的檔案描述子資源,最終導致 `Too many open files` 之錯誤。 :::danger 不要用 ChatGPT 翻譯或參照低劣的簡體中文素材,以第一手材料為主。 ::: 因此核心需限制檔案描述子數量,以及時發現這些問題。 運行新 *bench* 測試 ebpf-tcp-server 之 Response time 效能結果部分擷取如下: ```c Thread 130540644599488 30 // Thread id: 0 Thread 130543454783168 42 Thread 130544209757888 67 Thread 130543433811648 35 Thread 130544188786368 28 Thread 130544146843328 45 Thread 130543287011008 59 Thread 130543192639168 65 Thread 130542427178688 28 Thread 130544031499968 31 Thread 130534038570688 47 Thread 130542353778368 13 Thread 130544492873408 39 Thread 130541011601088 24 Thread 130540571199168 61 Thread 130543245067968 106 // Thread id: 15 /*...*/ Thread 130542479607488 43710 // Thread id: 331 Thread 130540445370048 43680 Thread 130540738971328 18606 Thread 130541661718208 18476 Thread 130540340512448 75 Thread 130539721852608 69 Thread 130537425471168 18565 Thread 130536198637248 43370 Thread 130537498871488 43 Thread 130537320613568 88 Thread 130536712439488 18505 Thread 130538987849408 22 Thread 130537603729088 42822 // Thread id: 343 /*...*/ Thread 130541514917568 13570 // Thread id: 796 Thread 130541787547328 8594 Thread 130540518770368 54819 Thread 130543958099648 2616 Thread 130543580612288 27378 // Thread id: 800 ``` > 資料左方為 `pthread_self()`;右方為 Response time。 :::danger 使用 gnuplot 製圖。 ::: 同在 `MAX_THREAD` 設為 1000 且 `BENCH_COUNT` 設置為 1 的情況下,經反覆實驗後,該測試程式大多都在 700 至 800 多 threads 時會 blocked,因此只能收集約 800 多筆 Response time 資料。 ![圖片](https://hackmd.io/_uploads/Sk4fDbTUR.png) #### kecho 運行新 *bench* 測試 kecho 之 Response time 效能結果部分擷取如下: ```c Thread 127336760477376 251 // Thread id: 0 Thread 127336403961536 281 Thread 127336466876096 285 Thread 127336508819136 356 /*...*/ Thread 127330762622656 66047 // Thread id: 625 Thread 127334506038976 78380 Thread 127326924834496 71647 Thread 127331307882176 78 Thread 127331182053056 45654 /*...*/ Thread 127335481214656 65343 // Thread id: 797 Thread 127331790227136 60372 Thread 127331905570496 21684 Thread 127331779741376 21178 // Thread id: 800 ``` ![圖片](https://hackmd.io/_uploads/ByUEw-6L0.png) :::danger 如何解釋上圖的統計分佈? ::: #### user-echo-server 運行新 *bench* 測試 user-echo-server 之 Response time 效能結果部分擷取如下: ```c Thread 123808719570624 426 // Thread id: 0 Thread 123817150121664 648 Thread 123814077793984 61 Thread 123817307408064 126 /*...*/ Thread 123806895048384 47293 // Thread id: 625 Thread 123808300140224 58431 Thread 123817192064704 74 Thread 123809873004224 22026 Thread 123807733909184 121 /*...*/ Thread 123809883489984 119189 // Thread id: 797 Thread 123816604862144 70824 Thread 123808730056384 51914 Thread 123817045264064 117844 // Thread id: 800 ``` ![圖片](https://hackmd.io/_uploads/rklUv-T8C.png) :::danger 如何解釋上圖的統計分佈? ::: #### 數據圖表 整合 3 種伺服器透過 *bench* 測試後,產出之 Response time 資料之分布圖表如下: ![圖片](https://hackmd.io/_uploads/Sk5dP-aUC.png) 轉換成箱型圖以更簡潔地展示實驗結果: ![圖片](https://hackmd.io/_uploads/S1s9smT80.png) #### 效能分析 擷取前 800 筆 Response time 資料並進行「算數平均」,以利比較 3 種不同設計之伺服器效能。 | Server | Average Response Time (in microseconds) | | ---------------- |:--------------------------------------- | | kecho | 22589.4425 | | user-echo-server | 41863.085 | | ebpf-tcp-server | 21360.23875 | 可以發現在本地端以測試程式分別對此 3 種伺服器進行壓力測試,結果為 ebpf-tcp-server 略優於 kecho server 再優於 user-echo-server。 我們可回顧下圖之 eBPF socket redirect 流程: ![圖片](https://hackmd.io/_uploads/B13rGNBH0.png) 當傳送端與目的端「在同一台電腦(localhost)」時,可省略 Network stack 的處理,直接在兩個 Socket 間傳輸;因此 ebpf-tcp-server 之效能,預期上是會優於「封包會通過 Network stack 處理」之 kecho 及 user-echo-server。 ebpf-tcp-server 雖然少了並行處理的能力,但其利用 eBPF 掛載到核心中,以直接在 Kernel space 動態處理 socket 與 socket 間封包的 redirect,在此測試中可證明其與「有並行處理能力且運作於 Kernel space 的 kecho」有接近甚至超越的性能。 若追求性能,透過 eBPF 打造 TCP server 及直接將 echo server 載入至核心是很好的選擇;但若想易於開發和靈活性,我認為在使用者層級運行的 echo server 仍是可行的選擇。 思考:若加強 kecho 的並行處理能力,或急遽提高 Threads 的數量,ebpf-echo-server 是否還有效能上的勝算? ### 同網域之其他機器測試 echo server 之平均效能分析 #### 實驗環境 於同一臺電腦上,建立 2 臺虛擬機器分別作為 TCP Server 端及執行 bench 之 Client 端,其中 TCP Server 端之電腦設備與 [本機端測試之實驗環境](https://hackmd.io/@sysprog/H1AORs8I0#%E5%AF%A6%E7%8F%BE%E7%92%B0%E5%A2%83) 相同。 更改使 Client 端之封包傳送對象為另一臺電腦之 IP(而非 `127.0.0.1`)。 #### 數據圖表 整合 3 種伺服器透過 *bench* 測試後,產出之 Response time 資料之折線圖表如下: ![圖片](https://hackmd.io/_uploads/HJ8cD-6IC.png) :::danger 改用其他方式展現實驗結果,例如: http://gnuplot.info/demo_5.2/boxplot.html ::: 轉換成箱型圖以更簡潔地展示實驗結果: ![圖片](https://hackmd.io/_uploads/Byr8cmaLR.png) #### 效能分析 特別的是,在此實驗環境下的 ebpf-tcp-server 可以負擔 900 多 threads 之互動後才 blocked(原因待調查)。 :::danger 使用 Ftrace 來分析,參見《Demystifying the Linux CPU Scheduler》第六章。 ::: 因此,效能分析擷取前 900 筆 Response time 資料並進行「算數平均」以提供更準確之評測結果,來比較 3 種不同設計之伺服器效能。 | Server | Average Response Time (in microseconds) | | ---------------- |:--------------------------------------- | | kecho | 48725.63111 | | user-echo-server | 67953.77111 | | ebpf-tcp-server | 36346.39111 | > 對比起在本機端測試伺服器效能,當傳送/接收端在不同機器上,可能因網路延遲或更多的 Context-switch 之因素,導致三種伺服器之平均 Response time 皆增加。 在不同機器測試 echo server 之 Response time 能更明顯地看出 3 種 echo server 的效能落差。 尤其是 ebpf-tcp-server 在效能上極具優勢,原因可回頭思考基於 eBPF echo server 之原理-若其在不同電腦,則可在另一端之 socket 收到封包後,將封包 redirect 到「該封包的傳送端(Egress interface)」,以在「不將資料複製到 User 端」的情形下,回傳封包給對方。可節省兩次 Memory copies 的時間及空間,達到 **Zero-copy** 之成效。 回顧下圖: ![圖片](https://hackmd.io/_uploads/ByDP7VHrC.png) 左半圖是執行於「使用者層級」之 user-echo-server 之封包傳遞示意圖;右半圖則為透過 socket redirect 技術所建構之 ebpf-tcp-server。可證實 user-echo-server 及 ebpf-tcp-server 之效能差距之巨大的原因。 另外,執行於「核心層級」之 kecho 由於無「基於 eBPF 之 socket redirect」的輔助,即使伺服器不需要跨到 Userspace 去運行,但相比 ebpf-tcp-server,其仍須呼叫 send/recv 等 System call 以便與 Client 溝通,硬生生多了許多系統呼叫的開銷。而此缺點也在如上之「效能評測表格」中反映出來。 :::info TODO: - 使用 eBPF Debugger 或 Ftrace 尋找 recv 函式 blocked 的問題所在。 - 以更多種效能指標(如 Throughput 等)評測伺服器效能。 :::