Redis网络模型(io多路复用)

阻塞IO(Blocking IO)

假设服务端只开启一个线程处理请求,第一个请求到来,开始调用内核read函数,然后就会发生阻塞,第二个请求到来时服务端将无法处理,只能等第一个请求读取完成。这种方式的缺点很明显,每次只能处理一个请求,无法发挥cpu多核优势,性能低下。

为了解决这个问题,我们可以引入多线程,这样就可以同时处理多个请求了,但服务端可能同时有成千上万的请求需要处理,随之而来的是线程数膨胀,频繁创建、销毁线程带来的性能影响,当然我们可以使用线程池,但服务能处理的总体数量就会受限于线程池线程数量。

非阻塞IO(NON-Blocking IO)

相比阻塞IO,非阻塞IO会立即返回,调用者不会阻塞,此时可以做一些其它事情,例如处理其它请求。但是非阻塞IO需要主动轮询是否有数据需要处理,且这种轮询需要从用户态切换到内核态这,假如没有数据产生就会有很多空轮询,白白浪费cpu资源。

阻塞IO、非阻塞IO,要么需要开启更多线程去处理IO,要么需要从用户态切换到内核态轮询IO事件,那么有没有一种机制,用户程序只需要将请求提交给内核,由内核用少量的线程去监听,有事件就通知用户程序呢?这就是IO多路复用。

IO多路复用(IO Multiplexing)

IO多路复用机制是指一个线程处理多个IO流,多路是指网络连接,复用指的是同一个线程。
如果简单从图上看IO多路复用相比阻塞IO似乎并没有什么高明之处,假设服务只处理少量的连接,那么相比阻塞IO确实没有太大的提升,但如果连接数非常多,差距就会立竿见影。
首先IO多路复用会提交一批需要监听的文件句柄(socket也是一种文件句柄)到内核,由内核开启一个线程负责监听,把轮询工作交给内核,当有事件发生时,由内核通知用户程序。这不需要用户程序开启更多的线程去处理连接,也不需要用户程序切换到内核态去轮询,用一个线程就能处理大量网络IO请求。
redis底层采用的就是IO多路复用模型,实际上基本所有中间件在处理网络IO这一块都会使用到IO多路复用,如kafka,rocketmq等,所以本次学习之后对其它中间件的理解也是很有帮助的。

select/poll/epoll

linux最开始提供的是select函数,方法如下:

1
select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)

该方法需要传递3个集合,r,e,w分别表示读、写、异常事件集合。集合类型是bitmap,通过0/1表示该位置的fd(文件描述符,socket也是其中一种)是否关心对应读、写、异常事件。例如我们对fd为1和2的读事件关心,r参数的第1,2个bit就设置为1。

用户进程调用select函数将关心的事件传递给内核系统,然后就会阻塞,直到传递的事件至少有一个发生时,方法调用会返回。内核返回时,同样把发生的事件用这3个参数返回回来,如r参数第1个bit为1表示fd为1的发生读事件,第2个bit依然为0,表示fd为2的没有发生读事件。用户进程调用时传递关心的事件,内核返回时返回发生的事件。

select存在的问题:

  1. 大小有限制。为1024,由于每次select函数调用都需要在用户空间和内核空间传递这些参数,为了提升拷贝效率,linux限制最大为1024。
  2. 这3个集合有相应事件触发时,会被内核修改,所以每次调用select方法都需要重新设置这3个集合的内容。
  3. 当有事件触发select方法返回,需要遍历集合才能找到就绪的文件描述符,例如传1024个读事件,只有一个读事件发生,需要遍历1024个才能找到这一个。
  4. 同样在内核级别,每次需要遍历集合查看有哪些事件发生,效率低下。

poll函数对select函数做了一些改进

1
2
3
4
5
6
7
poll(struct pollfd *fds, int nfds, int timeout)

struct pollfd {
int fd;
short events;
short revents;
}

poll函数需要传一个pollfd结构数组,其中fd表示文件描述符,events表示关心的事件,revents表示发生的事件,当有事件发生时,内核通过这个参数返回回来。

poll相比select的改进:

  1. 传不固定大小的数组,没有1024的限制了(问题1)
  2. 将关心的事件和实际发生的事件分开,不需要每次都重新设置参数(问题2)。例如poll数组传1024个fd和事件,实际只有一个事件发生,那么只需要重置一下这个fd的revent即可,而select需要重置1024个bit。

poll没有解决select的问题3和4。另外,虽然poll没有1024个大小的限制,但每次依然需要在用户和内核空间传输这些内容,数量大时效率依然较低。

这几个问题的根本实际很简单,核心问题是select/poll方法对于内核来说是无状态的,内核不会保存用户调用传递的数据,所以每次都是全量在用户和内核空间来回拷贝,如果调用时传给内核就保存起来,有新增文件描述符需要关注就再次调用增量添加,有事件触发时就只返回对应的文件描述符,那么问题就迎刃而解了,这就是epoll做的事情。

epoll对应3个方法

1
2
3
int epoll_create(int size);
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);

epoll_create负责创建一个上下文,用于存储数据,底层是用红黑树,以后的操作就都在这个上下文上进行。
epoll_ctl负责将文件描述和所关心的事件注册到上下文。
epoll_wait用于等待事件的发生,当有有事件触发,就只返回对应的文件描述符了。

select 底层是位图数组,限制就是会被数组长度限制,只有1024大小,内核轮询这个事件是否就绪,如果有的话内核态会把用户态的参数拷进内核空间然后检测每个的状态,select() 返回时,内核会把修改后的 fd_set(就绪标记)拷回用户空间,这样子的开销是比较大的,每次都需要扫描,拷贝。

poll底层的话是个链表,他的话解决了select的大小限制问题,但是还是没有解决用户态与内核态之间的问题

epoll的话他底层是个红黑树,他有三个函数,creat,ctl,wait,他的改进是用户在修改的时候,内核会把信息拷入内核数据结构,当内核返回就绪事件时候,epoll_wait会把准备好的事件批量拷贝到用户提供的缓冲区