Zero Copy

xflush,metaq都适用了zero copy技术来提高性能,多次看到这个词,却一直没有理解透彻,于是找了一些资料了解。以下此技术点知识的翻译及汇总。

翻译自这里

到目前为止几乎所有的人都听说过linux下的所谓的zero-copy,但是我还是经常碰到不能完全理解这个内容的人。 基于此,我打算写一些文章深挖,剖析一下这个有用的功能。在这个文章里面,我们从user-mode(用户模式)应用这个角度来看下zero copy, 所以更详尽的kernel-level(内核)细节会被有意的忽略。

What is Zero-Copy?

为了更好的理解一个问题的解决方案,我们首先需要的是理解问题本身。让我们了解下网络服务器进程(?network server dæmon )为client端提供数据存储在文件中的服务的简单过程中包含了什么。一下是实例代码:

read(file, tmp_buf, len);

write(socket, tmp_buf, len);

看起来非常简单;你可能会想里面最多也就两个系统调用(system calls)。事实上,实时也就是如此。在这两次调用之后,数据(data)已经被复制了最少四次,同时执行了相同次数的 user/kernel mode上下文切换 。(事实上这个过程比这个描述的更复杂)。为了更深入的理解这个过程,请看图1。上半部展示了user/kernel mode上下文切换,并且下半部展示了复制操作。

Copying in Two Sample System Calls

  • 第一步:读系统调用(read system call)引起了从user mode to kernel mode 的上下文切换。第一次的copy被DMA engine执行,它从磁盘中读取了文件的内容然后存储到kernel address space buffer 里面。

  • 第二步:第二次的copy是数据从kernel buffer 复制到 user buffer,然后读系统调用(read system call)返回。这个 返回 引起了从 kernel mode 变回 user mode 的上下文切换。现在数据被存在了user address space buffer了。

  • 第三步:写系统调用(write system call)引起了从user mode to kernel mode。第三次copy是为了把再次把数据放入到kernel address space buffer。这次数据被放在了不同的buffer,是跟sockets相关的buffer。

  • 第四步:写系统调用(write system call)返回,创建了我们的第四次的上下文切换。异步且于之前不依赖(Independently and asynchronously)的情况下,第四次是在DMA engine 从kernel buffer 复制 到 protocol engine的时候发生的。你可能问自己,“什么是 Independently and asynchronously? 数据难道不是在调用返回之前就完成数据传输的么?” 事实上,调用返回 步保证 输入传输完成;它甚至步保证数据开始传输。 它只是意味着网卡驱动在其处理队列中有清空的描述符(?free descriptors)并且接收了我们需要传输的数据。这个时候在网卡的处理队列中可能还有很多的数据包在我们的数据之前。除非驱动/硬件实现了优先级环活着队列,不然数据在处理队列中都是按照先进先出的方式传输的。(带分叉的DMA 复制 表示了最后的复制能够被延迟)。

正如你所看到的, 以上过程有很多的数据复制是不必要的。其中一部分复制是可以消除来减少资源浪费以提高性能的。 作为一个驱动开发者, 我在一些有非常先进功能的硬件上面工作过。有些硬件能够绕过主存直接传送数据到另外一个设备。这个功能能够减少系统内存中的数据冗余,非常值得拥有,但是不是所有的硬件都是支持的。可能还会存在额外的数据问题会引入一定的复杂性:来源于disk的数据需要repackage才能发送给网卡。 为了减少资源浪费, 我们可以从减少kernel buffers和 user buffers之间的数据复制。

减少一次复制的一种方法是跳过调用 read 而用 调用 mmap 来替代。举例来说:

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

为了更好的了解这个过程,请看下图 2. Context switches(上下文切换)仍然一样。
Calling mmap

  • 第一步:mmap 系统调用通过DMA engine复制到kernel buffer。这个buffer之后会被user process(用户京城)共享使用,而不需要在kernel memory space 和 user memory sapce之间执行数据的复制。

  • 第二步:写系统调用让kernal 把之前的 kernel buffer中的数据传输到 socket相关的kernel buffer。

  • 第三步:第三次数据复制发生在 DMA engine 传输数据从 kernel socket buffers 到protocal engine。

通过使用mmap来替代read,我们已经减少了一半的kernel不得步复制的次数。在大量数据需要传输的时候,这种方式带来了很好的结果。然而,这种提升不是没有代价的;这种使用mmap+write方法的方式会存在隐藏的陷阱。其中一种情况是党你正在使用mmap(memory map)读取文件的时候并且已经调用了write操作 的时候,另外一个线程删减了文件的部分内容。你的write system call 就会被bus error(signal SIGBUS)打断,因为你执行了一个次有问题的内存访问。这个signal的默认行为就是杀死对应的进程和dump core--但对网络服务器无法记全所有希望了解的操作。有两种方式能够解决这个问题。

  • 第一种解决方案是为 SIGBUS signal 添加singal handler,然后handler只要简单的return逻辑就可以了。通过这种方式,write system call 会返回在它被中断前要写的字节流并且errno会被设置成success。这种方式在我看来是一种不好的解决方案,只是一种处理了表层症状而不是问题的根本的方式。因为SIGBUS signals 有时候说明了进程出现了非常严重的问题,但现在却被屏蔽了,所以不推荐使用这种方案。

  • 第二种解决方案使用了 kernel 的file leasing[文件租约](在Microsoft Windows里面叫做“opportunistic locking”)。我认为这是一种解决这个问题的正确方法。通过在文件描述符(file descriptor)续约(leasing),你可以在kernel中获得某个具体文件的租约(lease)。紧接着你可以在kernel中请求一次读/写租约(lease)。当另一个进程尝试着删减(修改)你正在传输的文件内容的时候,kernel会发送给你一个实时的signal,RT_SIGNAL_LEASE signal。它告诉你kernel正在打断你在某个文件上面的读/写租约。你的write system call会在你的程序访问非法地址和北SIGBUS signal杀掉之前就被中断。write system call 返回的数据是中断之间将要卸乳的字节流数据,并且errno 会被设置成 success。一下是从kernel获得租约的事例代码:

    if(fcntl(fd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {
        perror("kernel lease set signal");
        return -1;
    }
    /* l_type can be F_RDLCK F_WRLCK */
    if(fcntl(fd, F_SETLEASE, l_type)){
        perror("kernel lease set type");
        return -1;
    }
    

    你需要在 mmap 文件之前获得租约,然后在你完成逻辑之后解除租约。解除租约可以通过调用 fcntl F_SETLEASE with the lease type of F_UNLCK 来完成。

在kerverl version 2.1, the sendfile system call 被添加来简化网络内机器之间或一台机器本地文件之间的数据传输。 sendfile 不但能减少数据的复制,还能减少context switches(kernel, user),是按照如下方式使用的:

sendfile(socket, file, len);

为了更好的理解这个过程,请看图3.
图3

  • 第一步:sendfile system call 把文件的数据通过DMA engine 复制到kernel buffer里面。然后数据通过kernel复制到sockets相关的kernel buffer里面。

  • 第二步:第三次的复制是当DMA engine 从kernel socket buffers中数据传送数据到protocal engine中。

你可能会想当另外一个线程删减这个我们正在使用sendfile system call 来传输的文件的时候会发生什么。如果我们没有注册任何signal handlers(信号量处理器),sendfile call 会返回被中断之前将要传输的字节流,并且errno会被设置为sucess状态。

如果我们在调用sendfile之前通过kernel获得获得文件的租约,那么之后的行为和返回状态就和之前描述的一样。我们也会在sendfile call 返回之前得到RT_SIGNAL_LEASE信号量。

到现在位置,我们已经能够减少若干次kernel发生的数据复制了,但是我们仍然留下了一次复制。那么是否那次复制也能避免?当然可以,只要通过一些硬件的帮助。为了消除所有通过kernel完成的数据复制,我们需要网卡支持gather(聚合) operations。简单理解就是等在等待被传输的数据不需要在连续的内存中;他们能够被存储在不同的内存位置。在内部版本2.4,socket buffer discriptor为了适应这些需求而做了修改--这就是Linux的zero copy。这个方法不但减少多次的context switches,而且它减少processors(处理器)完成的多次数据复制。对于user-level的应用什么都不需要改变,代码仍然如下:

sendfile(socket, file, len);

为了能够更深入的理解这个过程,请看图4.
图4

  • 第一步:the sendfile system call 通过DMA engine复制数据内容至kernel buffer。

  • 第二步:没有数据被复制到socket buffer。只有包含数据的 whereabouts(内存地址信息?)和长度信息的descriptors(描述符号)才被添加到socket buffer。The DMA engine 直接把数据从kernel buffer 传递到 protocol engine,所以消除了剩余最后的数据复制。

因为数据实际上仍然从disk复制到了memory并且之后从memory到了wire,有人可能会说这个不是真正的 zero copy。 之前所描述的是 操作系统角度 的zero copy,因为数据没有在kernel buffers之间被复制。当使用zero copy的时候,能得到除了避免数据复制之外的性能提升,例如更少的context switches,更少的CPU data cache pollution(cpu 数据缓存污染,不需要修改cpu的缓存内容?)和 no CPU checksum calculations(这个是什么?)

How to use Zero-Copy?

现在我们知道了什么是zero copy,让我们实践下理论并且写一些代码吧。

C的代码参考这里

以下为JAVA中如何完成zero copy,只要使用api:

java.nio.channel.FileChannel的transferTo(long position, long count, WritableByteChannel target) throws IOException;

来自参考的性能数据:

File size Normal file transfer (ms) transferTo (ms)
7MB 156 45
21MB 337 128
63MB 843 387
98MB 1320 617
200MB 2124 1150
350MB 3631 1762
700MB 13498 4422
1GB 18399 8537