Redis系列--IO多路复用

Redis Server跑在单进程单线程中,接收到的命令操作都是按照顺序线性执行的,即便如此,它的读写性能依然能达到10W+的QPS,不得不说:Redis的设计十分优秀。

为什么Redis的读写性能这么高呢?原因有许多,我们列举主要的三个:

    1、Redis基于内存操作:

绝大部分的请求为纯粹的内存操作,而且使用hash结构存储数据,查找和操作的时间复杂度均为O(1)。

    2、Redis数据结构简单:

redis对数据的操作还是比较简单的,而且redis的数据结构是专门设计的。

    3、单线程-IO多路复用模型:

单线程的设计省去了很多的麻烦:比如上下文切换、资源竞争、CPU切换消耗以及各种锁操作等等问题,而IO多路复用模型的使用更让Redis提升了效率。

今天,我们就来聊一聊:IO多路复用模型

IO即为网络I/O,多路即为多个TCP连接,复用即为共用一个线程或者进程,模型最大的优势是系统开销小,不必创建也不必维护过多的线程或进程。

IO多路复用是经典的Reactor设计模式,有时也称为异步阻塞IO(异步指socket为non-blocking,堵塞指select堵塞),为常见的四种IO模型之一,其他三种分别是:同步堵塞IO、同步非堵塞IO、异步(非堵塞)IO。

IO多路复用的核心是可以同时处理多个连接请求,为此使用了两个系统调用,分别是:

  • select/poll/epoll--模型机制:可以监视多个描述符(fd),一旦某个描述符就绪(读/写/异常)就能通知程序进行相应的读写操作。读写操作都是自己负责的,也即是阻塞的,所以本质上都是同步(堵塞)IO。Redis支持这三种机制,默认使用epoll机制。

  • recvfrom--接收数据

而blocking IO只调用了recvfrom,所以在连接数不高的情况下,blocking IO的性能不一定比IO多路复用差。

补充:异步IO无需自己负责读写操作。

我们来看一下IO多路复用模型的三种实现机制:

1、select机制:

select原理:

当client连接到server后,server会创建一个该连接的描述符fd,fd有三种状态,分别是读、写、异常,存放在三个fd_set(其实就是三个long型数组)中。

#include <sys/select.h>//select接口,调用时会堵塞int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

而select()的本质就是通过设置和检查fd的三个fd_set来进行下一步处理。

 select大体执行步骤:

        1) 使用copy_from_user从用户空间拷贝fd_set到内核空间

        2) 内核遍历[0,nfds)范围内的每个fd,调用fd所对应的设备的驱动poll函数,poll函数可以检测fd的可用流(读流、写流、异常流)。

       3) 检查是否有流发生,如果有发生,把流设置对应的类别,并执行4,如果没有流发生,执行5。或者timeout=0,执行4。

       4) select返回。

       5)select阻塞进程,等待被流对应的设备唤醒时执行2,或timeout到期,执行4。

select的局限性:

  • 维护一个存放大量描述符的数组:每次调用select()都需要将fd_set从用户态拷贝到内核态,然后由内核遍历fd_set,如果fd_set很大,那么拷贝和遍历的开销会很大,为了减少性能损坏,内核对fd_set的大小做了限制,并通过宏定义控制,无法改变(1024)。

  • 单进程监听的描述符数量有限制:单进程能打开的最大连接数。

  • 轮询遍历数组,效率低下:select机制只知道有IO发生,但是不知道是哪几个描述符,每次唤醒,都需要遍历一次,复杂度为O(n)。

2、poll机制:

poll原理:

poll的实现和select非常相似,轮询+遍历+根据描述符的状态处理,只是fd_set换成了pollfd,而且去掉了最大描述符数量限制,其他的局限性同样存在。

#include <poll.h>int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
struct pollfd {int fd;         /* 文件描述符 */short events;         /* 等待的事件 */short revents;       /* 实际发生了的事件 */} ;

3、epoll机制:epoll对select和poll进行了改进,避免了上述三个局限性。

epoll原理:

与select和poll只提供一个接口函数不同的是,epoll提供了三个接口函数及一些结构体:

/*建一个epoll句柄*/
int epoll_create(int size);

/*向epoll句柄中添加需要监听的fd和时间event*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 

/*返回发生事件的队列*/
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

struct eventpoll 
{
  ...  
    
    /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,也就是这个epoll_ctl监控的事件*/   
    struct rb_root rbr;

    /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/  
    struct list_head rdllist;
  ...
};
  • 调用epoll_create:linux内核会在epoll文件系统创建一个file节点,同时创建一个eventpoll结构体,结构体中有两个重要的成员:rbr是一棵红黑树,用于存放epoll_ctl注册的socket和事件;rdllist是一条双向链表,用于存放准备就绪的事件供epoll_wait调用。

  • 调用epoll_ctl:会检测rbr中是否已经存在节点,有就返回,没有则新增,同时会向内核注册回调函数ep_poll_callback,当有事件中断来临时,调用回调函数向rdllist中插入数据,epoll_ctl也可以增删改事件。

  • 调用epoll_wait:返回或者判断rdllist中的数据即可。

epoll两种工作模式:LT--水平触发  ET--边缘触发

  • LT:只要文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作。

  • ET:检测到有IO事件时,通过epoll_wait调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,必须将该文件描述符一直读到空,让errno返回EAGAIN为止,否则下次的epoll_wait不会返回余下的数据,会丢掉事件。

ET比LT更加高效,因为ET只通知一次,而LT会通知多次,LT可能会充斥大量不关心的就绪文件描述符。


epoll总结:

  • 使用红黑树而不是数组存放描述符和事件,增删改查非常高效,轻易可处理大量并发连接。

  • 红黑树及双向链表都在内核cache中,避免拷贝开销。

  • 采用回调机制,事件的发生只需关注rdllist双向链表即可。

epoll编程框架:一个大体的编程应用框架

for( ; ; )
{
    nfds = epoll_wait(epfd, events, 20, 500);
    for(i=0; i<nfds; ++i)
    {
        if(events[i].data.fd == listenfd) //如果是主socket的事件,则表示有新的连接
        {
            connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
            ev.data.fd=connfd;
            ev.events=EPOLLIN|EPOLLET;
            epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
        }
        else if( events[i].events&EPOLLIN ) //接收到数据,读socket
        {

            if ( (sockfd = events[i].data.fd) < 0) continue;
            n = read(sockfd, line, MAXLINE)) < 0    //读
            ev.data.ptr = md;     //md为自定义类型,添加数据
            ev.events=EPOLLOUT|EPOLLET;
            epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓
        }
        else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
        {
            struct myepoll_data* md = (myepoll_data*)events[i].data.ptr;    //取数据
            sockfd = md->fd;
            send( sockfd, md->ptr, strlen((char*)md->ptr), 0 );        //发送数据
            ev.data.fd=sockfd;
            ev.events=EPOLLIN|EPOLLET;
            epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
        }
        else
        {
            //其他情况的处理
        }
    }
}

PS:如有任何问题或疑问,请留言告诉我。


喜欢这篇文章的朋友,欢迎关注公众号,第一时间收到更新内容。

发布了6 篇原创文章 · 获赞 2 · 访问量 123

猜你喜欢

转载自blog.csdn.net/weixin_45784328/article/details/105408635