Redis 中的事件迴圈

Draveness發表於2019-02-02

在目前的很多服務中,由於需要持續接受客戶端或者使用者的輸入,所以需要一個事件迴圈來等待並處理外部事件,這篇文章主要會介紹 Redis 中的事件迴圈是如何處理事件的。

在文章中,我們會先從 Redis 的實現中分析事件是如何被處理的,然後用更具象化的方式瞭解服務中的不同模組是如何交流的。

aeEventLoop

在分析具體程式碼之前,先了解一下在事件處理中處於核心部分的 aeEventLoop 到底是什麼:

Redis 中的事件迴圈
reids-eventloop

aeEventLoop 在 Redis 就是負責儲存待處理檔案事件和時間事件的結構體,其中儲存大量事件執行的上下文資訊,同時持有三個事件陣列:

  • aeFileEvent
  • aeTimeEvent
  • aeFiredEvent

aeFileEventaeTimeEvent 中會儲存監聽的檔案事件和時間事件,而最後的 aeFiredEvent 用於儲存待處理的檔案事件,我們會在後面的章節中介紹它們是如何工作的。

Redis 服務中的 EventLoop

redis-server 啟動時,首先會初始化一些 redis 服務的配置,最後會呼叫 aeMain 函式陷入 aeEventLoop 迴圈中,等待外部事件的發生:

int main(int argc, char **argv) {
    ...

    aeMain(server.el);
}複製程式碼

aeMain 函式其實就是一個封裝的 while 迴圈,迴圈中的程式碼會一直執行直到 eventLoopstop 被設定為 true

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        if (eventLoop->beforesleep != NULL)
            eventLoop->beforesleep(eventLoop);
        aeProcessEvents(eventLoop, AE_ALL_EVENTS);
    }
}複製程式碼

它會不停嘗試呼叫 aeProcessEvents 對可能存在的多種事件進行處理,而 aeProcessEvents 就是實際用於處理事件的函式:

int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
    int processed = 0, numevents;

    if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;

    if (eventLoop->maxfd != -1 ||
        ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
        struct timeval *tvp;

        #1:計算 I/O 多路複用的等待時間 tvp

        numevents = aeApiPoll(eventLoop, tvp);
        for (int j = 0; j < numevents; j++) {
            aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
            int mask = eventLoop->fired[j].mask;
            int fd = eventLoop->fired[j].fd;
            int rfired = 0;

            if (fe->mask & mask & AE_READABLE) {
                rfired = 1;
                fe->rfileProc(eventLoop,fd,fe->clientData,mask);
            }
            if (fe->mask & mask & AE_WRITABLE) {
                if (!rfired || fe->wfileProc != fe->rfileProc)
                    fe->wfileProc(eventLoop,fd,fe->clientData,mask);
            }
            processed++;
        }
    }
    if (flags & AE_TIME_EVENTS) processed += processTimeEvents(eventLoop);
    return processed;
}複製程式碼

上面的程式碼省略了 I/O 多路複用函式的等待時間,不過不會影響我們對程式碼的理解,整個方法大體由兩部分程式碼組成,一部分處理檔案事件,另一部分處理時間事件。

Redis 中會處理兩種事件:時間事件和檔案事件。

檔案事件

在一般情況下,aeProcessEvents 都會先計算最近的時間事件發生所需要等待的時間,然後呼叫 aeApiPoll 方法在這段時間中等待事件的發生,在這段時間中如果發生了檔案事件,就會優先處理檔案事件,否則就會一直等待,直到最近的時間事件需要觸發:

numevents = aeApiPoll(eventLoop, tvp);
for (j = 0; j < numevents; j++) {
    aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
    int mask = eventLoop->fired[j].mask;
    int fd = eventLoop->fired[j].fd;
    int rfired = 0;

    if (fe->mask & mask & AE_READABLE) {
        rfired = 1;
        fe->rfileProc(eventLoop,fd,fe->clientData,mask);
    }
    if (fe->mask & mask & AE_WRITABLE) {
        if (!rfired || fe->wfileProc != fe->rfileProc)
            fe->wfileProc(eventLoop,fd,fe->clientData,mask);
    }
    processed++;
}複製程式碼

檔案事件如果繫結了對應的讀/寫事件,就會執行對應的對應的程式碼,並傳入事件迴圈、檔案描述符、資料以及掩碼:

fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fe->wfileProc(eventLoop,fd,fe->clientData,mask);複製程式碼

其中 rfileProcwfileProc 就是在檔案事件被建立時傳入的函式指標:

int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData) {
    aeFileEvent *fe = &eventLoop->events[fd];

    if (aeApiAddEvent(eventLoop, fd, mask) == -1)
        return AE_ERR;
    fe->mask |= mask;
    if (mask & AE_READABLE) fe->rfileProc = proc;
    if (mask & AE_WRITABLE) fe->wfileProc = proc;
    fe->clientData = clientData;
    if (fd > eventLoop->maxfd)
        eventLoop->maxfd = fd;
    return AE_OK;
}複製程式碼

需要注意的是,傳入的 proc 函式會在對應的 mask 位事件發生時執行。

時間事件

在 Redis 中會發生兩種時間事件:

  • 一種是定時事件,每隔一段時間會執行一次;
  • 另一種是非定時事件,只會在某個時間點執行一次;

時間事件的處理在 processTimeEvents 中進行,我們會分三部分分析這個方法的實現:

static int processTimeEvents(aeEventLoop *eventLoop) {
    int processed = 0;
    aeTimeEvent *te, *prev;
    long long maxId;
    time_t now = time(NULL);

    if (now < eventLoop->lastTime) {
        te = eventLoop->timeEventHead;
        while(te) {
            te->when_sec = 0;
            te = te->next;
        }
    }
    eventLoop->lastTime = now;複製程式碼

由於對系統時間的調整會影響當前時間的獲取,進而影響時間事件的執行;如果系統時間先被設定到了未來的時間,又設定成正確的值,這就會導致時間事件會隨機延遲一段時間執行,也就是說,時間事件不會按照預期的安排儘早執行,而 eventLoop 中的 lastTime 就是用於檢測上述情況的變數:

typedef struct aeEventLoop {
    ...
    time_t lastTime;     /* Used to detect system clock skew */
    ...
} aeEventLoop;複製程式碼

如果發現了系統時間被改變(小於上次 processTimeEvents 函式執行的開始時間),就會強制所有時間事件儘早執行。

    prev = NULL;
    te = eventLoop->timeEventHead;
    maxId = eventLoop->timeEventNextId-1;
    while(te) {
        long now_sec, now_ms;
        long long id;

        if (te->id == AE_DELETED_EVENT_ID) {
            aeTimeEvent *next = te->next;
            if (prev == NULL)
                eventLoop->timeEventHead = te->next;
            else
                prev->next = te->next;
            if (te->finalizerProc)
                te->finalizerProc(eventLoop, te->clientData);
            zfree(te);
            te = next;
            continue;
        }複製程式碼

Redis 處理時間事件時,不會在當前迴圈中直接移除不再需要執行的事件,而是會在當前迴圈中將時間事件的 id 設定為 AE_DELETED_EVENT_ID,然後再下一個迴圈中刪除,並執行繫結的 finalizerProc

        aeGetTime(&now_sec, &now_ms);
        if (now_sec > te->when_sec ||
            (now_sec == te->when_sec && now_ms >= te->when_ms))
        {
            int retval;

            id = te->id;
            retval = te->timeProc(eventLoop, id, te->clientData);
            processed++;
            if (retval != AE_NOMORE) {
                aeAddMillisecondsToNow(retval,&te->when_sec,&te->when_ms);
            } else {
                te->id = AE_DELETED_EVENT_ID;
            }
        }
        prev = te;
        te = te->next;
    }
    return processed;
}複製程式碼

在移除不需要執行的時間事件之後,我們就開始通過比較時間來判斷是否需要呼叫 timeProc 函式,timeProc 函式的返回值 retval 為時間事件執行的時間間隔:

  • retval == AE_NOMORE:將時間事件的 id 設定為 AE_DELETED_EVENT_ID,等待下次 aeProcessEvents 執行時將事件清除;
  • retval != AE_NOMORE:修改當前時間事件的執行時間並重複利用當前的時間事件;

以使用 aeCreateTimeEvent 一個建立的簡單時間事件為例:

aeCreateTimeEvent(config.el,1,showThroughput,NULL,NULL)複製程式碼

時間事件對應的函式 showThroughput 在每次執行時會返回一個數字,也就是該事件發生的時間間隔:

int showThroughput(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    ...
    float dt = (float)(mstime()-config.start)/1000.0;
    float rps = (float)config.requests_finished/dt;
    printf("%s: %.2f
", config.title, rps);
    fflush(stdout);
    return 250; /* every 250ms */
}複製程式碼

這樣就不需要重新 malloc 一塊相同大小的記憶體,提高了時間事件處理的效能,並減少了記憶體的使用量。

我們對 Redis 中對時間事件的處理以流程圖的形式簡單總結一下:

Redis 中的事件迴圈
process-time-event

建立時間事件的方法實現其實非常簡單,在這裡不想過多分析這個方法,唯一需要注意的就是時間事件的 id 跟資料庫中的大多數主鍵都是遞增的:

long long aeCreateTimeEvent(aeEventLoop *eventLoop, long long milliseconds,
        aeTimeProc *proc, void *clientData,
        aeEventFinalizerProc *finalizerProc) {
    long long id = eventLoop->timeEventNextId++;
    aeTimeEvent *te;

    te = zmalloc(sizeof(*te));
    if (te == NULL) return AE_ERR;
    te->id = id;
    aeAddMillisecondsToNow(milliseconds,&te->when_sec,&te->when_ms);
    te->timeProc = proc;
    te->finalizerProc = finalizerProc;
    te->clientData = clientData;
    te->next = eventLoop->timeEventHead;
    eventLoop->timeEventHead = te;
    return id;
}複製程式碼

事件的處理

上一章節我們已經從程式碼的角度對 Redis 中事件的處理有一定的瞭解,在這裡,我想從更高的角度來觀察 Redis 對於事件的處理是怎麼進行的。

整個 Redis 服務在啟動之後會陷入一個巨大的 while 迴圈,不停地執行 processEvents 方法處理檔案事件 fe 和時間事件 te 。

有關 Redis 中的 I/O 多路複用模組可以看這篇文章 Redis 和 I/O 多路複用

當檔案事件觸發時會被標記為 “紅色” 交由 processEvents 方法處理,而時間事件的處理都會交給 processTimeEvents 這一子方法:

Redis 中的事件迴圈
redis-eventloop-proces-event

在每個事件迴圈中 Redis 都會先處理檔案事件,然後再處理時間事件直到整個迴圈停止,processEventsprocessTimeEvents 作為 Redis 中發生事件的消費者,每次都會從“事件池”中拉去待處理的事件進行消費。

檔案事件的處理

由於檔案事件觸發條件較多,並且 OS 底層實現差異性較大,底層的 I/O 多路複用模組使用了 eventLoop->aeFiredEvent 儲存對應的檔案描述符以及事件,將資訊傳遞給上層進行處理,並抹平了底層實現的差異。

整個 I/O 多路複用模組在事件迴圈看來就是一個輸入事件、輸出 aeFiredEvent 陣列的一個黑箱:

Redis 中的事件迴圈
eventloop-file-event-in-redis

在這個黑箱中,我們使用 aeCreateFileEventaeDeleteFileEvent 來新增刪除需要監聽的檔案描述符以及事件。

在對應事件發生時,當前單元格會“變色”表示發生了可讀(黃色)或可寫(綠色)事件,呼叫 aeApiPoll 時會把對應的檔案描述符和事件放入 aeFiredEvent 陣列,並在 processEvents 方法中執行事件對應的回撥。

時間事件的處理

時間事件的處理相比檔案事件就容易多了,每次 processTimeEvents 方法呼叫時都會對整個 timeEventHead 陣列進行遍歷:

Redis 中的事件迴圈
process-time-events-in-redis

遍歷的過程中會將時間的觸發時間與當前時間比較,然後執行時間對應的 timeProc,並根據 timeProc 的返回值修改當前事件的引數,並在下一個迴圈的遍歷中移除不再執行的時間事件。

總結

筆者對於文章中兩個模組的展示順序考慮了比較久的時間,最後還是覺得,目前這樣的順序更易於理解。

Redis 對於事件的處理方式十分精巧,通過傳入函式指標以及返回值的方式,將時間事件移除的控制權交給了需要執行的處理器 timeProc,在 processTimeEvents 設定 aeApiPoll 超時時間也十分巧妙,充分地利用了每一次事件迴圈,防止過多的無用的空轉,並且保證了該方法不會阻塞太長時間。

事件迴圈的機制並不能時間事件準確地在某一個時間點一定執行,往往會比實際約定處理的時間稍微晚一些。

Reference

其它

Follow: Draveness · GitHub

Source: draveness.me/redis-event…

相關文章