IO多路复用

文章详细介绍了IO多路复用的概念,从阻塞IO到非阻塞IO的演变,并对比了select、poll和epoll三种IO多路复用机制,强调了epoll在效率和性能上的优势。


1 概念

IO多路复用(IO Multiplexing) 是一种同步IO模型,在单个进程/线程内就可以同时处理多个IO请求。一个进程/线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路一般是指网络连接,复用指的是同一个进程/线程。
一个进程/线程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程/线程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。

2. IO多路复用的出现

2.1 阻塞IO

在最初的操作系统中,只有BIO模式,即阻塞IO。通常代码如下:

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

在调用read函数时,线程会被阻塞,这就造成了程序无法响应其他socket的请求连接。为了解决这个问题,很多工程师都采用accept一个操作后,就去创建一个新的线程,在新的线程中去做read操作:

while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  pthread_create(doWork);  // 创建一个新的线程
}
void doWork() {
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

但这并不是真正的非阻塞IO,只不过用了多线程的手段使得主线程没有卡在read函数上而已。操作系统为我们提供的redad函数仍然是阻塞的。流程图如下:
在这里插入图片描述
这就是传统的阻塞 IO。但是,线程资源毕竟是有限的,所以这种方式无法满足需要处理大量IO请求的场景。

2.2 非阻塞IO

后来操作系统提供了非阻塞的 read 系统调用。这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。代码如下:

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

这里我们要注意到一个细节:

非阻塞的 read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。

当数据已到达内核缓冲区,此时调用 read 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。整体流程如下图:
在这里插入图片描述

2.3 IO 多路复用

2.3.1 select

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

//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;

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,检查描述字后立即返回,轮询

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)    // 将给定的描述符从文件中删除  
//selec使用示例
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 <= min(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事件
      }
    }
  }

可以看出几个细节:

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

2.3.2 poll

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

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

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

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

2.3.3 epoll

epoll 是最终的大 boss,它解决了 select 和 poll 的一些问题。

epoll 主要针对三点进行了改进:
1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

操作系统提供了这三个函数:

//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
// 第一步,创建一个 epoll 句柄
int epoll_create(int size); // 内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中
// 第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
// 第三步,类似发起了 select() 调用
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程

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;
}

epoll缺点:
只能在Linux下工作,当然Windows下有更强大的IOCP。


总结

所以,IO 模型的演进,其实就是时代的变化,倒逼着操作系统将更多的功能加到自己的内核而已。

selec	poll	epoll

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

selecpollepoll
数据结构bitmap数组红黑树
最大连接数1024无上限无上限
fd拷贝每次调用selec拷贝每次调用poll拷贝fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝
工作效率轮询O:(n)轮询:O(n)回调:O(1)
### 原理 在传统的阻塞IO模型中,每个IO操作都会导致线程阻塞,直到数据准备完毕或发送完毕,且每个线程只能处理一个IO操作,当面临大量并发连接时,需创建大量线程,消耗大量系统资源。而IO多路复用通过使用操作系统提供的IO复用机制,如select、poll或epoll,可同时监听多个文件描述符上的IO事件,实现高效的IO处理。例如select函数和poll函数模型,进程告诉多路复用器(内核)所有的socket号,多路复用器去获取每个socket的状态,当程序获取到某个socket号有事件发生时,就去该socket号上处理对应的事件,如read事件或者recived事件。不过select函数底层是数组,有最大连接数的限制,poll函数底层是链表,无最大连接数的限制。这两种模型都存在需要遍历所有socket(O(N)复杂度)以及重复传递数据(内核无状态,每次都要从用户态向内核态传递所有的socket号去遍历获取状态)的缺点 [^2][^3]。 ### 应用 IO多路复用广泛应用于需要同时处理大量网络连接的场景中,如高并发服务器、即时通讯系统等。在高并发服务器中,可能会同时有大量客户端连接,使用IO多路复用可以高效地处理这些连接上的IO事件;在即时通讯系统里,也需要同时处理多个用户的消息收发,IO多路复用能提升系统的处理效率 [^1]。 ### 实现方式 常见的实现方式有select、poll和epoll。 - **select**:进程将所有需要监听的socket号告知内核,内核会检查这些socket的状态,当有事件发生时通知进程。但由于其底层使用数组存储socket号,有最大连接数的限制,且每次调用都要将所有socket号从用户态复制到内核态,遍历所有socket,效率较低 [^3]。 - **poll**:与select类似,也是让内核检查多个socket的状态。不同的是,poll底层使用链表存储socket号,没有最大连接数的限制,但同样存在遍历所有socket和重复传递数据的问题 [^3]。 - **epoll**:是一种更为高效的IO多路复用实现方式,它使用事件驱动的机制,只关注有事件发生的socket,避免了遍历所有socket,并且使用内存映射(mmap)技术避免了数据的重复复制,提高了效率。 ### 示例代码(Java中使用NIO模拟简单的IO多路复用) ```java import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; import java.util.Set; public class NioServer { public static void main(String[] args) throws IOException { // 创建选择器 Selector selector = Selector.open(); // 打开监听通道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 绑定端口 serverSocketChannel.socket().bind(new InetSocketAddress(8080)); // 设置为非阻塞模式 serverSocketChannel.configureBlocking(false); // 将通道注册到选择器上,并监听连接事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { // 等待事件发生 int readyChannels = selector.select(); if (readyChannels == 0) continue; // 获取所有就绪的选择键 Set<SelectionKey> selectedKeys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key.isAcceptable()) { // 处理新的连接 ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel(); SocketChannel socketChannel = serverChannel.accept(); socketChannel.configureBlocking(false); // 注册读事件 socketChannel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 处理读事件 SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int bytesRead = socketChannel.read(buffer); if (bytesRead > 0) { buffer.flip(); byte[] data = new byte[buffer.remaining()]; buffer.get(data); System.out.println("Received: " + new String(data)); } } // 移除处理过的键 keyIterator.remove(); } } } } ```
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值