常见IO模型-IO多路复用

443 阅读4分钟

常见IO模型:

  • 同步阻塞IO(Blocking IO):使用recv一直等数据直到拷贝到用户空间,并同步返回状态,这段时间内进程始终阻塞. 进程阻塞的本质是将进程的state的改变state=TASK_INTERRUPTIBLE/TASK_RUNABLE,剩下的交给操作系统进程调度算法.

  • 同步非阻塞IO(Non-blocking IO): socket设置为non-block,recv不管有没有获取到数据都返回,如果没有数据则等一段时间后在调用recv,如此循环. 只在检查无数据时是非阻塞的,有数据时仍然同步等待数据从内核到用户空间。

  • IO多路复用: 用Reactor模式较多,具体参考第2部分介绍

    • select
    
    //创建fds数组
    sockfd = socket(...);
    bind(sockfd, addr);
    listen(sockfd,port);
    for...{
        //通过listen_socket,创建connect_socket. listen_socket负责继续监听,connect_socket负责处理数据读写
        fds[i] = accept(sockfd);
    }
    //创建bitmap/rset,并关联fds,最多1024bit
    while(1) {
    
        FD_ZERO(&bitmap); //bitmap由用户创建,并拷贝到内核更改,无法复用
        for ...{
            FD_SET(fds[i], &bitmap)
        }
    
        //此处多路复用器阻塞,等待有事件唤醒
        select(max+1,&rset);
        //traversal fds,时间复杂度O(n)
    
    }
    
    

    限制:
    1. 一个进程最多能标记1024个fd
    2. 用户态和内核态之间切换拷贝bitmap
    3. bitmap无法重用
    4. 需要完整遍历bitmap每一位

  • poll:

    相较select改进:

    1. 通过pollfd数组,解除了bitmap最多可标记1024个socket的限制
    2. 可以重用pollfd,不需要每次创建bitmap
        stuct pollfd{
            int fd;
            //POLLIN 有数据可读, POLLOUT写事件允许, POLLERR 错误,POLLRDHUP流socket对端关闭连接;...
            short events;
            short revents;
        }
    
        ...
    
        for...{
            pollfd_arr[i].fd = accept(listen_socket_fd);
            pollfd_arr[i].events = POLLIN;
        }
        
        while(1){
            //此处多路复用器阻塞,
            poll(pollfd_arr, count,...);
            for i in count{
                if (pollfd_arr[i].revents & POLLIN){
                    read(pollfd_arr[i].fd, buffer);
                    pollfds[i].revents = 0; //重置状态 复用
                }
            }
        }
    
    
  • epoll: bsd上是kqueue.

    相比poll的改进:

    1. 不仅告诉调用方有数据,还能提供具体哪个sock有数据,避免了遍历查找
    2. events由用户态空间和内核空间共享避免了切换, 并且返回产生事件的句柄数量,以及内核排序好的句柄数组,调用方只需遍历有事件发生的fd数组.
      struct epoll_event events[1024]
      int epfd = epoll_create
    
      ...
    
      for...{
          static struct epoll_event ev;
          ev.data.fd = accept(listen_socket_fd,....);
          ev.events = EPOLLIN;
          epoll_ctl(epfd, ev.data.fd,&ev ...);
    
          whil(1){
              //阻塞, 返回产生事件的个数,并且 events是内核已排序好的
              int count = epoll_wait(epfd, events, ....);
              for(i in count){
                  read(events[i].data.fd, buffer..);
              }
          }
      }
    
    
  • 异步非阻塞IO: 由操作系统内核负责读取socket数据,并写入用户指定的缓冲区,用户线程不会被阻塞,目前操作系统支持不太完善。

扩展

1. IO多路复用中常用的Reactor反应堆模式

主要角色:

  • handle : linux中称为文件描述符,windows中称为句柄,是对资源在os上的抽象,如socet\打开的文件\timer等,

  • synchronous event demultiplexer 同步多路事件分离器,本质是系统调用,如select poll epoll等,可以等待多个handle,在等待过程中所在线程牌挂起状态不消耗CPU时间。当某个句柄有事件产生时才会返回

  • Event Handler/Concrete Event Handler 定义一些钩子函数或称为回调方法,handle有事件产生就会调用钩子方法,在socket中一般有decode-process-encode这些过程,Concrete处理具体的业务逻辑

  • Initiation Dispatcher 初始事件分发器,用于管理event handler, 并调用多路分离器等待其返回时,将事件分发到event handle事件处理器进行处理

按线程划分:

  • 单线程Reactor模型: 与NIO流程相似,一个线程处理所有的IO和process()过程,要求IO和CPU速度匹配,无处理较慢的请求,否则引起后续的请求积压

  • 多线程Reactor模型: 一个或多个IO线程,多个工作处理线程,由于reactor既处理IO请求也响应连接请求容易出现瓶颈。

  • 主从Reactor模型: 一个main reactor负责监听selector连接池,多个sub reactor 处理accept连接请求,性能较好。

线程池缺点是大并发下耗满线程,服务阻塞。对于大文件传输占用长时间数据读写操作的,可以考虑使用每个连接建立新的线程或异步非阻塞IO模式。并发数少用哪个都没有区别。异步非阻塞IO由于操作系统支持限制用的较少,大多高性能并发服务采用IO多路复用+多线程(线程池)的架构。

2. TCP粘包分包问题

每个UDP消息头部都有来源和端口号的消息头,一次write就是一条消息,接收端也以消息为单位提取,不完整的包会被丢弃,因此不会出现粘包。而TCP是面向流的协议接收端无法得知接收的数据是否已传完,因此要处理粘包、半包问题,解决方案有:

  1. 基于固定长度消息
  2. 基于消息边界符
  3. 消息头指定消息长度。