Redis的事件機制

lijihong0723發表於2020-07-27

Redis程式的執行過程是一個處理事件的過程,也稱Redis是一個事件驅動的服務。Redis中的事件分兩類:檔案事件(File Event)、時間事件(Time Event)。檔案事件處理檔案的讀寫操作,特別是與客戶端通訊的Socket檔案描述符的讀寫操作;時間事件主要用於處理一些定時處理的任務。

本文首先介紹Redis的執行過程,闡明Redis程式是一個事件驅動的程式;接著介紹事件機制實現中涉及的資料結構以及事件的註冊;最後介紹了處理客戶端中涉及到的套接字檔案讀寫事件。

一、Redis的執行過程

Redis的執行過程是一個事件處理的過程,可以通過下圖反映出來:

​ 圖1 Redis的事件處理過程

從上圖可以看出:Redis伺服器的執行過程就是迴圈等待並處理事件的過程。通過時間事件將執行事件分成一個個的時間分片,如圖1的右半部分所示。如果在指定的時間分片中,有檔案事件發生,如:讀檔案描述符可讀、寫檔案描述符可寫,則呼叫相應的處理函式進行檔案的讀寫處理。檔案事件處理完成之後,處理期望發生時間在當前時間之前或正好是當前時刻的時間事件。然後再進入下一次迴圈迭代處理。

如果在指定的事件間隔中,沒有檔案事件發生,則不需要處理,直接進行時間事件的處理,如下圖所示。

​ 圖2 Redis的事件處理過程(無檔案事件發生)

二、事件資料結構

2.1 檔案事件資料結構

Redis用如下結構體來記錄一個檔案事件:

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

通過mask來描述發生了什麼事件:

  • AE_READABLE:檔案描述符可讀;
  • AE_WRITABLE:檔案描述符可寫;
  • AE_BARRIER:檔案描述符阻塞

rfileProc和wfileProc分別為讀事件和寫事件發生時的回撥函式,其函式簽名如下:

typedef void aeFileProc(struct aeEventLoop *eventLoop, int fd, void *clientData, int mask);
2.2 事件事件資料結構

Redis用如下結構體來記錄一個時間事件:

/* Time event structure */
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;

when_sec和when_ms指定時間事件發生的時間,timeProc為時間事件發生時的處理函式,簽名如下:

typedef int aeTimeProc(struct aeEventLoop *eventLoop, long long id, void *clientData);

prev和next表明時間事件構成了一個雙向連結串列。

3.3 事件迴圈

Redis用如下結構體來記錄系統中註冊的事件及其狀態:

/* State of an event based program */
typedef struct aeEventLoop {
    int maxfd;   /* highest file descriptor currently registered */
    int setsize; /* max number of file descriptors tracked */
    long long timeEventNextId;
    time_t lastTime;     /* Used to detect system clock skew */
    aeFileEvent *events; /* Registered events */
    aeFiredEvent *fired; /* Fired events */
    aeTimeEvent *timeEventHead;
    int stop;
    void *apidata; /* This is used for polling API specific data */
    aeBeforeSleepProc *beforesleep;
    aeBeforeSleepProc *aftersleep;
} aeEventLoop;

這一結構體中,最主要的就是檔案事件指標events和時間事件頭指標timeEventHead。檔案事件指標event指向一個固定大小(可配置)陣列,通過檔案描述符作為下標,可以獲取檔案對應的事件物件。

三、事件的註冊過程

事件驅動的程式實際上就是在事件發生時,呼叫相應的處理函式(即:回撥函式)進行邏輯處理。因此關於事件,程式需要知道:①事件的發生;② 回撥函式。事件的註冊過程就是告訴程式這兩。下面我們分別從檔案事件、時間事件的註冊過程進行闡述。

3.1 檔案事件的註冊過程

對於檔案事件:

  • 事件的發生:應用程式需要知道哪些檔案描述符發生了哪些事件。感知檔案描述符上有事件發生是由作業系統的職責,應用程式需要告訴作業系統,它關心哪些檔案描述符的哪些事件,這樣通過相應的系統API就會返回發生了事件的檔案描述符。
  • 回撥函式:應用程式知道了檔案描述符發生了事件之後,需要呼叫相應回撥函式進行處理,因而需要在事件發生之前將相應的回撥函式準備好。

這就是檔案事件的註冊過程,函式的實現如下:

int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
        aeFileProc *proc, void *clientData)
{
    if (fd >= eventLoop->setsize) {
        errno = ERANGE;
        return AE_ERR;
    }
    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;
}

這段程式碼邏輯非常清晰:首先根據檔案描述符獲得檔案事件物件,接著在作業系統中新增自己關心的檔案描述符(addApiAddEvent),最後將回撥函式記錄到檔案事件物件中。因此,一個執行緒就可以同時監聽多個檔案事件,這就是IO多路複用。作業系統提供多種IO多路複用模型,如:Select模型、Poll模型、EPOLL模型等。Redis支援所有這些模型,使用者可以根據需要進行選擇。不同的模型,向作業系統新增檔案描述符方式也不同,Redis將這部分邏輯封裝在aeApiAddEvent中,下面程式碼是EPOLL模型的實現:

static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
    aeApiState *state = eventLoop->apidata;
    struct epoll_event ee = {0}; /* avoid valgrind warning */
    /* If the fd was already monitored for some event, we need a MOD
     * operation. Otherwise we need an ADD operation. */
    int op = eventLoop->events[fd].mask == AE_NONE ?
            EPOLL_CTL_ADD : EPOLL_CTL_MOD;

    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;
    if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;
    return 0;
}

這段程式碼就是對作業系統呼叫epoll_ctl()的封裝,EPOLLIN對應的是讀(輸入)事件,EPOLLOUT對應的是寫(輸出)事件。

3.2 時間事件的註冊過程

對於時間事件:

  • 事件的發生:當前時刻正好是事件期望發生的時刻,或者是晚於事件期望發生的時刻,所以需要讓程式知道事件期望發生的時刻;
  • 回撥函式:此時呼叫回撥函式進行處理,所以需要讓程式知道事件的回撥函式。

對應的事件事件註冊函式如下:

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->prev = NULL;
    te->next = eventLoop->timeEventHead;
    if (te->next)
        te->next->prev = te;
    eventLoop->timeEventHead = te;
    return id;
}

這段程式碼邏輯也是非常簡單:首先建立時間事件物件,接著設定事件,設定回撥函式,最後將事件事件物件插入到時間事件連結串列中。設定時間事件期望發生的時間比較簡單:

static void aeAddMillisecondsToNow(long long milliseconds, long *sec, long *ms) {
    long cur_sec, cur_ms, when_sec, when_ms;

    aeGetTime(&cur_sec, &cur_ms);
    when_sec = cur_sec + milliseconds/1000;
    when_ms = cur_ms + milliseconds%1000;
    if (when_ms >= 1000) {
        when_sec ++;
        when_ms -= 1000;
    }
    *sec = when_sec;
    *ms = when_ms;
}

static void aeGetTime(long *seconds, long *milliseconds)
{
    struct timeval tv;

    gettimeofday(&tv, NULL);
    *seconds = tv.tv_sec;
    *milliseconds = tv.tv_usec/1000;
}

當前時間加上期望的時間間隔,作為事件期望發生的時刻。

四、套接字檔案事件

Redis為客戶端提供儲存資料和獲取資料的快取服務,監聽並處理來自請求,將結果返回給客戶端,這一過程將會發生以下檔案事件:

與上圖相對應,對於一個請求,Redis會註冊三個檔案事件:

4.1 TCP連線建立事件

伺服器初始化時,在伺服器套接字上註冊TCP連線建立的事件。

void initServer(void) {
    /* Create an event handler for accepting new connections in TCP and Unix
     * domain sockets. */
    for (j = 0; j < server.ipfd_count; j++) {
        if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
            acceptTcpHandler,NULL) == AE_ERR)
            {
                serverPanic(
                    "Unrecoverable error creating server.ipfd file event.");
            }
    }
}

回撥函式為acceptTcpHandler,該函式最重要的職責是建立客戶端結構。

4.2 客戶端套接字讀事件

建立客戶端:在客戶端套接字上註冊客戶端套接字可讀事件。

if (aeCreateFileEvent(server.el,fd,AE_READABLE,
                      readQueryFromClient, c) == AE_ERR)
{
    close(fd);
    zfree(c);
    return NULL;
}

回撥函式為readQueryFromClient,顧名思義,此函式將從客戶端套接字中讀取資料。

4.3 向客戶端返回資料

Redis完成請求後,Redis並非處理完一個請求後就註冊一個寫檔案事件,然後事件回撥函式中往客戶端寫回結果。根據圖1,檢測到檔案事件發生後,Redis對這些檔案事件進行處理,即:呼叫rReadProc或writeProc回撥函式。處理完成後,對於需要向客戶端寫回的資料,先快取到記憶體中:

typedef struct client {
    // ...其他欄位
    
    list *reply;            /* List of reply objects to send to the client. */
    
    /* Response buffer */
    int bufpos;
    char buf[PROTO_REPLY_CHUNK_BYTES];
};

傳送給客戶端的資料會存放到兩個地方:

  • reply指標存放待傳送的物件;

  • buf中存放待返回的資料,bufpos指示資料中的最後一個位元組所在位置。

這裡遵循一個原則:只要能存放在buf中,就儘量存入buf位元組陣列中,如果buf存不下了,才存放在reply物件陣列中。

寫回發生在進入下一次等待檔案事件之前,見圖1中【等待前處理】,會呼叫以下函式來處理客戶端資料寫回邏輯:

int writeToClient(int fd, client *c, int handler_installed) {
    while(clientHasPendingReplies(c)) {
        if (c->bufpos > 0) {
            nwritten = write(fd,c->buf+c->sentlen,c->bufpos-c->sentlen);
            if (nwritten <= 0) break;
            c->sentlen += nwritten;
            totwritten += nwritten;
            if ((int)c->sentlen == c->bufpos) {
                c->bufpos = 0;
                c->sentlen = 0;
            }
        } else {
            o = listNodeValue(listFirst(c->reply));
            objlen = o->used;
            if (objlen == 0) {
                c->reply_bytes -= o->size;
                listDelNode(c->reply,listFirst(c->reply));
                continue;
            }

            nwritten = write(fd, o->buf + c->sentlen, objlen - c->sentlen);
            if (nwritten <= 0) break;
            c->sentlen += nwritten;
            totwritten += nwritten;
        }
    }
}

上述函式只擷取了資料傳送部分,首先傳送buf中的資料,然後傳送reply中的資料。

有讀者可能會疑惑:write()系統呼叫是阻塞式的介面,上述做法會不會在write()呼叫的地方有等待,從而導致效能低下?這裡就要介紹Redis是怎麼處理這個問題的。

首先,我們發現建立客戶端的程式碼:

client *createClient(int fd) {
    client *c = zmalloc(sizeof(client));
    if (fd != -1) {
        anetNonBlock(NULL,fd);
    }
}

可以看到設定fd是非阻塞(NonBlock),這就保證了在套接字fd上的read()和write()系統呼叫不是阻塞的。

其次,和檔案事件的處理操作一樣,往客戶端寫資料的操作也是批量的,函式如下:

int handleClientsWithPendingWrites(void) {
    listRewind(server.clients_pending_write,&li);
    while((ln = listNext(&li))) {
        /* Try to write buffers to the client socket. */
        if (writeToClient(c->fd,c,0) == C_ERR) continue;
        /* If after the synchronous writes above we still have data to
         * output to the client, we need to install the writable handler. */
        if (clientHasPendingReplies(c)) {
            int ae_flags = AE_WRITABLE;
            if (aeCreateFileEvent(server.el, c->fd, ae_flags,
                sendReplyToClient, c) == AE_ERR)
            {
                    freeClientAsync(c);
            }
        }
    }
}

可以看到,首先對每個客戶端呼叫剛才介紹的writeToClient()函式進行寫資料,如果還有資料沒寫完,那麼註冊寫事件,當套接字檔案描述符寫就緒時,呼叫sendReplyToClient()進行剩餘資料的寫操作:

void sendReplyToClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    writeToClient(fd,privdata,1);
}

仔細想一下就明白了:處理完得到結果後,這時套接字的寫緩衝區一般是空的,因此write()函式呼叫成功,所以就不需要註冊寫檔案事件了。如果寫緩衝區滿了,還有資料沒寫完,此時再註冊寫檔案事件。並且在資料寫完後,將寫事件刪除:

int writeToClient(int fd, client *c, int handler_installed) {
    if (!clientHasPendingReplies(c)) {
        if (handler_installed) aeDeleteFileEvent(server.el,c->fd,AE_WRITABLE);
    }
}

注意到,在sendReplyToClient()函式實現中,第三個引數正好是1。

相關文章