到底什么是IO多路复用?

目录

一、阻塞IO

二、非阻塞 IO

三、IO 多路复用

 1、select

2、poll

3、epoll

 四、总结一下

一、阻塞IO

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

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

这段代码会执行得磕磕绊绊,就像这样。

可以看到,服务端的线程阻塞在了两个地方,一个是 accept 函数(accept 函数: 是用来建立链接,经历了三次握手之后成功建立链接),一个是 read 函数。

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

这就是传统的阻塞 IO。

整体流程如下图。

所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 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,再开始处理业务。

这里我们注意到一个细节。

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

当数据已到达内核缓冲区,此时调用 read 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。

整体流程如下图

也就是说这不是真正意义上的非阻塞IO,也就是我们常见的NIO,NIO也被称为同步非阻塞IO,

现在常用的NIO, 是NIO+IO多路复用(IO Multiplexing)的结合体

三、IO 多路复用

I/O多路复用是指通过一种机制,让单个进程可以同时处理多个I/O事件,而不需要通过多线程或多进程来实现。这种机制可以提高网络应用程序的并发性能和响应速度。

在Linux下,常见的I/O多路复用机制有select、poll和epoll。

为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。

当然还有个聪明的办法,我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。

fdlist.add(connfd);

然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。

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

这样,我们就成功用一个线程处理了多个客户端连接。

你是不是觉得这有些多路复用的意思?

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

所以,还是得恳请操作系统老大,提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历(而不是在用户态调用,再陷入到内核态中去遍历),才能真正解决这个问题

 1、select

          原理是在内核中维护一个文件描述符集合,通过调用select函数来监控这个集合中的文件描述符是否有可读、可写或异常等事件发生。当有事件发生时,select函数会返回,并将就绪的文件描述符返回给应用程序。     

        select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中

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

 总结下select有以下几个缺点:

  • 文件描述符集合大小有限,一般默认是1024
  • 每次调用select函数都会发生文件描述符集合在用户态和内核态间的拷贝,效率较低。
  • 如果同时处理大量的文件描述符,select的效率会变得非常低。

2、poll

        poll也是Unix下的I/O多路复用机制之一,与select类似,它的原理是在内核中维护一个文件描述符集合,通过调用poll函数来监控这个集合中的文件描述符是否有可读、可写或异常等事件发生。当有事件发生时,poll函数会返回,并将就绪的文件描述符返回给应用程序。

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

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

3、epoll

        epoll是Linux下的新一代I/O多路复用机制,它采用了事件驱动(Event-Driven)方式,可以处理大量的文件描述符,并且只需遍历那些发生事件的文件描述符。这使得epoll具有更高的效率和更低的延迟。

epoll的工作原理:

epoll的工作原理是在内核中维护一个红黑树和一个双向链表。

红黑树用于存储要监控的文件描述符,它的每个节点都对应一个文件描述符,每个节点中存储有文件描述符的相关信息,比如是否可读、是否可写等等。

双向链表用于存储就绪的文件描述符及其对应的事件类型,当某个文件描述符就绪时,它会被加入到这个链表中,等待应用程序调用epoll_wait函数来处理。

epoll_wait函数在等待就绪的文件描述符时,实际上是将红黑树中所有就绪的文件描述符节点取出来,然后将它们对应的文件描述符和事件类型存储到双向链表中,最后将链表中的数据返回给应用程序。

由于红黑树的查找和插入操作的时间复杂度都是O(logN),所以epoll能够高效地处理大量的文件描述符。同时,由于双向链表只用于存储就绪的文件描述符,所以它的长度通常比较短,而且每次只需要遍历一次就能够获取所有就绪的文件描述符,因此epoll的性能也比较高。

总结一下:epoll 通过两个方面解决了 select/poll 的问题。

  • epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。
  • epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率

epoll 具体工作流程:

  • 通过epoll_create创建epoll对象,此时epoll对象的内核结构包含就绪链表和红黑树,就绪队列是用于保存所有读写事件到来的socket。红黑树用于保存所有待检测的socket。
  • 通过 epoll_crt 将待检测的socket,加入到红黑树中,并注册一个事件回调函数,当有事件到来的之后,会调用这个回调函数,进而通知到 epoll 对象。
  • 调用 epoll_wait 等待事件的发生,当内核检测到事件发生后,调用该socket注册的回调函数,执行回调函数就能找到socket对应的epoll对象,然后会将事件加入到epoll对象的绪队列中,最后将就绪队列返回给应用层

epoll的优点:

  • 没有文件描述符集合大小的限制,支持大量的文件描述符。
  • 只需要在初始化时拷贝一次文件描述符集合到内核态,效率比select和poll更高。
  • 可以处理大量的文件描述符,无论是活跃的还是不活跃的。
  • 可以处理多个事件类型,包括可读、可写、异常等。
  • 支持边缘触发和水平触发两种模式。

 四、总结一下

一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 IO

为了破这个局,程序员在用户态通过多线程来防止主线程卡死。

后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是 非阻塞 IO

但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。

后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用

多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。

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

如果你建立了这样的思维,很容易发现网上的一些错误。

比如好多文章说,多路复用之所以效率高,是因为用一个线程就可以监控多个文件描述符。

这显然是知其然而不知其所以然,多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。

就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值