網路編程(三):從libevent到事件通知機制

由於POSIX標準的滯後性,事件通知API的混亂一直保持到現在, 所有就有libevent、libev甚至後面的libuv的出現為跨平台編程掃清障礙。

下面是WikiPedia對於libevent的介紹:

libevent是一個非同步事件處理軟體函式庫,以BSD許可證發布。 libevent提供了一組應用程序編程介面(API),讓程序員可以設定某些事件發生時所執行的函式,也就是說,libevent可以用來取代網路伺服器所使用的事件循環檢查框架。

由於可以省去對網路的處理,且擁有不錯的效能, 有些軟體使用libevent作為網路底層的函式庫,如:Chromium(Chrome的開源版)、 memcached、Tor。

按照libevent的官方網站,libevent庫提供了以下功能:當一個文件描述符的特定事件 (如可讀,可寫或出錯)發生了,或一個定時事件發生了, libevent就會自動執行用戶指定的回調函數,來處理事件。

目前,libevent已支持以下介面/dev/poll, kqueue(2), event ports, select(2), poll(2) 和epoll(4)。

libevent的內部事件機制完全是基於所使用的介面的。因此libevent非常容易移植, 也使它的擴展性非常容易。目前,libevent已在以下操作系統中編譯通過: Linux,BSD,Mac OS X,Solaris和Windows。

libevent的高明之處還在於,它把fd讀寫、信號、DNS、定時器甚至idle(空閑) 都抽象化成了event(事件)。

我們可以簡單看一下一個簡單的基於libevent的網路server, 這有助於我們理解event-driven programming(事件驅動編程), 也為我們後續的實操做準備。

我在代碼中增加了詳細的注釋,希望大家能大致明白event-driven programming 的一半方法:

/* 這是一個示例性質的libevent的程序,監聽在TCP的9995埠。 當連接建立成功後,它將會給Client回應一個消息"Hello, World!
"
發送完畢後就將連接關閉。 程序也處理了SIGINT (ctrl-c)信號,收到這個信號後優雅退出程序。 這個程序也用到了一些libevent比較高級的API:「bufferevent」 這套API將buffer的「水位線」也抽象成了event來處理,靈感應該是來自 Windows平台的IOCP。*/// 引入常用Linux系統頭文件 #include <string.h>#include <errno.h>#include <stdio.h>#include <signal.h>#include <netinet/in.h>#include <arpa/inet.h>#include <sys/socket.h>// 引入libevent 2.x相關的頭文件 #include <event2/bufferevent.h>#include <event2/buffer.h>#include <event2/listener.h>#include <event2/util.h>#include <event2/event.h>// 定義字元串常量,將會回應給Client用 static const char MESSAGE[] = "Hello, World!
";// server監聽的埠 static const int PORT = 9995;// 定義幾個event callback的prototype(原型) static void listener_cb(struct evconnlistener * , evutil_socket_t, struct sockaddr * , int socklen, void * );static void conn_writecb(struct bufferevent * , void * );static void conn_eventcb(struct bufferevent * , short, void * );static void signal_cb(evutil_socket_t, short, void * );// 定義標準的main函數 intmain(int argc, char ** argv){ // event_base是整個event循環必要的結構體 struct event_base * base; // libevent的高級API專為監聽的FD使用 struct evconnlistener * listener; // 信號處理event指針 struct event * signal_event; // 保存監聽地址和埠的結構體 struct sockaddr_in sin; // 分配並初始化event_base base = event_base_new(); if (!base) { // 如果發生任何錯誤,向stderr(標準錯誤輸出)打一條日誌,退出 // 在C語言里,很多返回指針的API都以返回null為出錯的返回值 // if (!base) 等價於 if (base == null) fprintf(stderr, "Could not initialize libevent!
"); return 1; } // 初始化sockaddr_in結構體,監聽在0.0.0.0:9995 memset(&sin, 0, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_port = htons(PORT); // bind在上面制定的IP和埠,同時初始化listen的事件循環和callback:listener_cb // 並把listener的事件循環註冊在event_base:base上 listener = evconnlistener_new_bind(base, listener_cb, (void * )base, LEV_OPT_REUSEABLE|LEV_OPT_CLOSE_ON_FREE, -1, (struct sockaddr*)&sin, sizeof(sin)); if (!listener) { // 如果發生任何錯誤,向stderr(標準錯誤輸出)打一條日誌,退出 fprintf(stderr, "Could not create a listener!
"); return 1; } // 初始化信號處理event signal_event = evsignal_new(base, SIGINT, signal_cb, (void * )base); // 把這個callback放入base中 if (!signal_event || event_add(signal_event, NULL)<0) { fprintf(stderr, "Could not create/add a signal event!
"); return 1; } // 程序將在下面這一行內啟動event循環,只有在調用event_base_loopexit後 // 才會從下面這個函數返回,並向下執行各種清理函數,導致整個程序退出 event_base_dispatch(base); // 各種清理free evconnlistener_free(listener); event_free(signal_event); event_base_free(base); printf("done
"); return 0;}// 監聽埠的event callback static voidlistener_cb(struct evconnlistener * listener, evutil_socket_t fd, struct sockaddr * sa, int socklen, void * user_data){ struct event_base * base = user_data; struct bufferevent * bev; // 新建一個bufferevent,設定BEV_OPT_CLOSE_ON_FREE, // 保證bufferevent被free的時候fd也會被關閉 bev = bufferevent_socket_new(base, fd, BEV_OPT_CLOSE_ON_FREE); if (!bev) { fprintf(stderr, "Error constructing bufferevent!"); event_base_loopbreak(base); return; } // 設定寫buffer的event和其它event bufferevent_setcb(bev, NULL, conn_writecb, conn_eventcb, NULL); // 開啟向fd中寫的event bufferevent_enable(bev, EV_WRITE); // 關閉從fd中讀寫入buffer的event bufferevent_disable(bev, EV_READ); // 向buffer中寫入"Hello, World!
"
// 上面的操作保證在fd可寫時,將buffer中的內容寫出去 bufferevent_write(bev, MESSAGE, strlen(MESSAGE));}// 每次fd可寫,數據非阻塞寫入後,會雕也難怪conn_writecb // 這個函數每次檢查eventbuffer的剩餘大小,如果為0 // 表示數據已經全部寫完,將eventbuffer free掉 // 由於在上面設定了BEV_OPT_CLOSE_ON_FREE,所以fd也會被關閉 static voidconn_writecb(struct bufferevent * bev, void * user_data){ struct evbuffer * output = bufferevent_get_output(bev); if (evbuffer_get_length(output) == 0) { printf("flushed answer
"); bufferevent_free(bev); }}// 處理讀、寫event之外的event的callback static voidconn_eventcb(struct bufferevent * bev, short events, void * user_data){ if (events & BEV_EVENT_EOF) { // Client端關閉連接 printf("Connection closed.
"); } else if (events & BEV_EVENT_ERROR) { // 連接出錯 printf("Got an error on the connection: %s
", strerror(errno)); } // 如果還有其它的event沒有處理,那就關閉這個bufferevent bufferevent_free(bev);}// 信號處理event,收到SIGINT (ctrl-c)信號後,延遲2s退出event循環 static voidsignal_cb(evutil_socket_t sig, short events, void * user_data){ struct event_base * base = user_data; struct timeval delay = { 2, 0 }; printf("Caught an interrupt signal; exiting cleanly in two seconds.
"); event_base_loopexit(base, &delay);}

可以看出,用這種方式寫出來的非同步非阻塞server的邏輯還是比較容易理解的。

和協程的實現方式相比,這種方式完全避免了「手工」的上線文切換, 有利於CPU的分支預測的成功率,能發揮CPU處理網路連接的的最大潛能。

水平觸發LT & 邊沿觸發ET

在struct epoll_event里有連個Flag:EPOLLET和EPOLLLT讓初學者很難以理解

以下是一段關於epoll的man文檔:

epoll is a variant of poll(2) that can be used either as an edge-triggered or a level-triggered interface and scales well to large numbers of watched file descriptors. The following system calls are pro- vided to create and manage an epoll instance:

  • An epoll instance created by epoll_create(2), which returns a file descriptor referring to the epoll instance. (The more recent epoll_create1(2) extends the functionality of epoll_create(2).)

  • Interest in particular file descriptors is then registered via epoll_ctl(2). The set of file descriptors currently registered on an epoll instance is sometimes called an epoll set.

  • Finally, the actual wait is started by epoll_wait(2).

Level-Triggered and Edge-Triggered The epoll event distribution interface is able to behave both as edge-triggered (ET) and as level-triggered (LT). The difference between the two mechanisms can be described as follows. Suppose that this scenario happens:

  1. The file descriptor that represents the read side of a pipe (rfd) is registered on the epoll instance.

  2. A pipe writer writes 2 kB of data on the write side of the pipe.

  3. A call to epoll_wait(2) is done that will return rfd as a ready file descriptor.

  4. The pipe reader reads 1 kB of data from rfd.

  5. A call to epoll_wait(2) is done.

If the rfd file descriptor has been added to the epoll interface using the EPOLLET (edge-triggered) flag, the call to epoll_wait(2) done in step 5 will probably hang despite the available data still present in the file input buffer; meanwhile the remote peer might be expecting a response based on the data it already sent. The reason for this is that edge-triggered mode only delivers events when changes occur on the monitored file descriptor. So, in step 5 the caller might end up waiting for some data that is already present inside the input buffer. In the above example, an event on rfd will be generated because of the write done in 2 and the event is consumed in 3. Since the read operation done in 4 does not consume the whole buffer data, the call to epoll_wait(2) done in step 5 might block indefinitely.

An application that employs the EPOLLET flag should use non-blocking file descriptors to avoid having a blocking read or write starve a task that is handling multiple file descriptors. The suggested way to use epoll as an edge-triggered (EPOLLET) interface is as follows:

i with non-blocking file descriptors; and

ii by waiting for an event only after read(2) or write(2) return EAGAIN.

By contrast, when used as a level-triggered interface (the default, when EPOLLET is not specified), epoll is simply a faster poll(2), and can be used wherever the latter is used since it shares the same semantics.

Since even with edge-triggered epoll, multiple events can be generated upon receipt of multiple chunks of data, the caller has the option to specify the EPOLLONESHOT flag, to tell epoll to disable the associated file descriptor after the receipt of an event with epoll_wait(2). When the EPOLLONESHOT flag is specified, it is the caller』s responsibility to rearm the file descriptor using epoll_ctl(2) with EPOLL_CTL_MOD.

在epoll的man文檔里,我們會看到一個花費大量篇幅描述的兩個概念:

LT(Level Triggered,水平觸發) 和 ET(Edge Triggered,邊沿觸發)

作者當年花費了九牛二虎之力也沒能領悟這段「經文」。後來一個偶然的機會, 一個做電子設計的朋友給我講明白了其中的道道。

為了弄明白LT(Level Triggered,水平觸發) 和 ET(Edge Triggered,邊沿觸發), 我們先要了解,這個Level和Edge是什麼涵義,Level翻譯成中文這裡準確的涵義應該是電平; Edge是邊沿。

這兩個詞曾經是電子信號領域的一個專有名詞。如果,用時序圖來標示一個數字電信號「010」, 應該是類似下圖所示:

  • 低電平表示0。
  • 高電平表示1。
  • 0向1變化的豎線就是上升沿。
  • 1向0變化的豎線就是下降沿。
  • 在0或者1的情況下觸發的信號就是LT(Level Triggered,水平觸發)
  • 在0向1、1向0變化的過程中觸發的信號就是 和 ET(Edge Triggered,邊沿觸發)

0或1都是一個狀態,而0向1、1向0變化則只是一個事件。

我們很直觀的就可以得出結論,LT是一個持續的狀態,ET是個事件性的一次性狀態。

二者的差異在於Level Triggered模式下只要某個socket處於readable/writable狀態, 無論什麼時候進行epoll_wait都會返回該socket;

而Edge Triggered模式下只有某個socket從unreadable變為readable或 從unwritable變為writable時,epoll_wait才會返回該socket。

雖然有很多資料表明ET模式的銷量會比LT稍高, 但ET模式的編程由於事件只通知一次,很容易犯錯誤導致程序假死,我們推薦epoll工作於LT模式。 除非你很清楚你選擇的是什麼。

閑話QQ的通信協議

如果大家研究過早期的騰訊QQ的通信協議,可以發現QQ的通信協議是基於UDP的。 這點從今天的角度看來顯得十分的怪異,因為用UDP這種無連接的協議 實現一套保證消息可靠性的聊天服務的難度是非常之高的。

了解過那段歷史的同學可能知道,當時UDP的確是QQ的唯一選擇。 當年QQ達到百萬人同時在線的時候,國外的同行還沒有認為C10K是個問題。 想要用TCP承擔百萬人同時在線,在當時的技術條件下恐怕要付出上千台伺服器的代價, 這對於當時的"小企鵝"來說是絕對負擔不起的一筆投入。

由於缺乏操作系統對於高性能TCP協議的支持,想要在極為有限的伺服器條件下 處理QQ的C1000K問題,UDP的確是當時的騰訊架構師的唯一選擇。

# 得到授權之前,拒絕任何形式的轉載


推薦閱讀:

全棧工程師必備Linux 基礎
想學習 Linux 下的伺服器系統管理,有哪些值得推薦書籍或資料?
想學習 Linux,裝個虛擬機,裝哪個發行版好?

TAG:网络编程 | Linux | 高性能 |