读懂零拷贝是什么
zero copy实现高效的数据传输
许多web应用系统都会向用户提供大量的静态内容,这也就是说会有大量地从磁盘读取文件数据,并把读取后的数据写回到响应套接字中。这个活动似乎看起来几乎不涉及到CPU计算,但是它却有点低效:系统内核从磁盘读取数据,并借由内核空间-用户空间的切换把数据推送给应用系统,之后应用系统又借由内核空间-用户空间的切换把数据写出到套接字。从实际上来看,在把数据从磁盘文件传输到套接字的过程中,应用系统其实是一个无效的中间媒介。
数据每次在用户空间-内核空间移动时,它都需要被拷贝,而这样就消耗了cpu的周期和内存的带宽。不过幸运地是,你可以通过zero copy技术来消除这些无效的拷贝操作。利用zero copy的系统可以直接请求内核把数据直接从磁盘文件复制到套接字,而无需经由应用系统。零拷贝(zero copy)极大地提供了应用性能,并减少了在内核空间和用户空间的切换次数。
Java类库对于Linux和UNIX上的零拷贝支持是通过 java.nio.channels.FileChannel类的transferTo()方法来实现的。你可以使用transferTo()方法直接把一个channel中的字节数据传输到另一个可写的字节channel中,而无需数据流经应用系统。本文首先将展示一下使用传统的拷贝语义来完成文件传输时所产生的消耗,之后再展示一下使用transferTo()的零拷贝技术是如何实现更高性能的。
数据传输: 传统的做法
设想一下这样一个场景: 读取一个文件,并通过网络把文件中的数据传输到另一个程序中。这个操作的核心就是代码示例1中的俩个调用。
代码示例1:把文件中的字节复制到套接字
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
虽然代码示例1比较简单,但是其内部的拷贝操作却需要在用户空间和内核空间进行四次上下文切换,并且数据需要被复制四次。图1展示了在系统内部数据是如何从文件中被移动到套接字中的。
传统数据拷贝方法
传统的上下文切换
上面的图中涉及到的步骤有:
-
1、read()调用导致上下文从用户模式切换到内核模式。其内部,sys_read()会从一个文件中读取数据。第一次的拷贝操作是由DMA(direct memory access)引擎执行的,它会从磁盘中读取文件内容,并把它们存储到内核地址空间缓存中。
-
2、大量数据从read buffer中拷贝到用户空间地址缓存中,read()调用结束并返回。read()调用返回之后会导致另一个上下文切换---从内核模式切换到用户模式。此时,数据被存储在了用户地址空间缓存中。
- 3、之后,send()套接字调用又导致了一次上下文切换-从用户模式到内核模式。执行第三次拷贝操作,此数据被再次放入内核地址空间。这次,数据被放入了一个不同的buffer中,此buffer和一个目地套接字相关。
- 4、send()系统调用返回,第四次上下文切换发生。当DMA引擎把内核中的数据传递到协议引擎时,发生了第四次拷贝。
内核buffer这个中间媒介的使用似乎看起来是低效的。但是,内核buffer当初作为一个中间媒介被引入这个过程却是为了提供性能的。当应用系统请求的数据不超过内核缓存所能容纳的大小的时候,在读操作的一端,使用内核这个中间媒介使得内核buffer可以起到“readahead cache”的作用。这在所请求数据远小于内核buffer的情况下,可以极大地性能。在写操作的一端,内核这个中间媒介可以实现异步写入。
不幸的是,如果请求的数据远比内核缓存大的情况下,这种方法本身也可能导致性能瓶颈。数据在被最终传送应用系统之前,在磁盘、内核buffer、用户buffer之间进行了多次拷贝操作。
通过消除这些冗余的数据拷贝,零拷贝可以极大地提高性能。
数据传输: 零拷贝方法
如果你重新检查一下上面一个传统的场景,你将发现第二次和第三次的数据拷贝其实是不必要的。应用系统其实就是在缓存数据,并把缓存的数据
写入到套接字。反之,数据可以被直接地从read buffer中传输到套接字buffer中。transferTo()方法可以帮助你做到这一点。
示例代码2: transferTo()方法
public void transferTo(long position, long count, WritableByteChannel target);
transferTo()方法可以直接把数据从一个file channel中传输到一个给定的writable byte channel中。内部的实现取决于底层的操作系统对于
零拷贝的支持。在UNIX和各种Linux系统中,transferTo调用会被路由到sendfile()系统调用,就像示例3所展示的
示例代码3:sendfile()系统调用
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
在代码示例1中的file.read()和sockect.send()操作可以被直接替换为单个的transferTo()调用。
示例代码4:使用transferTo()方法把数据从磁盘文件复制到套接字
transferTo(position, count, writableChannel);
使用transferTo时的数据路径
Figure 3. Data copy with transferTo()使用transferTo方法时的上下文切换
Figure 4. Context switching with transferTo()当你使用transferTo()方法时,会执行如下动作:
-
1、transferTo()方法的执行,会让DMA引擎把文件内容拷贝到read buffer中,
之后,内核会把数据从内核buffer拷贝到一个和输出套接字相关的内核buffer中 -
2、DMA引擎把数据从内核套接字缓存传递到协议引擎
这是一个进步: 我们已经减少了上下文切换的次数。由原来的4次减少为2次,并减少了数据拷贝的次数从4次降低为3次。但是这还没有达到我们的零拷贝目标。我们可以进一步减少数据复制的次数,如果底层的网络接口支持聚合操作。从linux kernel 2.4以及其后的系统,套接字缓存描述符都做了修改以适应这种需求。 这种方法不仅减少了多次上下文切换而且也消除了涉及到CPU的数据拷贝操作。虽然用户端的使用还是像以前一样,但其内部的运行机制已经发生了改变:
-
1、 transferTo()方法的调用,使得DMA引擎把文件内容复制到内核缓存。
-
2、没有数据再被复制进套接字缓存。相反,只有相关位置和数据长度信息的描述符被追加进套接字缓存中。DMA引擎
直接把套接字缓存中的数据传输到协议引擎中,也就因此消除了最后一个cpu拷贝操作。
transferTo和聚合操作的同时使用
Figure 5. Data copies when transferTo() and gather operations are used创建一个文件服务器
现在,我们使用在客户端和服务器之间传输文件的相同示例,来实践零副本(示例代码请参见下载)。 TraditionalClient.java和TraditionalServer.java基于传统的复制语义,使用File.read()和Socket.send()。TraditionalServer.java是一个服务器程序,该程序在特定的端口上侦听客户端进行连接,然后一次从套接字读取4K字节的数据。 TraditionalClient.java连接到服务器,从文件中读取(使用File.read())4K字节数据,然后通过套接字将内容(使用socket.send())发送到服务器。
同样,TransferToServer.java和TransferToClient.java执行相同的功能,但改用transferTo()方法(进而使用sendfile()系统调用)将文件从服务器传输到客户端。
性能比较
我们在linux2.6上执行上面的示例程序,并测量使用传统方法和使用transferTo方法所消耗的时间对比。
表1:性能对比: 传统方法 VS 零拷贝
文件大小 | 传统的文件传输方法 (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 |
总结
我们上面展示了相较于使用传统方法,使用TransferTo()的性能优势。中间媒介的buffer拷贝---即使它们隐藏在内核层面,依旧产生了相当可观的消耗。
如果一个应用系统需要在channel间处理大量的数据拷贝的话,零拷贝技术可以带来极大地性能提升。