查看原文
其他

读源码搞懂为什么Redis用单线程还这么快

神技圈子 CSDN 2021-05-22

作者 | 神技圈子       责编 | 欧阳姝黎
出品 | CSDN博客

前言


通常来说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; } }}

零信任风口来了,新一代企业安全架构革新势在必行

Android 12 重磅亮相!阔别 2 年的 Google I/O 开发者大会回来了

为什么程序员都不写文档?

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存