--- tags: LINUX KERNEL, LKI --- # 事件驅動伺服器:原理和實例 > 資料整理: [eecheng87](https://github.com/eecheng87), [jserv](https://wiki.csie.ncku.edu.tw/User/jserv) > [The Evolution of Linux I/O Models: A Path towards IO_uring](https://docs.google.com/presentation/d/1CJiDAO6GbWwpeaeG-TMN751H8Bpq_nKQnnZE_yclFB4/edit?usp=sharing) / [錄影](https://youtu.be/Y0npaFZVKYo) ==[直播錄影](https://youtu.be/4ahU3xN5dMY)== ## I/O Model 在討論伺服器前,應先理解 I/O Model,後者不僅會影響著應用程式的行為,更直接影響其 I/O 存取速度。本文介紹常見的 I/O Model,如 * Blocking I/O * Non blocking I/O * I/O multiplexing * Asynchronous I/O ### Blocking I/O ![image](https://hackmd.io/_uploads/rymfwIqKa.png) > [圖片來源](https://programmersought.com/article/56383199720/) 當行程在使用者層級呼叫 `read` 系統呼叫後,經過 mode transition,進入核心等待資料準備,當需要資料還沒抵達緩衝區 (buffer),行程會持續待在核心空間。從使用者層級的角度來說,就是 blocking 在一個系統呼叫,自然無法繼續執行程式: ```c read(fd, buf, size); /* reach here until get "size" bytes data */ do_something(); ... ``` 由於 blocking (阻塞) 的特性,高效的網頁伺服器實作中,會避免只用此 I/O Model。 ### Non blocking I/O ![image](https://hackmd.io/_uploads/By3fPUcYp.png) > [圖片來源](https://programmersought.com/article/56383199720/) 當行程呼叫 `read` 後,經過 mode transition,進入核心等待資料準備,若核心的資料尚未準備好,會馬上返回使用者模式的行程,避開阻塞。所以從使用者層級的角度來看,不再因資料尚未準備好,而阻塞於系統呼叫。 可藉由 `fcntl` 系統改變 file descriptor 的屬性 (即 `O_NONBLOCK`),通知核心 fd 應看作 non-blocking 來處理。 實際案例可見高效伺服器的實作 [lwan](https://github.com/lpereira/lwan),在 [lwan-readahead.c](https://github.com/lpereira/lwan/blob/f51cd6cc6f929107c283ec3dfda9bab431a14d87/src/lib/lwan-readahead.c#L120) 中前見運用 non-blocking IO 的實例: ```c while (true) { struct lwan_readahead_cmd cmd[16]; ssize_t n_bytes = read(readahead_pipe_fd[0], cmd, sizeof(cmd)); ssize_t cmds; if (UNLIKELY(n_bytes < 0)) { if (errno == EAGAIN || errno == EINTR) continue; lwan_status_perror("Ignoring error while reading from pipe (%d)", readahead_pipe_fd[0]); continue; #if PIPE_DIRECT_FLAG } else if (UNLIKELY(n_bytes % (ssize_t)sizeof(cmd[0]))) { lwan_status_warning("Ignoring readahead packet read of %zd bytes", n_bytes); continue; #endif } ``` 此時的 `read` 已設定為 non-blocking,因此即使資料尚未準備好,都會馬上返回。接著要判斷自核心返回使用者層級的原因,若只是單純資料沒準備好,收到的回傳值就是 `EAGAIN`,剩下的工作就是找機會再次呼叫 `read`。 ### I/O multiplexing ![image](https://hackmd.io/_uploads/S1_QPLqFa.png) > [圖片來源](https://programmersought.com/article/56383199720/) 簡單來說,I/O multiplexing 就是 [select](https://man7.org/linux/man-pages/man2/select.2.html) / [epoll](https://man7.org/linux/man-pages/man7/epoll.7.html) 的行為,雖說二者仍有差別 (`epoll` 有別於 `select`,沒有監聽數量的限制,且在找尋是透過紅黑樹,找尋的複雜度是 $O(logN)$,而 `select` 是 $O(N)$ ),但行為可歸納為一類。 > [poll vs select vs event-based](https://daniel.haxx.se/docs/poll-vs-select.html) 以 `epoll` 的例子來說,當行程呼叫 `epoll_wait` 後,經過 mode transition,開始核心監聽 (監聽的時間根據傳入的 `timeout` 決定),時間到後切回使用者層級,回傳的數值代表剛監聽到的事件數量,根據數量來做對應的處理 (利用 callback)。 epoll 操作的架構很固定,大致如下: ```c nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1); ... for (n = 0; n < nfds; ++n) { if (events[n].data.fd == listen_sock) { /* do connection */ } else { /* do request */ do_use_fd(events[n].data.fd); } } ``` I/O multiplexing 的好處是,只用單一執行緒即可「同時處理」多個網路連接的 I/O,這裡的「同時處理」是巨觀,而非微觀表現。但只有單一執行緒的話,用 event-driven 來描述較恰當,也就是所謂的事件觸發,當有事件 (無論是 connection, reading requests 或 write requests) 來臨/完成,才做對應的處理,而不是一直卡在那裡等結果,浪費 CPU 資源。 注意: `epoll_wait` 本質上還是 blocking 操作。 ### Asynchronous I/O ![image](https://hackmd.io/_uploads/BkZEPU5Ya.png) > [圖片來源](https://programmersought.com/article/56383199720/) 倘若 I/O multiplexing 已讓人拍案叫絕,Asynchronous I/O 就更玄妙,可想像為 non-blocking I/O 的加強版:呼叫 `read` 後切換至核心空間時,此時**即使資料尚未就緒**,仍會馬上返回使用者層級,讓原本的程式碼得以繼續執行,而方才的工作留在核心,轉交其他核心執行緒 (kernel thread) 來完成。當完成後,核心會藉由 signal 等方式通知使用者模式的行程。 ```c aio_read(fd, buf, size); ----- // do read in background (kernel) /* return immediately */ | do_something_else(); | | wait_complete(); <----- ``` 這種模式乍聽之下的確無懈可擊,[POSIX asynchronous I/O (AIO)](https://man7.org/linux/man-pages/man7/aio.7.html) 也很早就納入 `POSIX.1` 規範,但 AIO 實作和應用上卻都有很大的挑戰,不論是 glibc POSIX AIO 甚至是 Linux native AIO。所以目前主流的高效伺服器通常都以 I/O multiplexing 為主。 ## Event-driven Server Event-driven 是種概念,沒有明確的科學定義,但要了解 event-driven 的行為不難,參考〈[如何向你阿嬤解釋 "Event-Driven" Web Servers](https://daverecycles.tumblr.com/post/3104767110/explain-event-driven-web-servers-to-your-grandma)〉: ### 傳統網頁伺服器 想像有個 pizza 店,只雇用一個店員,當有客戶打電話進來訂餐時,店員收到訂單後不會掛斷電話,直到 pizza 做好後,店員通知客戶可以來拿後,再掛斷電話。 這種模式很明顯的缺點是,在服務某客戶期間,無法接洽其他客戶,直接影響著用戶體驗 (latency) 與單位時間的服務量 (throughput)。 ### 事件驅動的網頁伺服器 這個 pizza 店同樣只雇用一個店員,當有客戶打電話進來訂餐時,店員收到訂單後會馬上掛斷電話,然後等到 pizza 製作完畢,再打電話通知客戶可以來拿取。期間因為電話已掛斷,所以仍可持續收到更多的訂單。當然可請更多員工 (worker thread in thread pool) 在廚房烤 pizza 來增加單位時間的服務量 (throughput)。不過一旦員工數量持續增長,pizza 店的容量反而是限制 (capacity)。 ## 案例探討: [NGINX](https://nginx.org/) ### Design ![image](https://hackmd.io/_uploads/HkREwL5Ka.png) > [圖片出處](https://www.nginx.com/blog/inside-nginx-how-we-designed-for-performance-scale/) NGINX 的組成為一個 Master 行程,負責做初始化相關的工作,例如組態設定, 建立 worker, 管理 worker (接收來自外界的 signal,向各 worker 發送 signal,監控 worker 的運行狀態)。至於 Worker 就是專門來處理客戶端的請求 (一般是網頁瀏覽器),NGINX 的 worker 會對 CPU 設定 [affinity](https://linux.die.net/man/2/sched_getaffinity),因此可降低 thread (worker) 間的 context switch 數量,抑制其引起效能低落的可能性。 為了確保 worker 各自處理獨立的連線,worker 間會嘗試獲取 accept lock 來決定連線。每個 worker 使用 asynchronous & non-blocking 的方式,達到高並行程度的監聽事件(非處理事件,因為一個 worker 在一個時間最多只能處理一個事件)。 ### Event loop 每個採用事件驅動模型開發的網頁伺服器,大多會有一個主要的迴圈 (main loop,若有多個 worker 則有多個迴圈),後者負責的工作,以核心空間 (涉及系統呼叫的內部行為) 來說,包含接收新的連線、接收請求、回應請求等等。以使用者層級來說,則包含封包過濾、內容壓縮等操作。 NGINX 針對 Linux 的實作中,worker 的 main loop 可參考 [ngx_epoll_module.c](https://github.com/nginx/nginx/blob/a64190933e06758d50eea926e6a55974645096fd/src/event/modules/ngx_epoll_module.c) 中的 `ngx_epoll_process_events`: ```c static ngx_int_t ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags) { ... events = epoll_wait(ep, event_list, (int) nevents, timer); ... for (i = 0; i < events; i++) { c = event_list[i].data.ptr; ... revents = event_list[i].events; ... if ((revents & EPOLLIN) && rev->active) { ... } wev = c->write; if ((revents & EPOLLOUT) && wev->active) { ... } } return NGX_OK; } ``` 可見到,雖然 NGINX 的 main loop 超過 200 行,但其骨架還是照著 `epoll` 常見的應用案例。先用 `epoll_wait` 監聽準備好的 events,再逐一處理。 ### 單一連線請求的流程 NGINX 完成一次連線請求的流程非常複雜,包含大量的使用者層級封包處理邏輯,本文不會細究。以下提供上至 main loop 內呼叫 `rev->handler` (連線請求),下至底層系統呼叫 `sendfile` 的流程: * `ngx_http_wait_request_handler` $\to$ `ngx_http_process_request_line` * `ngx_http_process_request_line` $\to$ `ngx_http_process_request_headers` ... * `ngx_http_write_filter` $\to$ `c->send_chain` * `c->send_chain` === `ngx_send_chain` * `ngx_send_chain` === `ngx_io.send_chain` (`event/ngx_event.h`) * `ngx_io` === `ngx_os_io` (`event/modules/ngx_epoll_module.c`) * `ngx_os_io` === `ngx_linux_io` (`os/unix/ngx_linux_init.c`) * `static ngx_os_io_t ngx_linux_io = {..ngx_linux_sendfile_chain}` (`os/unix/ngx_linux_init.c`) * `sendfile64` [註1] filter module 由眾多 filters 組成,主要是負責對輸出的內容進行處理,可對輸出進行修改。所有的 filter 模組都被組織成一條 list,輸出會依次穿越所有的 filter。`ngx_http_write_filter` 為最後一個 filter,將最後過濾好的資訊傳送出去。 [註2] filter 的順序很重要,在編譯的時候就決定好,可見 [auto/modules](https://github.com/nginx/nginx/blob/a64190933e06758d50eea926e6a55974645096fd/auto/modules)。 [註3] 可透過 GDB 的命令 `catch syscall SYSCALL_NUMBER`, `set follow-fork-mode child`, `r`, 再 `where`,用以觀察 NGINX 處理請求的流程。 ### Thread pool #### 為何要有 thread pool? 依據《[NGINX Development guide](http://nginx.org/en/docs/dev/development_guide.html#threads)》 > Keep in mind that the threads interface is a helper for the existing asynchronous approach to processing client connections, and by no means intended as a replacement. 因為 Linux 本身沒有提供完整 AIO 介面 (但 FreeBSD 有),因此沒辦法完整的使用 NGINX 的高度並行設計 (thread pool 支援)。 但在 Linux 上的某些特殊情境仍有使用 thread pool 的好處,可參考 [Thread Pools in NGINX Boost Performance 9x!](https://www.nginx.com/blog/thread-pools-boost-performance-9x/)。說明在傳輸極大檔案時,可藉由透過 thread pool 來增加 throughput。 #### 機制 NGINX 實作 thread pool 的原理/流程: worker process 拿到任務後會把 task 丟到 thread pool,然後 worker process **就假設成功,接續處理下一個 task**。 #### 程式碼 以下追蹤程式碼,應證上述總結: ```c ngx_linux_sendfile(ngx_connection_t *c, ngx_buf_t *file, size_t size) { ... #if (NGX_THREADS) if (file->file->thread_handler) { return ngx_linux_sendfile_thread(c, file, size); } #endif ``` 回到無 thread pool 的 NGINX 處理連線請求時,`sendfile` 的所在處 `ngx_linux_sendfile`,當設定 thread 版本 (by `--with-thread`) 即啟用 `NGX_THREADS`,所以真正負責任務的函式為 `ngx_linux_sendfile_thread`。 ```c static ssize_t ngx_linux_sendfile_thread(ngx_connection_t *c, ngx_buf_t *file, size_t size) { ... task = c->sendfile_task; if (task == NULL) { task = ngx_thread_task_alloc(c->pool, sizeof(ngx_linux_sendfile_ctx_t)); if (task == NULL) { return NGX_ERROR; } task->handler = ngx_linux_sendfile_thread_handler; c->sendfile_task = task; } ... if (file->file->thread_handler(task, file->file) != NGX_OK) { return NGX_ERROR; } return NGX_DONE; } ``` 這裡有三件事需要注意: 1. `task->handler` 是用來記錄稍後 thread 需要做的工作內容,因此這裡很明顯,給 thread 做的事為 `ngx_linux_sendfile_thread_handler` 2. `file->thread_handler` 負責把至此設定好的 task meta data (如 handler 等...) 置入 thread pool (藉由 `ngx_thread_task_post`) 3. 最後直接回傳 `NGX_DONE`,事實上任務可能尚未執行完畢。 延續 (1): ```c static void ngx_linux_sendfile_thread_handler(void *data, ngx_log_t *log) { ... file = ctx->file; offset = file->file_pos; again: n = sendfile(ctx->socket, file->file->fd, &offset, ctx->size); if (n == -1) { ctx->err = ngx_errno; } else { ctx->sent = n; ctx->err = 0; } ... if (ctx->err == NGX_EINTR) { goto again; } } ``` thread 真正做的任務為 `ngx_linux_sendfile_thread_handler`,此 handler 就是負責 `sendfile` 並試到其成功為止。 而此 handler 真正被呼叫的地方在 thread 的 loop 中: ```c static void * ngx_thread_pool_cycle(void *data) { ... for ( ;; ) { ... task->handler(task->ctx, tp->log); ... } ``` `ngx_thread_pool_cycle` 基本上就是 consumer,負責: 1. 上鎖後搶任務 2. 執行任務 3. 把任務置入 finish list 4. notify。 舉 `epoll` 系列來說,notify 的實作存於 `ngx_epoll_module_ctx` 中的成員 `ngx_epoll_notify`。 ```c static void ngx_epoll_notify_handler(ngx_event_t *ev) { ... n = read(notify_fd, &count, sizeof(uint64_t)); ``` notify_handler 的 `read` 會觸發 `EPOLLIN` 信號。因為已將 `notify_fd` 透過 `epoll_ctl` (於 `ngx_epoll_notify_init`) 加入 `ep` 作為感興趣的執行案例 (instance), 所以可由 `ep` (`epoll` file descriptor) 感知 (透過 `ngx_epoll_process_events` 中的 `epoll_wait`)。 延續 (2): `thread_handler` 可能指向 `ngx_http_cache_thread_handler`, `ngx_http_upstream_thread_handler` 等等,但函式內都含 `ngx_thread_task_post`,負責把任務移到丟到 queue 上。 #### Thread pool 與 epoll loop 的關係 統整上述討論和程式碼行為,展示如下圖: ![](https://i.imgur.com/sc1t6aS.png) 補充一些細節: 1. 在 epoll_module 內會初始化與 notify 的資料,包含: * 建立 `notify_fd` 供 epoll loop 與 thread 間溝通 * 設定 `notify_event` 並加入 `epoll` interested list (藉由 `epoll_ctl`) 2. 當有 notify 事件產生 (`ngx_epoll_notify` 產生的 `EPOLLIN` 訊號),`epoll_wait` 收到後,擷取 event 內的處理函式 (`rev->handler`)。此 handler 即 `ngx_epoll_notify_handler`,於 `epoll_notify_init` 內設定的。 3. `ngx_epoll_notify_handler` 是會在 epoll main loop 中呼叫,其內容包含: * `read`: 銜接 thread 中 `ngx_notify` 產生的 `write` * 呼叫 handler,此 handler 為呼叫 `ngx_notify` 的參數,舉例來說: `ngx_thread_poll_handler`