Netty-零拷贝

  • 所谓的零拷贝是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。

传统IO工作方式

如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。

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

代码通常如下,一般会需要两个系统调用:

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

代码很简单,虽然就两行代码,但是这里面发生了不少的事情。


image-20201208141519253.png

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

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

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。

零拷贝技术

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

  • mmap + write
  • sendfile

其核心是减少上下文切换数据拷贝次数。

mmap + write

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

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

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

image-20201208142335017.png

具体过程如下:

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

我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。所以整个过程进行了4次上下文切换和3次数据拷贝。

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

sendfile

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

#include 
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

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

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

image-20201208143217816.png

但是这还不是真正的零拷贝技术,如果网卡支持 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 次数据拷贝,如下图:

image-20201208144313824.png

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

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

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

NIO中的零拷贝

mmap

/**
 * 说明
 * 1. MappedByteBuffer可让文件直接在内存(堆外内存)修改,操作系统不需要拷贝一
 */
public class MappedByteBufferTest {

    public static void main(String[] args) {

        try(RandomAccessFile randomAccessFile = new RandomAccessFile("netty/src/main/resources/1.txt", "rw");){

            final FileChannel channel = randomAccessFile.getChannel();
            /**
             * 参数1 FileChannel.MapMode.READ_WRITE 读写模式
             * 参数2 0: 可以直接修改的起始位置
             * 参数3 5: 是映射到内存的大小,即将1.txt的多少个字节映射到内存
             * 实际类型:directByteBuffer
             */
            final MappedByteBuffer map = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);

            map.put(0, (byte)'H');
            map.put(3, (byte)'9');

        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

sendfile

FileChannel类型:

/**
* This method is potentially much more efficient than a simple loop
* that reads from this channel and writes to the target channel.  Many
* operating systems can transfer bytes directly from the filesystem cache
* to the target channel without actually copying them.
*/
public abstract long transferTo(long position, long count, WritableByteChannel target)throws IOException;

如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。

Netty中的零拷贝

在 Netty 的世界中 Zero Copy 其实有两层含义。

  1. 这里的零拷贝的是指代 Netty 中对于数据高效率操作方式。
    • 指在 Java 之上(user space)允许 CompositeByteBuf 使用单个 ByteBuf 一样操作多个 ByteBuf 而不需要任何 copy。
    • 以及允许使用slice,切分单个ByteBuf为多个,而实际上操作的还是同一个ByteBuf,不需要cotpy。
  2. 如果你所在的系统支持 zero copy,则可以使用 FileRegion 来写入 Channel,实际是就是调用上文Nio零拷贝中的transferTo方法进行传输。

CompositeBuf

package netty.zerocopy;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;

import java.nio.charset.StandardCharsets;

/**
 * netty 零拷贝
 * @author huangyichun
 * @date 2020/12/9
 */
public class CompositeDemo {

    public static void main(String[] args) {
        ByteBuf buf1 = Unpooled.copiedBuffer("hello, world", StandardCharsets.UTF_8);

        ByteBuf buf2 = Unpooled.copiedBuffer("let's go", StandardCharsets.UTF_8);

        ByteBuf compositeBuf = Unpooled.wrappedBuffer(buf1, buf2);

        compositeBuf.setBytes(1, "my name".getBytes());

        System.out.println(buf1.toString(StandardCharsets.UTF_8));
        System.out.println(buf2.toString(StandardCharsets.UTF_8));
        System.out.println(compositeBuf.toString(StandardCharsets.UTF_8));
    }
}

打印结果:

hmy nameorld                                                    
let's go                                                        
hmy nameorldlet's go
从上面这个案例可以看出,**compositeBuf** 实际是引用了 **buf1**和**buf2**, 因为我们改变了compositeBuf 内容后,buf1也被改变了,也就是说**CompositeBuf** 没有进行数据copy。
 ByteBuf compositeBuf = Unpooled.wrappedBuffer(buf1, buf2);  //生成一个CompositeBuf

我们可以查看源码:

    public static ByteBuf wrappedBuffer(int maxNumComponents, ByteBuf... buffers) {
        switch (buffers.length) {
        case 0:
            break;
        case 1:
            ByteBuf buffer = buffers[0];
            if (buffer.isReadable()) {
                return wrappedBuffer(buffer.order(BIG_ENDIAN));
            } else {
                buffer.release();
            }
            break;
        //上面案例实际上走到逻辑是这个, maxNumComponents=2
        default:
            for (int i = 0; i < buffers.length; i++) {
                ByteBuf buf = buffers[i];
                if (buf.isReadable()) {
                    //创建CompositeByteBuf
                    return new CompositeByteBuf(ALLOC, false, maxNumComponents, buffers, i);
                }
                buf.release();
            }
            break;
        }
        return EMPTY_BUFFER;
    }

CompositeByteBuf的构造函数:

    private CompositeByteBuf(ByteBufAllocator alloc, boolean direct, int maxNumComponents, int initSize) {
        super(AbstractByteBufAllocator.DEFAULT_MAX_CAPACITY);

        this.alloc = ObjectUtil.checkNotNull(alloc, "alloc");
        if (maxNumComponents < 1) {
            throw new IllegalArgumentException(
                    "maxNumComponents: " + maxNumComponents + " (expected: >= 1)");
        }

        this.direct = direct;
        this.maxNumComponents = maxNumComponents;
        components = newCompArray(initSize, maxNumComponents);
    }

其参数含义:

  • alloc: ByteBuf 分配
  • direct: 是否是 DirectByteBuf
  • maxNumComponents: 最多能组合 ByteBuf
  • buffers:组合的 ByteBuf
  • offset: ByteBuf 在总 ByteBuf 组合中的偏移值

Slice

package netty.zerocopy;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;

import java.nio.charset.StandardCharsets;

/**
 * @author huangyichun
 * @date 2020/12/9
 */
public class SliceDemo {

    public static void main(String[] args) {

        ByteBuf buf = Unpooled.copiedBuffer("hello, world", StandardCharsets.UTF_8);

        ByteBuf pre = buf.slice(0, 6);
        ByteBuf next = buf.slice(6, buf.readableBytes());

        buf.setBytes(0, "MyName".getBytes());

        System.out.println(buf.toString(StandardCharsets.UTF_8));
        System.out.println(pre.toString(StandardCharsets.UTF_8));
        System.out.println(next.toString(StandardCharsets.UTF_8));
    }
}

打印结果:

MyName world                                                    
MyName
 world      

从结果可以明显看出来pre 和 next还是指向buf的。因此slice操作不涉及内容copy,而pre实际的类型是UnpooledSlicedByteBuf 其内部包含了一个指向buf的引用buffer,这里就不进行源码分析了。

FileRegion

下面实例来自:io.netty.example.file.FileServerHandler.channelRead0

@Override
    public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        RandomAccessFile raf = null;
        long length = -1;
        try {
            raf = new RandomAccessFile(msg, "r");
            length = raf.length();
        } catch (Exception e) {
            ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
            return;
        } finally {
            if (length < 0 && raf != null) {
                raf.close();
            }
        }

        ctx.write("OK: " + raf.length() + '\n');
        if (ctx.pipeline().get(SslHandler.class) == null) {
            // SSL not enabled - can use zero-copy file transfer.
            ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
        } else {
            // SSL enabled - cannot use zero-copy file transfer.
            ctx.write(new ChunkedFile(raf));
        }
        ctx.writeAndFlush("\n");
    }

这里可以看出当 SSL 没有开启时可以使用 DefaultFileRegion,其是 FileRegion 的默认实现,内部封装了 FileChannel.transferTo 的方法,关于这个方法,上文已经介绍过了。

总结:

  1. 使用 Netty 提供的 CompositeByteBuf 类, 可以将多个ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了各个 ByteBuf 之间的拷贝。
  2. ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf, 避免了内存的拷贝。
  3. 通过 FileRegion 包装的FileChannel.tranferTo 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel, 避免了传统通过循环 write 方式导致的内存拷贝问题.

参考:
https://binglau7.github.io/2018/12/10/Netty%E4%B8%8EZero-Copy/
https://mp.weixin.qq.com/s?__biz=MzAwNDA2OTM1Ng==&mid=2453146714&idx=2&sn=fa45883a655b280c949d0e1c33f4d844&scene=21#wechat_redirect

你可能感兴趣的:(Netty-零拷贝)