架构IO多路复用

Posted sysu_lluozh

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了架构IO多路复用相关的知识,希望对你有一定的参考价值。

服务器端编程经常需要构造高性能的IO模型,常见的IO模型有:

  1. 同步阻塞IO(Blocking IO)
    传统的IO模型
  2. 同步非阻塞IO(Non-blocking IO)
    默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK
  3. IO多路复用(IO Multiplexing)
    经典的Reactor设计模式,有时也称异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型
  4. 异步IO(Asynchronous IO)
    经典的Proactor设计模式,也称为异步非阻塞IO

一、同步阻塞IO

服务端为了处理客户端的连接和请求的数据,写了如下代码

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) 
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接

这段代码服务端的线程阻塞在了两个地方,一个是accept函数,一个是read函数

如果再把read函数的细节展开,发现其阻塞在了两个阶段

  1. 网卡将客户端发过来的数据拷贝到内核缓冲区
  2. 内核缓冲区设置关联的文件描述符为可读,将内核缓冲区中的数据拷贝到用户缓冲区

整体流程如下:

所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接

二、非阻塞IO

为了解决上面的问题,其关键在于改造这个read函数

有一种办法是,每次都创建一个新的进程或线程,去调用read函数,并做业务处理

while(1) 
  connfd = accept(listenfd);  // 阻塞建立连接
  pthread_create(doWork);  // 创建一个新的线程

void doWork() 
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接

这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的read请求上
不过,这不叫非阻塞IO,只不过用了多线程的手段使得主线程没有卡在read函数上不往下处理,操作系统提供的read函数仍然是阻塞

所以真正的非阻塞 IO,不能是仅通过用户层处理,而是要操作系统提供一个非阻塞的read函数
这个read函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待

操作系统提供了这样的功能,只需要在调用read前,将文件描述符设置为非阻塞即可

fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

这样,就需要用户线程循环调用read,直到返回值不为-1,再开始处理业务

这里注意到两个问题:

  1. 非阻塞的read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的
    当数据已到达内核缓冲区,此时调用read函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回
  2. 为每个客户端创建一个线程,服务器端的线程资源很容易被耗光

整体流程如下:

三、IO多路复用

当然还有个办法,可以每accept一个客户端连接后,将这个文件描述符(connfd)放到一个数组里,然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞read方法

fdlist.add(connfd);

while(1) 
  for(fd <-- fdlist) 
    if(read(fd) != -1) 
      doSomeThing();
    
  

但这和用多线程去将阻塞IO改造成非阻塞IO一样,这种遍历方式只是用户自己想出的小把戏,每次遍历遇到read返回-1时仍然是一次浪费资源的系统调用

在while循环里做系统调用,好比做分布式项目时在while里做rpc请求一样不划算

所以,还是得需要操作系统提供一个有这样效果的函数,将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题

  • select

select是操作系统提供的系统调用函数,通过它可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后去处理:

select系统调用的函数定义如下:

int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
//  1.NULL,永远等下去
//  2.设置timeval,等待固定时间
//  3.设置timeval里时间均为0,检查描述字后立即返回,轮询

首先一个线程不断接受客户端连接,并把socket文件描述符放到一个list里

while(1) 
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);

然后,另一个线程不再自己遍历,而是调用select,将这批文件描述符list交给操作系统去遍历

while(1) 
  // 把一堆文件描述符 list 传给 select 函数
  // 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
  nready = select(list);
  ...

不过,当select函数返回后,用户依然需要遍历刚刚提交给操作系统的list
只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销

while(1) 
  nready = select(list);
  // 用户层依然要遍历,只不过少了很多无效的系统调用
  for(fd <-- fdlist) 
    if(fd != -1) 
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) break;
    
  

可以看出几个细节:

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

整个select的流程图如下:

可以看到,这种方式既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)

  • poll

poll也是操作系统提供的系统调用函数

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd 
  intfd; /*文件描述符*/
  shortevents; /*监控的事件*/
  shortrevents; /*监控事件中满足条件返回的事件*/
;

它和select的主要区别就是,去掉了select只能监听1024个文件描述符的限制

  • epoll

还记得上面select的三个细节么?

  1. select调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的(可优化为不复制)

  2. select在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销(内核层可优化为异步事件通知)

  3. select仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
    所以 epoll 主要就是针对这三点进行了改进。

  4. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可

  5. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒

  6. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合

具体,操作系统提供了这三个函数

第一步,创建一个 epoll 句柄

int epoll_create(int size);

第二步,向内核添加、修改或删除要监控的文件描述符

int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);

第三步,类似发起了select()调用

int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

具体流程如下:

以上是关于架构IO多路复用的主要内容,如果未能解决你的问题,请参考以下文章

五种网络IO模型以及多路复用IO中select/epoll对比

阻塞、非阻塞、多路复用、同步、异步、BIO、NIO、AIO 一文搞定

图解 | 原来这就是 IO 多路复用

Redis和多路复用模型

REDIS01_单线程的概述多线程的引入概述IO多路复用如何开启多线程

吃透Redis:网络框架篇-多路复用器