likes
comments
collection
share

写篇文章来证明我零拷贝算是入门了

作者站长头像
站长
· 阅读数 6

👈👈👈 欢迎点赞收藏关注哟

一. 前言

Netty 之所以快有很多原因,但是零拷贝一定是其中必不可少的一环。

对于这一块我一直一知半解,这里来尝试学清楚。

零拷贝的目的很简单,主要是为了减少不必要的数据复制操作,这里的复制其实主要指的是 用户态内核态的数据转换。

零拷贝是什么?

零拷贝又叫 Zero-Copy , 其用法是在网络文件处理过程中,不需要将文件拷贝到用户空间,而是直接在内核空间中传输到网络中。

从原理上,零拷贝分为内核空间的处理和文件的处理。在内核层面主要是为了避免用户空间到内核空间的数据传输

而在文件层面,主要是为了避免内存缓冲区到文件的传输

本文概括 :

  • 第二节 : 会梳理零拷贝的相关概念,其中最核心的就是零拷贝的几种实现方式
  • 第三节 : 主要来分析 Netty 中如何实现的零拷贝,已经我们日常使用这有什么可以借鉴的

二. 零拷贝涉及哪些知识点

2.1 关于内核态和用户态

简单点说 用户的应用通常是处在用户态中的,当需要调用系统硬件资源的时候,用户态不具有那么高的权限,此时就需要切换到内核态进行资源的控制和管理。

  • S1 : 当一个请求来临后 ,首先会通过 Socket 底层组件进行硬件交互
  • S2 : 然后传到到 Socket 缓冲区 , 此时处在内核态中
  • S3 : 然后再从缓冲区复制到对应的用户态中,此时至少会完成2次拷贝操作

写篇文章来证明我零拷贝算是入门了

2.2 DMA 技术

DMA 叫直接内存访问,DMA 可以 让外部设备(硬盘,网口)在没有 CPU 干预的情况下,直接访问内存。

数据的传输是需要CPU介入的。 例如先从外部设备读取到 CPU 寄存器,再由寄存器到内存。

2.3 零拷贝的实现方案

  • 方案一 : 基于虚拟内存
    • 原理 : 多个虚拟内存指向同一个物理地址,这样应用缓冲区和内核缓冲区可以映射到一个地方。
    • 效果 : 当两个缓冲区映射为一个后,就可以避免用户态和内核态的相互复制
  • 方案二 : mmap/write方式
    • 原理 : memory map 可以将文件的内容映射到进程的地址空间 , 可以不通过IO(Read/write)直接读取这个映射区域
    • 效果 : mmap 可以用于文件映射,共享内存,匿名内存映射

写篇文章来证明我零拷贝算是入门了

  • 方案三 : sendfile 方式
    • 前提 :需要系统支持,例如 Unix 系统
    • 原理 :直接在内核中进行数据传输,不需要从内核缓冲区复制到用户缓冲区
    • 效果 : 和 mmap 一致,本质上是简化了这个过程

写篇文章来证明我零拷贝算是入门了

  • 方案四 : 带有 scatter/gather 的 sendfile方式

    • 原理 : 在方案三的基础上去掉了内存缓冲区和Socket缓冲区的复制 , 通过内存映射的方式将两者关联
    • 效果 : 可以减少一次 CPU Copy 过程
  • 方案五 : Slice 方式

    • 原理 :在两个文件描述符之间进行数据传输,而无需在用户空间和内核空间之间复制数据
    • 效果 :主要是分割出逻辑切片,该切片(一个新的缓冲区)会与原始缓冲区共享相同的底层数据

写篇文章来证明我零拷贝算是入门了

三. Netty 对零拷贝的使用

好了,基础的东西就不深入了,想看得更详细的可以看看参考文档里面的推荐。

下面开始深入理解 Netty 的零拷贝 : Netty 中有以下几种方式实现了零拷贝的方法 :

  • 特性一 : ByteBuf 可以使用直接内存对 Socket 进行读写,避免了与缓冲区之间的拷贝
  • 特性二 : ByteBuf 普遍支持 slice 方法,让一个 ByteBuf 分解为多个共享存储的 ByteBuf ,实现零拷贝
  • 特性三 : Netty 通过 CompositeByteBuf 将多个 ByteBuf 合并成一个逻辑上的 ByteBuf ,减少了不同 ByteBuf 之间的拷贝过程
  • 特性四 : Netty 通过 DefaultFileRegion 来实现和文件系统的零拷贝,底层实现为 sendfile
  • 特性五 : 为了更高效的使用零拷贝,Netty 中还实现了很多方法,例如 Wrap : 其目的在于将数组 直接包装ByteBuf , 这样就可以避免转换数据的拷贝过程

3.1 通过一个案例来看怎么实现 sendfile

public static void main(String[] args) {

    String filePath = "path/to/your/file.txt";
    String host = "localhost";
    int port = 8080;

    try {
        // S1 : 打开文件通道
        FileChannel fileChannel = new FileInputStream(filePath).getChannel();

        // S2 : 打开套接字通道
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress(host, port));

        // S3 : 使用 transferTo 进行零拷贝的文件到套接字传输
        long transferredBytes = fileChannel.transferTo(0, fileChannel.size(), socketChannel);

        System.out.println("Transferred bytes: " + transferredBytes);
    } catch (Exception e) {
        System.out.println("执行异常");
    }

}

以上就是一个常见的 sendFile 的处理流程,在 transferTo 方法中会调用 sendFile 进行处理 :

写篇文章来证明我零拷贝算是入门了

DefaultFileRegion 源码逻辑

写篇文章来证明我零拷贝算是入门了

可以看到,最终通过 DefaultFileRegion 会发起对 FileChannelImpl 的调用,在调用终点即为 Native 方法 :

private native long transferTo0(FileDescriptor var1, long var2, long var4, FileDescriptor var6);

3.2 通过一个案例来看怎么实现 Slice

public static void main(String[] args) {

    ByteBuf originalBuffer = Unpooled.buffer(10);
    originalBuffer.writeBytes(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10});

    // 创建切片,共享原始数据的一部分
    ByteBuf slicedBuffer = originalBuffer.slice(2, 4);

    // 对切片进行修改会影响原始数据
    slicedBuffer.setByte(0, 99);

    // 打印原始数据
    System.out.println(originalBuffer); // 输出: 01 02 63 04 05 06 07 08 09 0A

}

3.3 深入学习一下 CompositeByteBuf 怎么合并

CompositeByteBuf 类的作用主要是将多个 ByteBuf 合并成一个逻辑层面的 ByteBuf 。 这样的好处就是可以避免在多个 ByteBuf 之间进行数据拷贝。

来看一下其中的几个方法 :

  • addComponent : 向一个 CompositeByteBuf 中添加一个缓冲区
  • removeComponent : 从 CompositeByteBuf 中移除指定索引的子缓冲区
  • component : 返回指定索引处的子缓冲区
// 准备一个数组用来存储所有的 Component 对象,该对象包含了一个 ByteBuf
private Component[] components; 


// 首先是写入 :这里我屏蔽了一些代码,只看其中最核心的
private int addComponent0(boolean increaseWriterIndex, int cIndex, ByteBuf buffer) {
        // S1 : 检查给定的组件索引是否有效,以及组件容量是否为正数
        checkComponentIndex(cIndex);
        
        // S2 : 构建一个新的 Component
        Component c = newComponent(ensureAccessible(buffer), 0);
        
        // S3 : 将一个新的 Component 对象插入到 components 列表中
        addComp(cIndex, c);
        
        // S4 : 更新组件的偏移量 , 用于读写操作
        updateComponentOffsets(cIndex)
}


// 其次是读取 : 通过 readBytes 直接读取
public CompositeByteBuf getBytes(int index, ByteBuffer dst) {
        // 获得指定的索引位置
        int i = toComponentIndex0(index);
        // 开始循环遍历
        while (length > 0) {
            Component c = components[i];
            int localLength = Math.min(length, c.endOffset - index);
            dst.limit(dst.position() + localLength);
            c.buf.getBytes(c.idx(index), dst);
            index += localLength;
            length -= localLength;
            // 下一个索引位
            i ++;
        }
}

public static void main(String[] args) {
    // 创建两个 ByteBuf 作为示例子缓冲区
    ByteBuf buffer1 = Unpooled.wrappedBuffer(new byte[]{1, 2, 3});
    ByteBuf buffer2 = Unpooled.wrappedBuffer(new byte[]{4, 5, 6});

    // 创建 CompositeByteBuf,并添加两个子缓冲区
    CompositeByteBuf compositeBuffer = Unpooled.compositeBuffer();
    compositeBuffer.addComponent(true, buffer1);
    compositeBuffer.addComponent(true, buffer2);

    // 创建目标数组
    byte[] destination = new byte[6];

    // 使用 getBytes 方法将数据复制到目标数组
    compositeBuffer.getBytes(0, destination, 0, 6);

    // 打印复制后的目标数组
    System.out.print("Copied bytes: ");
    for (byte b : destination) {
        System.out.print(b + " ");
    }
}

3.4 Wrap 方法如何使用

public static void main(String[] args) {
    // 创建一个字节数组
    byte[] byteArray = {1, 2, 3, 4, 5};

    // 使用 ByteBuf 的 wrap 方法将字节数组包装为 ByteBuf
    ByteBuf byteBuf = Unpooled.wrappedBuffer(byteArray);

    // 打印 ByteBuf 的内容
    System.out.println("Original ByteBuf: " + byteBuf.toString());

    // 修改 ByteBuf 的内容
    byteBuf.setByte(0, 99);

    // 打印修改后的字节数组
    System.out.print("Modified byte array: ");
    for (byte b : byteArray) {
        System.out.print(b + " ");
    }
}

总结

勉强学了一遍 , 应用也算了解了,如有错误欢迎指正。

参考文档

转载自:https://juejin.cn/post/7333021939358613544
评论
请登录