IO多路复用 select poll epoll 笔记

273 阅读6分钟

前言与参考引用

看苏炳榅老师的视频和博客就够了,下面是读后笔记
IO多路转接(复用)之select | 爱编程的大丙 (subingwen.cn)
IO多路转接(复用)之epoll | 爱编程的大丙 (subingwen.cn)
勘误:苏炳榲老师的代码有一处值得改进:就是select中,如果套接字活着是max_fd,关闭套接字后就应该检查关闭的是否是max_fd,若是的话将max_fd更新,至少 --max_fd
select poll epoll优缺点分析:
彻底搞懂epoll高效运行的原理 (qq.com)

多线程/多进程并发与I/O多路复用并发的区别(要求掌握,抄袭苏炳榲老师结论与多进程和多线程技术相比,I/O 多路复用技术的最大优势是系统开销小,系统不必创建进程 / 线程,也不必维护这些进程 / 线程,从而大大减小了系统的开销。):

  • 多线程 / 多进程并发
    • 主线程 / 父进程:调用 accept() 监测客户端连接请求
      • 如果没有新的客户端的连接请求,当前线程 / 进程会阻塞
      • 如果有新的客户端连接请求解除阻塞,建立连接
    • 子线程 / 子进程:和建立连接的客户端通信
      • 调用 read() / recv() 接收客户端发送的通信数据,如果没有通信数据,当前线程 / 进程会阻塞,数据到达之后阻塞自动解除
      • 调用 write() / send() 给客户端发送数据,如果写缓冲区已满,当前线程 / 进程会阻塞,否则将待发送数据写入写缓冲区中
  • IO 多路转接并发
    • 使用 IO 多路转接函数委托内核检测服务器端所有的文件描述符(通信和监听两类),这个检测过程会导致进程 / 线程的阻塞,如果检测到已就绪的文件描述符阻塞解除,并将这些已就绪的文件描述符传出
    • 根据类型对传出的所有已就绪文件描述符进行判断,并做出不同的处理
      • 监听的文件描述符:和客户端建立连接
        • 此时调用 accept() 是不会导致程序阻塞的,因为监听的文件描述符是已就绪的(有新请求)
      • 通信的文件描述符:调用通信函数和已建立连接的客户端通信
        • 调用 read() / recv() 不会阻塞程序,因为通信的文件描述符是就绪的,读缓冲区内已有数据
        • 调用 write() / send() 不会阻塞程序,因为通信的文件描述符是就绪的,写缓冲区不满,可以往里面写数据
  • 对这些文件描述符继续进行下一轮的检测(循环往复。。。)

正文

  • 套接字的普遍套路
    • 非阻塞(它们都不等待事件):
      # 创建socket
      socket    
      # 可选  
      setsocket
      # 绑定 ip 及端口    
      bind
      # 监听  
      listen
      
    • 阻塞:
      accept  
      # 这两个默认是阻塞,可以改成非阻塞  
      recv  
      send
      
  • 引入 select poll epoll的缘由:将一系列可能引起阻塞的操作(比如新连接到来 accpet, 缓冲可读,有数据可读入 recv, 缓冲可写 send)委托内核,让内核做一个限时的(指定timeout)有结果反馈的操作,根据反馈结果再来做相关的处理
    或者简而言之就是select poll epoll 封装了事件驱动
  • pollselect不同
    • select(后面三者文件描述符中取最大的, 读文件描述符, 写.., 异常.., 超时设置)输入,输出都是一种类型(读,写都是指针类型)
      #include <sys/select.h>
      struct timeval {
          time_t      tv_sec;         /* seconds */
          suseconds_t tv_usec;        /* microseconds */
      };
      
      int select(int nfds, fd_set *readfds, fd_set *writefds,
                 fd_set *exceptfds, struct timeval * timeout);
      
    • poll将事件和套接字封装在一起,即
      struct pollfd {
      int   fd;         /* 委托内核检测的文件描述符 */
      short events;     /* 输入 委托内核检测文件描述符的什么事件 */
      short revents;    /* 输出 文件描述符实际发生的事件 -> 传出 */
      };        
      
  • selectpoll的通病
    • 往返内核的数据拷贝,每一次的selectpoll都会与内核发生两次数据拷贝(因为从参数作输入输出)
    • 内部都是线性表组织文件描述符 select最大只能支持 1024 个套接字(小优点:跨平台)
      线性表友好型: 1. 文件描述符少, 2. 文件描述符活跃的多

自然引入epoll,重点是epoll

  • 优点:

    • 基于红黑树,索引较快
      内部既组织了文件描述符的红黑树,还维护了以这些文件描述符上对应的回调事件

    • 内核和用户态之间采用共享内存

    • 能直接应答哪些套接字上发生了哪些事件

    • selectpoll将任务的添加和反馈放在同一个函数上
      epoll任务添加,及反馈获取在不同的函数,代码易于阅读
      而且还能像注册回调函数时一样,传用户数据

      struct epoll_event {
      uint32_t     events;      /* Epoll events */
      epoll_data_t data;        /* User data variable */
      };
      
  • 就是三个函数

    epoll_create 
    epoll_ctl  输入  
    epoll_wait 输出
    
  • epoll_ctl(, , , 第4参数)第4参数是指针类型,它指向的数据将拷贝给内核,因此原数据即使在栈上,也不会崩溃

  • 三者阻塞时长的设置

    • 单位ms
    • -1 表示阻塞
    • 0 表示立即返回(与0时长对应),不阻塞
  • 这些函数引起的错误保存在全局变量errno

    // 打印错误
    perror(errno)  
    
  • epoll默认水平触发,水平触发来得频繁,对资源的消耗也相对大水平与边缘触发是否向上递交事件的触发因素是

    • 水平触发:
      缓冲区是否读干净,是否有残留
      有新的数据到来
    • 边缘触发:
      是否有新数据到来(缓冲区是否为空并不关心)
  • 在边缘模式下,要考虑在一次的触发下将缓冲读干净
    关键就在将recv 设成非阻塞
    以及在非阻塞情况下,根据recv的返回值,和errno的值来判断缓冲是否读完

    int flag = fcntl(cfd, F_GETFL);                                       
    fcntl(cfd, F_SETFL, flag |= O_NONBLOCK);
    
    // 如果 len == -1,且 errno 为 EWOULDBLOCK 或 EAGAIN     
    // 即认定缓冲清空
    len = recv  
    (errno == EAGAIN || errno == EWOULDBLOCK)
    
  • 小技巧,侦听的socket设成水平触发模式,因为侦听比较重要,可能客户端就只发起一次请求

  • 如果像李超老师一样fork子进程,并且是将请求连接的业务和与客户端通信的业务做在一样的子进程,那么当一个新连接到来时将会产生惊群效应(苏炳榲老师处理得比较好,就是将处理连接的业务单独放在一个线程里

  • muduo库用上了eventfd来做进程间同步的手段
    Linux fd 系列 — eventfd 是什么? - 知乎 (zhihu.com)