概叙
https://developer.ibm.com/articles/j-zerocopy/
Zero Copy I: User-Mode Perspective | Linux Journal
科普文:软件架构Linux系列之【五种IO模型小结】-CSDN博客
从前面的文章我们可以知道,操作系统一次io把外部设备数据拷贝到用户进程缓冲区(读),或者把用户进程缓冲区的数据转移到外部设备(写/网络io),这里的外部设备包括磁盘、socket通信。
今天我们继续讲io中的项性格提升技术“零拷贝”。
传统传输方式
Linux标准访问文件方式
在Linux中,访问文件的方式是通过两个系统调用实现的:read()
和write()
。
当应用程序调用read()
系统调用读取一块数据的时候:
- 如果该块数据已经在内存中,就直接从内存中读取数据并返回给应用程序;
- 如果该块数据不在内存中,name数据会被从磁盘上读取到页缓存中,再从页缓存中拷贝到用户地址空间中去。
如果一个进程读取某个文件,那么其他进程就都不可以读取或者更改该文件;对于写操作,当一个进程调用了write()
系统调用往某个文件中写数据的时候,数据会先从用户地址空间拷贝到操作系统内核地址空间的页缓存中,然后才被写到磁盘上。
对于这种标准的访问文件方式,在数据被写到页缓存中时,write()
系统调用就算执行完成,并不会等数据完全写入到磁盘上。
Linux在这里采用的是延迟写机制。
一般情况下,应用程序采取的写操作机制有三种:
- 同步写(Synchronous Writes),数据会立即从缓存页写回磁盘,应用程序会一直等待到写入磁盘的结束。
- 异步写(Asynchronous Writes),数据写入缓存页后,操作系统会定期将页缓存中的数据刷到磁盘上,在写入磁盘结束后,系统会通知应用程序写入已完成。
- 延迟写(Deferred Writes),数据写入缓存页后立即返回应用程序写入成功,操作系统定期将页缓存中是数据刷入磁盘,由于写入页缓存时已经返回吸入成功,写入磁盘之后不会通知应用程序。因此延迟写机制是存在数据丢失的风险的。
1. 传输过程
图1. 传统方式数据传输示意图
如上图所示,数据按箭头的方向流动,从本地终端的硬盘存储中读取数据,经过4次copy,最终到达NIC buffer
,通过网卡再发送给其他终端。
这种传输方式实际上是一种经过优化的设计,虽然看起来效率比较低下,但是内核缓冲区
的存在使得整个流程的性能得到了提升。
内核缓冲区
的引入充当了预读缓存
的角色,使得数据并不是直接从硬盘到用户缓冲区,而是允许应用程序在未请求的情况下,内核缓冲区
中已经存在了相应的数据。
内核空间内,内存与硬件存储之间的数据传输使用了DMA直接内存存取
的复制方式,这种方式不需要CPU的参与,并且提高读取速度。CPU也因此可以趁机去完成其他的工作。
例如用户缓冲区大小为4K,内核缓冲区大小为8K,文件总大小为40K,每次用户请求读取4K数据时,内核缓冲区中已经预读存入了相应的数据。
硬盘到内存(内核缓冲区)的数据传输速度是比较慢的,尤其是SSD应用之前,而内核缓冲区到用户缓冲区这种内存到内存的复制相对较快,用户缓冲区就不用等待硬盘数据传输到内存。广义上也是一种空间换时间的做法。DMA
DMA(Direct Memory Access),即直接存储器存取,是一种快速传送数据的机制。利用它进行数据传送时不需要CPU的参与。使用DMA拷贝数据会获取一部分系统数据总线资源,用来传输数据,而不需要CPU参数。IO读取也不会引发中断。CPU读取IO操作,系统调用时是会引发中断的。
然而,一些情况下内核缓冲区
也无法完全跟上应用程序的步伐,比如用户缓冲区的大于内核缓冲区的大小。预读的数据无法满足需要,仍然需要等待硬盘到内存的缓慢传输。此时性能将会大打折扣。
尽管做了很多优化,如图所示,数据已经被复制了至少四次,并且执行了多次的用户和内核上下文的切换。实际上这个过程比图示要复杂得多。
2. 上下文切换和数据复制过程
图2.上下文切换和数据复制
图2所示是传统方式数据传输(图1)时上下文切换和数据复制的过程。上半部分表示上下文切换,下半部分表示数据复制流程。
step 1:第一次拷贝
系统调用读操作时,上下文会从用户模式切换到内核模式。在内核空间中,DMA引擎执行了第一次数据拷贝,将数据从硬盘等其他存储设备上导入到内核缓冲区。
step 2:第二次拷贝
数据从内核缓冲区拷贝到用户缓冲区,系统调用读操作结束并返回。调用的返回会导致又一次的上下文切换,上下文从内核又切换到用户模式。
step 3:第三次拷贝
系统写操作开始调用,进行第三次数据复制,将数据从用户缓冲区写回内核的socket缓冲区,此时回引起一次从用户模式到内核模式的上下文切换。
step 4:第四次拷贝
写操作的系统调用返回,引起第四次上下文切换。开始第四次数据复制,数据从内核缓冲区复制到协议引擎。这次复制是异步并且独立的,系统不保证数据一定会传输,这次返回只是任务提交成功,数据包进入了队列,等待传输。就像线程池模型中任务提交时,任务只是成功提交到任务队列,何时开始执行上游调用程序并不知情。
summary
传统的传输方式会存在大量的数据复制和上下文切换,如果这些重复可以消除一部分,就可以减少开销并提升性能。某些硬件可以绕过主存储器将数据直接传输到另一个设备,但是如下节“类比举例”一样,并不是所有硬件都支持这项功能,而且这项功能的实现远非这么简单。
为了降低开销,我们可以减少复制,而不是直接消除复制。
系统为了减少复制所采用的所有方式中,最多的就是让这些传输尽可能不跨越用户空间和内核空间的边界,因为每次跨越边界就意味着一次复制。
类比举例
在干货之前,先喝口汤压压惊。举个通俗点的例子来类比描述传统的 no zero-copy的做法,A用左手拿筷子要吃饭,B告诉你A,你需要用右手拿筷子,然后A把筷子从左手给了B,然后B又把筷子塞到A的右手里,A开始吃饭。
A和B这里可以看成两个上下文,A的左手传递筷子给B之后,切换到了B的上下文,B传递给A的右手,又切换回A的上下文,这个代价其实是非常昂贵的。
为了减少这种昂贵的代价,我们可以想象一些场景来逐步降低事情的复杂度。
最直观最简单的方法,B只需要告诉A,也就是说B发出一条指令,A接收指令之后,自己把筷子从左手换到右手,就可以既减少了上下文的切换,减轻了B的压力,又减少了传递的次数和沟通代价。然而这需要A具备这样的功能,计算机中某些硬件可以提供这样的支持,但是如果A是一个不满3岁的孩子,他可能听不懂你的话,又或者不明白如何把筷子从左手转到右手。
这个时候B只需要扶住A的左手,帮他把筷子换到右手里。这样,也缩减了这个过程中的代价。
零拷贝zero-copy
零复制(英语:Zero-copy;也译零拷贝)技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
零拷贝原理的总体介绍
零拷贝是一种I/O操作优化技术,旨在减少CPU在数据从一个存储区域复制到另一个存储区域时的上下文切换和拷贝次数。通过减少这些操作,可以提高系统性能,减少资源消耗。
零拷贝的实现方式CPU
- sendfile系统调用:Linux内核通过
sendfile
系统调用支持零拷贝,它允许数据直接从文件描述符传输到网络socket,减少了数据在用户态和内核态之间的拷贝次数。 -
splice
系统调用:splice
系统调用允许将数据在文件描述符、管道、socket等之间传输,而不经过用户空间,从而减少数据拷贝。 - DMA(直接内存访问):DMA技术允许硬件直接在内存中进行数据传输,减少了CPU的参与,从而减少了CPU的拷贝次数。
- TransmitFile API:Microsoft Windows通过
TransmitFile API
支持零复制,允许数据直接从文件传输到网络socket,减少了数据在用户态和内核态之间的拷贝次数。 - Java NIO:Java的NIO库通过
transferTo()
和transferFrom()
方法支持零拷贝,允许数据在通道之间传输,减少数据拷贝次数。
零拷贝的应用场景
- 网络通信:在网络通信中,零拷贝技术可以减少数据在网络传输过程中的拷贝次数,提高网络通信效率。
- 文件传输:在文件传输过程中,使用零拷贝技术可以减少文件在磁盘和内存之间的拷贝次数,提高文件传输速度。
- 数据库操作:在数据库读写操作中,使用零拷贝技术可以减少数据在内存和磁盘之间的拷贝次数,提高数据库操作的效率。
1.no zero-copy即传统传输方式
Web应用程序通常提供大量静态内容,例如使用聊天工具向好友发送了一张本地图片,应用程序需要从磁盘读取图片数据,并将完全相同的数据写到响应socket中,通过网络发送给对方。这个操作看起来貌似不需要占用过多的CPU资源,因为没有计算的需求,但仍然效率较低:内核从磁盘读取数据并将其推送到应用程序,然后应用程序将其推回到内核写到套接字。这种场景下,应用程序充当了一个低效的中介,它将数据从磁盘文件获取到套接字。
2 Java中的zero copy
Java类库通过java.nio.channels.FileChannel
中的transferTo()
方法在Linux和UNIX系统上支持零拷贝。使用transferTo()
法将字节直接从调用它的通道传输到另一个可写字节通道,而不需要数据流经应用程序。
本文先解释下传统复制,然后介绍zero copy
的几种机制,最后解释前言中的疑问。
科普文:BIO、NIO和AIO小结_bio和nio-CSDN博客
Java中的零拷贝是指减少数据拷贝次数,提高系统性能的技术。在Java中,主要的零拷贝技术有transferTo和MappedByteBuffer。
解决方案1:使用FileChannel的transferTo方法
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.channels.FileChannel;
public class ZeroCopyExample {
public static void main(String[] args) {
try (FileInputStream fis = new FileInputStream("input.txt");
FileOutputStream fos = new FileOutputStream("output.txt");
FileChannel inputChannel = fis.getChannel();
FileChannel outputChannel = fos.getChannel()) {
outputChannel.transferFrom(inputChannel, 0, inputChannel.size());
} catch (Exception e) {
e.printStackTrace();
}
}
}
解决方案2:使用MappedByteBuffer
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
public class ZeroCopyExample {
public static void main(String[] args) {
try (RandomAccessFile raf = new RandomAccessFile("input.txt", "rw");
FileChannel fileChannel = raf.getChannel()) {
MappedByteBuffer mappedBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, raf.length());
// 使用mappedBuffer进行操作
} catch (Exception e) {
e.printStackTrace();
}
}
}
这两种方法都能够减少数据在内核缓冲区和用户缓冲区之间的拷贝次数,从而提高文件传输的效率。
3 zero copy(MMP方式零拷贝)
每次数据经过用户内核边界时,都必须复制一次数据,这会消耗CPU周期和内存带宽。
zero-copy技术的出现就是通过减少复制次数来消除这些副本。使用零拷贝请求的应用程序,内核将数据直接从磁盘文件复制到套接字,而不用 无需通过应用程序。零拷贝极大地提高了应用程序性能,并减少了内核和用户模式之间的上下文切换次数。
图3. mmap方式零拷贝数据传输示意图
mmap系统调用的方式如下:
tmp_buf = mmap(file, len);
write(socket, tmp_buf, len);
这种方式用到两种系统调用,mmap+write。
这种传输方式使用mmap()
代替了read()
,磁盘上的数据会通过DMA
被拷贝到内核缓冲区,然后操作系统会把这块内核缓冲区与应用程序共享,这样就避免了跨越边界的一次复制。应用程序再调用write()
直接将内核缓冲区的内容拷贝到socket缓冲区,最后系统把数据从socket缓冲区传输到网卡。mmap
减少了一次拷贝,提升了效率。但是mmap
也可能遇到一些隐藏的问题。例如,当应用程序map
了一个文件,但是这个文件被另一个进程截断时,write()
系统调用会因为访问非法地址而被SIGBUS
信号终止。SIGBUS
信号默认会杀死你的进程并产生一个coredump
。
解决mmap
上述问题的方式通常有两种:
- 增加对
SIGBUS
信号的处理程序
当遇到SIGBUS
信号时,处理程序可以直接去调用return,这样,write
调用在被中断之前返回已经写入的字节数并且将errno
设置为success
。但是这么处理显得较为粗糙。 - 使用文件租借锁
在文件描述符上使用租借锁,这样当有进程要截断这个文件时,内核会立刻发送一个RT_SIGNAL_LEASE
信号,这样在程序访问非法内存之前,中断write
调用,返回已经写入的字节数,并将errno
设置为success
,而不必等到write
被SIGBUS
杀死再做处理。
图4 mmap上下文切换与数据传输
如上图所示,mmap
+write
的复制减少了文件的复制,但是上下文切换的次数和read
+write
的方式是一样的。
mmap(memory map)采用虚拟内存,地址映射来减少一次拷贝。可以减少将数据从内核态拷贝到用户态的性能消耗。
如上图所示,从数据并没有从内核态拷贝到用户态,而是直接通过内存映射的方式得到待传文件的虚拟内存地址,在发送的时候,可以通过共享的虚拟内存地址将待发送文件信息拷贝到socket缓存区,发送出去。
#include <sys/mman.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
int main() {
// 打开文件并将其映射到内存中
int fd = open("file.txt", O_RDONLY);
size_t size = lseek(fd, 0, SEEK_END);
char* data = (char*) mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
// 创建套接字并连接到目标地址
int sock = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(1234);
connect(sock, (sockaddr*) &addr, sizeof(addr));
// 将内存中的数据直接写入套接字
write(sock, data, size);//
// 关闭套接字和文件,并解除内存映射
close(sock);
munmap(data, size);
close(fd);
return 0;
}
mmap这里只是减少了copy,但还是需要4次上下文切换。那是否有什么方式可以减少上下文切换,这时sendfile就出来了。
4 zero copy(sendfile方式零拷贝)
图5 sendfile数据传输示意图
Linux的内核版本2.1之后,系统引入了sendfile
来简化文件传输到网络的工作,这种方式不仅减少了拷贝次数,也减少了上下文的切换。
使用sendfile代替了read
+write
操作。
图6 sendfile上下文切换与数据传输
数据发生三次拷贝,首先sendfile
系统调用,通过DMA引擎将文件复制到内核缓冲区。
在内核区,内核将数据复制到socket
缓冲区。
最后,DMA引擎将数据从内核socket缓冲区传递到协议引擎中(网卡)。
sendfile可以减少文件发送时的上下文切换。
sendfile
是否会遇到和mmap
同样的隐藏问题?
- 如果另一个进程截断了使用
sendfile
传输的文件,sendfile
在没有任何信号处理程序的情况下,会返回被中断前传输的字节数,并且errno
被设置为success
。 - 如果使用了文件租借锁,sendfile可以获得
RT_SIGNAL_LEASE
信号,并给出和没有使用文件租借锁同样的返回。
从 Linux 2.1 版本开始,Linux 引入了 sendfile
来简化操作。sendfile
方式可以替换上面的mmap/write
方式来进一步优化。
mmap();
write();
sendfile将以上两个操作替换为:sendfile(); 一个操作
这样就减少了上下文切换,因为少了一个应用程序发起write
操作,直接发起sendfile
操作。
直接通过DMA将磁盘数据复制到缓存区,在内核态将缓冲区的数据拷贝到socket缓存区,不需要用户态参与。
#include <sys/socket.h>
#include <fcntl.h>
#include <cstring>
int main() {
// 打开文件并获取文件描述符
int fd = open("file.txt", O_RDONLY);
// 创建套接字并连接到目标地址
int sock = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(1234);
connect(sock, (sockaddr*) &addr, sizeof(addr));
// 使用sendfile函数将文件信息发送到套接字
off_t offset = 0;
struct stat stat_buf;
fstat(fd, &stat_buf);
sendfile(sock, fd, &offset, stat_buf.st_size);//直接使用sendfile发送文件,避免上下文切换
// 关闭套接字和文件
close(sock);
close(fd);
return 0;
}
可以看到sendfile经历了3次的copy动作,而且没有频繁的用户态↔内核态的状态切换。 那sendfile是不是就是完美的,还可不可以把cpu copy也节省呢? 带有 scatter/gather 的 sendfile方式 Linux 2.4 内核进行了优化,提供了带有 scatter/gather 的 sendfile 操作,这个操作可以把最后一次 CPU COPY 去除。其原理就是在内核空间 Read BUffer 和 Socket Buffer 不做数据复制,而是将 Read Buffer 的内存地址、偏移量记录到相应的 Socket Buffer 中,这样就不需要复制。其本质和虚拟内存的解决方法思路一致,就是内存地址的记录。 下图展示了scatter/gather 的 sendfile 的原理:
5. zero copy(使用DMA gather copy的sendfile)
在内核2.4版本之后,sendfile
可以在硬件支持的情况下实现更高效的传输。
图7 使用DMA gather copy的sendfile数据传输示意图
在硬件的支持下,不再从内核缓冲区的数据拷贝到socket缓冲区,取而代之的仅仅是缓冲区文件描述符和数据长度的拷贝。
这样DMA引擎直接将页缓存中数据打包发送到网络中即可。
图8 DMA gather copy的sendfile上下文切换与数据传输
这种方式避免了最后一次拷贝,并且减轻了CPU的负担,省去了页缓存到socket缓冲区的CPU Copy
。这种sendfile是Linux中真正的零拷贝,虽然依然需要磁盘到内存的复制,但是内核空间和用户空间内已经不存在任何多余的复制。
这种方式的前提是硬件和相关驱动程序支持DMA Gather Copy
。
6.总结
为什么mmap会多消耗CPU?mmap
没有完全消除内存中的文件复制,从页缓存到socket缓冲区需要进行CPU Copy
,并且上下文的切换次数和传统的read
+write
方式一样。
因此,相对于sendfile
,mmap
会占用更多的CPU资源。
为什么mmap比sendfile内存安全性控制复杂,为什么mmap会引起JVM Crash?mmap
没有提供被其他进程截断时的处理,需要添加对SIGBUS
信号中断的处理。由于截断后,mmap
访问了非法内存,SIGBUS
信号会导致JVM Crash
的问题。
为什么sendfile只能是BIO的,不能使用NIO?(个人理解,未验证)
sendfile
在使用DMA gather copy
的情况下,降低了CPU资源的占用,减少了文件复制和上下文切换次数,但是由于socket缓冲区中拿到的只是文件描述符和数据长度,并没有拿到真正的文件,因此并不能执行write等相关操作异步写或者延迟写,只能进行同步写。这也是减少上下文切换付出的代价,接收到sendfile后,都在内核态执行,缺少应用程序的干预因此可控性也较差。所以sendfile
只能使用BIO
这种同步阻塞的IO。
However, 在大文件的传输上,sendfile
依然是最佳的方式。
Java中NIO的类库通过java.nio.channels.FileChannel
中的transferTo()
依赖的零拷贝是sendfile
,因此实质上transferTo
并不支持真正意义上的NIO。
而mmap
+write
的方式,使真正的文件被复制到socket缓冲区,从socket缓冲区到网卡的复制过程是可以异步的,但是这种操作意味着更多的CPU消耗。
RocketMQ中更多的需求是小文件的传输,而NIO的特性可以更快更高效的应对这种场景。在这种权衡考量下,牺牲部分CPU资源来换取更高的文件传输效率的选择显然是一种更优的方案。