I/O多路复用(select/poll/epoll)

579 阅读9分钟

I/O多路复用(select/poll/epoll)

Socket编程

服务端

  • 服务端首先调用 socket() 函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调用 bind() 函数,给这个 Socket 绑定一个 IP 地址和端口
  • 调用 listen() 函数进行监听,此时对应 TCP 状态图中的 listen,如果我们要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听。
  • 服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。

客户端

  • 创建好 Socket 后,调用 connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号。

  • TCP三次连接。没完全建立连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态;建立连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态;

  • TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。

    tcp_socket

I/O模式

  • 阻塞式:等待数据准备好(文件描述符可读/写)才继续执行

    tcp_socket

  • 非阻塞式:轮询文件描述符,不等待,通过返回错误值提示未就绪,会消耗CPU资源

    非阻塞I/O

  • I/O复用(select、poll、epoll) :阻塞在select、poll、epoll上,等待多个描述符就绪,返回其中一个

    IO复用

  • 信号(SIGIO) :首先开启套接字的信号驱动式I/O功能(并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

    信号IO

  • 异步I/O:上述I/O都是同步I/O。异步I/O工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到用户空间缓冲区)完成后通知。这种模型与信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知何时可以启动一个I/O操作,而异步I/O模型是由内核通知I/O操作何时完成

    异步IO

同步I/O和异步I/O对比

  • 同步I/O操作(synchronous I/O opetation)导致请求进程阻塞,直到I/O操作完成;
  • 异步I/O操作(asynchronous I/O opetation)不导致请求进程阻塞。

IO模式比较

select/poll

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

epoll 通过两个方面,很好解决了 select/poll 的问题。

  • epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  • epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)水平触发(level-triggered,LT)

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。

select实现

该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。

# include <sys/select.h>
# include <sys/time.h>struct timeval{
long tv_sec;   /*seconds*/
long tv_usec;  /*microseconde */
};
​
int select(int maxfdp1, fdset *readset, fdset *writeset, fd_set *exceptset, const struct timeval *timeout);
  • 返回值:

    • 跨所有描述符集的已就绪的总位数。
    • 如果在任何描述符就绪之前定时器到时,那么返回0。
    • 返回-1表示出错(这是可能发生的,譬如本函数被一个所捕获的信号中断)。
  • 参数

    • timeout指定超时时间,timeval结构指定秒数和微秒。设为NULL永远等待;设为0不等待,轮询;其他大于0值,等待一段时间。

    • 中间的三个参数readset、writeset和exceptset指定让内核测试读、写和异常条件的描述符。select函数的中间三个参数readset、writeset和exceptset中,如果对某一个的条件不感兴趣,就可以把它设为空指针。

      void FD_ZERO(fd_set *fdset);         /* clear all bits in fdset */
      void FD_SET(int fd,fd_set *fdset);   /* turn on the bit for fd in fdset */
      void FD_CLR(int fd,fd_set *fdset);   /* trun off the bit for fd in fdset */
      int FD_ISSET(int fd,fd_set *flset);  /* is the bit for fd on in fdset? */
      
    • maxfdp1参数指定待测试的描述符个数,它的值是待测试的最大描述符加1,描述符0,1,2 ... 一直到maxfdp1-1均将被测试。

描述符就绪条件

  • 读就绪

    • 套接字接收缓冲区有数据,对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(读入的字节数)。
    • 该连接的读半部关闭(也就是接收了FIN的TCP连接)。对这样的套接字的读操作将不阻塞并返回0(也就是返回EOF)。
    • 该套接字是一个监听套接字且已完成的连接数不为0,accept不会阻塞。
    • 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这些待处理错误(pending error)也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
  • 写就绪

    • 套接字发送缓冲区有数据,写操作不阻塞且返回一个大于0的值(写入的字节数)
    • 该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号
    • 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终
    • 其上有一个套接字错误待处理。

select处理对端TCP

  • 如果对端TCP发送数据,那么该套接字变为可读,并且read返回一个大于0的值(即读入数据的字节数)。
  • 如果对端TCP发送一个FIN(对端进程终止),那么该套接字变为可读,并且read返回0 (EOF)。
  • 如果对端TCP发送一个RST(对端主机崩溃并重新启动),那么该套接字变为可读,并且read返回-1,而errno中含有确切的错误码。

参考资料

I/O 多路复用:select/poll/epoll

《Unix网络编程卷1》