文件 IO
应用读写文件基本流程
- 用户缓冲区:在这里可以理解为
byte b[]
在 JVM 堆内存占用的空间
1. 读文件
用户程序通过内核程序提供的读取文件 api 发起对某个文件读取。此时程序从用户态切换到内核态,用户程序处于阻塞状态,由于读取的内容还不在内核缓冲区中,导致触发 OS 缺页中断异常。然后由 OS 负责发起对磁盘文件的数据读取,这个读取动作交由 DMA 去完成;读取到数据后,先存放在 OS 内核的主存空间(PageCache);然后 OS 再将数据拷贝一份至用户进程空间的主存 ByteBuffer 中。此时程序由内核态切换至用户态继续运行程序。程序将 ByteBuffer 中的内容读取到本地变量中,即完成文件数据读取工作。
DMA 技术?
简单理解就是,在进行 I/O 设备和内核内存的数据传输时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
时序图如下:
2. 写文件
用户程序通过内核程序提供的读取文件 api 发起对某个文件写入磁盘。此时程序从用户态切换到内核态,用户程序处于阻塞状态,由 OS 负责发起对磁盘文件的数据写入。用户写入数据后,并不是直接写到磁盘的,而是先写到 ByteBuffer 中,然后再提交到 PageCache 中,最后由操作系统决定何时写入磁盘。数据写入 PageCache 中后,此时程序由内核态切换至用户态继续运行。
PageCache 中的数据最终写入磁盘是由操作系统异步提交至磁盘的。一般是定时或 PageCache 满了的时候写入。如果用户程序通过调用 flush() 方法强制写入,则操作系统也会服从这个命令。立即将数据写入磁盘然后由内核态切换回用户态继续运行程序。但是这样做会损失性能,但可以确切的知道数据是否已经写入磁盘了。
3.文件在磁盘上的存放
文件内容在磁盘上怎么存放的呢?
// 还没想好怎么写,TODO
4. 顺序 IO 和 随机 IO
其实所有的写操作在磁盘上都是随机写,这是由现代操作系统的文件系统设计方案决定的。当用户程序写入数据到page cache
后,操作系统决定何时写入磁盘中的某个空闲块,所有的文件都不是连续分配的,都是以块为单位分散存储在磁盘上。
但有一种情况例外,mmap()
函数,它将磁盘物理空间和page cache
内存空间进行映射,如果磁盘有连续的物理空间,它将预占用大小的磁盘连续物理空间,这个被映射的磁盘物理空间不会被其他文件写入数据;当我们把数据写入page cache
,操作系统将会把数据连续写入这个映射好的磁盘连续物理空间。
mmap()
函数,我们后面再讲
5. 缓存 IO 和 直接 IO
使用了page cache
技术的 IO,叫缓存 IO;与之相对应不使用page cache
技术的 IO,叫直接 IO。
Page Cache 有什么作用?
缓存最近被访问的数据:读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内核page cache
,这样就可以用读内存替换读磁盘。
当然,这个数据被多次读加入
page cache
才有效果,不然只读一次,那还不如从磁盘上拷贝到用户缓冲区,少了一次 CPU 拷贝过程。
预读功能:page cache
有「预读功能」。假设read()
方法每次只会读 32 KB 的字节,虽然read()
刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 page cache
,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出page cache
前,进程读取到它了,收益就非常大。
Page Cache 一定好?
通过上面分析,使用 page cache
优点这么多,那么缓存 IO 一定比 直接 IO 要好。那当然不是,在数据不需要被访问,只需要一次写或者一次读,那么可以不用 page cache
减少 CPU 拷贝消耗;传输大文件的时候,由于大文件难以命中 page cache
缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
暂未发现 Java 使用直接 IO 的 API
6. 零拷贝(Zero-copy)和 mmap
零拷贝( zero-copy )技术可以有效地改善数据传输的性能,在内核驱动程序(比如网络堆栈或者磁盘存储驱动程序)处理 I/O 数据的时候,零拷贝技术可以在某种程度上减少甚至完全避免不必要 CPU 数据拷贝操作。
实现零拷贝读写方式:
- mmap()
- sendfile()
6.1 mmap()
我们知道,read()
系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap()
替换 read()
系统调用函数。
mmap()
系统调用函数会把文件磁盘地址「映射」到内核缓存区(page cache),而内核缓存区会 「映射」到用户空间(虚拟地址)。这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝。
注意,这里用户空间(虚拟地址)不是直接映射到文件磁盘地址,而是文件对应的
page cache
,用户虚拟地址一般是和内存物理地址「映射」的(代表这块内存属于用户空间)。如果使用内存映射技术,则用户虚拟地址可以和内核内存地址「映射」,代表这块内核内存共享给用户空间。
根据维基百科给出的定义:在大多数操作系统中,映射的内存区域实际上是内核的page cache
,这意味着不需要在用户空间创建副本。多个进程之间也可以通过同时映射page cache
,来进行进程通信)
mmap() 函数简介:
void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset)
- start:要映射到的内存区域的起始地址,通常都是用NULL(NULL即为0)。NULL表示由内核来指定该内存地址
- offset:以文件开始处的偏移量, 必须是分页大小的整数倍, 通常为0, 表示从文件头开始映射
- length:映射多大空间
- prot: 映射区的保护方式(PROT_EXEC: 映射区可被执行、PROT_READ: 映射区可被读取、PROT_WRITE: 映射区可被写入、PROT_NONE: 映射区不能存取)
- flags: 映射区的特性
- fd:文件描述符(由open函数返回)
mmap() + write()
以读取磁盘数据,通过网卡发送给其他服务器为例,来看看 mmap()
是怎样的。
应用程序调用 mmap()
函数后,用户虚拟地址和 page cache
建立映射关系。之后应用程序发起读数据操作,DMA 会把磁盘数据拷贝到 page cache
,CPU 不需要把数据再拷贝到用户缓存区了,因为应用已经能直接访问 page cache
。
应用进程再调用 write()
,CPU 将数据拷贝至 Socket 发送缓冲区,最后,DMA 把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区,网卡发送数据。
从上面流程看,mmap()
比传统缓冲 IO,少了一次 CPU 数据拷贝的过程。但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换。
Java 简用 Mmap()
public static void fileWrite(String filePath) {
try {
RandomAccessFile accessFile = new RandomAccessFile(new File(filePath), "rw");
FileChannel fileChannel = accessFile.getChannel();
// 操作系统提供的一个内存映射的机制的类
MappedByteBuffer map = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, (long) 1024);
map.put(("hello").getBytes());
} catch (IOException e) {
} finally {
}
}
6.2 sendfile()
size_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
- out_fd:源文件描述符,也是被拷贝的文件
- in_fd:目标文件描述符,也就是 socket
- offset:偏移量
- count:内容大小
它可以替代前面的 read()
和 write()
这两个系统调用,这样就可以减少一次系统调用;其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里。
相比
mmap()
,它不需要将数据拷贝到线程栈,应用程序也就无法对数据进行加工
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:
$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on
- 通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
- 缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝
使用零拷贝技术的项目
事实上,Kafka 这个开源项目,就利用了「零拷贝」技术,从而大幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之一。
如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 transferTo方法:
@Overridepublic
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile()
系统调用函数。