NIO

作者: 愤怒的老照 | 来源:发表于2021-01-17 15:31 被阅读0次

    0 前提

    互联网服务端典型通信流程如下:


    image.png
    • 客户端请求服务器,服务端操作系统通过网卡将数据读取到内核缓冲区,再将数据从内核缓冲区读取到进程缓冲区
    • 服务端业务处理,处理客户端的请求并构造返回结果,并从进程缓冲区写入系统缓冲区
    • 操作系统将内核缓冲区中的数据写入网卡,通过底层协议发送目标客户端

    1 Unix五种网络编程模型

    首先需要了解同步/异步,阻塞/非阻塞的区别和联系,一般来说,IO操作分为两个阶段:

    • 等待阶段:数据可能来自其他应用程序或网络,如果没有数据,应用程序会阻塞等待。针对此阶段没有数据的情况,应用进程表现为阻塞等待和立即返回,可分为阻塞/非阻塞两种形式。
    • 拷贝数据:将就绪的数据从内核缓冲拷贝到应用程序缓冲区。此阶段表现为用户进程需要等待数据从内核缓冲区拷贝到进程缓冲区还是完全由内核处理后通知,可分为同步/异步

    1.1 阻塞IO

    image.png

    这种通信模型下,用户进程发起IO操作后,必须等待数据准备好,并等待数据从内核缓冲区复制到进程缓冲区中。整体表现为用户进程调用IO操作后一直阻塞,直到数据准备完成。Java BIO就是这种网络模型。

    在这种网络模型下,需要为每个链接单独使用一个线程处理,在高并发的场景下需要维护大量线程,内存、线程的切换开销会变得巨大。

    1.2 非阻塞IO

    image.png

    这种模型下,用户进程发起一个IO操作后可返回做其他事情,但是用户进程需要不断进行系统调用,轮询检查数据报是否准备好,在内核缓冲区有数据的情况下用户进程同步的等待数据复制到进程缓冲区,系统调用返回成功。

    这种模型下需要不断的重复发起IO系统调用,不断的轮询内核,进程上下文切换导致系统利用率较低,所以Java并没有涉及到此类IO模型。

    1.3 IO多路复用

    image.png
    IO多路复用就是一个进程可以监视多个文件描述符,一旦有描述符就绪后,操作系统通知应用程序进行处理,依赖于操作系统的select/poll/epoll系统调用,https://zhuanlan.zhihu.com/p/39970630

    这个过程应用程序就可以同时监听多个IO请求,这比起基于多线程阻塞式IO要先进得多,因为服务器只需要少数线程就可以进行大量的客户端通信。目前主流的高性能处理方案就是采用IO多路复用

    1.4 信号驱动IO

    image.png

    在信号驱动式 I/O 模型中,相比于前三种,实现了在等待数据时,进程不被阻塞,主线程可以继续工作,所以性能更好。

    但是对于TCP链接,此类方式几乎没有被使用,因为SIGIO信号是linus信号,没有附加信息,而TPC socket产生的信号事件头7中,所以应用程序收到SIGIO后,根本无法区分处理

    1.5 异步IO

    image.png

    上面几种通信模型,无论是阻塞,非阻塞,IO复用,本质上都是同步的,因为需要用户进程等待数据从内核空间复制到用户空间,而异步IOP则是实现了真正的非阻塞IO。

    当用户进程发起IO操作,内核会在整个操作完成后通知进程,包括等待/复制两阶段。但是由于编码复杂,操作系统支持情况等原因,实际生产环境很少用到异步IO模型

    2 零拷贝

    2.1 IO中断和DMA

    2.1.1 IO中断

    在没有 DMA 技术前,I/O 的过程需要CPU响应,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的

    image.png

    2.1.2 DMA技术

    在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。


    image.png

    由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

    2.2 传统文件传输

    image.png
    • 期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。

    • 还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的

    我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。

    2.3 mmap + write

    mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。


    image.png

    具体过程如下:

    • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
    • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
    • 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

    我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。

    但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。

    2.4 sendfile

    它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

    image.png

    但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
    于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

    • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
    • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;


      image.png

    2.5 狭义零拷贝和广义零拷贝

    Linux 2.4内核新增 sendfile 系统调用,提供了零拷贝。磁盘数据通过 DMA 拷贝到内核态 Buffer 后,直接通过 DMA 拷贝到 NIC Buffer(socket buffer),无需 CPU 拷贝。这也是零拷贝这一说法的来源。这是真正操作系统 意义上的零拷贝(也就是狭义零拷贝)。随着发展,零拷贝的概念得到了延伸,就是目前的减少不必要的数据拷贝都算作零拷贝的范畴(广义零拷贝);

    2.6 NIO零拷贝

    在Java NIO中,使用了Direct Buffer实现内存广义的零拷贝,
    在网络编程中,通常由 read、write 来完成一次 I/O 读写操作。每一次 I/O 读写操作都需要完成四次内存拷贝,路径是 I/O 设备 -> 内核空间 -> 用户空间 -> 内核空间 -> 其它 I/O 设备。

    而在Java当中,而在 Java 中,在用户空间中又存在一个拷贝,那就是从 Java 堆内存中拷贝到临时的直接内存中,通过临时的直接内存拷贝到内存空间中去。此时的直接内存和堆内存都是属于用户空间。

    为什么在执行网络IO或者文件IO时,一定要通过堆外内存呢?

    • 直接内存是在堆外,申请过多不会引起gc;例:申请一块堆外空间,当内存池去使用,netty 就是这种机制。
    • 在我们写数据的时候,若数据在堆上,则需要从堆上拷贝到堆外,操作系统才可以去操作这个拷贝的数据;若数据在堆外,就少了一次从堆上拷贝到堆外这个阶段了,节省的时间是非常明显的。大家可能比较纳闷为啥我们的操作系统属于内核态 在ring0级别按理说可以访问所有内存,为啥不直接操作堆上的数据,因为Java 有gc,gc 可能不会回收要被写的数据,但是可能会移动它(把已用内存压缩在一边,清除内存碎片),操作系统是通过内存地址去操作内存的,内存地址变了,这些写到文件或者网络里的数据可能并不是我们想要写的数据,也有可能产生很多未知的错误。

    3 Reactor线程模型

    Reactor 模式处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式中有 2 个关键组成:

    • 1)Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。
    • 2)Handlers:处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。
      根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:

    1)单 Reactor 单线程;
    2)单 Reactor 多线程;
    3)主从 Reactor 多线程。

    3.1 单 Reactor 单线程


    image.png

    1)Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发;
    2)如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理;
    3)如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应;
    4)Handler 会完成 Read→业务处理→Send 的完整业务流程。

    优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成。
    缺点:性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。

    3.2 单 Reactor 多线程


    image.png

    1)Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发;
    2)如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后续的各种事件;
    3)如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应;
    4)Handler 只负责响应事件,不做具体业务处理,通过 Read 读取数据后,会分发给后面的 Worker 线程池进行业务处理;
    5)Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理;
    6)Handler 收到响应结果后通过 Send 将响应结果返回给 Client。

    优点:可以充分利用多核 CPU 的处理能力。
    缺点:多线程数据共享和访问比较复杂;Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈。

    3.3 主从 Reactor 多线程


    image.png

    1)Reactor 主线程 MainReactor 对象通过 Select 监控建立连接事件,收到事件后通过 Acceptor 接收,处理建立连接事件;
    2)Acceptor 处理建立连接事件后,MainReactor 将连接分配 Reactor 子线程给 SubReactor 进行处理;
    3)SubReactor 将连接加入连接队列进行监听,并创建一个 Handler 用于处理各种连接事件;
    4)当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应;
    5)Handler 通过 Read 读取数据后,会分发给后面的 Worker 线程池进行业务处理;
    6)Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理;
    7)Handler 收到响应结果后通过 Send 将响应结果返回给 Client。

    优点:父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。

    参考:http://www.52im.net/thread-1939-1-1.html
    参考:https://www.cnblogs.com/xiaolincoding/p/13719610.html

    相关文章

      网友评论

          本文标题:NIO

          本文链接:https://www.haomeiwen.com/subject/oiyaaktx.html