Netty基础学习Day1

929 阅读22分钟

1. 三大I/O概述

BIO,NIO,AIO

1.1 BIO理解

BIO被称为 Blocking I/O,是同步并阻塞型IO,服务器实现模式为一个连接一个线程,我们以client / server 为例, 当Client客户端向 Server发起一个请求,Server就会创建一个线程来处理这个请求,如果该线程没有读取到数据,就会一直阻塞在这,等待客户端发送数据,这会造成不必要的线程开销,当并发大的时候会创建大量的线程,比较占用系统资源。优化方案是使用线程池。

img

BIO是Java传统的IO模型,InputStream,OutputStream等输入输出流都是基于BIO,问题也比较明显,即:一个请求一个线程比较占用资源,另外当read()不到数据时线程会阻塞,导致性能达不到最优,所以BIO适用于并发连接少的业务场景。优点是程序简单容易理解。

1.2 NIO理解(Netty基于这个)

NIO是non-blocking I/O 或者 New IO ,是同步非阻塞的IO模型,实现模式是一个线程处理多个请求,客户端发送的请求都会交给多路复用器去处理,多路复用器采用轮询机制,轮询到连接有IO请求就创建线程进行处理。NIO采用非阻塞机制,当线程读取不到客户端发送的数据也不会阻塞,这样该线程就可以做其他的事情。

img

BIO 和 NIO 的区别

  1. BIO以流的方式处理数据,NIO以块(buffer)的方式处理数据,块的IO效率高于流的IO效率
  2. BIO是阻塞IO,NIO是非阻塞IO
  3. BIO使用字符流,或者字节流进行操作,NIO基于channel通道和buffer缓冲区进行操作
  4. BIO适用于并发低的业务场景,NIO适用于并发高的业务场景

1.3 AIO理解

在进行 I/O 编程中,常用到两种模式:Reactor和 Proactor,Java 的 NIO 就是 Reactor ,AIO采用 Proactor 模式。

AIO: NIO2.0,或称为AIO(Asynchronous I/O),是异步非阻塞IO是对NIO的增强,AIO 引入异步通道的概念,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。

目前 AIO 还没有广泛应用,Netty 也是基于NIO, 而不是AIO,所以这里不做过多讲解。

1.4 三种IO对比

img

2. NIO的三大核心

img

Java NIO有三个核心的组件:

  1. selector 选择器 ,
  2. channel 通道
  3. buffer 缓冲区

1️⃣Selector 多路复用器

选择器,也叫多路复用器,Java的NIO通过selector实现一个线程处理多个客户端链接,多个channel可以注册到同一个Selector,Selector能够监测到channel上是否有读/写事件发生,从而获取事件和对事件进行处理,所以Selector切到哪个channel是由事件决定的。当线程从某个客户端通道未读取到数据时,可以把空闲时间用来做其他任务,性能得到了提升。

2️⃣Channel 通道

channel 通道是双向的,channel 可以往buffer写入数据,同时channel 也可以从buffer读取数据,它可以同时进行读写。它和BIO中的stream流很像,区别是stream流只能单向读或者写,而NIO总的channel可以双向读写,常用的Channel类有

  • FileChannel:主要用于文件的IO操作
  • DatagramChannel :主要用于 UDP 的数据读写
  • ServerSocketChannelSocketChannel :用于TCP数据读取

3️⃣Buffer 缓冲区

buffer主要是和channel通道做数据交互,Channel 提供从文件或网络读取数据的渠道,但是数据读取到一个它稍后处理的buffer中,实现了IO的非阻塞。每个channel通道都会对应一个buffer,buffer是一个内存块,底层有一个数组,NIO的buffer可以写如数据,也可以从中读取数据。每个channel通道都会对应一个buffer,buffer是一个内存块,底层有一个数组,NIO的buffer可以写入数据,也可以从中读取数据。

在Java中封装了很多基于buffer的类,如:

  • ByteBuffer: 存储字节数据到缓冲区
  • ShortBuffer: 存储字符串数据到缓冲区
  • CharBuffer: 存储字符数据到缓冲区
  • IntBuffer: 存储整数数据到缓冲区
  • LongBuffer: 存储长整型数据到缓冲区
  • FloatBuffer: 存储小数到缓冲区
  • DoubleBuffer: 存储小数到缓冲区
  • MappedByteBuffer: 基于内存操作文件

Buffer 的理解和使用

blog.csdn.net/u014494148/…

3. Buffer的使用

3.1 ByteBuffer的简单实用

@Test
public void byteBufferTest(){
	//创建一个容量 1024 的bytebuffer
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    //存储元素
    byteBuffer.putChar('哈');
    byteBuffer.putInt(123);
    byteBuffer.putShort((short) 123);

    //读写转换
    byteBuffer.flip();
    
    //获取元素
    System.out.println(byteBuffer.getChar());   //哈
    System.out.println(byteBuffer.getInt());    //123
    System.out.println(byteBuffer.getLong());   //BufferUnderflowException 缓冲区溢出异常
}

使用buffer是需要注意,如果put的数据类型,和get是使用的类型不一致,可能会出现BufferUnderflowException 缓冲区溢出异常

3.2 写数据到文件

这个案例是通过Java把一段字符串写到磁盘的某个文件,它的大概流程示意图如下:

img

实现步骤如下:

  1. 把数据写入一个ByteBuffer缓冲区
  2. 创建一个FileOutputStream 输出流,目的是磁盘的一个文件
  3. 通过FileOutputStream得到FileChannel通道
  4. 调用channel.write,把ByteBuffer中的数据写入FileChannel,从而写到磁盘文件
    //使用NIO向磁盘写一个文件
    @Test
    public void nioWriteTest() throws IOException {
        //文件输出流
        FileOutputStream fileOutputStream = new FileOutputStream("G:/1.txt");
        //获取通道
        FileChannel channel = fileOutputStream.getChannel();
        //构建一个 容量1024字节长度的缓冲取
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        System.out.println(byteBuffer.getClass().getName());
        //给buffer写入数据
        byteBuffer.put("你好NIO".getBytes());
        //读写转换
        byteBuffer.flip();
        //把buffer中的数据通过通道写入初盘文件
        channel.write(byteBuffer);
        //关闭通道
        channel.close();
        //关闭输出流
        fileOutputStream.close();
    }

3.3 使用NIO完成文件拷贝

这个案例是通过NIO实现文件拷贝,它的大概流程示意图如下:

img

实现步骤如下:

  1. 创建一个FileInputStream,目的是读取磁盘的某个文件
  2. 通过FileInputStream得到FileChannel
  3. 创建一个ByteBuffer用来接收数据
  4. 调用 channel.read 把数据写入bytebuffer
  5. 创建FileOutputStream,目的是把数据写到另外一个文件
  6. 通过FileOutputStream得到FileChannel通道
  7. 调用channel.write,把ByteBuffer中的数据写入FileChannel,从而写到磁盘文件

实现代码如下:

	//文件拷贝 1.txt 中的内容拷贝到2.txt
    @Test
    public void nioCopyTest() throws IOException {

        //文件对象
        File file = new File("g:/1.txt");
        //文件输入流
        FileInputStream fileInputStream = new FileInputStream(file);
        //得到通道
        FileChannel intChannel = fileInputStream.getChannel();

        //文件输出流
        FileOutputStream fileOutputStream = new FileOutputStream("g:/2.txt");
        //获取通道
        FileChannel outChannel = fileOutputStream.getChannel();

        //缓冲区,容量为file的长度
        ByteBuffer byteBuffer = ByteBuffer.allocate((int)file.length());

        while(true){

            //每次读取需要把缓冲区复位,否则当bytebuffer总的position等于limit的时候,
            //read的返回值是 0 ,用于不会走-1,就会死循环
            byteBuffer.clear();

            //把数据读取到缓冲区
            int readLenth = intChannel.read(byteBuffer);
            System.out.println("redLength = "+readLenth);
            //读取结果长度为-1说明读完了
            if(readLenth == -1){
                break;
            }
            //缓冲区读写交换
            byteBuffer.flip();
            outChannel.write(byteBuffer);
        }
        //关闭通道
        intChannel.close();
        //关闭流
        fileInputStream.close();
        //关闭通道
        outChannel.close();
        //关闭流
        fileOutputStream.close();
    }

3.4 使用transferFrom拷贝文件

FileChannel提供了 transferFrom方法可以实现通道和通道之间的数据拷贝,方法包括三个参数:

public abstract long transferFrom(ReadableByteChannel src,
                                      long position, long count)throws IOException;
  • src : 源通道,即从哪个通道拷贝数据
  • position :拷贝的开始位置; 必须是非负数
  • count : 要拷贝的最大字节数; 必须是非负数

实现代码如下:

 //文件拷贝 1.txt 中的内容拷贝到2.txt
  @Test
  public void nioCopyTest2() throws IOException {

      //读操作=================================================================================
      //文件对象
      File file = new File("d:/1.txt");
      //文件输入流
      FileInputStream fileInputStream = new FileInputStream(file);
      //得到通道
      FileChannel inputChannel = fileInputStream.getChannel();

      //文件输出流
      FileOutputStream fileOutputStream = new FileOutputStream("d:/2.txt");
      //获取通道
      FileChannel outChannel = fileOutputStream.getChannel();
      //使用transferFrom拷贝数据,将inputChannel中数据拷贝到outChannel
      outChannel.transferFrom(inputChannel, 0, inputChannel.size());

      outChannel.close();
      inputChannel.close();
      fileInputStream.close();
      fileOutputStream.close();
  }

3.5 HeapByteBuffer只读buffer的使用

HeapByteBuffer,只读Buffer,只允许从中读数据,不允许写数据,否则抛出ReadOnlyBufferException异常,案例如下:

    /**
     * 只读buffer
     */
    @Test
    public void nioOnlyRead() throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        for (int i = 0 ; i < 10 ; i++){
            byteBuffer.putInt(i);   //0123456789
        }

        //读写转换
        byteBuffer.flip();

        //得到一个只读buffer ,使用的是java.nio.HeapByteBufferR
        ByteBuffer readOnlyBuffer = byteBuffer.asReadOnlyBuffer();

        //java.nio.HeapByteBufferR
        System.out.println(readOnlyBuffer.getClass().getName());

        while(readOnlyBuffer.hasRemaining()){
            System.out.print(readOnlyBuffer.getInt());  //0123456789
        }

        readOnlyBuffer.putInt(10);  //ReadOnlyBufferException ,不允许写
    }

3.6 MappedByteBuffer 的使用

nio中引入了一种基于MappedByteBuffer操作大文件的方式,其读写性能极高,nio中引入了一种基于MappedByteBuffer操作大文件的方式,其读写性能极高,它可以基于内存实现文件的修改,这里的内存指的是“堆外内存”。

我们来做过案例,使用MappedByteBuffer来修改一个文本内容:"helloworld"把 h和w修改为大写。

@Test
 public void mappedByteBuffer() throws IOException {
     //随机访问文件,RW代表支持而读写,文件内容为 :helloworld
     File file = new File("d:/3.txt");
     RandomAccessFile randomAccessFile = new RandomAccessFile(file,"rw");

     //通道
     FileChannel channel = randomAccessFile.getChannel();

     //得到MappedByteBuffer : mode:读写模式, position: 映射区域的起始位置 size: 映射区域大小
     MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, file.length());
     //第1个字节修改为 大 H
     mappedByteBuffer.put(0,(byte)'H');
     //第6个字节修改为 大 W
     mappedByteBuffer.put(5,(byte)'W');
     randomAccessFile.close();
 }

4. 理解Selector 和 Channel

Selector 选择器,也叫多路复用器,可以同时处理多个客户端连接,多路复用器采用轮询机制来选择有读写事件的客户端链接进行处理。

  1. 通过 Selector ,一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。
  2. 由于它的读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。

工作原理

img

**Selector :多路复用器(也叫选择器)**的作用就是提供给SocketChannel通道来注册,然后Selector会轮询的去监听通道通道的读写事件从而做出相应的IO处理,它 的工作流程如下:

  1. 首先需要创建一个ServerSocketChannel,它类似于(ServerSocket)需要指定一个监听的IP和Port。需要注意的是ServerSocketChannel为了兼容BIO,默认是阻塞的,可以通过ServerSocketChannel#configureBlocking(false)来指定为NIO模式。

    通过ServerSocketChannel 可以获取一个客户端的SocketChannel, 客户端的SocketChannel 需要注册到Selector上, 然后每个通道都会对应一个SelectionKey

  2. **选择器可以通过Selector.open() 创建,然后将 ServerSocketChannel 注册给Selector。**选择器的 Selector#select() 方法,可以选择有事件的通道(SocketChannel ),并返回已就绪的通道数量。事件类型包括:“连接”,“接收” ,“读”,“写”。

  3. 如果 Selector#select() 返回值大于0代表某些通道有事件发生,可以通过 selector.selectedKeys() 来得到所有有事件通道的SelectionKey。

    然后可以通过SelectionKey方向拿到SocketChannel,从而将SocketChannel的数据读取到Buffer中,完成IO操作。

1️⃣ServerSocketChannel

ServerSocketChannel是服务端用来用来监听客户端Socket链接,通过accept方法可以获取客户端SocketChannel,从而将 SocketChannel 注册到Selector

相关方法如下:

  • open : 创建一个 ServerSocketChannel 通道
  • bind(SocketAddress local):设置服务器端监听的地址和端口号
  • configureBlocking(boolean block) : 设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
  • accept() :接受一个连接,返回代表这个连接的通道对象SocketChannel
  • register(Selector sel, int ops) :把当前通道注册到选择器,并设置监听事件

2️⃣SocketChannel

一个客户端链接服务端就会产生通道:ServerChannel,需要注册到Selector,被Selector监听通道的读写事件。ServerChannel负责具体的读写,把缓冲区的数据写入通道,或者把通道中的数据写入缓冲区。

相关方法如下:

  1. open() : 得到一个 SocketChannel 通道
  2. configureBlocking(boolean block) :设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
  3. connect(SocketAddress remote):连接远程服务器,通过SocketAddress 指定IP和端口
  4. finishConnect(): 如果connect连接失败,就要通过finishConnect方法完成连接操作
  5. write(ByteBuffer src) : 把ByteBuffer中的数据,往通道里写
  6. read(ByteBuffer dst) :从通道里读数据,写入ByteBuffer
  7. register(Selector sel, int ops, Object att) : 把该通道注册到selector,并设置监听事件(OPS),最后一个参数可以设置共享数据,该方法会返回一个 SelectionKey ,这个key对应了该通道。
  8. close() :关闭通道

3️⃣SelectionKey

当 ServerChannel 注册到Selector就会产生SelectionKey,通过SelectionKey可以反向获得SocketChannel通道对象,从而进行IO读写操作。

相关方法如下:

  1. **selector() ** : 通过SelectionKey 获取得到与之关联的 Selector 对象
  2. channel():得到与之关联的通道
  3. attachment():得到与之关联的共享数据
  4. interestOps(int ops):设置或改变监听事件
  5. isAcceptable():是否可以 accept,“接收就绪”事件
  6. isReadable():是否可以读
  7. isWritable():是否可以写

事件包括:

	SelectionKey.OP_CONNECT : 值 16,连接就绪 ,比如:一个 channel连接到一个服务器
	SelectionKey.OP_ACCEPT : 值 8,接收就绪,比如:ServerSocketChannel准备好接入新的连接
	SelectionKey.OP_READ :值 1,“读就绪”,通道有可以读数据可以说是
	SelectionKey.OP_WRITE : 值 4 ,“写就绪” ,等待写数据的通道可以说

4️⃣Selector

负责采用轮询方式监听通道,当通道有读写事件就进行IO操作。

  1. open() :得到一个选择器对象
  2. select(long timeout):选择有事件的通道,将其对应的 SelectionKey 加入到内部集合中,返回通道的数量
  3. selectedKeys() : 从内部集合中得到所有有事件 SelectionKey
  4. keys() : 从内部集合中得到所有SelectionKey
  5. close():关闭选择器

使用案例

使用selector ,ServerSocketChannel,SocketChannel完成一个 C/S 案例

服务端代码

    //通道
    @Test
    public void serverSocketChannelTest() throws IOException {

        //创建服务端通道
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

        //socket监听地址和端口
        SocketAddress socketAddress = new InetSocketAddress("127.0.0.1",5000);

        //和某个SocketAddress绑定,设置服务器监听的地址和端口号
        serverSocketChannel.bind(socketAddress);

        //启动NIO        默认采用阻塞,为了兼容BIO
        serverSocketChannel.configureBlocking(false);

        //创建选择器
        Selector selector = Selector.open();

        //将通道注册到选择器,事件类型为:OP_ACCEPT “接受”
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        //==选择器轮询=======================================================================

        while(true){
            //select,选择有事件的通道,返回有事件发生通道的key的个数  ,超时时间 1s
            if(selector.select(1000) == 0){
                System.out.println("无连接...轮询等待...");
                continue;
            }
            //有事件发生,得到有事件的通道的key的集合
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            //遍历key的集合
            while (iterator.hasNext()){
                //拿到每个通道的key
                SelectionKey key = iterator.next();

                //如果当前通道事件是: OP_ACCEPT ,就注册通道
                if(key.isAcceptable()){
                    //接收一个socketChannel
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    System.out.println("客户端链接成功...");
                    // 设置非阻塞模式
                    socketChannel.configureBlocking(false);
                    //把socketChannel注册到选择器 ,并给通道绑定一个buffer
                    socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                }

                //如果通道事件是: OP_READ,说明通道有数据
                if(key.isReadable()){
                    //通过key得到SocketChannel
                    SocketChannel channel = (SocketChannel)key.channel();
                    //得到channel绑定的buffer
                    ByteBuffer byteBuffer = (ByteBuffer)key.attachment();
                    //从通道把数据读出数据写入到buffer
                    channel.read(byteBuffer);

                    System.out.println(new String(byteBuffer.array()));
                }
                //删除当前key
                iterator.remove();
            }
        }
    }

客户端代码

    //通道
    @Test
    public void socketChannelTest() throws IOException {
        //创建一个SocketChannel
        SocketChannel socketChannel = SocketChannel.open();

        //使用非阻塞模式
        socketChannel.configureBlocking(false);

        //链接的地址和端口
        InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 5000);
        //尝试链接,如果使用的异步,那么需要使用 socketChannel.finishConnect() 来确保连接成功。
        if(!socketChannel.connect(inetSocketAddress)){
            //如果没链接成功,会通过while循环,直到 finishConnect 链接成功,跳出while
            while(!socketChannel.finishConnect()){
                System.out.println("还未完成链接...等待中...");
            }
        }

        //链接成功,把数据写出去
        socketChannel.write(ByteBuffer.wrap("你好".getBytes()));
        System.out.println("向服务端发送数据...");
        //防止客户端结束,所以使用read()阻塞
        System.in.read();
    }

image-20220707151814431

资料来源地址:

blog.csdn.net/u014494148/…

5. 内核空间和用户空间

内核空间和用户空间 操作系统的核心是内核,可以访问受保护的内存空间,也有访问底层硬件设备的权限,为了避免用户进程直接操作内核,操作系统将虚拟内存划分为内核空间(Kernel-space)和 用户空间(User-space)

  • 内核空间

    内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的

  • 用户空间

    每个用户进程都有一个独立的用户空间,处于用户态的进程不能访问内核空间中的数据和调用内核函数 ,因此要进行系统调用的时候,就要将进程切换到内核态。

6. DMA传输原理

DMA (Direct Memory Access):DMA的意思是直接内存访问,它允许外围设备(硬件子系统)直接访问系统主内存。有了 DMA之后,系统主内存 与 硬盘或网卡之间的数据传输可以绕开 CPU 的全程调度,大大解放了CPU的劳动力,下面我们来理解一下DMA

6.1 传统 IO流程

我们针对下面案例来分析一下IO的执行流程

RandomAccessFile randomAccessFile = new RandomAccessFile(new File("file.txt"),"rw");

byte[] arr = new byte[(int)file.length()];

//读
randomAccessFile.read(arr);

//把数据写到Sokcet
Socket socket = new ServerSocket(5555).accept();

//写
socket.getOutputStream().write(arr);

上面的案例完成了一次读写操作

先是从磁盘读取 file.txt 文件,内容存储到 byte[]中

然后把 byte[]中的数据写到socket 。

那么在没有DMA的情况下IO是如何工作的呢?

img

  1. 用户进程向 发起 read操作,用户进程由用户态切换为内核态,然后一直阻塞等待数据的返回。
  2. CPU 在接收到指令以后对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区。
  3. 接下来由CPU将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区。
  4. 用户进程由内核态切换回用户态,解除阻塞状态,程序继续执行。

由于整个IO过程都需要CPU亲力亲为,在数据的拷贝是非常消耗CPU性能的,为了提升IO性能出现了DMA技术。

6.2 DMA IO流程

下面以读取文件数据到内存为例来演示 DMA 原理:

img

  1. 应用进程发起read命令, 调用CPU 读取数据,此时CPU会将用户进行从用户态切到内核态,程序线程一直阻塞等待数据的返回。
  2. CPU向 DMA 磁盘控制器发起调度指令。
  3. DMA 磁盘控制器向磁盘请求IO,将磁盘数据先放入磁盘控制器缓冲区,CPU 不参与此过程。
  4. 后续DMA收到完成指令, 将数据从磁盘控制器缓冲区拷贝到内核缓冲区。
  5. DMA 磁盘控制器向 CPU 发出数据读完的信号,由CPU 负责将数据从内核缓冲区拷贝到用户缓冲区。
  6. 最后切换回用户态,返回数据,解除阻塞, 程序继续往后执行。

所以为什么要出现DMA呢?

如果没有DMA,那么所有的拷贝操作都需要CPU的参与,拷贝数据非常消耗CPU资源,导致整体系统性能下降。所以DMA的出现解放了CPU,使得系统性能得到提升。

经过上面的流程,数据已经读取到用户缓冲区,接下来执行 write 向网络发送数据,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(socket buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送,流程如下:

img

解释一下图中的步骤:

  1. 用户进程通过 write() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  2. CPU 将用户缓冲区(user buffer)中的数据拷贝到内核空间(kernel space)的网络缓冲区(socket buffer)。
  3. CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
  4. 上下文从内核态(kernel space)切换回用户态(user space),write 系统调用执行返回。

6.3 DMA的问题

img

下面是内核切换完整流程:

  1. 用户进程执行read,从用户态切到内核态
  2. DMA控制器将数据从硬盘拷贝到内核缓冲区
  3. CPU将内核缓冲区的数据拷贝到用户空间的用户缓冲区
  4. 上下文从内核态切回用户态,read 调用执行返回。

DMA拷贝虽然一定程度解放了CPU,但是涉及到的内核切换次数和数据拷贝次数太多,依然不能让IO性能达到最优。

7. 零拷贝技术

零拷贝(Zero-copy)技术指在计算机执行操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域,从而可以减少上下文切换以及 CPU 的拷贝时间。

它的作用是在数据报从网络设备到用户程序空间传递的过程中,减少数据拷贝次数,减少系统调用,实现 CPU 的零参与,彻底消除 CPU 在这方面的负载

也就是说所谓的零拷贝是消除CPU拷贝,但是DMA拷贝肯定是需要的。

1️⃣MMAP模式

使用 MMAP 的目的是将内核中缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,这样在进行网络传输时,就可以减少内核空间到用户空间的拷贝,大致流程如下:

img

然而内核读缓冲区(read buffer)仍需将数据拷贝到内核写缓冲区(socket buffer), 整个拷贝过程会发生 4 次内核切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。

  1. 用户进程调用 mmap 函数,用户进程从用户态切到内核态
  2. 将用户进程的内核缓冲区与用户缓存区进行内存地址映射
  3. DMA 控制器将数据从主存或硬盘拷贝到内核缓冲区
  4. 上下文从内核态切回用户态mmap 系统调用结束
  5. 用户进程调用 write 函数,上下文从用户态切换为内核态
  6. CPU 将内核缓冲区的数据拷贝到网络缓冲区(SocketBuffer)
  7. CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。
  8. 上下文从内核态切换回用户态,write调用结束

MMAP的问题是 4次内核切换,3次数据拷贝,拷贝次数和切换次数依然很多。

2️⃣Sendfile模式

Sendfile在Linux2.1被引入 ,Sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数通。过 Sendfile 数据可以直接在内核空间内部进行 I/O 传输,也就是说数据直接通过内核缓冲区(Kernel Buffer)拷贝到Socket缓冲区(Socket Buffer), 数据根部不经过用户空间,对于用户来说数据是不可见的。

img

基于 Sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝

  1. 用户进程执行 sendfile,上下文从用户态切换为内核态
  2. DMA 控制器将数据从主存或硬盘拷贝到内核缓冲区
  3. CPU 将内核缓冲区中的数据拷贝到的网络缓冲区
  4. CPU 利用 DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。
  5. 上下文从内核态切换回用户态Sendfile 结束

Sendfile模式只需要2次内核态的切换,数据拷贝次数还是3次,它的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程

3️⃣Sendfile+DMA 优化

在Linux2.4 对Sendfile进行了优化 ,它将内核缓冲区中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中 。

也就是说它实现了将内核缓冲区中的数据直接拷贝到网卡设备,省去了内核缓冲区数据拷贝到网络缓冲区的过程,彻底消除了CPU拷贝0。

img

整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝。

  1. 用户进程调用 sendfile 上下文从用户态切换为内核态
  2. DMA 控制器将数据从主存或硬盘拷贝到内核缓冲区
  3. CPU 把内核缓冲区中的文件描述符和数据长度拷贝到网络缓冲区(socket buffer)。
  4. 基于已有的文件描述符和数据长度,DMA 控制器直接批量地将数据从内核缓冲区拷贝到网卡进行数据传输。
  5. 上下文从内核态切换回用户态Sendfile 执行结束

这种方式用户程序依然不能对数据进行修改的问题,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。

4️⃣Splice

Linux 在 2.6.17 版本引入 Splice 系统调用 , 它通过在内核缓冲区和网络缓冲区之间建立通道(pipeline),来避免了两者之间的 CPU 拷贝操作。

img

整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。

  1. 用户进程调用 splice 函数,从用户态切换为内核态。
  2. DMA 控制器将数据从主存或硬盘拷贝到内核缓冲区。
  3. CPU 在内核缓冲区和网络缓冲区之间建立管道(pipeline)。
  4. DMA 控制器将数据从网络缓冲区拷贝到网卡进行数据传输。
  5. 上下文从内核态切换回用户态Splice 调用结束

Splice 拷贝的问题是用户程序同样不能对数据进行修改。

缓冲区共享

它的思想是为每个进程都维护着一个缓冲区,这个缓冲区池能被同时映射到用户空间和内核态,内核和用户共享这个缓冲区池,这样就避免了一系列的拷贝操作。就目前而言缓冲区共享并不是一个非常成熟的方案,这里也不进行探讨。

总结

零拷贝在数据进行IO时,对性能的影响是非常大的,零拷贝不是不拷贝,而是以消除CPU拷贝,减少拷贝,减少内核切换次数来提升IO性能为目的。

下面是各种零拷贝技术的对比

拷贝模式函数CPU拷贝次数DMA拷贝次数内核切换次数
传统IOread/write224
mmapmmap/write124
sendfilesendfile122
sendfile优化sendfile022
splicesplice022