文章目录

  • 1. Redis单进程单线程
  • 2. Redis的非阻塞IO
  • 3. I/O多路复用程序的实现


1. Redis单进程单线程

  1. Redis是单进程单线程的,Redis利用队列技术将并发访问变为串行访问,消除了传统数据库串行控制的开销。多线程处理会涉及到锁。
  2. 多线程处理会涉及到线程切换而消耗CPU。减少上下文切换时间,因为CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存或者网络带宽。单线程无法发挥多核CPU性能,不过可以通过在单机开多个Redis实例来解决。

单线程的redis为什么这么快

(一)纯内存操作
(二)单线程操作,避免了频繁的上下文切换
(三)采用了非阻塞I/O多路复用机制

2. Redis的非阻塞IO

单线程如何高效的处理并发的网络请求,redis使用的是基于react模式(反应器模式,当检测到一个新的事件,将其发送给相应的Handler去处理)开发的网络事件处理器被称为文件事件处理器组成结构:

多个套接字、IO多路复用程序、文件事件分派器、事件处理器。

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_多路复用

Redis客户端对服务端的调用分为发送命令,执行命令,返回结果三个过程,

  • 文件事件处理器使用I/O多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
  • 尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都推到一个队列里面,然后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字:当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O多路复用程序才会继续向文件事件分派器传送下一个套接字。

多路复用IO的实现是通过包装select、epoll、evport和kqueue这些I/O多路复用函数库来实现的,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件,比如ae_select.c、ae_epoll.c、ae_kqueue.c等,Redis为每个I/O多路复用函数库都实现了相同的API,所以I/O多路复用程序的底层实现是可以互换的

文件事件的类型分为可读事件(connect read close)可写事件(write),如果一个套接字同时产生了这两种事件,那么服务器将先读套接字,后写套接字。

Redis为文件事件编写了多个处理器,分别为socket关联连接、命令请求、命令回复处理器等。

多路I/O复用模型非阻塞IO

下面举一个例子,模拟一个tcp服务器处理30个客户socket。 假设你是一个监考老师,让30个学生解答一道竞赛考题,然后负责验收学生答卷,你有下面几个选择:

  1. 第一种选择:按顺序逐个验收,先验收A,然后是B,之后是C、D。。。这中间如果有一个学生卡住,全班都会被耽误。 这种模式就好比,你用循环挨个处理socket,根本不具有并发能力
  2. 第二种选择:你创建30个分身,每个分身检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接
  3. 第三种选择,你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。
    - 这种就是IO复用模型,Linux下的select、poll和epoll就是干这个的。将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。 这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor模式。【epoll监听哪些socket上有事件到达,当 accept、read、write 和 close 文件事件产生时,就会回调 FD 绑定的事件处理器】

3. I/O多路复用程序的实现

1、什么是IO多路复用

  • IO多路复用是一种同步IO模型实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程

2、为什么有IO多路复用机制?

没有IO多路复用机制时,有BIO、NIO两种实现方式,但有一些问题

同步阻塞(BIO)

服务端采用单线程,当accept一个请求后,在recv或send调用阻塞时,将无法accept其他请求(必须等上一个请求处recv或send完),无法处理并发,同步阻塞模型,一个客户端连接对应一个处理线程,对于每一个新的网络连接都会分配给一个线程,每个线程都独立处理自己负责的输入和输出, 也被称为Connection Per Thread模式

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_Redis_02

缺点:
1、IO代码里read操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,浪费资源
2、如果线程很多,会导致服务器线程太多,压力太大,比如C10K问题

所谓c10k问题,指的是服务器同时支持成千上万个客户端的问题,也就是concurrent 10 000 connection

应用场景: BIO 方式适用于连接数目比较小且固定的架构, 这种方式对服务器资源要求比较高, 但程序简单易理解。

// 伪代码描述
while(1) {
  // accept阻塞
  client_fd = accept(listen_fd)
  fds.append(client_fd)
  for (fd in fds) {
    // recv阻塞(会影响上面的accept)
    if (recv(fd)) {
      // logic
    }
  }  
}

服务器端采用多线程,当accept一个请求后,开启线程进行recv,可以完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销,10000个线程真正发生读写事件的线程数不会超过20%,每次accept都开一个线程也是一种资源浪费

// 伪代码描述
while(1) {
  // accept阻塞
  client_fd = accept(listen_fd)
  // 开启线程read数据(fd增多导致线程数增多)
  new Thread func() {
    // recv阻塞(多线程不影响上面的accept)
    if (recv(fd)) {
      // logic
    }
  }  
}

同步非阻塞(NIO)
服务器端当accept一个请求后,加入fds集合,每次轮询一遍fds集合recv(非阻塞)数据,没有数据则立即返回错误,每次轮询所有fd(包括没有发生读写事件的fd)会很浪费cpu

setNonblocking(listen_fd)
// 伪代码描述
while(1) {
  // accept非阻塞(cpu一直忙轮询)
  client_fd = accept(listen_fd)
  if (client_fd != null) {
    // 有人连接
    fds.append(client_fd)
  } else {
    // 无人连接
  }  
  for (fd in fds) {
    // recv非阻塞
    setNonblocking(client_fd)
    // recv 为非阻塞命令
    if (len = recv(fd) && len > 0) {
      // 有读写数据
      // logic
    } else {
       无读写数据
    }
  }  
}

IO多路复用(现在的做法)
服务器端采用单线程通过select/epoll等系统调用获取fd列表,遍历有事件的fd进行accept/recv/send,使其能支持更多的并发连接请求

fds = [listen_fd]
// 伪代码描述
while(1) {
  // 通过内核获取有读写事件发生的fd,只要有一个则返回,无则阻塞
  // 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,accept/recv是不会阻塞
  for (fd in select(fds)) {
    if (fd == listen_fd) {
        client_fd = accept(listen_fd)
        fds.append(client_fd)
    } elseif (len = recv(fd) && len != -1) { 
      // logic
    }
  }  
}

3、IO多路复用的三种实现方式

  • select
  • poll
  • epoll

4、select函数接口

#include <sys/select.h>
#include <sys/time.h>
#define FD_SETSIZE 1024

#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
// 数据结构 (bitmap)
typedef struct {
    unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
// API
int select(
    int max_fd, 
    fd_set *readset, 
    fd_set *writeset, 
    fd_set *exceptset, 
    struct timeval *timeout

)                              // 返回值就绪描述符的数目
FD_ZERO(int fd, fd_set* fds)   // 清空集合
FD_SET(int fd, fd_set* fds)    // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds)  // 判断指定描述符是否在集合中 
FD_CLR(int fd, fd_set* fds)    // 将给定的描述符从文件中删除

5、select使用示例

int main() {
  /*
   * 这里进行一些初始化的设置,
   * 包括socket建立,地址的设置等,
   */
  fd_set read_fs, write_fs;
  struct timeval timeout;
  int max = 0;  // 用于记录最大的fd,在轮询中时刻更新即可
  // 初始化比特位
  FD_ZERO(&read_fs);
  FD_ZERO(&write_fs);
  int nfds = 0; // 记录就绪的事件,可以减少遍历的次数
  while (1) {
    // 阻塞获取
    // 每次需要把fd从用户态拷贝到内核态
    nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout);
    // 每次需要遍历所有fd,判断有无读写事件发生
    for (int i = 0; i <= max && nfds; ++i) {
      if (i == listenfd) {
         --nfds;
         // 这里处理accept事件
         FD_SET(i, &read_fd);//将客户端socket加入到集合中
      }
      if (FD_ISSET(i, &read_fd)) {
        --nfds;
        // 这里处理read事件
      }
      if (FD_ISSET(i, &write_fd)) {
         --nfds;
        // 这里处理write事件
      }
    }
  }

6、select缺点

  • 单个进程所打开的FD是有限制的,通过FD_SETSIZE设置,默认1024
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 对socket扫描时是线性扫描(对所有的fds遍历扫描),采用轮询的方法,效率较低(高并发时)

7、poll函数接口

poll与select相比,只是没有fd的限制,其它基本一样

#include <poll.h>
// 数据结构
struct pollfd {
    int fd;                         // 需要监视的文件描述符
    short events;                   // 需要内核监视的事件
    short revents;                  // 实际发生的事件
};
// API

int poll(struct pollfd fds[], nfds_t nfds, int timeout);

8、poll使用示例

#include <poll.h>
// 数据结构
struct pollfd {
    int fd;                         // 需要监视的文件描述符
    short events;                   // 需要内核监视的事件
    short revents;                  // 实际发生的事件
};
// API
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
8、poll使用示例
// 先宏定义长度
#define MAX_POLLFD_LEN 4096  
int main() {
  /*
   * 在这里进行一些初始化的操作,
   * 比如初始化数据和socket等。
   */
  int nfds = 0;
  pollfd fds[MAX_POLLFD_LEN];
  memset(fds, 0, sizeof(fds));
  fds[0].fd = listenfd;
  fds[0].events = POLLRDNORM;
  int max  = 0;  // 队列的实际长度,是一个随时更新的,也可以自定义其他的
  int timeout = 0;
  int current_size = max;
  while (1) {
    // 阻塞获取
    // 每次需要把fd从用户态拷贝到内核态
    nfds = poll(fds, max+1, timeout);
    if (fds[0].revents & POLLRDNORM) {
        // 这里处理accept事件
        connfd = accept(listenfd);
        //将新的描述符添加到读描述符集合中
    }
    // 每次需要遍历所有fd,判断有无读写事件发生
    for (int i = 1; i < max; ++i) {     
      if (fds[i].revents & POLLRDNORM) { 
         sockfd = fds[i].fd
         if ((n = read(sockfd, buf, MAXLINE)) <= 0) {
            // 这里处理read事件
            if (n == 0) {
                close(sockfd);
                fds[i].fd = -1;
            }
         } else {
             // 这里处理write事件     
         }
         if (--nfds <= 0) {
            break;       
         }   
      }
    }
  }

9、poll缺点

  • 每次调用poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)

10、epoll函数接口

#include <sys/epoll.h>
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
};
// API
int epoll_create(int size); // 内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程

11、epoll使用示例

int main(int argc, char* argv[]){
   /*
   * 在这里进行一些初始化的操作,
   * 比如初始化数据和socket等。

   */
    // 内核中创建ep对象
    epfd=epoll_create(256);
    // 需要监听的socket放到ep中
    epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
    while(1) {
      // 阻塞获取
      nfds = epoll_wait(epfd,events,20,0);
      for(i=0;i<nfds;++i) {
          if(events[i].data.fd==listenfd) {
              // 这里处理accept事件
              connfd = accept(listenfd);
              // 接收新连接写到内核对象中
              epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
          } else if (events[i].events&EPOLLIN) {
              // 这里处理read事件
              read(sockfd, BUF, MAXLINE);
              //读完后准备写
              epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
          } else if(events[i].events&EPOLLOUT) {
              // 这里处理write事件
              write(sockfd, BUF, n);
              //写完后准备读
              epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
          }
      }
    }
    return 0;
}

12、epoll缺点

epoll只能工作在linux下

13、epoll LT 与 ET模式的区别
epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。

  • LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作
  • ET模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完,或者遇到EAGAIN错误

14、epoll应用

  • redis
  • nginx

15、select/poll/epoll之间的区别

select                  poll                    epoll                                                                   
数据结构          bitmap                   数组                    红黑树                                                                  
最大连接数        1024                     无上限                   无上限                                                                 
fd拷贝    每次调用select拷贝           每次调用poll拷贝   fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝
工作效率        轮询:O(n)                轮询:O(n)                回调:O(1)

select具体过程
应用进程想要通过select 去监控多个连接(也就是fd)的话需要经向大概如下的流程:

1、在调用select之前告诉select 应用进程需要监控哪些fd可读、可写、异常事件,这些分别都存在一个fd_set数组中。

2、然后应用进程调用select的时候把3个fd_set传给内核(这里也就产生了一次fd_set在用户空间到内核空间的复制),内核收到fd_set后对fd_set进行遍历,然后一个个去扫描对应fd是否满足可读写事件。

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_python_03

3、如果发现了有对应的fd有读写事件后,内核会把fd_set里没有事件状态的fd句柄清除,然后把有事件的fd返回给应用进程(这里又会把fd_set从内核空间复制用户空间)。

4、最后应用进程收到了select返回的活跃事件类型的fd句柄后,再向对应的fd发起数据读取或者写入数据操作。

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_python_04

通过上面的图我想你已经大概了解了select的工作模式,select 提供一种可以用一个进程监控多个网络连接的方式,但也还遗留了一些问题,这些问题也是后来select面对高并发环境的性能瓶颈。

  • 1、每调用一次select 就需要3个事件类型的fd_set需从用户空间拷贝到内核空间去,返回时select也会把保留了活跃事件的fd_set返回(从内核拷贝到用户空间)。当fd_set数据大的时候,这个过程消耗是很大的。
  • 2、select需要逐个遍历fd_set集合 ,然后去检查对应fd的可读写状态,如果fd_set 数据量多,那么遍历fd_set 就是一个比较耗时的过程。
  • 3、fd_set是个集合类型的数据结构有长度限制,32位系统长度1024,62位系统长度2048,这个就限制了select最多能同时监控1024个连接。

poll过程

在早期计算机网络并不发达,所以并发网络请求并不会很高,select模型也足够使用了,但是随着网络的高速发展,高并发的网络请求程序越来越多,而select模式下 fd_set 长度限制就开始成为了致命的缺陷。

吸取了select的教训,poll模式就不再使用数组的方式来保存自己所监控的fd信息了,poll模型里面通过使用链表的形式来保存自己监控的fd信息,正是这样poll模型里面是没有了连接限制,可以支持高并发的请求。

和select还有一点不同的是保存在链表里的需要监控的fd信息采用的是pollfd的文件格式,select 调用返回的fd_set是只包含了上次返回的活跃事件的fd_set集合,下一次调用select又需要把这几个fd_set清空,重新添加上自己感兴趣的fd和事件类型,而poll采用的pollfd 保存着对应fd需要监控的事件集合,也保存了一个当返回于激活事件的fd集合。 所以重新发请求时不需要重置感兴趣的事件类型参数。

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_多路复用_05

至于其他方面poll和select并没有多大的差别,select其他问题poll上面也还是存在。

epoll
不同于select 和poll的直接调用方式,epoll采用的是一组方法调用的方式,它的工作流程大致如下:

1、创建内核事件表(epoll_create)。

这里主要是向内核申请创建一个fd的文件描述符作为内核事件表(B+树结构的文件,没有数量限制),这个描述符用来保存应用进程需要监控哪些fd和对应类型的事件。

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_多路复用_06

2、添加或移出监控的fd和事件类型(epoll_ctl)。

调用此方法可以是向内核的内核事件表 动态的添加和移出fd 和对应事件类型。

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_多路复用_07

3、epoll_wait 绑定回调事件

内核向事件表的fd绑定一个回调函数。

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_python_08

当监控的fd活跃时,会调用callback函数把事件加到一个活跃事件队列里;

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_python_09

最后在epoll_wait 返回的时候内核会把活跃事件队列里的fd和事件类型返回给应用进程。

redis 异步非阻塞的事件驱动模型 redis 非阻塞io_Redis_10

最后,从epoll整体思路上来看,采用事先就在内核创建一个事件监听表,后面只需要往里面添加移出对应事件,因为本身事件表就在内核空间,所以就避免了向select、poll一样每次都要把自己需要监听的事件列表传输过去,然后又传回来,这也就避免了事件信息需要在用户空间和内核空间相互拷贝的问题。

然后epoll并不是像select一样去遍历事件列表,然后逐个轮询的监控fd的事件状态,而是事先就建立了fd与之对应的回调函数,当事件激活后主动回调callback函数,这也就避免了遍历事件列表的这个操作,所以epoll并不会像select和poll一样随着监控的fd变多而效率降低,这种事件机制也是epoll要比select和poll高效的主要原因。

17、高频面试题

什么是IO多路复用?

nginx/redis 所使用的IO模型是什么?

select、poll、epoll之间的区别

epoll 水平触发(LT)与 边缘触发(ET)的区别?