共计 5227 个字符,预计需要花费 14 分钟才能阅读完成。
Linux IO 结构
在开始之前,先介绍一下 Linux 的 IO 结构。
VFS(Virtual FileSystem) 虚拟文件系统
文件系统是内核的功能,是一种工作在内核空间的软件,访问一个文件必须要需要文件系统的存在才可以。Linux 可以支持多达数十种不同的文件系统,它们的实现各不相同,因此 Linux 内核向用户空间提供了虚拟文件系统这个统一的接口用来对文件系统进行操作。
虚拟文件系统是位于用户空间进程和内核空间中多种不同的底层文件系统的实现之间的一个抽象的接口层,它提供了常见的文件系统对象模型(如 i-node, file object, page cache, directory entry, etc.)和访问这些对象的方法(如 open, close, delete, write, read, create, fstat, etc.),并将它们统一输出,类似于库的作用。从而向用户进程隐藏了各种不同的文件系统的具体实现,这样上层软件只需要和 VFS 进行交互而不必关系底层的文件系统,简化了软件的开发,也使得 linux 可以支持多种不同的文件系统。
I/ O 子系统架构
上图概括了一次磁盘 write 操作的过程,假设文件已经被从磁盘中读入了 page cache 中。
- 一个用户进程通过 write() 系统调用发起写请求
- 内核更新对应的 page cache
- pdflush 内核线程将 page cache 写入至磁盘中
- 文件系统层将每一个 block buffer 存放为一个 bio 结构体,并向块设备层提交一个写请求
- 块设备层(block device)从上层接受到请求,执行 IO 调度操作,并将请求放入 IO 请求队列中
- 设备驱动(如 SCSI 或其他设备驱动)完成写操作
- 磁盘设备固件执行对应的硬件操作,如磁盘的旋转,寻道等,数据被写入到磁盘扇区中
Block Layer
Block layer 处理所有和块设备相关的操作。block layer 最关键是数据结构是 bio 结构体。bio 结构体是 file system layer 到 block layer 的接口。当执行一个写操作时,文件系统层将数据写入 page cache(由 block buffer 组成),将连续的块放到一起,组成 bio 结构体,然后将 bio 送至 block layer。
block layer 处理 bio 请求,并将这些请求链接成一个队列,称作 IO 请求队列,这个连接的操作就称作 IO 调度(也叫 IO elevator 即电梯算法).
Buffer IO
Buffer I/O 又被称作 Standard I/O,大多数文件系统的默认 I/O 操作都是 Buffer I/O。在 Linux 的 Buffer I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存(page cache)中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。Buffer I/O 有以下这些优点:
- 缓存 I/O 使用了操作系统内核缓冲区,在一定程度上分离了应用程序空间和实际的物理设备。
- 缓存 I/O 可以减少读盘的次数,从而提高性能。
Java 中的 IO 也是 Buffer IO。
常见的FileInputStream/FileOutPutStream/RandomAccessFile/FileChannel
,都是 Buffer IO。
Page Cache
在 Linux 的实现中,文件 Cache 分为两个层面,一是 Page Cache,另一个 Buffer Cache,每一个 Page Cache 包含若干 Buffer Cache。内存管理系统和 VFS 只与 Page Cache 交互,内存管理系统负责维护每项 Page Cache 的分配和回收,同时在使用 memory map 方式访问时负责建立映射;VFS 负责 Page Cache 与用户空间的数据交换。而具体文件系统则一般只与 Buffer Cache 交互,它们负责在外围存储设备和 Buffer Cache 之间交换数据。Page Cache、Buffer Cache、文件以及磁盘之间的关系如下图所示
MMAP
mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如下图所示:
MMAP 的读写实际是上也是会经过 Cache 层的,那么 MMAP 方式与普通方式(Buffer IO)操作文件的区别是什么呢?
上面已经介绍了 Buffer IO 的操作方式,MMAP 和 Buffer IO 的区别在于 Buffer IO 需要现在用户空间下维护一个 Buffer 区(例如 FileChannel 读写时使用的 Buffer);以写入文件为例,先向用户空间下的 Buffer 写入数据,然后再拷贝到内核缓冲(page cache),而 MMAP 直接将文件 (确切的说应该是文件对应的 Page Cache) 映射到进程的地址空间,进程就可以直接以内存的操作方式来操作文件了,不需要用户缓冲到内核缓冲的拷贝。进程对 mmap 的操作相当于直接操作了 cache,读取 mmap 时等于直接读取 cache,写入 mmap 时等于直接写 cache,然后操作系统异步刷盘,当然也可以手动调用 sync 强制刷盘。少了一次拷贝,速度上自然有提升,所以 MMAP 又成为零拷贝(ZERO COPY)。
MMAP 的优缺点
优点
- 小数据量的读写性能极高
缺点
- 映射的大小最好 4k 对齐
- 释放麻烦
- 只能定长
- 随机写频繁的场景下,性能不一定比 Buffer IO 快
虽然缺点很多,但是如果需要超高性能时还是需要考虑使用 mmap 的。
JAVA 中的 MMAP 使用
通过 FileChannel 创建 mmap
FileChannel channel = FileChannel.open(new File("your file path").toPath(), | |
StandardOpenOption.CREATE, | |
StandardOpenOption.READ, | |
StandardOpenOption.WRITE); | |
//FileChannel.MapMode.READ_WRITE 为映射的模式,READ_WRITE 代表可读写;0,10 为映射的文件偏移,单位字节 | |
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 10); | |
//MappedByteBuffer 继承于 NIO 的 ByteBuffer,读写数据的接口和 ByteBuffer 一致,注意:MappedByteBuffer 实际上属于堆外内存(Direct Buffer) | |
mappedByteBuffer.putInt(1); | |
mappedByteBuffer.put((byte) 0x01); | |
mappedByteBuffer.putLong(1l); | |
// 对于 mmap 的写入,都是写入在 cache 中的,操作系统会异步刷盘,当然如果对数据一致性有严格要求,可以手动调用 force 强制刷盘,但是这样性能就非常差了。mappedByteBuffer.force(); |
Java 中对于 MMAP 的释放没有一个优雅的方式,释放起来比较麻烦,下面贴一个释放的工具类:
import java.lang.invoke.MethodHandle; | |
import java.lang.invoke.MethodHandles; | |
import java.lang.invoke.MethodType; | |
import java.lang.reflect.Field; | |
import java.lang.reflect.Method; | |
import java.nio.ByteBuffer; | |
import java.nio.MappedByteBuffer; | |
public final class ByteBufferSupport | |
{ | |
private static final MethodHandle INVOKE_CLEANER; | |
static { | |
MethodHandle invoker; | |
try { | |
// Java 9 added an invokeCleaner method to Unsafe to work around | |
// module visibility issues for code that used to rely on DirectByteBuffer's cleaner() | |
Class<?> unsafeClass = Class.forName("sun.misc.Unsafe"); | |
Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe"); | |
theUnsafe.setAccessible(true); | |
invoker = MethodHandles.lookup() | |
.findVirtual(unsafeClass, "invokeCleaner", MethodType.methodType(void.class, ByteBuffer.class)) | |
.bindTo(theUnsafe.get(null)); | |
} | |
catch (Exception e) { | |
// fall back to pre-java 9 compatible behavior | |
try {Class<?> directByteBufferClass = Class.forName("java.nio.DirectByteBuffer"); | |
Class<?> cleanerClass = Class.forName("sun.misc.Cleaner"); | |
Method cleanerMethod = directByteBufferClass.getDeclaredMethod("cleaner"); | |
cleanerMethod.setAccessible(true); | |
MethodHandle getCleaner = MethodHandles.lookup().unreflect(cleanerMethod); | |
Method cleanMethod = cleanerClass.getDeclaredMethod("clean"); | |
cleanerMethod.setAccessible(true); | |
MethodHandle clean = MethodHandles.lookup().unreflect(cleanMethod); | |
clean = MethodHandles.dropArguments(clean, 1, directByteBufferClass); | |
invoker = MethodHandles.foldArguments(clean, getCleaner); | |
} | |
catch (Exception e1) {throw new AssertionError(e1); | |
} | |
} | |
INVOKE_CLEANER = invoker; | |
} | |
private ByteBufferSupport() | |
{ } | |
public static void unmap(MappedByteBuffer buffer) | |
{ | |
try {INVOKE_CLEANER.invoke(buffer); | |
} | |
catch (Throwable ignored) {throw Throwables.propagate(ignored); | |
} | |
} | |
} |
Direct IO
通过 Direct I/O 方式进行数据传输,数据均直接在用户地址空间的缓冲区和磁盘之间直接进行传输,完全不需要页缓存的支持。操作系统层提供的缓存往往会使应用程序在读写数据的时候获得更好的性能,但是对于某些特殊的应用程序,比如说数据库管理系统这类应用,他们更倾向于选择他们自己的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。下图是 Direct IO 的路径:
Java 中的 Direct IO
JDK 并没有提供对 Direct IO 的支持(但 C ++ 使用很简单),需要通过 JNA 的方式来调用,这里推荐两个 DIO 库
- https://github.com/smacke/jaydio
- https://github.com/lexburner/…
IO 方式的选择
Buffer IO
适用于普通类型的文件读写,性能尚可,操作简单,无注意事项。
MMAP
小数据量读写性能高,但不灵活。
Direct IO
需要自己控制 Cache 时,可以适用 Direct IO,例如数据库 / 中间件应用,可以避免文件的读写还经过一层 Page Cache,造成额外开销。
参考
- 计算机 IO
- 认真分析 mmap:是什么 为什么 怎么用
- Linux Performance andTuning Guidelines
- Linux 中直接 I/O 机制的介绍