Redis 事件機制詳解

程式設計師歷小冰發表於2019-08-08

Redis 採用事件驅動機制來處理大量的網路IO。它並沒有使用 libevent 或者 libev 這樣的成熟開源方案,而是自己實現一個非常簡潔的事件驅動庫 ae_event。

Redis中的事件驅動庫只關注網路IO,以及定時器。該事件庫處理下面兩類事件:

  • 檔案事件(file  event):用於處理 Redis 伺服器和客戶端之間的網路IO。
  • 時間事件(time  eveat):Redis 伺服器中的一些操作(比如serverCron函式)需要在給定的時間點執行,而時間事件就是處理這類定時操作的。

事件驅動庫的程式碼主要是在src/ae.c中實現的,其示意圖如下所示。

事件管理器示意圖

aeEventLoop是整個事件驅動的核心,它管理著檔案事件表和時間事件列表, 不斷地迴圈處理著就緒的檔案事件和到期的時間事件。下面我們就先分別介紹檔案事件和時間事件,然後講述相關的aeEventLoop原始碼實現。

檔案事件

Redis基於Reactor模式開發了自己的網路事件處理器,也就是檔案事件處理器。檔案事件處理器使用IO多路複用技術,同時監聽多個套接字,併為套接字關聯不同的事件處理函式。當套接字的可讀或者可寫事件觸發時,就會呼叫相應的事件處理函式。

Redis 使用的IO多路複用技術主要有:selectepollevportkqueue等。每個IO多路複用函式庫在 Redis 原始碼中都對應一個單獨的檔案,比如ae_select.c,ae_epoll.c, ae_kqueue.c等。Redis 會根據不同的作業系統,按照不同的優先順序選擇多路複用技術。事件響應框架一般都採用該架構,比如 netty 和 libevent。

示意圖

如下圖所示,檔案事件處理器有四個組成部分,它們分別是套接字、I/O多路複用程式、檔案事件分派器以及事件處理器。

示意圖

檔案事件是對套接字操作的抽象,每當一個套接字準備好執行 accept、read、write和 close 等操作時,就會產生一個檔案事件。因為 Redis 通常會連線多個套接字,所以多個檔案事件有可能併發的出現。

I/O多路複用程式負責監聽多個套接字,並向檔案事件派發器傳遞那些產生了事件的套接字。

儘管多個檔案事件可能會併發地出現,但I/O多路複用程式總是會將所有產生的套接字都放到同一個佇列(也就是後文中描述的aeEventLoopfired就緒事件表)裡邊,然後檔案事件處理器會以有序、同步、單個套接字的方式處理該佇列中的套接字,也就是處理就緒的檔案事件。

一次請求的過程示意圖

所以,一次 Redis 客戶端與伺服器進行連線並且傳送命令的過程如上圖所示。

  • 客戶端向服務端發起建立 socket 連線的請求,那麼監聽套接字將產生 AE_READABLE 事件,觸發連線應答處理器執行。處理器會對客戶端的連線請求進行應答,然後建立客戶端套接字,以及客戶端狀態,並將客戶端套接字的 AE_READABLE 事件與命令請求處理器關聯。
  • 客戶端建立連線後,向伺服器傳送命令,那麼客戶端套接字將產生 AE_READABLE 事件,觸發命令請求處理器執行,處理器讀取客戶端命令,然後傳遞給相關程式去執行。
  • 執行命令獲得相應的命令回覆,為了將命令回覆傳遞給客戶端,伺服器將客戶端套接字的 AE_WRITEABLE 事件與命令回覆處理器關聯。當客戶端試圖讀取命令回覆時,客戶端套接字產生 AE_WRITEABLE 事件,觸發命令回覆處理器將命令回覆全部寫入到套接字中。

時間事件

Redis 的時間事件分為以下兩類:

  • 定時事件:讓一段程式在指定的時間之後執行一次。
  • 週期性事件:讓一段程式每隔指定時間就執行一次。

Redis 的時間事件的具體定義結構如下所示。

typedef struct aeTimeEvent {
    /* 全域性唯一ID */
    long long id; /* time event identifier. */
    /* 秒精確的UNIX時間戳,記錄時間事件到達的時間*/
    long when_sec; /* seconds */
    /* 毫秒精確的UNIX時間戳,記錄時間事件到達的時間*/
    long when_ms; /* milliseconds */
    /* 時間處理器 */
    aeTimeProc *timeProc;
    /* 事件結束回撥函式,析構一些資源*/
    aeEventFinalizerProc *finalizerProc;
    /* 私有資料 */
    void *clientData;
    /* 前驅節點 */
    struct aeTimeEvent *prev;
    /* 後繼節點 */
    struct aeTimeEvent *next;
} aeTimeEvent;
複製程式碼

一個時間事件是定時事件還是週期性事件取決於時間處理器的返回值:

  • 如果返回值是 AE_NOMORE,那麼這個事件是一個定時事件,該事件在達到後刪除,之後不會再重複。
  • 如果返回值是非 AE_NOMORE 的值,那麼這個事件為週期性事件,當一個時間事件到達後,伺服器會根據時間處理器的返回值,對時間事件的 when 屬性進行更新,讓這個事件在一段時間後再次達到。

Redis 將所有時間事件都放在一個無序連結串列中,每次 Redis 會遍歷整個連結串列,查詢所有已經到達的時間事件,並且呼叫相應的事件處理器。

介紹完檔案事件和時間事件,我們接下來看一下 aeEventLoop的具體實現。

建立事件管理器

Redis 服務端在其初始化函式 initServer中,會建立事件管理器aeEventLoop物件。

函式aeCreateEventLoop將建立一個事件管理器,主要是初始化 aeEventLoop的各個屬性值,比如eventsfiredtimeEventHeadapidata

  • 首先建立aeEventLoop物件。
  • 初始化未就緒檔案事件表、就緒檔案事件表。events指標指向未就緒檔案事件表、fired指標指向就緒檔案事件表。表的內容在後面新增具體事件時進行初變更。
  • 初始化時間事件列表,設定timeEventHeadtimeEventNextId屬性。
  • 呼叫aeApiCreate 函式建立epoll例項,並初始化 apidata
aeEventLoop *aeCreateEventLoop(int setsize) {
    aeEventLoop *eventLoop;
    int i;
    /* 建立事件狀態結構 */
    if ((eventLoop = zmalloc(sizeof(*eventLoop))) == NULL) goto err;
    /* 建立未就緒事件表、就緒事件表 */
    eventLoop->events = zmalloc(sizeof(aeFileEvent)*setsize);
    eventLoop->fired = zmalloc(sizeof(aeFiredEvent)*setsize);
    if (eventLoop->events == NULL || eventLoop->fired == NULL) goto err;
    /* 設定陣列大小 */
    eventLoop->setsize = setsize;
    /* 初始化執行最近一次執行時間 */
    eventLoop->lastTime = time(NULL);
    /* 初始化時間事件結構 */
    eventLoop->timeEventHead = NULL;
    eventLoop->timeEventNextId = 0;
    eventLoop->stop = 0;
    eventLoop->maxfd = -1;
    eventLoop->beforesleep = NULL;
    eventLoop->aftersleep = NULL;
    /* 將多路複用io與事件管理器關聯起來 */
    if (aeApiCreate(eventLoop) == -1) goto err;
    /* 初始化監聽事件 */
    for (i = 0; i < setsize; i++)
        eventLoop->events[i].mask = AE_NONE;
    return eventLoop;
err:
   .....
}
複製程式碼

aeApiCreate 函式首先建立了aeApiState物件,初始化了epoll就緒事件表;然後呼叫epoll_create建立了epoll例項,最後將該aeApiState賦值給apidata屬性。

aeApiState物件中epfd儲存epoll的標識,events是一個epoll就緒事件陣列,當有epoll事件發生時,所有發生的epoll事件和其描述符將儲存在這個陣列中。這個就緒事件陣列由應用層開闢空間、核心負責把所有發生的事件填充到該陣列。

static int aeApiCreate(aeEventLoop *eventLoop) {
    aeApiState *state = zmalloc(sizeof(aeApiState));

    if (!state) return -1;
    /* 初始化epoll就緒事件表 */
    state->events = zmalloc(sizeof(struct epoll_event)*eventLoop->setsize);
    if (!state->events) {
        zfree(state);
        return -1;
    }
    /* 建立 epoll 例項 */
    state->epfd = epoll_create(1024); /* 1024 is just a hint for the kernel */
    if (state->epfd == -1) {
        zfree(state->events);
        zfree(state);
        return -1;
    }
    /* 事件管理器與epoll關聯 */
    eventLoop->apidata = state;
    return 0;
}
typedef struct aeApiState {
    /* epoll_event 例項描述符*/
    int epfd;
    /* 儲存epoll就緒事件表 */
    struct epoll_event *events;
} aeApiState;
複製程式碼

建立檔案事件

aeFileEvent是檔案事件結構,對於每一個具體的事件,都有讀處理函式和寫處理函式等。Redis 呼叫aeCreateFileEvent函式針對不同的套接字的讀寫事件註冊對應的檔案事件。

typedef struct aeFileEvent {
    /* 監聽事件型別掩碼,值可以是 AE_READABLE 或 AE_WRITABLE */
    int mask;
    /* 讀事件處理器 */
    aeFileProc *rfileProc;
    /* 寫事件處理器 */
    aeFileProc *wfileProc;
    /* 多路複用庫的私有資料 */
    void *clientData;
} aeFileEvent;
/* 使用typedef定義的處理器函式的函式型別 */
typedef void aeFileProc(struct aeEventLoop *eventLoop, 
int fd, void *clientData, int mask);

複製程式碼

比如說,Redis 進行主從複製時,從伺服器需要主伺服器建立連線,它會發起一個 socekt連線,然後呼叫aeCreateFileEvent函式針對發起的socket的讀寫事件註冊了對應的事件處理器,也就是syncWithMaster函式。

aeCreateFileEvent(server.el,fd,AE_READABLE|AE_WRITABLE,syncWithMaster,NULL);
/* 符合aeFileProc的函式定義 */
void syncWithMaster(aeEventLoop *el, int fd, void *privdata, int mask) {....}
複製程式碼

aeCreateFileEvent的引數fd指的是具體的socket套接字,procfd產生事件時,具體的處理函式,clientData則是回撥處理函式時需要傳入的資料。 aeCreateFileEvent主要做了三件事情:

  • fd為索引,在events未就緒事件表中找到對應事件。
  • 呼叫aeApiAddEvent函式,該事件註冊到具體的底層 I/O 多路複用中,本例為epoll。
  • 填充事件的回撥、引數、事件型別等引數。
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
                       aeFileProc *proc, void *clientData)
{
    /* 取出 fd 對應的檔案事件結構, fd 代表具體的 socket 套接字 */
    aeFileEvent *fe = &eventLoop->events[fd];
    /* 監聽指定 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;
}
複製程式碼

如上文所說,Redis 基於的底層 I/O 多路複用庫有多套,所以aeApiAddEvent也有多套實現,下面的原始碼是epoll下的實現。其核心操作就是呼叫epollepoll_ctl函式來向epoll註冊響應事件。有關epoll相關的知識可以看一下《Java NIO原始碼分析》

static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
    aeApiState *state = eventLoop->apidata;
    struct epoll_event ee = {0}; /* avoid valgrind warning */
    /* 如果 fd 沒有關聯任何事件,那麼這是一個 ADD 操作。如果已經關聯了某個/某些事件,那麼這是一個 MOD 操作。 */
    int op = eventLoop->events[fd].mask == AE_NONE ?
            EPOLL_CTL_ADD : EPOLL_CTL_MOD;

    /* 註冊事件到 epoll */
    ee.events = 0;
    mask |= eventLoop->events[fd].mask; /* Merge old events */
    if (mask & AE_READABLE) ee.events |= EPOLLIN;
    if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
    ee.data.fd = fd;
    /* 呼叫epoll_ctl 系統呼叫,將事件加入epoll中 */
    if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;
    return 0;
}
複製程式碼

事件處理

因為 Redis 中同時存在檔案事件和時間事件兩個事件型別,所以伺服器必須對這兩個事件進行排程,決定何時處理檔案事件,何時處理時間事件,以及如何排程它們。

aeMain函式以一個無限迴圈不斷地呼叫aeProcessEvents函式來處理所有的事件。

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        /* 如果有需要在事件處理前執行的函式,那麼執行它 */
        if (eventLoop->beforesleep != NULL)
            eventLoop->beforesleep(eventLoop);
        /* 開始處理事件*/
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
    }
}
複製程式碼

下面是aeProcessEvents的虛擬碼,它會首先計算距離當前時間最近的時間事件,以此計算一個超時時間;然後呼叫aeApiPoll函式去等待底層的I/O多路複用事件就緒;aeApiPoll函式返回之後,會處理所有已經產生檔案事件和已經達到的時間事件。

/* 虛擬碼 */
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
    /* 獲取到達時間距離當前時間最接近的時間事件*/
    time_event = aeSearchNearestTimer();
    /* 計算最接近的時間事件距離到達還有多少毫秒*/
    remaind_ms = time_event.when - unix_ts_now();
    /* 如果事件已經到達,那麼remaind_ms為負數,將其設定為0 */
    if (remaind_ms < 0) remaind_ms = 0;
    /* 根據 remaind_ms 的值,建立 timeval 結構*/
    timeval = create_timeval_with_ms(remaind_ms);
    /* 阻塞並等待檔案事件產生,最大阻塞時間由傳入的 timeval 結構決定,如果remaind_ms 的值為0,則aeApiPoll 呼叫後立刻返回,不阻塞*/
    /* aeApiPoll呼叫epoll_wait函式,等待I/O事件*/
    aeApiPoll(timeval);
    /* 處理所有已經產生的檔案事件*/
    processFileEvents();
    /* 處理所有已經到達的時間事件*/
    processTimeEvents();
}
複製程式碼

aeApiAddEvent類似,aeApiPoll也有多套實現,它其實就做了兩件事情,呼叫epoll_wait阻塞等待epoll的事件就緒,超時時間就是之前根據最快達到時間事件計算而來的超時時間;然後將就緒的epoll事件轉換到fired就緒事件。aeApiPoll就是上文所說的I/O多路複用程式。具體過程如下圖所示。

aeApiPoll示意圖

static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) 
{
    aeApiState *state = eventLoop->apidata;
    int retval, numevents = 0;
    // 呼叫epoll_wait函式,等待時間為最近達到時間事件的時間計算而來。
    retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
            tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
    // 有至少一個事件就緒?
    if (retval > 0) 
    {
        int j;
        /*為已就緒事件設定相應的模式,並加入到 eventLoop 的 fired 陣列中*/
        numevents = retval;
        for (j = 0; j < numevents; j++) 
	{
            int mask = 0;
            struct epoll_event *e = state->events+j;
            if (e->events & EPOLLIN)
		mask |= AE_READABLE;
            if (e->events & EPOLLOUT)
		mask |= AE_WRITABLE;
            if (e->events & EPOLLERR) 
		mask |= AE_WRITABLE;
            if (e->events & EPOLLHUP)
		mask |= AE_WRITABLE;
            /* 設定就緒事件表元素 */
            eventLoop->fired[j].fd = e->data.fd;
            eventLoop->fired[j].mask = mask;
        }
    }
    
    // 返回已就緒事件個數
    return numevents;
}
複製程式碼

processFileEvent是處理就緒檔案事件的虛擬碼,也是上文所述的檔案事件分派器,它其實就是遍歷fired就緒事件表,然後根據對應的事件型別來呼叫事件中註冊的不同處理器,讀事件呼叫rfileProc,而寫事件呼叫wfileProc

void processFileEvent(int numevents) {
    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 fired = 0;
            int invert = fe->mask & AE_BARRIER;
	        /* 讀事件 */
            if (!invert && fe->mask & mask & AE_READABLE) {
                /* 呼叫讀處理函式 */
                fe->rfileProc(eventLoop,fd,fe->clientData,mask);
                fired++;
            }
            /* 寫事件. */
            if (fe->mask & mask & AE_WRITABLE) {
                if (!fired || fe->wfileProc != fe->rfileProc) {
                    fe->wfileProc(eventLoop,fd,fe->clientData,mask);
                    fired++;
                }
            }
            if (invert && fe->mask & mask & AE_READABLE) {
                if (!fired || fe->wfileProc != fe->rfileProc) {
                    fe->rfileProc(eventLoop,fd,fe->clientData,mask);
                    fired++;
                }
            }
            processed++;
        }
    }
}
複製程式碼

processTimeEvents是處理時間事件的函式,它會遍歷aeEventLoop的事件事件列表,如果時間事件到達就執行其timeProc函式,並根據函式的返回值是否等於AE_NOMORE來決定該時間事件是否是週期性事件,並修改器到達時間。

static int processTimeEvents(aeEventLoop *eventLoop) {
    int processed = 0;
    aeTimeEvent *te;
    long long maxId;
    time_t now = time(NULL);
    ....
    eventLoop->lastTime = now;

    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 (te->prev)
                te->prev->next = te->next;
            else
                eventLoop->timeEventHead = te->next;
            if (te->next)
                te->next->prev = te->prev;
            if (te->finalizerProc)
                te->finalizerProc(eventLoop, te->clientData);
            zfree(te);
            te = next;
            continue;
        }

        /* id 大於最大maxId,是該迴圈週期生成的時間事件,不處理 */
        if (te->id > maxId) {
            te = te->next;
            continue;
        }
        aeGetTime(&now_sec, &now_ms);
        /* 事件已經到達,呼叫其timeProc函式*/
        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++;
            /* 如果返回值不等於 AE_NOMORE,表示是一個週期性事件,修改其when_sec和when_ms屬性*/
            if (retval != AE_NOMORE) {
                aeAddMillisecondsToNow(retval,&te->when_sec,&te->when_ms);
            } else {
                /* 一次性事件,標記為需刪除,下次遍歷時會刪除*/
                te->id = AE_DELETED_EVENT_ID;
            }
        }
        te = te->next;
    }
    return processed;
}
複製程式碼

刪除事件

當不在需要某個事件時,需要把事件刪除掉。例如: 如果fd同時監聽讀事件、寫事件。當不在需要監聽寫事件時,可以把該fd的寫事件刪除。

aeDeleteEventLoop函式的執行過程總結為以下幾個步驟 1、根據fd在未就緒表中查詢到事件 2、取消該fd對應的相應事件識別符號 3、呼叫aeApiFree函式,核心會將epoll監聽紅黑樹上的相應事件監聽取消。

後記

接下來,我們會繼續學習 Redis 的主從複製相關的原理,歡迎大家持續關注。

程式設計師歷小冰的部落格

Redis 事件機制詳解

推薦閱讀

相關文章