分布式存储引擎大厂实战——带你读源码搞懂为什么Redis用单线程还这么快
- 前言
- Redis为什么用单线程
- 单线程Redis为什么这么快
- Redis的非阻塞模式
- 基于EPOLL机制实现
- 文件事件
- 事件循环
- aeApiAddEvent函数
- 事件注册函数aeCreateFileEvent
- aeMain函数
- 向客户端返回数据
前言
通常来说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;
}
}
}