【Netty4核心原理②】【Java I/O 演进之路】

一、前言

本系列虽说本意是作为 《Netty4 核心原理》一书的读书笔记,但在实际阅读记录过程中加入了大量个人阅读的理解和内容,因此对书中内容存在大量删改。

本篇涉及内容 :第二章 Java I/O演进之路


本系列内容基于 Netty 4.1.73.Final 版本,如下:

 <dependency>
    <groupId>io.netty</groupId>
     <artifactId>netty-all</artifactId>
     <version>4.1.73.Final</version>
 </dependency>

系列文章目录:
【Netty4核心原理】【全系列文章目录】

二、I/O 交互流程

通常用用户进程中的一次完整IO交互流程分为两个阶段:首先经过内核空间,也就是由操作系统处理类;紧接着就是到用户空间,也就是交由应用程序。具体交互流程如下:
在这里插入图片描述

内核空间中存放内核代码合数据,而进程的用户空间存放的是用户程序的代码和数据。不管是用户空间还是内核空间,他们都处于虚拟空间中, Liunx 有两层保护机制:0级工内核(Kernel) 使用,3级供用户程序使用。每个进程都有各自的私有用户空间(0 - 3G),这个空间对系统中的其他集成是不可见的。最高的1G字节虚拟内核空间则为所有进程和内核共享。

操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,两者不能简单地使用指针传递数据,必须通过系统调用请求 Kernel 来协助完成 IO操作。

对一个输入设备来说,进程 IO 系统调用后,内核会先看缓冲区中有没有相应的缓存数据,如果没有再到设备中读取。因为设备 IO 一般速度比较慢,需要等待,内核缓冲区有数据则直接复制到进程空间。所以一个网络输入操作通常包括如下两个阶段:

  1. 等待网络数据到达网卡,然后将数据读取到内核缓冲区。
  2. 从内核缓冲区复制数据,然后拷贝到用户空间。

三、五种 I/O 通信模型

在网络环境下,通俗来说可以将 IO 分为两步:第一步是等待,第二步是数据搬迁。

如果想要提高 IO 效率,需要将等待时间和数据搬迁时间降低,针对于数据搬迁时间的降低是通过零拷贝(详见下文)技术实现。为了将等待时间降低,发展出了下面五种 IO 模型。

在介绍模型之前,先介绍两个概念:

  1. 同步和异步 :同步和异步其实是指 CPU 时间片的利用,主要看请求发起方对消息结果的获取是主动发起还是被动通知的。如果是请求方主动发起的,一直在等待应答结果(同步阻塞),或者可以先去处理其他事情,但要不断轮询查看发起的请求是否有应答结果(同步非阻塞),因为不管如何都要发起方主动获取消息结果,所以形式上还是同步操作。如果是由服务方通知的,也就是请求方发出请求后,要么一直等待通知(异步阻塞),要么先去干自己的事(异步非阻塞),当事情处理完成后,服务方会主动通知请求方他的请求已经完成,这就是异步。异步通知一般是通过状态改变,消息通知或者回调函数来完成,大多数时候采用的都是回调函数。
  2. 阻塞和非阻塞 :阻塞和非阻塞在计算机的世界里通常指的是针对 IO 的操作,简单来说,当我们调用一个函数后,在等待这个函数返回结果之前,当前线程是出于挂起状态还是运行状态。如果是挂起状态就意味着当前线程什么都不能做,只能等着获取结果,这就是同步阻塞;如果仍然是运行状态,就意味着当前线程可以继续处理其他任务,但要时不时看下是否有结果了,这就是同步非阻塞。

1. 阻塞 IO 模型

阻塞IO 模型的通信过程示意图如下:
在这里插入图片描述

当用户进程调用了 recvfrom后,内核就开始了 IO 的第一个阶段:准备数据。对于网络IO来说,很多时候数据在一开始还没有到达(如还未收到一个完成的 UDP包),这个时候内核就要等待足够的数据到来。而用户进程这边整个进程则会被阻塞,当数据准备好时,他就会将数据从内核拷贝到用户内存,然后返回结果,用户进程才会解除阻塞状态,重新运行起来。

阻塞 IO 模型特性总结如下:

特点在 IO 执行的两个阶段(等待数据和拷贝数据)都被阻塞
典型应用阻塞 Socket、Java BIO
优点进程阻塞挂起不消耗 CPU 资源;实现难度低,开发难度低;适合并发量小的网络应用开发
缺点不适合并发量大的应用;需要为每个请求分配一个处理线程,系统开销大

2. 非阻塞 IO 模型

非阻塞IO模型的通信过程示意如下图:
在这里插入图片描述

当用户进程发出 read 操作时,如果内核中的数据还没准备好,那么他并不会阻塞用户进行,而是立即返回一个 error。从用户进程的角度来说,他发起一个 read 操作后并不需要等待,而是直接就得到一个结果,用户进程判断结果是一个 error 时,他就知道数据还没准备好,于是他可以再次发送 read 操作(取决于用户逻辑的具体实现),一旦内核中的数据准备好了,并且再次收到了用户进程的系统调用,那么他就会马上将数据拷贝到用户内存,然后返回,非阻塞型接口相比于阻塞接口的显著差异在于在调用后立即返回。

非阻塞IO模型特性总结如下:

特点用户进程需要不断地主动询问内核(Kernel)数据是否准备好
典型应用Socket 设置 NON_BLOCK
优点实现难度低,开发应用相对于 阻塞IO 模型较难
缺点进程轮询调用,消耗CPU 资源;适合并发量较小且不需要及时响应的网络应用开发。

3. 多路复用 IO 模型

多路复用 IO 模型的通信过程示意图如下:
在这里插入图片描述

多个进程的 IO 可以注册到一个复用器(Selector)上,当用户进程调用该 Selector,Selector 会监听注册进来的所有 IO,如果Selector 监听的所有 IO 在内核缓冲区都没有可读数据,select 调用进程会被阻塞,而当任意一个 IO 在内核缓冲区中有可读数据时,select 调用就会返回,而后 select 调用进程可以自己或通知另外的进程(注册进程)再次发起读取IO,读取内核中准备好的数据,多个进程注册IO后,只有一个 select 调用进程被阻塞。

多路复用 IO 相对阻塞IO和非阻塞IO 更复杂一些。多路复用IO模型和阻塞IO模型并没有太大区别,事实上还要更差一些,因为这里需要使用两个系统调用(select 和 recvfrom),而阻塞IO模型只有一次系统调用(recvfrom)。但是用 Selector 的优势在于他可以同时处理多个连接,所以如果处理的连接数不是很多的花,使用 select/poll 的 Web Server 不一定比使用多线程加阻塞 IO 的 Web Server 性能更好,可能延迟还更大, select/poll 的优势并不是对于单个连接处理得更快,而是能处理更多的连接。

多路复用IO 模型的特性总结如下:

特点对于每一个 Socket,一般都被设置为非阻塞,但是整个用户的进程其实是一直被阻塞的,只不过进程是被 select 函数阻塞,而不是被 Socket IO 阻塞
典型应用Java NIO, Nginx(epoll、poll、select)
优点转义进程解决多个进程IO的阻塞问题,性能好,Reactor 模式;适合高并发服务应用开发,一个进程/线程响应多个请求
缺点实现和开发应用难度较大

4. 信号驱动 IO 模型

信号驱动 IO 模型的通信过程示意图如下:
在这里插入图片描述

信号驱动 IO 时指进程预先告知内核,向内核注册一个信号处理函数,然后用户进程返回不阻塞,当内核数据就绪时会发送一个信号给进程,用户进程便在信号处理函数中调用 IO读取数据,从上图可以看出,实际上 IO 内核拷贝到用户进程的过程还是阻塞的,信号驱动 IO 并没有实现真正的异步。

信号驱动IO 模型的特性总结如下:

特点并不符合异步IO要求,只能算作是伪异步,实际中并不常用
典型应用应用场景较少
优点应用较少,不做总结
缺点实现和开发应用难度大

5. 异步 IO 模型

异步 IO 模型的通信过程示意图如下:
在这里插入图片描述
用户进程发起 aio_read 操作后,给内核传递与 read 相同的描述符、缓冲区指针、缓冲区大小三个参数以及文件便宜,告诉内核当整个操作完成时,如何通知我们立刻就可以开始去做其他的事;而另一方面,从内核角度,当他收到一个 aio_read 之后,首先他会立刻返回,所以不会对用户进程产生任何阻碍,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个信号,告诉他 aio_read 操作完成。

异步IO的工作机制是:告知内核启动某个操作,并让内核在整个操作完成后通知我们,这种模型与信号驱动 IO 模型的区别在于,信号驱动IO模型是由内核通知我们合适可以启动一个 IO操作,这个IO 操作由用户自定义的信号函数来实现,而 异步 IO 模型由内核告诉我们 IO操作何时完成。

异步 IO 模型的特性总结如下:

特点真正实现了异步IO,是五种 IO 模型中唯一的一步模型
典型应用Java 7 AIO,高性能服务器应用
优点不阻塞,数据一步到位,采用 Proactor 模式;非常适合高性能、高并发应用
缺点需要操作系统的底层支持,Linux 2.5 内核首先,Linux2.6 产品的内核标准特性;实现和开发难度较大

6. 各 IO 模型的对比和总结

严格来说,前四种 IO 模型都是 同步 IO 操作,他们的区别在于第一阶段,而第二阶段是一样的,在数据从内核拷贝到应用缓冲区期间(用户空间),进程阻塞于recvfrom 调用。

这里有个地方需要注意: NIO 在执行 recvfrom 的时候,如果内核(Kernel)的数据没有准备好,这时候不会阻塞进程,但是当内核(Kernel)拷贝到用户内存中,这个时候进程就被阻塞了,在这段时间进程是会被阻塞的。

各IO 模型的阻塞状态对比如下:

在这里插入图片描述

从上图可以看出,阻塞程度 : 阻塞 IO > 非阻塞 IO > 多路复用 IO > 信号驱动 IO > 异步 IO。效率是由低到高的,下表进行了具体总结:

属性同步阻塞 IO伪异步IO非阻塞 IO(NIO)异步 IO
客户端数 :IO 线程数1:1M:N(M>=N)M:1M:0
阻塞类型阻塞阻塞阻塞非阻塞
同步同步同步同步(多路复用)异步
API 使用难度简单简单复杂一般
调试难度简单简单负责负责
可靠性非常差
吞吐量

四、BIO 到 NIO 的演进

BIO 和 NIO 之间的主要差异如下:

IO 模型BIONIO
通信面向流面向缓冲区
处理阻塞 IO非阻塞 IO
触发选择器

1. 面向流 和 面向缓冲区

Java NIO 与 BIO 第一个最大的区别是 BIO 是面向流的,NIO 是面向缓冲区的。Java BIO 面向流意味着每次从流中读一个或多个字节,直至读取所有的字节,他们没有被缓存在任何地方。此外,不能前后移动流中的数据,如果需要前后移动,就需要先将他缓存到一个缓冲区。

Java NIO 的缓冲导向方法略有不同,数据读取到他一个稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程的灵活性,但是还需要检查该缓冲区中是否包含所有需要处理的数据,而且要确保更多数据读入缓冲区时不能覆盖缓冲区中尚未处理的数据。

2. 阻塞与非阻塞

Java BIO 的各种流是阻塞的,这意味着当一个线程调用 read() 或 write() 时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情。Java NIO 的非阻塞模型是一个线程从某通道(Channel)发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有可用数据,就什么都不会获取,而不是保持线程阻塞,所以知道数据变成可以读取之前,该线程可以继续做其他事情。非阻塞也是如果,一个线程请求写入某通道一些数据,但不需要等待他完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以一个单独的线程可以管理多个 IO 通道

3. 选择器在 IO 中的应用

Java NIO 的选择器(Selector)允许一个单独的线程监视多个输入通道,可以注册多个通道使用一个选择器,然后使用一个单独的线程来 “选择” 通道:这些通道里已经有多个可以处理的输入,或者选择已准备写入的通道。这种选择机制是一个单独的线程更容易管理多个通道。

4. NIO 与 BIO 影响的程序设计

无论选择 BIO 还是 NIO ,都可能会影响程序设计的下面几个方面:

4.1 API 调用

BIO 与 NIO 的 API 调用不同,因为不同的选择会使用不同的 API

4.2 数据处理

4.2.1 BIO 的处理

在 BIO 中,我们是逐字节读取,如下读取 demo.txt 文件内容并打印。

    /**
     * bio 读取
     * @throws IOException
     */
    public static void bioRead() throws IOException {
    	// demo.txt 文件内容 : abcdefg
        try (FileInputStream inputStream = new FileInputStream("D://demo.txt")) {
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            String content = reader.readLine();
            // 输出 :content = abcdefg
            System.out.println("content = " + content);
        }
    }

这里需要明白,一旦当 reader.readLine(); 方法返回,我们就知道一行文本肯定已经读完,因为 readLine() 方法会阻塞直到整行读完。

4.2.2 NIO 的处理

在 NIO 中,我们是从缓冲区中读取数据,如下:

    /**
     * nio 读取
     * @throws IOException
     */
    public static void nioRead() throws IOException {
    	// demo.txt 文件内容 : abcdefg
        try (FileInputStream inputStream = new FileInputStream("D://demo.txt")) {
            FileChannel channel = inputStream.getChannel();
            ByteBuffer readBuffer = ByteBuffer.allocate(5);
            // 通道中数据写入到缓冲区中
            channel.read(readBuffer);
            // 转换读写模式
            readBuffer.flip();
            // 将缓冲区中的数据写入到字节数组中
            byte[] content = new byte[readBuffer.remaining()];
            readBuffer.get(content);
            // 输出 :content = abcde
            System.out.println("content = " + new String(content));
        }
    }

这里需要注意,由于缓冲区的大小只有 5 字节,因此无法保存 demo.txt 文件中的全部内容,导致输出的数据并不是 demo.txt 的全部内容。因此,即使当 channel.read(readBuffer); 方法返回结果,我们也不能确定程序一定可以被有效处理。针对这种粘包现象,可以自定义协议格式、固定消息长度等方式来解决。

4.3 设置处理线程数

NIO 可以只使用一个或几个单线程管理多个通道(网络连接或文件),但付出的代价是解析数据可能会比从一个阻塞流中读取数据更复杂。

如果需要管理同时打开的成千上万个连接,这些连接每次都只是发送少量数据,使用 NIO 服务器可能是一个优势。但如果仅仅是少量连接但是使用了非常高的带宽,一次性发送大量数据,使用 BIO 的效率可能会好一些。

五、 AIO 实例

JDK 1.7(NIO2)才是实现真正的异步AIO、把 IO 读写操作完全交给了操作系统。

1. AIO 基本原理

Java AIO 处理 API 中,重要的三个类分别是 AsynchronousServerSocketChannel (服务端)、AsynchronousSocketChannel(客户端)以及 CompletionHandler(用户处理器)。CompletionHandler 接口实现应用程序向操作系统发起 IO 请求,当完成后处理具体逻辑,否则做自己该做的事情,“真正”的异步IO 需要操作系统更强的支持。

在多路复用IO模型中,事件循环将文件句柄的状态时间通知给用户线程,由用户线程自行读取数据、处理数据。而在异步 IO 模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区中,内核在 IO 完成后通知用户线程直接使用即可。异步 IO 模型使用 Proactor 设计模式实现了这一机制。

2. AIO Demo

下面给出一个 AIO 的 Demo

  1. 服务端代码

    public class AIOServer {
        public static void main(String[] args) throws IOException {
            new AIOServer();
        }
    
        public AIOServer() throws IOException {
            this.listen();
        }
    
        private void listen() throws IOException {
            ExecutorService executorService = Executors.newCachedThreadPool();
            AsynchronousChannelGroup threadGroup = AsynchronousChannelGroup.withCachedThreadPool(executorService, 1);
            AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(threadGroup);
    
            server.bind(new InetSocketAddress(8080));
            System.out.println("服务器启动, 端口 8080");
    
            server.accept(null, new CompletionHandler<>() {
                final ByteBuffer buffer = ByteBuffer.allocate(1024);
    
                @SneakyThrows
                @Override
                public void completed(AsynchronousSocketChannel result, Object attachment) {
                    try (result) {
                        System.out.println("IO 操作成功,开始获取数据");
                        buffer.clear();
                        // 读取 客户端发送数据
                        result.read(buffer).get();
                        // 切换读写模式
                        buffer.flip();
                        byte[] data = new byte[buffer.remaining()];
                        buffer.get(data);
                        String clientMsg = new String(data);
                        System.out.println("客户端消息 : " + clientMsg);
    
                        // 回复客户端消息
                        result.write(ByteBuffer.wrap(("callback : " + clientMsg).getBytes()));
                        // 切换回读模式,以便下一次接收消息
                        buffer.flip();
                    } finally {
                        // 继续注册监听下次客户端消息
                        server.accept(null, this);
                    }
                    System.out.println("IO 操作完成");
                }
    
                @Override
                public void failed(Throwable exc, Object attachment) {
                    System.out.println("IO 操作失败, " + exc.getMessage());
                }
            });
            // 挂起当前线程
            ThreadUtil.sleep(Integer.MAX_VALUE);
        }
    }
    
  2. 客户端代码

    public class AIOClient {
    
        private AsynchronousSocketChannel client;
    
        public static void main(String[] args) throws IOException {
            new AIOClient().connect("localhost", 8080);
        }
    
        public AIOClient() throws IOException {
            this.client = AsynchronousSocketChannel.open();
        }
    
        public void connect(String host, int port) {
            client.connect(new InetSocketAddress(host, port), null, new CompletionHandler<Void, Object>() {
                @Override
                public void completed(Void result, Object attachment) {
                    client.write(ByteBuffer.wrap("测试数据发送".getBytes()));
                    System.out.println("数据已经发送到服务器");
                }
    
                @Override
                public void failed(Throwable exc, Object attachment) {
                    exc.printStackTrace();
                }
            });
    
            ByteBuffer buffer = ByteBuffer.allocate(1024);
    
            client.read(buffer, null, new CompletionHandler<>() {
                @Override
                public void completed(Integer result, Object attachment) {
                    System.out.println("IO 操作完成 " + result);
                    System.out.println("获取反馈结果 " + new String(buffer.array()));
                }
    
                @Override
                public void failed(Throwable exc, Object attachment) {
                    exc.printStackTrace();
                }
            });
    
            ThreadUtil.sleep(Integer.MAX_VALUE);
        }
    }
    
  3. 输出内容

    1. 服务端输出:
      在这里插入图片描述

    2. 客户端输出
      在这里插入图片描述

六、补充内容

1. 零拷贝

本部分内容来源: 原来 8 张图,就可以搞懂「零拷贝」了,强烈推荐阅读原文。(由于原文写的非常清除,所以大部分内容直接搬运了,侵删


1.1 DMA

DMA 即 直接内存访问(Direct Memory Access) 技术。他的作用是在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。

在 DMA 技术出现前,I/O 的过程是这样的:

  1. CPU 发出对应的指令给磁盘控制器,然后返回;
  2. 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;
  3. CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。

过程如下图:

在这里插入图片描述可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。

而 DMA 控制器则接手了数据从磁盘控制器拷贝到内核缓冲区的过程,如下图:
在这里插入图片描述

具体过程如下:

  1. 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
  2. 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
  3. DMA 进一步将 I/O 请求发送给磁盘;
  4. 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
  5. DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务;
  6. 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
  7. CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

1.2 传统的文件拷贝

传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。

为了防止 OS本身以及关键数据遭到应用程序有意或无意的破坏,通常将 CPU 执行状态分为用户态和系统态(内核态)。

  1. 系统态:也被成为内核态、管态。运行在此状态的 CPU 具有较高的特权,能执行一切指令,可以访问所有寄存器以及存储区。传统的OS都在系统态运行,微内核 OS 只有微内核运行在系统态
  2. 用户态: 也称为目态。运行在此状态的CPU具有较低的特权,仅能执行规定的指令,访问指定的寄存器与存储区。应用程序只能在用户态运行,不能执行OS执行以及访问 OS区域。微内核 OS 中的服务进程(服务器)也是运行在用户态。

简单来说就是为了权限安全,应用程序不能拥有全部的数据权限,关键数据权限只能由系统态持有并执行,防止遭到应用程序的破坏。

传统的文件拷贝一般会需要两个系统调用:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

这两次系统调用过程如下:
在这里插入图片描述

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

上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:
第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。

这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。

所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。

1.3 如何实现零拷贝

零拷贝技术实现的方式通常有 2 种:

  • mmap + write
  • sendfile

mmap : 直接内存映射,Linux提供的mmap系统调用, 它可以将一段用户空间内存映射到内核空间, 当映射成功后, 用户对这段内存区域的修改可以直接反映到内核空间;同样地, 内核空间对这段区域的修改也直接反映用户空间。正因为有这样的映射关系, 就不需要在用户态(User-space)与内核态(Kernel-space) 之间拷贝数据, 提高了数据传输的效率,这就是内存直接映射技术。

JDK1.4加入了NIO机制和直接内存,目的是防止Java堆和Native堆之间数据复制带来的性能损耗,此后NIO可以使用Native的方式直接在 Native堆分配内存

1.3.1 mmap + write

在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
在这里插入图片描述
具体过程如下:

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

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

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

1.3.2 sendfile

在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。

其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
在这里插入图片描述

但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:

$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
所以,这个过程之中,只进行了 2 次数据拷贝,如下图:
在这里插入图片描述

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。

2. select、poll、epoll 的发展

本部分内容来源 :小白也看得懂的 I/O 多路复用解析(超详细案例),推荐观看原文。


2.1 select

IO 多路复用中的 select 函数定义如下:
在这里插入图片描述

上面函数入参释义如下:

  1. nfds :3个监听集合的文件描述符最大值 +1 , 目的是告诉 OS 当前操作需要监听的最大文件描述符大小,OS 只会检查不大于 nfds 的文件描述符的事件。
  2. readfds、writefds、exceptfds 三个是为Long 类型数组,存储数据是 BitMap。一个 Long 占 8个字节,8*8=64位,16个 Long 最长可以描述 1024个 FD。
  3. timeout : 最长等待时间,为0 表示不等待 ,为 -1 表示不超时,一直等待到有就绪事件再返回。
2.1.1 select 的工作流程
  1. 我们假设有四个socket 客户端与服务端建立连接,此时有四个 fd,如下:
    在这里插入图片描述

  2. 当用户空间调用 select 函数时,用户线程会阻塞直至有文件就绪。而系统会首先将这个四个 fd 拷贝到内核空间,由内核空间来遍历这四个fd 是否就绪。如下:
    在这里插入图片描述

  3. 当内核空间检查到某个 fd 已经就绪,他会将这个 fd 打上一个标记,并返回给用户空间一个值,这个返回值是 fd 的就绪数量。如下图
    在这里插入图片描述

    如果遍历了一遍之后发现四个 fd 都没有就绪,则会将当前用户线程阻塞起来,当客户端向服务端发送数据时,数据会通过网络传输来到达服务端的网卡,网卡会通过 DMA 的方式将这个数据包写入到指定内存中,处理完成后会通过中断信号通知 CPU 有新的数据包到达,CPU 收到中断信号后进行响应中断,调用中断的处理程序进行处理(根据 数据包的 IP 和端口号找到对应的socket,然后将这个数据保存到这个 socket 的一个接收队列,然后再检查这个 socket 对应的一个等待队列里是否有进行正在阻塞等待,如果有则会唤醒该进程,用户线程唤醒后会继续检查一遍 fd 列表,如果有 fd 就绪则会返回就绪数量。)之所以不直接在内核空间重复遍历而采取阻塞唤醒机制是因为重复遍历会消耗较大CPU资源。

  4. 此时用户空间知道有几个 fd 事件就绪,但是并不知道是哪个 socket 上的 fd,因此用户空间需要遍历 fd 来找到就绪的 fd。

    在调用 select 函数时,传入了三个 fd_set 参数,fd_set 参数在入参和出参时的意义不同,入参的参数代表要监听那些 fd,出参时被select 修改为那些 fd 已经就绪。fd_set 底层结构是 long 类型的位图,入参时为 1 标识监听该 fd,返回时所有的值被重置为 0,如果返回时的值为 1 则说明该 fd 已经就绪。
    因为 fd_set 的长度限制,所以 select 监听的 fd 做到只能为 1024 个。

综上:select 是将 socket 是否就绪检查下沉到操作系统层面,避免大量系统调用。调用返回时会告知程序有事件就绪,但不会告知具体是哪个 FD。

  • 优点:不需要每个 FD 都进行一次系统调用,解决了频繁的用户态内核切换问题
  • 缺点:
    • 单进程监听的 FD 存在限制,默认最大 1024;
    • 每次调用需要将 FD 从用户态拷贝到内核态;
    • 不知道具体哪个文件描述符就绪,需要遍历全部文件描述符;
    • 入参的三个 fd_set 集合每次调用都需要重置。

2.2 poll

poll 函数的入参定义如下:
在这里插入图片描述

poll 函数的逻辑基本与 select 相同,主要是优化了 监听FD 数量最大 1024 的限制,

  • 优点:不需要每个 FD 都进行一次系统调用,导致频繁的用户态内核态切换。(但在调用时仍需要切换一次)
  • 缺点:
    • 每次调用需要将 FD 从用户态拷贝到内核态;
    • 不知道具体哪个文件描述符就绪,需要遍历全部文件描述符;

2.3 epoll

epoll 函数的定义如下:
在这里插入图片描述

2.3.1 epoll 的工作流程
  1. 当调用 epoll_create 函数时会创建一个 epoll, epoll 底层数据结构是一个 eventpoll,整体结构如下图, 其中有三个重要参数

    • wq : 等待队列,调用时没有发现事件则会将进程进行阻塞并且将这个进程关联到 wq 中,当后续有事件来临时可以唤醒进程
    • rdllist : 就绪列表,保存已经有就绪事件的 fd 列表
    • rbd : 红黑树结构,将需要监听的事件的文件描述符通过红黑树的形式存储

    在这里插入图片描述

  2. 当调用 epoll_ctl 函数时,会将fd 拷贝到内核空间,并且会封装成 epitem 结构保存到 rbr 中。整体结构如下图, epitem 除了对应的fd 参数外还有四个参数:

    • rbn 用于关联到rbr 红黑树
    • ffd关联到rdllist 就绪列表
    • ep 关联到 eventpoll 对象
    • pwqlist 等待队列会关联到一个回调函数 ep_poll_callback, 当 epitem 对应的fd 有就绪事件时会通过 ep_poll_callback 来唤醒对应的进程
      在这里插入图片描述
  3. 通过 epoll_ctl 继续添加需要监听的fd,会形成一个红黑树结构,如下:
    在这里插入图片描述

  4. 调用 epoll_wait 时会去检查就绪队列 rdllist , 如果就绪队列为空,则会将调用 epoll_wait 的当前进行进行一个封装,添加到等待队列中,这个进程会让出 CPU 进入阻塞状态。
    在这里插入图片描述
    如果就绪队列不为空则直接返回就绪事件,而不会再阻塞当前进程,如下图:
    在这里插入图片描述

  5. 如果客户端此时有数据发过来,数据会通过网络传输来到达服务端的网卡,网卡会通过 DMA 的方式将这个数据包写入到指定内存中,处理完成后会通过中断信号通知 CPU 有新的数据包到达,CPU 收到中断信号后进行响应中断,调用中断的处理程序进行处理(根据 数据包的 IP 和端口号找到对应的socket,然后将这个数据保存到这个 socket 的一个接收队列,然后通过这个 socket 找到对应的epitem 中的回调函数ep_poll_callback )
    在这里插入图片描述

  6. ep_poll_callback 会将当前 socket 对应的 epitem 添加到就绪队列中,随后会唤醒等待队列 wq 中的等待进程,唤醒后的等待进程会判断 就绪列表 rdllist 中是否有就绪事件,如果有就绪事件则直接返回给用户空间,如下图:
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

总结:高效处理高并发下的大量连接,同时具备非常优异的性能。

  • 优点 :
  • 缺点 :
    • 跨平台性不能够好,支持 Liunx
    • 相较于 epoll, select 更轻量,可移植性更强
    • 在监听连接数和事件较少的场景下, select 更优。
  1. epoll 如何解决的文件拷贝以及 FD 的遍历?
  • epoll 直接在内核态维护了一个红黑树,将FD直接维护在了内核态中,解决了每次监听某个 FD 都需要拷贝到内核态
  • epoll 通过就绪列表可以直接知道哪些 FD 是就绪的,不需要在用户空间再去遍历 FD。
  1. 水平触发(LE)和边缘触发(ET)
  • LT :Level-triggered,水平触发,默认。 epoll_wait 检测到事件后,如果该事件没被处理完毕,后续每次 epoll_wait 调用都会返回该事件。
  • ET :Edge-triggered,边缘触发。epoll_wait 检查到事件后,只会在当次返回该事件,不管该事件是否被处理完毕。

2.4 总结

目前流行的多路复用IO的实现主要包括四种 :select、poll、epoll、kqueue,具体介绍如下表:

复用模型相对性能关键思路操作系统缺点Java支持
selectReactorWin/Linux1.单进程监听FD 限制 1024;2. 遍历才能得知哪个FD 就绪;3.每次调用 FD需要从用户态拷贝到内核态; 4. 入参三个 fd_set 每次调用都需要重置支持, Reactor 模式 。Linux Kernel 2.4 之前默认使用 select;当前win下对同步IO的支持都是 select 模型
poll较高ReactorLinux1. 遍历才能得知哪个FD 就绪;2. 每次调用 FD需要从用户态拷贝到内核态;Linux 下的 Java NIO 框架,Linux Kernel 2.6 之前使用 poll 进行支持,也是使用 Reactor 模式
epollReactor/ProactorLinux1. 相较于 epoll, select 更轻量可移植性更强;2. 在监听连接数和事件较少时 select 可能更优Linux Kernel 2.6 及之后使用 epoll 支持,之前则使用 poll 支持;需要注意 Linux 下没有 win 下的 IOCP 技术提供真正的异步 IO 支持,所以 Linux 下使用 epoll 模拟异步IO
kqueueProactorLinux不支持

七、参考内容

  1. 《Netty4 核心原理》
  2. 豆包
  3. 原来 8 张图,就可以搞懂「零拷贝」了
  4. 小白也看得懂的 I/O 多路复用解析(超详细案例)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猫吻鱼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值