動態

詳情 返回 返回

I/O 多路複用與網絡服務器併發策略 - 動態 詳情

原文:https://nullwy.me/2023/07/io-multiplexing-network-server/
如果覺得我的文章對你有用,請隨意讚賞

目前主流的網絡服務器,網絡 I/O 相關的底層最核心的技術都是 I/O 多路複用(I/O Multiplexing),比如 Apache HTTP Server、Nginx、Redis 等。本文嘗試解釋各種 I/O 模型,包括解釋什麼是 I/O 多路複用,同時也總結 I/O 多路複用底層的系統調用 select、poll、kqueue 和 epoll 的演進和區別,並編寫了使用這些函數的示例代碼。另外,本文還總結了各種基於 I/O 多路複用實現的網絡服務器的併發策略的三種模式,包括對 Apache HTTP Server、Nginx 和 Redis 等網絡服務器的併發策略的具體案例的解析。

I/O 模型與多路複用

類 Unix 系統下的 I/O 操作,默認是阻塞 I/O(Blocking I/O,縮寫為 BIO)。比如,當一個進程發出了讀操作請求,但沒有可訪問的數據時,該進程通常會阻塞在內核中,直到出現可以訪問的數據為止。然而,進程有時要處理對多個描述符的 I/O 操作,需要在多個文件描述符上阻塞,典型的場景是終端 I/O 和網絡 I/O。

例如,有一個遠程登錄程序,它要從鍵盤讀入數據然後把這些數據通過套接字發送到一個遠程的計算機上。這個程序還需要從和遠程終端相連接的套接字上讀取數據,並將數據顯示於屏幕上。如果進程在讀鍵盤數據時阻塞,它就不能讀那些從遠程終端發送到屏幕上的數據。這樣一來,在來自遠程終端的更多數據到達之前,用户就不知道該通過鍵盤輸入些什麼,於是,死鎖便產生了。相反的,如果進程在讀從遠程終端送來的數據時阻塞,它將不能讀來自鍵盤終端的數據。

歷史上,Unix 系統通過使用多個進程讓應用能同時處理多個文件描述符,這些進程間可以通過管道或者是其他的進程間通信方法進行通信。然而,如果處理上下文切換的代價比處理輸入的代價更大,那麼這種方法就會導致巨大的系統開銷,因為它要求在進程間進行頻繁的上下文切換。並且,在一個進程內實現這種應用會顯得比較直觀。由於上述原因,BSD 提供了三種機制,允許對描述符進行多路 I/O 訪問,非阻塞式 I/O、 I/O 多路複用和信號驅動 I/O1

  • 非阻塞 I/O(Nonblocking I/O,縮寫為 NIO):進程對描述符嘗試 I/O 操作,如果描述符未就緒(ready),系統不把本進程投入睡眠,而是返回一個錯誤(EAGAINEWOULDBLOCK)。進程收到錯誤後,要麼放棄,要麼不停地輪詢(polling),直至發現有描述符可以進行 I/O 操作為止。這種輪詢的方法的問題在於,進程必須連續不斷地運行,檢查描述符是否就緒,很浪費 CPU 時間。
  • I/O 多路複用(I/O Multiplexing):讓系統提供一種方法在多個感興趣的描述符中查找哪個描述符可以進行 I/O 操作,如果沒有這樣的描述符,系統會讓進程處於睡眠狀態,直到出現這樣的描述符為止。這種方法的缺點是對於每個操作,進程要完成兩個系統調用,一個用來查找就緒描述符,另一個是 I/O 操作本身。非阻塞 I/O 是在用户空間輪詢查找就緒描述符,而 I/O 多路複用是執行特殊的系統調用在內核空間查找就緒描述符。
  • 信號驅動 I/O(Signal-Driven I/O):當可以進行 I/O 操作時,使所有描述符發信號。進程只用等待這些信號就可以知道是否可以進行 I/O 操作。這種方法的缺點在於捕獲這些信號的開銷是巨大的,所以此方法對於那些涉及大量 I/O 操作的應用並不實用。

類 Unix 系統下,默認的 I/O 操作都是阻塞 I/O。有兩種方法可以將描述符設置非阻塞 I/O:(1) 如果是調用 open() 獲得描述符,則可以在調用時設置 O_NONBLOCK 標誌;(2) 對於已經打開的一個描述符,則可調用 fcntl(),由該函數為描述符設置 O_NONBLOCK 標誌。另外,對於網絡套接字的描述符,如果想在獲得描述符時直接指定為非阻塞 I/O,可以在調用 socket() 或 accept() 時傳入 SOCK_NONBLOCK 標誌,當然也可以在獲得描述符後,再調用 fcntl()修改。

I/O 多路複用,最早是在 4.2BSD(1983.08)中由 select() 系統調用提供的。雖然該系統調用主要用於終端 I/O 和網絡 I/O,但它對其他描述符同樣是起作用的。poll() 是另外一個實現 I/O 多路複用的系統調用,和 select() 功能幾乎相同。SVR3(1987)在增加 STREAMS 機制時增加了 poll() 系統調用。但在 SVR4 (1988)之前,poll() 只對 STREAMS 設備起作用。SVR4 開始支持對任意描述符起作用的 poll()select()poll() 系統調用,都是在 POSIX.1-2001 開始標準化定義,然而從可移植性角度考慮,支持 select() 的系統比支持 poll() 的系統要多,所以在應用的實現上,相比於 poll() 基於 select() 實現更多。另外 POSIX 還定義了 pselect(),它是能夠處理信號阻塞並提供了更高時間分辨率的 select() 的增強版本。

在 Linux 系統下,poll() 系統調用從 2.1.23 版本(1997.01)開始提供,而 poll() 庫函數由 libc 5.4.28(1997.05)開始提供。早期 Linux 內核未提供 poll() 系統調用,glibc 使用 select() 來模擬實現 poll()。另外,Linux 還提供特有的 I/O 多路複用解決方案,即 epoll,詳細介紹參見下文。

為了能持續不斷的監聽 I/O 操作就緒事件,應用實現上需要循環調用 select()poll()。為了方便使用,封裝各個不同的 I/O 多路複用函數的第三方庫,通常會把這樣的循環調用被抽象為事件循環(event loop),然後把 I/O 就緒事件的處理抽象成回調函數(callback)。最早的提供事件循環(event loop)抽象的典型的第三方庫是 libevent 庫(最早在 2002.04 發佈)。

信號驅動 I/O,在描述符就緒時內核會發送 SIGIO 信號。但是信號驅動 I/O 對於 TCP 套接字近乎無用,問題在於 SIGIO 信號產生得過於頻繁,並且它的出現並沒有告訴我們發生了什麼事件,無法區分觸發信號的各種情況。在 UDP 上使用信號驅動式 I/O 沒有上述問題。關於信號驅動 I/O 的詳細闡述,可以參閲《UNIX網絡編程 卷1》的第 25 章2

描述符就緒條件

select()poll() 系統調用是在多個文件描述符中查找就緒(ready)的描述符。就緒條件具體指是什麼呢?select() 的 man 文檔,有如下描述(poll() 的就緒條件類似,不展開討論):

A file descriptor is considered ready if it is possible to perform a corresponding I/O operation (e.g., read(2), or a sufficiently small write(2)) without blocking.
...
A file descriptor is ready for reading if a read operation will not block; in particular, a file descriptor is also ready on end-of-file.
A file descriptor is ready for writing if a write operation will not block. However, even if a file descriptor indicates as writable, a large write may still block.

針對網絡套接字描述符的就緒條件,《UNIX網絡編程 卷1》如下總結:

套接字描述的就緒條件小結

表中的“有數據可讀”含義是,該套接字接收緩衝區中的數據字節數大於等於套接字接收緩衝區低水位標記的當前大小。對這樣的套接字執行讀操作不會阻塞並將返回一個大於 0 的值(也就是返回準備好讀入的數據)。接收低水位標記,可以通過調用 setsockopt() 的 SO_RCVLOWAT 選項來設置,默認值為 1。

表中的“有可用於寫的空間”含義是,該套接字發送緩衝區中的可用空間字節數大於等於套接字發送緩衝區低水位標記的當前大小,並且或者該套接字已連接,或者該套接字不需要連接(如UDP套接字)。發送低水位標記,可以通過調用 setsockopt() 的 SO_SNDLOWAT 選項來設置,默認值為 1024。

表中的“關閉連接的讀一半”和“關閉連接的寫一半”含義是,套接字的 TCP 連接接收了關閉 FIN,此時會收到讀就緒事件和寫就緒事件。對這樣的套接字做讀操作將不阻塞並返回 0(也就是返回 EOF);對這樣的套接字做寫操作將產生 SIGPIPE 信號(Broken pipe: write to pipe with no readers)。

I/O 模型的比較

上文闡述的就是 Unix 系統的 4 種 I/O 模型,阻塞 I/O、非阻塞 I/O、I/O 多路複用和信號驅動式 I/O。另外,還有一種 I/O 模型是,異步 I/O(Asynchronous I/O,縮寫為 AIO)。異步 I/O,由 POSIX 規範定義,工作機制是,告知內核啓動某個操作,並讓內核在整個操作(包括將數據從內核複製到我們自己的緩衝區)完成後通知進程。這種模型與信號驅動模型的主要區別在於:信號驅動 I/O 是由內核通知我們何時可以啓動一個 I/O 操作,而異步 I/O 模型是由內核通知我們 I/O 操作何時完成。POSIX 定義的異步 I/O 的函數為,aio_write()aio_read() 等。

關於 POSIX 異步 IO,Linux 的 aio 的 man 文檔,有如下説明:

The current Linux POSIX AIO implementation is provided in user space by glibc. This has a number of limitations, most notably that maintaining multiple threads to perform I/O operations is expensive and scales poorly. Work has been in progress for some time on a kernel state-machine-based implementation of asynchronous I/O (see io_submit(2), io_setup(2), io_cancel(2), io_destroy(2), io_getevents(2)), but this implementation hasn't yet matured to the point where the POSIX AIO implementation can be completely reimplemented using the kernel system calls.

本質上,Linux 下的 POSIX AIO 是在用户空間下用線程模擬實現的 AIO,並非真正的 AIO,性能很差,所以很少被使用。

Linux 內核實現的 AIO 是 io_submit、io_setup、io_getevents 等系統調用,也被成為“Linux Native AIO”,或者縮寫為 KAIO(kernel AIO),從 Linux 2.5 開始支持(2001.11),這些系統調用對應的庫函數由 libaio 庫提供。但是 Linux Native AIO 幾乎不可用,只適合以 O_DIRECT 方式做直接 IO(無緩存的 I/O)。如果真的實現了異步 AIO,io_submit 系統調用不應該阻塞,但是對緩存 I/O、網絡訪問、管道等,io_submit 會發生阻塞,整個操作將在 io_submit 系統調用期間執行,並且通過調用 io_getevents,I/O 操作完成結果可以立即訪問,這樣也就破壞了異步 I/O 的目的3

最新的內核實現的 AIO 是 io_uring,已經被 Linux 5.1(2019.05)採納。很多開源項目,比如 libevent、libuv、Nginx、Redis 等,都有打算支持或甚至已經支持 io_uring。io_uring 的雜類資料整理,可以參考“Awesome io_uring”4。本文主要關注 I/O 多路複用,io_uring 不再展開討論。

《UNIX網絡編程 卷1》對這 5 種 I/O 模型做了對比2

5 種 I/O 模型的比較

可以看出,前 4 種模型的主要區別在於第一階段(等待描述符就緒),因為它們的第二階段是一樣的:在數據從內核複製到調用者的緩衝區期間,進程阻塞於 recvfrom 調用。相反,異步 I/O 模型在這兩個階段都要處理,從而不同於其他 4 種模型。

各個 I/O 模型,用户空間的應用與內核空間的交互過程如下圖所示(信號驅動 I/O 實際場景較少使用,所以忽略)5

阻塞式 I/O 非阻塞式 I/O
非阻塞式 I/O + I/O 多路複用 異步 I/O

通常對“I/O 多路複用”術語的理解,其實就是特指,由 select() 、poll() 或類似的系統調用實現的在多個文件描述符中查找就緒狀態描述符的技術。不過,根據 McKusick 書籍的描述1I/O 多路複用也可以泛指為,單個進程同時處理多個文件描述符的技術,與之相對立的技術是早期的由多個進程同時處理多個描述符的解決方案。廣義理解的話,I/O 多路複用包括非阻塞 I/O、狹義的 I/O 多路複用、信號驅動式 I/O、異步 IO 等技術

單獨的“多路複用(multiplexing)”術語,維基百科的解釋是,一個通信和計算機網絡領域的專業術語,多路複用通常表示在一個信道上傳輸多路信號或數據流的過程和技術。

阻塞、非阻塞與同步、異步的區別

在概念上,阻塞 I/O 和非阻塞 I/O,是根據系統是否會阻塞進程的執行而區分的:

  • 阻塞 I/O,在執行 I/O 操作後,如果 I/O 操作的描述符未就緒,系統會讓進程進入睡眠狀態,直到描述符就緒為止。
  • 非阻塞 I/O,在執行 I/O 操作後,不會阻塞當前進程,可以繼續執行其他的任務。

另外,POSIX 定義了同步 I/O(Synchronous I/O)和異步 I/O(Asynchronous I/O)兩個術語2

  • 同步 I/O 操作,導致請求進程阻塞,直到 I/O 操作完成
  • 異步 I/O 操作,不導致請求進程阻塞。
A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes.
An asynchronous I/O operation does not cause the requesting process to be blocked.

按照這個定義,阻塞 I/O、非阻塞 I/O、I/O 多路複用和信號驅動 I/O 都是同步 I/O,因為其中真正的 I/O 操作將阻塞進程(第二階段,將數據從內核複製到用户空間的緩衝區階段)。只有異步 I/O 模型與 POSIX 定義的異步 I/O 相匹配。

不過,對於 I/O 多路複用是屬於同步 I/O 還是異步 I/O 存在爭議,不同視角下存在不同的理解。I/O 多路複用,單看等待 I/O 就緒階段,其實是異步的。所以很多時候,I/O 多路複用,雖然沒有實現真正的 POSIX 定義的異步 I/O,但也被歸類為異步 I/O。比如,Jones 的文章5將 I/O 多路複用歸類為阻塞的異步 I/O,將 POSIX AIO 歸類為非阻塞的異步 I/O。類似的,封裝各個不同的 I/O 多路複用函數的 libevent 庫的官方文檔6自稱是“Asynchronous I/O”。Node.js 底層是基於 I/O 多路複用封裝的 libuv 庫,libuv 庫也自稱是“Asynchronous I/O”。另外,如果跳出 I/O 視角,從整體應用的執行流程角度看,基於 I/O 多路複用實現的應用,相對於在多個描述符列表上主輪詢流程,在單個描述符上 I/O 事件的應用處理流程相對獨立,可以認為是異步的。Node.js 文檔對“異步”的解釋如下7

Asynchronous means that things can happen independently of the main program flow.

嚴格意義上,典型的 I/O 多路複用的應用是單進程單線程執行的,本質上都是串行執行的,是假異步。

服務器併發策略與 C10K 問題

世界上第一個 HTTP 服務器,CERN httpd,早期實現採用的 I/O 模型是阻塞 I/O,然後為了能同時處理多個客户端連接,會為每個客户端連接創建一個新的處理請求的子進程(process-per-connection),這種併發模式被稱為 fork 模式,這也是傳統的 Unix 服務器採用的併發模式。之後的版本,改為基於 select() 的 I/O 多路複用 + fork 模式(參見源碼 HTDaemon.c)。

NCSA HTTPd,是早期的第一個流行的 HTTP 服務器,在版本 1.3 以及之前版本,也是採用阻塞 I/O + fork 模式實現,會為每個客户端連接創建一個新的子進程(參見源碼 httpd.c)。之後 NCSA HTTPd 的 1.4 版本,I/O 模型改造為基於 select() 的 I/O 多路複用,進程模型改為“pre-forking”模式,但這種模式本質上還是每個客户端連接對應單個子進程(process-per-connection),“pre-forking”的優點是在創建新客户端連接時有預先創建的子進程直接處理請求(類似進程池),避免在創建新連接的同時執行創建子進程這樣的重型操作,具體可以參見 1996.03 的官方文檔的對“pre-forking”模型的性能測試8(相關的實現源碼參見 httpd.c)。

Apache HTTP Server,最早在 1995.04 對外公開發布首個版本 0.6.2,這個版本的代碼基於 NCSA httpd 1.39。 之後在 5 月和 6 月,Apache 也開始實現“pre-forking”特性,在版本 0.8.0 開始正式支持910。一直到 Apache HTTP Server 2.2,prefork 模式依然是 Unix 系統下的默認模式11在 Apache 的 prefork 模式下,每個客户端連接由單獨的子進程處理,這樣的子進程被 Apache 稱為 worker 進程,worker 進程數就是同時處理的客户端連接數。因為線程相對進程更加輕量,理論上每個客户端連接對應單個線程(thread-per-connection)更有優勢。所以,在 2002.04,Apache 發佈 2.0 的首個 GA 版本時,新增了 worker 模式,一種多進程和多線程混合的模式。在 Apache 的 worker 模式下,每個客户端連接由單獨的子線程處理,這樣的子線程也就是 worker 線程,worker 線程數就是同時處理的客户端連接數。

相對單線程,多進程或多線程的問題是,佔用更多內存(每個線程都需要維護自己的線程棧),以及頻繁的上下文切換。所以,有些 HTTP 服務器傾向於基於 select() 實現單進程單線程的網絡服務器。這種模式實現的服務器一般會把循環調用 select() 的過程抽象為事件循環(event loop),把 I/O 操作就緒事件的處理抽象成回調函數(callback),所以也被稱為事件驅動服務器(event-driven server)。多核 CPU 時,為了能充分使用 CPU 多核資源,事件驅動服務器的進程數(或線程數)通常為 CPU 核數。事件驅動模式,在軟件架構中被也稱為 Reactor 模式。基於事件併發和基於線程的併發的比較,可以閲讀 John Ousterhout 的 1995 年的經典 slides:“Why Threads Are A Bad Idea (for most purposes)”12

早期的典型的基於 select() 實現的單線程 HTTP 服務器的例子,是由 ACME 實驗室開發並開源的 thttpd(1995.11 對外發布 1.0 版)。thttpd 服務器作者 Jef Poskanzer 在文章“Web Server Comparisons”(1998.07)13中對比了各個 Web 服務器。根據文章的對比,容易發現基於 select() 實現的單線程服務器,在響應性能和最大併發連接數上都佔優,thttpd 支持的最大的每秒請求數 QPS 是 720,thttpd 支持的最大併發連接數是 1000+。

雖然在實驗條件下表現良好,但是在真實場景下,基於 select() 實現的 HTTP 單線程服務器,性能並沒有優於傳統的基於 fork 模式的服務器14。Banga 等人經過分析後得出的主要原因是,當服務器同時處理的客户端連接數超過幾千後,系統調用 select()poll() 的性能很差,不具備可伸縮性。

最早的 HTTP 1.0 協議,在服務響應完成後連接會立即關閉,連接無法保持。HTTP 底層是 TCP 協議,建立 TCP 連接需要經過三次握手的過程。如果能複用 TCP 連接,同一個 HTTP 連接上的後續的 HTTP 請求就不用重新建立 TCP 連接,也就是能在同一個 HTTP 連接上支持多次 HTTP 請求和響應,這樣 HTTP 性能也就得到了提高。於是,HTTP 1.1 協議(RFC 2068,1997.01)開始持久連接(persistent connection),默認讓 HTTP 連接“keep-alive”。關於 HTTP 持久連接的詳細介紹,可以參考 RFC 2068 的“8.1 Persistent Connections”。

HTTP 協議支持持久連接後,也帶來了另外一個問題,就是出現大量的冷鏈接(cold connection)。瀏覽器如果未主動關閉連接,停留在網頁上,並且如果連接未超時,此時的連接雖然不活躍但會保持一段時間,這樣的連接就是冷鏈接。同時,隨着互聯網的快速發展,訪問網站的用户量不斷上升,Web 服務器需要維持的鏈接數也不斷上升,如何讓服務支持更多客户端連接問題也愈發尖鋭。當時 Web 服務器支持的最大併發連接數大致是 1K,於是 Dan Kegel 在 1999 年提出了 C10k 問題,如何能讓服務器支持 10K 的客户端連接,字母“C”代表的是“client connection”。在文章“The C10K problem”15中,Dan Kegel 對 C10K 問題的描述如下:

It's time for web servers to handle ten thousand clients simultaneously, don't you think? After all, the web is a big place now.

另外,值得一提的是,HTTP 1.0 和 HTTP 1.1 協議存在隊頭阻塞問題(HOL 阻塞,head of line blocking),為了避免隊頭阻塞,從而使網頁能更快響應,大多數瀏覽器會為每個域名同時開啓多個 HTTP 連接,通常是 6 個併發連接16,結果導致 Web 服務器需要維持的鏈接數增加數倍。在發佈 HTTP 1.1 協議的十幾年後的 2012 年,HTTP 2.0 的首個草稿發佈,而制定 HTTP/2 協議的最大的目標之一就是解決隊頭阻塞問題。

select 和 poll 性能問題

當 Web 服務器需要同時處理大量客户端連接時,服務器的性能表現差,原因就出在系統調用 select()poll() 的性能上,具體的問題有如下三點17

  • 每次調用 select()poll(),內核都必須檢查所有被指定的文件描述符,看它們是否處於就緒狀態。隨着待檢查的文件描述符數量的增加,調用耗時也隨之線性增加。若待檢查的文件描述符數為 n,select()poll() 的時間複雜度為 O(n)。
  • 每次調用 select()poll(),程序都必須傳遞一個表示所有需要被檢查的文件描述符的數據結構到內核,內核檢查過描述符後,修改這個數據結構並返回給程序。(此外,對於 select() 來説,我們還必須在每次調用前初始化這個數據結構。)隨着待檢查的文件描述符數量的增加,傳遞給內核的數據結構大小也會隨之增加。當檢查大量文件描述符時,從用户空間到內核空間來回拷貝這個數據結構將佔用大量的 CPU 時間。
  • select()poll() 調用完成後,程序必須檢查返回的數據結構中的每個元素,以此查明哪個文件描述符處於就緒狀態。

解決系統調用 select()poll() 的性能問題,讓服務器能同時處理大量連接,比較典型的解決方案是,FreeBSD 4.1(2000.07 發佈)開始支持的 kqueue 系統調用 ,以及 Linux 2.5.44(2002.10 發佈)開始支持的 epoll 系統調用。

kqueue 和 epoll 系統調用

kqueue 相關的 API 主要涉及兩個系統調用 kqueue()kevent()

  • kqueue():用於在內核空間創建 kqueue 數據結構
  • kevent()

    • 當傳入其中的 changelist 等參數時,用於將感興趣的 kevent 事件對象註冊到 kqueuekevent 對象上記錄感興趣文件描述符和事件類型
    • 當傳入其中的 eventlist 等參數時,用於查詢就緒的 kevent 事件對象列表

kqueue 的實現原理18:調用 kqueue() 創建由內核空間維護 kqueue 實例,kqueue 實例內包含鏈表,鏈表上保存全部監聽的 kevent 事件對象,kevent 對象上感興趣的記錄文件描述符和事件類型。通過 kevent() 系統調用,可以在鏈表上註冊、刪除某 kevent 對象。當設備 I/O 事件觸發時,設備與 kqueue 實例關聯的鈎子函數(hook)會被執行,鈎子函數會判斷事件是否與監聽的事件相符合,如果符合就把事件添加到 kqueue 實例內下鏈表 active list 的末尾。查詢就緒事件列表時,調用 kevent(),內核只需要檢查鏈表 active list 是否有元素,若有就把就緒事件列表拷貝到用户空間。

epoll 相關的 API 主要涉及三個系統調用 epoll_create()epoll_ctl()epoll_wait()

  • epoll_create():用於在內核空間創建 epoll 實例
  • epoll_ctl():用於添加感興趣的 epoll_event 事件對象到 epoll 實例,epoll_event 對象上記錄感興趣文件描述符和事件類型
  • epoll_wait():用於查詢就緒的 epoll_event 事件對象列表

epoll 的實現原理:調用 epoll_create() 創建由內核空間維護的 epoll 實例,epoll 實例內包含紅黑樹,紅黑樹上保存全部監聽的 epoll_event 事件對象,epoll_event 對象上記錄感興趣的文件描述符和事件類型。通過 epoll_ctl() 系統調用,可以在紅黑樹上註冊、刪除某 epoll_event 對象。所有添加到紅黑樹中的事件都會與設備驅動程序建立回調關係,當 I/O 就緒事件觸發時,會把事件添加到 epoll 實例內的鏈表 rdllist。查詢就緒事件列表時,調用 epoll_wait(),內核只需要檢查鏈表 rdllist 是否有元素,若有就把就緒事件列表拷貝到用户空間。

水平觸發和邊緣觸發

epoll 系統調用的事件通知模式,區分水平觸發(level-triggered,LT)和邊緣觸發(edge-triggered,ET),默認通知模式是水平觸發 LT,EPOLLET 標誌可以將通知模式改為邊緣觸發。poll()select() 所提供的通知模式是水平觸發,不支持邊緣觸發。水平觸發和邊緣觸發的通知模式的含義如下17

  • 水平觸發通知模式:如果文件描述符上可以非阻塞地執行 I/O 系統調用,此時認為它已經就緒。水平觸發模式下,應用程序可以不立即處理該事件,當應用程序下一次調用 epoll_wait() 時,epoll_wait() 還會再次嚮應用程序通告此事件,直到該事件被處理。這種模式下 epoll 相當於一個效率較高的 poll。
  • 邊緣觸發通知模式:如果文件描述符自上次狀態檢查以來有了新的 I/O 事件,此時需要觸發通知。也就是説,當 epoll_wait() 檢測到某 I/O 事件發生並將此事件通知應用程序後,後續的 epoll_wait() 調用將不再向應用程序通知這一事件,只通知新的 I/O 事件。邊緣觸發模式在很大程度上降低了同一個 epoll 事件被重複觸發的次數,因此效率要比水平觸發模式高。但是相對水平觸發,邊緣觸發模式下開發難度更大。

“水平觸發”和“邊緣觸發”術語源於電子工程領域。水平觸發是隻要有狀態發生就觸發。邊緣觸發是隻有在狀態改變的時候才會發生。條件觸發關心的是事件狀態,邊緣觸發關心的是事件本身。

採用邊緣觸發通知的程序通常要按照如下規則來設計:

  • 在接收到一個 I/O 事件通知後,程序在某個時刻應該在相應的文件描述符上儘可能多地執行 I/O(比如儘可能多地讀取字節)。如果程序沒這麼做,那麼就可能失去執行 I/O 的機會。
  • 如果儘可能多地執行 I/O,而文件描述符被設置為阻塞模式,那麼最終當沒有更多的 I/O 可執行時,I/O 系統調用就會阻塞。所以,每個被檢查的文件描述符通常都應該設置為非阻塞模式。

另外,在邊緣觸發 ET 模式下,如果多個線程同時監聽相同的描述符,只會有一個線程被喚醒用來處理 I/O 事件。epoll 的 man 文檔對這個特性有如下描述,這個特性也避免了“驚羣問題”(thundering herd problem)。

If multiple threads (or processes, if child processes have inherited the epoll file descriptor across fork(2)) are blocked in epoll_wait(2) waiting on the same epoll file descriptor and a file descriptor in the interest list that is marked for edge-triggered (EPOLLET) notification becomes ready, just one of the threads (or processes) is awoken from epoll_wait(2). This provides a useful optimization for avoiding "thundering herd" wake-ups in some scenarios.

因此,邊緣觸發通知模式其中一個適用的場景是,多核 CPU 上的多線程服務器,每個 CPU 核上運行一個線程,這些線程同時監聽相同的描述符19

kqueue 文檔沒有使用水平觸發和邊緣觸發術語。但接口效果上,默認是水平觸發。開啓EV_CLEAR標誌可以達到類似邊緣觸發的效果。EV_CLEAR 標誌的 man 文檔描述:

After the event is retrieved by the user, its state is reset.

因為邊緣觸發通知模式效率更高,Nginx 服務器採用的就是邊緣觸發,參見源碼 ngx_epoll_module.c 和 ngx_kqueue_module.c。

select、poll、kqueue 和 epoll 的比較

selectpollkqueueepoll 系統調用的多個維度的對比總結,如下表:

系統調用 select poll kqueue epoll
類 Unix 系統的支持情況 POSIX 標準。最早 4.2BSD 提供(1983) POSIX 標準。最早 SVR3 提供(1987) BSD 專有。最早 FreeBSD 4.1 提供 (2000.08) Linux 專有。最早 Linux 2.5.44 提供(2002.10)
查詢就緒描述符的時間複雜度 O(n) O(n) O(1) O(1)
感興趣描述符列表傳遞 每次 select() 都全量拷貝到內核空間 每次 poll() 都全量拷貝到內核空間 由內核空間維護 由內核空間維護
就緒描述符列表的返回 只返回就緒描述符數量,需要檢查感興趣描述符列表來判斷哪些是就緒描述符 只返回就緒描述符數量,需要檢查感興趣描述符列表來判斷哪些是就緒描述符 返回就緒事件數量,並同時返回就緒事件列表 返回就緒事件數量,並同時返回就緒事件列表
最大描述符數 被常數 FD_SETSIZE 限制(值為 1024) 無限制 無限制 無限制
觸發通知模式 水平 水平 水平和邊緣 水平和邊緣

附註:查詢就緒描述符的時間複雜度,selectpoll 都是 O(n),n 為感興趣描述符的總數,因為內核實現上需要輪詢全部感興趣的描述符列表。kqueueepoll 都是 O(1),實際的查詢耗時與就緒描述符總數線性有關,但真實場景下就緒描述符數量相對描述符總數很小,可以認為是常數,所以複雜度是 O(1)。

總體上,selectpoll 之間大同小異,而 kqueueepoll 之間也是大同小異。

libevent 庫,對系統調用 selectpollkqueueepoll 做了性能基準測試,如下圖所示(圖片來源)。基準測試聲明瞭大量連接(文件描述符),大多數連接是冷的,只有少數是活躍的。測試衡量的是,在不同的總連接數下,為 100 個活動連接提供服務所需的時間。可以看到,系統調用 selectpoll,隨着文件描述符的增加,耗時也隨之線性增加,2500 個文件描述符時耗時大約 20ms,5000 個文件描述符時耗時大約 40ms,10000 個文件描述符時耗時大約 80ms。而系統調用 kqueueepoll,耗時始終在 3ms ~ 5ms 之間。

性能基準測試:select vs poll vs kqueue vs epoll

echo 服務的簡單示例代碼

上文總結了 selectpollkqueueepoll 的接口特性和實現原理,但對具體應該如何使用這些函數沒有切身感受。筆者使用 I/O 多路複用函數 select、poll、epoll 和 kqueue 以及 libevent 庫,各自編寫了 echo 服務的簡單示例代碼。所謂 echo 服務,即服務端接收到客户端的字符串輸入,然後響應相同的字符串(為了方便區分響應字符串加了 > 前綴)。比如,如果客户端輸入字符串 hello,服務端將響應字符串 > hello;如果客户端輸入字符串 world,服務端將響應字符串 > world。完整的示例代碼參見 io-multiplexing-demo。

Nginx 服務器的併發策略解析

上文介紹了 NCSA HTTPd、Apache HTTP Server 和 thttpd 等 Web 服務器的併發策略。主要的併發策略有三種模式:單連接單進程模式、單連接單線程模式和單線程的事件驅動模式。

Nginx 最早是 2002 年開始開發的,2004.08 採用 BSD 協議對外開源首個版本 0.1.0,開發 Nginx 的目的是為了解決 C10k 問題20。2002 年,當時 FreeBSD 已經提供 kqueue 系統調用,而 Linux 的 epoll 即將正式發佈,新的 kqueueepoll 系統調用讓 Nginx 解決 C10k 問題成為可能。根據 w3techs 的統計,在 2013.07 Nginx 超越 Apache 成為 top 1000 網站使用最多的 Web 服務器21

Nginx 採用的是事件驅動架構,在單線程的進程上執行事件循環,以異步非阻塞的方式處理 I/O 操作事件,事件循環底層基於高效的 epollkqueue 實現的 I/O 多路複用22

Nginx 服務器,區分 Master 進程和 Worker 進程。Master 進程,用於加載配置文件、啓動 Worker 進程和平滑升級等。Worker 進程,是單線程的進程,用於執行事件循環,並以非阻塞方式處理 I/O 操作,因此單個 Worker 進程就能併發處理大量連接。一個完整的請求完全由 Worker 進程來處理,而且只在一個 Worker 進程中處理。為了能充分利用多核 CPU 資源,通常生產環境配置的 Worker 進程數量等於 CPU 核心數。Nginx 的架構圖如下22

Nginx 架構圖

Redis 服務器的併發策略解析

Redis 是內存數據庫,處理網絡請求也是採用單線程的事件驅動模式,底層基於高效的 epollkqueue 實現的 I/O 多路複用。事件循環處理的事件主要有,建立客户端新連接事件、客户端連接的緩衝區可讀事件、客户端連接的緩衝區可寫事件。Redis 的命令處理過程如下:

  • 在收到建立客户端新連接事件後,會在新建立的客户端套接字上監聽可讀事件,用於等待客户端發起命令請求。客户端連接可能會一直保持,處理之後的多個客户端命令請求。
  • 如果監聽到客户端連接的緩衝區可讀事件,也就是收到客户端的命令請求,服務器會讀取命令、解析命令,然後執行命令,最後把命令響應結果輸出到內存緩衝區。值得注意的是,命令響應結果輸出到內存緩衝區,但並未輸出客户端連接的緩衝區。
  • 在等到開啓新的事件循環時,Redis 會在等待接收新的 I/O 事件之前,統一將全部內存緩衝區的命令響應結果輸出到各個客户端。當命令響應結果數據量非常大時,無法一次性將所有數據都發送給某客户端,這時就會監聽該客户端緩衝區可寫事件。
  • 如果監聽到客户端連接的緩衝區可寫事件,Redis 就會發送剩餘部分的數據給客户端。

上述的命令處理過程,在源碼層面上涉及的核心代碼都在 networking.c 中:處理客户端新連接建立的事件的回調函數是 acceptTcpHandler,處理客户端命令請求事件的回調函數 readQueryFromClient,命令響應結果輸出到各個客户端對應的函數是 handleClientsWithPendingWrites,處理客户端的可寫事件的回調函數是 sendReplyToClient。更詳細的實現原理解析,本文不再展開,可以自行深入閲讀相關源代碼或書籍資料。

Redis 與 Nginx 在併發策略上有不同的選擇,Nginx 有多個 Worker 進程,每個 Worker 進程都運行自己的事件循環,而 Redis 整體上只有一個事件循環,採用的是單線程架構。這樣的架構設計帶來的問題就是 Redis 無法多核 CPU 併發。針對無法多核 CPU 併發問題,Redis 官方 FAQ 的推薦的解決方案是23:在多核 CPU 的單台機器上啓動多個 Redis 實例。Redis 作者 antirez,解釋了選擇單線程而不選擇多線程的原因,主要是:在 Redis 的數據結構上實現併發控制太複雜,多線程編程降低開發速度並且導致 bug 修復困難2425。採用單線程的原因,概況成一句話就是25

There is less to gain, and a lot of complexity to add.

不過,隨着 Redis 版本的演進,部分邏輯已經改成了多線程實現,Redis 新增的多線程特性有三處,Redis 2.4 新增的異步磁盤 IO、Redis 4.0 新增的“Lazy Freeing”和 Redis 6.0 新增的“Threaded I/O”。但整體設計上,還是可以認為 Redis 主要使用單線程設計,依然是單線程的事件循環,並以單線程的方式執行命令(絕大多數命令,“Lazy Freeing”相關的命令除外)2627

參考資料


  1. FreeBSD操作系統設計與實現,McKusick,2004:6.4.5 描述符上的多路I/O操作 ↩
  2. Unix網絡編程 卷1:套接字聯網API,Stevens,第3版2003 ↩
  3. 2014-04 AIO User Guide: A description of how to use AIO https://web.archive.org/web/0/http://code.google.com/p/kernel...
  4. Awesome io_uring https://github.com/espoal/awesome-iouringahref=#fnref-4class=footnote-backref↩/a
  5. 2006-08 M. Jones: Boost application performance using asynchronous I/O https://developer.ibm.com/articles/l-async/ahref=#fnref-5clas...
  6. Learning Libevent: A tiny introduction to asynchronous IO https://libevent.org/libevent-book/01_intro.htmlahref=#fnref-...
  7. Node.js: JavaScript Asynchronous Programming and Callbacks https://nodejs.dev/en/learn/javascript-asynchronous-programmi...
  8. 1995-04 NCSA httpd: Performance of Several HTTP Demons on an HP 735 Workstation https://web.archive.org/web/0/http://www.ncsa.uiuc.edu/Inform...
  9. About the Apache HTTP Server Project https://httpd.apache.org/ABOUT_APACHE.htmlahref=#fnref-9class...
  10. Changes with Apache (12 Jun 1995: This release included modified versions of a lot of code from the Apache 0.6.4 public release, plus an early pre-forking patch codeveloped by Robert Thau and Rob Hartill.) https://github.com/apache/httpd/blob/1.3.x/src/CHANGES#L9427ahref=#fnref-10class=footnote-backref↩/a
  11. Apache HTTP Server Version 2.2: Multi-Processing Modules (MPMs) https://httpd.apache.org/docs/2.2/en/mpm.html#defaultsahref=#...
  12. 1995 John Ousterhout: Why Threads Are A Bad Idea (for most purposes) (slides) http://www.cc.gatech.edu/classes/AY2010/cs4210_fall/papers/ou...
  13. 1998-07 Jef Poskanzer: Web Server Comparisons(thttpd 服務器作者) http://www.acme.com/software/thttpd/benchmarks.htmlahref=#fnr...
  14. 1998 Gaurav Banga, Jeffrey C. Mogul: Scalable Kernel Performance for Internet Servers Under Realistic Loads. USENIX Annual Technical Conference 1998 dblp usenix.org ↩
  15. 1999-05 Dan Kegel: The C10K problem(最後更新時間 2011.07) http://www.kegel.com/c10k.htmlahref=#fnref-15class=footnote-b...
  16. 2014-02 詳解瀏覽器最大併發連接數 https://web.archive.org/web/0/http://www.iefans.net/liulanqi-...
  17. Linux Unix系統編程手冊,Kerrisk 下冊:第63章 其他備選的I/O模型 ↩
  18. 2001 Jonathan Lemon: Kqueue - A Generic and Scalable Event Notification Facility. USENIX Annual Technical Conference 2001 dblp usenix.org ↩
  19. What is the purpose of epoll's edge triggered option? https://stackoverflow.com/a/73540436/689699ahref=#fnref-19class=footnote-backref↩/a
  20. 2012-01 Interview with Igor Sysoev, author of Apache's competitor NGINX https://web.archive.org/web/0/http://www.freesoftwaremagazine...
  21. 2013-07 Nginx just became the most used web server among the top 1000 websites https://w3techs.com/blog/entry/nginx_just_became_the_most_use...
  22. 2012-03 AOSA Volume 2 - nginx (Andrew Alexeev) https://aosabook.org/en/v2/nginx.htmlahref=#fnref-22class=foo...
  23. Redis FAQ: How can Redis use multiple CPUs or cores? https://redis.io/docs/getting-started/faq/#how-can-redis-use-...
  24. 2010-09 antirez: An update on the Memcached/Redis benchmark http://oldblog.antirez.com/post/update-on-memcached-redis-ben...
  25. 2019-02 antirez: An update about Redis developments in 2019 http://antirez.com/news/126ahref=#fnref-25class=footnote-back...
  26. Redis Doc: Diagnosing latency issues: Single threaded nature of Redis https://redis.io/docs/management/optimization/latency/#single...
  27. 2019-08 林添毅:正式支持多線程!Redis 6.0與老版性能對比評測 https://mp.weixin.qq.com/s/6WQNq5dNk-GuEhZXtVCo-Aahref=#fnref...

Add a new 評論

Some HTML is okay.