前言
通常來說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;
}
}
}