草庐IT

IO-Zero Copy

鱼蛮子9527 2023-03-28 原文

要说 IO 的性能优化就不得不提 Zero Copy(零拷贝),虽然名字叫零拷贝,但其实并不是完全没有拷贝过程,而是尽量减少不必要的拷贝及上下文切换。各种消息队列可以说是将零拷贝技术用到了极致,像 Kafka、RocketMQ 都用到到了 mmap、sendfile 等零拷贝技术来提升服务的性能。我们最常用的应用服务 Tomcat、Nginx 在返回静态资源的时候,都有使用零拷贝技术。

普通IO操作

以实现类似 Tomcat 中返回静态资源的功能举例。这个过程一般是读取文件内容,不需要做任何处理直接将读取的数据写入网络 Socket 中返回给用户,类似下面的伪代码过程。

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

这个看起来很简单,只有两次系统调用,感觉上应该不会有太多的开销。但其实这个过程至少会有四次数据的 拷贝,以及同等次数的上下文切换(实际上这个过程会更复杂)。

普通IO过程

如上图所示,上半部分为 Context 切换过程,下半部分为数据拷贝过程。详细过程如下:

  1. 系统调用 read 导致了从用户态到内核态的上下文切换,第一次拷贝是由 DMA 模块执行,它从磁盘读取文件内容并将它们保存到内核空间的缓冲区中。
  2. 数据被从内核缓冲区拷贝到用户空间缓冲区,同时 read 系统调用执行返回,系统调用的返回导致了一次上下文切换:从内核态返回到用户态。
  3. 系统调用 write 导致了从用户态到内核态的上下文切换,第三次数据拷贝又将数据保存到了内核空间的缓冲区,不过这次数据被保存到了另外一个缓冲区,一个专门处理 Socket 相关的缓冲区。
  4. write 系统调用的返回,导致了第四次上下文切换。第四次数据拷贝是 DMA 模块将数据从内核空间缓冲区传递至协议引擎,这与我们的代码的执行是独立且异步发生的。

为什么是独立、异步?难道不是在 write 系统调用返回时候数据已经被传送了吗?其实 write 系统调用的返回,并不意味着传输成功——它甚至无法保证传输的开始。调用的返回,只是表明以太网驱动程序在其传输队列中有空位,并已经接受我们的数据用于传输。可能有众多的数据排在我们的数据之前。除非驱动程序或硬件采用优先级队列的方法,各组数据是依照 FIFO 的次序被传输(图中叉状的 DMA 拷贝表明这一次复制可以被延后)

正如我们看到的,上面的过程中存在很多的数据冗余。某些冗余其实可以被消除,以减少开销、提高性能。某些硬件支持完全绕开内存,将数据直接传送给其他设备。这一特性消除了向系统内存的拷贝过程,因此是一种很好的选择,但并不是所有的硬件都支持。此外,来自于硬盘的数据必须重新打包(地址连续)才能用于网络传输,这也引入了一些复杂性。为了减少开销,我们可以从消除内核缓冲区与用户缓冲区之间的拷贝入手。

mmap

一种方式是利用 mmap 技术,mmap 是一种内存映射文件的方法,可以将内核空间的一段内存区域映射到用户空间。映射成功后,用户对这段内存区域的操作可以直接反映到内核空间。简单的来说:使用 mmap 可以将磁盘文件映射到内存, 用户通过修改/拷贝内存就能修改/拷贝磁盘文件。

实现这样的映射关系后,进程就可以像操作内存一样操作文件,系统会自动回写数据到对应的磁盘文件上,对文件的操作不必再调用 read , write 等系统调用。

tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);

利用 mmap 技术的伪代码过程如上所示。

mmap调用过程

如上图所示,利用 mmap 技术可以减少文件的拷贝次数,但无法减少上下文的切换次数。详细过程如下:

  1. mmap 系统调用通过 DMA 模块将文件内容拷贝到内核缓冲区,该缓冲区与用户进程共享,这样无需再执行从内核缓冲区到用户缓冲区的拷贝的操作。
  2. write 系统调用将数据从内核缓冲区拷贝到与 Socket 相关的内核缓冲区中。
  3. 第三次数据拷贝发生在 DMA 模块将数据由 Socket 的缓冲区传递给协议引擎时。

通过调用 mmap 而不是 read ,可以将数据的拷贝次数减少。当有大量数据要进行传输时会有比较好的效果。但是这种改进不是没有代价的,利用 mmap 与 write 这种组合方式,存在着一些隐藏的陷阱。例如,当在内存中对文件进行映射后调用 write,同时另外一个进程将同一文件截断。此时 write 系统调用会被接收到 SIGBUS 信号中断,因为当前进程访问了非法内存地址。对 SIGBUS 信号的默认处理是杀死当前进程并生成 dump core 文件——而这对于服务程序而言简直就是灾难。

sendfile

自内核版本 2.1 开始 sendfile 系统调用被引入,目的是为了简化通过网络在两个本地文件之间进行的数据传输。sendfile 系统调用的引入,不仅减少了数据复制,还减少了上下文切换的次数。使用方式如下:

sendfile(socket, file, len);
sendfile调用过程

sendfile 的数据拷贝过程与 mmap 类似,但是减少了上下文切换,详细过程如下:

  1. sendfile 系统调用将文件内容通过 DMA 模块拷贝到内核缓冲区,之后再被拷贝到与 Socket 相关的缓冲区内。
  2. 第三次拷贝是 DMA 模块将位于 Socket 相关缓冲区中的数据传递给协议引擎。

在调用 sendfile 发送数据的期间,如果有另外一个进程将文件截断。如果进程没有为 SIGBUS 注册任何信号处理函数的话,sendfile 系统调用将返回被信号中断前已发送的字节数,并将 errno 置为成功。

到此为止,我们已经能够避免多次拷贝,然而我们还存在一次多余的拷贝。这次拷贝也可以消除吗?在硬件提供的一些帮助下是可以的。这需要网络接口支持聚合操作特性,该特性需要待发送的数据不必要存放在地址连续的内存空间中。在内核版本 2.4,Socket 缓冲区描述符结构发生了改动,以适应此特性——这就是 Linux 中所谓的“零拷贝”。这种方式不仅减少了多个上下文切换,而且消除了数据冗余。

从用户层应用程序的角度来看,没有发生任何改动,代码仍然是类似下面的形式:

sendfile(socket, file, len);
sendfile调用过程

数据的复制过程如上图所示,详细过程如下:

  1. sendfile 系统调用将文件内容通过 DMA 模块复制到内核缓冲区
  2. 数据并未被复制到 Socket 相关的缓冲区内。只有记录数据位置和长度的描述符被追加到 Socket 缓冲区中。DMA 模块将数据直接从内核缓冲区传递给协议引擎,这样只有两次数据的拷贝。

简单的来说 sendfile 是将磁盘文件读取到内核缓冲区后直接扔给网卡,大大减少了数据的拷贝次数。由于数据实际上仍然由磁盘复制到内存,再由内存复制到发送设备,有人可能会说这并不是真正的“零拷贝”。然而,从操作系统的角度来看,这就是“零拷贝”,因为内核空间内不存在冗余数据。“零拷贝”特性除了避免无效复制之外,还能获得其他性能优势,例如更少的上下文切换,更少的 CPU cache 污染以及非必须的校验及计算。

mmap与sendfile的比较

mmap

优点:即使频繁调用,使用小块文件传输,效率也很高。
缺点:不能很好的利用 DMA 方式,会比 sendfile 多消耗 CPU,内存安全性控制复杂,需要避免 JVM Crash 问题。

sendfile

优点:可以利用 DMA 方式,消耗 CPU 较少,大块文件传输效率高,无内存安全问题。
缺点:小块文件效率低于 mmap 方式,只能是 BIO 方式传输,不能使用 NIO。

rocketMQ 在消费消息时,使用了 mmap,而 kafka 则使用了 sendfile。这也是为了适应两种中间件的特性而做出的选择,因为 rocketMQ 一个 broker 上所有 topic 的数据都存储在一个 commitLog 中,而 kafka 则是一个 topic 一个 partition (文件)。我们在使用过程中,也需要根据实际的使用场景来选择合适的技术,才能达到最优的性能。

Java中的使用

对普通 IO 操作 read(),write() 对应各种 Read,Writer 的 read(),write() 方法,大家应该都比较熟悉。mmap() 系统调用可以通过 FileChannel.map().load() 方法获得 MappedByteBuffer 对象进行操作使用。sendfie() 系统调用则对应 FileChannel 的 transferTo(),transferFrom() 方法。具体的使用样例也可以参考下面基准测试中的代码。

没有实际验证过的设想都是纸老虎,我们通过 jmh 对三种 IO 操作方式进行下 BenchMark,使用代码如下:

/**
 * 普通IO,mmap,sendfile 三种方式性能比较
 * @author 鱼蛮 on 2021/9/28
 **/
@Measurement(iterations = 5, time = 5)
public class IoPerformance extends AbstractBenchMark {

    public static final String FILE_IN = "/Users/yuman/data/file_in";
    public static final String FILE_OUT = "/Users/yuman/data/file_out";

    @Benchmark
    public void readNormal() throws IOException {
        try (BufferedReader br = Files.newBufferedReader(Paths.get(FILE_IN))){
            while ((br.read()) != -1) {
            }
        }
    }

    @Benchmark
    public void readMmap() throws IOException {
        File file = new File(FILE_IN);
        try (FileChannel fc = new RandomAccessFile(new File(FILE_IN), "r").getChannel()) {
            MappedByteBuffer mapIn = fc.map(FileChannel.MapMode.READ_ONLY, 0, file.length()).load();
            while (mapIn.hasRemaining()) {
                mapIn.get();
            }
        }
    }

    @Benchmark
    public void readSendfile() throws IOException {
        File file = new File(FILE_IN);
        try (FileChannel fc = new RandomAccessFile(new File(FILE_IN), "r").getChannel()) {
            ByteBuffer buffer = ByteBuffer.allocate((int) file.length());
            fc.read(buffer);
            buffer.flip();
            while (buffer.hasRemaining()) {
                buffer.get();
            }
        }
    }

    @Benchmark
    public void readWriteNormal() throws IOException {
        try (BufferedReader br = Files.newBufferedReader(Paths.get(FILE_IN));
             BufferedWriter bw = Files.newBufferedWriter(Paths.get(FILE_OUT))){
            int num;
            while ((num = br.read()) != -1) {
                bw.write(num);
            }
        }
    }

    @Benchmark
    public void readWriteMmap() throws IOException {
        File file = new File(FILE_IN);
        try (FileChannel fc = new RandomAccessFile(new File(FILE_IN), "r").getChannel();
             FileChannel fo = new RandomAccessFile(new File(FILE_OUT), "rw").getChannel()) {
            MappedByteBuffer mapIn = fc.map(FileChannel.MapMode.READ_ONLY, 0, file.length()).load();
            MappedByteBuffer mapOut = fo.map(FileChannel.MapMode.READ_WRITE, 0, file.length()).load();
            while (mapIn.hasRemaining()) {
                mapOut.put(mapIn.get());
            }
        }
    }

    @Benchmark
    public void readWriteSendfile() throws IOException {
        File file = new File(FILE_IN);
        try (FileChannel fc = new RandomAccessFile(new File(FILE_IN), "r").getChannel();
             FileChannel fo = new RandomAccessFile(new File(FILE_OUT), "rw").getChannel()) {
            long transferred = 0;
            while (transferred < file.length()){
                transferred += fo.transferFrom(fc, 0, fc.size());;
            }
        }
    }

    public static void initFile() throws IOException {
        try (BufferedWriter bw = Files.newBufferedWriter(Paths.get(FILE_IN))){
            for (int i = 0; i < 100000; i++) {
                for (int j = 0; j < 10; j++) {
                    bw.write(String.valueOf(j));
                }
            }
        }
    }

    public static void main(String[] args) throws RunnerException, IOException {
        initFile();

        Options opt = new OptionsBuilder()
                .include(IoPerformance.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

执行结果如下:

Benchmark                        Mode  Cnt         Score          Error  Units
IoPerformance.readNormal         avgt    5   4569931.672 ±  5624384.029  ns/op
IoPerformance.readMmap           avgt    5    817379.306 ±  3084799.347  ns/op
IoPerformance.readSendfile       avgt    5    245230.529 ±    10808.789  ns/op
IoPerformance.readWriteNormal    avgt    5  11154876.370 ± 10744253.350  ns/op
IoPerformance.readWriteMmap      avgt    5   2014111.528 ±    34198.192  ns/op
IoPerformance.readWriteSendfile  avgt    5    757706.184 ±    21718.482  ns/op

每次 BenchMark 的结果都有一定差别,但整体的性能趋势是不变的。我们可以看出 mmap 方式较普通的 IO 读写方式有数量级的性能提升,而 sendfile 方式较 mmap 也有数量级的性能提升。

其他说明

DMA

DMA 的英文全称是“Direct Memory Access”,汉语的意思就是直接内存访问。DMA 既可以指内存和外设直接存取数据这种内存访问的计算机技术,又可以指实现该技术的硬件模块(对于计算机 PC 而言,DMA 控制逻辑由 CPU 和 DMA 控制接口逻辑芯片共同组成,嵌入式系统的 DMA 控制器内建在处理器芯片内部,一般称为 DMA 控制器,DMAC)。

使用 DMA 的好处就是它不需要 CPU的干预而直接服务于外设,这样就可以解放 CPU,从而提高系统的效率。没有 DMA 时,只能是 CPU 参与外设的数据读写,然后与内存进行交互。有了 DMA 之后,在没有 CPU 的干预下通过 DMA ,外设就可以直接与内存进行数据交互。

Zero Copy I: User-Mode Perspective

CPU体系架构-DMA

有关IO-Zero Copy的更多相关文章

  1. ruby - 如何验证 IO.copy_stream 是否成功 - 2

    这里有一个很好的答案解释了如何在Ruby中下载文件而不将其加载到内存中:https://stackoverflow.com/a/29743394/4852737require'open-uri'download=open('http://example.com/image.png')IO.copy_stream(download,'~/image.png')我如何验证下载文件的IO.copy_stream调用是否真的成功——这意味着下载的文件与我打算下载的文件完全相同,而不是下载一半的损坏文件?documentation说IO.copy_stream返回它复制的字节数,但是当我还没有下

  2. Ruby 文件 IO 定界符? - 2

    我正在尝试解析一个文本文件,该文件每行包含可变数量的单词和数字,如下所示:foo4.500bar3.001.33foobar如何读取由空格而不是换行符分隔的文件?有什么方法可以设置File("file.txt").foreach方法以使用空格而不是换行符作为分隔符? 最佳答案 接受的答案将slurp文件,这可能是大文本文件的问题。更好的解决方案是IO.foreach.它是惯用的,将按字符流式传输文件:File.foreach(filename,""){|string|putsstring}包含“thisisanexample”结果的

  3. Get https://registry-1.docker.io/v2/: net/http: request canceled while waiting - 2

    1.错误信息:Errorresponsefromdaemon:Gethttps://registry-1.docker.io/v2/:net/http:requestcanceledwhilewaitingforconnection(Client.Timeoutexceededwhileawaitingheaders)或者:Errorresponsefromdaemon:Gethttps://registry-1.docker.io/v2/:net/http:TLShandshaketimeout2.报错原因:docker使用的镜像网址默认为国外,下载容易超时,需要修改成国内镜像地址(首先阿里

  4. ruby - 为什么不能使用类IO的实例方法noecho? - 2

    print"Enteryourpassword:"pass=STDIN.noecho(&:gets)puts"Yourpasswordis#{pass}!"输出:Enteryourpassword:input.rb:2:in`':undefinedmethod`noecho'for#>(NoMethodError) 最佳答案 一开始require'io/console'后来的Ruby1.9.3 关于ruby-为什么不能使用类IO的实例方法noecho?,我们在StackOverflow上

  5. ruby - 为 IO::popen 拯救 "command not found" - 2

    当我将IO::popen与不存在的命令一起使用时,我在屏幕上打印了一条错误消息:irb>IO.popen"fakefake"#=>#irb>(irb):1:commandnotfound:fakefake有什么方法可以捕获此错误,以便我可以在脚本中进行检查? 最佳答案 是:升级到ruby​​1.9。如果您在1.9中运行它,则会引发Errno::ENOENT,您将能够拯救它。(编辑)这是在1.8中的一种hackish方式:error=IO.pipe$stderr.reopenerror[1]pipe=IO.popen'qwe'#

  6. ruby - IO::EAGAINWaitReadable:资源暂时不可用 - 读取会阻塞 - 2

    当我尝试使用“套接字”库中的方法“read_nonblock”时出现以下错误IO::EAGAINWaitReadable:Resourcetemporarilyunavailable-readwouldblock但是当我通过终端上的IRB尝试时它工作正常如何让它读取缓冲区? 最佳答案 IgetthefollowingerrorwhenItrytousethemethod"read_nonblock"fromthe"socket"library当缓冲区中的数据未准备好时,这是预期的行为。由于异常IO::EAGAINWaitReadab

  7. ruby - 如何使用 ruby​​ fibers 避免阻塞 IO - 2

    我需要将目录中的一堆文件上传到S3。由于上传所需的90%以上的时间都花在了等待http请求完成上,所以我想以某种方式同时执行其中的几个。Fibers能帮我解决这个问题吗?它们被描述为解决此类问题的一种方法,但我想不出在http调用阻塞时我可以做任何工作的任何方法。有什么方法可以在没有线程的情况下解决这个问题? 最佳答案 我没有使用1.9中的纤程,但是1.8.6中的常规线程可以解决这个问题。尝试使用队列http://ruby-doc.org/stdlib/libdoc/thread/rdoc/classes/Queue.html查看文

  8. ruby - 如何从 ruby​​ 中的 IO 对象获取文件名 - 2

    在ruby中...我有一个由外部进程创建的IO对象,我需要从中获取文件名。然而我似乎只能得到文件描述符(3),这对我来说不是很有用。有没有办法从此对象获取文件名甚至获取文件对象?我正在从通知程序中获取IO对象。所以这也可能是获取文件路径的一种方式? 最佳答案 关于howtogetathefilenameinC也有类似的问题,我将在这里以ruby​​的方式给出这个问题的答案。在Linux中获取文件名假设io是您的IO对象。以下代码为您提供了文件名。File.readlink("/proc/self/fd/#{io.fileno}")例

  9. ruby-on-rails - Postgres 不会使用 c9.io 连接到 Ruby on Rails 应用程序中的服务器 - 2

    几天前,我成功地安装了Postgresql并从SQLite创建/迁移了我的数据库(为部署我的Rails4应用程序做准备)……我是这么想的。我重新启动了我的服务器,但是当我尝试访问我的应用程序时,出现了这个错误:PG::ConnectionBadcouldnotconnecttoserver:ConnectionrefusedIstheserverrunninglocallyandacceptingconnectionsonUnixdomainsocket"/var/run/postgresql/.s.PGSQL.5432"?我在SO上看到了几个类似的已回答问题,但它们都涉及Mac。由于

  10. ruby - celluloid-io 或带有蚊子循环的 eventmachine - 2

    我正在构建一个小的ruby​​程序来运行与MQTT的连接。服务器并订阅channel。我正在使用mosquittogem这只是libmosquitto的桥梁C库。我创建了一个非常简单的程序实现,可以使用rubymy_prog.rb运行:#DependenciesrequireFile.expand_path(File.join('..','environment'),__FILE__)#MQTTApplicationmodulePulsrclassMQTTattr_reader:host,:port,:alivedefinitialize(host='iot.eclipse.org',

随机推荐