写点什么

IO 原理(二):多路复用

作者:苏格拉格拉
  • 2022-11-16
    浙江
  • 本文字数:4791 字

    阅读完需:约 16 分钟

前面介绍了 Linux 操作系统下的 IO 原理,Java 标准 IO 以及 NIO 的基本情况。


以下,将对 IO 性能优化进一步深入,主要有三方面:

  • 多路复用:阻塞优化

  • 零拷贝:复制优化

  • PageCache:缓存优化


本篇主要内容是多路复用。

六、多路复用

这里,我们将继续探讨多路复用优化的本质。

1.从 bio 到多路复用

bio 操作 read 时:

线程需要等待数据从网卡复制到内核空间;

然后再将内核空间的数据复制到用户空间;

复制完成后接口返回,期间一直阻塞。


多路复用 nio 操作 read 时(与 bio 相比):

  • 从图中看,只是将一步阻塞拆分成了两步阻塞,但其实区别很大;

  • 第一步的 select 操作一次可以传一批 fd 给内核,让内核进行轮询,当任何一个 socket 中的数据准备好了,select 就会返回;

  • 所以对于前半部分的阻塞,只需要单独安排一个线程等待即可。


以 n 个任务的并发,第 1、2 步分别耗时 50ms 为例:

  • bio 每个任务需要开一个线程执行 100ms,线程资源占用:n100ms;

  • 多路复用 nio 需要开 1 个线程用于 select 管理所有的请求,其他线程用于后续操作,线程资源占用:一个线程+n50ms。


为什么 nio 往往与多路复用同时出现,bio 能不能多路复用,为什么 nio 能多路复用?


我觉得本质就看能不能将网卡->内核缓冲区操作拆分出来,然后在内核缓冲区数据完成的时候以某种形式让应用程序感知。

这就看操作系统提供怎样的能力与接口了,我可以想到有三种实现方式:

  • 1.read 接口非阻塞,内核缓冲区完成才返回成功,让每个连接自行轮询重试;

  • 2.接口阻塞,但是拆分出一个 select/epoll 接口,Kernel 内部重试,有数据后再返回接口;

  • 3.接口非阻塞,通过回调通知。

2.IO 模型

2.1.同步、异步、阻塞、非阻塞

这里有两组概念需要先搞清楚,分别是同步与异步,阻塞与非阻塞。


为简单理解,我们以调用接口为例:

阻塞/非阻塞关注点在调用方,调用接口后,如果 IO 资源没有准备好,那么程序该如何处理的问题:

  • 阻塞:等待

  • 非阻塞:继续执行

同步/异步关注点在被调用方,被调用接口后,如果 IO 资源没有准备好,该如何响应程序的问题:

  • 同步:不响应,直到 IO 资源准备好以后;

  • 异步:返回一个标记,当 IO 资源准备好以后,再用事件机制返回给程序。


以上两组概念进行组合,就会有四种情况,分别为“同步阻塞”、“同步非阻塞”、“异步阻塞”、“异步非阻塞”。在异步的情况下,被调用方都已经可以直接返回了,所以就不需要阻塞了,所以共有“同步阻塞”、“同步非阻塞”、“异步”三种情况。

2.2.组合情况

2.2.1.同步阻塞

这种 case 在前文的 bio 中已经有介绍,这里不再赘述。


优点:程序简单,在阻塞等待数据期间,用户线程挂起。用户线程基本不会占用 CPU 资源。

缺点:一般情况下,会为每个连接配套一条独立的线程,在高并发的场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。


因此,基本上,BIO 模型在高并发场景下是不可用的

2.2.2.同步非阻塞


参照上面的图,只看前半段即可。

即当内核缓冲区没有数据的时候,系统调用会立即返回;客户端可以不断刷新获取最新状态,也可以专门用一个线程来管理所有的状态,多路复用正是利用了类似这种的机制。


这种模型叫做同步非阻塞,我觉得不太恰当,首先非阻塞是可以确定的,但是“同步”就要打个问题号。

既然都已经立即返回了,那就不是再同步执行,而是在异步了,所以应该叫“异步非阻塞”更加好。

只是这个叫法容易和另外一种“异步非阻塞”混淆,那是一种“异步-服务端回调”的机制,而当前这个算是“异步-客户端轮询”的机制。

不过,我们还是继续称之为“同步非阻塞”吧。


优点:每次发起的 IO 系统调用,在内核的等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。

缺点:需要不断的重复发起 IO 系统调用,这种不断的轮询,将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低。

2.2.3.多路复用

相比“同步非阻塞”模型,多路复用不需要客户端不断的轮询,这个操作 Kernel 帮忙做掉了。取而代之的是给了客户端一个阻塞的 select 接口,当 Kernel 轮询到目标时,再将 select 接口返回。


IO 多路复用模型的基本原理就是 select/epoll 系统调用,单个线程不断的轮询 select/epoll 系统调用所负责的成百上千的 socket 连接,当某个或者某些 socket 网络连接有数据到达了,就返回这些可以读写的连接。

因此,好处也就显而易见了:通过一次 select/epoll 系统调用,就查询到到可以读写的一个甚至是成百上千的网络连接。


优点:它可以同时处理成千上万个连接。与一条线程维护一个连接相比,从而大大减小了系统的开销。

缺点:本质上,select/epoll 系统调用,属于同步 IO,也是阻塞 IO。都需要在读写事件就绪后,自己负责进行读写,也就是说这个读写过程是阻塞的。

2.2.3.异步

用户线程通过系统调用,告知 kernel 内核启动某个 IO 操作,用户线程返回。Kernel 内核在整个 IO 操作(包括数据准备、数据复制)完成后,通知用户程序,用户执行后续的业务操作。

  • 数据准备:将数据从网络物理设备(网卡)读取到内核缓冲区;

  • 数据复制:将数据从内核缓冲区拷贝到用户程序空间的缓冲区。


优点:

  • 在内核 kernel 的等待数据和复制数据的两个阶段,用户线程都不是 block (阻塞)的;

  • 不需要手动将数据从内核缓冲区拷贝到用户程序空间的缓冲区,收到通知回调即已完成。


缺点:

  • 需要完成事件的注册与传递,这里边需要底层操作系统提供大量的支持,去做大量的工作。目前来说, Windows 系统下通过 IOCP 实现了真正的异步 I/O。

  • 但是,就目前的业界形式来说,Windows 系统,很少作为百万级以上或者说高并发应用的服务器操作系统来使用。而在 Linux 系统下,异步 IO 模型在 2.6 版本才引入,目前并不完善。所以,这也是在 Linux 下,实现高并发网络编程时都是以 IO 复用模型模式为主。

2.3.总结

同步阻塞 IO 的做法:叫一个线程停留在一个水壶那,直到这个水壶烧开,才去处理其他事情。

非阻塞 IO 的做法:叫一个线程不停的循环观察每一个水壶,根据每个水壶当前的状态去处理。

  • 非阻塞 IO:自己每隔一段时间去看一下水烧开了没

  • 多路复用 IO:找一个人管一片水壶

异步 IO 的做法:每个水壶上装一个开关,当水开了以后会提醒对应的线程去处理。

3.多路复用

3.1.select、poll、epoll

select

原理:


1.把所有要管理的 socket 的 fd 从用户空间传递到内核空间;

2.遍历所有 fd 文件,并将当前进程挂到每个 fd 的等待队列中,当某个 fd 文件设备收到消息后,会唤醒设备等待队列上睡眠的进程,那么当前进程就会被唤醒;

3.如果遍历完所有的 fd 没有 I/O 事件,则当前进程进入睡眠,当有某个 fd 文件有 I/O 事件时,当前进程重新唤醒再次遍历所有 fd 文件。

5.如果发现了有对应的 fd 有读写事件后,内核会把 fd_set 里没有事件状态的 fd 句柄清除,然后把有事件的 fd 返回给应用进程(这里又会把 fd_set 从内核空间复制用户空间)。

6.最后应用进程收到了 select 返回的活跃事件类型的 fd 句柄后,再向对应的 fd 发起数据读取或者写入数据操作。


缺点:

需要遍历 fd,FD 剧增会造成遍历速度慢的“线性下降性能问题”;

被管理的 socket fd 需要从用户空间拷贝到内核空间,为了控制拷贝的大小而做了限制,即每个 select 能拷贝的 fds 集合大小只有 1024。

poll

poll 本质上和 select 没有区别,poll 模型里面通过使用链表的形式来保存自己监控的 fd 信息,正是这样 poll 模型里面是没有了连接限制,可以支持高并发的请求。


poll 解决了连接数限制的问题,但并没有解决轮询、用户/内核数据交换的问题。

epoll

基于以上问题,epoll 做了优化:

  • 为什么每次 select 需要把监控的 fds 传输到内核里,不能在内核里维护个?

  • 为什么 socket 只唤醒了 select,不能顺便告诉它是哪个 socket 来数据了?


1.通过 epoll_create 方法在 Kernel 创建了一个红黑树的根节点,用于存放感兴趣的 fd;

2.通过 epoll_ctrl 方法管理红黑树的节点,实现对监听事件的管理;

3.通过 epoll_wait 方法等待数据。

当有事件发生时网卡驱动会调用 fd 上注册的函数并将该 fd 添加到 rdlist 中,解除阻塞。


  • 对于用户/内核数据交换的问题:每次注册新的事件,使用 epoll_ctrl 进行增量维护,时间复杂度是 O(logn)。不需要每次传递全量的集合,减少了内核和用户空间大量的数据拷贝和内存分配。

  • 对于轮询问题:所有触发的事件都在 rdlist 中维护,不需要再去遍历数组/链表或者树。


整体对比:

3.2.从 Reactor 到 Proactor

前面在 nio selector章节 介绍了 nio 对比 bio 的多路复用优化,这里将继续展开将 reactor 模式讲清楚。

3.2.1.单 reactor 单线程

以上有 Reactor、Acceptor、Handler 三个对象:

  • Reactor 对象的作用是监听和分发事件;

  • Acceptor 对象的作用是获取连接;

  • Handler 对象的作用是处理业务;

对象里的 select、accept、read、send 是系统调用函数;dispatch 和 process 是其中的功能,其中 dispatch 是分发事件操作,process 是处理具体的业务逻辑。


听不懂也没关系,这里以一次简单的网络请求举个例子:

1.Reactor 对象通过 select 方法监听 socket 事件;

2.客户端发起请求,Reactor 收到 select 返回结果,发现是个 connect 请求,于是 dispatch 给 Acceptor 处理;

3.Acceptor 处理这个 connect 请求,与其进行 tcp 三次握手,建立 socket 连接;

4.Reactor 再次收到 select 返回结果,发现是个 read 请求,于是 dispatch 给 Handler 处理;

5.Handler 开始处理,先 read 出请求信息,然后进行业务逻辑处理,最后 send 返回客户端结果


上述操作也可以参照nio selector章节的代码。


这样的设计看着优雅,但也存在两个缺点:

  • 1.单个线程无法充分利用多核 cpu 资源

  • 2.一般服务端应用耗时的地方在 process,这样会造成其他请求阻塞,只适用于业务处理非常快速的场景。


Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

单线程的好处就是不需要考虑进程间通信,也不用担心多进程竞争。

3.2.3.单 Reactor 多线程

上述单 reactor 单线程不适合处理 process 速度过慢的场景,那就在 process 这个地方加上线程池来并发处理。

Handler 只处理 read、send 操作,process 操作外包出去给 Processor 干;

Processor 由外包公司 Thread Pool 管理,可以招好多打工人一起干。


以上通过充分使用多核 cpu,引入多线程,提升了业务的处理能力,但同时也因为多线程共享资源增加了编程的复杂性。


另外,单 Reactor 模式下,Reactor、Acceptor、Handler 都在主线程中,要承担所有事件的监听和响应以及其他很多工作,所以主线程又很容易为性能的瓶颈的地方。

3.2.4.主从 Reactor 多线程

方案详细说明如下:

  • 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;

  • 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。

  • 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。

  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。


也就是说,Main Thread 的 Main Reactor 只负责处理 accept 建立连接,后续的操作都交由 sub Thread 的 sub Reactor 处理;

subReactor 的处理方式同单线程单 reactor 模式的 reactor:需要 select 监听 accept 之外的事件,需要分发任务给 Handler 处理。


Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。

3.2.5.proactor

在 Linux 下的异步 I/O 是不完善的,  aio  系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。


而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是  IOCP ,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。

3.2.多路复用应用

Netty


发布于: 刚刚阅读数: 5
用户头像

还未添加个人签名 2018-08-22 加入

https://github.com/wengyingjian

评论

发布
暂无评论
IO原理(二):多路复用_reactor_苏格拉格拉_InfoQ写作社区