爲什麼Redis用單線程還這麼快?

前言

通常來說Redis是單線程,主要是指redis的網絡IO和讀寫鍵值對是由一個線程完成的。這也是redis對外提供鍵值存儲服務的主要流程。但是其它功能,比如持久化,集羣數據同步等,其實是由額外的線程執行的。

所以,redis並不是完全意義上的單線程,只是一般把它成爲單線程高性能的典型代表。那麼,很多小夥伴會提問,爲什麼用單線程?爲什麼單線程能這麼快。

Redis爲什麼用單線程

首先我們要得了解下多線程的開銷問題。平時寫程序很多人都覺得使用多線程,可以增加系統吞吐率,或者增加系統擴展性。的確對於一個多線程的系統來說,在合理的資源分配情況下,確實可以增加系統中處理請求操作的資源實體,進而提升系統能夠同時處理的請求數,即吞吐率。但是,如果沒有良好的系統設計經驗,實際得到的結果,其實會剛開始增加線程數時,系統吞吐率會增加。但是,再進一步增加線程時,系統吞吐率就增加遲緩了,甚至會出現下降的情況。

爲什麼會出現這種情況呢?關鍵的性能瓶頸就是系統中多線程同時對臨界資源的訪問。比如當有多個線程要修改一個共享的數據,爲了保證資源的正確性,就需要類似互斥鎖這樣額外的機制才能保證,這個額外的機制是需要額外開銷的。比如,redis有個數據類型List,它提供出隊(LPOP)和入隊(LPUSH)操作。假設redis採用多線程設計。現在假設有兩個線程T1和T2線程T1對一個List執行LPUSH操作,線程T2對該List執行LPOP操作,並對隊列長度減1。爲了保證隊列長度的正確性,需要讓這兩個線程的LPUSH和LPOP串行執行,否則,我們可能就會得到錯誤的長度結果。這就是多線程編程經常會遇到的共享資源併發訪問控制問題。

而且多線程開發中,併發控制一直是多線程開發的難點問題。如果沒有設計經驗,只是簡單地採用一個粗粒度的互斥鎖,就會出現不理想的結果那就是即使增加了線程,大部分線程也在等待獲取訪問臨界資源的互斥鎖,造成並行變串行,系統吞吐率並沒有隨着線程的增加而增加。

單線程Redis爲什麼這麼快

通常單線程的處理能力要比多線程差很多,但是Redis卻能用單線程模型達到每秒種十萬級別的處理能力。爲什麼呢?

一方面,Redis的大部分操作都在內存上完成,再加上它採用了高效的數據結構(比如哈希表、跳錶)。

另一方面,Redis採用了多路複用機制,能在網絡IO操作中能併發處理大量的客戶端請求,從而實現高吞吐率。那麼Redis爲什麼要採用多路複用呢?

如上圖所示,Redis爲了處理一個get請求流程如下,需要監聽客戶端請求(bind/listen),然後和客戶端建立連接(accept)。從socket中讀取請求(recv),解析客戶端發送請求後,根據請求類型讀取鍵值數據(get),最後將結果返回給客戶端(send)。其中accept()和recv()默認是阻塞操作。當Redis監聽一個客戶端有連接請求,但是一直未能成功建立連接時就會阻塞在accept()函數,這樣容易導致其它客戶端無法和Redis建立連接。同樣,當Redist通過recv()從一個客戶端讀取數據時,如果數據一直沒有到達,Redis也會阻塞在recv()。所以,這都會造成Redis整個線程阻塞,無法處理其它客戶端請求,效率極低。因此,需要將socket設置爲非阻塞。

Redis的非阻塞模式

socket網絡模型的非阻塞模式設置。一般主要調用fcntl。示例代碼如下

    int flag         flags = fcntl(fd, F_GETFL, 0);    if(flags < 0)    {      ...    }    flags |= O_NONBLOCK;    if(fcntl(fd, F_SETFL, flags) < 0)    {       ...       return -1;   }

在Redis的anet.c文件中也是的非阻塞代碼也是類似邏輯。用anetSetBlock函數處理,函數定義如下:​​​​​​​

int anetSetBlock(char *err, int fd, int non_block) {    int flags;
    /* Set the socket blocking (if non_block is zero) or non-blocking.     * Note that fcntl(2) for F_GETFL and F_SETFL can't be     * interrupted by a signal. */    if ((flags = fcntl(fd, F_GETFL)) == -1) {        anetSetError(err, "fcntl(F_GETFL): %s", strerror(errno));        return ANET_ERR;    }
    /* Check if this flag has been set or unset, if so,      * then there is no need to call fcntl to set/unset it again. */    if (!!(flags & O_NONBLOCK) == !!non_block)        return ANET_OK;
    if (non_block)        flags |= O_NONBLOCK;    else        flags &= ~O_NONBLOCK;
    if (fcntl(fd, F_SETFL, flags) == -1) {        anetSetError(err, "fcntl(F_SETFL,O_NONBLOCK): %s", strerror(errno));        return ANET_ERR;    }    return ANET_OK;}

監聽套接字設置爲非阻塞模式,Redis調動accept()函數但一直未有連接請求到達時,Redis線程可以返回處理其它操作,而不用一直等待。類似的,也可以針對已連接套接字設置非阻塞模式,Redis調用recv()後,如果已連接套接字上一直沒有數據到達,Redis線程同樣可以返回處理其它操作。但是我們也需要有機制繼續監聽該已連接套接字,並在有數據到達時通知Redis。這樣才能保證Redis線程,即不會像基本IO模型中一直阻塞點等待,也不會導致Redis無法處理實際到達的連接請求。

基於EPOLL機制實現

Linux中的IO多路複用是指一個執行體可以同時處理多個IO流,就是經常聽到的select/EPOLL機制。該機制可以允許內核中同時允許多個監聽套接字和已連接套接字。內核會一直監聽這些套接字上的連接請求。一旦有請求到達就會交給Redis線程處理。

Redis網絡框架基於EPOLL機制,此時,Redis線程不會阻塞在某個特定的監聽或已連接套接字上,也就不會阻塞在某一個特定的客戶端請求處理上。所以,Redis可以同時處理多個客戶端的連接請求。如下圖

​​​​​​​

爲了在請求到達時能通知到Redis線程,EPOLL提供了事件的回調機制。即針對不同事件調用相應的處理函數。下面我們就來介紹下它是如何實現的

文件事件

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);

事件循環

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指向一個固定大小(可配置)數組,通過文件描述符作爲下標,可以獲取文件對應的事件對象。

aeApiAddEvent函數

這個函數主要用來關聯事件到EPOLL,所以會調用epoll的ctl方法定義如下:​​​​​​​

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.     *     * 如果 fd 沒有關聯任何事件,那麼這是一個 ADD 操作。     * 如果已經關聯了某個/某些事件,那麼這是一個 MOD 操作。   */    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;}

當Redis服務創建一個客戶端請求的時候會調用,會註冊一個讀事件。

當Redis需要給客戶端寫數據的時候會調用prepareClientToWrite。這個方法主要是註冊對應fd的寫事件。

如果註冊失敗,Redis就不會將數據寫入緩衝。

如果對應套件字可寫,那麼Redis的事件循環就會將緩衝區新數據寫入socket。

事件註冊函數aeCreateFileEvent

這個是文件事件的註冊過程,函數實現如下​​​​​​​

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多路複用了。

aeMain函數

Redis事件處理器的主循環​​​​​​​

void aeMain(aeEventLoop *eventLoop) {    eventLoop->stop = 0;    while (!eventLoop->stop) {       //開始處理事件        aeProcessEvents(eventLoop, AE_ALL_EVENTS|                                   AE_CALL_BEFORE_SLEEP|                                   AE_CALL_AFTER_SLEEP);    }}

這個方法最終會調用epoll_wait()獲取對應事件並執行。

這些事件會放進一個事件隊列,Redis單線程會對該事件隊列不斷進行處理。比如當有讀請求到達時,讀請求對應讀事件。Redis對這個事件註冊get回調函數。當內核監聽到有讀請求到達時,就會觸發讀事件,這個時候就會回調Redis相應的get函數。

向客戶端返回數據

Redis完成請求後,Redis並非處理完一個請求後就註冊一個寫文件事件,然後事件回調函數中往客戶端寫回結果。檢測到文件事件發生後,Redis對這些文件事件進行處理,即調用rReadProc或writeProc回調函數。處理完成後,對於需要向客戶端寫回的數據,先緩存到內存中。​​​​​​

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

發送給客戶端的數據會存放到兩個地方:

  • reply指針存放待發送的對象;

  • buf中存放待返回的數據,bufpos指示數據中的最後一個字節所在位置。

    注意:只要能存放在buf中,就儘量存入buf字節數組中,如果buf存不下了,才存放在reply對象數組中。

    寫回客戶端發生在進入下一次等待文件事件之前,會調用以下函數處理寫回邏輯

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;        }    }}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章