IO多路复用:Redis中经典的Reactor设计模式

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多路复用的核心是可以同时处理多个连接请求,为此使用了两个系统调用,分别是:

1.select/poll/epoll--模型机制:可以监视多个描述符(fd),一旦某个描述符就绪(读/写/异常)就能通知程序进行相应的读写操作。读写操作都是自己负责的,也即是阻塞的,所以本质上都是同步(堵塞)IO。Redis支持这三种机制,默认使用epoll机制。2.recvfrom--接收数据。而blocking IO只调用了recvfrom,所以在连接数不高的情况下,blocking 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的局限性:

  1. 维护一个存放大量描述符的数组:每次调用select()都需要将fd_set从用户态拷贝到内核态,然后由内核遍历fd_set,如果fd_set很大,那么拷贝和遍历的开销会很大,为了减少性能损坏,内核对fd_set的大小做了限制,并通过宏定义控制,无法改变(1024)。
  2. 单进程监听的描述符数量有限制:单进程能打开的最大连接数。
  3. 轮询遍历数组,效率低下: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;

1.调用epoll_create:linux内核会在epoll文件系统创建一个file节点,同时创建一个eventpoll结构体,结构体中有两个重要的成员:rbr是一棵红黑树,用于存放epoll_ctl注册的socket和事件;rdllist是一条双向链表,用于存放准备就绪的事件供epoll_wait调用。

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

3.调用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双向链表即可。