这是我参与2022首次更文挑战的第18天,活动详情查看:2022首次更文挑战
参考
正文
根据参考的文章中的需求:
将服务端的文件通过socket发送出去。
对应的流程如下:
sequenceDiagram
participant us as 用户空间
participant up as 内核空间
participant HW as 硬件设备
Note left of us:读文件
note over up:内核缓冲区
us ->>+up:read()
note over HW:硬件
up->>+HW:请求
HW->>-up:DMA拷贝
note over us:用户缓冲区
up->>-us:CPU拷贝
note left of us:写文件
note over up:socket缓冲区
us->>+up:write(),CPU拷贝
note over HW:网卡
up->>+HW:DMA拷贝
HW->>-up:返回
up->>-us:返回
这里能看到有4次CPU上下文切换,2次CPU拷贝和2次DMA拷贝。
一般来说,普通的IO是通过小的字节数组,一次读取若干数据刷入到缓冲区中。
重要相关概念
内核空间 - 内核态,用户空间 - 用户态
这个也较好理解:
内核空间:只有操作系统内核可以访问的空间
用户空间:用户应用程序访问的内存区域
而内核态和用户态,指的就是进程在哪个空间上运行。
CPU上下文,CPU上下文切换
上下文:CPU寄存器(类比JVM内存区域中,虚拟机栈的程序计数器)
上下文切换:一般指CPU在内核态和用户态之间的切换;需要通过系统调用完成。
虚拟内存
操作系统分配的内存实际上为虚拟内存,和物理内存是n:1的关系。
可以类比JVM中的句柄访问的方式(不过那边反而可能对于历史的所有位置可能是1:n的关系,因为指针对应的物理地址会修改):
graph LR
操作系统管理-->虚拟内存1--> 物理内存1
操作系统管理-->虚拟内存2--> 物理内存2
操作系统管理-->虚拟内存3--> 物理内存2
操作系统管理-->虚拟内存4--> 物理内存2
操作系统管理-->虚拟内存5--> 物理内存1
DMA
DMA全称是 Direct Memory Access,直接内存访问,是主板上的一个独立芯片,允许设备和内存存储器间直接进行IO,而不需要CPU参与。
再次看一下上面的传统IO流程图,这里以read流程为例:
sequenceDiagram
participant up as 用户进程
participant CPU
participant DMA
participant hd as 磁盘
activate up
up->>CPU:read调用,用户态切换内核态
activate CPU
CPU->>DMA:发起IO请求
deactivate CPU
activate DMA
DMA->>hd:发起IO请求
deactivate DMA
activate hd
hd->>hd:将数据放入磁盘控制缓冲区
hd->>DMA:通知DMA控制器
deactivate hd
activate DMA
DMA->>DMA:数据从磁盘控制器缓冲区拷贝到内核缓冲区
DMA->>CPU:发起数据读完信号
deactivate DMA
activate CPU
CPU->>CPU:数据从内核缓冲区拷贝到用户缓冲区
CPU->>up:read调用返回,内核态切换用户态
deactivate CPU
deactivate up
这里DMA:
主要就是帮忙CPU转发一下IO请求,以及拷贝数据,提高CPU利用效率:因为CPU可以做很多事,如果在IO请求上。
零拷贝实现方式
零拷贝定义:
计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及CPU的拷贝时间。它是一种
I/O操作优化技术。
1.mmap + write
前面介绍了虚拟内存:操作系统可以把同一块物理内存绑定到多个虚拟内存上。
mmap是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,所以节省了一次CPU拷贝‘’并且用户进程内存是虚拟的,只是映射到内核的读缓冲区,可以节省一半的内存空间。
基于这个需求,我们可以把用户缓冲区和内核缓冲区用这一概念,绑定到同一块物理内存上,这样子就可以减少数据从内核缓冲区到用户缓冲区的一次读写:
sequenceDiagram
participant us as 用户空间
participant up as 内核空间
participant HW as 硬件设备
Note left of us:读文件
note over up:内核缓冲区(用户缓冲区)
us ->>+up:read()
note over HW:硬件
up->>+HW:请求
HW->>-up:DMA拷贝
note over us:用户缓冲区(内核缓冲区)
up->>-us:返回
note left of us:写文件
us->>+up:write()
note over up:socket缓冲区
up->>up:CPU拷贝(内核缓冲区->socket缓冲区)
note over HW:网卡
up->>+HW:DMA拷贝
HW->>-up:返回
up->>-us:返回
这样子相较传统的IO模式:
- 减少了一次CPU拷贝(从内核缓冲区到用户缓冲区的读写)
- 第二次是从内核缓冲区(用户缓冲区)到socket缓冲区的读写,实质上并没有发生改变
- 并且,减少了一半的内存空间。
这种方式需要三次数据拷贝(CPU1,DMA2),4次CPU上下文切换。
但,基于上面的模式:
- 我们只是需要:把数据发送出去,实质上并不需要通过用户空间
基于这个前提,是否可以再做优化?
2.sendfile
sendfile是Linux2.1内核版本后引入的一个系统调用函数
sendfile就可以实现上面的优化:
- 所有操作都在内核空间进行,CPU上下文的切换就仅在用户空间请求和内核空间返回结果上。
流程如下:
sequenceDiagram
participant us as 用户空间
participant up as 内核空间
participant HW as 硬件设备
Note left of us:senfile
note over up:内核缓冲区
us ->>+up:sendfile()
note over HW:硬件
up->>+HW:请求
HW->>-up:DMA拷贝
note over up:socket缓冲区
up->>up:CPU拷贝(内核缓冲区->socket缓冲区)
note over HW:网卡
up->>+HW:DMA拷贝
HW->>-up:返回
up->>-us:sendfile返回
这样子就省下了2次CPU上下文切换了,这种方式:
- 总计2次CPU上下文切换,3次拷贝(CPU1,DMA2)
但观察这个流程,如果要更优化一些,我们是否可以省去从内核缓冲区到socket缓冲区的CPU拷贝,而不需要再在内核空间分配空间做一次转运?
- 直接从硬件到网卡并不现实,因为这样子就需要做针对硬件的特殊处理
3.sendfile+DMA scatter/gather
linux 2.4版本之后,对
sendfile做了优化升级,引入SG-DMA技术,其实就是对DMA拷贝加入了scatter/gather操作,它可以直接从内核空间缓冲区中将数据读取到网卡。
对应流程如下:
sequenceDiagram
participant us as 用户空间
participant up as 内核空间
participant HW as 硬件设备
Note left of us:senfile
note over up:内核缓冲区
us ->>+up:sendfile()
note over HW:硬件
up->>+HW:请求
HW->>-up:DMA拷贝
up->>up:内核缓冲区的文件描述符信息发送到socket缓冲区
note over HW:网卡
up->>+HW:SG-DMA拷贝
HW->>-up:返回
up->>-us:sendfile返回
这种方式是对DMA的最佳使用范式了,因为在这里就完全没有CPU拷贝了,全部IO都到DMA上了。
- 之所以不再需要CPU拷贝,是因为没有发生在内存中(用户空间和内核空间)的数据拷贝了。
总结
| 方式 | CPU拷贝次数 | DMA拷贝次数 | 上下文切换次数 |
|---|---|---|---|
| 传统IO | 2 | 2 | 4 |
| mmap+write | 1 | 2 | 4 |
| sendfile | 1 | 2 | 2 |
| sendfile+DMA scatter/gather | 0 | 2 | 2 |
Java支持
java中提供了2种零拷贝方式:
- mmap
- sendflie
mmap
java的NIO对MMAP提供了支持 MapperdByteBuffer,使用demo如下:
public class Mmap {
public static void main(String[] args) throws Exception {
FileChannel readChannel = FileChannel.open(Paths.get("src","disa.txt"), StandardOpenOption.READ);
MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_WRITE, 0, readChannel.size());
FileChannel writeChannel = FileChannel.open(Paths.get("src","bia.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//数据传输
writeChannel.write(data);
readChannel.close();
writeChannel.close();
}
}
实际上,这里的mapperdByteBuffer的参数如下:
- 开启模式,这里指的是对readChannel的读取方式,只读,只写,或读写模式
- 这里必须对应前面channel的方式,如果前面是读模式,那么这里就不能是读写,只能是只读
- 起始位置,这里指的是读取的起始位置
- 读取长度,这里指的是从读取起始位置开始,读取多少字节
关于path,这里我的文件结构是:
src
- zeroCopy
- Mmap.class
- disa
在demo的语境中,mmap实质上是省去了从内核空间到用户空间的转写。
sendfile
NIO同样提供了对于sendfile的支持,对应的API为 fileChannel.transferTo/transferFrom,demo如下:
public static void main(String[] args) throws Exception {
FileChannel readChannel = FileChannel.open(Paths.get("src","disa"), StandardOpenOption.READ);
long len = readChannel.size();
long position = readChannel.position();
FileChannel writeChannel = FileChannel.open(Paths.get("src","bia"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//数据传输
readChannel.transferTo(position, len, writeChannel);
readChannel.close();
writeChannel.close();
}
性能对比
这里用JMH做性能对比,并把文件替换成一个108M的文件来进行读写,参赛选手:
- 传统IO(fileStream)
- mmap(这里修改了一下方式,并不一次性创建很大的buffer区)
- sendfile
JMH的介绍以及使用可参考:www.zhihu.com/question/27…
这里就开单线程不用多线程了,测试代码如下:
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 5)
@Threads(1)
@Fork(1)
@State(value = Scope.Benchmark)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class Mmap {
public static final long length = 1024 * 1024 * 2 ;//1K
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Mmap.class.getSimpleName())
.build();
new Runner(opt).run();
}
@Benchmark
public static void standard() throws Exception{
File f = new File("src\\disa");
FileInputStream fis = new FileInputStream(f);
FileOutputStream fos = new FileOutputStream(new File("src\\bia"));
byte[] b = new byte[(int)length];
int s;
while((s = fis.read(b))!=-1){
fos.write(b,0,s);
}
fis.close();
fos.close();
}
@Benchmark
public static void mmap() throws Exception{
FileChannel readChannel = FileChannel.open(Paths.get("src","disa"), StandardOpenOption.READ);
long start = 0;
long total = readChannel.size();
FileChannel writeChannel = FileChannel.open(Paths.get("src","bia"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//数据传输
while(start < total){
long l = Math.min(total - start,length);
MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY,start,l);
start += l;
writeChannel.write(data);
}
readChannel.close();
writeChannel.close();
}
@Benchmark
public static void sendFile() throws Exception{
FileChannel readChannel = FileChannel.open(Paths.get("src","disa"), StandardOpenOption.READ);
long len = readChannel.size();
long position = readChannel.position();
FileChannel writeChannel = FileChannel.open(Paths.get("src","bia"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//数据传输
readChannel.transferTo(position, len, writeChannel);
readChannel.close();
writeChannel.close();
}
score:
| Benchmark | Mode | Cnt | Score | Error | Units |
|---|---|---|---|---|---|
| zeroCopy.Mmap.mmap | avgt | 5 | 221.854 | ± 333.769 | ms/op |
| zeroCopy.Mmap.sendFile | avgt | 5 | 72.284 | ± 40.739 | ms/op |
| zeroCopy.Mmap.standard | avgt | 5 | 171.231 | ± 126.379 | ms/op |
这里有个小插曲:
基于上面的实现,我们知道mmap实际上需要向操作系统申请一块空间来做转存,而这块空间是在堆外分配的,这里可以看JDK中的注释:
Maps a region of this channel's file directly into memory.因此,如果在循环体中来分配mapBB,实际上是每次都要去新申请一块内存空间,这样子反而更慢。
这里更换一下mmap的分配模式,并在每个方法退出时,都把文件删除:
public static void mmap() throws Exception{
FileChannel readChannel = FileChannel.open(Paths.get("src","disa"), StandardOpenOption.READ);
long total = readChannel.size();
FileChannel writeChannel = FileChannel.open(Paths.get("src","bia"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//数据传输
MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY,0,total);
writeChannel.write(data);
readChannel.close();
writeChannel.close();
new File("src\\bia").delete();
}
并把整体测试拉到10次,结果:
| Benchmark | Mode | Cnt | Score | Error | Units |
|---|---|---|---|---|---|
| zeroCopy.Mmap.mmap | avgt | 10 | 334.484 | ± 179.737 | ms/op |
| zeroCopy.Mmap.sendFile | avgt | 10 | 254.879 | ± 250.466 | ms/op |
| zeroCopy.Mmap.standard | avgt | 10 | 428.532 | ± 149.874 | ms/op |
这个结果就比较符合我们的认知了:在效率上,sendFile>mmap>standard。
不过有个地方仍需要注意:
- 这个测试的结果并不是完全准确的,和机器实时运行状态也有关系。
因此,这里能看到sendFile的误差非常大(和上面的结果相比),几乎达到了100%。
适用场景
从上面的步骤来看,mmap如果要使用快,就得一次性直接写到内存空间中,因此其实并不适合大文件读写的场景,参见:bbs.csdn.net/topics/3301…