為什麼C/C++程序員都要閱讀Redis源碼之:Redis學習事件驅動設計

0. 為什麼我說C/C++程序員都要閱讀Redis源碼

主要原因就是『簡潔』。如果你用源碼編譯過Redis,你會發現十分輕快,一步到位。其他語言的開發者可能不會了解這種痛,作為C/C++程序員,如果你源碼編譯安裝過Nginx/Grpc/Thrift/Boost等開源產品,你會發現有很多依賴,而依賴本身又有依賴,十分痛苦。通常半天一天就耗進去了。由衷地羨慕 npm/maven/pip/composer/...這些包管理器。而Redis則給人驚喜,一行make了此殘生。

除了安裝過程簡潔,代碼也十分簡潔。使用純C語言編寫,每個模塊功能都劃分的很清晰。

廢話不多說,本文要介紹的是Redis裡的事件處理功能,與Memcache引入libevent這一臃腫的事件庫不同,Redis自己實現了一個小型輕量的事件驅動庫——AE。閱讀它的源碼是一次非常好的學習和體驗。

為什麼C/C++程序員都要閱讀Redis源碼之:Redis學習事件驅動設計

1. 跨平臺兼容:重劍無鋒,大巧不工

文件名說明ae.h/ae.c主要文件,根據OS平臺的不同依賴以下不同文件:

  • ae_epoll.c:Linux平臺
  • ae_kqueue.c:BSD平臺
  • ae_evport.c:Solaris平臺
  • ae_select.c:其他Unix平臺

雖然源碼文件看起來不少,但是實際上ae_epoll.c、 ae_kqueue.c、 ae_evport.c、 ae_select.c 這4個文件的功能是完全一樣的,提供一致的API接口,給ae.c文件調用。這是由於實現高性能的事件驅動的API(稱之為polling API)不存在ANSI或POSIX的標準,不同的OS內核有著自己的實現。比如Linux內核的epoll,BSD內核中的kqueue。

ae.c中有:

#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif

這些HAVE的宏,都是由在config.h中定義的。依據不同的操作系統,引入這4個文件中的某一個。從功能上來說,這樣設計的目的與GOF設計模式中“適配器模式”(修改成一致接口)或“外觀模式”(抽象複雜接口為簡單接口)的思想類似,但實際上我個人感覺更類似於《POSA》(卷二)中提到的“包裝門面模式”(Wrapper Facade)。Anyway,這個編程思想值得學習。

為什麼C/C++程序員都要閱讀Redis源碼之:Redis學習事件驅動設計

2. 用C++去設計,用C編碼:aeEventLoop

十幾年前,以Linux之父炮轟C++為開端,社區內展開了一場C與C++孰是孰非的論戰。而在國內,以原CSDN總編劉江援引此文為始,把戰火燒到了國內。孟巖、雲風、pongba幾位大佬都身陷其中。後來以孟巖的一句『用C設計,用C++編碼』在國內為這場論戰定下基調。

反觀Redis,他是純C編碼,但是融入了面向對象的思想。和上述觀點截然相反,可謂是『用C++去設計,用C編碼』。當然本文目的並非挑起語言之爭,各種語言自有其利弊,開源項目的語言選擇也主要是由於項目作者的個人經歷和主觀意願。

定義在ae.h中的結構體 aeEventLoop 是AE庫中最核心的數據結構,並且它採用了面向對象的設計思想:ae.h 中聲明瞭多個函數,其第一個參數都是一個aeEventLoop指針,用於操縱aeEventLoop結構體。從這個角度來說,可以將該結構體理解為面嚮對象語言中的類,而操縱它的函數則可以視為其成員函數。(其實C++的class編譯之後大概也是類似的模式)

  • aeCreateEventLoop:初始化一個事件循環結構體(eventLoop)
  • aeGetSetSize:返回當前setsize的值aeResizeSetSize改變setsize的值(空間重新分配)
  • aeDeleteEventLoop刪除事件循環eventLoop(釋放內存空間)
  • aeStop:停止事件循環,即stop值設為1
  • aeProcessEvents:核心部分:事件處理邏輯
  • aeMain:啟動事件循環,事件循環的入口
  • aeSetBeforeSleepProc:註冊回調函數,即每次主循環在休眠之前被調用

函數 aeCreateEventLoop 和 aeDeleteEventLoop 可以視為“類”aeEventLoop的構造和析構函數,其他為成員函數。

調用流程

在客戶程序調用AE庫的時候,一般是依次調用:

  • aeCreateEventLoop
  • aeSetBeforeSleepProc
  • aeMain
  • aeDeleteEventLoop

2.1 AE的兩種事件

事件處理,是有別於多線程/多進程的併發模型。我也都知道Redis是單線程的。它的性能主要依靠異步事件處理功能來實現。雖然事件處理通常和網絡編程混作一談,但其實事件處理本身不一定是為網絡編程服務的,它主要是服務於IO,網絡通信是IO,文件讀寫同樣是。當然Unix中萬物皆文件了,socket也是一種fd。

AE支持兩種事件:

  • 文件事件(IO)時間事件(毫秒級)

這兩種事件都作為aeEventLoop的結構體成員存在。

aeEventLoop各成員說明:

typedef struct aeEventLoop {
int maxfd; /* 當前註冊的最大fd */
int setsize; /* 監視的fd的最大數量 */
long long timeEventNextId; /* 下一個時間事件的ID */
time_t lastTime; /* 上次時間事件處理時間 */
aeFileEvent *events; /* 已註冊文件事件數組 */
aeFiredEvent *fired; /* 就緒的文件事件數組 */
aeTimeEvent *timeEventHead; /* 時間事件鏈表的頭 */
int stop; /* 是否停止(0:否;1:是)*/
void *apidata; /* 各平臺polling API所需的特定數據 */
aeBeforeSleepProc *beforesleep; /* 事件循環休眠開始的處理函數 */
aeBeforeSleepProc *aftersleep; /* 事件循環休眠結束的處理函數 */
} aeEventLoop;

文件事件,主要依靠兩個數組。一個是註冊的文件事件數組,一個是已就緒的文件事件數組。

typedef struct aeFileEvent {
int mask; /* one of AE_(READABLE|WRITABLE|BARRIER) */
aeFileProc *rfileProc;
aeFileProc *wfileProc;
void *clientData;
} aeFileEvent;
typedef struct aeFiredEvent {
int fd;
int mask;
} aeFiredEvent;

單詞Fired在這裡表示的是就緒的意思,不知道是antirez英語不好,還是我英語不好,反正我是不知道fire有這層含義。

更新:經@Serena Yu提醒fire event是英語表示事件發出。果然還是我英語不好……

每個文件事件,其讀寫設置了不同的處理函數。另外mask表示事件的觸發類型。當每次polling API返回就緒之後(比如epoll_wait返回),就緒會被設置到aeFireEvent,然後反查aeFileEvent獲得處理函數並處理。你會發現aeFileEvent結構體裡並沒有記錄fd。其實這是使用了HASH策略,aeEventLoop的成員 aeFileEvent數組的下標即是fd,便於快速查找。

時間事件,本質就是定時器任務,其數據結構採用一個雙向鏈表。鏈表每個結點為aeTimeEvent結構體,主要包含事件的ID(遞增)、就緒的時間,處理函數、清理函數、客戶數據。

typedef struct aeTimeEvent {
long long id; /* time event identifier. */
long when_sec; /* seconds */
long when_ms; /* milliseconds */
aeTimeProc *timeProc;
aeEventFinalizerProc *finalizerProc;
void *clientData;
struct aeTimeEvent *prev;
struct aeTimeEvent *next;
} aeTimeEvent;

每個事件循環中,每個時間事件的ID唯一且遞增,主要依賴aeEventLoop裡的timeEventNextId來維護這個ID的遞增關係。創建新的時間事件時(aeCreateTimeEvent)會賦值,由於只考慮了單線程,所以沒有加鎖邏輯,大家也不要貿然把AE用在多線程環境中。另外創建時間過程就是簡單的追加到時間事件鏈表的尾部,並沒有針對就緒時間做排序。

when_sec和 when_ms 記錄了時間事件的就緒時間(秒+毫秒),即噹噹前時間大於等於這個時間的時候,該時間事件應被處理。

時間事件的處理過程(processTimeEvents)主要就是:繼續遍歷鏈表,如果發現節點狀態為AE_DELETED_EVENT_ID則刪除該節點。如果判斷當前時間已經超過節點的就緒時間就開始處理。處理函數的返回值可以指定,後續不再處理該事件(NOMORE),則該節點會被置為AE_DELETED_EVENT_ID。如果下次還需要處理,則更新該節點的時間為下次就緒時間。

為什麼C/C++程序員都要閱讀Redis源碼之:Redis學習事件驅動設計

2.2 事件循環的處理邏輯

再用一張圖,回顧一下EventLoop中的兩種事件,基本可以做如下理解。一個鏈表,一個數組。文件事件中的數組不是線性填滿的,因為是採用的HASH策略,將fd作為數組下標了。

為什麼C/C++程序員都要閱讀Redis源碼之:Redis學習事件驅動設計

aeProcessEvents是aeEventLoop在循環過程中的的實際處理邏輯。

aeProcessEvents 是aeEventLoop在循環過程中的的實際處理邏輯。函數原型如下:

int aeProcessEvents(aeEventLoop *eventLoop, int flags);

flags標記,表示本次需要處理的事件類型標記和是否阻塞標記。

  • AE_TIME_EVENTS:時間事件標記
  • AE_FILE_EVENTS:文件事件標記
  • AE_DONT_WAIT:立即返回不阻塞等待的標記

aeProcessEvents代碼我就不貼了。它巧妙的地方是一次柔和了文件和時間事件的兩種處理過程。

在函數之初,會線性查找時間事件的鏈表,找到最近時間內會就緒時間事件,然後用它的就緒時間減去當前時間的時間差作為polling API的休眠時間(epoll_wait的timeout參數)。然後休眠等待polling api返回。在返回之後先執行aftersleep的的處理邏輯,然後執行這段休眠時間內就緒的文件事件,最後再處理就緒的時間事件。返回值是處理過的事件總數。

也就說AE會盡量在一次處理過程中,將時間事件和文件事件一次性處理。你也許會問如果沒有時間事件怎麼辦。當然沒關係,在aeProcessEvents開始部分就根據標記位進行了判斷。上面的邏輯是在文件事件和時間事件都存在的情況下,如果僅存在文件事件,則看是否設置了不阻塞的標記(AE_DONT_WAIT),若有,則polling 的超時時間設置為0。如無,即可以阻塞,則設置為-1,則polling API會阻塞直到有文件事件發生。

縱觀AE是一種非常簡易但也十分典型的Reactor網絡模型。


ae_epoll(Linux上polling API:epoll的封裝)

前文說道Redis適配各種Unix-like的操作系統。它將系統強相關的事件API部分單獨抽出來,包裝出了相同的接口給AE的對外API調用。在Linux系統上的API實現為:ae_epoll.c,建議在閱讀這個文件源碼之前先好好回顧一下epoll的API,這樣更助於快速理解。相信工作後大家寫業務邏輯,應該很少接觸epoll了。可以閱讀這個wik,快速回顧epoll的api:LinuxAPI:epoll

ae_epoll.c 完全被 ae.c調用。各函數調用關係如下(aeApi開頭的都是ae_epoll.c中的函數):

  • ae.c
  • aeCreateEventLoop
  • aeApiCreate
  • aeResizeSetSize
  • aeApiResize
  • aeDeleteEventLoop
  • aeApiFree
  • aeCreateFileEvent
  • aeApiAddEvent
  • aeDeleteFileEvent
  • aeApiDelEvent
  • aeProcessEvent
  • aeApiPoll
  • aeGetApiName
  • aeApiName

除了aeApiName()以外,其他函數第一個參數也都是aeEventLoop * 。用面向對象的思想來看這也是aeEventLoop的成員函數。試想若是C++,則可能會被處理成父子兩個類,而aeApi系列的函數是純虛的

aeApi的函數也是可以做到顧名即可思義。比如:

  1. aeApiCreate在堆上進行內存的分配,封裝epoll_create創建epfd,並寫入aeEventLoop。
  2. aeApiAddEvent、aeApiDelEvent是封裝的epoll_ctl來對aeEventLoop的監控的epoll事件進行添加和刪除。
  3. aeApiPoll是封裝的epoll_wait開啟事件循環,並且每次取出就緒的fd存入aeEventLoop的fired數組中,並置位相應的mask(讀or寫)
  4. aeApiResize、aeApiFree分別進行的是內存的重分配、資源的清理(關閉epfd,free內存)和epoll本身關聯不大。

不止AE中,整個Redis在使用堆內存的時候,都是使用它自己實現的zmalloc,而非libc的malloc。


Jim:吃水不忘挖井人,AE的靈感之源

閱讀完AE代碼,可能只需要一下午的時間,你會驚歎於作者的設計功力。其實裡面也沒有太多花哨的東西,但就是如此簡潔清晰的給你呈現了一個完成度如此之高的事件驅動處理庫。但我想即使大家都熟悉epoll、熟悉kqueue、熟悉數據結構也不一定能設計出來AE,所以把程序員比作代碼的設計師、建築師是絲毫不為過的。

“吃水不忘挖井人”,AE的設計靈感也是受另外一個開源項目影響,它就是 Jim。Redis的ae.c的開篇註釋中就已註明:

/* A simple event-driven programming library. Originally I wrote this code
* for the Jim's event-loop (Jim is a Tcl interpreter) but later translated
* it in form of a library for easy reuse.

Jim的源碼在Github上有它的鏡像,其中事件循環的代碼在此:https://github.com/msteveb/jimtcl/blob/master/jim-eventloop.c

簡單閱讀一下,你就會發現AE確實整體的處理邏輯是從Jim吸收的。但AE也不乏創新,比如抽象除polling API這層,達到了多平臺的兼容和解耦,而Jim強耦合了select。

Anyway,就像經典的『站在巨人肩膀』理論,雖然Jim不是巨人,但它啟發了AE,即使Jim最終被世人遺忘,而它的血肉也化作了土壤,滋養後來人,這就是開源運動的意義所在,也是魅力所在。

相關推薦

推薦中...