要说 IO 的性能优化就不得不提 Zero Copy(零拷贝),虽然名字叫零拷贝,但其实并不是完全没有拷贝过程,而是尽量减少不必要的拷贝及上下文切换。各种消息队列可以说是将零拷贝技术用到了极致,像 Kafka、RocketMQ 都用到到了 mmap、sendfile 等零拷贝技术来提升服务的性能。我们最常用的应用服务 Tomcat、Nginx 在返回静态资源的时候,都有使用零拷贝技术。
普通IO操作
以实现类似 Tomcat 中返回静态资源的功能举例。这个过程一般是读取文件内容,不需要做任何处理直接将读取的数据写入网络 Socket 中返回给用户,类似下面的伪代码过程。
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
这个看起来很简单,只有两次系统调用,感觉上应该不会有太多的开销。但其实这个过程至少会有四次数据的 拷贝,以及同等次数的上下文切换(实际上这个过程会更复杂)。
如上图所示,上半部分为 Context 切换过程,下半部分为数据拷贝过程。详细过程如下:
- 系统调用 read 导致了从用户态到内核态的上下文切换,第一次拷贝是由 DMA 模块执行,它从磁盘读取文件内容并将它们保存到内核空间的缓冲区中。
- 数据被从内核缓冲区拷贝到用户空间缓冲区,同时 read 系统调用执行返回,系统调用的返回导致了一次上下文切换:从内核态返回到用户态。
- 系统调用 write 导致了从用户态到内核态的上下文切换,第三次数据拷贝又将数据保存到了内核空间的缓冲区,不过这次数据被保存到了另外一个缓冲区,一个专门处理 Socket 相关的缓冲区。
- 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 系统调用通过 DMA 模块将文件内容拷贝到内核缓冲区,该缓冲区与用户进程共享,这样无需再执行从内核缓冲区到用户缓冲区的拷贝的操作。
- write 系统调用将数据从内核缓冲区拷贝到与 Socket 相关的内核缓冲区中。
- 第三次数据拷贝发生在 DMA 模块将数据由 Socket 的缓冲区传递给协议引擎时。
通过调用 mmap 而不是 read ,可以将数据的拷贝次数减少。当有大量数据要进行传输时会有比较好的效果。但是这种改进不是没有代价的,利用 mmap 与 write 这种组合方式,存在着一些隐藏的陷阱。例如,当在内存中对文件进行映射后调用 write,同时另外一个进程将同一文件截断。此时 write 系统调用会被接收到 SIGBUS 信号中断,因为当前进程访问了非法内存地址。对 SIGBUS 信号的默认处理是杀死当前进程并生成 dump core 文件——而这对于服务程序而言简直就是灾难。
sendfile
自内核版本 2.1 开始 sendfile 系统调用被引入,目的是为了简化通过网络在两个本地文件之间进行的数据传输。sendfile 系统调用的引入,不仅减少了数据复制,还减少了上下文切换的次数。使用方式如下:
sendfile(socket, file, len);
sendfile 的数据拷贝过程与 mmap 类似,但是减少了上下文切换,详细过程如下:
- sendfile 系统调用将文件内容通过 DMA 模块拷贝到内核缓冲区,之后再被拷贝到与 Socket 相关的缓冲区内。
- 第三次拷贝是 DMA 模块将位于 Socket 相关缓冲区中的数据传递给协议引擎。
在调用 sendfile 发送数据的期间,如果有另外一个进程将文件截断。如果进程没有为 SIGBUS 注册任何信号处理函数的话,sendfile 系统调用将返回被信号中断前已发送的字节数,并将 errno 置为成功。
到此为止,我们已经能够避免多次拷贝,然而我们还存在一次多余的拷贝。这次拷贝也可以消除吗?在硬件提供的一些帮助下是可以的。这需要网络接口支持聚合操作特性,该特性需要待发送的数据不必要存放在地址连续的内存空间中。在内核版本 2.4,Socket 缓冲区描述符结构发生了改动,以适应此特性——这就是 Linux 中所谓的“零拷贝”。这种方式不仅减少了多个上下文切换,而且消除了数据冗余。
从用户层应用程序的角度来看,没有发生任何改动,代码仍然是类似下面的形式:
sendfile(socket, file, len);
数据的复制过程如上图所示,详细过程如下:
- sendfile 系统调用将文件内容通过 DMA 模块复制到内核缓冲区
- 数据并未被复制到 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