单线程Redis为什么这么快

发布时间:2021-10-13 10:18:26 作者:iii
来源:亿速云 阅读:86

这篇文章主要讲解了“单线程Redis为什么这么快”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“单线程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为什么这么快

如上图所示,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为什么这么快

为了在请求到达时能通知到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来描述发生了什么事件:

那么,回调机制怎么工作的呢?其实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;

发送给客户端的数据会存放到两个地方:

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

感谢各位的阅读,以上就是“单线程Redis为什么这么快”的内容了,经过本文的学习后,相信大家对单线程Redis为什么这么快这一问题有了更深刻的体会,具体使用情况还需要大家实践验证。这里是亿速云,小编将为大家推送更多相关知识点的文章,欢迎关注!

相关推荐:Redis 为什么使用单进程单线程方式也这么快

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

redis

上一篇:linux自带的定时任务有哪些

下一篇:如何总结Java中BigDecimal类与int、Integer使用

相关阅读