零拷贝(Zero-copy)及其应用详解

https://zhuanlan.zhihu.com/p/341371360

https://zhuanlan.zhihu.com/p/592397046

前言

零拷贝(Zero-copy)是一种高效的数据传输机制,在追求低延迟的传输场景中十分常用。本文先通过传统方案引出零拷贝机制,然后分析其细节,最后介绍它的部分应用。 文中涉及到的操作系统理论知识都可以参考英文维基或者相关书籍,如Abraham Silberschatz著《操作系统概念》、Andrew S. Tanenbaum著《现代操作系统》等。

传统的数据传输方法

在互联网时代,从某台机器将一份数据(比如一个文件)通过网络传输到另外一台机器,是再平常不过的事情了。如果按照一般的思路,用Java语言来描述发送端的逻辑,大致如下。

Socket socket = new Socket(HOST, PORT);
InputStream inputStream = new FileInputStream(FILE_PATH);
OutputStream outputStream = new DataOutputStream(socket.getOutputStream());

byte[] buffer = new byte[4096];
while (inputStream.read(buffer) >= 0) {
    outputStream.write(buffer);
}

outputStream.close();
socket.close();
inputStream.close();

看起来当然是很简单的。但是如果我们深入到操作系统的层面,就会发现实际的微观操作要更复杂,具体来说有以下步骤:

  1. JVM向OS发出read()系统调用,触发上下文切换,从用户态切换到内核态。
  2. 从外部存储(如硬盘)读取文件内容,通过直接内存访问(DMA)存入内核地址空间的缓冲区。
  3. 将数据从内核缓冲区拷贝到用户空间缓冲区,read()系统调用返回,并从内核态切换回用户态。
  4. JVM向OS发出write()系统调用,触发上下文切换,从用户态切换到内核态。
  5. 将数据从用户缓冲区拷贝到内核中与目的地Socket关联的缓冲区。
  6. 数据最终经由Socket通过DMA传送到硬件(如网卡)缓冲区,write()系统调用返回,并从内核态切换回用户态。

如果语言描述看起来有些乱的话,通过时序图描述会更清楚一些。

 

传统方法的时序图

到了这一步,你是否觉得简单的代码逻辑下隐藏着很累赘的东西了?事实也确实如此,这个过程一共发生了4次上下文切换(严格来讲是模式切换),并且数据也被来回拷贝了4次。如果忽略掉系统调用的细节,整个过程可以用下面的两张简图表示。

 

传统方法的流程框图

 

传统方法的上下文切换过程

我们都知道,上下文切换是CPU密集型的工作,数据拷贝是I/O密集型的工作。如果一次简单的传输就要像上面这样复杂的话,效率是相当低下的。零拷贝机制的终极目标,就是消除冗余的上下文切换和数据拷贝,提高效率。

零拷贝的数据传输方法

“基础的”零拷贝机制

通过上面的分析可以看出,第2、3次拷贝(也就是从内核空间到用户空间的来回复制)是没有意义的,数据应该可以直接从内核缓冲区直接送入Socket缓冲区。零拷贝机制就实现了这一点。不过零拷贝需要由操作系统直接支持,不同OS有不同的实现方法。大多数Unix-like系统都是提供了一个名为sendfile()的系统调用,在其man page中,就有这样的描述:

sendfile() copies data between one file descriptor and another.
Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.

下面是零拷贝机制下,数据传输的时序图。

 

零拷贝方法的时序图

可见确实是消除了从内核空间到用户空间的来回复制,因此“zero-copy”这个词实际上是站在内核的角度来说的,并不是完全不会发生任何拷贝。

在Java NIO包中提供了零拷贝机制对应的API,即FileChannel.transferTo()方法。不过FileChannel类是抽象类,transferTo()也是一个抽象方法,因此还要依赖于具体实现。FileChannel的实现类并不在JDK本身,而位于sun.nio.ch.FileChannelImpl类中,零拷贝的具体实现自然也都是native方法,看官如有兴趣可以自行查找源码来看,这里不再赘述。

将传统方式的发送端逻辑改写一下,大致如下。

SocketAddress socketAddress = new InetSocketAddress(HOST, PORT);
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(socketAddress);

File file = new File(FILE_PATH);
FileChannel fileChannel = new FileInputStream(file).getChannel();
fileChannel.transferTo(0, file.length(), socketChannel);

fileChannel.close();
socketChannel.close();

借助transferTo()方法的话,整个过程就可以用下面的简图表示了。

 

零拷贝方法的流程框图

 

零拷贝方法的上下文切换过程

可见,不仅拷贝的次数变成了3次,上下文切换的次数也减少到了2次,效率比传统方式高了很多。但是它还并非完美状态,下面看一看让它变得更优化的方法。

对Scatter/Gather的支持

在“基础”零拷贝方式的时序图中,有一个“write data to target socket buffer”的回环,在框图中也有一个从“Read buffer”到“Socket buffer”的大箭头。这是因为在一般的Block DMA方式中,源物理地址和目标物理地址都得是连续的,所以一次只能传输物理上连续的一块数据,每传输一个块发起一次中断,直到传输完成,所以必须要在两个缓冲区之间拷贝数据。

而Scatter/Gather DMA方式则不同,会预先维护一个物理上不连续的块描述符的链表,描述符中包含有数据的起始地址和长度。传输时只需要遍历链表,按序传输数据,全部完成后发起一次中断即可,效率比Block DMA要高。也就是说,硬件可以通过Scatter/Gather DMA直接从内核缓冲区中取得全部数据,不需要再从内核缓冲区向Socket缓冲区拷贝数据。因此上面的时序图还可以进一步简化。

 

支持Scatter/Gather的零拷贝时序图

这就是完全体的零拷贝机制了,是不是清爽了很多?相对地,它的流程框图如下。

 

支持Scatter/Gather的零拷贝流程框图

对内存映射(mmap)的支持

上面讲的机制看起来一切都很好,但它还是有个缺点:如果我想在传输时修改数据本身,就无能为力了。不过,很多操作系统也提供了内存映射机制,对应的系统调用为mmap()/munmap()。通过它可以将文件数据映射到内核地址空间,直接进行操作,操作完之后再刷回去。其对应的简要时序图如下。

 

支持mmap的零拷贝时序图

当然,天下没有免费的午餐,上面的过程仍然会发生4次上下文切换。另外,它需要在快表(TLB)中始终维护着所有数据对应的地址空间,直到刷写完成,因此处理缺页的overhead也会更大。在使用该机制时,需要权衡效率。

NIO框架中提供了MappedByteBuffer用来支持mmap。它与常用的DirectByteBuffer一样,都是在堆外内存分配空间。相对地,HeapByteBuffer在堆内内存分配空间。

零拷贝机制的应用

零拷贝在很多框架中得到了广泛应用,一般都以Netty为例来分析。但作为大数据工程师,我就以Kafka与Spark为例来简单说两句吧。

在Kafka中的应用

在使用Kafka时,我们经常会想,为什么Kafka能够达到如此巨大的数据吞吐量?这与Kafka的很多设计哲学是分不开的,比如分区并行、ISR机制、顺序写入、页缓存、高效序列化等等,零拷贝当然也是其中之一。由于Kafka的消息存储涉及到海量数据读写,所以利用零拷贝能够显著地降低延迟,提高效率。

在Kafka中,底层传输动作由TransportLayer接口来定义。它对SocketChannel进行了简单的封装,其中transferFrom()方法定义如下。(Kafka版本为0.10.2.2)

/**
 * Transfers bytes from `fileChannel` to this `TransportLayer`.
 *
 * This method will delegate to {@link FileChannel#transferTo(long, long, java.nio.channels.WritableByteChannel)},
 * but it will unwrap the destination channel, if possible, in order to benefit from zero copy. This is required
 * because the fast path of `transferTo` is only executed if the destination buffer inherits from an internal JDK
 * class.
 *
 * @param fileChannel The source channel
 * @param position The position within the file at which the transfer is to begin; must be non-negative
 * @param count The maximum number of bytes to be transferred; must be non-negative
 * @return The number of bytes, possibly zero, that were actually transferred
 * @see FileChannel#transferTo(long, long, java.nio.channels.WritableByteChannel)
 */
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException;

该方法的功能是将FileChannel中的数据传输到TransportLayer,也就是SocketChannel。在实现类PlaintextTransportLayer的对应方法中,就是直接调用了FileChannel.transferTo()方法。

@Override
    public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
        return fileChannel.transferTo(position, count, socketChannel);
    }

对该方法的调用则位于FileRecords.writeTo()方法中,用于将Kafka收到的缓存数据零拷贝地写入目的Channel。

@Override
    public long writeTo(GatheringByteChannel destChannel, long offset, int length) throws IOException {
        long newSize = Math.min(channel.size(), end) - start;
        int oldSize = sizeInBytes();
        if (newSize < oldSize)
            throw new KafkaException(String.format(
                    "Size of FileRecords %s has been truncated during write: old size %d, new size %d",
                    file.getAbsolutePath(), oldSize, newSize));

        long position = start + offset;
        int count = Math.min(length, oldSize);
        final long bytesTransferred;
        if (destChannel instanceof TransportLayer) {
            TransportLayer tl = (TransportLayer) destChannel;
            bytesTransferred = tl.transferFrom(channel, position, count);
        } else {
            bytesTransferred = channel.transferTo(position, count, destChannel);
        }
        return bytesTransferred;
    }

在Spark中的应用 Spark虽然是一个高效的积极使用内存的计算框架,但在需要使用磁盘时也会适当地溢写。零拷贝机制在Spark Core中主要就被用来优化Shuffle过程中的溢写逻辑。由于Shuffle过程涉及大量的数据交换,因此效率当然是越高越好。

在启用Bypass机制的Sort Shuffle以及Tungsten Sort Shuffle的shuffle write阶段,都使用了零拷贝来快速合并溢写文件的分片,有一个专门的配置项spark.file.transferTo来控制是否启用零拷贝(默认当然是true)。以BypassMergeSortShuffleWriter为例,它最终是调用了通用工具类Utils中的copyFileStreamNIO()方法。

def copyFileStreamNIO(
      input: FileChannel,
      output: FileChannel,
      startPosition: Long,
      bytesToCopy: Long): Unit = {
    val initialPos = output.position()
    var count = 0L
    // In case transferTo method transferred less data than we have required.
    while (count < bytesToCopy) {
      count += input.transferTo(count + startPosition, bytesToCopy - count, output)
    }
    assert(count == bytesToCopy,
      s"request to copy $bytesToCopy bytes, but actually copied $count bytes.")

    val finalPos = output.position()
    val expectedPos = initialPos + bytesToCopy
    assert(finalPos == expectedPos,
      s"""
         |Current position $finalPos do not equal to expected position $expectedPos
         |after transferTo, please check your kernel version to see if it is 2.6.32,
         |this is a kernel bug which will lead to unexpected behavior when using transferTo.
         |You can set spark.file.transferTo = false to disable this NIO feature.
           """.stripMargin)
  }

可见,该方法用于将数据从一个FileChannel零拷贝到另一个FileChannel。通过控制起始位置和长度参数,就可以精确地将所有溢写文件拼合在一起了。

 

 

Linux网络编程 | 零拷贝 :sendfile、mmap、splice、tee

 

传统文件传输的问题

网络编程中,如果我们想要提供文件传输的功能,最简单的方法就是用read将数据从磁盘上的文件中读取出来,再将其用write写入到socket中,通过网络协议发送给客户端。

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

但是就是这两个简单的操作,却带来了大量的性能丢失

例如我们的服务器需要为客户端提供一个下载操作,此时的操作如下

从上图可以看出,虽然仅仅只有这两行代码,但是却在发生了四次用户态和内核态的上下文切换,以及四次数据拷贝,也就是在这个地方产生了大量不必要的损耗。

那么为什么会发生这些操作呢?

上下文切换

由于read和recv是系统调用,所以每次调用该函数我们都需要从用户态切换至内核态,等待内核完成任务后再从内核态切换回用户态。

数据拷贝

上面也说了,由于数据的读取与写入都是由系统进行的,那么我们就得将数据从用户的缓冲区中拷贝到内核,

  • 第一次拷贝:将磁盘中的数据拷贝到内核的缓冲区中
  • 第二次拷贝:内核将数据处理完,接着拷贝到用户缓冲区中
  • 第三次拷贝:此时需要通过socket将数据发送出去,将用户缓冲区中的数据拷贝至内核中socket的缓冲区中
  • 第四次拷贝:把内核中socket缓冲区的数据拷贝到网卡的缓冲区中,通过网卡将数据发送出去。

所以要想优化传输性能,就要从减少数据拷贝和用户态内核态的上下文切换下手,这也就是零拷贝技术的由来。

什么是零拷贝呢?

零拷贝的主要任务就是避免CPU将数据从一块存储中拷贝到另一块存储,主要就是利用各种技术,避免让CPU做大量的数据拷贝任务,以此减少不必要的拷贝。或者借助其他的一些组件来完成简单的数据传输任务,让CPU解脱出来专注别的任务,使得系统资源的利用更加有效

Linux中实现零拷贝的方法主要有以下几种,下面一一对其进行介绍

  1. sendfile
  2. mmap
  3. splice
  4. tee

相关视频推荐

手写用户态协议栈以及零拷贝的实现

10道面试题搞清tcp/ip网络协议栈的秘密

C++后台开发进阶必看合集,全网最详细讲解后端开发技术

学习地址:c/c++ linux服务器开发/后台架构师

需要C/C++ Linux服务器架构师学习资料加qun579733396获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

sendfile

sendfile函数的作用是直接在两个文件描述符之间传递数据。由于整个操作完全在内核中(直接从内核缓冲区拷贝到socket缓冲区),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝。

需要注意的是,in_fd必须是一个支持类似mmap函数的文件描述符,不能是socket或者管道,而out_fd必须是一个socket,由此可见sendfile是专门为了在网络上传输文件而实现的函数。

#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
参数:
out_fd : 待写入内容的文件描述符
in_fd : 待读出内容的文件描述符
offset : 文件的偏移量
count : 需要传输的字节数
返回值:
成功:返回传输的字节数
失败:返回-1并设置errno

mmap

mmap用于申请一段内存空间,也就是我们在进程间通信中提到过的共享内存,通过将内核缓冲区的数据映射到用户空间中,两者通过共享缓冲区直接访问统一资源,此时内核与用户空间就不需要再进行任何的数据拷贝操作了

其中mmap用于申请空间,额munmap用于释放这段空间。

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
                  int fd, off_t offset);
int munmap(void *addr, size_t length);

参数:

addr : 内存的起始地址,如果设置为空则系统会自动分配

length : 指定内存段的长度

prot : 内存段的访问权限,通过按位与或可以取以下几种值

flag : 选项

fd : 被映射文件对应的文件描述符

offset : 文件的偏移量

返回值:

成功:成功时返回指向内存区域的指针

失败:返回MAP_FAILED并设置errno

splice

splice函数用于在两个文件描述符之间移动数据,而不需要数据在内核空间和用户空间中来回拷贝

需要注意的是,使用splice函数时fd_in和fd_out至少有一个是管道文件描述符,即

#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out,
               loff_t *off_out, size_t len, unsigned int flags);

参数:

out_fd : 待写入内容的文件描述符

off_out : 待写入文件描述符的偏移量,如果文件描述符为管道则必须为空

in_fd : 待读出内容的文件描述符

off_in : 待读出文件描述符的偏移量,如果文件描述符为管道则必须为空

len : 需要复制的字节数

flags : 选项

返回值:

成功:返回在两个文件描述符之间复制的字节数

没有数据:返回0

失败:返回-1并设置errno

可能产生的errno

tee

tee函数用于在两个管道文件描述符之间复制数据,并且它是直接复制,不会将数据读出,所以源文件上的数据仍可以用于后面的读操作

#include <fcntl.h>
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

参数:

out_fd : 待写入内容的文件描述符

in_fd : 待读出内容的文件描述符

len : 需要复制的字节数

flags : 选项

返回值:

成功:返回在两个文件描述符之间复制的字节数

没有数据:返回0

 

著作权归作者所有。
商业转载请联系作者获得授权,非商业转载请注明出处。
作者:蚂蚁
链接:https://www.zhihu.com/question/21705041/answer/36219654
来源:知乎

`splice`是 zero copy API 中最重要的一个,签名如下:
  1.  
    ssize_t splice(int fd_in, loff_t *off_in, int fd_out,
  2.  
    loff_t *off_out, size_t len, unsigned int flags);

其中,fd_in 或者 fd_out必须有至少一个是 pipe,其功能是(逻辑上)从 fd_in 消费数据并复制到 fd_out 中,如传入非 NULL 的off_in/off_out则会指定消费或者复制端的 offset,并会禁止对于文件当前 offset 的处理,具体参考 manpage。

splice 的主要场景是从一个 非 pipe 的fd(通常是socket)splice 到一个预先创建的 pipe,然后在这个 pipe 上做一些事情,常规的就是从这个 pipe 再 splice 出去到别的 socket 上,这主要用于转发类应用,比如 haproxy,nginx 这类应用就很适合;在这个 pipe 上还可以做一些别的事情,比如 tee 到别的 pipe 上,tee 逻辑上复制内容(不消费),然后 tee 出来的 pipe 可以去做转储,或者再做转发,等等。

zero-copy 指的是 splice 过程中,有很大的可能(也存在另一种可能) 实现上 不需要进行任何内存拷贝,比方从 socket splice 到 pipe,合适的情况下,内核只需要修改 socket fd 的 current file offset(以实现消费语义),修改 pipe 的管理结构,使其指向 socket 缓冲区中我们通过 splice 调用指定的部分,最后修改 pipe 的当前 offset。

linus 是这么说的:
Actually, there _is_ a fundamental problem. Two of them, in fact.

The reason it goes through a pipe is two-fold:

- the pipe _is_ the buffer. The reason sendfile() sucks is that sendfile cannot work with <n> different buffer representations. sendfile() only works with _one_ buffer representation, namely the "page cache of the file".

By using the page cache directly, sendfile() doesn't need any extra buffering, but that's also why sendfile() fundamentally _cannot_ work with anything else. You cannot do "sendfile" between two sockets to forward data from one place to another, for example. You cannot do sendfile from a streaming device.

The pipe is just the standard in-kernel buffer between two arbitrary points. Think of it as a scatter-gather list with a wait-queue. That's what a pipe _is_. Trying to get rid of the pipe totally misses the whole point of splice().

Now, we could have a splice call that has an _implicit_ pipe, ie if neither side is a pipe, we could create a temporary pipe and thus allow what looks like a direct splice. But the pipe should still be there.

- The pipe is the buffer #2: it's what allows you to do _other_ things with splice that are simply impossible to do with sendfile. Notably, splice allows very naturally the "readv/writev" scatter-gather behaviour of _mixing_ streams. If you're a web-server, with splice you can do

write(pipefd, header, header_len);
splice(file, pipefd, file_len);
splice(pipefd, socket, total_len);


(this is all conceptual pseudo-code, of course), and this very naturally has none of the issues that sendfile() has with plugging etc. There's never any "send header separately and do extra work to make sure it is in the same packet as the start of the data". So having a separate buffer even when you _do_ have a buffer like the page cache is still something you want to do.So there.

再来说说几个不容易了解的问题:
1. 阻塞的问题:

splice 在操作的两端都可能阻塞,如从 socket splice 到 pipe 的情况,在 socket 端可能被阻塞,因为没有数据可以被消费,pipe 端可能被阻塞,因为 pipe 可能装满了,socket 的阻塞由它自己的`O_NONBLOCK`控制,pipe 端操作的阻塞由 splice 的 flag 上`SPLICE_F_NONBLOCK`参数控制。具体参考 manpage。
这是 API 上的问题。就 pipe 来说,有个最大容量的问题,默认大概是64K,可以通过 fcntl 参数和/proc 控制,具体的可以自己查阅相关手册。

2. pipe 操作的原子性的问题
一般的文件 write,操作系统有原子性保证,在 pipe 上,4k 以内有原子性保证,以上就没有了,加上阻塞的问题,pipe 的总的容量有限,所以用 splice 编程的时候,pipe 管理是需要仔细考虑的。

3. splice 和 epoll 等交互的问题
这个没有什么特别的,记住 splice 在读端语义是消费,相当于 read,在写端是生产,相当于 write 就行了。

4. 文件系统一致性的问题
实测,splice 操作 不具备文件系统一致性,就是说,如果你打开一个 regular file descriptor,把它作为 splice 的写端,在 splice 调用成功返回之后的 read 调用或者 mmap 可能 看不见 splice 生产的内容,甚至之后发生在这个 regular file descriptor的 splice 也 看不见 splice 生产的内容。这点非常非常重要。同样的结论对 vmsplice 也成立。
posted @   he_haha  阅读(862)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· spring官宣接入deepseek,真的太香了~
点击右上角即可分享
微信分享提示