前言
前一段时间参与定位Tomcat某一问题,涉及到sendfile系统调用。忽然想到之前一些使用经验,知道Java领域中有不少开源软件,都应用了零拷贝来提升其性能,于是有了本文。看看我们这些耳熟能详的软件吧,你是否曾了解过他们背后应用的技术点:
- Tomcat: 使用sendfile把大文件写入Socket,提升静态文件数据传输性能
- Netty: 统一的ByteBuf机制,对DirectBuffer封装采用堆外内存进行Socket读写;也支持使用sendfile把文件缓冲区的数据发送到目标Channel
- RocketMQ:使用mmap内存映射文件方式对CommitLog文件读写,当客户端消费消息时把内容写到目标Socket
本文是对网上知识点的收集与整理而成,在此分享给大家。本文中【OS层】章节中介绍零拷贝技术的部分内容与图片来源于看过就懂的java零拷贝及实现方式详解,在此先致谢。
什么是零拷贝
零拷贝(Zero Copy)是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另外一个特定区域。这种技术通常应用在通过网络传输文件时,节省CPU周期和带宽。避免让CPU做大量的数据拷贝任务,让CPU解脱出来专注于别的任务,这样就可以让系统资源的利用更加有效。
OS层
传统I/O
搞清楚零拷贝之前,先抛开Java,了解Linux中I/O体系中几个核心知识点:
- 内核空间:Linux内核运行的空间
- 用户空间:用户程序运行的空间。Linux为了系统安全,内核空间与用户空间是隔离的,即使用户程序崩溃了,不会导致内核受到影响。内存管理,操作权限等都划分两个空间隔离。在内核空间内可以调用系统的一切资源,并向用户空间的程序提供系统接口。而用户空间的程序不能直接调用资源系统,只能通过系统接口来间接向内核发起请求,这又称系统调用。
- 磁盘/网卡:磁盘/网卡相对于内存来说是慢速I/O,他们之间数据传输主要有两种方式:
- PIO,经过CPU:磁盘/网卡与内存的数据交换,数据要经过CPU存储转发
- DMA,不经过CPU:直接进行磁盘/网卡和内存的数据交换,CPU只需要向DMA控制器下达指令,由DMA控制器通过系统总线来传输数据,传送完毕再通知CPU
- CPU上下文切换:先把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
传统I/O的工作方式下,存在数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的硬件I/O接口从磁盘/网卡等硬件读取或写入。
代码会涉及2次系统调用,Linux中file与socket都抽象为文件,下面函数的第一个参数其实都是文件描述符:
- read(file, tmp_buf, len);
- write(socket, tmp_buf, len);
整个过程发生了2次系统调用,4次用户态与内核态的上下文切换。上下文切换存在如下问题:
- 调度:每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态
- 时延:需要耗时几十纳秒到几微秒,CPU调度也会有时延,在高并发的场景下,这类时间容易被累积和放大,从而影响整体系统的性能表现
存在用户空间<->内核空间<->磁盘/网卡之间的数据交换,发生了4次数据拷贝,其中2次是DMA拷贝,另外2次则是通过CPU拷贝:
- 拷贝1(DMA拷贝):把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过DMA搬运
- 拷贝2(CPU拷贝):把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由CPU完成
- 拷贝3(CPU拷贝):把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的socket的缓冲区里,这个过程依然还是由CPU搬运
- 拷贝4(DMA拷贝):把内核的socket缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由DMA搬运
零拷贝技术
mmap
Linux采用虚拟内存管理方式,多个虚拟内存可以指向同一个物理地址。利用这个特性,可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样在I/O操作时数据就不需要来回复制。将内核中的读缓冲区与用户空间的缓冲区进行映射,所有的IO操作都在内核中完成。
mmap是内核提供的一个系统调用,其函数原型如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
mmap+write利用了虚拟内存的特性来实现的零拷贝,其流程如下:
上述流程就是少了1次CPU拷贝,提升了I/O的速度。不过上下文的切换还是4次并没有减少,这是因为还是要应用程序发起write操作。mmap是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,所以节省了1次CPU拷贝。并且用户进程内存是虚拟的,只是映射到内核的读缓冲区,应用层内存也会减少一半。
sendfile
sendfile是内核提供的另一个系统调用,其函数原型如下:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
sendfile可以在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作,因此可以使用它来实现零拷贝。其流程如下:
sendfile方式有3次数据拷贝,包括了2次DMA拷贝和1次CPU拷贝,以及2次上下文切换。
sendfile+DMA scatter/gather
那能不能把CPU拷贝减少到0?Linux 2.4内核对sendfile进行了优化,提供了带有scatter/gather的sendfile操作,这个操作可以把最后一次CPU拷贝去除。其原理就是在内核空间Read BUffer和Socket Buffer不做数据复制,而是将Read Buffer的内存地址、偏移量记录到相应的Socket Buffer中,这样就不需要复制。其本质和虚拟内存的解决方法思路一致,就是内存地址的记录。其流程如下:
scatter/gather的sendfile只有2次DMA拷贝,以及2次上下文切换,CPU拷贝已经完全没有。不过这种复制功能是需要硬件及驱动程序支持。
splice
在Linux 2.6.17版本引入了splice,函数原型如下:
ssize_t splice(int fdin, loff_t *offin, int fdout, loff_t *offout, size_t len, unsigned int flags);
splice调用和sendfile非常相似,它并不仅限于sendfile的功能。也就是说splice是sendfile的一个超集。另外内核还一个tee函数,是专门应用在两个管道间的数据移动,同样是零拷贝。
- 相同点:splice与sendfile都需要两个已经打开的文件描述符,一个表示输入,一个表示输出
- 不同点:splice允许任意两个文件互相连接,而并不只是文件与socket进行数据传输,splice不需要硬件支持
在Linux 2.6.23版本中,sendfile机制的实现已经没有了,但是其API及相应的功能还在,相应的功能是利用了splice机制来实现。
小结
所谓的零拷贝,都是为了减少CPU拷贝及减少上下文的切换,汇总如下:
系统调用 | CPU拷贝 | DMA拷贝 | 上下文切换 | |
---|---|---|---|---|
传统I/O | read+write | 2 | 2 | 4 |
mmap | mmap+write | 1 | 2 | 4 |
sendfile | sendfile | 1 | 2 | 2 |
sendfile+gather | sendfile | 0 | 2 | 2 |
splice | splice | 0 | 2 | 0 |
引入了零拷贝之后,2次DMA拷贝是都少不了,因为两次DMA都是依赖硬件完成。
Java层
零拷贝技术
相比OS层,Java由于JVM引入GC,有自己的堆内存管理。零拷贝需要考虑两个问题:
- 从哪拷贝到哪:用户空间当如磁盘写数据时,需要将用户缓冲区(堆内内存)中的内容拷贝到内核缓冲区(堆外内存)中,OS再将内核缓冲区中的内容写进磁盘中
- 零拷贝如何优化:在用户空间中,直接申请堆外内存,写入其需要写进磁盘的数据,去掉对堆内内存使用
因此,Java的零拷贝技术核心先要能使用堆外内存。
Java NIO
JVM的GC机制则会存在对其所管理的内存的拷贝移动,会影响效率。当有一些高性能要求场景,需要直接使用OS原生的堆内存,DirectByteBuffer则是可出直接申请与释放JVM堆外内存。此内存不由JVM管理,不受GC影响。直接使用堆外内存从而避免JVM GC带来的拷贝。
Java NIO API提供了OS层零拷贝的API封装,上层应用使用也非常方便:
- mmap:MappedByteBuffer类,其底层是mmap系统调用
- sendfile: FileChannel提供两个方法(transferTo/transferFrom),其底层是sendfile系统调用
Netty
Netty相比Java内置的NIO,它从三个层次来减少数据的拷贝:
- 避免数据流经用户空间:Netty的FileRegion中FileChannel.tranferTo,实现数据如何写到目标Channel,可以使用mmap+write
- 避免数据在JVM堆与OS用户堆的拷贝:Java提供DirectByteBuffer,Netty提供对DirectByteBuffer堆外内存的统一接口封装ByteBuf
- 避免数据在用户空间多次拷贝:Netty提供ByteBuf抽象,支持引用计数与池化,并提供CompositeByteBuf组合视图来减少拷贝
- ByteBuf:数据可共享,retain/release管理引用计数
- ByteBufHolder:duplicate对于ByteBuf进行一个浅拷贝,共享同一个数据区域,但不共享read和write索引
- CompositeByteBuf:组合数个缓冲区为一体,并对外展现为一个缓冲区,可以将它们逻辑上当成一个完整的ByteBuf来操作,这样就免去了重新分配空间再复制数据的开销
开源分析
Tomcat
Tomcat是一个Web服务端软件,Web应用通常存在一些静态资源文件,而这些静态文件不是需要经过应用来额外处理,可以直接由Tomcat直接发给客户端,因而不需要经过用户空间,则可能利用前面的提到零拷贝技术。
为了提高性能,节省带宽,Tomcat提供一种内建机制来对静态资源文件压缩,但压缩节省带宽了却提高了CPU。Tomcat又提供sendfile的功能,当默认大于48Kb的静态文件,会直接使用sendfile功能进行传送,而不再启用压缩。
相关的配置可以参见:Advanced IO and Tomcat 和 Default Servlet Reference 。
Tomcat提供三种IO:
- BIO:阻塞IO,现在应该很少使用
- NIO:非阻塞IO技术,使用Java提供NIO API,Tomcat提供了NIO与NIO2两种实现
- APR:基于JNI调用操作系统相关API,性能相对NIO有提升,但需要下载APR需要的库
Tomcat定义了三种类型的Endpoint,分别对应上面三种IO模式,在NIO模式实现的 NioEndpoint.java中 可以找到如下代码:
if (sd.fchannel == null) {
// Setup the file channel
File f = new File(sd.fileName);
@SuppressWarnings("resource") // Closed when channel is closed
FileInputStream fis = new FileInputStream(f);
sd.fchannel = fis.getChannel(); // 生成静态文件的FileChannel
}
// Configure output channel
sc = socketWrapper.getSocket();
// TLS/SSL channel is slightly different
WritableByteChannel wc = ((sc instanceof SecureNioChannel) ? sc : sc.getIOChannel());
// We still have data in the buffer
if (sc.getOutboundRemaining() > 0) {
...
} else {
long written = sd.fchannel.transferTo(sd.pos, sd.length, wc); // 底层sendfile系统调用
if (written > 0) {
RocketMQ
RocketMQ支持消息持久化,所有消息接收之后都是顺序追加写入到CommitLog中,CommitLog是存储在磁盘上的文件。消费者连接服务端会创建CosumerQueue,CommitLog文件中的消息与CosumerQueue建立索引关系,当消费者通过CosumerQueue得到消息的真实物理地址,再去CommitLog上获取到对应的消息。
RocketMQ是采用mmap+write来实现CommitLog文件的内容发送,它避免了JVM的堆内存的拷贝,也减少一次CPU拷贝,但没有没有采用sendfile。
我们可以在 MappedFile.java 中找到文件映射初始化,消息消费和删除需要支持随机读写也很好理解:
try {
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
TOTAL_MAPPED_FILES.incrementAndGet();
ok = true;
} catch (FileNotFoundException e) {
log.error("Failed to create file " + this.fileName, e);
throw e;
} catch (IOException e) {
当客户端来拉取消息时,我们可以在 PullMessageProcessor.java 中找到如下代码:
if (this.brokerController.getBrokerConfig().isTransferMsgByHeap()) {
final byte[] r = this.readGetMessageResult(getMessageResult, requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId());
this.brokerController.getBrokerStatsManager().incGroupGetLatency(requestHeader.getConsumerGroup(),
requestHeader.getTopic(), requestHeader.getQueueId(),
(int) (this.brokerController.getMessageStore().now() - beginTimeMills));
response.setBody(r);
} else {
try {
FileRegion fileRegion =
new ManyMessageTransfer(response.encodeHeader(getMessageResult.getBufferTotalSize()), getMessageResult);
channel.writeAndFlush(fileRegion).addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
getMessageResult.release();
if (!future.isSuccess()) {
log.error("transfer many message by pagecache failed, {}", channel.remoteAddress(), future.cause());
}
}
});
} catch (Throwable e) {
log.error("transfer many message by pagecache exception", e);
getMessageResult.release();
}
- 如果是transferMsgByHeap,而会把消息读到堆中,再写到响应Body
- 如果不是transferMsgByHeap,则会创建MessageTransfer对象,而它实现了Netty的FileRegion接口,在 tranferTo 方法把内容写到目标channel
结语
本文搜集整理了零拷贝的一些知识点,并简单打开两个开源软件的源码来看看,可以看到零拷贝技术并不是什么复杂高深的技术,在Java层使用也非常简单。希望本文能给大家带来一些启发,在后续的网络编程中对零拷贝技术有所应用。