小白談一談:命令在redis中是怎麼被執行的呢?

so_easy發表於2021-03-08

當我準備談談redis的命令是如何執行時,發現其實在網上已經有很多優秀的文章講過這類問題了。
推薦看過的一個github上的原始碼分析:github.com/menwengit/redis_source_...

當然,即使很多人已經講解了這類問題,可是依然不影響我去寫一些自己的所感。因為,讀書百遍不如自己寫一遍。這也是防止眼高手低。

命令是如何被執行的呢?

以在命令列執行命令redis-cli為例。這個我們太熟悉了,只要執行redis-cli就連線redis-server成功了,接下來就可以執行一些getset命令。再然後就得到了我們要的結果。但是你從來有想過,這些命令的背後到底幹了些什麼呢?接下里是我翻看原始碼(版本3.0.7)之後,得到的一些答案。

第一步:啟動伺服器

檔案入口:redis.cmain函式
重點看如下的2個方法:

initServer();
aeMain(server.el);

initServer中初始化的資料很多,其中和本節討論相關的有:

  • 初始化server的資料儲存結構
  • 初始化服務端socket
  • 初始化EventLoop

初始化server的資料儲存結構是貫穿redis的生命週期的,因為任何操作都會涉及到從redis的資料結構中獲取或者設定資料。
初始化服務端socket
1)在server的配置檔案中,是支援多個socket的配置。也支援IPv4和IPv6。這麼做的好處就是redis-server既可以支援本地連線的unix,又支援本地的網路連線127.0.0.1,還能支援遠端的網路連線remote_address:port等。
2)預設的socket初始化後設定的是非阻塞模式。
初始化EventLoop

對於小白的我來說,剛學redis聽得最多的就是redis中的事件,之所以redis很快就是因為事件和IO多路複用及單程式等等。聽的再多,如果不自己去探究原始碼終究是紙上得來終覺淺,絕知此事要躬行。所以即便是看完了本篇文章依然要去看原始碼,每個人的理解都會不一樣。我本地是mac,redis底層自動會使用kqueue。以下的分析都是基於kqueue的IO多路複用。

// 初始化 EventLoop物件
server.el = aeCreateEventLoop(server.maxclients+REDIS_EVENTLOOP_FDSET_INCR);
// 註冊時間事件到el物件,回撥函式是`serverCron`
aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL);
// 註冊網路連線的檔案事件:就是服務端socket監聽client的連線,並且關聯fd和回撥函式`acceptTcpHandler`。
for (j = 0; j < server.ipfd_count; j++) {
    aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,acceptTcpHandler,NULL)
}
// 註冊本地檔案事件:監聽本地client的連線並且關聯回撥函式`acceptUnixHandler`。
aeCreateFileEvent(server.el,server.sofd,AE_READABLE,acceptUnixHandler,NULL);

到這裡,將服務端的socket和時間事件及檔案事件都簡單的拉出來溜溜了。怎麼理解kqueue的事件觸發呢?聯想PHP的socket_select函式去理解。推薦閱讀:www.jianshu.com/p/397449cadc9a

  • socket_select函式是很早之前最開始使用的IO多路複用模式,它的工作機制就是在核心態接收到的事件通知給使用者態,並且返回目前已準備好的數量。然後切換到使用者態之後在迴圈已經準備好的數量,接收資料。
    socket_select($read, $write, $except, $sec, $usec);
  • $read是可讀事件:將所有可讀的事件放到該陣列,然後從該陣列迴圈遍歷並且讀取陣列內檔案的資料。
  • $write是可寫事件:將已準備好的可寫檔案放在這陣列,然後迴圈遍歷呼叫寫函式寫入資料到該檔案。

select的缺點就是迴圈遍歷,並且最大隻能支援1024個客戶端同時連線。後來升級到了poll,但是poll只是去除了1024的限制。後來直到進化到了epoll,才得以解決select的所有弊端,kqueue和epoll類似。

都知道一個問題:Redis是序列的,那為什麼速度還那麼快呢?一般我們的回答都是IO多路複用。那麼現在有個問題就是:Redis是如何保證序列的呢?

當你讀完Redis序列地處理client的請求時,可能並沒有多大感觸。但是如果讓你寫一個基於多程式下的server和client通訊時,你就會發現如何保證server和client接收到的資料準確可信是很難的問題。這裡就不得不讚嘆為什麼Redis要用單程式處理了,因為實在是省去了很多併發問題的思考。
多程式下的serverclient通訊問題,看beanstalk的issue:github.com/pheanstalk/pheanstalk/i...

解答這個問題必須再看原始碼的時候,注意aeEventLoop的資料結構:

/* State of an event based program */
// 事件狀態的描述
typedef struct aeEventLoop {
    // 最大的事件描述符
    int maxfd;   /* highest file descriptor currently registered */
    // 目前追蹤的事件數量
    int setsize; /* max number of file descriptors tracked */
    // 時間事件的下一個待觸發的id
    long long timeEventNextId;
    // 用於檢測系統偏差
    time_t lastTime;     /* Used to detect system clock skew */
    // 已註冊的檔案事件:如服務端socket。是一個array。
    aeFileEvent *events; /* Registered events */
    // 觸發的事件,是一個array。
    aeFiredEvent *fired; /* Fired events */
    // 時間事件是一個連結串列。指向了該連結串列的頭指標。
    aeTimeEvent *timeEventHead;
    // 置為1則結束迴圈
    int stop;
    // 萬能指標 void*:
    // 我的mac是使用的是kqueue,所以儲存的資料結構如:
    /*typedef struct aeApiState {
            int kqfd; //指的是kqueue
            struct kevent *events; //提前申請好的setsize的kqueue的大小空間
        } aeApiState;*/
    void *apidata; /* This is used for polling API specific data */
    // 回撥函式
    aeBeforeSleepProc *beforesleep;
} aeEventLoop;

先注意到兩個屬性:aeFileEvent *eventsaeFiredEvent *fired,然後對比下ae.c檔案下的aeCreateFileEvent函式去了解。

// accept:server.el  server.ipfd[j]   AE_READABLE   acceptTcpHandler  null
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
        aeFileProc *proc, void *clientData)
{
    if (fd >= eventLoop->setsize) {
        errno = ERANGE;
        return AE_ERR;
    }
    // 已註冊的事件,這裡是fe是引用的地址。
    aeFileEvent *fe = &eventLoop->events[fd];
    // 註冊kevent事件:readable | writeable
    if (aeApiAddEvent(eventLoop, fd, mask) == -1)
        return AE_ERR;
    //如 accept是READBALE事件
    fe->mask |= mask;
    // rfileproc和wfileproc分別記錄回撥函式,和fd關聯起來了
    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;
}

結合之前的accept操作:server.el server.ipfd[j] AE_READABLE acceptTcpHandler null,那麼得到的如下:

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

aeFileEvent *events[fd1] = {
    int mask = AE_READABLE;
    aeFileProc *rfileProc = 'acceptTcpHandler';
    aeFileProc *wfileProc;
    void *clientData = null;
}; /* Registered events */
aeFiredEvent *fired; /* Fired events */

同時aeApiAddEventkevent註冊了檔案事件,程式碼如下:

static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
    aeApiState *state = eventLoop->apidata;
    struct kevent ke;
    if (mask & AE_READABLE) {
        EV_SET(&ke, fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
        /* kevent引數說明
        kq            - kqueue() 返回的唯一描述符, 標記著一個核心佇列
        changes       – 需要對kqueue進行修改的事件集合, 此引數就是kevent()對目前kqueue中的事件的操作,比如刪除kqueue中已經存在的事件,或者向kqueue中新增新的事件,也就是說,kevent()透過此引數對kqueue的修改
        nchanges      – 需要修改的事件的個數
        events        – kevent()會把所有事件儲存在events中
        nevents       – kevent()需要知道儲存空間有多大, == 0 : kevent()會立即返回
        timeout       – 超時控制, = NULL:kevent()會一直等到有關注的事件發生; != NULL:kevent()會等待指定的時間
        */
        // 結合select理解:就是新增到了read陣列,並且等待資料的讀取。
        if (kevent(state->kqfd, &ke, 1, NULL, 0, NULL) == -1) return -1;
    }
    if (mask & AE_WRITABLE) {
        EV_SET(&ke, fd, EVFILT_WRITE, EV_ADD, 0, 0, NULL);
        // 結合select理解:就是新增到了write陣列,並且等待資料的寫入。
        if (kevent(state->kqfd, &ke, 1, NULL, 0, NULL) == -1) return -1;
    }
    return 0;
}

當事件被觸發時,那麼就會觸發執行回撥函式acceptTcpHandler,簡練的程式碼如下:

// 其實就是執行了`accept`函式,並且返回了client的檔案描述符cfd。
cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
// 設定成非阻塞模式,且將cfd註冊到kevent:
// aeCreateFileEvent(server.el,fd,AE_READABLE, readQueryFromClient, c)
acceptCommonHandler(cfd,0);

那麼當client請求server傳送命令時,就會觸發函式readQueryFromClient,Redis經過對命令一系列的處理之後,經過函式processCommand執行命令,最終執行的程式碼如:c->cmd->proc(c);,這裡的函式就是執行命令的函式,如set|get等。執行完命令之後透過addReply函式返回給客戶端結果。但是並不會立即返回給client,而是將事件註冊到了kevent:aeCreateFileEvent(server.el, c->fd, AE_WRITABLE, sendReplyToClient, c),待。
這裡可想可知EventLoop的屬性event又多加了一個事件。
此時就能得到如下的EventLoop的部分屬性資料:

aeFileEvent 
events[fd1] = {
    int mask = AE_READABLE;
    aeFileProc *rfileProc = 'acceptTcpHandler';
    aeFileProc *wfileProc;
    void *clientData = null;
}; /* Registered events */
event[fd2] = {
    int mask = AE_READABLE;
    aeFileProc *rfileProc = 'readQueryFromClient';
    aeFileProc *wfileProc;
    void *clientData = c;
};
event[fd2] = {
    int mask = AE_READABLE;
    aeFileProc *rfileProc = 'sendReplyToClient';
    aeFileProc *wfileProc;
    void *clientData = c;
};
aeFiredEvent *fired; /* Fired events */

以上都只是介紹了事件被觸發後的執行過程,但是這些事件是何時被觸發的呢?這些事件的觸發在main函式的aeMain函式中,跟蹤程式碼如:

...
// 和時間事件聯合起作用,可能等待至下一個時間事件的觸發,也可能等待直到某個事件發生。
// 一旦某個事件被觸發,就會將該事件記錄到`EventLoop`的`fired`屬性。然後非同步呼叫了回撥函式。
numevents = aeApiPoll(eventLoop, tvp);
// 這就是序列的根本原因所在:將所有觸發的事件先儲存到陣列`fired`,然後在遍歷這些事件依次處理。
// 由於此前已經將fd和回撥函式相關聯,則在遍歷的時候就可以執行回撥函式。
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;

        /* note the fe->mask & mask & ... code: maybe an already processed
             * event removed an element that fired and we still didn't
             * processed, so we check if the event is still valid. */
            if (fe->mask & mask & AE_READABLE) {
                rfired = 1;
                /* 如socket等待accept,那麼就是一個READABLE事件,然後執行的回撥函式
                 * rfileProc=acceptTcpHandler,那麼就會執行這個函式 */
                fe->rfileProc(eventLoop,fd,fe->clientData,mask);
            }
            if (fe->mask & mask & AE_WRITABLE) {
                if (!rfired || fe->wfileProc != fe->rfileProc)
                    // 如get命令,就是一個writeable事件
                    fe->wfileProc(eventLoop,fd,fe->clientData,mask);
            }
            processed++;
        }

看下aeApiPoll函式:

static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
    aeApiState *state = eventLoop->apidata;
    int retval, numevents = 0;

    if (tvp != NULL) {
        struct timespec timeout;
        timeout.tv_sec = tvp->tv_sec;
        timeout.tv_nsec = tvp->tv_usec * 1000;
        // 時間事件存在時,則等待下個時間事件的觸發的時間。如果此區間觸發了一個檔案事件,
        // 則優先處理檔案事件。時間事件可能就會延遲處理。
        retval = kevent(state->kqfd, NULL, 0, state->events, eventLoop->setsize,
                        &timeout);
    } else {
        // 阻塞等待直到事件的發生
        retval = kevent(state->kqfd, NULL, 0, state->events, eventLoop->setsize,
                        NULL);
    }

    if (retval > 0) {
        int j;
        numevents = retval;
        // 檔案事件被觸發了,優先執行檔案事件
        for(j = 0; j < numevents; j++) {
            int mask = 0;
            struct kevent *e = state->events+j;
            // 將kevent監聽到的事件型別和eventLoop的mask相關聯。
            if (e->filter == EVFILT_READ) mask |= AE_READABLE;
            if (e->filter == EVFILT_WRITE) mask |= AE_WRITABLE;
            // 這就是序列,是一個陣列
            eventLoop->fired[j].fd = e->ident;
            eventLoop->fired[j].mask = mask;
        }
    }
    return numevents;
}

到這裡,在整體的過一遍就是:

  • server註冊服務端socket的fd到kevent,然後kevent就會非同步的去監聽這個fd,並且將這個fd關聯回撥函式acceptTcpHandler
  • kevent監聽到了client的連線請求,然後呼叫了函式acceptTcpHandler,然後再把cfd註冊到了kevent且關聯了回撥函式readQueryFromClient
  • kevent監聽到了client的傳送資料,然後呼叫了函式readQueryFromClient,再處理請求將cfd註冊到了kevent且關聯了回撥函式sendReplyToClient

更具體的描述是:

  • initServer中先初始化server.el,這是全域性公用的。
    server.el = aeCreateEventLoop(server.maxclients+REDIS_EVENTLOOP_FDSET_INCR);
  • 透過aeCreateTimeEvent註冊時間事件。這裡只是註冊,該函式會立即返回。
    aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL)
  • 然後將初始化好的服務端Socket也透過aeCreateFileEvent註冊檔案事件。
    aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL);
    aeCreateFileEvent(server.el,server.sofd,AE_READABLE, acceptUnixHandler,NULL);
  • 然後在aeMain函式中,處理時間事件和檔案事件。
    void aeMain(aeEventLoop *eventLoop) {
      eventLoop->stop = 0;
      // 事件處理是一個迴圈
      while (!eventLoop->stop) {
          // 在此觸發beforesleep函式。
          if (eventLoop->beforesleep != NULL)
              eventLoop->beforesleep(eventLoop);
          aeProcessEvents(eventLoop, AE_ALL_EVENTS);
      }
    }
  • aeProcessEvents函式會先執行時間事件,在連結串列timeEventHead找到最近的時間事件。
    ```php
    /* … 省略上述程式碼
    找到最近的時間事件並且計算下次執行的時間間隔。但是可以看到時間事件的優先順序是低於檔案事件的。
    即使時間事件已經到了該執行的時間,可是也會先呼叫kevent函式等待檔案事件的發生。不過這裡分為兩種情況:
    1)無時間事件:kevent會一直阻塞等待直到檔案事件發生。
    2)有時間事件:kevent會等待最近時間事件觸發的時間間隙去處理檔案事件。如果沒有檔案事件發生,則處理時間事件。
    aeApiPoll函式內,如果有檔案事件觸發,則將fd放到aeEventLoop->aeFiredEventfired陣列,並且關聯AE_READABLE|AE_WRITABLE。

*/
numevents = aeApiPoll(eventLoop, tvp);

- 迴圈`aeEventLoop->aeFiredEvent``fd`,並且關聯起`aeEventLoop->aeFileEvent``event`,取出`fd`的對應關係`event[fd]`的操作函式`proc`即之前註冊事件時的回撥函式,然後開始執行回撥函式`proc`。如此所有kevent觸發的事件都會被序列放在`fired`陣列,然後在依次執行這些`fd`的回撥函式。

> 時間事件和檔案事件:
時間事件的觸發分為定時事件和週期性事件,定時事件觸發一次就結束了,而週期性事件會每隔固定的時間就被觸發一次,如`serverCron`。
在時間事件觸發的時間窗內,就會觸發`kevent`阻塞相應的等待時間。在這個時間視窗內,可能就會觸發了某個檔案事件。如果沒有觸發檔案事件,則會執行時間事件。
[kqueue介紹](https://blog.csdn.net/bytxl/article/details/17526351 "kqueue介紹")

## 第二步:client
分析原始檔`redis-cli.c``main`函式。
```c
/* Start interactive mode when no command is provided */
    if (argc == 0 && !config.eval) {
        /* Ignore SIGPIPE in interactive mode to force a reconnect */
        signal(SIGPIPE, SIG_IGN);

        /* Note that in repl mode we don't abort on connection error.
         * A new attempt will be performed for every command send. */
         // client是阻塞模式
        cliConnect(0);
        repl();
    }

repl()的程式碼繼續跟蹤到redisGetReply,再到redisBufferWrite方法,是寫資料到server。

int redisGetReply(redisContext *c, void **reply) {
    int wdone = 0;
    void *aux = NULL;

    /* Try to read pending replies */
    if (redisGetReplyFromReader(c,&aux) == REDIS_ERR)
        return REDIS_ERR;

    /* For the blocking context, flush output buffer and read reply */
    if (aux == NULL && c->flags & REDIS_BLOCK) {
        /* Write until done */
        do {
            if (redisBufferWrite(c,&wdone) == REDIS_ERR)
                return REDIS_ERR;
        } while (!wdone);

        /* Read until there is a reply */
        do {
            if (redisBufferRead(c) == REDIS_ERR)
                return REDIS_ERR;
            if (redisGetReplyFromReader(c,&aux) == REDIS_ERR)
                return REDIS_ERR;
        } while (aux == NULL);
    }

    /* Set reply object */
    if (reply != NULL) *reply = aux;
    return REDIS_OK;
}
int redisBufferWrite(redisContext *c, int *done) {
    int nwritten;

    /* Return early when the context has seen an error. */
    if (c->err)
        return REDIS_ERR;

    if (sdslen(c->obuf) > 0) {
        nwritten = write(c->fd,c->obuf,sdslen(c->obuf));
        if (nwritten == -1) {
            // errno==35 || errno == 4 這不是個錯誤,繼續執行。注意到client是阻塞模式。
            if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) {
                /* Try again later */
            } else {
                __redisSetError(c,REDIS_ERR_IO,NULL);
                return REDIS_ERR;
            }
        } else if (nwritten > 0) {
            if (nwritten == (signed)sdslen(c->obuf)) {
                sdsfree(c->obuf);
                c->obuf = sdsempty();
            } else {
                sdsrange(c->obuf,nwritten,-1);
            }
        }
    }
    if (done != NULL) *done = (sdslen(c->obuf) == 0);
    return REDIS_OK;
}

讀取server的資料程式碼如:

/* Use this function to handle a read event on the descriptor. It will try
 * and read some bytes from the socket and feed them to the reply parser.
 *
 * After this function is called, you may use redisContextReadReply to
 * see if there is a reply available. */
int redisBufferRead(redisContext *c) {
    char buf[1024*16];
    int nread;

    /* Return early when the context has seen an error. */
    if (c->err)
        return REDIS_ERR;

    nread = read(c->fd,buf,sizeof(buf));
    if (nread == -1) {
        if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) {
            /* Try again later */
        } else {
            __redisSetError(c,REDIS_ERR_IO,NULL);
            return REDIS_ERR;
        }
    } else if (nread == 0) {
        __redisSetError(c,REDIS_ERR_EOF,"Server closed the connection");
        return REDIS_ERR;
    } else {
        if (redisReaderFeed(c->reader,buf,nread) != REDIS_OK) {
            __redisSetError(c,c->reader->err,c->reader->errstr);
            return REDIS_ERR;
        }
    }
    return REDIS_OK;
}

思考

分析完Redis的kevent之後,聯想了下Swoole協程,如程式碼:

Co\run(function() {
    go(function() {
        Co::sleep(1);
        // do something
    });
});
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章