使用 libevent 和 libev 提高網路應用效能

發表於2016-07-18

簡介

許多伺服器部署(尤其是 web 伺服器部署)面對的最大問題之一是必須能夠處理大量連線。無論是通過構建基於雲的服務來處理網路通訊流,還是把應用程式分佈在 IBM Amazon EC 例項上,還是為網站提供高效能元件,都需要能夠處理大量併發連線。

一個好例子是,web 應用程式最近越來越動態了,尤其是使用 AJAX 技術的應用程式。如果要部署的系統允許數千客戶端直接在網頁中更新資訊,比如提供事件或問題實時監視的系統,那麼提供資訊的速度就非常重要了。在網格或雲環境中,可能有來自數千客戶端的持久連線同時開啟著,必須能夠處理每個客戶端的請求並做出響應。

在討論 libevent 和 libev 如何處理多個網路連線之前,我們先簡要回顧一下處理這類連線的傳統解決方案。

處理多個客戶端

處理多個連線有許多不同的傳統方法,但是在處理大量連線時它們往往會產生問題,因為它們使用的記憶體或 CPU 太多,或者達到了某個作業系統限制。

使用的主要方法如下:

  • 迴圈:早期系統使用簡單的迴圈選擇解決方案,即迴圈遍歷開啟的網路連線的列表,判斷是否有要讀取的資料。這種方法既緩慢(尤其是隨著連線數量增加越來越慢),又低效(因為在處理當前連線時其他連線可能正在傳送請求並等待響應)。在系統迴圈遍歷每個連線時,其他連線不得不等待。如果有 100 個連線,其中只有一個有資料,那麼仍然必須處理其他 99 個連線,才能輪到真正需要處理的連線。
  • poll、epoll 和變體:這是對迴圈方法的改進,它用一個結構儲存要監視的每個連線的陣列,當在網路套接字上發現資料時,通過回撥機制呼叫處理函式。poll 的問題是這個結構會非常大,在列表中新增新的網路連線時,修改結構會增加負載並影響效能。
  • 選擇select() 函式呼叫使用一個靜態結構,它事先被硬編碼為相當小的數量(1024 個連線),因此不適用於非常大的部署。

在各種平臺上還有其他實現(比如 Solaris 上的 /dev/poll 或 FreeBSD/NetBSD 上的 kqueue),它們在各自的 OS 上效能可能更好,但是無法移植,也不一定能夠解決處理請求的高層問題。

上面的所有解決方案都用簡單的迴圈等待並處理請求,然後把請求分派給另一個函式以處理實際的網路互動。關鍵在於迴圈和網路套接字需要大量管理程式碼,這樣才能監聽、更新和控制不同的連線和介面。

處理許多連線的另一種方法是,利用現代核心中的多執行緒支援監聽和處理連線,為每個連線啟動一個新執行緒。這把責任直接交給作業系統,但是會在 RAM 和 CPU 方面增加相當大的開銷,因為每個執行緒都需要自己的執行空間。另外,如果每個執行緒都忙於處理網路連線,執行緒之間的上下文切換會很頻繁。最後,許多核心並不適於處理如此大量的活躍執行緒。

libevent 方法

libevent 庫實際上沒有更換 select()poll() 或其他機制的基礎。而是使用對於每個平臺最高效的高效能解決方案在實現外加上一個包裝器。

為了實際處理每個請求,libevent 庫提供一種事件機制,它作為底層網路後端的包裝器。事件系統讓為連線新增處理函式變得非常簡便,同時降低了底層 I/O 複雜性。這是 libevent 系統的核心。

libevent 庫的其他元件提供其他功能,包括緩衝的事件系統(用於緩衝傳送到客戶端/從客戶端接收的資料)以及 HTTP、DNS 和 RPC 系統的核心實現。

建立 libevent 伺服器的基本方法是,註冊當發生某一操作(比如接受來自客戶端的連線)時應該執行的函式,然後呼叫主事件迴圈 event_dispatch()。執行過程的控制現在由 libevent 系統處理。註冊事件和將呼叫的函式之後,事件系統開始自治;在應用程式執行時,可以在事件佇列中新增(註冊)或刪除(取消註冊)事件。事件註冊非常方便,可以通過它新增新事件以處理新開啟的連線,從而構建靈活的網路處理系統。

例如,可以開啟一個監聽套接字,然後註冊一個回撥函式,每當需要呼叫 accept() 函式以開啟新連線時呼叫這個回撥函式,這樣就建立了一個網路伺服器。清單 1 所示的程式碼片段說明基本過程:

清單 1. 開啟監聽套接字,註冊一個回撥函式(每當需要呼叫accept()函式以開啟新連線時呼叫它),由此建立網路伺服器

event_set() 函式建立新的事件結構,event_add() 在事件佇列機制中新增事件。然後,event_dispatch() 啟動事件佇列系統,開始監聽(並接受)請求。

清單 2 給出一個更完整的示例,它構建一個非常簡單的回顯伺服器:

清單 2. 構建簡單的回顯伺服器

下面討論各個函式及其操作:

  • main():主函式建立用來監聽連線的套接字,然後建立 accept() 的回撥函式以便通過事件處理函式處理每個連線。
  • accept_callback():當接受連線時,事件系統呼叫此函式。此函式接受到客戶端的連線;新增客戶端套接字資訊和一個 bufferevent 結構;在事件結構中為客戶端套接字上的讀/寫/錯誤事件新增回撥函式;作為引數傳遞客戶端結構(和嵌入的 eventbuffer 和客戶端套接字)。每當對應的客戶端套接字包含讀、寫或錯誤操作時,呼叫對應的回撥函式。
  • buf_read_callback():當客戶端套接字有要讀的資料時呼叫它。作為回顯服務,此函式把 “you said…” 寫回客戶端。套接字仍然開啟,可以接受新請求。
  • buf_write_callback():當有要寫的資料時呼叫它。在這個簡單的服務中,不需要此函式,所以定義是空的。
  • buf_error_callback():當出現錯誤時呼叫它。這包括客戶端中斷連線。在出現錯誤的所有場景中,關閉客戶端套接字,從事件列表中刪除客戶端套接字的事件條目,釋放客戶端結構的記憶體。
  • setnonblock():設定網路套接字以開放 I/O。

當客戶端連線時,在事件佇列中新增新事件以處理客戶端連線;當客戶端中斷連線時刪除事件。在幕後,libevent 處理網路套接字,識別需要服務的客戶端,分別呼叫對應的函式。

為了構建這個應用程式,需要編譯 C 原始碼並新增 libevent 庫:$ gcc -o basic basic.c -levent

從客戶端的角度來看,這個伺服器僅僅把傳送給它的任何文字傳送回來(見 清單 3)。

清單 3. 伺服器把傳送給它的文字傳送回來

這樣的網路應用程式非常適合需要處理多個連線的大規模分散式部署,比如 IBM Cloud 系統。

很難通過簡單的解決方案觀察處理大量併發連線的情況和效能改進。可以使用嵌入的 HTTP 實現幫助瞭解可伸縮性。

使用內建的 HTTP 伺服器

如果希望構建本機應用程式,可以使用一般的基於網路的 libevent 介面;但是,越來越常見的場景是開發基於 HTTP 協議的應用程式,以及裝載或動態地重新裝載資訊的網頁。如果使用任何 AJAX 庫,客戶端就需要 HTTP,即使您返回的資訊是 XML 或 JSON。

libevent 中的 HTTP 實現並不是 Apache HTTP 伺服器的替代品,而是適用於與雲和 web 環境相關聯的大規模動態內容的實用解決方案。例如,可以在 IBM Cloud 或其他解決方案中部署基於 libevent 的介面。因為可以使用 HTTP 進行通訊,伺服器可以與其他元件整合。

要想使用 libevent 服務,需要使用與主要網路事件模型相同的基本結構,但是還必須處理網路介面,HTTP 包裝器會替您處理。這使整個過程變成四個函式呼叫(初始化、啟動 HTTP 伺服器、設定 HTTP 回撥函式和進入事件迴圈),再加上傳送回資料的回撥函式。清單 4 給出一個非常簡單的示例:

清單 4. 使用 libevent 服務的簡單示例

應該可以通過前面的示例看出程式碼的基本結構,不需要解釋。主要元素是 evhttp_set_gencb() 函式(它設定當收到 HTTP 請求時要使用的回撥函式)和 generic_request_handler() 回撥函式本身(它用一個表示成功的簡單訊息填充響應緩衝區)。

HTTP 包裝器提供許多其他功能。例如,有一個請求解析器,它會從典型的請求中提取出查詢引數(就像處理 CGI 請求一樣)。還可以設定在不同的請求路徑中要觸發的處理函式。通過設定不同的回撥函式和處理函式,可以使用路徑 ‘/db/’ 提供到資料庫的介面,或使用 ‘/memc’ 提供到 memcached 的介面。

libevent 工具包的另一個特性是支援通用計時器。可以在指定的時間段之後觸發事件。可以通過結合使用計時器和 HTTP 實現提供輕量的服務,從而自動地提供檔案內容,在修改檔案內容時更新返回的資料。例如,以前要想在新聞頻發的活動期間提供即時更新服務,前端 web 應用程式就需要定期重新裝載新聞稿,而現在可以輕鬆地提供內容。整個應用程式(和 web 服務)都在記憶體中,因此響應非常快。

這就是 清單 5 中的示例的主要用途:

清單 5. 使用計時器在新聞頻發的活動期間提供即時更新服務

這個伺服器的基本原理與前面的示例相同。首先,指令碼設定一個 HTTP 伺服器,它只響應對基本 URL 主機/埠組合的請求(不處理請求 URI)。第一步是裝載檔案 (read_file())。在裝載最初的檔案時和在計時器觸發回撥時都使用此函式。

read_file() 函式使用 stat() 函式呼叫檢查檔案的修改時間,只有在上一次裝載之後修改了檔案的情況下,它才重新讀取檔案的內容。此函式通過呼叫 fread() 裝載檔案資料,把資料複製到另一個結構中,然後使用 strcpy() 把資料從裝載的字串轉移到全域性字串中。

load_file() 函式是觸發計時器時呼叫的函式。它通過呼叫 read_file() 裝載內容,然後使用 RELOAD_TIMEOUT 值設定計時器,作為嘗試裝載檔案之前的秒數。libevent 計時器使用 timeval 結構,允許按秒和毫秒指定計時器。計時器不是週期性的;當觸發計時器事件時設定它,然後從事件佇列中刪除事件。

使用與前面的示例相同的格式編譯程式碼:$ gcc -o basichttpfile basichttpfile.c -levent

現在,建立作為資料使用的靜態檔案;預設檔案是 sample.html,但是可以通過命令列上的第一個引數指定任何檔案(見 清單 6)。

清單 6. 建立作為資料使用的靜態檔案

現在,程式可以接受請求了,重新裝載計時器也啟動了。如果修改 sample.html 的內容,應該會重新裝載此檔案並在日誌中記錄一個訊息。例如,清單 7 中的輸出顯示初始裝載和兩次重新裝載:

清單 7. 輸出顯示初始裝載和兩次重新裝載

注意,要想獲得最大的收益,必須確保環境沒有限制開啟的檔案描述符數量。可以使用 ulimit 命令修改限制(需要適當的許可權或根訪問)。具體的設定取決與您的 OS,但是在 Linux® 上可以用 -n 選項設定開啟的檔案描述符(和網路套接字)的數量:

清單 8. 用 -n 選項設定開啟的檔案描述符數量

通過指定數字提高限制:$ ulimit -n 20000

可以使用 Apache Bench 2 (ab2) 等效能基準測試應用程式檢查伺服器的效能。可以指定併發查詢的數量以及請求的總數。例如,使用 100,000 個請求執行基準測試,併發請求數量為 1000 個:$ ab2 -n 100000 -c 1000 http://192.168.0.22:8081/

使用伺服器示例中所示的 8K 檔案執行這個示例系統,獲得的結果為大約每秒處理 11,000 個請求。請記住,這個 libevent 伺服器在單一執行緒中執行,而且單一客戶端不太可能給伺服器造成壓力,因為它還受到開啟請求的方法的限制。儘管如此,在交換的文件大小適中的情況下,這樣的處理速率對於單執行緒應用程式來說仍然令人吃驚。

使用其他語言的實現

儘管 C 語言很適合許多系統應用程式,但是在現代環境中不經常使用 C 語言,指令碼語言更靈活、更實用。幸運的是,Perl 和 PHP 等大多數指令碼語言是用 C 編寫的,所以可以通過擴充套件模組使用 libevent 等 C 庫。

例如,清單 9 給出 Perl 網路伺服器指令碼的基本結構。accept_callback() 函式與 清單 1 所示核心 libevent 示例中的 accept 函式相同。

清單 9. Perl 網路伺服器指令碼的基本結構

用這些語言編寫的 libevent 實現通常支援 libevent 系統的核心,但是不一定支援 HTTP 包裝器。因此,對指令碼程式設計的應用程式使用這些解決方案會比較複雜。有兩種方法:要麼把指令碼語言嵌入到基於 C 的 libevent 應用程式中,要麼使用基於指令碼語言環境構建的眾多 HTTP 實現之一。例如,Python 包含功能很強的 HTTP 伺服器類 (httplib/httplib2)。

應該指出一點:在指令碼語言中沒有什麼東西是無法用 C 重新實現的。但是,要考慮到開發時間的限制,而且與現有程式碼整合可能更重要。

libev 庫

與 libevent 一樣,libev 系統也是基於事件迴圈的系統,它在 poll()select() 等機制的本機實現的基礎上提供基於事件的迴圈。到我撰寫本文時,libev 實現的開銷更低,能夠實現更好的基準測試結果。libev API 比較原始,沒有 HTTP 包裝器,但是 libev 支援在實現中內建更多事件型別。例如,一種 evstat 實現可以監視多個檔案的屬性變動,可以在 清單 4 所示的 HTTP 檔案解決方案中使用它。

但是,libevent 和 libev 的基本過程是相同的。建立所需的網路監聽套接字,註冊在執行期間要呼叫的事件,然後啟動主事件迴圈,讓 libev 處理過程的其餘部分。

例如,可以使用 Ruby 介面按照與清單 1 相似的方式提供回顯伺服器,見 清單 10。

清單 10. 使用 Ruby 介面提供回顯伺服器

Ruby 實現尤其出色,因為它為許多常用的網路解決方案提供了包裝器,包括 HTTP 客戶端、OpenSSL 和 DNS。其他指令碼語言實現包括功能全面的 Perl 和 Python 實現,您可以試一試。

結束語

libevent 和 libev 都提供靈活且強大的環境,支援為處理伺服器端或客戶端請求實現高效能網路(和其他 I/O)介面。目標是以高效(CPU/RAM 使用量低)的方式支援數千甚至數萬個連線。在本文中,您看到了一些示例,包括 libevent 中內建的 HTTP 服務,可以使用這些技術支援基於 IBM Cloud、EC2 或 AJAX 的 web 應用程式。

相關文章